Compare commits

..

112 Commits

Author SHA1 Message Date
Zack
026d027386 Update Struct for Buttons Footer 2022-05-25 23:13:27 -05:00
J. Nick Koston
d0ead1fdb8 Fix history cache when there is cacheConfig (#12788) 2022-05-25 20:39:55 -05:00
J. Nick Koston
b0e6c41238 Handle history api being passed entity ids as CSV (#12787) 2022-05-25 23:05:56 +00:00
Philip Allgaier
2c1550b10f Fix typo in credentials removal dialog (#12784) 2022-05-25 18:09:15 +00:00
Philip Allgaier
ffc4ca5b56 Use dynamic weather domain icon + icon alignment fix weather more-info (#12781) 2022-05-25 19:39:34 +02:00
Zack Barett
85ad6619b7 Bumped version to 20220525.0 (#12779) 2022-05-25 11:49:40 -05:00
Raman Gupta
7358faf88e Update zwave_js/network_status WS API (#12735) 2022-05-25 11:49:25 -05:00
Philip Allgaier
19d014307a Ensure state is vertically centered in more-info (#12780) 2022-05-25 11:40:04 -05:00
Philip Allgaier
5217f5c50c Fix "unavailable" handling for climate state rendering (#12778) 2022-05-25 15:47:11 +00:00
Zack Barett
c4624faa71 Hardware MVP (#12773) 2022-05-25 17:11:15 +02:00
Steve Repsher
b35ba4d673 Add aria-haspopup to button menus (#12758)
Co-authored-by: Zack Barett <zackbarett@hey.com>
2022-05-25 16:05:43 +02:00
Marc Mueller
f8303bff76 Move metadata to pyproject.toml (#12770) 2022-05-25 08:16:09 -05:00
Zack Barett
e61aa266a6 Move Logbook and make device page better (#12763)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2022-05-25 12:55:08 +00:00
Raman Gupta
d7971c69ad Add controller statistics to zwave_js config dashboard (#12668) 2022-05-25 11:36:27 +02:00
Raman Gupta
966a624ef6 Move zwave_js node comments from device config to info page (#12625)
Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2022-05-24 21:16:07 -05:00
Zack Barett
7cc576a616 Bump Version to 20220524.0 (#12769) 2022-05-24 19:08:56 -05:00
Zack Barett
2dec8e70ec Integration filter for Area Selector (#12682)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-05-24 23:26:14 +00:00
Zack Barett
97663aef42 Add integration filter to Device Selector (#12680)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-05-24 23:25:09 +00:00
Paulus Schoutsen
3f1a2526b3 Hide hidden media player entities in media panel (#12766) 2022-05-24 21:07:22 +00:00
Franck Nijhof
e7517a8b61 Simplify OAuth2 authorize callback URL (#12765) 2022-05-24 13:32:33 -07:00
Thomas Lovén
e3d394eb32 Change service_data to just data (#12628) 2022-05-24 10:49:07 -05:00
Yosi Levy
536ea822b3 Fix (#12764) 2022-05-24 15:25:50 +00:00
Bram Kragten
8e4e22b6f8 Add compare to energy sources table (#12762) 2022-05-24 17:20:16 +02:00
Yosi Levy
2eaa246a03 RTL updates (#12745) 2022-05-24 15:14:11 +00:00
Philip Allgaier
e841bf89be Add My HA link to about page to Github issue template (#12761) 2022-05-24 08:42:34 -05:00
Philip Allgaier
36e1203fb1 Adjust path to version info in issue template (#12760) 2022-05-24 13:10:26 +00:00
J. Nick Koston
3acab5a39c Adjust logbook stream consumer to handle new metadata (#12755) 2022-05-23 22:37:45 -07:00
Zack Barett
49cfde1fe7 Bumped version to 20220523.0 (#12756) 2022-05-23 16:26:00 -07:00
Philip Allgaier
49c018c000 Allow setting device_class "outlet" again through entity settings (#12669)
* Allow setting `device_class` "outlet" again through UI

* Fixes

* Null check deviceClass and adjust used translation
2022-05-23 18:18:08 -05:00
David F. Mulcahey
b71b230bfd Make entities and devices independent in the scene editor (#11046)
Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Erik <erik@montnemery.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-05-23 23:08:44 +00:00
Allen Porter
e1fd7244a5 Open Application Credentials from integration configuration flow (#12708) 2022-05-23 15:46:34 -07:00
J. Nick Koston
067c2fdfa8 Use new logbook streaming websocket api for cases where we need end_time (#12753) 2022-05-23 15:40:05 -07:00
J. Nick Koston
a02b817d7f Use new localized context state and source in logbook (#12742) 2022-05-23 14:32:11 -05:00
Bram Kragten
7db6e0b779 Move preload_stream setting to entity settings (#12730)
Co-authored-by: Zack <zackbarett@hey.com>
2022-05-23 18:30:57 +00:00
Joakim Sørensen
1d5cc91a2d Remove kernel and agent versions from about page (#12750) 2022-05-23 13:01:59 -05:00
Joakim Sørensen
0623e7dce4 Fetch supervisor info directly (#12751) 2022-05-23 13:00:16 -05:00
J. Nick Koston
da106d278c Use logbook livestream when requesting a time window that includes the future (#12744) 2022-05-23 12:58:50 -05:00
Joakim Sørensen
51c5ab33f0 Stop closed event when selecting datadisk (#12749) 2022-05-23 12:58:36 +02:00
Paulus Schoutsen
8ac4a6d900 Bumped version to 20220521.0 2022-05-20 17:28:06 -07:00
Paulus Schoutsen
fae1bcf0e0 Fixes logbook (#12740) 2022-05-20 11:25:19 -07:00
Allen Porter
9a9eec40b2 Add an application credentials display name (#12720)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2022-05-19 21:27:43 -07:00
Bram Kragten
6ab19d66d5 Add option to compare energy graphs with previous period (#12723)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-05-20 04:20:18 +00:00
J. Nick Koston
a0a7ce014f Compute the icon based on the logbook state and not the current state (#12725)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2022-05-19 21:12:17 -07:00
Paulus Schoutsen
bfeb90780f Pass device ID to logbook if available (#12728) 2022-05-20 04:09:33 +00:00
J. Nick Koston
1f105b6c15 Get attributes from first state when using minimal responses (#12732) 2022-05-19 20:56:11 -07:00
Raman Gupta
5b7b0ea326 Use device_id instead of config entry id and node id for zwave_js (#12658)
* Use device_id instead of config entry id and node id for zwave_js

* Add additional cleanup from #12642

* Revert removal of multiple config entries check

* Update src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* Update src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2022-05-19 10:23:16 -07:00
Raman Gupta
32a991989f Update zwave_js data collection URL (#12666) 2022-05-19 17:05:31 +02:00
Allen Porter
788f76ab9c Add error handling for application credentials removal (#12686) 2022-05-19 16:51:33 +02:00
Yosi Levy
f6411dce66 Select + target picker Rtl fixes (#12711) 2022-05-19 16:28:56 +02:00
Yosi Levy
6f19ea1d84 Various RTL fixes (#12721) 2022-05-19 16:25:30 +02:00
Michael Irigoyen
448609533f Update Material Design Icons to v6.7.96 (#12111) 2022-05-19 16:21:00 +02:00
J. Nick Koston
6c48ace41e Fix python to js timestamp conversions in logbook traces (#12677)
- The websocket version needs the time converted from
  where python stores the decimal
2022-05-18 12:36:08 -07:00
Paulus Schoutsen
c41e100c1c Bumped version to 20220518.0 2022-05-18 12:10:42 -07:00
RoboMagus
8216b522c2 Fix 'loading_log' string (#12712) 2022-05-18 12:09:31 -07:00
Paulus Schoutsen
82035d587a Import all date-fns from modules (#12717) 2022-05-18 12:09:25 -07:00
J. Nick Koston
2796c3570a Support requesting multiple integration manifests in a single request (#12706)
* Support requesting multiple integration manifests in a single request

* only fetch if there are some to actually fetch

* handle empty

* not truthy, wrong language

* Do not copy params

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-05-18 12:09:09 -07:00
J. Nick Koston
f4f51e1de5 Show the integration brand icon when there is no entity in logbook (#12713) 2022-05-18 12:01:09 -07:00
J. Nick Koston
af6b0d3266 Support requesting translations for multiple integrations in one request (#12704)
* Support requesting translations for multiple integrations in one request

- Requires https://github.com/home-assistant/core/pull/71979

* onboarding as well

* integrations -> integration

* fix cache

* short return if they are all loaded

* reduce

* reduce

* reduce
2022-05-18 11:37:47 -07:00
Paulus Schoutsen
7d1c77a38f Add support for OAuth2 callback via My (#12718) 2022-05-18 11:18:43 -07:00
J. Nick Koston
f807618f75 Convert history calls to use new websocket endpoint (#12662) 2022-05-18 10:20:38 -07:00
Steve Repsher
4cfb6713cb Delete focus targets for replaced dialogs (#12724) 2022-05-18 16:18:22 +00:00
Patrick ZAJDA
d32f84f28d Add missing labels in energy dashboard settings (#12722)
Signed-off-by: Patrick ZAJDA <patrick@zajda.fr>
2022-05-18 18:17:31 +02:00
Paulus Schoutsen
5fb1504211 Add logbook to area info page (#12715) 2022-05-17 12:20:49 -07:00
Paulus Schoutsen
c37e1f0c9d Add logbook to device info page (#12714) 2022-05-17 11:02:23 -07:00
Paulus Schoutsen
90c234ffad Refactor logbook data fetch logic into reusable class (#12701) 2022-05-17 08:53:22 -07:00
breakthestatic
dd3a3ec586 Add guard logic from PR home-assistant#12181 to input select row (#12703) 2022-05-17 10:25:32 +00:00
Zack Barett
6f67da09c0 Show manage cloud link to config (#12673) 2022-05-17 12:14:43 +02:00
Franck Nijhof
ba27c184f6 Add my support for Application Credentials (#12709) 2022-05-17 12:13:46 +02:00
Paulus Schoutsen
b37f97128a Fix float-end for LTR (#12707) 2022-05-17 08:20:19 +02:00
Bram Kragten
ee0de942f7 Bumped version to 20220516.0 2022-05-16 20:37:50 +02:00
Steve Repsher
ae2d48f2f4 Return focus after dialogs close (#11999) 2022-05-16 17:10:41 +02:00
Yosi Levy
1bd760b455 Rtl changes (#12693) 2022-05-16 15:57:14 +02:00
Joakim Sørensen
3d66a68791 Guard for missing backup integration (#12696) 2022-05-16 13:39:41 +02:00
J. Nick Koston
01a53439c4 Teach logbook about additional context data (#12667)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2022-05-15 21:25:55 -07:00
Sven
09ee8dbeb6 Update Lokalise URL (#12684) 2022-05-15 17:59:31 +02:00
Philip Allgaier
f36c91550d Add missing label to search icon (#12671) 2022-05-13 18:58:01 -04:00
Franck Nijhof
6be6c711d0 Fix strict error handling in Markdown card templates (#12661) 2022-05-13 13:17:56 +02:00
Allen Porter
72a36fb1cd Add calendar trigger offsets in automation editor (#12486)
* Add calendar trigger offsets in automation editor

* Use duration selector for offset

* Fix typing for offsets/duratons
2022-05-12 07:42:15 -05:00
J. Nick Koston
4c982b3323 Switch logbook calls to use the new websocket (#12665) 2022-05-11 22:28:18 -05:00
Yosi Levy
c9c3be71cc Merge pull request #12620 from yosilevy/RTL-no-host-context
Replace host-context with css properties - after session with Bram
2022-05-11 16:30:21 +03:00
Bram Kragten
f1b965dcc5 Update ha-fab.ts 2022-05-11 15:19:03 +02:00
Bram Kragten
a08a23a93d Use FabBase 2022-05-11 14:25:43 +02:00
Bram Kragten
2040a49458 Update var name 2022-05-11 14:21:02 +02:00
Bram Kragten
df94f4f907 Merge branch 'dev' into RTL-no-host-context 2022-05-11 14:18:26 +02:00
Bram Kragten
96d375cb84 Use / 2022-05-11 14:16:44 +02:00
Yosi Levy
7a9c2f56c5 Rtl menu fix (#12561)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2022-05-11 11:01:45 +02:00
J. Nick Koston
5ec7193e5c Show script traces in logbook (#12643) 2022-05-10 23:32:09 +02:00
Zack Barett
d89e4337f2 Hide Cloud URL - Add Copy Icon (#12655)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2022-05-10 19:37:31 +00:00
Zack Barett
2e192d5021 Update Translations to create helper (#12656) 2022-05-10 21:25:03 +02:00
Yosi Levy
7db28c0156 Update following review 2022-05-10 19:31:23 +03:00
Yosi Levy
f09c842981 Update src/state/translations-mixin.ts
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2022-05-10 18:25:28 +03:00
Yosi Levy
b295bbd706 RTL settings clickable list item fix (#12595) 2022-05-10 16:57:18 +02:00
Patrick ZAJDA
8d3132fefc Add label for Fix issue column header in statistics developer tools (#12597)
Signed-off-by: Patrick ZAJDA <patrick@zajda.fr>
2022-05-09 17:14:59 +02:00
Allen Porter
00c5d3dbbb Add configuration panel for Application Credentials (#12344)
Co-authored-by: Zack Barett <zackbarett@hey.com>
Co-authored-by: Zack <zackbarett@hey.com>
2022-05-09 17:03:59 +02:00
Zack Barett
ca37aff47d Move YAML to first tab of Developer Tools (#12589) 2022-05-09 08:07:17 -05:00
Joakim Sørensen
9ed069ef6a Get full core logs from core (#12639) 2022-05-09 08:07:01 -05:00
Philip Allgaier
6faa3eb848 Remove "Lovelace" from Github issue templates (#12614)
* Remove "Lovelace" from Github issue templates

* Changes from review
2022-05-09 09:47:39 +02:00
Yosi Levy
6c73ae5bf7 Replace host-context with css properties 2022-05-07 06:39:39 +03:00
Zack Barett
ce77ddf365 Revert #10991 (#12618) 2022-05-07 02:48:57 +00:00
Steve Repsher
cf05fbaa9d Fix enter key support for generic dialog box (#12600) 2022-05-06 13:32:44 +02:00
Joakim Sørensen
552c474feb Fix setting _externalAccess (#12584) 2022-05-04 08:17:09 -05:00
Bram Kragten
a4f8e886bc Bumped version to 20220504.0 2022-05-04 13:14:25 +02:00
Bram Kragten
cc0c96b8b4 Make update notification better, add progress (#12581) 2022-05-04 11:09:54 +00:00
Philip Allgaier
445f0e23fe System menu description tweaks (#12578) 2022-05-04 11:07:41 +00:00
Bram Kragten
6f240297d1 Remove hassio config panel (#12580) 2022-05-04 10:20:41 +00:00
Paulus Schoutsen
6da4981b70 Clone on duplicate (#12574) 2022-05-04 12:04:59 +02:00
Zack Barett
cfadf4d700 Fixes issue where the grid cards wouldnt be sized correctly (#12571) 2022-05-04 12:04:24 +02:00
Zack Barett
7e60de0531 Add padding when no updates (#12575) 2022-05-04 11:59:24 +02:00
Zack Barett
aaef6d7b91 Fix Overlapping Media List items (#12569) 2022-05-03 23:10:40 +02:00
Zack Barett
58c5ce2638 Bumped version to 20220503.0 (#12566) 2022-05-03 11:14:12 -07:00
Joakim Sørensen
a9d01c7b55 Add missing outlined to supervisor panel (#12565) 2022-05-03 17:06:21 +00:00
Zack Barett
c5de8a4361 Add new system menu descriptions (#12564) 2022-05-03 16:44:43 +00:00
204 changed files with 7000 additions and 3332 deletions

View File

@@ -51,7 +51,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
<!--
Provide details about the versions you are using, which helps us reproducing
and finding the issue quicker. Version information is found in the
Home Assistant frontend: Configuration -> Info.
Home Assistant frontend: Settings -> About.
Browser version and operating system is important! Please try to replicate
your issue in a different browser and be sure to include your findings.

View File

@@ -1,4 +1,4 @@
name: Report a bug with the UI, Frontend or Lovelace
name: Report a bug with the UI / Dashboards
description: Report an issue related to the Home Assistant frontend.
labels: bug
body:
@@ -9,7 +9,7 @@ body:
If you have a feature or enhancement request for the frontend, please [start an discussion][fr] instead of creating an issue.
**Please not not report issues for custom Lovelace cards.**
**Please not not report issues for custom cards.**
[fr]: https://github.com/home-assistant/frontend/discussions
[releases]: https://github.com/home-assistant/home-assistant/releases
@@ -64,7 +64,7 @@ body:
label: What version of Home Assistant Core has the issue?
placeholder: core-
description: >
Can be found in the Configuration panel -> Info.
Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/).
- type: input
attributes:
label: What was the last working version of Home Assistant Core?

View File

@@ -1,17 +1,17 @@
blank_issues_enabled: false
contact_links:
- name: Request a feature for the UI, Frontend or Lovelace
- name: Request a feature for the UI / Dashboards
url: https://github.com/home-assistant/frontend/discussions/category_choices
about: Request an new feature for the Home Assistant frontend.
- name: Report a bug that is NOT related to the UI, Frontend or Lovelace
- name: Report a bug that is NOT related to the UI / Dashboards
url: https://github.com/home-assistant/core/issues
about: This is the issue tracker for our frontend. Please report other issues with the backend repository.
about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository.
- name: Report incorrect or missing information on our website
url: https://github.com/home-assistant/home-assistant.io/issues
about: Our documentation has its own issue tracker. Please report issues with the website there.
- name: I have a question or need support
url: https://www.home-assistant.io/help
about: We use GitHub for tracking bugs, check our website for resources on getting help.
about: We use GitHub for tracking bugs. Check our website for resources on getting help.
- name: I'm unsure where to go
url: https://www.home-assistant.io/join-chat
about: If you are unsure where to go, then joining our chat is recommended; Just ask!

View File

@@ -26,8 +26,8 @@ module.exports = {
},
version() {
const version = fs
.readFileSync(path.resolve(paths.polymer_dir, "setup.cfg"), "utf8")
.match(/version\W+=\W(\d{8}\.\d)/);
.readFileSync(path.resolve(paths.polymer_dir, "pyproject.toml"), "utf8")
.match(/version\W+=\W"(\d{8}\.\d)"/);
if (!version) {
throw Error("Version not found");
}

View File

@@ -194,7 +194,7 @@ export const demoLovelaceJimpower: DemoConfig["lovelace"] = () => ({
type: "state-icon",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "group.downstairs_lights",
},
service: "homeassistant.toggle",

View File

@@ -377,7 +377,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC bed",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "script.air_cleaner_quiet",
},
service: "script.turn_on",
@@ -390,7 +390,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC bed",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "script.air_cleaner_auto",
},
service: "script.turn_on",
@@ -403,7 +403,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC bed",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "script.air_cleaner_turbo",
},
service: "script.turn_on",
@@ -416,7 +416,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "script.ac_off",
},
service: "script.turn_on",
@@ -429,7 +429,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "script.ac_on",
},
service: "script.turn_on",
@@ -629,7 +629,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "scene.morning_lights",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "scene.morning_lights",
},
service: "scene.turn_on",
@@ -641,7 +641,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "scene.movie_time",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "scene.movie_time",
},
service: "scene.turn_on",
@@ -702,7 +702,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "light.downstairs_lights",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "light.downstairs_lights",
},
service: "light.toggle",
@@ -714,7 +714,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "light.upstairs_lights",
tap_action: {
action: "call-service",
service_data: {
data: {
entity_id: "light.upstairs_lights",
},
service: "light.toggle",

View File

@@ -1,4 +1,4 @@
import { format, startOfToday, startOfTomorrow } from "date-fns";
import { format, startOfToday, startOfTomorrow } from "date-fns/esm";
import { EnergySolarForecasts } from "../../../src/data/energy";
import { MockHomeAssistant } from "../../../src/fake_data/provide_hass";

View File

@@ -4,7 +4,7 @@ import {
addMonths,
differenceInHours,
endOfDay,
} from "date-fns";
} from "date-fns/esm";
import { HassEntity } from "home-assistant-js-websocket";
import { StatisticValue } from "../../../src/data/history";
import { MockHomeAssistant } from "../../../src/fake_data/provide_hass";

View File

@@ -119,7 +119,7 @@ export const basicTrace: DemoTrace = {
params: {
domain: "input_boolean",
service: "toggle",
service_data: {},
data: {},
target: {
entity_id: ["input_boolean.toggle_4"],
},
@@ -164,7 +164,7 @@ export const basicTrace: DemoTrace = {
params: {
domain: "input_boolean",
service: "toggle",
service_data: {},
data: {},
target: {
entity_id: ["input_boolean.toggle_2"],
},
@@ -182,7 +182,7 @@ export const basicTrace: DemoTrace = {
params: {
domain: "input_boolean",
service: "toggle",
service_data: {},
data: {},
target: {
entity_id: ["input_boolean.toggle_3"],
},
@@ -200,7 +200,7 @@ export const basicTrace: DemoTrace = {
params: {
domain: "input_boolean",
service: "toggle",
service_data: {},
data: {},
target: {
entity_id: ["input_boolean.toggle_4"],
},
@@ -298,11 +298,11 @@ export const basicTrace: DemoTrace = {
source: "state of input_boolean.toggle_1",
entity_id: "automation.toggle_toggles",
context_id: "6cfcae368e7b3686fad6c59e83ae76c9",
when: "2021-03-25T04:36:51.240832+00:00",
when: 1616647011.240832,
domain: "automation",
},
{
when: "2021-03-25T04:36:51.249828+00:00",
when: 1616647011.249828,
name: "Toggle 4",
state: "on",
entity_id: "input_boolean.toggle_4",
@@ -313,7 +313,7 @@ export const basicTrace: DemoTrace = {
context_name: "Ensure Party mode",
},
{
when: "2021-03-25T04:36:51.258947+00:00",
when: 1616647011.258947,
name: "Toggle 2",
state: "on",
entity_id: "input_boolean.toggle_2",
@@ -324,7 +324,7 @@ export const basicTrace: DemoTrace = {
context_name: "Ensure Party mode",
},
{
when: "2021-03-25T04:36:51.261806+00:00",
when: 1616647011.261806,
name: "Toggle 3",
state: "off",
entity_id: "input_boolean.toggle_3",
@@ -335,7 +335,7 @@ export const basicTrace: DemoTrace = {
context_name: "Ensure Party mode",
},
{
when: "2021-03-25T04:36:51.265246+00:00",
when: 1616647011.265246,
name: "Toggle 4",
state: "off",
entity_id: "input_boolean.toggle_4",

View File

@@ -185,11 +185,11 @@ export const motionLightTrace: DemoTrace = {
"has been triggered by state of binary_sensor.pauluss_macbook_pro_camera_in_use",
source: "state of binary_sensor.pauluss_macbook_pro_camera_in_use",
entity_id: "automation.auto_elgato",
when: "2021-03-14T06:07:01.768492+00:00",
when: 1615702021.768492,
domain: "automation",
},
{
when: "2021-03-14T06:07:01.872187+00:00",
when: 1615702021.872187,
name: "Elgato Key Light Air",
state: "on",
entity_id: "light.elgato_key_light_air",
@@ -200,7 +200,7 @@ export const motionLightTrace: DemoTrace = {
context_name: "Auto Elgato",
},
{
when: "2021-03-14T06:07:53.284505+00:00",
when: 1615702073.284505,
name: "Elgato Key Light Air",
state: "off",
entity_id: "light.elgato_key_light_air",

View File

@@ -249,7 +249,7 @@ const CONFIGS = [
name: Bed light
action_name: Toggle light
service: light.toggle
service_data:
data:
entity_id: light.bed_light
- type: section
label: Links

View File

@@ -199,7 +199,7 @@ const CONFIGS = [
tap_action:
action: call-service
service: light.turn_on
service_data:
data:
entity_id: light.ceiling_lights
- entity: sun.sun
name: Regular

View File

@@ -40,7 +40,7 @@ const CONFIGS = [
left: 90%
padding: 0px
service: light.turn_off
service_data:
data:
entity_id: group.all_lights
- type: icon
icon: mdi:cctv
@@ -88,7 +88,7 @@ const CONFIGS = [
left: 90%
padding: 0px
service: light.turn_off
service_data:
data:
entity_id: group.all_lights
- type: icon
icon: mdi:cctv

View File

@@ -17,7 +17,10 @@ import {
HassioAddonDetails,
} from "../../../src/data/hassio/addon";
import { extractApiErrorMessage } from "../../../src/data/hassio/common";
import { setSupervisorOption } from "../../../src/data/hassio/supervisor";
import {
fetchHassioSupervisorInfo,
setSupervisorOption,
} from "../../../src/data/hassio/supervisor";
import { Supervisor } from "../../../src/data/supervisor/supervisor";
import { showConfirmationDialog } from "../../../src/dialogs/generic/show-dialog-box";
import "../../../src/layouts/hass-error-screen";
@@ -169,38 +172,40 @@ class HassioAddonDashboard extends LitElement {
if (this.route.path === "") {
const requestedAddon = extractSearchParam("addon");
const requestedAddonRepository = extractSearchParam("repository_url");
if (
requestedAddonRepository &&
!this.supervisor.supervisor.addons_repositories.find(
(repo) => repo === requestedAddonRepository
)
) {
if (requestedAddonRepository) {
const supervisorInfo = await fetchHassioSupervisorInfo(this.hass);
if (
!(await showConfirmationDialog(this, {
title: this.supervisor.localize("my.add_addon_repository_title"),
text: this.supervisor.localize(
"my.add_addon_repository_description",
{ addon: requestedAddon, repository: requestedAddonRepository }
),
confirmText: this.supervisor.localize("common.add"),
dismissText: this.supervisor.localize("common.cancel"),
}))
!supervisorInfo.addons_repositories.find(
(repo) => repo === requestedAddonRepository
)
) {
this._error = this.supervisor.localize(
"my.error_repository_not_found"
);
return;
}
if (
!(await showConfirmationDialog(this, {
title: this.supervisor.localize("my.add_addon_repository_title"),
text: this.supervisor.localize(
"my.add_addon_repository_description",
{ addon: requestedAddon, repository: requestedAddonRepository }
),
confirmText: this.supervisor.localize("common.add"),
dismissText: this.supervisor.localize("common.cancel"),
}))
) {
this._error = this.supervisor.localize(
"my.error_repository_not_found"
);
return;
}
try {
await setSupervisorOption(this.hass, {
addons_repositories: [
...this.supervisor.supervisor.addons_repositories,
requestedAddonRepository,
],
});
} catch (err: any) {
this._error = extractApiErrorMessage(err);
try {
await setSupervisorOption(this.hass, {
addons_repositories: [
...supervisorInfo.addons_repositories,
requestedAddonRepository,
],
});
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
}

View File

@@ -85,7 +85,7 @@ export class HassioUpdate extends LitElement {
return html``;
}
return html`
<ha-card>
<ha-card outlined>
<div class="card-content">
<div class="icon">
<ha-svg-icon .path=${mdiHomeAssistant}></ha-svg-icon>

View File

@@ -48,7 +48,7 @@ class HassioCoreInfo extends LitElement {
];
return html`
<ha-card header="Core">
<ha-card header="Core" outlined>
<div class="card-content">
<div>
<ha-settings-row>

View File

@@ -66,7 +66,7 @@ class HassioHostInfo extends LitElement {
},
];
return html`
<ha-card header="Host">
<ha-card header="Host" outlined>
<div class="card-content">
<div>
${this.supervisor.host.features.includes("hostname")

View File

@@ -57,7 +57,7 @@ class HassioSupervisorInfo extends LitElement {
},
];
return html`
<ha-card header="Supervisor">
<ha-card header="Supervisor" outlined>
<div class="card-content">
<div>
<ha-settings-row>

View File

@@ -65,7 +65,7 @@ class HassioSupervisorLog extends LitElement {
protected render(): TemplateResult | void {
return html`
<ha-card>
<ha-card outlined>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}

View File

@@ -128,6 +128,7 @@ class UpdateAvailableCard extends LitElement {
return html`
<ha-card
outlined
.header=${this.supervisor.localize("update_available.update_name", {
name: this._name,
})}

View File

@@ -72,8 +72,8 @@
"@material/mwc-textfield": "0.25.3",
"@material/mwc-top-app-bar-fixed": "^0.25.3",
"@material/top-app-bar": "14.0.0-canary.261f2db59.0",
"@mdi/js": "6.6.95",
"@mdi/svg": "6.6.95",
"@mdi/js": "6.7.96",
"@mdi/svg": "6.7.96",
"@polymer/app-layout": "^3.1.0",
"@polymer/iron-flex-layout": "^3.0.1",
"@polymer/iron-icon": "^3.0.1",
@@ -106,7 +106,6 @@
"deep-clone-simple": "^1.1.1",
"deep-freeze": "^0.0.1",
"fuse.js": "^6.0.0",
"fuzzysort": "^1.2.1",
"google-timezones-json": "^1.0.2",
"hls.js": "^1.1.5",
"home-assistant-js-websocket": "^7.0.3",

View File

@@ -1,3 +1,30 @@
[build-system]
requires = ["setuptools~=60.5", "wheel~=0.37.1"]
requires = ["setuptools~=62.3", "wheel~=0.37.1"]
build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20220525.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"
authors = [
{name = "The Home Assistant Authors", email = "hello@home-assistant.io"}
]
requires-python = ">=3.4.0"
[project.urls]
"Homepage" = "https://github.com/home-assistant/frontend"
[tool.setuptools]
platforms = ["any"]
zip-safe = false
include-package-data = true
[tool.setuptools.packages.find]
include = ["hass_frontend*"]
[tool.mypy]
python_version = 3.4
show_error_codes = true
strict = true

View File

@@ -50,14 +50,14 @@ async function main(args) {
return;
}
const setup = fs.readFileSync("setup.cfg", "utf8");
const version = setup.match(/\d{8}\.\d+/)[0];
const setup = fs.readFileSync("pyproject.toml", "utf8");
const version = setup.match(/version\W+=\W"(\d{8}\.\d)"/)[1];
const newVersion = method(version);
console.log("Current version:", version);
console.log("New version:", newVersion);
fs.writeFileSync("setup.cfg", setup.replace(version, newVersion), "utf-8");
fs.writeFileSync("pyproject.toml", setup.replace(version, newVersion), "utf-8");
if (!commit) {
return;

View File

@@ -1,26 +0,0 @@
[metadata]
name = home-assistant-frontend
version = 20220502.0
author = The Home Assistant Authors
author_email = hello@home-assistant.io
license = Apache-2.0
platforms = any
description = The Home Assistant frontend
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/home-assistant/frontend
[options]
packages = find:
zip_safe = False
include_package_data = True
python_requires = >= 3.4.0
[options.packages.find]
include =
hass_frontend*
[mypy]
python_version = 3.4
show_error_codes = True
strict = True

View File

@@ -0,0 +1,41 @@
const DEFAULT_OWN = true;
// Finds the closest ancestor of an element that has a specific optionally owned property,
// traversing slot and shadow root boundaries until the body element is reached
export const closestWithProperty = (
element: Element | null,
property: string | symbol,
own = DEFAULT_OWN
) => {
if (!element || element === document.body) return null;
element = element.assignedSlot ?? element;
if (element.parentElement) {
element = element.parentElement;
} else {
const root = element.getRootNode();
element = root instanceof ShadowRoot ? root.host : null;
}
if (
own
? Object.prototype.hasOwnProperty.call(element, property)
: element && property in element
)
return element;
return closestWithProperty(element, property, own);
};
// Finds the set of all such ancestors and includes starting element as first in the set
export const ancestorsWithProperty = (
element: Element | null,
property: string | symbol,
own = DEFAULT_OWN
) => {
const ancestors: Set<Element> = new Set();
while (element) {
ancestors.add(element);
element = closestWithProperty(element, property, own);
}
return ancestors;
};

View File

@@ -1,6 +1,11 @@
import { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE_STATES } from "../../data/entity";
export const computeActiveState = (stateObj: HassEntity): string => {
if (UNAVAILABLE_STATES.includes(stateObj.state)) {
return stateObj.state;
}
const domain = stateObj.entity_id.split(".")[0];
let state = stateObj.state;

View File

@@ -2,67 +2,74 @@ import { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { FrontendLocaleData } from "../../data/translation";
import {
updateIsInstalling,
UpdateEntity,
UPDATE_SUPPORT_PROGRESS,
updateIsInstallingFromAttributes,
} from "../../data/update";
import { formatDate } from "../datetime/format_date";
import { formatDateTime } from "../datetime/format_date_time";
import { formatTime } from "../datetime/format_time";
import { formatNumber, isNumericState } from "../number/format_number";
import { formatNumber, isNumericFromAttributes } from "../number/format_number";
import { LocalizeFunc } from "../translations/localize";
import { computeStateDomain } from "./compute_state_domain";
import { supportsFeature } from "./supports-feature";
import { supportsFeatureFromAttributes } from "./supports-feature";
import { formatDuration, UNIT_TO_SECOND_CONVERT } from "../datetime/duration";
import { computeDomain } from "./compute_domain";
export const computeStateDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
locale: FrontendLocaleData,
state?: string
): string => {
const compareState = state !== undefined ? state : stateObj.state;
): string =>
computeStateDisplayFromEntityAttributes(
localize,
locale,
stateObj.entity_id,
stateObj.attributes,
state !== undefined ? state : stateObj.state
);
if (compareState === UNKNOWN || compareState === UNAVAILABLE) {
return localize(`state.default.${compareState}`);
export const computeStateDisplayFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
entityId: string,
attributes: any,
state: string
): string => {
if (state === UNKNOWN || state === UNAVAILABLE) {
return localize(`state.default.${state}`);
}
// Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber`
if (isNumericState(stateObj)) {
if (isNumericFromAttributes(attributes)) {
// state is duration
if (
stateObj.attributes.device_class === "duration" &&
stateObj.attributes.unit_of_measurement &&
UNIT_TO_SECOND_CONVERT[stateObj.attributes.unit_of_measurement]
attributes.device_class === "duration" &&
attributes.unit_of_measurement &&
UNIT_TO_SECOND_CONVERT[attributes.unit_of_measurement]
) {
try {
return formatDuration(
compareState,
stateObj.attributes.unit_of_measurement
);
return formatDuration(state, attributes.unit_of_measurement);
} catch (_err) {
// fallback to default
}
}
if (stateObj.attributes.device_class === "monetary") {
if (attributes.device_class === "monetary") {
try {
return formatNumber(compareState, locale, {
return formatNumber(state, locale, {
style: "currency",
currency: stateObj.attributes.unit_of_measurement,
currency: attributes.unit_of_measurement,
minimumFractionDigits: 2,
});
} catch (_err) {
// fallback to default
}
}
return `${formatNumber(compareState, locale)}${
stateObj.attributes.unit_of_measurement
? " " + stateObj.attributes.unit_of_measurement
: ""
return `${formatNumber(state, locale)}${
attributes.unit_of_measurement ? " " + attributes.unit_of_measurement : ""
}`;
}
const domain = computeStateDomain(stateObj);
const domain = computeDomain(entityId);
if (domain === "input_datetime") {
if (state !== undefined) {
@@ -97,36 +104,32 @@ export const computeStateDisplay = (
} else {
// If not trying to display an explicit state, create `Date` object from `stateObj`'s attributes then format.
let date: Date;
if (stateObj.attributes.has_date && stateObj.attributes.has_time) {
if (attributes.has_date && attributes.has_time) {
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day,
stateObj.attributes.hour,
stateObj.attributes.minute
attributes.year,
attributes.month - 1,
attributes.day,
attributes.hour,
attributes.minute
);
return formatDateTime(date, locale);
}
if (stateObj.attributes.has_date) {
date = new Date(
stateObj.attributes.year,
stateObj.attributes.month - 1,
stateObj.attributes.day
);
if (attributes.has_date) {
date = new Date(attributes.year, attributes.month - 1, attributes.day);
return formatDate(date, locale);
}
if (stateObj.attributes.has_time) {
if (attributes.has_time) {
date = new Date();
date.setHours(stateObj.attributes.hour, stateObj.attributes.minute);
date.setHours(attributes.hour, attributes.minute);
return formatTime(date, locale);
}
return stateObj.state;
return state;
}
}
if (domain === "humidifier") {
if (compareState === "on" && stateObj.attributes.humidity) {
return `${stateObj.attributes.humidity} %`;
if (state === "on" && attributes.humidity) {
return `${attributes.humidity} %`;
}
}
@@ -136,7 +139,7 @@ export const computeStateDisplay = (
domain === "number" ||
domain === "input_number"
) {
return formatNumber(compareState, locale);
return formatNumber(state, locale);
}
// state of button is a timestamp
@@ -144,12 +147,12 @@ export const computeStateDisplay = (
domain === "button" ||
domain === "input_button" ||
domain === "scene" ||
(domain === "sensor" && stateObj.attributes.device_class === "timestamp")
(domain === "sensor" && attributes.device_class === "timestamp")
) {
try {
return formatDateTime(new Date(compareState), locale);
return formatDateTime(new Date(state), locale);
} catch (_err) {
return compareState;
return state;
}
}
@@ -160,30 +163,28 @@ export const computeStateDisplay = (
// When the latest version is skipped, show the latest version
// When update is not available, show "Up-to-date"
// When update is not available and there is no latest_version show "Unavailable"
return compareState === "on"
? updateIsInstalling(stateObj as UpdateEntity)
? supportsFeature(stateObj, UPDATE_SUPPORT_PROGRESS)
return state === "on"
? updateIsInstallingFromAttributes(attributes)
? supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS)
? localize("ui.card.update.installing_with_progress", {
progress: stateObj.attributes.in_progress,
progress: attributes.in_progress,
})
: localize("ui.card.update.installing")
: stateObj.attributes.latest_version
: stateObj.attributes.skipped_version ===
stateObj.attributes.latest_version
? stateObj.attributes.latest_version ??
localize("state.default.unavailable")
: attributes.latest_version
: attributes.skipped_version === attributes.latest_version
? attributes.latest_version ?? localize("state.default.unavailable")
: localize("ui.card.update.up_to_date");
}
return (
// Return device class translation
(stateObj.attributes.device_class &&
(attributes.device_class &&
localize(
`component.${domain}.state.${stateObj.attributes.device_class}.${compareState}`
`component.${domain}.state.${attributes.device_class}.${state}`
)) ||
// Return default translation
localize(`component.${domain}.state._.${compareState}`) ||
localize(`component.${domain}.state._.${state}`) ||
// We don't know! Return the raw state.
compareState
state
);
};

View File

@@ -1,7 +1,13 @@
import { HassEntity } from "home-assistant-js-websocket";
import { computeObjectId } from "./compute_object_id";
export const computeStateNameFromEntityAttributes = (
entityId: string,
attributes: { [key: string]: any }
): string =>
attributes.friendly_name === undefined
? computeObjectId(entityId).replace(/_/g, " ")
: attributes.friendly_name || "";
export const computeStateName = (stateObj: HassEntity): string =>
stateObj.attributes.friendly_name === undefined
? computeObjectId(stateObj.entity_id).replace(/_/g, " ")
: stateObj.attributes.friendly_name || "";
computeStateNameFromEntityAttributes(stateObj.entity_id, stateObj.attributes);

View File

@@ -29,7 +29,8 @@ import {
mdiWeatherNight,
} from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket";
import { updateIsInstalling, UpdateEntity } from "../../data/update";
import { UpdateEntity, updateIsInstalling } from "../../data/update";
import { weatherIcon } from "../../data/weather";
/**
* Return the icon to be used for a domain.
*
@@ -46,6 +47,20 @@ export const domainIcon = (
stateObj?: HassEntity,
state?: string
): string => {
const icon = domainIconWithoutDefault(domain, stateObj, state);
if (icon) {
return icon;
}
// eslint-disable-next-line
console.warn(`Unable to find icon for domain ${domain}`);
return DEFAULT_DOMAIN_ICON;
};
export const domainIconWithoutDefault = (
domain: string,
stateObj?: HassEntity,
state?: string
): string | undefined => {
const compareState = state !== undefined ? state : stateObj?.state;
switch (domain) {
@@ -87,6 +102,15 @@ export const domainIcon = (
? mdiCheckCircleOutline
: mdiCloseCircleOutline;
case "input_datetime":
if (!stateObj?.attributes.has_date) {
return mdiClock;
}
if (!stateObj.attributes.has_time) {
return mdiCalendar;
}
break;
case "lock":
switch (compareState) {
case "unlocked":
@@ -124,15 +148,6 @@ export const domainIcon = (
break;
}
case "input_datetime":
if (!stateObj?.attributes.has_date) {
return mdiClock;
}
if (!stateObj.attributes.has_time) {
return mdiCalendar;
}
break;
case "sun":
return stateObj?.state === "above_horizon"
? FIXED_DOMAIN_ICONS[domain]
@@ -144,13 +159,14 @@ export const domainIcon = (
? mdiPackageDown
: mdiPackageUp
: mdiPackage;
case "weather":
return weatherIcon(stateObj?.state);
}
if (domain in FIXED_DOMAIN_ICONS) {
return FIXED_DOMAIN_ICONS[domain];
}
// eslint-disable-next-line
console.warn(`Unable to find icon for domain ${domain}`);
return DEFAULT_DOMAIN_ICON;
return undefined;
};

View File

@@ -3,6 +3,13 @@ import { HassEntity } from "home-assistant-js-websocket";
export const supportsFeature = (
stateObj: HassEntity,
feature: number
): boolean => supportsFeatureFromAttributes(stateObj.attributes, feature);
export const supportsFeatureFromAttributes = (
attributes: {
[key: string]: any;
},
feature: number
): boolean =>
// eslint-disable-next-line no-bitwise
(stateObj.attributes.supported_features! & feature) !== 0;
(attributes.supported_features! & feature) !== 0;

View File

@@ -7,8 +7,11 @@ import { round } from "./round";
* @param stateObj The entity state object
*/
export const isNumericState = (stateObj: HassEntity): boolean =>
!!stateObj.attributes.unit_of_measurement ||
!!stateObj.attributes.state_class;
isNumericFromAttributes(stateObj.attributes);
export const isNumericFromAttributes = (attributes: {
[key: string]: any;
}): boolean => !!attributes.unit_of_measurement || !!attributes.state_class;
export const numberFormatToLocale = (
localeOptions: FrontendLocaleData

View File

@@ -0,0 +1,244 @@
// MIT License
// Copyright (c) 2015 - present Microsoft Corporation
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/
/**
* An inlined enum containing useful character codes (to be used with String.charCodeAt).
* Please leave the const keyword such that it gets inlined when compiled to JavaScript!
*/
export enum CharCode {
Null = 0,
/**
* The `\b` character.
*/
Backspace = 8,
/**
* The `\t` character.
*/
Tab = 9,
/**
* The `\n` character.
*/
LineFeed = 10,
/**
* The `\r` character.
*/
CarriageReturn = 13,
Space = 32,
/**
* The `!` character.
*/
ExclamationMark = 33,
/**
* The `"` character.
*/
DoubleQuote = 34,
/**
* The `#` character.
*/
Hash = 35,
/**
* The `$` character.
*/
DollarSign = 36,
/**
* The `%` character.
*/
PercentSign = 37,
/**
* The `&` character.
*/
Ampersand = 38,
/**
* The `'` character.
*/
SingleQuote = 39,
/**
* The `(` character.
*/
OpenParen = 40,
/**
* The `)` character.
*/
CloseParen = 41,
/**
* The `*` character.
*/
Asterisk = 42,
/**
* The `+` character.
*/
Plus = 43,
/**
* The `,` character.
*/
Comma = 44,
/**
* The `-` character.
*/
Dash = 45,
/**
* The `.` character.
*/
Period = 46,
/**
* The `/` character.
*/
Slash = 47,
Digit0 = 48,
Digit1 = 49,
Digit2 = 50,
Digit3 = 51,
Digit4 = 52,
Digit5 = 53,
Digit6 = 54,
Digit7 = 55,
Digit8 = 56,
Digit9 = 57,
/**
* The `:` character.
*/
Colon = 58,
/**
* The `;` character.
*/
Semicolon = 59,
/**
* The `<` character.
*/
LessThan = 60,
/**
* The `=` character.
*/
Equals = 61,
/**
* The `>` character.
*/
GreaterThan = 62,
/**
* The `?` character.
*/
QuestionMark = 63,
/**
* The `@` character.
*/
AtSign = 64,
A = 65,
B = 66,
C = 67,
D = 68,
E = 69,
F = 70,
G = 71,
H = 72,
I = 73,
J = 74,
K = 75,
L = 76,
M = 77,
N = 78,
O = 79,
P = 80,
Q = 81,
R = 82,
S = 83,
T = 84,
U = 85,
V = 86,
W = 87,
X = 88,
Y = 89,
Z = 90,
/**
* The `[` character.
*/
OpenSquareBracket = 91,
/**
* The `\` character.
*/
Backslash = 92,
/**
* The `]` character.
*/
CloseSquareBracket = 93,
/**
* The `^` character.
*/
Caret = 94,
/**
* The `_` character.
*/
Underline = 95,
/**
* The ``(`)`` character.
*/
BackTick = 96,
a = 97,
b = 98,
c = 99,
d = 100,
e = 101,
f = 102,
g = 103,
h = 104,
i = 105,
j = 106,
k = 107,
l = 108,
m = 109,
n = 110,
o = 111,
p = 112,
q = 113,
r = 114,
s = 115,
t = 116,
u = 117,
v = 118,
w = 119,
x = 120,
y = 121,
z = 122,
/**
* The `{` character.
*/
OpenCurlyBrace = 123,
/**
* The `|` character.
*/
Pipe = 124,
/**
* The `}` character.
*/
CloseCurlyBrace = 125,
/**
* The `~` character.
*/
Tilde = 126,
}

View File

@@ -0,0 +1,551 @@
/* eslint-disable no-console */
// MIT License
// Copyright (c) 2015 - present Microsoft Corporation
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { CharCode } from "./char-code";
const _debug = false;
export interface Match {
start: number;
end: number;
}
const _maxLen = 128;
function initTable() {
const table: number[][] = [];
const row: number[] = [];
for (let i = 0; i <= _maxLen; i++) {
row[i] = 0;
}
for (let i = 0; i <= _maxLen; i++) {
table.push(row.slice(0));
}
return table;
}
function isSeparatorAtPos(value: string, index: number): boolean {
if (index < 0 || index >= value.length) {
return false;
}
const code = value.codePointAt(index);
switch (code) {
case CharCode.Underline:
case CharCode.Dash:
case CharCode.Period:
case CharCode.Space:
case CharCode.Slash:
case CharCode.Backslash:
case CharCode.SingleQuote:
case CharCode.DoubleQuote:
case CharCode.Colon:
case CharCode.DollarSign:
case CharCode.LessThan:
case CharCode.OpenParen:
case CharCode.OpenSquareBracket:
return true;
case undefined:
return false;
default:
if (isEmojiImprecise(code)) {
return true;
}
return false;
}
}
function isWhitespaceAtPos(value: string, index: number): boolean {
if (index < 0 || index >= value.length) {
return false;
}
const code = value.charCodeAt(index);
switch (code) {
case CharCode.Space:
case CharCode.Tab:
return true;
default:
return false;
}
}
function isUpperCaseAtPos(pos: number, word: string, wordLow: string): boolean {
return word[pos] !== wordLow[pos];
}
export function isPatternInWord(
patternLow: string,
patternPos: number,
patternLen: number,
wordLow: string,
wordPos: number,
wordLen: number,
fillMinWordPosArr = false
): boolean {
while (patternPos < patternLen && wordPos < wordLen) {
if (patternLow[patternPos] === wordLow[wordPos]) {
if (fillMinWordPosArr) {
// Remember the min word position for each pattern position
_minWordMatchPos[patternPos] = wordPos;
}
patternPos += 1;
}
wordPos += 1;
}
return patternPos === patternLen; // pattern must be exhausted
}
enum Arrow {
Diag = 1,
Left = 2,
LeftLeft = 3,
}
/**
* An array representing a fuzzy match.
*
* 0. the score
* 1. the offset at which matching started
* 2. `<match_pos_N>`
* 3. `<match_pos_1>`
* 4. `<match_pos_0>` etc
*/
// export type FuzzyScore = [score: number, wordStart: number, ...matches: number[]];// [number, number, number];
export type FuzzyScore = Array<number>;
export function fuzzyScore(
pattern: string,
patternLow: string,
patternStart: number,
word: string,
wordLow: string,
wordStart: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined {
const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length;
const wordLen = word.length > _maxLen ? _maxLen : word.length;
if (
patternStart >= patternLen ||
wordStart >= wordLen ||
patternLen - patternStart > wordLen - wordStart
) {
return undefined;
}
// Run a simple check if the characters of pattern occur
// (in order) at all in word. If that isn't the case we
// stop because no match will be possible
if (
!isPatternInWord(
patternLow,
patternStart,
patternLen,
wordLow,
wordStart,
wordLen,
true
)
) {
return undefined;
}
// Find the max matching word position for each pattern position
// NOTE: the min matching word position was filled in above, in the `isPatternInWord` call
_fillInMaxWordMatchPos(
patternLen,
wordLen,
patternStart,
wordStart,
patternLow,
wordLow
);
let row: number;
let column = 1;
let patternPos: number;
let wordPos: number;
const hasStrongFirstMatch = [false];
// There will be a match, fill in tables
for (
row = 1, patternPos = patternStart;
patternPos < patternLen;
row++, patternPos++
) {
// Reduce search space to possible matching word positions and to possible access from next row
const minWordMatchPos = _minWordMatchPos[patternPos];
const maxWordMatchPos = _maxWordMatchPos[patternPos];
const nextMaxWordMatchPos =
patternPos + 1 < patternLen ? _maxWordMatchPos[patternPos + 1] : wordLen;
for (
column = minWordMatchPos - wordStart + 1, wordPos = minWordMatchPos;
wordPos < nextMaxWordMatchPos;
column++, wordPos++
) {
let score = Number.MIN_SAFE_INTEGER;
let canComeDiag = false;
if (wordPos <= maxWordMatchPos) {
score = _doScore(
pattern,
patternLow,
patternPos,
patternStart,
word,
wordLow,
wordPos,
wordLen,
wordStart,
_diag[row - 1][column - 1] === 0,
hasStrongFirstMatch
);
}
let diagScore = 0;
if (score !== Number.MAX_SAFE_INTEGER) {
canComeDiag = true;
diagScore = score + _table[row - 1][column - 1];
}
const canComeLeft = wordPos > minWordMatchPos;
const leftScore = canComeLeft
? _table[row][column - 1] + (_diag[row][column - 1] > 0 ? -5 : 0)
: 0; // penalty for a gap start
const canComeLeftLeft =
wordPos > minWordMatchPos + 1 && _diag[row][column - 1] > 0;
const leftLeftScore = canComeLeftLeft
? _table[row][column - 2] + (_diag[row][column - 2] > 0 ? -5 : 0)
: 0; // penalty for a gap start
if (
canComeLeftLeft &&
(!canComeLeft || leftLeftScore >= leftScore) &&
(!canComeDiag || leftLeftScore >= diagScore)
) {
// always prefer choosing left left to jump over a diagonal because that means a match is earlier in the word
_table[row][column] = leftLeftScore;
_arrows[row][column] = Arrow.LeftLeft;
_diag[row][column] = 0;
} else if (canComeLeft && (!canComeDiag || leftScore >= diagScore)) {
// always prefer choosing left since that means a match is earlier in the word
_table[row][column] = leftScore;
_arrows[row][column] = Arrow.Left;
_diag[row][column] = 0;
} else if (canComeDiag) {
_table[row][column] = diagScore;
_arrows[row][column] = Arrow.Diag;
_diag[row][column] = _diag[row - 1][column - 1] + 1;
} else {
throw new Error(`not possible`);
}
}
}
if (_debug) {
printTables(pattern, patternStart, word, wordStart);
}
if (!hasStrongFirstMatch[0] && !firstMatchCanBeWeak) {
return undefined;
}
row--;
column--;
const result: FuzzyScore = [_table[row][column], wordStart];
let backwardsDiagLength = 0;
let maxMatchColumn = 0;
while (row >= 1) {
// Find the column where we go diagonally up
let diagColumn = column;
do {
const arrow = _arrows[row][diagColumn];
if (arrow === Arrow.LeftLeft) {
diagColumn -= 2;
} else if (arrow === Arrow.Left) {
diagColumn -= 1;
} else {
// found the diagonal
break;
}
} while (diagColumn >= 1);
// Overturn the "forwards" decision if keeping the "backwards" diagonal would give a better match
if (
backwardsDiagLength > 1 && // only if we would have a contiguous match of 3 characters
patternLow[patternStart + row - 1] === wordLow[wordStart + column - 1] && // only if we can do a contiguous match diagonally
!isUpperCaseAtPos(diagColumn + wordStart - 1, word, wordLow) && // only if the forwards chose diagonal is not an uppercase
backwardsDiagLength + 1 > _diag[row][diagColumn] // only if our contiguous match would be longer than the "forwards" contiguous match
) {
diagColumn = column;
}
if (diagColumn === column) {
// this is a contiguous match
backwardsDiagLength++;
} else {
backwardsDiagLength = 1;
}
if (!maxMatchColumn) {
// remember the last matched column
maxMatchColumn = diagColumn;
}
row--;
column = diagColumn - 1;
result.push(column);
}
if (wordLen === patternLen) {
// the word matches the pattern with all characters!
// giving the score a total match boost (to come up ahead other words)
result[0] += 2;
}
// Add 1 penalty for each skipped character in the word
const skippedCharsCount = maxMatchColumn - patternLen;
result[0] -= skippedCharsCount;
return result;
}
function _doScore(
pattern: string,
patternLow: string,
patternPos: number,
patternStart: number,
word: string,
wordLow: string,
wordPos: number,
wordLen: number,
wordStart: number,
newMatchStart: boolean,
outFirstMatchStrong: boolean[]
): number {
if (patternLow[patternPos] !== wordLow[wordPos]) {
return Number.MIN_SAFE_INTEGER;
}
let score = 1;
let isGapLocation = false;
if (wordPos === patternPos - patternStart) {
// common prefix: `foobar <-> foobaz`
// ^^^^^
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
} else if (
isUpperCaseAtPos(wordPos, word, wordLow) &&
(wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow))
) {
// hitting upper-case: `foo <-> forOthers`
// ^^ ^
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
isGapLocation = true;
} else if (
isSeparatorAtPos(wordLow, wordPos) &&
(wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1))
) {
// hitting a separator: `. <-> foo.bar`
// ^
score = 5;
} else if (
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1)
) {
// post separator: `foo <-> bar_foo`
// ^^^
score = 5;
isGapLocation = true;
}
if (score > 1 && patternPos === patternStart) {
outFirstMatchStrong[0] = true;
}
if (!isGapLocation) {
isGapLocation =
isUpperCaseAtPos(wordPos, word, wordLow) ||
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1);
}
//
if (patternPos === patternStart) {
// first character in pattern
if (wordPos > wordStart) {
// the first pattern character would match a word character that is not at the word start
// so introduce a penalty to account for the gap preceding this match
score -= isGapLocation ? 3 : 5;
}
} else if (newMatchStart) {
// this would be the beginning of a new match (i.e. there would be a gap before this location)
score += isGapLocation ? 2 : 0;
} else {
// this is part of a contiguous match, so give it a slight bonus, but do so only if it would not be a prefered gap location
score += isGapLocation ? 0 : 1;
}
if (wordPos + 1 === wordLen) {
// we always penalize gaps, but this gives unfair advantages to a match that would match the last character in the word
// so pretend there is a gap after the last character in the word to normalize things
score -= isGapLocation ? 3 : 5;
}
return score;
}
function printTable(
table: number[][],
pattern: string,
patternLen: number,
word: string,
wordLen: number
): string {
function pad(s: string, n: number, _pad = " ") {
while (s.length < n) {
s = _pad + s;
}
return s;
}
let ret = ` | |${word
.split("")
.map((c) => pad(c, 3))
.join("|")}\n`;
for (let i = 0; i <= patternLen; i++) {
if (i === 0) {
ret += " |";
} else {
ret += `${pattern[i - 1]}|`;
}
ret +=
table[i]
.slice(0, wordLen + 1)
.map((n) => pad(n.toString(), 3))
.join("|") + "\n";
}
return ret;
}
function printTables(
pattern: string,
patternStart: number,
word: string,
wordStart: number
): void {
pattern = pattern.substr(patternStart);
word = word.substr(wordStart);
console.log(printTable(_table, pattern, pattern.length, word, word.length));
console.log(printTable(_arrows, pattern, pattern.length, word, word.length));
console.log(printTable(_diag, pattern, pattern.length, word, word.length));
}
const _minWordMatchPos = initArr(2 * _maxLen); // min word position for a certain pattern position
const _maxWordMatchPos = initArr(2 * _maxLen); // max word position for a certain pattern position
const _diag = initTable(); // the length of a contiguous diagonal match
const _table = initTable();
const _arrows = <Arrow[][]>initTable();
function initArr(maxLen: number) {
const row: number[] = [];
for (let i = 0; i <= maxLen; i++) {
row[i] = 0;
}
return row;
}
function _fillInMaxWordMatchPos(
patternLen: number,
wordLen: number,
patternStart: number,
wordStart: number,
patternLow: string,
wordLow: string
) {
let patternPos = patternLen - 1;
let wordPos = wordLen - 1;
while (patternPos >= patternStart && wordPos >= wordStart) {
if (patternLow[patternPos] === wordLow[wordPos]) {
_maxWordMatchPos[patternPos] = wordPos;
patternPos--;
}
wordPos--;
}
}
export interface FuzzyScorer {
(
pattern: string,
lowPattern: string,
patternPos: number,
word: string,
lowWord: string,
wordPos: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined;
}
export function createMatches(score: undefined | FuzzyScore): Match[] {
if (typeof score === "undefined") {
return [];
}
const res: Match[] = [];
const wordPos = score[1];
for (let i = score.length - 1; i > 1; i--) {
const pos = score[i] + wordPos;
const last = res[res.length - 1];
if (last && last.end === pos) {
last.end = pos + 1;
} else {
res.push({ start: pos, end: pos + 1 });
}
}
return res;
}
/**
* A fast function (therefore imprecise) to check if code points are emojis.
* Generated using https://github.com/alexdima/unicode-utils/blob/master/generate-emoji-test.js
*/
export function isEmojiImprecise(x: number): boolean {
return (
(x >= 0x1f1e6 && x <= 0x1f1ff) ||
x === 8986 ||
x === 8987 ||
x === 9200 ||
x === 9203 ||
(x >= 9728 && x <= 10175) ||
x === 11088 ||
x === 11093 ||
(x >= 127744 && x <= 128591) ||
(x >= 128640 && x <= 128764) ||
(x >= 128992 && x <= 129003) ||
(x >= 129280 && x <= 129535) ||
(x >= 129648 && x <= 129750)
);
}

View File

@@ -1,4 +1,52 @@
import fuzzysort from "fuzzysort";
import { fuzzyScore } from "./filter";
/**
* Determine whether a sequence of letters exists in another string,
* in that order, allowing for skipping. Ex: "chdr" exists in "chandelier")
*
* @param {string} filter - Sequence of letters to check for
* @param {ScorableTextItem} item - Item against whose strings will be checked
*
* @return {number} Score representing how well the word matches the filter. Return of 0 means no match.
*/
export const fuzzySequentialMatch = (
filter: string,
item: ScorableTextItem
) => {
let topScore = Number.NEGATIVE_INFINITY;
for (const word of item.strings) {
const scores = fuzzyScore(
filter,
filter.toLowerCase(),
0,
word,
word.toLowerCase(),
0,
true
);
if (!scores) {
continue;
}
// The VS Code implementation of filter returns a 0 for a weak match.
// But if .filter() sees a "0", it considers that a failed match and will remove it.
// So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering.
const score = scores[0] === 0 ? 1 : scores[0];
if (score > topScore) {
topScore = score;
}
}
if (topScore === Number.NEGATIVE_INFINITY) {
return undefined;
}
return topScore;
};
/**
* An interface that objects must extend in order to use the fuzzy sequence matcher
@@ -18,48 +66,18 @@ export interface ScorableTextItem {
strings: string[];
}
export type FuzzyFilterSort = <T extends ScorableTextItem>(
type FuzzyFilterSort = <T extends ScorableTextItem>(
filter: string,
items: T[]
) => T[];
export function fuzzyMatcher(search: string | null): (string) => boolean {
const scorer = fuzzyScorer(search);
return (value: string) => scorer([value]) !== Number.NEGATIVE_INFINITY;
}
export function fuzzyScorer(
search: string | null
): (values: string[]) => number {
const searchTerms = (search || "").match(/("[^"]+"|[^"\s]+)/g);
if (!searchTerms) {
return () => 0;
}
return (values) =>
searchTerms
.map((term) => {
const resultsForTerm = fuzzysort.go(term, values, {
allowTypo: true,
});
if (resultsForTerm.length > 0) {
return Math.max(...resultsForTerm.map((result) => result.score));
}
return Number.NEGATIVE_INFINITY;
})
.reduce((partial, current) => partial + current, 0);
}
export const fuzzySortFilterSort: FuzzyFilterSort = (filter, items) => {
const scorer = fuzzyScorer(filter);
return items
export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) =>
items
.map((item) => {
item.score = scorer(item.strings);
item.score = fuzzySequentialMatch(filter, item);
return item;
})
.filter((item) => item.score !== undefined && item.score > -100000)
.filter((item) => item.score !== undefined)
.sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) =>
scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0
);
};
export const defaultFuzzyFilterSort = fuzzySortFilterSort;

View File

@@ -13,7 +13,7 @@ export const throttle = <T extends any[]>(
) => {
let timeout: number | undefined;
let previous = 0;
return (...args: T): void => {
const throttledFunc = (...args: T): void => {
const later = () => {
previous = leading === false ? 0 : Date.now();
timeout = undefined;
@@ -35,4 +35,10 @@ export const throttle = <T extends any[]>(
timeout = window.setTimeout(later, remaining);
}
};
throttledFunc.cancel = () => {
clearTimeout(timeout);
timeout = undefined;
previous = 0;
};
return throttledFunc;
};

View File

@@ -34,7 +34,7 @@ import {
endOfMonth,
endOfQuarter,
endOfYear,
} from "date-fns";
} from "date-fns/esm";
import {
formatDate,
formatDateMonth,

View File

@@ -269,8 +269,8 @@ export class HaDataTable extends LitElement {
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length ===
this._checkableRowsCount}
.checked=${this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
>
</ha-checkbox>
</div>

View File

@@ -7,26 +7,25 @@ import type {
SortableColumnContainer,
SortingDirection,
} from "./ha-data-table";
import { fuzzyMatcher } from "../../common/string/filter/sequence-matching";
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
filter: string
) => {
const matcher = fuzzyMatcher(filter);
filter = filter.toUpperCase();
return data.filter((row) =>
Object.entries(columns).some((columnEntry) => {
const [key, column] = columnEntry;
if (column.filterable) {
if (
matcher(
String(
column.filterKey
? row[column.valueColumn || key][column.filterKey]
: row[column.valueColumn || key]
)
String(
column.filterKey
? row[column.valueColumn || key][column.filterKey]
: row[column.valueColumn || key]
)
.toUpperCase()
.includes(filter)
) {
return true;
}

View File

@@ -15,13 +15,12 @@ import type { HaComboBox } from "../ha-combo-box";
import "../ha-icon-button";
import "../ha-svg-icon";
import "./state-badge";
import { defaultFuzzyFilterSort } from "../../common/string/filter/sequence-matching";
interface HassEntityWithCachedName extends HassEntity {
friendly_name: string;
}
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
// eslint-disable-next-line lit/prefer-static-styles
const rowRenderer: ComboBoxLitRenderer<HassEntityWithCachedName> = (item) =>
@@ -337,18 +336,11 @@ export class HaEntityPicker extends LitElement {
}
private _filterChanged(ev: CustomEvent): void {
const filterString = ev.detail.value;
const sortableEntityStates = this._states.map((entityState) => ({
strings: [entityState.entity_id, computeStateName(entityState)],
entityState: entityState,
}));
const sortedEntityStates = defaultFuzzyFilterSort(
filterString,
sortableEntityStates
);
(this.comboBox as any).filteredItems = sortedEntityStates.map(
(sortableItem) => sortableItem.entityState
const filterString = ev.detail.value.toLowerCase();
(this.comboBox as any).filteredItems = this._states.filter(
(entityState) =>
entityState.entity_id.toLowerCase().includes(filterString) ||
computeStateName(entityState).toLowerCase().includes(filterString)
);
}

View File

@@ -1,17 +1,22 @@
import type { Button } from "@material/mwc-button";
import "@material/mwc-menu";
import type { Corner, Menu, MenuCorner } from "@material/mwc-menu";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators";
import { FOCUS_TARGET } from "../dialogs/make-dialog-manager";
import type { HaIconButton } from "./ha-icon-button";
@customElement("ha-button-menu")
export class HaButtonMenu extends LitElement {
protected readonly [FOCUS_TARGET];
@property() public corner: Corner = "TOP_START";
@property() public menuCorner: MenuCorner = "START";
@property({ type: Number }) public x?: number;
@property({ type: Number }) public x: number | null = null;
@property({ type: Number }) public y?: number;
@property({ type: Number }) public y: number | null = null;
@property({ type: Boolean }) public multi = false;
@@ -31,10 +36,18 @@ export class HaButtonMenu extends LitElement {
return this._menu?.selected;
}
public override focus() {
if (this._menu?.open) {
this._menu.focusItemAtIndex(0);
} else {
this._triggerButton?.focus();
}
}
protected render(): TemplateResult {
return html`
<div @click=${this._handleClick}>
<slot name="trigger"></slot>
<slot name="trigger" @slotchange=${this._setTriggerAria}></slot>
</div>
<mwc-menu
.corner=${this.corner}
@@ -50,6 +63,21 @@ export class HaButtonMenu extends LitElement {
`;
}
protected firstUpdated(changedProps): void {
super.firstUpdated(changedProps);
if (document.dir === "rtl") {
this.updateComplete.then(() => {
this.querySelectorAll("mwc-list-item").forEach((item) => {
const style = document.createElement("style");
style.innerHTML =
"span.material-icons:first-of-type { margin-left: var(--mdc-list-item-graphic-margin, 32px) !important; margin-right: 0px !important;}";
item!.shadowRoot!.appendChild(style);
});
});
}
}
private _handleClick(): void {
if (this.disabled) {
return;
@@ -58,6 +86,18 @@ export class HaButtonMenu extends LitElement {
this._menu!.show();
}
private get _triggerButton() {
return this.querySelector(
'ha-icon-button[slot="trigger"], mwc-button[slot="trigger"]'
) as HaIconButton | Button | null;
}
private _setTriggerAria() {
if (this._triggerButton) {
this._triggerButton.ariaHasPopup = "menu";
}
}
static get styles(): CSSResultGroup {
return css`
:host {

View File

@@ -66,9 +66,13 @@ export class HaChip extends LitElement {
line-height: 14px;
color: var(--ha-chip-icon-color, var(--ha-chip-text-color));
}
.mdc-chip.mdc-chip--selected .mdc-chip__checkmark,
.mdc-chip.no-text
.mdc-chip__icon--leading:not(.mdc-chip__icon--leading-hidden) {
margin-right: -4px;
margin-inline-start: -4px;
margin-inline-end: 4px;
direction: var(--direction);
}
span[role="gridcell"] {

View File

@@ -47,10 +47,6 @@ export class HaClickableListItem extends ListItemBase {
padding-left: 0px;
padding-right: 0px;
}
:host([rtl]) span {
margin-left: var(--mdc-list-item-graphic-margin, 20px) !important;
margin-right: 0px !important;
}
:host([graphic="avatar"]:not([twoLine])),
:host([graphic="icon"]:not([twoLine])) {
height: 48px;
@@ -64,6 +60,19 @@ export class HaClickableListItem extends ListItemBase {
padding-right: var(--mdc-list-side-padding, 20px);
overflow: hidden;
}
span.material-icons:first-of-type {
margin-inline-start: 0px !important;
margin-inline-end: var(
--mdc-list-item-graphic-margin,
16px
) !important;
direction: var(--direction);
}
span.material-icons:last-of-type {
margin-inline-start: auto !important;
margin-inline-end: 0px !important;
direction: var(--direction);
}
`,
];
}

View File

@@ -3,6 +3,7 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { formatNumber } from "../common/number/format_number";
import { CLIMATE_PRESET_NONE } from "../data/climate";
import { UNAVAILABLE_STATES } from "../data/entity";
import type { HomeAssistant } from "../types";
@customElement("ha-climate-state")
@@ -15,22 +16,22 @@ class HaClimateState extends LitElement {
const currentStatus = this._computeCurrentStatus();
return html`<div class="target">
${this.stateObj.state !== "unknown"
${!UNAVAILABLE_STATES.includes(this.stateObj.state)
? html`<span class="state-label">
${this._localizeState()}
${this.stateObj.attributes.preset_mode &&
this.stateObj.attributes.preset_mode !== CLIMATE_PRESET_NONE
? html`-
${this.hass.localize(
`state_attributes.climate.preset_mode.${this.stateObj.attributes.preset_mode}`
) || this.stateObj.attributes.preset_mode}`
: ""}
</span>`
: ""}
<div class="unit">${this._computeTarget()}</div>
${this._localizeState()}
${this.stateObj.attributes.preset_mode &&
this.stateObj.attributes.preset_mode !== CLIMATE_PRESET_NONE
? html`-
${this.hass.localize(
`state_attributes.climate.preset_mode.${this.stateObj.attributes.preset_mode}`
) || this.stateObj.attributes.preset_mode}`
: ""}
</span>
<div class="unit">${this._computeTarget()}</div>`
: this._localizeState()}
</div>
${currentStatus
${currentStatus && !UNAVAILABLE_STATES.includes(this.stateObj.state)
? html`<div class="current">
${this.hass.localize("ui.card.climate.currently")}:
<div class="unit">${currentStatus}</div>
@@ -108,6 +109,10 @@ class HaClimateState extends LitElement {
}
private _localizeState(): string {
if (UNAVAILABLE_STATES.includes(this.stateObj.state)) {
return this.hass.localize(`state.default.${this.stateObj.state}`);
}
const stateString = this.hass.localize(
`component.climate.state._.${this.stateObj.state}`
);

View File

@@ -241,6 +241,9 @@ export class HaComboBox extends LitElement {
.toggle-button {
right: 12px;
top: -10px;
inset-inline-start: initial;
inset-inline-end: 12px;
direction: var(--direction);
}
:host([opened]) .toggle-button {
color: var(--primary-color);
@@ -249,18 +252,9 @@ export class HaComboBox extends LitElement {
--mdc-icon-size: 20px;
top: -7px;
right: 36px;
}
:host-context([style*="direction: rtl;"]) .toggle-button {
left: 12px;
right: auto;
top: -10px;
}
:host-context([style*="direction: rtl;"]) .clear-button {
--mdc-icon-size: 20px;
top: -7px;
left: 36px;
right: auto;
inset-inline-start: initial;
inset-inline-end: 36px;
direction: var(--direction);
}
`;
}

View File

@@ -140,6 +140,9 @@ export class HaDateRangePicker extends LitElement {
return css`
ha-svg-icon {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
direction: var(--direction);
}
.date-range-inputs {
@@ -166,6 +169,9 @@ export class HaDateRangePicker extends LitElement {
ha-textfield:last-child {
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
direction: var(--direction);
}
@media only screen and (max-width: 800px) {

View File

@@ -3,8 +3,8 @@ import { styles } from "@material/mwc-dialog/mwc-dialog.css";
import { mdiClose } from "@mdi/js";
import { css, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators";
import { computeRTLDirection } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import { FOCUS_TARGET } from "../dialogs/make-dialog-manager";
import "./ha-icon-button";
export const createCloseHeading = (
@@ -17,12 +17,13 @@ export const createCloseHeading = (
.path=${mdiClose}
dialogAction="close"
class="header_button"
dir=${computeRTLDirection(hass)}
></ha-icon-button>
`;
@customElement("ha-dialog")
export class HaDialog extends DialogBase {
protected readonly [FOCUS_TARGET];
public scrollToPos(x: number, y: number) {
this.contentElement?.scrollTo(x, y);
}
@@ -89,18 +90,18 @@ export class HaDialog extends DialogBase {
}
.header_title {
margin-right: 40px;
margin-inline-end: 40px;
direction: var(--direction);
}
[dir="rtl"].header_button {
right: auto;
left: 16px;
.header_button {
inset-inline-start: initial;
inset-inline-end: 16px;
direction: var(--direction);
}
[dir="rtl"].header_title {
margin-left: 40px;
margin-right: 0px;
}
:host-context([style*="direction: rtl;"]) .dialog-actions {
left: 0px !important;
right: auto !important;
.dialog-actions {
inset-inline-start: initial !important;
inset-inline-end: 0px !important;
direction: var(--direction);
}
`,
];

View File

@@ -133,6 +133,9 @@ class HaExpansionPanel extends LitElement {
.summary-icon {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
margin-left: auto;
margin-inline-start: auto;
margin-inline-end: initial;
direction: var(--direction);
}
.summary-icon.expanded {

View File

@@ -1,24 +1,25 @@
import { Fab } from "@material/mwc-fab";
import { FabBase } from "@material/mwc-fab/mwc-fab-base";
import { styles } from "@material/mwc-fab/mwc-fab.css";
import { customElement } from "lit/decorators";
import { css } from "lit";
@customElement("ha-fab")
export class HaFab extends Fab {
export class HaFab extends FabBase {
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.style.setProperty("--mdc-theme-secondary", "var(--primary-color)");
}
static override styles = Fab.styles.concat([
static override styles = [
styles,
css`
:host-context([style*="direction: rtl;"])
.mdc-fab--extended
.mdc-fab__icon {
margin-left: 12px !important;
margin-right: calc(12px - 20px) !important;
:host .mdc-fab--extended .mdc-fab__icon {
margin-inline-start: -8px;
margin-inline-end: 12px;
direction: var(--direction);
}
`,
]);
];
}
declare global {

View File

@@ -175,24 +175,23 @@ export class HaFileUpload extends LitElement {
}
.mdc-text-field__icon--leading {
margin-bottom: 12px;
}
:host-context([style*="direction: rtl;"])
.mdc-text-field__icon--leading {
margin-right: 0px;
inset-inline-start: initial;
inset-inline-end: 0px;
direction: var(--direction);
}
.mdc-text-field--filled .mdc-floating-label--float-above {
transform: scale(0.75);
top: 8px;
}
:host-context([style*="direction: rtl;"]) .mdc-floating-label {
left: initial;
right: 16px;
.mdc-floating-label {
inset-inline-start: 16px !important;
inset-inline-end: initial !important;
direction: var(--direction);
}
:host-context([style*="direction: rtl;"])
.mdc-text-field--filled
.mdc-floating-label {
left: initial;
right: 48px;
.mdc-text-field--filled .mdc-floating-label {
inset-inline-start: 48px !important;
inset-inline-end: initial !important;
direction: var(--direction);
}
.dragged:before {
position: var(--layout-fit_-_position);

View File

@@ -133,9 +133,10 @@ export class HaFormString extends LitElement implements HaFormElement {
color: var(--secondary-text-color);
}
:host-context([style*="direction: rtl;"]) ha-icon-button {
right: auto;
left: 12px;
ha-icon-button {
inset-inline-start: initial;
inset-inline-end: 12px;
direction: var(--direction);
}
`;
}

View File

@@ -1,6 +1,8 @@
import "@material/mwc-icon-button";
import type { IconButton } from "@material/mwc-icon-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import "./ha-svg-icon";
@customElement("ha-icon-button")
@@ -11,21 +13,32 @@ export class HaIconButton extends LitElement {
@property({ type: String }) path?: string;
// Label that is used for ARIA support and as tooltip
@property({ type: String }) label = "";
@property({ type: String }) label?: string;
// These should always be set as properties, not attributes,
// so that only the <button> element gets the attribute
@property({ type: String, attribute: "aria-haspopup" })
override ariaHasPopup!: IconButton["ariaHasPopup"];
@property({ type: Boolean }) hideTitle = false;
@query("mwc-icon-button", true) private _button?: IconButton;
public override focus() {
this._button?.focus();
}
static shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
};
protected render(): TemplateResult {
// Note: `ariaLabel` required despite the `mwc-icon-button` docs saying `label` should be enough
return html`
<mwc-icon-button
.ariaLabel=${this.label}
.title=${this.hideTitle ? "" : this.label}
aria-label=${ifDefined(this.label)}
title=${ifDefined(this.hideTitle ? undefined : this.label)}
aria-haspopup=${ifDefined(this.ariaHasPopup)}
.disabled=${this.disabled}
>
${this.path

View File

@@ -47,9 +47,18 @@ export class HaSelect extends SelectBase {
.mdc-select__anchor {
width: var(--ha-select-min-width, 200px);
}
:host-context([style*="direction: rtl;"]) .mdc-floating-label {
right: 16px !important;
left: initial !important;
.mdc-select--filled .mdc-floating-label {
inset-inline-start: 12px;
inset-inline-end: initial;
direction: var(--direction);
}
.mdc-select .mdc-select__anchor {
padding-inline-start: 12px;
padding-inline-end: 0px;
direction: var(--direction);
}
.mdc-select__anchor .mdc-floating-label--float-above {
transform-origin: var(--float-start);
}
`,
];

View File

@@ -1,15 +1,24 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ConfigEntry, getConfigEntries } from "../../data/config_entries";
import memoizeOne from "memoize-one";
import { DeviceRegistryEntry } from "../../data/device_registry";
import { EntityRegistryEntry } from "../../data/entity_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../data/entity_registry";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../data/entity_sources";
import { AreaSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../types";
import "../ha-area-picker";
import "../ha-areas-picker";
@customElement("ha-selector-area")
export class HaAreaSelector extends LitElement {
export class HaAreaSelector extends SubscribeMixin(LitElement) {
@property() public hass!: HomeAssistant;
@property() public selector!: AreaSelector;
@@ -20,29 +29,44 @@ export class HaAreaSelector extends LitElement {
@property() public helper?: string;
@state() public _configEntries?: ConfigEntry[];
@state() private _entitySources?: EntitySources;
@state() private _entities?: EntityRegistryEntry[];
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entities = entities.filter((entity) => entity.device_id !== null);
}),
];
}
protected updated(changedProperties) {
if (changedProperties.has("selector")) {
const oldSelector = changedProperties.get("selector");
if (
oldSelector !== this.selector &&
this.selector.area.device?.integration
) {
getConfigEntries(this.hass, {
domain: this.selector.area.device.integration,
}).then((entries) => {
this._configEntries = entries;
});
}
if (
changedProperties.has("selector") &&
(this.selector.area.device?.integration ||
this.selector.area.entity?.integration) &&
!this._entitySources
) {
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
}
}
protected render() {
if (
(this.selector.area.device?.integration ||
this.selector.area.entity?.integration) &&
!this._entitySources
) {
return html``;
}
if (!this.selector.area.multiple) {
return html`
<ha-area-picker
@@ -87,39 +111,62 @@ export class HaAreaSelector extends LitElement {
}
private _filterEntities = (entity: EntityRegistryEntry): boolean => {
if (this.selector.area.entity?.integration) {
if (entity.platform !== this.selector.area.entity.integration) {
const filterIntegration = this.selector.area.entity?.integration;
if (
filterIntegration &&
this._entitySources?.[entity.entity_id]?.domain !== filterIntegration
) {
return false;
}
return true;
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if (!this.selector.area.device) {
return true;
}
const {
manufacturer: filterManufacturer,
model: filterModel,
integration: filterIntegration,
} = this.selector.area.device;
if (filterManufacturer && device.manufacturer !== filterManufacturer) {
return false;
}
if (filterModel && device.model !== filterModel) {
return false;
}
if (filterIntegration && this._entitySources && this._entities) {
const deviceIntegrations = this._deviceIntegrations(
this._entitySources,
this._entities
);
if (!deviceIntegrations?.[device.id]?.includes(filterIntegration)) {
return false;
}
}
return true;
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if (
this.selector.area.device?.manufacturer &&
device.manufacturer !== this.selector.area.device.manufacturer
) {
return false;
}
if (
this.selector.area.device?.model &&
device.model !== this.selector.area.device.model
) {
return false;
}
if (this.selector.area.device?.integration) {
if (
this._configEntries &&
!this._configEntries.some((entry) =>
device.config_entries.includes(entry.entry_id)
)
) {
return false;
private _deviceIntegrations = memoizeOne(
(entitySources: EntitySources, entities: EntityRegistryEntry[]) => {
const deviceIntegrations: Record<string, string[]> = {};
for (const entity of entities) {
const source = entitySources[entity.entity_id];
if (!source?.domain) {
continue;
}
if (!deviceIntegrations[entity.device_id!]) {
deviceIntegrations[entity.device_id!] = [];
}
deviceIntegrations[entity.device_id!].push(source.domain);
}
return deviceIntegrations;
}
return true;
};
);
}
declare global {

View File

@@ -1,18 +1,33 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ConfigEntry, getConfigEntries } from "../../data/config_entries";
import memoizeOne from "memoize-one";
import { ConfigEntry } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../data/entity_registry";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../data/entity_sources";
import type { DeviceSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import "../device/ha-device-picker";
import "../device/ha-devices-picker";
@customElement("ha-selector-device")
export class HaDeviceSelector extends LitElement {
export class HaDeviceSelector extends SubscribeMixin(LitElement) {
@property() public hass!: HomeAssistant;
@property() public selector!: DeviceSelector;
@state() private _entitySources?: EntitySources;
@state() private _entities?: EntityRegistryEntry[];
@property() public value?: any;
@property() public label?: string;
@@ -25,20 +40,32 @@ export class HaDeviceSelector extends LitElement {
@property({ type: Boolean }) public required = true;
protected updated(changedProperties) {
if (changedProperties.has("selector")) {
const oldSelector = changedProperties.get("selector");
if (oldSelector !== this.selector && this.selector.device?.integration) {
getConfigEntries(this.hass, {
domain: this.selector.device.integration,
}).then((entries) => {
this._configEntries = entries;
});
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entities = entities.filter((entity) => entity.device_id !== null);
}),
];
}
protected updated(changedProperties): void {
super.updated(changedProperties);
if (
changedProperties.has("selector") &&
this.selector.device.integration &&
!this._entitySources
) {
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
}
}
protected render() {
if (this.selector.device.integration && !this._entitySources) {
return html``;
}
if (!this.selector.device.multiple) {
return html`
<ha-device-picker
@@ -80,30 +107,48 @@ export class HaDeviceSelector extends LitElement {
}
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if (
this.selector.device?.manufacturer &&
device.manufacturer !== this.selector.device.manufacturer
) {
const {
manufacturer: filterManufacturer,
model: filterModel,
integration: filterIntegration,
} = this.selector.device;
if (filterManufacturer && device.manufacturer !== filterManufacturer) {
return false;
}
if (
this.selector.device?.model &&
device.model !== this.selector.device.model
) {
if (filterModel && device.model !== filterModel) {
return false;
}
if (this.selector.device?.integration) {
if (
this._configEntries &&
!this._configEntries.some((entry) =>
device.config_entries.includes(entry.entry_id)
)
) {
if (filterIntegration && this._entitySources && this._entities) {
const deviceIntegrations = this._deviceIntegrations(
this._entitySources,
this._entities
);
if (!deviceIntegrations?.[device.id]?.includes(filterIntegration)) {
return false;
}
}
return true;
};
private _deviceIntegrations = memoizeOne(
(entitySources: EntitySources, entities: EntityRegistryEntry[]) => {
const deviceIntegrations: Record<string, string[]> = {};
for (const entity of entities) {
const source = entitySources[entity.entity_id];
if (!source?.domain) {
continue;
}
if (!deviceIntegrations[entity.device_id!]) {
deviceIntegrations[entity.device_id!] = [];
}
deviceIntegrations[entity.device_id!].push(source.domain);
}
return deviceIntegrations;
}
);
}
declare global {

View File

@@ -287,9 +287,7 @@ export class HaServiceControl extends LitElement {
${shouldRenderServiceDataYaml
? html`<ha-yaml-editor
.hass=${this.hass}
.label=${this.hass.localize(
"ui.components.service-control.service_data"
)}
.label=${this.hass.localize("ui.components.service-control.data")}
.name=${"data"}
.defaultValue=${this._value?.data}
@value-changed=${this._dataChanged}

View File

@@ -569,6 +569,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
height: 16px;
--mdc-icon-size: 14px;
color: var(--secondary-text-color);
margin-inline-start: 4px !important;
margin-inline-end: -4px !important;
direction: var(--direction);
}
.mdc-chip__icon--leading {
display: flex;
@@ -578,6 +581,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
border-radius: 50%;
padding: 6px;
margin-left: -14px !important;
margin-inline-start: -14px !important;
margin-inline-end: 4px !important;
direction: var(--direction);
}
.expand-btn {
margin-right: 0;
@@ -616,10 +622,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
opacity: var(--light-disabled-opacity);
pointer-events: none;
}
:host-context([style*="direction: rtl;"]) .mdc-chip__icon {
margin-right: -14px !important;
margin-left: 4px !important;
}
`;
}
}

View File

@@ -57,6 +57,9 @@ export class HaTextField extends TextFieldBase {
.mdc-text-field__affix--suffix {
padding-left: var(--text-field-suffix-padding-left, 12px);
padding-right: var(--text-field-suffix-padding-right, 0px);
padding-inline-start: var(--text-field-suffix-padding-left, 12px);
padding-inline-end: var(--text-field-suffix-padding-right, 0px);
direction: var(--direction);
}
.mdc-text-field:not(.mdc-text-field--disabled)
@@ -92,17 +95,19 @@ export class HaTextField extends TextFieldBase {
overflow: var(--text-field-overflow);
}
:host-context([style*="direction: rtl;"]) .mdc-floating-label {
right: 10px !important;
left: initial !important;
.mdc-floating-label {
inset-inline-start: 16px !important;
inset-inline-end: initial !important;
transform-origin: var(--float-start);
direction: var(--direction);
}
:host-context([style*="direction: rtl;"])
.mdc-text-field--with-leading-icon.mdc-text-field--filled
.mdc-text-field--with-leading-icon.mdc-text-field--filled
.mdc-floating-label {
max-width: calc(100% - 48px);
right: 48px !important;
left: initial !important;
inset-inline-start: 48px !important;
inset-inline-end: initial !important;
direction: var(--direction);
}
`,
];

View File

@@ -314,9 +314,10 @@ class DialogMediaManage extends LitElement {
vertical-align: middle;
}
:host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] {
margin-left: 8px !important;
margin-right: 0px !important;
ha-svg-icon[slot="icon"] {
margin-inline-start: 0px !important;
margin-inline-end: 8px !important;
direction: var(--direction);
}
.refresh {

View File

@@ -60,9 +60,10 @@ class MediaManageButton extends LitElement {
vertical-align: middle;
}
:host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] {
margin-left: 8px;
margin-right: 0px;
ha-svg-icon[slot="icon"] {
margin-inline-start: 0px;
margin-inline-end: 8px;
direction: var(--direction);
}
`;
}

View File

@@ -1,10 +1,11 @@
import "@lit-labs/virtualizer";
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { grid } from "@lit-labs/virtualizer/layouts/grid";
import "@material/mwc-button/mwc-button";
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiArrowUpRight, mdiPlay, mdiPlus } from "@mdi/js";
import "@polymer/paper-tooltip/paper-tooltip";
import { grid } from "@lit-labs/virtualizer/layouts/grid";
import "@lit-labs/virtualizer";
import {
css,
CSSResultGroup,
@@ -21,10 +22,12 @@ import {
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTLDirection } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { getSignedPath } from "../../data/auth";
import type { MediaPlayerItem } from "../../data/media-player";
import {
browseMediaPlayer,
@@ -39,6 +42,7 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { installResizeObserver } from "../../panels/lovelace/common/install-resize-observer";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
import { documentationUrl } from "../../util/documentation-url";
import "../entity/ha-entity-picker";
import "../ha-button-menu";
@@ -49,8 +53,6 @@ import "../ha-icon-button";
import "../ha-svg-icon";
import "./ha-browse-media-tts";
import type { TtsMediaPickedEvent } from "./ha-browse-media-tts";
import { getSignedPath } from "../../data/auth";
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
declare global {
interface HASSDomEvents {
@@ -100,6 +102,10 @@ export class HaMediaPlayerBrowse extends LitElement {
@query(".content") private _content?: HTMLDivElement;
@query("lit-virtualizer") private _virtualizer?: LitVirtualizer;
private _observed = false;
private _headerOffsetHeight = 0;
private _resizeObserver?: ResizeObserver;
@@ -280,6 +286,19 @@ export class HaMediaPlayerBrowse extends LitElement {
this._animateHeaderHeight();
} else if (changedProps.has("_currentItem")) {
this._setHeaderHeight();
// This fixes a race condition for resizing of the cards using the grid layout
if (this._observed) {
return;
}
// @ts-ignore
const virtualizer = this._virtualizer?._virtualizer;
if (virtualizer) {
this._observed = true;
setTimeout(() => virtualizer._observeMutations(), 0);
}
}
}
@@ -477,6 +496,9 @@ export class HaMediaPlayerBrowse extends LitElement {
<lit-virtualizer
scroller
.items=${children}
style=${styleMap({
height: `${children.length * 72 + 26}px`,
})}
.renderItem=${this._renderListItem}
></lit-virtualizer>
${currentItem.not_shown
@@ -606,7 +628,6 @@ export class HaMediaPlayerBrowse extends LitElement {
</div>
<span class="title">${child.title}</span>
</mwc-list-item>
<li divider role="separator"></li>
`;
};

View File

@@ -120,9 +120,10 @@ class MediaUploadButton extends LitElement {
vertical-align: middle;
}
:host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] {
margin-left: 8px;
margin-right: 0px;
ha-svg-icon[slot="icon"] {
margin-inline-start: 0px;
margin-inline-end: 8px;
direction: var(--direction);
}
`;
}

View File

@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators";
import { LogbookEntry } from "../../data/logbook";
import { HomeAssistant } from "../../types";
import "./hat-logbook-note";
import "../../panels/logbook/ha-logbook";
import "../../panels/logbook/ha-logbook-renderer";
import { TraceExtended } from "../../data/trace";
@customElement("ha-trace-logbook")
@@ -19,12 +19,12 @@ export class HaTraceLogbook extends LitElement {
protected render(): TemplateResult {
return this.logbookEntries.length
? html`
<ha-logbook
<ha-logbook-renderer
relative-time
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
></ha-logbook>
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
: html`<div class="padded-box">

View File

@@ -13,7 +13,7 @@ import {
getDataFromPath,
TraceExtended,
} from "../../data/trace";
import "../../panels/logbook/ha-logbook";
import "../../panels/logbook/ha-logbook-renderer";
import { traceTabStyles } from "./trace-tab-styles";
import { HomeAssistant } from "../../types";
import type { NodeInfo } from "./hat-script-graph";
@@ -194,7 +194,7 @@ export class HaTracePathDetails extends LitElement {
// it's the last entry. Find all logbook entries after start.
const startTime = new Date(startTrace[0].timestamp);
const idx = this.logbookEntries.findIndex(
(entry) => new Date(entry.when) >= startTime
(entry) => new Date(entry.when * 1000) >= startTime
);
if (idx === -1) {
entries = [];
@@ -210,7 +210,7 @@ export class HaTracePathDetails extends LitElement {
entries = [];
for (const entry of this.logbookEntries || []) {
const entryDate = new Date(entry.when);
const entryDate = new Date(entry.when * 1000);
if (entryDate >= startTime) {
if (entryDate < endTime) {
entries.push(entry);
@@ -224,12 +224,12 @@ export class HaTracePathDetails extends LitElement {
return entries.length
? html`
<ha-logbook
<ha-logbook-renderer
relative-time
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
></ha-logbook>
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
: html`<div class="padded-box">

View File

@@ -116,7 +116,7 @@ class LogbookRenderer {
maybeRenderItem() {
const logbookEntry = this.curItem;
this.curIndex++;
const entryDate = new Date(logbookEntry.when);
const entryDate = new Date(logbookEntry.when * 1000);
if (this.pendingItems.length === 0) {
this.pendingItems.push([entryDate, logbookEntry]);
@@ -248,7 +248,7 @@ class ActionRenderer {
// Render all logbook items that are in front of this item.
while (
this.logbookRenderer.hasNext &&
new Date(this.logbookRenderer.curItem.when) < timestamp
new Date(this.logbookRenderer.curItem.when * 1000) < timestamp
) {
this.logbookRenderer.maybeRenderItem();
}

View File

@@ -0,0 +1,47 @@
import { HomeAssistant } from "../types";
export interface ApplicationCredentialsConfig {
domains: string[];
}
export interface ApplicationCredential {
id: string;
domain: string;
client_id: string;
client_secret: string;
name: string;
}
export const fetchApplicationCredentialsConfig = async (hass: HomeAssistant) =>
hass.callWS<ApplicationCredentialsConfig>({
type: "application_credentials/config",
});
export const fetchApplicationCredentials = async (hass: HomeAssistant) =>
hass.callWS<ApplicationCredential[]>({
type: "application_credentials/list",
});
export const createApplicationCredential = async (
hass: HomeAssistant,
domain: string,
clientId: string,
clientSecret: string,
name?: string
) =>
hass.callWS<ApplicationCredential>({
type: "application_credentials/create",
domain,
client_id: clientId,
client_secret: clientSecret,
name,
});
export const deleteApplicationCredential = async (
hass: HomeAssistant,
applicationCredentialsId: string
) =>
hass.callWS<void>({
type: "application_credentials/delete",
application_credentials_id: applicationCredentialsId,
});

View File

@@ -157,6 +157,7 @@ export interface CalendarTrigger extends BaseTrigger {
platform: "calendar";
event: "start" | "end";
entity_id: string;
offset: string;
}
export type Trigger =

View File

@@ -1,13 +1,13 @@
import { HassEntity } from "home-assistant-js-websocket";
import { LocalizeFunc } from "../common/translations/localize";
import { HomeAssistant } from "../types";
import {
computeHistory,
fetchRecent,
HistoryStates,
HistoryResult,
LineChartUnit,
TimelineEntity,
entityIdHistoryNeedsAttributes,
fetchRecentWS,
} from "./history";
export interface CacheConfig {
@@ -34,7 +34,7 @@ const RECENT_THRESHOLD = 60000; // 1 minute
const RECENT_CACHE: { [cacheKey: string]: RecentCacheResults } = {};
const stateHistoryCache: { [cacheKey: string]: CachedResults } = {};
// Cached type 1 unction. Without cache config.
// Cached type 1 function. Without cache config.
export const getRecent = (
hass: HomeAssistant,
entityId: string,
@@ -55,7 +55,7 @@ export const getRecent = (
}
const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId);
const prom = fetchRecent(
const prom = fetchRecentWS(
hass,
entityId,
startTime,
@@ -103,13 +103,14 @@ export const getRecentWithCache = (
language: string
) => {
const cacheKey = cacheConfig.cacheKey;
const fullCacheKey = cacheKey + `_${cacheConfig.hoursToShow}`;
const endTime = new Date();
const startTime = new Date(endTime);
startTime.setHours(startTime.getHours() - cacheConfig.hoursToShow);
let toFetchStartTime = startTime;
let appendingToCache = false;
let cache = stateHistoryCache[cacheKey + `_${cacheConfig.hoursToShow}`];
let cache = stateHistoryCache[fullCacheKey];
if (
cache &&
toFetchStartTime >= cache.startTime &&
@@ -123,7 +124,7 @@ export const getRecentWithCache = (
return cache.prom;
}
} else {
cache = stateHistoryCache[cacheKey] = getEmptyCache(
cache = stateHistoryCache[fullCacheKey] = getEmptyCache(
language,
startTime,
endTime
@@ -134,12 +135,12 @@ export const getRecentWithCache = (
const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId);
const genProm = async () => {
let fetchedHistory: HassEntity[][];
let fetchedHistory: HistoryStates;
try {
const results = await Promise.all([
curCacheProm,
fetchRecent(
fetchRecentWS(
hass,
entityId,
toFetchStartTime,
@@ -152,7 +153,7 @@ export const getRecentWithCache = (
]);
fetchedHistory = results[1];
} catch (err: any) {
delete stateHistoryCache[cacheKey];
delete stateHistoryCache[fullCacheKey];
throw err;
}
const stateHistory = computeHistory(hass, fetchedHistory, localize);

View File

@@ -1,11 +1,14 @@
import {
addDays,
addHours,
addMilliseconds,
addMonths,
differenceInDays,
endOfToday,
endOfYesterday,
startOfToday,
startOfYesterday,
} from "date-fns";
} from "date-fns/esm";
import { Collection, getCollection } from "home-assistant-js-websocket";
import { groupBy } from "../common/util/group-by";
import { subscribeOne } from "../common/util/subscribe-one";
@@ -14,9 +17,9 @@ import { ConfigEntry, getConfigEntries } from "./config_entries";
import { subscribeEntityRegistry } from "./entity_registry";
import {
fetchStatistics,
getStatisticMetadata,
Statistics,
StatisticsMetaData,
getStatisticMetadata,
} from "./history";
const energyCollectionKeys: (string | undefined)[] = [];
@@ -232,19 +235,24 @@ export const energySourcesByType = (prefs: EnergyPreferences) =>
export interface EnergyData {
start: Date;
end?: Date;
startCompare?: Date;
endCompare?: Date;
prefs: EnergyPreferences;
info: EnergyInfo;
stats: Statistics;
statsCompare: Statistics;
co2SignalConfigEntry?: ConfigEntry;
co2SignalEntity?: string;
fossilEnergyConsumption?: FossilEnergyConsumption;
fossilEnergyConsumptionCompare?: FossilEnergyConsumption;
}
const getEnergyData = async (
hass: HomeAssistant,
prefs: EnergyPreferences,
start: Date,
end?: Date
end?: Date,
compare?: boolean
): Promise<EnergyData> => {
const [configEntries, entityRegistryEntries, info] = await Promise.all([
getConfigEntries(hass, { domain: "co2signal" }),
@@ -350,6 +358,8 @@ const getEnergyData = async (
}
const dayDifference = differenceInDays(end || new Date(), start);
const period =
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour";
// Subtract 1 hour from start to get starting point data
const startMinHour = addHours(start, -1);
@@ -359,10 +369,34 @@ const getEnergyData = async (
startMinHour,
end,
statIDs,
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"
period
);
let statsCompare;
let startCompare;
let endCompare;
if (compare) {
if (dayDifference > 27 && dayDifference < 32) {
// When comparing a month, we want to start at the begining of the month
startCompare = addMonths(start, -1);
} else {
startCompare = addDays(start, (dayDifference + 1) * -1);
}
const compareStartMinHour = addHours(startCompare, -1);
endCompare = addMilliseconds(start, -1);
statsCompare = await fetchStatistics(
hass!,
compareStartMinHour,
endCompare,
statIDs,
period
);
}
let fossilEnergyConsumption: FossilEnergyConsumption | undefined;
let fossilEnergyConsumptionCompare: FossilEnergyConsumption | undefined;
if (co2SignalEntity !== undefined) {
fossilEnergyConsumption = await getFossilEnergyConsumption(
@@ -373,6 +407,16 @@ const getEnergyData = async (
end,
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"
);
if (compare) {
fossilEnergyConsumptionCompare = await getFossilEnergyConsumption(
hass!,
startCompare,
consumptionStatIDs,
co2SignalEntity,
endCompare,
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"
);
}
}
Object.values(stats).forEach((stat) => {
@@ -388,15 +432,19 @@ const getEnergyData = async (
}
});
const data = {
const data: EnergyData = {
start,
end,
startCompare,
endCompare,
info,
prefs,
stats,
statsCompare,
co2SignalConfigEntry,
co2SignalEntity,
fossilEnergyConsumption,
fossilEnergyConsumptionCompare,
};
return data;
@@ -405,9 +453,11 @@ const getEnergyData = async (
export interface EnergyCollection extends Collection<EnergyData> {
start: Date;
end?: Date;
compare?: boolean;
prefs?: EnergyPreferences;
clearPrefs(): void;
setPeriod(newStart: Date, newEnd?: Date): void;
setCompare(compare: boolean): void;
_refreshTimeout?: number;
_updatePeriodTimeout?: number;
_active: number;
@@ -478,7 +528,8 @@ export const getEnergyDataCollection = (
hass,
collection.prefs,
collection.start,
collection.end
collection.end,
collection.compare
);
}
) as EnergyCollection;
@@ -534,6 +585,9 @@ export const getEnergyDataCollection = (
collection._updatePeriodTimeout = undefined;
}
};
collection.setCompare = (compare: boolean) => {
collection.compare = compare;
};
return collection;
};

View File

@@ -20,3 +20,20 @@ export const BOARD_NAMES: Record<string, string> = {
"intel-nuc": "Intel NUC",
yellow: "Home Assistant Yellow",
};
export interface HardwareInfo {
hardware: HardwareInfoEntry[];
}
export interface HardwareInfoEntry {
board: HardwareInfoBoardInfo;
name: string;
url?: string;
}
export interface HardwareInfoBoardInfo {
manufacturer: string;
model?: string;
revision?: string;
hassio_board_id?: string;
}

View File

@@ -1,8 +1,7 @@
import { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDisplay } from "../common/entity/compute_state_display";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
import { LocalizeFunc } from "../common/translations/localize";
import { HomeAssistant } from "../types";
import { FrontendLocaleData } from "./translation";
@@ -27,7 +26,7 @@ const LINE_ATTRIBUTES_TO_KEEP = [
export interface LineChartState {
state: string;
last_changed: string;
last_changed: number;
attributes?: Record<string, any>;
}
@@ -47,7 +46,7 @@ export interface LineChartUnit {
export interface TimelineState {
state_localize: string;
state: string;
last_changed: string;
last_changed: number;
}
export interface TimelineEntity {
@@ -141,6 +140,21 @@ export interface StatisticsValidationResults {
[statisticId: string]: StatisticsValidationResult[];
}
export interface HistoryStates {
[entityId: string]: EntityHistoryState[];
}
interface EntityHistoryState {
/** state */
s: string;
/** attributes */
a: { [key: string]: any };
/** last_changed; if set, also applies to lu */
lc: number;
/** last_updated */
lu: number;
}
export const entityIdHistoryNeedsAttributes = (
hass: HomeAssistant,
entityId: string
@@ -181,6 +195,27 @@ export const fetchRecent = (
return hass.callApi("GET", url);
};
export const fetchRecentWS = (
hass: HomeAssistant,
entityId: string, // This may be CSV
startTime: Date,
endTime: Date,
skipInitialState = false,
significantChangesOnly?: boolean,
minimalResponse = true,
noAttributes?: boolean
) =>
hass.callWS<HistoryStates>({
type: "history/history_during_period",
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
significant_changes_only: significantChangesOnly || false,
include_start_time_state: !skipInitialState,
minimal_response: minimalResponse,
no_attributes: noAttributes || false,
entity_ids: entityId.split(","),
});
export const fetchDate = (
hass: HomeAssistant,
startTime: Date,
@@ -198,6 +233,27 @@ export const fetchDate = (
}`
);
export const fetchDateWS = (
hass: HomeAssistant,
startTime: Date,
endTime: Date,
entityId?: string
) => {
const params = {
type: "history/history_during_period",
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
minimal_response: true,
no_attributes: !!(
entityId && !entityIdHistoryNeedsAttributes(hass, entityId)
),
};
if (entityId) {
return hass.callWS<HistoryStates>({ ...params, entity_ids: [entityId] });
}
return hass.callWS<HistoryStates>(params);
};
const equalState = (obj1: LineChartState, obj2: LineChartState) =>
obj1.state === obj2.state &&
// Only compare attributes if both states have an attributes object.
@@ -212,46 +268,47 @@ const equalState = (obj1: LineChartState, obj2: LineChartState) =>
const processTimelineEntity = (
localize: LocalizeFunc,
language: FrontendLocaleData,
states: HassEntity[]
entityId: string,
states: EntityHistoryState[]
): TimelineEntity => {
const data: TimelineState[] = [];
const last_element = states.length - 1;
const first: EntityHistoryState = states[0];
for (const state of states) {
if (data.length > 0 && state.state === data[data.length - 1].state) {
if (data.length > 0 && state.s === data[data.length - 1].state) {
continue;
}
// Copy the data from the last element as its the newest
// and is only needed to localize the data
if (!state.entity_id) {
state.attributes = states[last_element].attributes;
state.entity_id = states[last_element].entity_id;
}
data.push({
state_localize: computeStateDisplay(localize, state, language),
state: state.state,
last_changed: state.last_changed,
state_localize: computeStateDisplayFromEntityAttributes(
localize,
language,
entityId,
state.a || first.a,
state.s
),
state: state.s,
// lc (last_changed) may be omitted if its the same
// as lu (last_updated).
last_changed: (state.lc ? state.lc : state.lu) * 1000,
});
}
return {
name: computeStateName(states[0]),
entity_id: states[0].entity_id,
name: computeStateNameFromEntityAttributes(entityId, states[0].a),
entity_id: entityId,
data,
};
};
const processLineChartEntities = (
unit,
entities: HassEntity[][]
entities: HistoryStates
): LineChartUnit => {
const data: LineChartEntity[] = [];
for (const states of entities) {
const last: HassEntity = states[states.length - 1];
const domain = computeStateDomain(last);
Object.keys(entities).forEach((entityId) => {
const states = entities[entityId];
const first: EntityHistoryState = states[0];
const domain = computeDomain(entityId);
const processedStates: LineChartState[] = [];
for (const state of states) {
@@ -259,18 +316,24 @@ const processLineChartEntities = (
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
processedState = {
state: state.state,
last_changed: state.last_updated,
state: state.s,
last_changed: state.lu * 1000,
attributes: {},
};
for (const attr of LINE_ATTRIBUTES_TO_KEEP) {
if (attr in state.attributes) {
processedState.attributes![attr] = state.attributes[attr];
if (attr in state.a) {
processedState.attributes![attr] = state.a[attr];
}
}
} else {
processedState = state;
processedState = {
state: state.s,
// lc (last_changed) may be omitted if its the same
// as lu (last_updated).
last_changed: (state.lc ? state.lc : state.lu) * 1000,
attributes: {},
};
}
if (
@@ -289,52 +352,53 @@ const processLineChartEntities = (
data.push({
domain,
name: computeStateName(last),
entity_id: last.entity_id,
name: computeStateNameFromEntityAttributes(entityId, first.a),
entity_id: entityId,
states: processedStates,
});
}
});
return {
unit,
identifier: entities.map((states) => states[0].entity_id).join(""),
identifier: Object.keys(entities).join(""),
data,
};
};
const stateUsesUnits = (state: HassEntity) =>
"unit_of_measurement" in state.attributes ||
"state_class" in state.attributes;
attributesHaveUnits(state.attributes);
const attributesHaveUnits = (attributes: { [key: string]: any }) =>
"unit_of_measurement" in attributes || "state_class" in attributes;
export const computeHistory = (
hass: HomeAssistant,
stateHistory: HassEntity[][],
stateHistory: HistoryStates,
localize: LocalizeFunc
): HistoryResult => {
const lineChartDevices: { [unit: string]: HassEntity[][] } = {};
const lineChartDevices: { [unit: string]: HistoryStates } = {};
const timelineDevices: TimelineEntity[] = [];
if (!stateHistory) {
return { line: [], timeline: [] };
}
stateHistory.forEach((stateInfo) => {
Object.keys(stateHistory).forEach((entityId) => {
const stateInfo = stateHistory[entityId];
if (stateInfo.length === 0) {
return;
}
const entityId = stateInfo[0].entity_id;
const currentState =
entityId in hass.states ? hass.states[entityId] : undefined;
const stateWithUnitorStateClass =
!currentState &&
stateInfo.find((state) => state.attributes && stateUsesUnits(state));
stateInfo.find((state) => state.a && attributesHaveUnits(state.a));
let unit: string | undefined;
if (currentState && stateUsesUnits(currentState)) {
unit = currentState.attributes.unit_of_measurement || " ";
} else if (stateWithUnitorStateClass) {
unit = stateWithUnitorStateClass.attributes.unit_of_measurement || " ";
unit = stateWithUnitorStateClass.a.unit_of_measurement || " ";
} else {
unit = {
climate: hass.config.unit_system.temperature,
@@ -348,12 +412,15 @@ export const computeHistory = (
if (!unit) {
timelineDevices.push(
processTimelineEntity(localize, hass.locale, stateInfo)
processTimelineEntity(localize, hass.locale, entityId, stateInfo)
);
} else if (unit in lineChartDevices) {
lineChartDevices[unit].push(stateInfo);
} else if (unit in lineChartDevices && entityId in lineChartDevices[unit]) {
lineChartDevices[unit][entityId].push(...stateInfo);
} else {
lineChartDevices[unit] = [stateInfo];
if (!(unit in lineChartDevices)) {
lineChartDevices[unit] = {};
}
lineChartDevices[unit][entityId] = stateInfo;
}
});

View File

@@ -42,8 +42,18 @@ export const domainToName = (
manifest?: IntegrationManifest
) => localize(`component.${domain}.title`) || manifest?.name || domain;
export const fetchIntegrationManifests = (hass: HomeAssistant) =>
hass.callWS<IntegrationManifest[]>({ type: "manifest/list" });
export const fetchIntegrationManifests = (
hass: HomeAssistant,
integrations?: string[]
) => {
const params: any = {
type: "manifest/list",
};
if (integrations) {
params.integrations = integrations;
}
return hass.callWS<IntegrationManifest[]>(params);
};
export const fetchIntegrationManifest = (
hass: HomeAssistant,

View File

@@ -1,5 +1,9 @@
import { HassEntity } from "home-assistant-js-websocket";
import { BINARY_STATE_OFF, BINARY_STATE_ON } from "../common/const";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import {
BINARY_STATE_OFF,
BINARY_STATE_ON,
DOMAINS_WITH_DYNAMIC_PICTURE,
} from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDisplay } from "../common/entity/compute_state_display";
import { LocalizeFunc } from "../common/translations/localize";
@@ -9,25 +13,51 @@ import { UNAVAILABLE_STATES } from "./entity";
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
export const CONTINUOUS_DOMAINS = ["proximity", "sensor"];
export interface LogbookStreamMessage {
events: LogbookEntry[];
start_time?: number; // Start time of this historical chunk
end_time?: number; // End time of this historical chunk
partial?: boolean; // Indiciates more historical chunks are coming
}
export interface LogbookEntry {
when: string;
// Base data
when: number; // Python timestamp. Do *1000 to get JS timestamp.
name: string;
message?: string;
entity_id?: string;
icon?: string;
source?: string;
source?: string; // The trigger source
domain?: string;
state?: string; // The state of the entity
// Context data
context_id?: string;
context_user_id?: string;
context_event_type?: string;
context_domain?: string;
context_service?: string;
context_service?: string; // Service calls only
context_entity_id?: string;
context_entity_id_name?: string;
context_entity_id_name?: string; // Legacy, not longer sent
context_name?: string;
state?: string;
context_state?: string; // The state of the entity
context_source?: string; // The trigger source
context_message?: string;
}
//
// Localization mapping for all the triggers in core
// in homeassistant.components.homeassistant.triggers
//
const triggerPhrases = {
"numeric state of": "triggered_by_numeric_state_of", // number state trigger
"state of": "triggered_by_state_of", // state trigger
event: "triggered_by_event", // event trigger
time: "triggered_by_time", // time trigger
"time pattern": "triggered_by_time_pattern", // time trigger
"Home Assistant stopping": "triggered_by_homeassistant_stopping", // stop event
"Home Assistant starting": "triggered_by_homeassistant_starting", // start event
};
const DATA_CACHE: {
[cacheKey: string]: { [entityId: string]: Promise<LogbookEntry[]> };
} = {};
@@ -37,18 +67,13 @@ export const getLogbookDataForContext = async (
startDate: string,
contextId?: string
): Promise<LogbookEntry[]> => {
const localize = await hass.loadBackendTranslation("device_class");
return addLogbookMessage(
await hass.loadBackendTranslation("device_class");
return getLogbookDataFromServer(
hass,
localize,
await getLogbookDataFromServer(
hass,
startDate,
undefined,
undefined,
undefined,
contextId
)
startDate,
undefined,
undefined,
contextId
);
};
@@ -56,107 +81,123 @@ export const getLogbookData = async (
hass: HomeAssistant,
startDate: string,
endDate: string,
entityId?: string,
entity_matches_only?: boolean
entityIds?: string[],
deviceIds?: string[]
): Promise<LogbookEntry[]> => {
const localize = await hass.loadBackendTranslation("device_class");
return addLogbookMessage(
hass,
localize,
await getLogbookDataCache(
hass,
startDate,
endDate,
entityId,
entity_matches_only
)
);
};
export const addLogbookMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,
logbookData: LogbookEntry[]
): LogbookEntry[] => {
for (const entry of logbookData) {
const stateObj = hass!.states[entry.entity_id!];
if (entry.state && stateObj) {
entry.message = getLogbookMessage(
await hass.loadBackendTranslation("device_class");
return deviceIds?.length
? getLogbookDataFromServer(
hass,
localize,
entry.state,
stateObj,
computeDomain(entry.entity_id!)
);
}
}
return logbookData;
startDate,
endDate,
entityIds,
undefined,
deviceIds
)
: getLogbookDataCache(hass, startDate, endDate, entityIds);
};
export const getLogbookDataCache = async (
const getLogbookDataCache = async (
hass: HomeAssistant,
startDate: string,
endDate: string,
entityId?: string,
entity_matches_only?: boolean
entityId?: string[]
) => {
const ALL_ENTITIES = "*";
if (!entityId) {
entityId = ALL_ENTITIES;
}
const entityIdKey = entityId ? entityId.toString() : ALL_ENTITIES;
const cacheKey = `${startDate}${endDate}`;
if (!DATA_CACHE[cacheKey]) {
DATA_CACHE[cacheKey] = {};
}
if (entityId in DATA_CACHE[cacheKey]) {
return DATA_CACHE[cacheKey][entityId];
if (entityIdKey in DATA_CACHE[cacheKey]) {
return DATA_CACHE[cacheKey][entityIdKey];
}
if (entityId !== ALL_ENTITIES && DATA_CACHE[cacheKey][ALL_ENTITIES]) {
if (entityId && DATA_CACHE[cacheKey][ALL_ENTITIES]) {
const entities = await DATA_CACHE[cacheKey][ALL_ENTITIES];
return entities.filter((entity) => entity.entity_id === entityId);
return entities.filter(
(entity) => entity.entity_id && entityId.includes(entity.entity_id)
);
}
DATA_CACHE[cacheKey][entityId] = getLogbookDataFromServer(
DATA_CACHE[cacheKey][entityIdKey] = getLogbookDataFromServer(
hass,
startDate,
endDate,
entityId !== ALL_ENTITIES ? entityId : undefined,
entity_matches_only
).then((entries) => entries.reverse());
return DATA_CACHE[cacheKey][entityId];
entityId
);
return DATA_CACHE[cacheKey][entityIdKey];
};
const getLogbookDataFromServer = async (
const getLogbookDataFromServer = (
hass: HomeAssistant,
startDate: string,
endDate?: string,
entityId?: string,
entitymatchesOnly?: boolean,
contextId?: string
) => {
const params = new URLSearchParams();
entityIds?: string[],
contextId?: string,
deviceIds?: string[]
): Promise<LogbookEntry[]> => {
// If all specified filters are empty lists, we can return an empty list.
if (
(entityIds || deviceIds) &&
(!entityIds || entityIds.length === 0) &&
(!deviceIds || deviceIds.length === 0)
) {
return Promise.resolve([]);
}
const params: any = {
type: "logbook/get_events",
start_time: startDate,
};
if (endDate) {
params.append("end_time", endDate);
params.end_time = endDate;
}
if (entityId) {
params.append("entity", entityId);
if (entityIds?.length) {
params.entity_ids = entityIds;
}
if (entitymatchesOnly) {
params.append("entity_matches_only", "");
if (deviceIds?.length) {
params.device_ids = deviceIds;
}
if (contextId) {
params.append("context_id", contextId);
params.context_id = contextId;
}
return hass.callWS<LogbookEntry[]>(params);
};
return hass.callApi<LogbookEntry[]>(
"GET",
`logbook/${startDate}?${params.toString()}`
export const subscribeLogbook = (
hass: HomeAssistant,
callbackFunction: (message: LogbookStreamMessage) => void,
startDate: string,
endDate: string,
entityIds?: string[],
deviceIds?: string[]
): Promise<UnsubscribeFunc> => {
// If all specified filters are empty lists, we can return an empty list.
if (
(entityIds || deviceIds) &&
(!entityIds || entityIds.length === 0) &&
(!deviceIds || deviceIds.length === 0)
) {
return Promise.reject("No entities or devices");
}
const params: any = {
type: "logbook/event_stream",
start_time: startDate,
end_time: endDate,
};
if (entityIds?.length) {
params.entity_ids = entityIds;
}
if (deviceIds?.length) {
params.device_ids = deviceIds;
}
return hass.connection.subscribeMessage<LogbookStreamMessage>(
(message) => callbackFunction(message),
params
);
};
@@ -164,7 +205,49 @@ export const clearLogbookCache = (startDate: string, endDate: string) => {
DATA_CACHE[`${startDate}${endDate}`] = {};
};
export const getLogbookMessage = (
export const createHistoricState = (
currentStateObj: HassEntity,
state?: string
): HassEntity => <HassEntity>(<unknown>{
entity_id: currentStateObj.entity_id,
state: state,
attributes: {
// Rebuild the historical state by copying static attributes only
device_class: currentStateObj?.attributes.device_class,
source_type: currentStateObj?.attributes.source_type,
has_date: currentStateObj?.attributes.has_date,
has_time: currentStateObj?.attributes.has_time,
// We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering,
// as they would present a false state in the log (played media right now vs actual historic data).
entity_picture_local: DOMAINS_WITH_DYNAMIC_PICTURE.has(
computeDomain(currentStateObj.entity_id)
)
? undefined
: currentStateObj?.attributes.entity_picture_local,
entity_picture: DOMAINS_WITH_DYNAMIC_PICTURE.has(
computeDomain(currentStateObj.entity_id)
)
? undefined
: currentStateObj?.attributes.entity_picture,
},
});
export const localizeTriggerSource = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhrase in triggerPhrases) {
if (source.startsWith(triggerPhrase)) {
return source.replace(
triggerPhrase,
`${localize(`ui.components.logbook.${triggerPhrases[triggerPhrase]}`)}`
);
}
}
return source;
};
export const localizeStateMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,
state: string,

View File

@@ -131,9 +131,9 @@ export interface CallServiceActionConfig extends BaseActionConfig {
action: "call-service";
service: string;
target?: HassServiceTarget;
service_data?: {
[key: string]: any;
};
// "service_data" is kept for backwards compatibility. Replaced by "data".
service_data?: Record<string, unknown>;
data?: Record<string, unknown>;
}
export interface NavigateActionConfig extends BaseActionConfig {

View File

@@ -47,12 +47,17 @@ export interface SceneConfig {
name: string;
icon?: string;
entities: SceneEntities;
metadata?: SceneMetaData;
}
export interface SceneEntities {
[entityId: string]: string | { state: string; [key: string]: any };
}
export interface SceneMetaData {
[entityId: string]: { entity_only?: boolean | undefined };
}
export const activateScene = (
hass: HomeAssistant,
entityId: string

View File

@@ -52,7 +52,7 @@ export const getHassTranslations = async (
hass: HomeAssistant,
language: string,
category: TranslationCategory,
integration?: string,
integration?: string | string[],
config_flow?: boolean
): Promise<Record<string, unknown>> => {
const result = await hass.callWS<{ resources: Record<string, unknown> }>({

View File

@@ -2,10 +2,15 @@ import type {
HassEntities,
HassEntityAttributeBase,
HassEntityBase,
HassEvent,
} from "home-assistant-js-websocket";
import { BINARY_STATE_ON } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
import {
supportsFeature,
supportsFeatureFromAttributes,
} from "../common/entity/supports-feature";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { HomeAssistant } from "../types";
@@ -33,8 +38,13 @@ export interface UpdateEntity extends HassEntityBase {
}
export const updateUsesProgress = (entity: UpdateEntity): boolean =>
supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) &&
typeof entity.attributes.in_progress === "number";
updateUsesProgressFromAttributes(entity.attributes);
export const updateUsesProgressFromAttributes = (attributes: {
[key: string]: any;
}): boolean =>
supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS) &&
typeof attributes.in_progress === "number";
export const updateCanInstall = (
entity: UpdateEntity,
@@ -47,6 +57,11 @@ export const updateCanInstall = (
export const updateIsInstalling = (entity: UpdateEntity): boolean =>
updateUsesProgress(entity) || !!entity.attributes.in_progress;
export const updateIsInstallingFromAttributes = (attributes: {
[key: string]: any;
}): boolean =>
updateUsesProgressFromAttributes(attributes) || !!attributes.in_progress;
export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
hass.callWS<string | null>({
type: "update/release_notes",
@@ -110,15 +125,32 @@ export const checkForEntityUpdates = async (
return;
}
let updated = 0;
const unsubscribeEvents = await hass.connection.subscribeEvents<HassEvent>(
(event) => {
if (computeDomain(event.data.entity_id) === "update") {
updated++;
showToast(element, {
message: hass.localize("ui.panel.config.updates.updates_refreshed", {
count: updated,
}),
});
}
},
"state_changed"
);
await hass.callService("homeassistant", "update_entity", {
entity_id: entities,
});
if (filterUpdateEntitiesWithInstall(hass.states).length) {
showToast(element, {
message: hass.localize("ui.panel.config.updates.updates_refreshed"),
});
} else {
// there is no reliable way to know if all the updates are done updating, so we just wait a bit for now...
await new Promise((r) => setTimeout(r, 10000));
unsubscribeEvents();
if (updated === 0) {
showToast(element, {
message: hass.localize("ui.panel.config.updates.no_new_updates"),
});

View File

@@ -2,9 +2,21 @@ import {
mdiAlertCircleOutline,
mdiGauge,
mdiWaterPercent,
mdiWeatherCloudy,
mdiWeatherFog,
mdiWeatherHail,
mdiWeatherLightning,
mdiWeatherLightningRainy,
mdiWeatherNight,
mdiWeatherNightPartlyCloudy,
mdiWeatherPartlyCloudy,
mdiWeatherPouring,
mdiWeatherRainy,
mdiWeatherSnowy,
mdiWeatherSnowyRainy,
mdiWeatherSunny,
mdiWeatherWindy,
mdiWeatherWindyVariant,
} from "@mdi/js";
import {
HassEntityAttributeBase,
@@ -57,7 +69,21 @@ export const weatherSVGs = new Set<string>([
]);
export const weatherIcons = {
"clear-night": mdiWeatherNight,
cloudy: mdiWeatherCloudy,
exceptional: mdiAlertCircleOutline,
fog: mdiWeatherFog,
hail: mdiWeatherHail,
lightning: mdiWeatherLightning,
"lightning-rainy": mdiWeatherLightningRainy,
partlycloudy: mdiWeatherPartlyCloudy,
pouring: mdiWeatherPouring,
rainy: mdiWeatherRainy,
snowy: mdiWeatherSnowy,
"snowy-rainy": mdiWeatherSnowyRainy,
sunny: mdiWeatherSunny,
windy: mdiWeatherWindy,
"windy-variant": mdiWeatherWindyVariant,
};
export const weatherAttrIcons = {
@@ -437,6 +463,13 @@ export const getWeatherStateIcon = (
return undefined;
};
export const weatherIcon = (state?: string, nightTime?: boolean): string =>
!state
? undefined
: nightTime && state === "partlycloudy"
? mdiWeatherNightPartlyCloudy
: weatherIcons[state];
const DAY_IN_MILLISECONDS = 86400000;
export const isForecastHourly = (

View File

@@ -145,7 +145,7 @@ export interface ZWaveJSController {
supports_timers: boolean;
is_heal_network_active: boolean;
inclusion_state: InclusionState;
nodes: number[];
nodes: ZWaveJSNodeStatus[];
}
export interface ZWaveJSNodeStatus {
@@ -167,6 +167,9 @@ export interface ZwaveJSNodeMetadata {
wakeup: string;
reset: string;
device_database_url: string;
}
export interface ZwaveJSNodeComments {
comments: ZWaveJSNodeComment[];
}
@@ -200,8 +203,7 @@ export interface ZWaveJSNodeConfigParamMetadata {
export interface ZWaveJSSetConfigParamData {
type: string;
entry_id: string;
node_id: number;
device_id: string;
property: number;
property_key?: number;
value: string | number;
@@ -228,6 +230,20 @@ export interface ZWaveJSHealNetworkStatusMessage {
heal_node_status: { [key: number]: string };
}
export interface ZWaveJSControllerStatisticsUpdatedMessage {
event: "statistics updated";
source: "controller";
messages_tx: number;
messages_rx: number;
messages_dropped_tx: number;
messages_dropped_rx: number;
nak: number;
can: number;
timeout_ack: number;
timeout_response: number;
timeout_callback: number;
}
export interface ZWaveJSRemovedNode {
node_id: number;
manufacturer: string;
@@ -285,12 +301,23 @@ export const migrateZwave = (
export const fetchZwaveNetworkStatus = (
hass: HomeAssistant,
entry_id: string
): Promise<ZWaveJSNetwork> =>
hass.callWS({
device_or_entry_id: {
device_id?: string;
entry_id?: string;
}
): Promise<ZWaveJSNetwork> => {
if (device_or_entry_id.device_id && device_or_entry_id.entry_id) {
throw new Error("Only one of device or entry ID should be supplied.");
}
if (!device_or_entry_id.device_id && !device_or_entry_id.entry_id) {
throw new Error("Either device or entry ID should be supplied.");
}
return hass.callWS({
type: "zwave_js/network_status",
entry_id,
device_id: device_or_entry_id.device_id,
entry_id: device_or_entry_id.entry_id,
});
};
export const fetchZwaveDataCollectionStatus = (
hass: HomeAssistant,
@@ -427,49 +454,50 @@ export const unprovisionZwaveSmartStartNode = (
export const fetchZwaveNodeStatus = (
hass: HomeAssistant,
entry_id: string,
node_id: number
device_id: string
): Promise<ZWaveJSNodeStatus> =>
hass.callWS({
type: "zwave_js/node_status",
entry_id,
node_id,
device_id,
});
export const fetchZwaveNodeMetadata = (
hass: HomeAssistant,
entry_id: string,
node_id: number
device_id: string
): Promise<ZwaveJSNodeMetadata> =>
hass.callWS({
type: "zwave_js/node_metadata",
entry_id,
node_id,
device_id,
});
export const fetchZwaveNodeComments = (
hass: HomeAssistant,
device_id: string
): Promise<ZwaveJSNodeComments> =>
hass.callWS({
type: "zwave_js/node_comments",
device_id,
});
export const fetchZwaveNodeConfigParameters = (
hass: HomeAssistant,
entry_id: string,
node_id: number
device_id: string
): Promise<ZWaveJSNodeConfigParams> =>
hass.callWS({
type: "zwave_js/get_config_parameters",
entry_id,
node_id,
device_id,
});
export const setZwaveNodeConfigParameter = (
hass: HomeAssistant,
entry_id: string,
node_id: number,
device_id: string,
property: number,
value: number,
property_key?: number
): Promise<ZWaveJSSetConfigParamResult> => {
const data: ZWaveJSSetConfigParamData = {
type: "zwave_js/set_config_parameter",
entry_id,
node_id,
device_id,
property,
value,
property_key,
@@ -479,42 +507,36 @@ export const setZwaveNodeConfigParameter = (
export const reinterviewZwaveNode = (
hass: HomeAssistant,
entry_id: string,
node_id: number,
device_id: string,
callbackFunction: (message: ZWaveJSRefreshNodeStatusMessage) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(
(message: any) => callbackFunction(message),
{
type: "zwave_js/refresh_node_info",
entry_id,
node_id,
device_id,
}
);
export const healZwaveNode = (
hass: HomeAssistant,
entry_id: string,
node_id: number
device_id: string
): Promise<boolean> =>
hass.callWS({
type: "zwave_js/heal_node",
entry_id,
node_id,
device_id,
});
export const removeFailedZwaveNode = (
hass: HomeAssistant,
entry_id: string,
node_id: number,
device_id: string,
callbackFunction: (message: any) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(
(message: any) => callbackFunction(message),
{
type: "zwave_js/remove_failed_node",
entry_id,
node_id,
device_id,
}
);
@@ -538,16 +560,14 @@ export const stopHealZwaveNetwork = (
export const subscribeZwaveNodeReady = (
hass: HomeAssistant,
entry_id: string,
node_id: number,
device_id: string,
callbackFunction: (message) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(
(message: any) => callbackFunction(message),
{
type: "zwave_js/node_ready",
entry_id,
node_id,
device_id,
}
);
@@ -564,6 +584,19 @@ export const subscribeHealZwaveNetworkProgress = (
}
);
export const subscribeZwaveControllerStatistics = (
hass: HomeAssistant,
entry_id: string,
callbackFunction: (message: ZWaveJSControllerStatisticsUpdatedMessage) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(
(message: any) => callbackFunction(message),
{
type: "zwave_js/subscribe_controller_statistics",
entry_id,
}
);
export const getZwaveJsIdentifiersFromDevice = (
device: DeviceRegistryEntry
): ZWaveJSNodeIdentifiers | undefined => {

View File

@@ -309,7 +309,7 @@ class DataEntryFlowDialog extends LitElement {
: this._step.type === "abort"
? html`
<step-flow-abort
.flowConfig=${this._params.flowConfig}
.params=${this._params}
.step=${this._step}
.hass=${this.hass}
.domain=${this._step.handler}
@@ -518,10 +518,9 @@ class DataEntryFlowDialog extends LitElement {
position: absolute;
top: 0;
right: 0;
}
:host-context([style*="direction: rtl;"]) .dialog-actions {
right: auto;
left: 0;
inset-inline-start: initial;
inset-inline-end: 0px;
direction: var(--direction);
}
.dialog-actions > * {
color: var(--secondary-text-color);

View File

@@ -131,6 +131,7 @@ export interface DataEntryFlowDialogParams {
}) => void;
flowConfig: FlowConfig;
showAdvanced?: boolean;
dialogParentElement?: HTMLElement;
}
export const loadDataEntryFlowDialog = () => import("./dialog-data-entry-flow");
@@ -146,6 +147,7 @@ export const showFlowDialog = (
dialogParams: {
...dialogParams,
flowConfig,
dialogParentElement: element,
},
});
};

View File

@@ -1,15 +1,25 @@
import "@material/mwc-button";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import {
CSSResultGroup,
html,
LitElement,
TemplateResult,
PropertyValues,
} from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { DataEntryFlowStepAbort } from "../../data/data_entry_flow";
import { HomeAssistant } from "../../types";
import { FlowConfig } from "./show-dialog-data-entry-flow";
import { showAddApplicationCredentialDialog } from "../../panels/config/application_credentials/show-dialog-add-application-credential";
import { configFlowContentStyles } from "./styles";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { domainToName } from "../../data/integration";
import { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import { showConfigFlowDialog } from "./show-dialog-config-flow";
@customElement("step-flow-abort")
class StepFlowAbort extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig;
@property({ attribute: false }) public params!: DataEntryFlowDialogParams;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -17,11 +27,21 @@ class StepFlowAbort extends LitElement {
@property({ attribute: false }) public domain!: string;
protected firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
if (this.step.reason === "missing_credentials") {
this._handleMissingCreds();
}
}
protected render(): TemplateResult {
if (this.step.reason === "missing_credentials") {
return html``;
}
return html`
<h2>${this.hass.localize(`component.${this.domain}.title`)}</h2>
<div class="content">
${this.flowConfig.renderAbortDescription(this.hass, this.step)}
${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
</div>
<div class="buttons">
<mwc-button @click=${this._flowDone}
@@ -33,6 +53,32 @@ class StepFlowAbort extends LitElement {
`;
}
private async _handleMissingCreds() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.missing_credentials",
{
integration: domainToName(this.hass.localize, this.domain),
}
),
});
this._flowDone();
if (!confirm) {
return;
}
// Prompt to enter credentials and restart integration setup
showAddApplicationCredentialDialog(this.params.dialogParentElement!, {
selectedDomain: this.domain,
applicationCredentialAddedCallback: () => {
showConfigFlowDialog(this.params.dialogParentElement!, {
dialogClosedCallback: this.params.dialogClosedCallback,
startFlowHandler: this.domain,
showAdvanced: this.hass.userData?.showAdvanced,
});
},
});
}
private _flowDone(): void {
fireEvent(this, "flow-update", { step: undefined });
}

View File

@@ -192,11 +192,8 @@ class StepFlowForm extends LitElement {
}
h2 {
word-break: break-word;
padding-right: 72px;
}
:host-context([style*="direction: rtl;"]) h2 {
padding-right: auto !important;
padding-left: 72px !important;
padding-inline-end: 72px;
direction: var(--direction);
}
`,
];

View File

@@ -104,11 +104,8 @@ class StepFlowPickFlow extends LitElement {
margin: 16px 0;
}
h2 {
padding-right: 66px;
}
:host-context([style*="direction: rtl;"]) h2 {
padding-right: auto !important;
padding-left: 66px !important;
padding-inline-end: 66px;
direction: var(--direction);
}
@media all and (max-height: 900px) {
div {

View File

@@ -311,11 +311,8 @@ class StepFlowPickHandler extends LitElement {
border-bottom-color: var(--divider-color);
}
h2 {
padding-right: 66px;
}
:host-context([style*="direction: rtl;"]) h2 {
padding-right: auto !important;
padding-left: 66px !important;
padding-inline-end: 66px;
direction: var(--direction);
}
@media all and (max-height: 900px) {
mwc-list {

View File

@@ -3,7 +3,11 @@ import { css } from "lit";
export const configFlowContentStyles = css`
h2 {
margin: 24px 38px 0 0;
margin-inline-start: 0px;
margin-inline-end: 38px;
padding: 0 24px;
padding-inline-start: 24px;
padding-inline-end: 24px;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-family: var(

View File

@@ -1,12 +1,13 @@
import "@material/mwc-button/mwc-button";
import { mdiAlertOutline } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import "../../components/ha-switch";
import "../../components/ha-textfield";
import { HaTextField } from "../../components/ha-textfield";
import { haStyleDialog } from "../../resources/styles";
import { HomeAssistant } from "../../types";
import { DialogBoxParams } from "./show-dialog-box";
@@ -17,13 +18,10 @@ class DialogBox extends LitElement {
@state() private _params?: DialogBoxParams;
@state() private _value?: string;
@query("ha-textfield") private _textField?: HaTextField;
public async showDialog(params: DialogBoxParams): Promise<void> {
this._params = params;
if (params.prompt) {
this._value = params.defaultValue;
}
}
public closeDialog(): boolean {
@@ -75,9 +73,7 @@ class DialogBox extends LitElement {
? html`
<ha-textfield
dialogInitialFocus
.value=${this._value || ""}
@keyup=${this._handleKeyUp}
@input=${this._valueChanged}
value=${ifDefined(this._params.defaultValue)}
.label=${this._params.inputLabel
? this._params.inputLabel
: ""}
@@ -109,10 +105,6 @@ class DialogBox extends LitElement {
`;
}
private _valueChanged(ev) {
this._value = ev.target.value;
}
private _dismiss(): void {
if (this._params?.cancel) {
this._params.cancel();
@@ -120,15 +112,9 @@ class DialogBox extends LitElement {
this._close();
}
private _handleKeyUp(ev: KeyboardEvent) {
if (ev.keyCode === 13) {
this._confirm();
}
}
private _confirm(): void {
if (this._params!.confirm) {
this._params!.confirm(this._value);
this._params!.confirm(this._textField?.value);
}
this._close();
}

View File

@@ -1,6 +1,9 @@
import { HASSDomEvent, ValidHassDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
import { ancestorsWithProperty } from "../common/dom/ancestors-with-property";
import { deepActiveElement } from "../common/dom/deep-active-element";
import { nextRender } from "../common/util/render-status";
declare global {
// for fire event
@@ -40,7 +43,17 @@ export interface DialogState {
dialogParams?: unknown;
}
const LOADED = {};
interface LoadedDialogInfo {
element: Promise<HassDialog>;
closedFocusTargets?: Set<Element>;
}
interface LoadedDialogsDict {
[tag: string]: LoadedDialogInfo;
}
const LOADED: LoadedDialogsDict = {};
export const FOCUS_TARGET = Symbol.for("HA focus target");
export const showDialog = async (
element: HTMLElement & ProvideHassElement,
@@ -60,11 +73,25 @@ export const showDialog = async (
}
return;
}
LOADED[dialogTag] = dialogImport().then(() => {
const dialogEl = document.createElement(dialogTag) as HassDialog;
element.provideHass(dialogEl);
return dialogEl;
});
LOADED[dialogTag] = {
element: dialogImport().then(() => {
const dialogEl = document.createElement(dialogTag) as HassDialog;
element.provideHass(dialogEl);
return dialogEl;
}),
};
}
// Get the focus targets after the dialog closes, but keep the original if dialog is being replaced
if (mainWindow.history.state?.replaced) {
LOADED[dialogTag].closedFocusTargets =
LOADED[mainWindow.history.state.dialog].closedFocusTargets;
delete LOADED[mainWindow.history.state.dialog].closedFocusTargets;
} else {
LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty(
deepActiveElement(),
FOCUS_TARGET
);
}
if (addHistory) {
@@ -93,25 +120,29 @@ export const showDialog = async (
);
}
}
const dialogElement = await LOADED[dialogTag];
const dialogElement = await LOADED[dialogTag].element;
dialogElement.addEventListener("dialog-closed", _handleClosedFocus);
// Append it again so it's the last element in the root,
// so it's guaranteed to be on top of the other elements
root.appendChild(dialogElement);
dialogElement.showDialog(dialogParams);
};
export const replaceDialog = () => {
export const replaceDialog = (dialogElement: HassDialog) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, replaced: true },
""
);
dialogElement.removeEventListener("dialog-closed", _handleClosedFocus);
};
export const closeDialog = async (dialogTag: string): Promise<boolean> => {
if (!(dialogTag in LOADED)) {
return true;
}
const dialogElement: HassDialog = await LOADED[dialogTag];
const dialogElement = await LOADED[dialogTag].element;
if (dialogElement.closeDialog) {
return dialogElement.closeDialog() !== false;
}
@@ -137,3 +168,33 @@ export const makeDialogManager = (
}
);
};
const _handleClosedFocus = async (ev: HASSDomEvent<DialogClosedParams>) => {
const closedFocusTargets = LOADED[ev.detail.dialog].closedFocusTargets;
delete LOADED[ev.detail.dialog].closedFocusTargets;
if (!closedFocusTargets) return;
// Undo whatever the browser focused to provide easy checking
let focusedElement = deepActiveElement();
if (focusedElement instanceof HTMLElement) focusedElement.blur();
// Make sure backdrop is fully updated before trying (especially needed for underlay dialogs)
await nextRender();
// Try all targets in order and stop when one works
for (const focusTarget of closedFocusTargets) {
if (focusTarget instanceof HTMLElement) {
focusTarget.focus();
focusedElement = deepActiveElement();
if (focusedElement && focusedElement !== document.body) return;
}
}
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(
"Failed to focus any targets after closing dialog: %o",
closedFocusTargets
);
}
};

View File

@@ -1,35 +1,14 @@
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-camera-stream";
import type { HaCheckbox } from "../../../components/ha-checkbox";
import "../../../components/ha-checkbox";
import {
CameraEntity,
CameraPreferences,
CAMERA_SUPPORT_STREAM,
fetchCameraPrefs,
STREAM_TYPE_HLS,
updateCameraPrefs,
} from "../../../data/camera";
import { CameraEntity } from "../../../data/camera";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-formfield";
class MoreInfoCamera extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: CameraEntity;
@state() private _cameraPrefs?: CameraPreferences;
@state() private _attached = false;
public connectedCallback() {
@@ -54,83 +33,13 @@ class MoreInfoCamera extends LitElement {
allow-exoplayer
controls
></ha-camera-stream>
${this._cameraPrefs
? html`
<ha-formfield label="Preload stream">
<ha-checkbox
.checked=${this._cameraPrefs.preload_stream}
@change=${this._handleCheckboxChanged}
>
</ha-checkbox>
</ha-formfield>
`
: undefined}
`;
}
protected updated(changedProps: PropertyValues) {
if (!changedProps.has("stateObj")) {
return;
}
const oldState = changedProps.get("stateObj") as this["stateObj"];
const oldEntityId = oldState ? oldState.entity_id : undefined;
const curEntityId = this.stateObj ? this.stateObj.entity_id : undefined;
// Same entity, ignore.
if (curEntityId === oldEntityId) {
return;
}
if (
curEntityId &&
isComponentLoaded(this.hass!, "stream") &&
supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM) &&
// The stream component for HLS streams supports a server-side pre-load
// option that client initiated WebRTC streams do not
this.stateObj!.attributes.frontend_stream_type === STREAM_TYPE_HLS
) {
// Fetch in background while we set up the video.
this._fetchCameraPrefs();
}
}
private async _fetchCameraPrefs() {
this._cameraPrefs = await fetchCameraPrefs(
this.hass!,
this.stateObj!.entity_id
);
}
private async _handleCheckboxChanged(ev) {
const checkbox = ev.currentTarget as HaCheckbox;
try {
this._cameraPrefs = await updateCameraPrefs(
this.hass!,
this.stateObj!.entity_id,
{
preload_stream: checkbox.checked!,
}
);
} catch (err: any) {
alert(err.message);
checkbox.checked = !checkbox.checked;
}
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
position: relative;
}
ha-formfield {
position: absolute;
top: 0;
right: 0;
background-color: var(--secondary-background-color);
padding-right: 16px;
border-bottom-left-radius: 4px;
}
`;
}

View File

@@ -1,23 +1,9 @@
import {
mdiAlertCircleOutline,
mdiEye,
mdiGauge,
mdiThermometer,
mdiWaterPercent,
mdiWeatherCloudy,
mdiWeatherFog,
mdiWeatherHail,
mdiWeatherLightning,
mdiWeatherLightningRainy,
mdiWeatherNight,
mdiWeatherPartlyCloudy,
mdiWeatherPouring,
mdiWeatherRainy,
mdiWeatherSnowy,
mdiWeatherSnowyRainy,
mdiWeatherSunny,
mdiWeatherWindy,
mdiWeatherWindyVariant,
} from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket";
import {
@@ -37,27 +23,10 @@ import {
getWeatherUnit,
getWind,
isForecastHourly,
weatherIcons,
} from "../../../data/weather";
import { HomeAssistant } from "../../../types";
const weatherIcons = {
"clear-night": mdiWeatherNight,
cloudy: mdiWeatherCloudy,
exceptional: mdiAlertCircleOutline,
fog: mdiWeatherFog,
hail: mdiWeatherHail,
lightning: mdiWeatherLightning,
"lightning-rainy": mdiWeatherLightningRainy,
partlycloudy: mdiWeatherPartlyCloudy,
pouring: mdiWeatherPouring,
rainy: mdiWeatherRainy,
snowy: mdiWeatherSnowy,
"snowy-rainy": mdiWeatherSnowyRainy,
sunny: mdiWeatherSunny,
windy: mdiWeatherWindy,
"windy-variant": mdiWeatherWindyVariant,
};
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -235,6 +204,7 @@ class MoreInfoWeather extends LitElement {
return css`
ha-svg-icon {
color: var(--paper-item-icon-color);
margin-left: 8px;
}
.section {
margin: 16px 0 8px 0;

View File

@@ -295,7 +295,7 @@ export class MoreInfoDialog extends LitElement {
}
private _gotoSettings() {
replaceDialog();
replaceDialog(this);
showEntityEditorDialog(this, {
entity_id: this._entityId!,
});

View File

@@ -1,4 +1,4 @@
import { startOfYesterday } from "date-fns";
import { startOfYesterday } from "date-fns/esm";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";

View File

@@ -1,17 +1,11 @@
import { startOfYesterday } from "date-fns";
import { startOfYesterday } from "date-fns/esm";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { throttle } from "../../common/util/throttle";
import "../../components/ha-circular-progress";
import { getLogbookData, LogbookEntry } from "../../data/logbook";
import { loadTraceContexts, TraceContexts } from "../../data/trace";
import { fetchUsers } from "../../data/user";
import "../../panels/logbook/ha-logbook";
import { haStyle } from "../../resources/styles";
import { HomeAssistant } from "../../types";
import type { HomeAssistant } from "../../types";
@customElement("ha-more-info-logbook")
export class MoreInfoLogbook extends LitElement {
@@ -19,26 +13,14 @@ export class MoreInfoLogbook extends LitElement {
@property() public entityId!: string;
@state() private _logbookEntries?: LogbookEntry[];
@state() private _traceContexts?: TraceContexts;
@state() private _userIdToName = {};
private _lastLogbookDate?: Date;
private _fetchUserPromise?: Promise<void>;
private _error?: string;
private _showMoreHref = "";
private _throttleGetLogbookEntries = throttle(() => {
this._getLogBookData();
}, 10000);
private _time = { recent: 86400 };
private _entityIdAsList = memoizeOne((entityId: string) => [entityId]);
protected render(): TemplateResult {
if (!this.entityId) {
if (!isComponentLoaded(this.hass, "logbook") || !this.entityId) {
return html``;
}
const stateObj = this.hass.states[this.entityId];
@@ -48,150 +30,34 @@ export class MoreInfoLogbook extends LitElement {
}
return html`
${isComponentLoaded(this.hass, "logbook")
? this._error
? html`<div class="no-entries">
${`${this.hass.localize(
"ui.components.logbook.retrieval_error"
)}: ${this._error}`}
</div>`
: !this._logbookEntries
? html`
<ha-circular-progress
active
alt=${this.hass.localize("ui.common.loading")}
></ha-circular-progress>
`
: this._logbookEntries.length
? html`
<div class="header">
<div class="title">
${this.hass.localize("ui.dialogs.more_info_control.logbook")}
</div>
<a href=${this._showMoreHref} @click=${this._close}
>${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}</a
>
</div>
<ha-logbook
narrow
no-icon
no-name
relative-time
.hass=${this.hass}
.entries=${this._logbookEntries}
.traceContexts=${this._traceContexts}
.userIdToName=${this._userIdToName}
></ha-logbook>
`
: html`<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>`
: ""}
<div class="header">
<div class="title">
${this.hass.localize("ui.dialogs.more_info_control.logbook")}
</div>
<a href=${this._showMoreHref} @click=${this._close}
>${this.hass.localize("ui.dialogs.more_info_control.show_more")}</a
>
</div>
<ha-logbook
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._entityIdAsList(this.entityId)}
narrow
no-icon
no-name
relative-time
></ha-logbook>
`;
}
protected firstUpdated(): void {
this._fetchUserPromise = this._fetchUserNames();
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("entityId")) {
this._lastLogbookDate = undefined;
this._logbookEntries = undefined;
if (!this.entityId) {
return;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("entityId") && this.entityId) {
this._showMoreHref = `/logbook?entity_id=${
this.entityId
}&start_date=${startOfYesterday().toISOString()}`;
this._throttleGetLogbookEntries();
return;
}
if (!this.entityId || !changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
oldHass &&
this.hass.states[this.entityId] !== oldHass?.states[this.entityId]
) {
// wait for commit of data (we only account for the default setting of 1 sec)
setTimeout(this._throttleGetLogbookEntries, 1000);
}
}
private async _getLogBookData() {
if (!isComponentLoaded(this.hass, "logbook")) {
return;
}
const lastDate =
this._lastLogbookDate ||
new Date(new Date().getTime() - 24 * 60 * 60 * 1000);
const now = new Date();
let newEntries;
let traceContexts;
try {
[newEntries, traceContexts] = await Promise.all([
getLogbookData(
this.hass,
lastDate.toISOString(),
now.toISOString(),
this.entityId,
true
),
this.hass.user?.is_admin ? loadTraceContexts(this.hass) : {},
this._fetchUserPromise,
]);
} catch (err: any) {
this._error = err.message;
}
this._logbookEntries = this._logbookEntries
? [...newEntries, ...this._logbookEntries]
: newEntries;
this._lastLogbookDate = now;
this._traceContexts = traceContexts;
}
private async _fetchUserNames() {
const userIdToName = {};
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
// Process persons
Object.values(this.hass.states).forEach((entity) => {
if (
entity.attributes.user_id &&
computeStateDomain(entity) === "person"
) {
this._userIdToName[entity.attributes.user_id] =
entity.attributes.friendly_name;
}
});
// Process users
if (userProm) {
const users = await userProm;
for (const user of users) {
if (!(user.id in userIdToName)) {
userIdToName[user.id] = user.name;
}
}
}
this._userIdToName = userIdToName;
}
private _close(): void {
@@ -200,13 +66,7 @@ export class MoreInfoLogbook extends LitElement {
static get styles() {
return [
haStyle,
css`
.no-entries {
text-align: center;
padding: 16px;
color: var(--secondary-text-color);
}
ha-logbook {
--logbook-max-height: 250px;
}
@@ -215,10 +75,6 @@ export class MoreInfoLogbook extends LitElement {
--logbook-max-height: unset;
}
}
ha-circular-progress {
display: flex;
justify-content: center;
}
.header {
display: flex;
flex-direction: row;

View File

@@ -25,7 +25,7 @@ import { domainIcon } from "../../common/entity/domain_icon";
import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import {
defaultFuzzyFilterSort,
fuzzyFilterSort,
ScorableTextItem,
} from "../../common/string/filter/sequence-matching";
import { debounce } from "../../common/util/debounce";
@@ -725,7 +725,7 @@ export class QuickBar extends LitElement {
private _filterItems = memoizeOne(
(items: QuickBarItem[], filter: string): QuickBarItem[] =>
defaultFuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
);
static get styles() {

View File

@@ -49,12 +49,14 @@ class OnboardingIntegrations extends LitElement {
this.hass.loadBackendTranslation("title", undefined, true);
this._unsubEvents = subscribeConfigFlowInProgress(this.hass, (flows) => {
this._discovered = flows;
const integrations: Set<string> = new Set();
for (const flow of flows) {
// To render title placeholders
if (flow.context.title_placeholders) {
this.hass.loadBackendTranslation("config", flow.handler);
integrations.add(flow.handler);
}
}
this.hass.loadBackendTranslation("config", Array.from(integrations));
});
}

View File

@@ -336,6 +336,9 @@ export class HAFullCalendar extends LitElement {
.today {
margin-right: 20px;
margin-inline-end: 20px;
margin-inline-start: initial;
direction: var(--direction);
}
.prev,

View File

@@ -194,10 +194,13 @@ class PanelCalendar extends LitElement {
.calendar-list {
padding-right: 16px;
padding-inline-end: 16px;
padding-inline-start: initial;
min-width: 170px;
flex: 0 0 15%;
overflow: hidden;
--mdc-theme-text-primary-on-background: var(--primary-text-color);
direction: var(--direction);
}
.calendar-list > div {

View File

@@ -0,0 +1,249 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list-item";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ComboBoxLitRenderer } from "lit-vaadin-helpers";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-circular-progress";
import "../../../components/ha-combo-box";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-textfield";
import {
fetchApplicationCredentialsConfig,
createApplicationCredential,
ApplicationCredential,
} from "../../../data/application_credential";
import { domainToName } from "../../../data/integration";
import { PolymerChangedEvent } from "../../../polymer-types";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential";
interface Domain {
id: string;
name: string;
}
const rowRenderer: ComboBoxLitRenderer<Domain> = (item) => html`<mwc-list-item>
<span>${item.name}</span>
</mwc-list-item>`;
@customElement("dialog-add-application-credential")
export class DialogAddApplicationCredential extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loading = false;
// Error message when can't talk to server etc
@state() private _error?: string;
@state() private _params?: AddApplicationCredentialDialogParams;
@state() private _domain?: string;
@state() private _name?: string;
@state() private _clientId?: string;
@state() private _clientSecret?: string;
@state() private _domains?: Domain[];
public showDialog(params: AddApplicationCredentialDialogParams) {
this._params = params;
this._domain =
params.selectedDomain !== undefined ? params.selectedDomain : "";
this._name = "";
this._clientId = "";
this._clientSecret = "";
this._error = undefined;
this._loading = false;
this._fetchConfig();
}
private async _fetchConfig() {
const config = await fetchApplicationCredentialsConfig(this.hass);
this._domains = config.domains.map((domain) => ({
id: domain,
name: domainToName(this.hass.localize, domain),
}));
}
protected render(): TemplateResult {
if (!this._params || !this._domains) {
return html``;
}
return html`
<ha-dialog
open
@closed=${this._abortDialog}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)
)}
>
<div>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
<ha-combo-box
name="domain"
.hass=${this.hass}
.disabled=${!!this._params.selectedDomain}
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.domain"
)}
.value=${this._domain}
.renderer=${rowRenderer}
.items=${this._domains}
item-id-path="id"
item-value-path="id"
item-label-path="name"
required
@value-changed=${this._handleDomainPicked}
></ha-combo-box>
<ha-textfield
class="name"
name="name"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.name"
)}
.value=${this._name}
required
@input=${this._handleValueChanged}
error-message=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
></ha-textfield>
<ha-textfield
class="clientId"
name="clientId"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_id"
)}
.value=${this._clientId}
required
@input=${this._handleValueChanged}
error-message=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
></ha-textfield>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret"
)}
type="password"
name="clientSecret"
.value=${this._clientSecret}
required
@input=${this._handleValueChanged}
error-message=${this.hass.localize("ui.common.error_required")}
></ha-textfield>
</div>
${this._loading
? html`
<div slot="primaryAction" class="submit-spinner">
<ha-circular-progress active></ha-circular-progress>
</div>
`
: html`
<mwc-button
slot="primaryAction"
.disabled=${!this._domain ||
!this._clientId ||
!this._clientSecret}
@click=${this._createApplicationCredential}
>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.create"
)}
</mwc-button>
`}
</ha-dialog>
`;
}
public closeDialog() {
this._params = undefined;
this._domains = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private async _handleDomainPicked(ev: PolymerChangedEvent<string>) {
const target = ev.target as any;
if (target.selectedItem) {
this._domain = target.selectedItem.id;
}
}
private _handleValueChanged(ev: CustomEvent) {
this._error = undefined;
const name = (ev.target as any).name;
const value = (ev.target as any).value;
this[`_${name}`] = value;
}
private _abortDialog() {
if (this._params && this._params.dialogAbortedCallback) {
this._params.dialogAbortedCallback();
}
this.closeDialog();
}
private async _createApplicationCredential(ev) {
ev.preventDefault();
if (!this._domain || !this._clientId || !this._clientSecret) {
return;
}
this._loading = true;
this._error = "";
let applicationCredential: ApplicationCredential;
try {
applicationCredential = await createApplicationCredential(
this.hass,
this._domain,
this._clientId,
this._clientSecret,
this._name
);
} catch (err: any) {
this._loading = false;
this._error = err.message;
return;
}
this._params!.applicationCredentialAddedCallback(applicationCredential);
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 500px;
--dialog-z-index: 10;
}
.row {
display: flex;
padding: 8px 0;
}
ha-combo-box {
display: block;
margin-bottom: 24px;
}
ha-textfield {
display: block;
margin-bottom: 24px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-add-application-credential": DialogAddApplicationCredential;
}
}

View File

@@ -0,0 +1,285 @@
import { mdiDelete, mdiPlus } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { LocalizeFunc } from "../../../common/translations/localize";
import {
DataTableColumnContainer,
SelectionChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-icon";
import "../../../components/ha-fab";
import "../../../components/ha-help-tooltip";
import "../../../components/ha-svg-icon";
import {
ApplicationCredential,
deleteApplicationCredential,
fetchApplicationCredentials,
} from "../../../data/application_credential";
import { domainToName } from "../../../data/integration";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential";
@customElement("ha-config-application-credentials")
export class HaConfigApplicationCredentials extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() public _applicationCredentials: ApplicationCredential[] = [];
@property() public isWide!: boolean;
@property() public narrow!: boolean;
@property() public route!: Route;
@state() private _selected: string[] = [];
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _columns = memoizeOne(
(narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<ApplicationCredential> = {
name: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.name"
),
width: "40%",
direction: "asc",
grows: true,
template: (_, entry: ApplicationCredential) => html`${entry.name}`,
},
clientId: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.client_id"
),
width: "30%",
direction: "asc",
hidden: narrow,
template: (_, entry: ApplicationCredential) =>
html`${entry.client_id}`,
},
application: {
title: localize(
"ui.panel.config.application_credentials.picker.headers.application"
),
sortable: true,
width: "30%",
direction: "asc",
template: (_, entry) => html`${domainToName(localize, entry.domain)}`,
},
};
return columns;
}
);
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._loadTranslations();
this._fetchApplicationCredentials();
}
protected render() {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
backPath="/config"
.tabs=${configSections.devices}
.columns=${this._columns(this.narrow, this.hass.localize)}
.data=${this._applicationCredentials}
hasFab
selectable
@selection-changed=${this._handleSelectionChanged}
>
${this._selected.length
? html`
<div
class=${classMap({
"header-toolbar": this.narrow,
"table-header": !this.narrow,
})}
slot="header"
>
<p class="selected-txt">
${this.hass.localize(
"ui.panel.config.application_credentials.picker.selected",
"number",
this._selected.length
)}
</p>
<div class="header-btns">
${!this.narrow
? html`
<mwc-button
@click=${this._removeSelected}
class="warning"
>${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}</mwc-button
>
`
: html`
<ha-icon-button
class="warning"
id="remove-btn"
@click=${this._removeSelected}
.path=${mdiDelete}
.label=${this.hass.localize("ui.common.remove")}
></ha-icon-button>
<ha-help-tooltip
.label=${this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.button"
)}
>
</ha-help-tooltip>
`}
</div>
</div>
`
: html``}
<ha-fab
slot="fab"
.label=${this.hass.localize(
"ui.panel.config.application_credentials.picker.add_application_credential"
)}
extended
@click=${this._addApplicationCredential}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
`;
}
private _handleSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
): void {
this._selected = ev.detail.value;
}
private _removeSelected() {
showConfirmationDialog(this, {
title: this.hass.localize(
`ui.panel.config.application_credentials.picker.remove_selected.confirm_title`,
"number",
this._selected.length
),
text: this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.confirm_text"
),
confirmText: this.hass.localize("ui.common.remove"),
dismissText: this.hass.localize("ui.common.cancel"),
confirm: async () => {
try {
await Promise.all(
this._selected.map(async (applicationCredential) => {
await deleteApplicationCredential(
this.hass,
applicationCredential
);
})
);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.application_credentials.picker.remove_selected.error_title"
),
text: err.message,
});
return;
}
this._dataTable.clearSelection();
this._fetchApplicationCredentials();
},
});
}
private async _loadTranslations() {
await this.hass.loadBackendTranslation("title", undefined, true);
}
private async _fetchApplicationCredentials() {
this._applicationCredentials = await fetchApplicationCredentials(this.hass);
}
private _addApplicationCredential() {
showAddApplicationCredentialDialog(this, {
applicationCredentialAddedCallback: async (
applicationCredential: ApplicationCredential
) => {
if (applicationCredential) {
this._applicationCredentials = [
...this._applicationCredentials,
applicationCredential,
];
}
},
});
}
static get styles(): CSSResultGroup {
return css`
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 56px;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-bottom: 1px solid
var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42));
box-sizing: border-box;
}
.header-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--secondary-text-color);
position: relative;
top: -4px;
}
.selected-txt {
font-weight: bold;
padding-left: 16px;
padding-inline-start: 16px;
direction: var(--direction);
}
.table-header .selected-txt {
margin-top: 20px;
}
.header-toolbar .selected-txt {
font-size: 16px;
}
.header-toolbar .header-btns {
margin-right: -12px;
}
.header-btns {
display: flex;
}
.header-btns > mwc-button,
.header-btns > ha-icon-button {
margin: 8px;
}
ha-button-menu {
margin-left: 8px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-application-credentials": HaConfigApplicationCredentials;
}
}

View File

@@ -0,0 +1,24 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { ApplicationCredential } from "../../../data/application_credential";
export interface AddApplicationCredentialDialogParams {
applicationCredentialAddedCallback: (
applicationCredential: ApplicationCredential
) => void;
dialogAbortedCallback?: () => void;
selectedDomain?: string;
}
export const loadAddApplicationCredentialDialog = () =>
import("./dialog-add-application-credential");
export const showAddApplicationCredentialDialog = (
element: HTMLElement,
dialogParams: AddApplicationCredentialDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-add-application-credential",
dialogImport: loadAddApplicationCredentialDialog,
dialogParams,
});
};

View File

@@ -2,7 +2,10 @@ import "@material/mwc-button";
import { mdiImagePlus, mdiPencil } from "@mdi/js";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { HassEntity } from "home-assistant-js-websocket/dist/types";
import {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -19,6 +22,7 @@ import "../../../components/ha-icon-next";
import {
AreaRegistryEntry,
deleteAreaRegistryEntry,
subscribeAreaRegistry,
updateAreaRegistryEntry,
} from "../../../data/area_registry";
import { AutomationEntity } from "../../../data/automation";
@@ -26,18 +30,22 @@ import {
computeDeviceName,
DeviceRegistryEntry,
sortDeviceRegistryByName,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import {
computeEntityRegistryName,
EntityRegistryEntry,
sortEntityRegistryByName,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { SceneEntity } from "../../../data/scene";
import { ScriptEntity } from "../../../data/script";
import { findRelated, RelatedResult } from "../../../data/search";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import "../../logbook/ha-logbook";
import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor";
import { configSections } from "../ha-panel-config";
import {
@@ -51,17 +59,11 @@ declare type NameAndEntity<EntityType extends HassEntity> = {
};
@customElement("ha-config-area-page")
class HaConfigAreaPage extends LitElement {
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public areaId!: string;
@property() public areas!: AreaRegistryEntry[];
@property() public devices!: DeviceRegistryEntry[];
@property() public entities!: EntityRegistryEntry[];
@property({ type: Boolean, reflect: true }) public narrow!: boolean;
@property() public isWide!: boolean;
@@ -70,8 +72,16 @@ class HaConfigAreaPage extends LitElement {
@property() public route!: Route;
@state() public _areas!: AreaRegistryEntry[];
@state() public _devices!: DeviceRegistryEntry[];
@state() public _entities!: EntityRegistryEntry[];
@state() private _related?: RelatedResult;
private _logbookTime = { recent: 86400 };
private _area = memoizeOne(
(
areaId: string,
@@ -86,7 +96,7 @@ class HaConfigAreaPage extends LitElement {
registryDevices: DeviceRegistryEntry[],
registryEntities: EntityRegistryEntry[]
) => {
const devices = new Map();
const devices = new Map<string, DeviceRegistryEntry>();
for (const device of registryDevices) {
if (device.area_id === areaId) {
@@ -102,7 +112,7 @@ class HaConfigAreaPage extends LitElement {
if (entity.area_id === areaId) {
entities.push(entity);
}
} else if (devices.has(entity.device_id)) {
} else if (entity.device_id && devices.has(entity.device_id)) {
indirectEntities.push(entity);
}
}
@@ -115,6 +125,20 @@ class HaConfigAreaPage extends LitElement {
}
);
private _allDeviceIds = memoizeOne((devices: DeviceRegistryEntry[]) =>
devices.map((device) => device.id)
);
private _allEntities = memoizeOne(
(memberships: {
entities: EntityRegistryEntry[];
indirectEntities: EntityRegistryEntry[];
}) =>
memberships.entities
.map((entry) => entry.entity_id)
.concat(memberships.indirectEntities.map((entry) => entry.entity_id))
);
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
@@ -127,8 +151,26 @@ class HaConfigAreaPage extends LitElement {
}
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._devices = entries;
}),
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entities = entries;
}),
];
}
protected render(): TemplateResult {
const area = this._area(this.areaId, this.areas);
if (!this._areas || !this._devices || !this._entities) {
return html``;
}
const area = this._area(this.areaId, this._areas);
if (!area) {
return html`
@@ -139,11 +181,12 @@ class HaConfigAreaPage extends LitElement {
`;
}
const { devices, entities } = this._memberships(
const memberships = this._memberships(
this.areaId,
this.devices,
this.entities
this._devices,
this._entities
);
const { devices, entities } = memberships;
// Pre-compute the entity and device names, so we can sort by them
if (devices) {
@@ -359,8 +402,6 @@ class HaConfigAreaPage extends LitElement {
</ha-card>
`
: ""}
</div>
<div class="column">
${isComponentLoaded(this.hass, "scene")
? html`
<ha-card
@@ -442,6 +483,26 @@ class HaConfigAreaPage extends LitElement {
`
: ""}
</div>
<div class="column">
${isComponentLoaded(this.hass, "logbook")
? html`
<ha-card
outlined
.header=${this.hass.localize("panel.logbook")}
>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
</div>
</div>
</hass-tabs-subpage>
`;
@@ -699,6 +760,13 @@ class HaConfigAreaPage extends LitElement {
opacity: 0.5;
border-radius: 50%;
}
ha-logbook {
height: 400px;
}
:host([narrow]) ha-logbook {
height: 235px;
overflow: auto;
}
`,
];
}

Some files were not shown because too many files have changed in this diff Show More