Compare commits

...

69 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
146 changed files with 4898 additions and 2987 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 Provide details about the versions you are using, which helps us reproducing
and finding the issue quicker. Version information is found in the 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 Browser version and operating system is important! Please try to replicate
your issue in a different browser and be sure to include your findings. your issue in a different browser and be sure to include your findings.

View File

@@ -64,7 +64,7 @@ body:
label: What version of Home Assistant Core has the issue? label: What version of Home Assistant Core has the issue?
placeholder: core- placeholder: core-
description: > description: >
Can be found in the Configuration panel -> Info. Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/).
- type: input - type: input
attributes: attributes:
label: What was the last working version of Home Assistant Core? label: What was the last working version of Home Assistant Core?

View File

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

View File

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

View File

@@ -377,7 +377,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC bed", name: "AC bed",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.air_cleaner_quiet", entity_id: "script.air_cleaner_quiet",
}, },
service: "script.turn_on", service: "script.turn_on",
@@ -390,7 +390,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC bed", name: "AC bed",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.air_cleaner_auto", entity_id: "script.air_cleaner_auto",
}, },
service: "script.turn_on", service: "script.turn_on",
@@ -403,7 +403,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC bed", name: "AC bed",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.air_cleaner_turbo", entity_id: "script.air_cleaner_turbo",
}, },
service: "script.turn_on", service: "script.turn_on",
@@ -416,7 +416,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC", name: "AC",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.ac_off", entity_id: "script.ac_off",
}, },
service: "script.turn_on", service: "script.turn_on",
@@ -429,7 +429,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
name: "AC", name: "AC",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "script.ac_on", entity_id: "script.ac_on",
}, },
service: "script.turn_on", service: "script.turn_on",
@@ -629,7 +629,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "scene.morning_lights", entity: "scene.morning_lights",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "scene.morning_lights", entity_id: "scene.morning_lights",
}, },
service: "scene.turn_on", service: "scene.turn_on",
@@ -641,7 +641,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "scene.movie_time", entity: "scene.movie_time",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "scene.movie_time", entity_id: "scene.movie_time",
}, },
service: "scene.turn_on", service: "scene.turn_on",
@@ -702,7 +702,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "light.downstairs_lights", entity: "light.downstairs_lights",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "light.downstairs_lights", entity_id: "light.downstairs_lights",
}, },
service: "light.toggle", service: "light.toggle",
@@ -714,7 +714,7 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({
entity: "light.upstairs_lights", entity: "light.upstairs_lights",
tap_action: { tap_action: {
action: "call-service", action: "call-service",
service_data: { data: {
entity_id: "light.upstairs_lights", entity_id: "light.upstairs_lights",
}, },
service: "light.toggle", 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 { EnergySolarForecasts } from "../../../src/data/energy";
import { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; import { MockHomeAssistant } from "../../../src/fake_data/provide_hass";

View File

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

View File

@@ -119,7 +119,7 @@ export const basicTrace: DemoTrace = {
params: { params: {
domain: "input_boolean", domain: "input_boolean",
service: "toggle", service: "toggle",
service_data: {}, data: {},
target: { target: {
entity_id: ["input_boolean.toggle_4"], entity_id: ["input_boolean.toggle_4"],
}, },
@@ -164,7 +164,7 @@ export const basicTrace: DemoTrace = {
params: { params: {
domain: "input_boolean", domain: "input_boolean",
service: "toggle", service: "toggle",
service_data: {}, data: {},
target: { target: {
entity_id: ["input_boolean.toggle_2"], entity_id: ["input_boolean.toggle_2"],
}, },
@@ -182,7 +182,7 @@ export const basicTrace: DemoTrace = {
params: { params: {
domain: "input_boolean", domain: "input_boolean",
service: "toggle", service: "toggle",
service_data: {}, data: {},
target: { target: {
entity_id: ["input_boolean.toggle_3"], entity_id: ["input_boolean.toggle_3"],
}, },
@@ -200,7 +200,7 @@ export const basicTrace: DemoTrace = {
params: { params: {
domain: "input_boolean", domain: "input_boolean",
service: "toggle", service: "toggle",
service_data: {}, data: {},
target: { target: {
entity_id: ["input_boolean.toggle_4"], entity_id: ["input_boolean.toggle_4"],
}, },

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,8 +72,8 @@
"@material/mwc-textfield": "0.25.3", "@material/mwc-textfield": "0.25.3",
"@material/mwc-top-app-bar-fixed": "^0.25.3", "@material/mwc-top-app-bar-fixed": "^0.25.3",
"@material/top-app-bar": "14.0.0-canary.261f2db59.0", "@material/top-app-bar": "14.0.0-canary.261f2db59.0",
"@mdi/js": "6.6.95", "@mdi/js": "6.7.96",
"@mdi/svg": "6.6.95", "@mdi/svg": "6.7.96",
"@polymer/app-layout": "^3.1.0", "@polymer/app-layout": "^3.1.0",
"@polymer/iron-flex-layout": "^3.0.1", "@polymer/iron-flex-layout": "^3.0.1",
"@polymer/iron-icon": "^3.0.1", "@polymer/iron-icon": "^3.0.1",

View File

@@ -1,3 +1,30 @@
[build-system] [build-system]
requires = ["setuptools~=60.5", "wheel~=0.37.1"] requires = ["setuptools~=62.3", "wheel~=0.37.1"]
build-backend = "setuptools.build_meta" 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; return;
} }
const setup = fs.readFileSync("setup.cfg", "utf8"); const setup = fs.readFileSync("pyproject.toml", "utf8");
const version = setup.match(/\d{8}\.\d+/)[0]; const version = setup.match(/version\W+=\W"(\d{8}\.\d)"/)[1];
const newVersion = method(version); const newVersion = method(version);
console.log("Current version:", version); console.log("Current version:", version);
console.log("New version:", newVersion); 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) { if (!commit) {
return; return;

View File

@@ -1,26 +0,0 @@
[metadata]
name = home-assistant-frontend
version = 20220516.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

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

View File

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

View File

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

View File

@@ -3,6 +3,13 @@ import { HassEntity } from "home-assistant-js-websocket";
export const supportsFeature = ( export const supportsFeature = (
stateObj: HassEntity, stateObj: HassEntity,
feature: number feature: number
): boolean => supportsFeatureFromAttributes(stateObj.attributes, feature);
export const supportsFeatureFromAttributes = (
attributes: {
[key: string]: any;
},
feature: number
): boolean => ): boolean =>
// eslint-disable-next-line no-bitwise // 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 * @param stateObj The entity state object
*/ */
export const isNumericState = (stateObj: HassEntity): boolean => export const isNumericState = (stateObj: HassEntity): boolean =>
!!stateObj.attributes.unit_of_measurement || isNumericFromAttributes(stateObj.attributes);
!!stateObj.attributes.state_class;
export const isNumericFromAttributes = (attributes: {
[key: string]: any;
}): boolean => !!attributes.unit_of_measurement || !!attributes.state_class;
export const numberFormatToLocale = ( export const numberFormatToLocale = (
localeOptions: FrontendLocaleData localeOptions: FrontendLocaleData

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ interface HassEntityWithCachedName extends HassEntity {
friendly_name: string; friendly_name: string;
} }
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
// eslint-disable-next-line lit/prefer-static-styles // eslint-disable-next-line lit/prefer-static-styles
const rowRenderer: ComboBoxLitRenderer<HassEntityWithCachedName> = (item) => const rowRenderer: ComboBoxLitRenderer<HassEntityWithCachedName> = (item) =>

View File

@@ -2,12 +2,7 @@ import type { Button } from "@material/mwc-button";
import "@material/mwc-menu"; import "@material/mwc-menu";
import type { Corner, Menu, MenuCorner } from "@material/mwc-menu"; import type { Corner, Menu, MenuCorner } from "@material/mwc-menu";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { import { customElement, property, query } from "lit/decorators";
customElement,
property,
query,
queryAssignedElements,
} from "lit/decorators";
import { FOCUS_TARGET } from "../dialogs/make-dialog-manager"; import { FOCUS_TARGET } from "../dialogs/make-dialog-manager";
import type { HaIconButton } from "./ha-icon-button"; import type { HaIconButton } from "./ha-icon-button";
@@ -33,12 +28,6 @@ export class HaButtonMenu extends LitElement {
@query("mwc-menu", true) private _menu?: Menu; @query("mwc-menu", true) private _menu?: Menu;
@queryAssignedElements({
slot: "trigger",
selector: "ha-icon-button, mwc-button",
})
private _triggerButton!: Array<HaIconButton | Button>;
public get items() { public get items() {
return this._menu?.items; return this._menu?.items;
} }
@@ -51,14 +40,14 @@ export class HaButtonMenu extends LitElement {
if (this._menu?.open) { if (this._menu?.open) {
this._menu.focusItemAtIndex(0); this._menu.focusItemAtIndex(0);
} else { } else {
this._triggerButton[0]?.focus(); this._triggerButton?.focus();
} }
} }
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<div @click=${this._handleClick}> <div @click=${this._handleClick}>
<slot name="trigger"></slot> <slot name="trigger" @slotchange=${this._setTriggerAria}></slot>
</div> </div>
<mwc-menu <mwc-menu
.corner=${this.corner} .corner=${this.corner}
@@ -97,6 +86,18 @@ export class HaButtonMenu extends LitElement {
this._menu!.show(); 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 { static get styles(): CSSResultGroup {
return css` return css`
:host { :host {

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import "@material/mwc-icon-button";
import type { IconButton } from "@material/mwc-icon-button"; import type { IconButton } from "@material/mwc-icon-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import "./ha-svg-icon"; import "./ha-svg-icon";
@customElement("ha-icon-button") @customElement("ha-icon-button")
@@ -12,7 +13,12 @@ export class HaIconButton extends LitElement {
@property({ type: String }) path?: string; @property({ type: String }) path?: string;
// Label that is used for ARIA support and as tooltip // 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; @property({ type: Boolean }) hideTitle = false;
@@ -28,11 +34,11 @@ export class HaIconButton extends LitElement {
}; };
protected render(): TemplateResult { protected render(): TemplateResult {
// Note: `ariaLabel` required despite the `mwc-icon-button` docs saying `label` should be enough
return html` return html`
<mwc-icon-button <mwc-icon-button
.ariaLabel=${this.label} aria-label=${ifDefined(this.label)}
.title=${this.hideTitle ? "" : this.label} title=${ifDefined(this.hideTitle ? undefined : this.label)}
aria-haspopup=${ifDefined(this.ariaHasPopup)}
.disabled=${this.disabled} .disabled=${this.disabled}
> >
${this.path ${this.path

View File

@@ -47,11 +47,19 @@ export class HaSelect extends SelectBase {
.mdc-select__anchor { .mdc-select__anchor {
width: var(--ha-select-min-width, 200px); width: var(--ha-select-min-width, 200px);
} }
.mdc-floating-label { .mdc-select--filled .mdc-floating-label {
inset-inline-start: 16px !important; inset-inline-start: 12px;
inset-inline-end: initial !important; inset-inline-end: initial;
direction: var(--direction); 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 { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; 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 { 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 { AreaSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../ha-area-picker"; import "../ha-area-picker";
import "../ha-areas-picker"; import "../ha-areas-picker";
@customElement("ha-selector-area") @customElement("ha-selector-area")
export class HaAreaSelector extends LitElement { export class HaAreaSelector extends SubscribeMixin(LitElement) {
@property() public hass!: HomeAssistant; @property() public hass!: HomeAssistant;
@property() public selector!: AreaSelector; @property() public selector!: AreaSelector;
@@ -20,29 +29,44 @@ export class HaAreaSelector extends LitElement {
@property() public helper?: string; @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 disabled = false;
@property({ type: Boolean }) public required = true; @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) { protected updated(changedProperties) {
if (changedProperties.has("selector")) { if (
const oldSelector = changedProperties.get("selector"); changedProperties.has("selector") &&
if ( (this.selector.area.device?.integration ||
oldSelector !== this.selector && this.selector.area.entity?.integration) &&
this.selector.area.device?.integration !this._entitySources
) { ) {
getConfigEntries(this.hass, { fetchEntitySourcesWithCache(this.hass).then((sources) => {
domain: this.selector.area.device.integration, this._entitySources = sources;
}).then((entries) => { });
this._configEntries = entries;
});
}
} }
} }
protected render() { protected render() {
if (
(this.selector.area.device?.integration ||
this.selector.area.entity?.integration) &&
!this._entitySources
) {
return html``;
}
if (!this.selector.area.multiple) { if (!this.selector.area.multiple) {
return html` return html`
<ha-area-picker <ha-area-picker
@@ -87,39 +111,62 @@ export class HaAreaSelector extends LitElement {
} }
private _filterEntities = (entity: EntityRegistryEntry): boolean => { private _filterEntities = (entity: EntityRegistryEntry): boolean => {
if (this.selector.area.entity?.integration) { const filterIntegration = this.selector.area.entity?.integration;
if (entity.platform !== 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 false;
} }
} }
return true; return true;
}; };
private _filterDevices = (device: DeviceRegistryEntry): boolean => { private _deviceIntegrations = memoizeOne(
if ( (entitySources: EntitySources, entities: EntityRegistryEntry[]) => {
this.selector.area.device?.manufacturer && const deviceIntegrations: Record<string, string[]> = {};
device.manufacturer !== this.selector.area.device.manufacturer
) { for (const entity of entities) {
return false; const source = entitySources[entity.entity_id];
} if (!source?.domain) {
if ( continue;
this.selector.area.device?.model && }
device.model !== this.selector.area.device.model if (!deviceIntegrations[entity.device_id!]) {
) { deviceIntegrations[entity.device_id!] = [];
return false; }
} deviceIntegrations[entity.device_id!].push(source.domain);
if (this.selector.area.device?.integration) {
if (
this._configEntries &&
!this._configEntries.some((entry) =>
device.config_entries.includes(entry.entry_id)
)
) {
return false;
} }
return deviceIntegrations;
} }
return true; );
};
} }
declare global { declare global {

View File

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

View File

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

View File

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

View File

@@ -57,6 +57,9 @@ export class HaTextField extends TextFieldBase {
.mdc-text-field__affix--suffix { .mdc-text-field__affix--suffix {
padding-left: var(--text-field-suffix-padding-left, 12px); padding-left: var(--text-field-suffix-padding-left, 12px);
padding-right: var(--text-field-suffix-padding-right, 0px); 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) .mdc-text-field:not(.mdc-text-field--disabled)
@@ -95,6 +98,7 @@ export class HaTextField extends TextFieldBase {
.mdc-floating-label { .mdc-floating-label {
inset-inline-start: 16px !important; inset-inline-start: 16px !important;
inset-inline-end: initial !important; inset-inline-end: initial !important;
transform-origin: var(--float-start);
direction: var(--direction); direction: var(--direction);
} }

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ export interface ApplicationCredential {
domain: string; domain: string;
client_id: string; client_id: string;
client_secret: string; client_secret: string;
name: string;
} }
export const fetchApplicationCredentialsConfig = async (hass: HomeAssistant) => export const fetchApplicationCredentialsConfig = async (hass: HomeAssistant) =>
@@ -25,13 +26,15 @@ export const createApplicationCredential = async (
hass: HomeAssistant, hass: HomeAssistant,
domain: string, domain: string,
clientId: string, clientId: string,
clientSecret: string clientSecret: string,
name?: string
) => ) =>
hass.callWS<ApplicationCredential>({ hass.callWS<ApplicationCredential>({
type: "application_credentials/create", type: "application_credentials/create",
domain, domain,
client_id: clientId, client_id: clientId,
client_secret: clientSecret, client_secret: clientSecret,
name,
}); });
export const deleteApplicationCredential = async ( export const deleteApplicationCredential = async (

View File

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

View File

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

View File

@@ -20,3 +20,20 @@ export const BOARD_NAMES: Record<string, string> = {
"intel-nuc": "Intel NUC", "intel-nuc": "Intel NUC",
yellow: "Home Assistant Yellow", 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 { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDisplay } from "../common/entity/compute_state_display"; import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
import { computeStateDomain } from "../common/entity/compute_state_domain"; import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
import { computeStateName } from "../common/entity/compute_state_name";
import { LocalizeFunc } from "../common/translations/localize"; import { LocalizeFunc } from "../common/translations/localize";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { FrontendLocaleData } from "./translation"; import { FrontendLocaleData } from "./translation";
@@ -27,7 +26,7 @@ const LINE_ATTRIBUTES_TO_KEEP = [
export interface LineChartState { export interface LineChartState {
state: string; state: string;
last_changed: string; last_changed: number;
attributes?: Record<string, any>; attributes?: Record<string, any>;
} }
@@ -47,7 +46,7 @@ export interface LineChartUnit {
export interface TimelineState { export interface TimelineState {
state_localize: string; state_localize: string;
state: string; state: string;
last_changed: string; last_changed: number;
} }
export interface TimelineEntity { export interface TimelineEntity {
@@ -141,6 +140,21 @@ export interface StatisticsValidationResults {
[statisticId: string]: StatisticsValidationResult[]; [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 = ( export const entityIdHistoryNeedsAttributes = (
hass: HomeAssistant, hass: HomeAssistant,
entityId: string entityId: string
@@ -181,6 +195,27 @@ export const fetchRecent = (
return hass.callApi("GET", url); 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 = ( export const fetchDate = (
hass: HomeAssistant, hass: HomeAssistant,
startTime: Date, 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) => const equalState = (obj1: LineChartState, obj2: LineChartState) =>
obj1.state === obj2.state && obj1.state === obj2.state &&
// Only compare attributes if both states have an attributes object. // Only compare attributes if both states have an attributes object.
@@ -212,46 +268,47 @@ const equalState = (obj1: LineChartState, obj2: LineChartState) =>
const processTimelineEntity = ( const processTimelineEntity = (
localize: LocalizeFunc, localize: LocalizeFunc,
language: FrontendLocaleData, language: FrontendLocaleData,
states: HassEntity[] entityId: string,
states: EntityHistoryState[]
): TimelineEntity => { ): TimelineEntity => {
const data: TimelineState[] = []; const data: TimelineState[] = [];
const last_element = states.length - 1; const first: EntityHistoryState = states[0];
for (const state of states) { 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; 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({ data.push({
state_localize: computeStateDisplay(localize, state, language), state_localize: computeStateDisplayFromEntityAttributes(
state: state.state, localize,
last_changed: state.last_changed, 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 { return {
name: computeStateName(states[0]), name: computeStateNameFromEntityAttributes(entityId, states[0].a),
entity_id: states[0].entity_id, entity_id: entityId,
data, data,
}; };
}; };
const processLineChartEntities = ( const processLineChartEntities = (
unit, unit,
entities: HassEntity[][] entities: HistoryStates
): LineChartUnit => { ): LineChartUnit => {
const data: LineChartEntity[] = []; const data: LineChartEntity[] = [];
for (const states of entities) { Object.keys(entities).forEach((entityId) => {
const last: HassEntity = states[states.length - 1]; const states = entities[entityId];
const domain = computeStateDomain(last); const first: EntityHistoryState = states[0];
const domain = computeDomain(entityId);
const processedStates: LineChartState[] = []; const processedStates: LineChartState[] = [];
for (const state of states) { for (const state of states) {
@@ -259,18 +316,24 @@ const processLineChartEntities = (
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) { if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
processedState = { processedState = {
state: state.state, state: state.s,
last_changed: state.last_updated, last_changed: state.lu * 1000,
attributes: {}, attributes: {},
}; };
for (const attr of LINE_ATTRIBUTES_TO_KEEP) { for (const attr of LINE_ATTRIBUTES_TO_KEEP) {
if (attr in state.attributes) { if (attr in state.a) {
processedState.attributes![attr] = state.attributes[attr]; processedState.attributes![attr] = state.a[attr];
} }
} }
} else { } 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 ( if (
@@ -289,52 +352,53 @@ const processLineChartEntities = (
data.push({ data.push({
domain, domain,
name: computeStateName(last), name: computeStateNameFromEntityAttributes(entityId, first.a),
entity_id: last.entity_id, entity_id: entityId,
states: processedStates, states: processedStates,
}); });
} });
return { return {
unit, unit,
identifier: entities.map((states) => states[0].entity_id).join(""), identifier: Object.keys(entities).join(""),
data, data,
}; };
}; };
const stateUsesUnits = (state: HassEntity) => const stateUsesUnits = (state: HassEntity) =>
"unit_of_measurement" in state.attributes || attributesHaveUnits(state.attributes);
"state_class" in state.attributes;
const attributesHaveUnits = (attributes: { [key: string]: any }) =>
"unit_of_measurement" in attributes || "state_class" in attributes;
export const computeHistory = ( export const computeHistory = (
hass: HomeAssistant, hass: HomeAssistant,
stateHistory: HassEntity[][], stateHistory: HistoryStates,
localize: LocalizeFunc localize: LocalizeFunc
): HistoryResult => { ): HistoryResult => {
const lineChartDevices: { [unit: string]: HassEntity[][] } = {}; const lineChartDevices: { [unit: string]: HistoryStates } = {};
const timelineDevices: TimelineEntity[] = []; const timelineDevices: TimelineEntity[] = [];
if (!stateHistory) { if (!stateHistory) {
return { line: [], timeline: [] }; return { line: [], timeline: [] };
} }
Object.keys(stateHistory).forEach((entityId) => {
stateHistory.forEach((stateInfo) => { const stateInfo = stateHistory[entityId];
if (stateInfo.length === 0) { if (stateInfo.length === 0) {
return; return;
} }
const entityId = stateInfo[0].entity_id;
const currentState = const currentState =
entityId in hass.states ? hass.states[entityId] : undefined; entityId in hass.states ? hass.states[entityId] : undefined;
const stateWithUnitorStateClass = const stateWithUnitorStateClass =
!currentState && !currentState &&
stateInfo.find((state) => state.attributes && stateUsesUnits(state)); stateInfo.find((state) => state.a && attributesHaveUnits(state.a));
let unit: string | undefined; let unit: string | undefined;
if (currentState && stateUsesUnits(currentState)) { if (currentState && stateUsesUnits(currentState)) {
unit = currentState.attributes.unit_of_measurement || " "; unit = currentState.attributes.unit_of_measurement || " ";
} else if (stateWithUnitorStateClass) { } else if (stateWithUnitorStateClass) {
unit = stateWithUnitorStateClass.attributes.unit_of_measurement || " "; unit = stateWithUnitorStateClass.a.unit_of_measurement || " ";
} else { } else {
unit = { unit = {
climate: hass.config.unit_system.temperature, climate: hass.config.unit_system.temperature,
@@ -348,12 +412,15 @@ export const computeHistory = (
if (!unit) { if (!unit) {
timelineDevices.push( timelineDevices.push(
processTimelineEntity(localize, hass.locale, stateInfo) processTimelineEntity(localize, hass.locale, entityId, stateInfo)
); );
} else if (unit in lineChartDevices) { } else if (unit in lineChartDevices && entityId in lineChartDevices[unit]) {
lineChartDevices[unit].push(stateInfo); lineChartDevices[unit][entityId].push(...stateInfo);
} else { } 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 manifest?: IntegrationManifest
) => localize(`component.${domain}.title`) || manifest?.name || domain; ) => localize(`component.${domain}.title`) || manifest?.name || domain;
export const fetchIntegrationManifests = (hass: HomeAssistant) => export const fetchIntegrationManifests = (
hass.callWS<IntegrationManifest[]>({ type: "manifest/list" }); hass: HomeAssistant,
integrations?: string[]
) => {
const params: any = {
type: "manifest/list",
};
if (integrations) {
params.integrations = integrations;
}
return hass.callWS<IntegrationManifest[]>(params);
};
export const fetchIntegrationManifest = ( export const fetchIntegrationManifest = (
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -1,5 +1,9 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { BINARY_STATE_OFF, BINARY_STATE_ON } from "../common/const"; import {
BINARY_STATE_OFF,
BINARY_STATE_ON,
DOMAINS_WITH_DYNAMIC_PICTURE,
} from "../common/const";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDisplay } from "../common/entity/compute_state_display"; import { computeStateDisplay } from "../common/entity/compute_state_display";
import { LocalizeFunc } from "../common/translations/localize"; import { LocalizeFunc } from "../common/translations/localize";
@@ -9,26 +13,51 @@ import { UNAVAILABLE_STATES } from "./entity";
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
export const CONTINUOUS_DOMAINS = ["proximity", "sensor"]; 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 { export interface LogbookEntry {
when: number; // Base data
when: number; // Python timestamp. Do *1000 to get JS timestamp.
name: string; name: string;
message?: string; message?: string;
entity_id?: string; entity_id?: string;
icon?: string; icon?: string;
source?: string; source?: string; // The trigger source
domain?: string; domain?: string;
state?: string; // The state of the entity
// Context data
context_id?: string; context_id?: string;
context_user_id?: string; context_user_id?: string;
context_event_type?: string; context_event_type?: string;
context_domain?: string; context_domain?: string;
context_service?: string; context_service?: string; // Service calls only
context_entity_id?: string; context_entity_id?: string;
context_entity_id_name?: string; context_entity_id_name?: string; // Legacy, not longer sent
context_name?: string; context_name?: string;
context_state?: string; // The state of the entity
context_source?: string; // The trigger source
context_message?: string; context_message?: string;
state?: 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: { const DATA_CACHE: {
[cacheKey: string]: { [entityId: string]: Promise<LogbookEntry[]> }; [cacheKey: string]: { [entityId: string]: Promise<LogbookEntry[]> };
} = {}; } = {};
@@ -38,17 +67,13 @@ export const getLogbookDataForContext = async (
startDate: string, startDate: string,
contextId?: string contextId?: string
): Promise<LogbookEntry[]> => { ): Promise<LogbookEntry[]> => {
const localize = await hass.loadBackendTranslation("device_class"); await hass.loadBackendTranslation("device_class");
return addLogbookMessage( return getLogbookDataFromServer(
hass, hass,
localize, startDate,
await getLogbookDataFromServer( undefined,
hass, undefined,
startDate, contextId
undefined,
undefined,
contextId
)
); );
}; };
@@ -56,99 +81,173 @@ export const getLogbookData = async (
hass: HomeAssistant, hass: HomeAssistant,
startDate: string, startDate: string,
endDate: string, endDate: string,
entityId?: string entityIds?: string[],
deviceIds?: string[]
): Promise<LogbookEntry[]> => { ): Promise<LogbookEntry[]> => {
const localize = await hass.loadBackendTranslation("device_class"); await hass.loadBackendTranslation("device_class");
return addLogbookMessage( return deviceIds?.length
hass, ? getLogbookDataFromServer(
localize,
await getLogbookDataCache(hass, startDate, endDate, entityId)
);
};
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(
hass, hass,
localize, startDate,
entry.state, endDate,
stateObj, entityIds,
computeDomain(entry.entity_id!) undefined,
); deviceIds
} )
} : getLogbookDataCache(hass, startDate, endDate, entityIds);
return logbookData;
}; };
export const getLogbookDataCache = async ( const getLogbookDataCache = async (
hass: HomeAssistant, hass: HomeAssistant,
startDate: string, startDate: string,
endDate: string, endDate: string,
entityId?: string entityId?: string[]
) => { ) => {
const ALL_ENTITIES = "*"; const ALL_ENTITIES = "*";
if (!entityId) { const entityIdKey = entityId ? entityId.toString() : ALL_ENTITIES;
entityId = ALL_ENTITIES;
}
const cacheKey = `${startDate}${endDate}`; const cacheKey = `${startDate}${endDate}`;
if (!DATA_CACHE[cacheKey]) { if (!DATA_CACHE[cacheKey]) {
DATA_CACHE[cacheKey] = {}; DATA_CACHE[cacheKey] = {};
} }
if (entityId in DATA_CACHE[cacheKey]) { if (entityIdKey in DATA_CACHE[cacheKey]) {
return DATA_CACHE[cacheKey][entityId]; 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]; 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, hass,
startDate, startDate,
endDate, endDate,
entityId !== ALL_ENTITIES ? entityId : undefined entityId
).then((entries) => entries.reverse()); );
return DATA_CACHE[cacheKey][entityId]; return DATA_CACHE[cacheKey][entityIdKey];
}; };
export const getLogbookDataFromServer = ( const getLogbookDataFromServer = (
hass: HomeAssistant, hass: HomeAssistant,
startDate: string, startDate: string,
endDate?: string, endDate?: string,
entityId?: string, entityIds?: string[],
contextId?: string contextId?: string,
) => { deviceIds?: string[]
let params: any = { ): 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", type: "logbook/get_events",
start_time: startDate, start_time: startDate,
}; };
if (endDate) { if (endDate) {
params = { ...params, end_time: endDate }; params.end_time = endDate;
} }
if (entityId) { if (entityIds?.length) {
params = { ...params, entity_ids: entityId.split(",") }; params.entity_ids = entityIds;
} else if (contextId) { }
params = { ...params, context_id: contextId }; if (deviceIds?.length) {
params.device_ids = deviceIds;
}
if (contextId) {
params.context_id = contextId;
} }
return hass.callWS<LogbookEntry[]>(params); return hass.callWS<LogbookEntry[]>(params);
}; };
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
);
};
export const clearLogbookCache = (startDate: string, endDate: string) => { export const clearLogbookCache = (startDate: string, endDate: string) => {
DATA_CACHE[`${startDate}${endDate}`] = {}; 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, hass: HomeAssistant,
localize: LocalizeFunc, localize: LocalizeFunc,
state: string, state: string,

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,10 @@ import type {
import { BINARY_STATE_ON } from "../common/const"; import { BINARY_STATE_ON } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_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 { caseInsensitiveStringCompare } from "../common/string/compare";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
@@ -35,8 +38,13 @@ export interface UpdateEntity extends HassEntityBase {
} }
export const updateUsesProgress = (entity: UpdateEntity): boolean => export const updateUsesProgress = (entity: UpdateEntity): boolean =>
supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) && updateUsesProgressFromAttributes(entity.attributes);
typeof entity.attributes.in_progress === "number";
export const updateUsesProgressFromAttributes = (attributes: {
[key: string]: any;
}): boolean =>
supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS) &&
typeof attributes.in_progress === "number";
export const updateCanInstall = ( export const updateCanInstall = (
entity: UpdateEntity, entity: UpdateEntity,
@@ -49,6 +57,11 @@ export const updateCanInstall = (
export const updateIsInstalling = (entity: UpdateEntity): boolean => export const updateIsInstalling = (entity: UpdateEntity): boolean =>
updateUsesProgress(entity) || !!entity.attributes.in_progress; 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) => export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
hass.callWS<string | null>({ hass.callWS<string | null>({
type: "update/release_notes", type: "update/release_notes",

View File

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

View File

@@ -145,7 +145,7 @@ export interface ZWaveJSController {
supports_timers: boolean; supports_timers: boolean;
is_heal_network_active: boolean; is_heal_network_active: boolean;
inclusion_state: InclusionState; inclusion_state: InclusionState;
nodes: number[]; nodes: ZWaveJSNodeStatus[];
} }
export interface ZWaveJSNodeStatus { export interface ZWaveJSNodeStatus {
@@ -167,6 +167,9 @@ export interface ZwaveJSNodeMetadata {
wakeup: string; wakeup: string;
reset: string; reset: string;
device_database_url: string; device_database_url: string;
}
export interface ZwaveJSNodeComments {
comments: ZWaveJSNodeComment[]; comments: ZWaveJSNodeComment[];
} }
@@ -200,8 +203,7 @@ export interface ZWaveJSNodeConfigParamMetadata {
export interface ZWaveJSSetConfigParamData { export interface ZWaveJSSetConfigParamData {
type: string; type: string;
entry_id: string; device_id: string;
node_id: number;
property: number; property: number;
property_key?: number; property_key?: number;
value: string | number; value: string | number;
@@ -228,6 +230,20 @@ export interface ZWaveJSHealNetworkStatusMessage {
heal_node_status: { [key: number]: string }; 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 { export interface ZWaveJSRemovedNode {
node_id: number; node_id: number;
manufacturer: string; manufacturer: string;
@@ -285,12 +301,23 @@ export const migrateZwave = (
export const fetchZwaveNetworkStatus = ( export const fetchZwaveNetworkStatus = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string device_or_entry_id: {
): Promise<ZWaveJSNetwork> => device_id?: string;
hass.callWS({ 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", 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 = ( export const fetchZwaveDataCollectionStatus = (
hass: HomeAssistant, hass: HomeAssistant,
@@ -427,49 +454,50 @@ export const unprovisionZwaveSmartStartNode = (
export const fetchZwaveNodeStatus = ( export const fetchZwaveNodeStatus = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string
node_id: number
): Promise<ZWaveJSNodeStatus> => ): Promise<ZWaveJSNodeStatus> =>
hass.callWS({ hass.callWS({
type: "zwave_js/node_status", type: "zwave_js/node_status",
entry_id, device_id,
node_id,
}); });
export const fetchZwaveNodeMetadata = ( export const fetchZwaveNodeMetadata = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string
node_id: number
): Promise<ZwaveJSNodeMetadata> => ): Promise<ZwaveJSNodeMetadata> =>
hass.callWS({ hass.callWS({
type: "zwave_js/node_metadata", type: "zwave_js/node_metadata",
entry_id, device_id,
node_id, });
export const fetchZwaveNodeComments = (
hass: HomeAssistant,
device_id: string
): Promise<ZwaveJSNodeComments> =>
hass.callWS({
type: "zwave_js/node_comments",
device_id,
}); });
export const fetchZwaveNodeConfigParameters = ( export const fetchZwaveNodeConfigParameters = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string
node_id: number
): Promise<ZWaveJSNodeConfigParams> => ): Promise<ZWaveJSNodeConfigParams> =>
hass.callWS({ hass.callWS({
type: "zwave_js/get_config_parameters", type: "zwave_js/get_config_parameters",
entry_id, device_id,
node_id,
}); });
export const setZwaveNodeConfigParameter = ( export const setZwaveNodeConfigParameter = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string,
node_id: number,
property: number, property: number,
value: number, value: number,
property_key?: number property_key?: number
): Promise<ZWaveJSSetConfigParamResult> => { ): Promise<ZWaveJSSetConfigParamResult> => {
const data: ZWaveJSSetConfigParamData = { const data: ZWaveJSSetConfigParamData = {
type: "zwave_js/set_config_parameter", type: "zwave_js/set_config_parameter",
entry_id, device_id,
node_id,
property, property,
value, value,
property_key, property_key,
@@ -479,42 +507,36 @@ export const setZwaveNodeConfigParameter = (
export const reinterviewZwaveNode = ( export const reinterviewZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string,
node_id: number,
callbackFunction: (message: ZWaveJSRefreshNodeStatusMessage) => void callbackFunction: (message: ZWaveJSRefreshNodeStatusMessage) => void
): Promise<UnsubscribeFunc> => ): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage( hass.connection.subscribeMessage(
(message: any) => callbackFunction(message), (message: any) => callbackFunction(message),
{ {
type: "zwave_js/refresh_node_info", type: "zwave_js/refresh_node_info",
entry_id, device_id,
node_id,
} }
); );
export const healZwaveNode = ( export const healZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string
node_id: number
): Promise<boolean> => ): Promise<boolean> =>
hass.callWS({ hass.callWS({
type: "zwave_js/heal_node", type: "zwave_js/heal_node",
entry_id, device_id,
node_id,
}); });
export const removeFailedZwaveNode = ( export const removeFailedZwaveNode = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string,
node_id: number,
callbackFunction: (message: any) => void callbackFunction: (message: any) => void
): Promise<UnsubscribeFunc> => ): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage( hass.connection.subscribeMessage(
(message: any) => callbackFunction(message), (message: any) => callbackFunction(message),
{ {
type: "zwave_js/remove_failed_node", type: "zwave_js/remove_failed_node",
entry_id, device_id,
node_id,
} }
); );
@@ -538,16 +560,14 @@ export const stopHealZwaveNetwork = (
export const subscribeZwaveNodeReady = ( export const subscribeZwaveNodeReady = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, device_id: string,
node_id: number,
callbackFunction: (message) => void callbackFunction: (message) => void
): Promise<UnsubscribeFunc> => ): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage( hass.connection.subscribeMessage(
(message: any) => callbackFunction(message), (message: any) => callbackFunction(message),
{ {
type: "zwave_js/node_ready", type: "zwave_js/node_ready",
entry_id, device_id,
node_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 = ( export const getZwaveJsIdentifiersFromDevice = (
device: DeviceRegistryEntry device: DeviceRegistryEntry
): ZWaveJSNodeIdentifiers | undefined => { ): ZWaveJSNodeIdentifiers | undefined => {

View File

@@ -309,7 +309,7 @@ class DataEntryFlowDialog extends LitElement {
: this._step.type === "abort" : this._step.type === "abort"
? html` ? html`
<step-flow-abort <step-flow-abort
.flowConfig=${this._params.flowConfig} .params=${this._params}
.step=${this._step} .step=${this._step}
.hass=${this.hass} .hass=${this.hass}
.domain=${this._step.handler} .domain=${this._step.handler}

View File

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

View File

@@ -1,15 +1,25 @@
import "@material/mwc-button"; 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 { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { DataEntryFlowStepAbort } from "../../data/data_entry_flow"; import { DataEntryFlowStepAbort } from "../../data/data_entry_flow";
import { HomeAssistant } from "../../types"; 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 { 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") @customElement("step-flow-abort")
class StepFlowAbort extends LitElement { class StepFlowAbort extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig; @property({ attribute: false }) public params!: DataEntryFlowDialogParams;
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -17,11 +27,21 @@ class StepFlowAbort extends LitElement {
@property({ attribute: false }) public domain!: string; @property({ attribute: false }) public domain!: string;
protected firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
if (this.step.reason === "missing_credentials") {
this._handleMissingCreds();
}
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (this.step.reason === "missing_credentials") {
return html``;
}
return html` return html`
<h2>${this.hass.localize(`component.${this.domain}.title`)}</h2> <h2>${this.hass.localize(`component.${this.domain}.title`)}</h2>
<div class="content"> <div class="content">
${this.flowConfig.renderAbortDescription(this.hass, this.step)} ${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
</div> </div>
<div class="buttons"> <div class="buttons">
<mwc-button @click=${this._flowDone} <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 { private _flowDone(): void {
fireEvent(this, "flow-update", { step: undefined }); fireEvent(this, "flow-update", { step: undefined });
} }

View File

@@ -86,6 +86,7 @@ export const showDialog = async (
if (mainWindow.history.state?.replaced) { if (mainWindow.history.state?.replaced) {
LOADED[dialogTag].closedFocusTargets = LOADED[dialogTag].closedFocusTargets =
LOADED[mainWindow.history.state.dialog].closedFocusTargets; LOADED[mainWindow.history.state.dialog].closedFocusTargets;
delete LOADED[mainWindow.history.state.dialog].closedFocusTargets;
} else { } else {
LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty( LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty(
deepActiveElement(), deepActiveElement(),

View File

@@ -1,35 +1,14 @@
import { import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { property, state } from "lit/decorators"; 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 "../../../components/ha-camera-stream";
import type { HaCheckbox } from "../../../components/ha-checkbox"; import { CameraEntity } from "../../../data/camera";
import "../../../components/ha-checkbox";
import {
CameraEntity,
CameraPreferences,
CAMERA_SUPPORT_STREAM,
fetchCameraPrefs,
STREAM_TYPE_HLS,
updateCameraPrefs,
} from "../../../data/camera";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "../../../components/ha-formfield";
class MoreInfoCamera extends LitElement { class MoreInfoCamera extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: CameraEntity; @property({ attribute: false }) public stateObj?: CameraEntity;
@state() private _cameraPrefs?: CameraPreferences;
@state() private _attached = false; @state() private _attached = false;
public connectedCallback() { public connectedCallback() {
@@ -54,83 +33,13 @@ class MoreInfoCamera extends LitElement {
allow-exoplayer allow-exoplayer
controls controls
></ha-camera-stream> ></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 { static get styles(): CSSResultGroup {
return css` return css`
:host { :host {
display: block; 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 { import {
mdiAlertCircleOutline,
mdiEye, mdiEye,
mdiGauge, mdiGauge,
mdiThermometer, mdiThermometer,
mdiWaterPercent, mdiWaterPercent,
mdiWeatherCloudy,
mdiWeatherFog,
mdiWeatherHail,
mdiWeatherLightning,
mdiWeatherLightningRainy,
mdiWeatherNight,
mdiWeatherPartlyCloudy,
mdiWeatherPouring,
mdiWeatherRainy,
mdiWeatherSnowy,
mdiWeatherSnowyRainy,
mdiWeatherSunny,
mdiWeatherWindy, mdiWeatherWindy,
mdiWeatherWindyVariant,
} from "@mdi/js"; } from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { import {
@@ -37,27 +23,10 @@ import {
getWeatherUnit, getWeatherUnit,
getWind, getWind,
isForecastHourly, isForecastHourly,
weatherIcons,
} from "../../../data/weather"; } from "../../../data/weather";
import { HomeAssistant } from "../../../types"; 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") @customElement("more-info-weather")
class MoreInfoWeather extends LitElement { class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -235,6 +204,7 @@ class MoreInfoWeather extends LitElement {
return css` return css`
ha-svg-icon { ha-svg-icon {
color: var(--paper-item-icon-color); color: var(--paper-item-icon-color);
margin-left: 8px;
} }
.section { .section {
margin: 16px 0 8px 0; margin: 16px 0 8px 0;

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 { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; 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 { 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 { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event"; 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 "../../panels/logbook/ha-logbook";
import { haStyle } from "../../resources/styles"; import type { HomeAssistant } from "../../types";
import { HomeAssistant } from "../../types";
@customElement("ha-more-info-logbook") @customElement("ha-more-info-logbook")
export class MoreInfoLogbook extends LitElement { export class MoreInfoLogbook extends LitElement {
@@ -19,26 +13,14 @@ export class MoreInfoLogbook extends LitElement {
@property() public entityId!: string; @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 _showMoreHref = "";
private _throttleGetLogbookEntries = throttle(() => { private _time = { recent: 86400 };
this._getLogBookData();
}, 10000); private _entityIdAsList = memoizeOne((entityId: string) => [entityId]);
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.entityId) { if (!isComponentLoaded(this.hass, "logbook") || !this.entityId) {
return html``; return html``;
} }
const stateObj = this.hass.states[this.entityId]; const stateObj = this.hass.states[this.entityId];
@@ -48,149 +30,34 @@ export class MoreInfoLogbook extends LitElement {
} }
return html` return html`
${isComponentLoaded(this.hass, "logbook") <div class="header">
? this._error <div class="title">
? html`<div class="no-entries"> ${this.hass.localize("ui.dialogs.more_info_control.logbook")}
${`${this.hass.localize( </div>
"ui.components.logbook.retrieval_error" <a href=${this._showMoreHref} @click=${this._close}
)}: ${this._error}`} >${this.hass.localize("ui.dialogs.more_info_control.show_more")}</a
</div>` >
: !this._logbookEntries </div>
? html` <ha-logbook
<ha-circular-progress .hass=${this.hass}
active .time=${this._time}
alt=${this.hass.localize("ui.common.loading")} .entityIds=${this._entityIdAsList(this.entityId)}
></ha-circular-progress> narrow
` no-icon
: this._logbookEntries.length no-name
? html` relative-time
<div class="header"> ></ha-logbook>
<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>`
: ""}
`; `;
} }
protected firstUpdated(): void { protected willUpdate(changedProps: PropertyValues): void {
this._fetchUserPromise = this._fetchUserNames(); super.willUpdate(changedProps);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("entityId")) {
this._lastLogbookDate = undefined;
this._logbookEntries = undefined;
if (!this.entityId) {
return;
}
if (changedProps.has("entityId") && this.entityId) {
this._showMoreHref = `/logbook?entity_id=${ this._showMoreHref = `/logbook?entity_id=${
this.entityId this.entityId
}&start_date=${startOfYesterday().toISOString()}`; }&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
),
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 { private _close(): void {
@@ -199,13 +66,7 @@ export class MoreInfoLogbook extends LitElement {
static get styles() { static get styles() {
return [ return [
haStyle,
css` css`
.no-entries {
text-align: center;
padding: 16px;
color: var(--secondary-text-color);
}
ha-logbook { ha-logbook {
--logbook-max-height: 250px; --logbook-max-height: 250px;
} }
@@ -214,10 +75,6 @@ export class MoreInfoLogbook extends LitElement {
--logbook-max-height: unset; --logbook-max-height: unset;
} }
} }
ha-circular-progress {
display: flex;
justify-content: center;
}
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@@ -49,12 +49,14 @@ class OnboardingIntegrations extends LitElement {
this.hass.loadBackendTranslation("title", undefined, true); this.hass.loadBackendTranslation("title", undefined, true);
this._unsubEvents = subscribeConfigFlowInProgress(this.hass, (flows) => { this._unsubEvents = subscribeConfigFlowInProgress(this.hass, (flows) => {
this._discovered = flows; this._discovered = flows;
const integrations: Set<string> = new Set();
for (const flow of flows) { for (const flow of flows) {
// To render title placeholders // To render title placeholders
if (flow.context.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 { .today {
margin-right: 20px; margin-right: 20px;
margin-inline-end: 20px;
margin-inline-start: initial;
direction: var(--direction);
} }
.prev, .prev,

View File

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

View File

@@ -41,6 +41,8 @@ export class DialogAddApplicationCredential extends LitElement {
@state() private _domain?: string; @state() private _domain?: string;
@state() private _name?: string;
@state() private _clientId?: string; @state() private _clientId?: string;
@state() private _clientSecret?: string; @state() private _clientSecret?: string;
@@ -49,7 +51,9 @@ export class DialogAddApplicationCredential extends LitElement {
public showDialog(params: AddApplicationCredentialDialogParams) { public showDialog(params: AddApplicationCredentialDialogParams) {
this._params = params; this._params = params;
this._domain = ""; this._domain =
params.selectedDomain !== undefined ? params.selectedDomain : "";
this._name = "";
this._clientId = ""; this._clientId = "";
this._clientSecret = ""; this._clientSecret = "";
this._error = undefined; this._error = undefined;
@@ -72,7 +76,7 @@ export class DialogAddApplicationCredential extends LitElement {
return html` return html`
<ha-dialog <ha-dialog
open open
@closed=${this.closeDialog} @closed=${this._abortDialog}
scrimClickAction scrimClickAction
escapeKeyAction escapeKeyAction
.heading=${createCloseHeading( .heading=${createCloseHeading(
@@ -87,6 +91,7 @@ export class DialogAddApplicationCredential extends LitElement {
<ha-combo-box <ha-combo-box
name="domain" name="domain"
.hass=${this.hass} .hass=${this.hass}
.disabled=${!!this._params.selectedDomain}
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.domain" "ui.panel.config.application_credentials.editor.domain"
)} )}
@@ -99,6 +104,18 @@ export class DialogAddApplicationCredential extends LitElement {
required required
@value-changed=${this._handleDomainPicked} @value-changed=${this._handleDomainPicked}
></ha-combo-box> ></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 <ha-textfield
class="clientId" class="clientId"
name="clientId" name="clientId"
@@ -166,6 +183,13 @@ export class DialogAddApplicationCredential extends LitElement {
this[`_${name}`] = value; this[`_${name}`] = value;
} }
private _abortDialog() {
if (this._params && this._params.dialogAbortedCallback) {
this._params.dialogAbortedCallback();
}
this.closeDialog();
}
private async _createApplicationCredential(ev) { private async _createApplicationCredential(ev) {
ev.preventDefault(); ev.preventDefault();
if (!this._domain || !this._clientId || !this._clientSecret) { if (!this._domain || !this._clientId || !this._clientSecret) {
@@ -181,7 +205,8 @@ export class DialogAddApplicationCredential extends LitElement {
this.hass, this.hass,
this._domain, this._domain,
this._clientId, this._clientId,
this._clientSecret this._clientSecret,
this._name
); );
} catch (err: any) { } catch (err: any) {
this._loading = false; this._loading = false;

View File

@@ -19,7 +19,10 @@ import {
fetchApplicationCredentials, fetchApplicationCredentials,
} from "../../../data/application_credential"; } from "../../../data/application_credential";
import { domainToName } from "../../../data/integration"; import { domainToName } from "../../../data/integration";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table"; import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table"; import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
@@ -46,13 +49,22 @@ export class HaConfigApplicationCredentials extends LitElement {
private _columns = memoizeOne( private _columns = memoizeOne(
(narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => { (narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<ApplicationCredential> = { 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: { clientId: {
title: localize( title: localize(
"ui.panel.config.application_credentials.picker.headers.client_id" "ui.panel.config.application_credentials.picker.headers.client_id"
), ),
width: "25%", width: "30%",
direction: "asc", direction: "asc",
grows: true, hidden: narrow,
template: (_, entry: ApplicationCredential) => template: (_, entry: ApplicationCredential) =>
html`${entry.client_id}`, html`${entry.client_id}`,
}, },
@@ -61,9 +73,8 @@ export class HaConfigApplicationCredentials extends LitElement {
"ui.panel.config.application_credentials.picker.headers.application" "ui.panel.config.application_credentials.picker.headers.application"
), ),
sortable: true, sortable: true,
width: "20%", width: "30%",
direction: "asc", direction: "asc",
hidden: narrow,
template: (_, entry) => html`${domainToName(localize, entry.domain)}`, template: (_, entry) => html`${domainToName(localize, entry.domain)}`,
}, },
}; };
@@ -171,11 +182,24 @@ export class HaConfigApplicationCredentials extends LitElement {
confirmText: this.hass.localize("ui.common.remove"), confirmText: this.hass.localize("ui.common.remove"),
dismissText: this.hass.localize("ui.common.cancel"), dismissText: this.hass.localize("ui.common.cancel"),
confirm: async () => { confirm: async () => {
await Promise.all( try {
this._selected.map(async (applicationCredential) => { await Promise.all(
await deleteApplicationCredential(this.hass, applicationCredential); 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._dataTable.clearSelection();
this._fetchApplicationCredentials(); this._fetchApplicationCredentials();
}, },
@@ -228,6 +252,8 @@ export class HaConfigApplicationCredentials extends LitElement {
.selected-txt { .selected-txt {
font-weight: bold; font-weight: bold;
padding-left: 16px; padding-left: 16px;
padding-inline-start: 16px;
direction: var(--direction);
} }
.table-header .selected-txt { .table-header .selected-txt {
margin-top: 20px; margin-top: 20px;

View File

@@ -5,6 +5,8 @@ export interface AddApplicationCredentialDialogParams {
applicationCredentialAddedCallback: ( applicationCredentialAddedCallback: (
applicationCredential: ApplicationCredential applicationCredential: ApplicationCredential
) => void; ) => void;
dialogAbortedCallback?: () => void;
selectedDomain?: string;
} }
export const loadAddApplicationCredentialDialog = () => export const loadAddApplicationCredentialDialog = () =>

View File

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

View File

@@ -1,6 +1,7 @@
import { mdiHelpCircle, mdiPlus } from "@mdi/js"; import { mdiHelpCircle, mdiPlus } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
@@ -9,12 +10,20 @@ import "../../../components/ha-svg-icon";
import { import {
AreaRegistryEntry, AreaRegistryEntry,
createAreaRegistryEntry, createAreaRegistryEntry,
subscribeAreaRegistry,
} from "../../../data/area_registry"; } from "../../../data/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device_registry"; import {
import type { EntityRegistryEntry } from "../../../data/entity_registry"; DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage"; import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import "../ha-config-section"; import "../ha-config-section";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
@@ -24,7 +33,7 @@ import {
} from "./show-dialog-area-registry-detail"; } from "./show-dialog-area-registry-detail";
@customElement("ha-config-areas-dashboard") @customElement("ha-config-areas-dashboard")
export class HaConfigAreasDashboard extends LitElement { export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public isWide?: boolean; @property() public isWide?: boolean;
@@ -33,13 +42,13 @@ export class HaConfigAreasDashboard extends LitElement {
@property() public route!: Route; @property() public route!: Route;
@property() public areas!: AreaRegistryEntry[]; @state() private _areas!: AreaRegistryEntry[];
@property() public devices!: DeviceRegistryEntry[]; @state() private _devices!: DeviceRegistryEntry[];
@property() public entities!: EntityRegistryEntry[]; @state() private _entities!: EntityRegistryEntry[];
private _areas = memoizeOne( private _processAreas = memoizeOne(
( (
areas: AreaRegistryEntry[], areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[], devices: DeviceRegistryEntry[],
@@ -75,6 +84,20 @@ export class HaConfigAreasDashboard 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 { protected render(): TemplateResult {
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage
@@ -92,56 +115,62 @@ export class HaConfigAreasDashboard extends LitElement {
@click=${this._showHelp} @click=${this._showHelp}
></ha-icon-button> ></ha-icon-button>
<div class="container"> <div class="container">
${this._areas(this.areas, this.devices, this.entities).map( ${!this._areas || !this._devices || !this._entities
(area) => ? ""
html`<a href=${`/config/areas/area/${area.area_id}`} : this._processAreas(
><ha-card outlined> this._areas,
<div this._devices,
style=${styleMap({ this._entities
backgroundImage: area.picture ).map(
? `url(${area.picture})` (area) =>
: undefined, html`<a href=${`/config/areas/area/${area.area_id}`}
})} ><ha-card outlined>
class="picture ${!area.picture ? "placeholder" : ""}" <div
></div> style=${styleMap({
<h1 class="card-header">${area.name}</h1> backgroundImage: area.picture
<div class="card-content"> ? `url(${area.picture})`
<div> : undefined,
${area.devices })}
? html` class="picture ${!area.picture ? "placeholder" : ""}"
${this.hass.localize( ></div>
"ui.panel.config.integrations.config_entry.devices", <h1 class="card-header">${area.name}</h1>
"count", <div class="card-content">
area.devices <div>
)}${area.services ? "," : ""} ${area.devices
` ? html`
: ""} ${this.hass.localize(
${area.services "ui.panel.config.integrations.config_entry.devices",
? html` "count",
${this.hass.localize( area.devices
"ui.panel.config.integrations.config_entry.services", )}${area.services ? "," : ""}
"count", `
area.services : ""}
)} ${area.services
` ? html`
: ""} ${this.hass.localize(
${(area.devices || area.services) && area.entities "ui.panel.config.integrations.config_entry.services",
? this.hass.localize("ui.common.and") "count",
: ""} area.services
${area.entities )}
? html` `
${this.hass.localize( : ""}
"ui.panel.config.integrations.config_entry.entities", ${(area.devices || area.services) && area.entities
"count", ? this.hass.localize("ui.common.and")
area.entities : ""}
)} ${area.entities
` ? html`
: ""} ${this.hass.localize(
</div> "ui.panel.config.integrations.config_entry.entities",
</div> "count",
</ha-card></a area.entities
>` )}
)} `
: ""}
</div>
</div>
</ha-card></a
>`
)}
</div> </div>
<ha-fab <ha-fab
slot="fab" slot="fab"

View File

@@ -1,20 +1,4 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { customElement, property } from "lit/decorators";
import { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { stringCompare } from "../../../common/string/compare";
import {
AreaRegistryEntry,
subscribeAreaRegistry,
} from "../../../data/area_registry";
import { ConfigEntry, getConfigEntries } from "../../../data/config_entries";
import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { import {
HassRouterPage, HassRouterPage,
RouterOptions, RouterOptions,
@@ -46,44 +30,6 @@ class HaConfigAreas extends HassRouterPage {
}, },
}; };
@state() private _configEntries: ConfigEntry[] = [];
@state()
private _deviceRegistryEntries: DeviceRegistryEntry[] = [];
@state()
private _entityRegistryEntries: EntityRegistryEntry[] = [];
@state() private _areas: AreaRegistryEntry[] = [];
private _unsubs?: UnsubscribeFunc[];
public connectedCallback() {
super.connectedCallback();
if (!this.hass) {
return;
}
this._loadData();
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubs) {
while (this._unsubs.length) {
this._unsubs.pop()!();
}
this._unsubs = undefined;
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (!this._unsubs && changedProps.has("hass")) {
this._loadData();
}
}
protected updatePageEl(pageEl) { protected updatePageEl(pageEl) {
pageEl.hass = this.hass; pageEl.hass = this.hass;
@@ -91,37 +37,11 @@ class HaConfigAreas extends HassRouterPage {
pageEl.areaId = this.routeTail.path.substr(1); pageEl.areaId = this.routeTail.path.substr(1);
} }
pageEl.entries = this._configEntries;
pageEl.devices = this._deviceRegistryEntries;
pageEl.entities = this._entityRegistryEntries;
pageEl.areas = this._areas;
pageEl.narrow = this.narrow; pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide; pageEl.isWide = this.isWide;
pageEl.showAdvanced = this.showAdvanced; pageEl.showAdvanced = this.showAdvanced;
pageEl.route = this.routeTail; pageEl.route = this.routeTail;
} }
private _loadData() {
getConfigEntries(this.hass).then((configEntries) => {
this._configEntries = configEntries.sort((conf1, conf2) =>
stringCompare(conf1.title, conf2.title)
);
});
if (this._unsubs) {
return;
}
this._unsubs = [
subscribeAreaRegistry(this.hass.connection, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._deviceRegistryEntries = entries;
}),
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entityRegistryEntries = entries;
}),
];
}
} }
declare global { declare global {

View File

@@ -0,0 +1,13 @@
import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import type { DeviceAction } from "../../../ha-config-device-page";
import { showMQTTDeviceDebugInfoDialog } from "./show-dialog-mqtt-device-debug-info";
export const getMQTTDeviceActions = (
el: HTMLElement,
device: DeviceRegistryEntry
): DeviceAction[] => [
{
label: "MQTT Info",
action: async () => showMQTTDeviceDebugInfoDialog(el, { device }),
},
];

View File

@@ -1,36 +0,0 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import { haStyle } from "../../../../../../resources/styles";
import { HomeAssistant } from "../../../../../../types";
import { showMQTTDeviceDebugInfoDialog } from "./show-dialog-mqtt-device-debug-info";
@customElement("ha-device-actions-mqtt")
export class HaDeviceActionsMqtt extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public device!: DeviceRegistryEntry;
protected render(): TemplateResult {
return html`
<mwc-button @click=${this._showDebugInfo}> MQTT Info </mwc-button>
`;
}
private async _showDebugInfo(): Promise<void> {
const device = this.device;
await showMQTTDeviceDebugInfoDialog(this, { device });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: flex;
justify-content: space-between;
}
`,
];
}
}

View File

@@ -0,0 +1,108 @@
import { navigate } from "../../../../../../common/navigate";
import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import { fetchZHADevice } from "../../../../../../data/zha";
import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../../../types";
import { showZHAClusterDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-cluster";
import { showZHADeviceChildrenDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-device-children";
import { showZHADeviceZigbeeInfoDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-device-zigbee-info";
import { showZHAReconfigureDeviceDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-reconfigure-device";
import type { DeviceAction } from "../../../ha-config-device-page";
export const getZHADeviceActions = async (
el: HTMLElement,
hass: HomeAssistant,
device: DeviceRegistryEntry
): Promise<DeviceAction[]> => {
const zigbeeConnection = device.connections.find(
(conn) => conn[0] === "zigbee"
);
if (!zigbeeConnection) {
return [];
}
const zhaDevice = await fetchZHADevice(hass, zigbeeConnection[1]);
if (!zhaDevice) {
return [];
}
const actions: DeviceAction[] = [];
if (zhaDevice.device_type !== "Coordinator") {
actions.push({
label: hass.localize("ui.dialogs.zha_device_info.buttons.reconfigure"),
action: () => showZHAReconfigureDeviceDialog(el, { device: zhaDevice }),
});
}
if (
zhaDevice.power_source === "Mains" &&
(zhaDevice.device_type === "Router" ||
zhaDevice.device_type === "Coordinator")
) {
actions.push(
...[
{
label: hass.localize("ui.dialogs.zha_device_info.buttons.add"),
action: () => navigate(`/config/zha/add/${zhaDevice!.ieee}`),
},
{
label: hass.localize(
"ui.dialogs.zha_device_info.buttons.device_children"
),
action: () => showZHADeviceChildrenDialog(el, { device: zhaDevice! }),
},
]
);
}
if (zhaDevice.device_type !== "Coordinator") {
actions.push(
...[
{
label: hass.localize(
"ui.dialogs.zha_device_info.buttons.zigbee_information"
),
action: () =>
showZHADeviceZigbeeInfoDialog(el, { device: zhaDevice }),
},
{
label: hass.localize("ui.dialogs.zha_device_info.buttons.clusters"),
action: () => showZHAClusterDialog(el, { device: zhaDevice }),
},
{
label: hass.localize(
"ui.dialogs.zha_device_info.buttons.view_in_visualization"
),
action: () =>
navigate(`/config/zha/visualization/${zhaDevice!.device_reg_id}`),
},
{
label: hass.localize("ui.dialogs.zha_device_info.buttons.remove"),
classes: "warning",
action: async () => {
const confirmed = await showConfirmationDialog(el, {
text: hass.localize(
"ui.dialogs.zha_device_info.confirmations.remove"
),
});
if (!confirmed) {
return;
}
await hass.callService("zha", "remove", {
ieee: zhaDevice.ieee,
});
history.back();
},
},
]
);
}
return actions;
};

View File

@@ -1,155 +0,0 @@
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { navigate } from "../../../../../../common/navigate";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import { fetchZHADevice, ZHADevice } from "../../../../../../data/zha";
import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../../../resources/styles";
import { HomeAssistant } from "../../../../../../types";
import { showZHAClusterDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-cluster";
import { showZHADeviceChildrenDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-device-children";
import { showZHADeviceZigbeeInfoDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-device-zigbee-info";
import { showZHAReconfigureDeviceDialog } from "../../../../integrations/integration-panels/zha/show-dialog-zha-reconfigure-device";
@customElement("ha-device-actions-zha")
export class HaDeviceActionsZha extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public device!: DeviceRegistryEntry;
@state() private _zhaDevice?: ZHADevice;
protected updated(changedProperties: PropertyValues) {
if (changedProperties.has("device")) {
const zigbeeConnection = this.device.connections.find(
(conn) => conn[0] === "zigbee"
);
if (!zigbeeConnection) {
return;
}
fetchZHADevice(this.hass, zigbeeConnection[1]).then((device) => {
this._zhaDevice = device;
});
}
}
protected render(): TemplateResult {
if (!this._zhaDevice) {
return html``;
}
return html`
${this._zhaDevice.device_type !== "Coordinator"
? html`
<mwc-button @click=${this._onReconfigureNodeClick}>
${this.hass!.localize(
"ui.dialogs.zha_device_info.buttons.reconfigure"
)}
</mwc-button>
`
: ""}
${this._zhaDevice.power_source === "Mains" &&
(this._zhaDevice.device_type === "Router" ||
this._zhaDevice.device_type === "Coordinator")
? html`
<mwc-button @click=${this._onAddDevicesClick}>
${this.hass!.localize("ui.dialogs.zha_device_info.buttons.add")}
</mwc-button>
<mwc-button @click=${this._handleDeviceChildrenClicked}>
${this.hass!.localize(
"ui.dialogs.zha_device_info.buttons.device_children"
)}
</mwc-button>
`
: ""}
${this._zhaDevice.device_type !== "Coordinator"
? html`
<mwc-button @click=${this._handleZigbeeInfoClicked}>
${this.hass!.localize(
"ui.dialogs.zha_device_info.buttons.zigbee_information"
)}
</mwc-button>
<mwc-button @click=${this._showClustersDialog}>
${this.hass!.localize(
"ui.dialogs.zha_device_info.buttons.clusters"
)}
</mwc-button>
<mwc-button @click=${this._onViewInVisualizationClick}>
${this.hass!.localize(
"ui.dialogs.zha_device_info.buttons.view_in_visualization"
)}
</mwc-button>
<mwc-button class="warning" @click=${this._removeDevice}>
${this.hass!.localize(
"ui.dialogs.zha_device_info.buttons.remove"
)}
</mwc-button>
`
: ""}
`;
}
private async _showClustersDialog(): Promise<void> {
await showZHAClusterDialog(this, { device: this._zhaDevice! });
}
private async _onReconfigureNodeClick(): Promise<void> {
if (!this.hass) {
return;
}
showZHAReconfigureDeviceDialog(this, { device: this._zhaDevice! });
}
private _onAddDevicesClick() {
navigate(`/config/zha/add/${this._zhaDevice!.ieee}`);
}
private _onViewInVisualizationClick() {
navigate(`/config/zha/visualization/${this._zhaDevice!.device_reg_id}`);
}
private async _handleZigbeeInfoClicked() {
showZHADeviceZigbeeInfoDialog(this, { device: this._zhaDevice! });
}
private async _handleDeviceChildrenClicked() {
showZHADeviceChildrenDialog(this, { device: this._zhaDevice! });
}
private async _removeDevice() {
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.dialogs.zha_device_info.confirmations.remove"
),
});
if (!confirmed) {
return;
}
await this.hass.callService("zha", "remove", {
ieee: this._zhaDevice!.ieee,
});
history.back();
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
}
`,
];
}
}

View File

@@ -7,6 +7,7 @@ import {
TemplateResult, TemplateResult,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import "../../../../../../components/ha-expansion-panel";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; import { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import { fetchZHADevice, ZHADevice } from "../../../../../../data/zha"; import { fetchZHADevice, ZHADevice } from "../../../../../../data/zha";
import { haStyle } from "../../../../../../resources/styles"; import { haStyle } from "../../../../../../resources/styles";
@@ -40,38 +41,39 @@ export class HaDeviceActionsZha extends LitElement {
return html``; return html``;
} }
return html` return html`
<h4>Zigbee info</h4> <ha-expansion-panel header="Zigbee info">
<div>IEEE: ${this._zhaDevice.ieee}</div> <div>IEEE: ${this._zhaDevice.ieee}</div>
<div>Nwk: ${formatAsPaddedHex(this._zhaDevice.nwk)}</div> <div>Nwk: ${formatAsPaddedHex(this._zhaDevice.nwk)}</div>
<div>Device Type: ${this._zhaDevice.device_type}</div> <div>Device Type: ${this._zhaDevice.device_type}</div>
<div> <div>
LQI: LQI:
${this._zhaDevice.lqi || ${this._zhaDevice.lqi ||
this.hass!.localize("ui.dialogs.zha_device_info.unknown")} this.hass!.localize("ui.dialogs.zha_device_info.unknown")}
</div> </div>
<div> <div>
RSSI: RSSI:
${this._zhaDevice.rssi || ${this._zhaDevice.rssi ||
this.hass!.localize("ui.dialogs.zha_device_info.unknown")} this.hass!.localize("ui.dialogs.zha_device_info.unknown")}
</div> </div>
<div> <div>
${this.hass!.localize("ui.dialogs.zha_device_info.last_seen")}: ${this.hass!.localize("ui.dialogs.zha_device_info.last_seen")}:
${this._zhaDevice.last_seen || ${this._zhaDevice.last_seen ||
this.hass!.localize("ui.dialogs.zha_device_info.unknown")} this.hass!.localize("ui.dialogs.zha_device_info.unknown")}
</div> </div>
<div> <div>
${this.hass!.localize("ui.dialogs.zha_device_info.power_source")}: ${this.hass!.localize("ui.dialogs.zha_device_info.power_source")}:
${this._zhaDevice.power_source || ${this._zhaDevice.power_source ||
this.hass!.localize("ui.dialogs.zha_device_info.unknown")} this.hass!.localize("ui.dialogs.zha_device_info.unknown")}
</div> </div>
${this._zhaDevice.quirk_applied ${this._zhaDevice.quirk_applied
? html` ? html`
<div> <div>
${this.hass!.localize("ui.dialogs.zha_device_info.quirk")}: ${this.hass!.localize("ui.dialogs.zha_device_info.quirk")}:
${this._zhaDevice.quirk_class} ${this._zhaDevice.quirk_class}
</div> </div>
` `
: ""} : ""}
</ha-expansion-panel>
`; `;
} }
@@ -86,6 +88,11 @@ export class HaDeviceActionsZha extends LitElement {
word-break: break-all; word-break: break-all;
margin-top: 2px; margin-top: 2px;
} }
ha-expansion-panel {
--expansion-panel-summary-padding: 0;
--expansion-panel-content-padding: 0;
padding-top: 4px;
}
`, `,
]; ];
} }

View File

@@ -0,0 +1,68 @@
import { getConfigEntries } from "../../../../../../data/config_entries";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import { fetchZwaveNodeStatus } from "../../../../../../data/zwave_js";
import type { HomeAssistant } from "../../../../../../types";
import { showZWaveJSHealNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node";
import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node";
import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node";
import type { DeviceAction } from "../../../ha-config-device-page";
export const getZwaveDeviceActions = async (
el: HTMLElement,
hass: HomeAssistant,
device: DeviceRegistryEntry
): Promise<DeviceAction[]> => {
const configEntries = await getConfigEntries(hass, {
domain: "zwave_js",
});
const configEntry = configEntries.find((entry) =>
device.config_entries.includes(entry.entry_id)
);
if (!configEntry) {
return [];
}
const entryId = configEntry.entry_id;
const node = await fetchZwaveNodeStatus(hass, device.id);
if (!node || node.is_controller_node) {
return [];
}
return [
{
label: hass.localize(
"ui.panel.config.zwave_js.device_info.device_config"
),
href: `/config/zwave_js/node_config/${device.id}?config_entry=${entryId}`,
},
{
label: hass.localize(
"ui.panel.config.zwave_js.device_info.reinterview_device"
),
action: () =>
showZWaveJSReinterviewNodeDialog(el, {
device_id: device.id,
}),
},
{
label: hass.localize("ui.panel.config.zwave_js.device_info.heal_node"),
action: () =>
showZWaveJSHealNodeDialog(el, {
device: device,
}),
},
{
label: hass.localize(
"ui.panel.config.zwave_js.device_info.remove_failed"
),
action: () =>
showZWaveJSRemoveFailedNodeDialog(el, {
device_id: device.id,
}),
},
];
};

View File

@@ -1,139 +0,0 @@
import "@material/mwc-button/mwc-button";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import {
fetchZwaveNodeStatus,
getZwaveJsIdentifiersFromDevice,
ZWaveJSNodeIdentifiers,
ZWaveJSNodeStatus,
} from "../../../../../../data/zwave_js";
import { haStyle } from "../../../../../../resources/styles";
import { HomeAssistant } from "../../../../../../types";
import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node";
import { showZWaveJSHealNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node";
import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node";
@customElement("ha-device-actions-zwave_js")
export class HaDeviceActionsZWaveJS extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public device!: DeviceRegistryEntry;
@state() private _entryId?: string;
@state() private _nodeId?: number;
@state() private _node?: ZWaveJSNodeStatus;
protected updated(changedProperties: PropertyValues) {
if (changedProperties.has("device")) {
const identifiers: ZWaveJSNodeIdentifiers | undefined =
getZwaveJsIdentifiersFromDevice(this.device);
if (!identifiers) {
return;
}
this._nodeId = identifiers.node_id;
this._entryId = this.device.config_entries[0];
this._fetchNodeDetails();
}
}
protected async _fetchNodeDetails() {
if (!this._nodeId || !this._entryId) {
return;
}
this._node = await fetchZwaveNodeStatus(
this.hass,
this._entryId,
this._nodeId
);
}
protected render(): TemplateResult {
if (!this._node) {
return html``;
}
return html`
${!this._node.is_controller_node
? html`
<a
.href=${`/config/zwave_js/node_config/${this.device.id}?config_entry=${this._entryId}`}
>
<mwc-button>
${this.hass.localize(
"ui.panel.config.zwave_js.device_info.device_config"
)}
</mwc-button>
</a>
<mwc-button @click=${this._reinterviewClicked}>
${this.hass.localize(
"ui.panel.config.zwave_js.device_info.reinterview_device"
)}
</mwc-button>
<mwc-button @click=${this._healNodeClicked}>
${this.hass.localize(
"ui.panel.config.zwave_js.device_info.heal_node"
)}
</mwc-button>
<mwc-button @click=${this._removeFailedNode}>
${this.hass.localize(
"ui.panel.config.zwave_js.device_info.remove_failed"
)}
</mwc-button>
`
: ""}
`;
}
private async _reinterviewClicked() {
if (!this._nodeId || !this._entryId) {
return;
}
showZWaveJSReinterviewNodeDialog(this, {
entry_id: this._entryId,
node_id: this._nodeId,
});
}
private async _healNodeClicked() {
if (!this._nodeId || !this._entryId) {
return;
}
showZWaveJSHealNodeDialog(this, {
entry_id: this._entryId,
node_id: this._nodeId,
device: this.device,
});
}
private async _removeFailedNode() {
if (!this._nodeId || !this._entryId) {
return;
}
showZWaveJSRemoveFailedNodeDialog(this, {
entry_id: this._entryId,
node_id: this._nodeId,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
a {
text-decoration: none;
}
`,
];
}
}

View File

@@ -0,0 +1,52 @@
import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import {
ZwaveJSNodeComments,
fetchZwaveNodeComments,
} from "../../../../../../data/zwave_js";
import { HomeAssistant } from "../../../../../../types";
@customElement("ha-device-alerts-zwave_js")
export class HaDeviceAlertsZWaveJS extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@state() private _nodeComments?: ZwaveJSNodeComments;
protected willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("device")) {
this._fetchNodeDetails();
}
}
private async _fetchNodeDetails() {
this._nodeComments = await fetchZwaveNodeComments(
this.hass,
this.device.id
);
}
protected render(): TemplateResult {
if (this._nodeComments && this._nodeComments.comments?.length > 0) {
return html`
<div>
${this._nodeComments.comments.map(
(comment) => html`<ha-alert .alertType=${comment.level}>
${comment.text}
</ha-alert>`
)}
</div>
`;
}
return html``;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-alerts-zwave_js": HaDeviceAlertsZWaveJS;
}
}

View File

@@ -7,18 +7,17 @@ import {
TemplateResult, TemplateResult,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; import "../../../../../../components/ha-expansion-panel";
import { import {
ConfigEntry, ConfigEntry,
getConfigEntries, getConfigEntries,
} from "../../../../../../data/config_entries"; } from "../../../../../../data/config_entries";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import { import {
fetchZwaveNodeStatus, fetchZwaveNodeStatus,
getZwaveJsIdentifiersFromDevice,
nodeStatus, nodeStatus,
ZWaveJSNodeStatus,
ZWaveJSNodeIdentifiers,
SecurityClass, SecurityClass,
ZWaveJSNodeStatus,
} from "../../../../../../data/zwave_js"; } from "../../../../../../data/zwave_js";
import { haStyle } from "../../../../../../resources/styles"; import { haStyle } from "../../../../../../resources/styles";
import { HomeAssistant } from "../../../../../../types"; import { HomeAssistant } from "../../../../../../types";
@@ -29,57 +28,41 @@ export class HaDeviceInfoZWaveJS extends LitElement {
@property({ attribute: false }) public device!: DeviceRegistryEntry; @property({ attribute: false }) public device!: DeviceRegistryEntry;
@state() private _entryId?: string;
@state() private _configEntry?: ConfigEntry; @state() private _configEntry?: ConfigEntry;
@state() private _multipleConfigEntries = false; @state() private _multipleConfigEntries = false;
@state() private _nodeId?: number;
@state() private _node?: ZWaveJSNodeStatus; @state() private _node?: ZWaveJSNodeStatus;
protected updated(changedProperties: PropertyValues) { public willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("device")) { if (changedProperties.has("device")) {
const identifiers: ZWaveJSNodeIdentifiers | undefined =
getZwaveJsIdentifiersFromDevice(this.device);
if (!identifiers) {
return;
}
this._nodeId = identifiers.node_id;
this._entryId = this.device.config_entries[0];
this._fetchNodeDetails(); this._fetchNodeDetails();
} }
} }
protected async _fetchNodeDetails() { protected async _fetchNodeDetails() {
if (!this._nodeId || !this._entryId) { if (!this.device) {
return; return;
} }
const configEntries = await getConfigEntries(this.hass, { const configEntries = await getConfigEntries(this.hass, {
domain: "zwave_js", domain: "zwave_js",
}); });
let zwaveJsConfEntries = 0;
for (const entry of configEntries) { this._multipleConfigEntries = configEntries.length > 1;
if (zwaveJsConfEntries) {
this._multipleConfigEntries = true; const configEntry = configEntries.find((entry) =>
} this.device.config_entries.includes(entry.entry_id)
if (entry.entry_id === this._entryId) { );
this._configEntry = entry;
} if (!configEntry) {
if (this._configEntry && this._multipleConfigEntries) { return;
break;
}
zwaveJsConfEntries++;
} }
this._node = await fetchZwaveNodeStatus( this._configEntry = configEntry;
this.hass,
this._entryId, this._node = await fetchZwaveNodeStatus(this.hass, this.device.id);
this._nodeId
);
} }
protected render(): TemplateResult { protected render(): TemplateResult {
@@ -87,73 +70,76 @@ export class HaDeviceInfoZWaveJS extends LitElement {
return html``; return html``;
} }
return html` return html`
<h4> <ha-expansion-panel
${this.hass.localize("ui.panel.config.zwave_js.device_info.zwave_info")} .header=${this.hass.localize(
</h4> "ui.panel.config.zwave_js.device_info.zwave_info"
${this._multipleConfigEntries )}
? html` >
<div> ${this._multipleConfigEntries
${this.hass.localize("ui.panel.config.zwave_js.common.source")}: ? html`
${this._configEntry!.title} <div>
</div> ${this.hass.localize("ui.panel.config.zwave_js.common.source")}:
` ${this._configEntry!.title}
: ""} </div>
<div> `
${this.hass.localize("ui.panel.config.zwave_js.common.node_id")}: : ""}
${this._node.node_id} <div>
</div> ${this.hass.localize("ui.panel.config.zwave_js.common.node_id")}:
${!this._node.is_controller_node ${this._node.node_id}
? html` </div>
<div> ${!this._node.is_controller_node
${this.hass.localize( ? html`
"ui.panel.config.zwave_js.device_info.node_status" <div>
)}: ${this.hass.localize(
${this.hass.localize( "ui.panel.config.zwave_js.device_info.node_status"
`ui.panel.config.zwave_js.node_status.${ )}:
nodeStatus[this._node.status] ${this.hass.localize(
}` `ui.panel.config.zwave_js.node_status.${
)} nodeStatus[this._node.status]
</div> }`
<div> )}
${this.hass.localize( </div>
"ui.panel.config.zwave_js.device_info.node_ready" <div>
)}: ${this.hass.localize(
${this._node.ready "ui.panel.config.zwave_js.device_info.node_ready"
? this.hass.localize("ui.common.yes") )}:
: this.hass.localize("ui.common.no")} ${this._node.ready
</div> ? this.hass.localize("ui.common.yes")
<div> : this.hass.localize("ui.common.no")}
${this.hass.localize( </div>
"ui.panel.config.zwave_js.device_info.highest_security" <div>
)}: ${this.hass.localize(
${this._node.highest_security_class !== null "ui.panel.config.zwave_js.device_info.highest_security"
? this.hass.localize( )}:
`ui.panel.config.zwave_js.security_classes.${ ${this._node.highest_security_class !== null
SecurityClass[this._node.highest_security_class] ? this.hass.localize(
}.title` `ui.panel.config.zwave_js.security_classes.${
) SecurityClass[this._node.highest_security_class]
: this._node.is_secure === false }.title`
? this.hass.localize( )
"ui.panel.config.zwave_js.security_classes.none.title" : this._node.is_secure === false
) ? this.hass.localize(
: this.hass.localize( "ui.panel.config.zwave_js.security_classes.none.title"
"ui.panel.config.zwave_js.device_info.unknown" )
)} : this.hass.localize(
</div> "ui.panel.config.zwave_js.device_info.unknown"
<div> )}
${this.hass.localize( </div>
"ui.panel.config.zwave_js.device_info.zwave_plus" <div>
)}: ${this.hass.localize(
${this._node.zwave_plus_version "ui.panel.config.zwave_js.device_info.zwave_plus"
? this.hass.localize( )}:
"ui.panel.config.zwave_js.device_info.zwave_plus_version", ${this._node.zwave_plus_version
"version", ? this.hass.localize(
this._node.zwave_plus_version "ui.panel.config.zwave_js.device_info.zwave_plus_version",
) "version",
: this.hass.localize("ui.common.no")} this._node.zwave_plus_version
</div> )
` : this.hass.localize("ui.common.no")}
: ""} </div>
`
: ""}
</ha-expansion-panel>
`; `;
} }
@@ -168,6 +154,11 @@ export class HaDeviceInfoZWaveJS extends LitElement {
word-break: break-all; word-break: break-all;
margin-top: 2px; margin-top: 2px;
} }
ha-expansion-panel {
--expansion-panel-summary-padding: 0;
--expansion-panel-content-padding: 0;
padding-top: 4px;
}
`, `,
]; ];
} }

View File

@@ -1,4 +1,9 @@
import { mdiOpenInNew, mdiPencil, mdiPlusCircle } from "@mdi/js"; import {
mdiDotsVertical,
mdiOpenInNew,
mdiPencil,
mdiPlusCircle,
} from "@mdi/js";
import "@polymer/paper-tooltip/paper-tooltip"; import "@polymer/paper-tooltip/paper-tooltip";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
@@ -13,6 +18,7 @@ import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by"; import { groupBy } from "../../../common/util/group-by";
import "../../../components/entity/ha-battery-icon"; import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-button-menu";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next"; import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
@@ -26,14 +32,14 @@ import {
import { import {
computeDeviceName, computeDeviceName,
DeviceRegistryEntry, DeviceRegistryEntry,
updateDeviceRegistryEntry,
removeConfigEntryFromDevice, removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import { import {
fetchDiagnosticHandler,
getDeviceDiagnosticsDownloadUrl,
getConfigEntryDiagnosticsDownloadUrl,
DiagnosticInfo, DiagnosticInfo,
fetchDiagnosticHandler,
getConfigEntryDiagnosticsDownloadUrl,
getDeviceDiagnosticsDownloadUrl,
} from "../../../data/diagnostics"; } from "../../../data/diagnostics";
import { import {
EntityRegistryEntry, EntityRegistryEntry,
@@ -51,9 +57,10 @@ import {
import "../../../layouts/hass-error-screen"; import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-tabs-subpage"; import "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types"; import type { HomeAssistant, Route } from "../../../types";
import { brandsUrl } from "../../../util/brands-url"; import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download"; import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
import "../ha-config-section"; import "../ha-config-section";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import "./device-detail/ha-device-entities-card"; import "./device-detail/ha-device-entities-card";
@@ -68,6 +75,14 @@ export interface EntityRegistryStateEntry extends EntityRegistryEntry {
stateName?: string | null; stateName?: string | null;
} }
export interface DeviceAction {
href?: string;
action?: (ev: any) => void;
label: string;
trailingIcon?: string;
classes?: string;
}
@customElement("ha-config-device-page") @customElement("ha-config-device-page")
export class HaConfigDevicePage extends LitElement { export class HaConfigDevicePage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -93,11 +108,13 @@ export class HaConfigDevicePage extends LitElement {
@state() private _related?: RelatedResult; @state() private _related?: RelatedResult;
// If a number, it's the request ID so we make sure we don't show older info // If a number, it's the request ID so we make sure we don't show older info
@state() private _diagnosticDownloadLinks?: @state() private _diagnosticDownloadLinks?: number | DeviceAction[];
| number
| (TemplateResult | string)[];
@state() private _deleteButtons?: (TemplateResult | string)[]; @state() private _deleteButtons?: DeviceAction[];
@state() private _deviceActions?: DeviceAction[];
private _logbookTime = { recent: 86400 };
private _device = memoizeOne( private _device = memoizeOne(
( (
@@ -131,6 +148,13 @@ export class HaConfigDevicePage extends LitElement {
) )
); );
private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]);
private _entityIds = memoizeOne(
(entries: EntityRegistryStateEntry[]): string[] =>
entries.map((entry) => entry.entity_id)
);
private _entitiesByCategory = memoizeOne( private _entitiesByCategory = memoizeOne(
(entities: EntityRegistryEntry[]) => { (entities: EntityRegistryEntry[]) => {
const result = groupBy(entities, (entry) => const result = groupBy(entities, (entry) =>
@@ -186,15 +210,17 @@ export class HaConfigDevicePage extends LitElement {
if ( if (
changedProps.has("deviceId") || changedProps.has("deviceId") ||
changedProps.has("devices") || changedProps.has("devices") ||
changedProps.has("deviceId") ||
changedProps.has("entries") changedProps.has("entries")
) { ) {
this._diagnosticDownloadLinks = undefined; this._diagnosticDownloadLinks = undefined;
this._deleteButtons = undefined; this._deleteButtons = undefined;
this._deviceActions = undefined;
} }
if ( if (
(this._diagnosticDownloadLinks && this._deleteButtons) || (this._diagnosticDownloadLinks &&
this._deleteButtons &&
this._deviceActions) ||
!this.devices || !this.devices ||
!this.deviceId || !this.deviceId ||
!this.entries !this.entries
@@ -204,129 +230,10 @@ export class HaConfigDevicePage extends LitElement {
this._diagnosticDownloadLinks = Math.random(); this._diagnosticDownloadLinks = Math.random();
this._deleteButtons = []; // To prevent re-rendering if no delete buttons this._deleteButtons = []; // To prevent re-rendering if no delete buttons
this._renderDiagnosticButtons(this._diagnosticDownloadLinks); this._deviceActions = [];
this._renderDeleteButtons(); this._getDiagnosticButtons(this._diagnosticDownloadLinks);
} this._getDeleteActions();
this._getDeviceActions();
private async _renderDiagnosticButtons(requestId: number): Promise<void> {
if (!isComponentLoaded(this.hass, "diagnostics")) {
return;
}
const device = this._device(this.deviceId, this.devices);
if (!device) {
return;
}
let links = await Promise.all(
this._integrations(device, this.entries).map(
async (entry): Promise<boolean | { link: string; domain: string }> => {
if (entry.state !== "loaded") {
return false;
}
let info: DiagnosticInfo;
try {
info = await fetchDiagnosticHandler(this.hass, entry.domain);
} catch (err: any) {
if (err.code === "not_found") {
return false;
}
throw err;
}
if (!info.handlers.device && !info.handlers.config_entry) {
return false;
}
return {
link: info.handlers.device
? getDeviceDiagnosticsDownloadUrl(entry.entry_id, this.deviceId)
: getConfigEntryDiagnosticsDownloadUrl(entry.entry_id),
domain: entry.domain,
};
}
)
);
links = links.filter(Boolean);
if (this._diagnosticDownloadLinks !== requestId) {
return;
}
if (links.length > 0) {
this._diagnosticDownloadLinks = (
links as { link: string; domain: string }[]
).map(
(link) => html`
<a href=${link.link} @click=${this._signUrl}>
<mwc-button>
${links.length > 1
? this.hass.localize(
`ui.panel.config.devices.download_diagnostics_integration`,
{
integration: domainToName(
this.hass.localize,
link.domain
),
}
)
: this.hass.localize(
`ui.panel.config.devices.download_diagnostics`
)}
</mwc-button>
</a>
`
);
}
}
private _renderDeleteButtons() {
const device = this._device(this.deviceId, this.devices);
if (!device) {
return;
}
const buttons: TemplateResult[] = [];
this._integrations(device, this.entries).forEach((entry) => {
if (entry.state !== "loaded" || !entry.supports_remove_device) {
return;
}
buttons.push(html`
<mwc-button
class="warning"
.entryId=${entry.entry_id}
@click=${this._confirmDeleteEntry}
>
${buttons.length > 1
? this.hass.localize(
`ui.panel.config.devices.delete_device_integration`,
{
integration: domainToName(this.hass.localize, entry.domain),
}
)
: this.hass.localize(`ui.panel.config.devices.delete_device`)}
</mwc-button>
`);
});
if (buttons.length > 0) {
this._deleteButtons = buttons;
}
}
private async _confirmDeleteEntry(e: MouseEvent): Promise<void> {
const entryId = (e.currentTarget as any).entryId;
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
});
if (!confirmed) {
return;
}
await removeConfigEntryFromDevice(this.hass!, this.deviceId, entryId);
} }
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
@@ -371,14 +278,18 @@ export class HaConfigDevicePage extends LitElement {
: undefined; : undefined;
const area = this._computeArea(this.areas, device); const area = this._computeArea(this.areas, device);
const configurationUrlIsHomeAssistant =
device.configuration_url?.startsWith("homeassistant://") || false;
const configurationUrl = configurationUrlIsHomeAssistant
? device.configuration_url!.replace("homeassistant://", "/")
: device.configuration_url;
const deviceInfo: TemplateResult[] = []; const deviceInfo: TemplateResult[] = [];
const deviceAlerts: TemplateResult[] = [];
const actions = [...(this._deviceActions || [])];
if (Array.isArray(this._diagnosticDownloadLinks)) {
actions.push(...this._diagnosticDownloadLinks);
}
if (this._deleteButtons) {
actions.push(...this._deleteButtons);
}
const firstDeviceAction = actions.shift();
if (device.disabled_by) { if (device.disabled_by) {
deviceInfo.push( deviceInfo.push(
@@ -397,53 +308,19 @@ export class HaConfigDevicePage extends LitElement {
)} )}
</ha-alert> </ha-alert>
${device.disabled_by === "user" ${device.disabled_by === "user"
? html` <div class="card-actions" slot="actions"> ? html`
<mwc-button unelevated @click=${this._enableDevice}> <div class="card-actions" slot="actions">
${this.hass.localize("ui.common.enable")} <mwc-button unelevated @click=${this._enableDevice}>
</mwc-button> ${this.hass.localize("ui.common.enable")}
</div>` </mwc-button>
</div>
`
: ""} : ""}
` `
); );
} }
const deviceActions: (TemplateResult | string)[] = []; this._renderIntegrationInfo(device, integrations, deviceInfo, deviceAlerts);
if (configurationUrl) {
deviceActions.push(html`
<a
href=${configurationUrl}
rel="noopener noreferrer"
.target=${configurationUrlIsHomeAssistant ? "_self" : "_blank"}
>
<mwc-button>
${this.hass.localize(
`ui.panel.config.devices.open_configuration_url_${
device.entry_type || "device"
}`
)}
<ha-svg-icon
.path=${mdiOpenInNew}
slot="trailingIcon"
></ha-svg-icon>
</mwc-button>
</a>
`);
}
this._renderIntegrationInfo(
device,
integrations,
deviceInfo,
deviceActions
);
if (Array.isArray(this._diagnosticDownloadLinks)) {
deviceActions.push(...this._diagnosticDownloadLinks);
}
if (this._deleteButtons) {
deviceActions.push(...this._deleteButtons);
}
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage
@@ -467,10 +344,6 @@ export class HaConfigDevicePage extends LitElement {
` `
: "" : ""
} }
<div class="container"> <div class="container">
<div class="header fullwidth"> <div class="header fullwidth">
${ ${
@@ -537,6 +410,11 @@ export class HaConfigDevicePage extends LitElement {
</div> </div>
</div> </div>
<div class="column"> <div class="column">
${
deviceAlerts.length
? html` <div class="fullwidth">${deviceAlerts}</div> `
: ""
}
<ha-device-info-card <ha-device-info-card
.hass=${this.hass} .hass=${this.hass}
.areas=${this.areas} .areas=${this.areas}
@@ -545,37 +423,70 @@ export class HaConfigDevicePage extends LitElement {
> >
${deviceInfo} ${deviceInfo}
${ ${
deviceActions.length firstDeviceAction || actions.length
? html` ? html`
<div class="card-actions" slot="actions"> <div class="card-actions" slot="actions">
${deviceActions} <div>
<a href=${ifDefined(firstDeviceAction!.href)}>
<mwc-button
class=${ifDefined(firstDeviceAction!.classes)}
.action=${firstDeviceAction!.action}
@click=${this._deviceActionClicked}
>
${firstDeviceAction!.label}
${firstDeviceAction!.trailingIcon
? html`
<ha-svg-icon
.path=${firstDeviceAction!.trailingIcon}
slot="trailingIcon"
></ha-svg-icon>
`
: ""}
</mwc-button>
</a>
</div>
${actions.length
? html`
<ha-button-menu corner="BOTTOM_START">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize(
"ui.common.menu"
)}
.path=${mdiDotsVertical}
></ha-icon-button>
${actions.map(
(deviceAction) => html`
<a href=${ifDefined(deviceAction.href)}>
<mwc-list-item
class=${ifDefined(
deviceAction.classes
)}
.action=${deviceAction.action}
@click=${this._deviceActionClicked}
>
${deviceAction.label}
${deviceAction.trailingIcon
? html`
<ha-svg-icon
.path=${deviceAction.trailingIcon}
></ha-svg-icon>
`
: ""}
</mwc-list-item>
</a>
`
)}
</ha-button-menu>
`
: ""}
</div> </div>
` `
: "" : ""
} }
</ha-device-info-card> </ha-device-info-card>
</div>
<div class="column">
${["control", "sensor", "config", "diagnostic"].map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
? html`
<ha-device-entities-card
.hass=${this.hass}
.header=${this.hass.localize(
`ui.panel.config.devices.entities.${category}`
)}
.deviceName=${deviceName}
.entities=${entitiesByCategory[category]}
.showHidden=${device.disabled_by !== null}
>
</ha-device-entities-card>
`
: ""
)}
</div>
<div class="column">
${ ${
isComponentLoaded(this.hass, "automation") isComponentLoaded(this.hass, "automation")
? html` ? html`
@@ -844,12 +755,227 @@ export class HaConfigDevicePage extends LitElement {
` `
: "" : ""
} }
</div>
<div class="column">
${["control", "sensor", "config", "diagnostic"].map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
? html`
<ha-device-entities-card
.hass=${this.hass}
.header=${this.hass.localize(
`ui.panel.config.devices.entities.${category}`
)}
.deviceName=${deviceName}
.entities=${entitiesByCategory[category]}
.showHidden=${device.disabled_by !== null}
>
</ha-device-entities-card>
`
: ""
)}
</div>
<div class="column">
${
isComponentLoaded(this.hass, "logbook")
? html`
<ha-card outlined>
<h1 class="card-header">
${this.hass.localize("panel.logbook")}
</h1>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""
}
</div> </div>
</div> </div>
</ha-config-section> </ha-config-section>
</hass-tabs-subpage> `; </hass-tabs-subpage> `;
} }
private async _getDiagnosticButtons(requestId: number): Promise<void> {
if (!isComponentLoaded(this.hass, "diagnostics")) {
return;
}
const device = this._device(this.deviceId, this.devices);
if (!device) {
return;
}
let links = await Promise.all(
this._integrations(device, this.entries).map(
async (entry): Promise<boolean | { link: string; domain: string }> => {
if (entry.state !== "loaded") {
return false;
}
let info: DiagnosticInfo;
try {
info = await fetchDiagnosticHandler(this.hass, entry.domain);
} catch (err: any) {
if (err.code === "not_found") {
return false;
}
throw err;
}
if (!info.handlers.device && !info.handlers.config_entry) {
return false;
}
return {
link: info.handlers.device
? getDeviceDiagnosticsDownloadUrl(entry.entry_id, this.deviceId)
: getConfigEntryDiagnosticsDownloadUrl(entry.entry_id),
domain: entry.domain,
};
}
)
);
links = links.filter(Boolean);
if (this._diagnosticDownloadLinks !== requestId) {
return;
}
if (links.length > 0) {
this._diagnosticDownloadLinks = (
links as { link: string; domain: string }[]
).map((link) => ({
href: link.link,
action: (ev) => this._signUrl(ev),
label:
links.length > 1
? this.hass.localize(
`ui.panel.config.devices.download_diagnostics_integration`,
{
integration: domainToName(this.hass.localize, link.domain),
}
)
: this.hass.localize(
`ui.panel.config.devices.download_diagnostics`
),
}));
}
}
private _getDeleteActions() {
const device = this._device(this.deviceId, this.devices);
if (!device) {
return;
}
const buttons: DeviceAction[] = [];
this._integrations(device, this.entries).forEach((entry) => {
if (entry.state !== "loaded" || !entry.supports_remove_device) {
return;
}
buttons.push({
action: async () => {
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
});
if (!confirmed) {
return;
}
await removeConfigEntryFromDevice(
this.hass!,
this.deviceId,
entry.entry_id
);
},
classes: "warning",
label:
buttons.length > 1
? this.hass.localize(
`ui.panel.config.devices.delete_device_integration`,
{
integration: domainToName(this.hass.localize, entry.domain),
}
)
: this.hass.localize(`ui.panel.config.devices.delete_device`),
});
});
if (buttons.length > 0) {
this._deleteButtons = buttons;
}
}
private async _getDeviceActions() {
const device = this._device(this.deviceId, this.devices);
if (!device) {
return;
}
const deviceActions: DeviceAction[] = [];
const configurationUrlIsHomeAssistant =
device.configuration_url?.startsWith("homeassistant://") || false;
const configurationUrl = configurationUrlIsHomeAssistant
? device.configuration_url!.replace("homeassistant://", "/")
: device.configuration_url;
if (configurationUrl) {
deviceActions.push({
href: configurationUrl,
label: this.hass.localize(
`ui.panel.config.devices.open_configuration_url_${
device.entry_type || "device"
}`
),
trailingIcon: mdiOpenInNew,
});
}
const domains = this._integrations(device, this.entries).map(
(int) => int.domain
);
if (domains.includes("mqtt")) {
const mqtt = await import(
"./device-detail/integration-elements/mqtt/device-actions"
);
const actions = mqtt.getMQTTDeviceActions(this, device);
deviceActions.push(...actions);
}
if (domains.includes("zha")) {
const zha = await import(
"./device-detail/integration-elements/zha/device-actions"
);
const actions = await zha.getZHADeviceActions(this, this.hass, device);
deviceActions.push(...actions);
}
if (domains.includes("zwave_js")) {
const zwave = await import(
"./device-detail/integration-elements/zwave_js/device-actions"
);
const actions = await zwave.getZwaveDeviceActions(
this,
this.hass,
device
);
deviceActions.push(...actions);
}
this._deviceActions = deviceActions;
}
private _computeEntityName(entity: EntityRegistryEntry) { private _computeEntityName(entity: EntityRegistryEntry) {
if (entity.name) { if (entity.name) {
return entity.name; return entity.name;
@@ -895,26 +1021,13 @@ export class HaConfigDevicePage extends LitElement {
} }
private _renderIntegrationInfo( private _renderIntegrationInfo(
device, device: DeviceRegistryEntry,
integrations: ConfigEntry[], integrations: ConfigEntry[],
deviceInfo: TemplateResult[], deviceInfo: TemplateResult[],
deviceActions: (string | TemplateResult)[] deviceAlerts: TemplateResult[]
): TemplateResult[] { ) {
const domains = integrations.map((int) => int.domain); const domains = integrations.map((int) => int.domain);
const templates: TemplateResult[] = [];
if (domains.includes("mqtt")) {
import(
"./device-detail/integration-elements/mqtt/ha-device-actions-mqtt"
);
deviceActions.push(html`
<ha-device-actions-mqtt
.hass=${this.hass}
.device=${device}
></ha-device-actions-mqtt>
`);
}
if (domains.includes("zha")) { if (domains.includes("zha")) {
import("./device-detail/integration-elements/zha/ha-device-actions-zha");
import("./device-detail/integration-elements/zha/ha-device-info-zha"); import("./device-detail/integration-elements/zha/ha-device-info-zha");
deviceInfo.push(html` deviceInfo.push(html`
<ha-device-info-zha <ha-device-info-zha
@@ -922,34 +1035,27 @@ export class HaConfigDevicePage extends LitElement {
.device=${device} .device=${device}
></ha-device-info-zha> ></ha-device-info-zha>
`); `);
deviceActions.push(html`
<ha-device-actions-zha
.hass=${this.hass}
.device=${device}
></ha-device-actions-zha>
`);
} }
if (domains.includes("zwave_js")) { if (domains.includes("zwave_js")) {
import( import(
"./device-detail/integration-elements/zwave_js/ha-device-info-zwave_js" "./device-detail/integration-elements/zwave_js/ha-device-alerts-zwave_js"
); );
import( import(
"./device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js" "./device-detail/integration-elements/zwave_js/ha-device-info-zwave_js"
); );
deviceAlerts.push(html`
<ha-device-alerts-zwave_js
.hass=${this.hass}
.device=${device}
></ha-device-alerts-zwave_js>
`);
deviceInfo.push(html` deviceInfo.push(html`
<ha-device-info-zwave_js <ha-device-info-zwave_js
.hass=${this.hass} .hass=${this.hass}
.device=${device} .device=${device}
></ha-device-info-zwave_js> ></ha-device-info-zwave_js>
`); `);
deviceActions.push(html`
<ha-device-actions-zwave_js
.hass=${this.hass}
.device=${device}
></ha-device-actions-zwave_js>
`);
} }
return templates;
} }
private async _showSettings() { private async _showSettings() {
@@ -1087,8 +1193,7 @@ export class HaConfigDevicePage extends LitElement {
} }
private async _signUrl(ev) { private async _signUrl(ev) {
const anchor = ev.target.closest("a"); const anchor = ev.currentTarget.closest("a");
ev.preventDefault();
const signedUrl = await getSignedPath( const signedUrl = await getSignedPath(
this.hass, this.hass,
anchor.getAttribute("href") anchor.getAttribute("href")
@@ -1096,6 +1201,16 @@ export class HaConfigDevicePage extends LitElement {
fileDownload(signedUrl.path); fileDownload(signedUrl.path);
} }
private _deviceActionClicked(ev) {
if (!ev.currentTarget.action) {
return;
}
ev.preventDefault();
(ev.currentTarget as any).action(ev);
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,
@@ -1126,9 +1241,6 @@ export class HaConfigDevicePage extends LitElement {
padding: 16px; padding: 16px;
} }
.show-more {
}
h1 { h1 {
margin: 0; margin: 0;
font-family: var(--paper-font-headline_-_font-family); font-family: var(--paper-font-headline_-_font-family);
@@ -1151,6 +1263,8 @@ export class HaConfigDevicePage extends LitElement {
display: flex; display: flex;
align-items: center; align-items: center;
padding-left: 8px; padding-left: 8px;
padding-inline-start: 8px;
direction: var(--direction);
} }
.column, .column,
@@ -1228,7 +1342,26 @@ export class HaConfigDevicePage extends LitElement {
.items { .items {
padding-bottom: 16px; padding-bottom: 16px;
} }
ha-logbook {
height: 400px;
}
:host([narrow]) ha-logbook {
height: 235px;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
`, `,
]; ];
} }
} }
declare global {
interface HTMLElementTagNameMap {
"ha-config-device-page": HaConfigDevicePage;
}
}

View File

@@ -541,7 +541,9 @@ export class HaConfigDeviceDashboard extends LitElement {
.clear { .clear {
color: var(--primary-color); color: var(--primary-color);
padding-left: 8px; padding-left: 8px;
padding-inline-start: 8px;
text-transform: uppercase; text-transform: uppercase;
direction: var(--direction);
} }
`, `,
haStyle, haStyle,

View File

@@ -107,10 +107,16 @@ export class EnergyBatterySettings extends LitElement {
> >
</div> </div>
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.battery.edit_battery_system"
)}
@click=${this._editSource} @click=${this._editSource}
.path=${mdiPencil} .path=${mdiPencil}
></ha-icon-button> ></ha-icon-button>
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.battery.delete_battery_system"
)}
@click=${this._deleteSource} @click=${this._deleteSource}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>

View File

@@ -94,10 +94,16 @@ export class EnergyGasSettings extends LitElement {
: source.stat_energy_from}</span : source.stat_energy_from}</span
> >
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.gas.edit_gas_source"
)}
@click=${this._editSource} @click=${this._editSource}
.path=${mdiPencil} .path=${mdiPencil}
></ha-icon-button> ></ha-icon-button>
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.gas.delete_gas_source"
)}
@click=${this._deleteSource} @click=${this._deleteSource}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>

View File

@@ -132,10 +132,16 @@ export class EnergyGridSettings extends LitElement {
: flow.stat_energy_from}</span : flow.stat_energy_from}</span
> >
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.edit_consumption"
)}
@click=${this._editFromSource} @click=${this._editFromSource}
.path=${mdiPencil} .path=${mdiPencil}
></ha-icon-button> ></ha-icon-button>
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.delete_consumption"
)}
@click=${this._deleteFromSource} @click=${this._deleteFromSource}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>
@@ -171,10 +177,16 @@ export class EnergyGridSettings extends LitElement {
: flow.stat_energy_to}</span : flow.stat_energy_to}</span
> >
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.edit_return"
)}
@click=${this._editToSource} @click=${this._editToSource}
.path=${mdiPencil} .path=${mdiPencil}
></ha-icon-button> ></ha-icon-button>
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.delete_return"
)}
@click=${this._deleteToSource} @click=${this._deleteToSource}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>
@@ -212,6 +224,9 @@ export class EnergyGridSettings extends LitElement {
<ha-icon-button .path=${mdiPencil}></ha-icon-button> <ha-icon-button .path=${mdiPencil}></ha-icon-button>
</a> </a>
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.grid.remove_co2_signal"
)}
@click=${this._removeCO2Sensor} @click=${this._removeCO2Sensor}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>

View File

@@ -104,12 +104,18 @@ export class EnergySolarSettings extends LitElement {
${this.info ${this.info
? html` ? html`
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.solar.edit_solar_production"
)}
@click=${this._editSource} @click=${this._editSource}
.path=${mdiPencil} .path=${mdiPencil}
></ha-icon-button> ></ha-icon-button>
` `
: ""} : ""}
<ha-icon-button <ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.energy.solar.delete_solar_production"
)}
@click=${this._deleteSource} @click=${this._deleteSource}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>

View File

@@ -12,10 +12,12 @@ import {
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation"; import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { domainIcon } from "../../../common/entity/domain_icon"; import { domainIcon } from "../../../common/entity/domain_icon";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { stringCompare } from "../../../common/string/compare"; import { stringCompare } from "../../../common/string/compare";
import { LocalizeFunc } from "../../../common/translations/localize"; import { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
@@ -24,8 +26,17 @@ import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon-picker"; import "../../../components/ha-icon-picker";
import "../../../components/ha-radio"; import "../../../components/ha-radio";
import "../../../components/ha-select"; import "../../../components/ha-select";
import "../../../components/ha-settings-row";
import "../../../components/ha-switch"; import "../../../components/ha-switch";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import {
CameraPreferences,
CAMERA_SUPPORT_STREAM,
fetchCameraPrefs,
STREAM_TYPE_HLS,
updateCameraPrefs,
} from "../../../data/camera";
import { import {
ConfigEntry, ConfigEntry,
deleteConfigEntry, deleteConfigEntry,
@@ -133,6 +144,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@state() private _submitting?: boolean; @state() private _submitting?: boolean;
@state() private _cameraPrefs?: CameraPreferences;
private _origEntityId!: string; private _origEntityId!: string;
private _deviceLookup?: Record<string, DeviceRegistryEntry>; private _deviceLookup?: Record<string, DeviceRegistryEntry>;
@@ -190,6 +203,20 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
const domain = computeDomain(this.entry.entity_id); const domain = computeDomain(this.entry.entity_id);
if (domain === "camera" && isComponentLoaded(this.hass, "stream")) {
const stateObj: HassEntity | undefined =
this.hass.states[this.entry.entity_id];
if (
stateObj &&
supportsFeature(stateObj, CAMERA_SUPPORT_STREAM) &&
// The stream component for HLS streams supports a server-side pre-load
// option that client initiated WebRTC streams do not
stateObj.attributes.frontend_stream_type === STREAM_TYPE_HLS
) {
this._fetchCameraPrefs();
}
}
if (domain === "sensor") { if (domain === "sensor") {
const stateObj: HassEntity | undefined = const stateObj: HassEntity | undefined =
this.hass.states[this.entry.entity_id]; this.hass.states[this.entry.entity_id];
@@ -340,9 +367,24 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@selected=${this._switchAsChanged} @selected=${this._switchAsChanged}
@closed=${stopPropagation} @closed=${stopPropagation}
> >
<mwc-list-item value="switch" selected> <mwc-list-item
${domainToName(this.hass.localize, "switch")}</mwc-list-item value="switch"
.selected=${!this._deviceClass ||
this._deviceClass === "switch"}
> >
${this.hass.localize(
"ui.dialogs.entity_registry.editor.device_classes.switch.switch"
)}
</mwc-list-item>
<mwc-list-item
value="outlet"
.selected=${!this._deviceClass ||
this._deviceClass === "outlet"}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.device_classes.switch.outlet"
)}
</mwc-list-item>
<li divider role="separator"></li> <li divider role="separator"></li>
${this._switchAsDomainsSorted( ${this._switchAsDomainsSorted(
SWITCH_AS_DOMAINS, SWITCH_AS_DOMAINS,
@@ -392,7 +434,27 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@value-changed=${this._areaPicked} @value-changed=${this._areaPicked}
></ha-area-picker>` ></ha-area-picker>`
: ""} : ""}
${this._cameraPrefs
? html`
<ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.preload_stream"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.preload_stream_description"
)}</span
>
<ha-switch
.checked=${this._cameraPrefs.preload_stream}
@change=${this._handleCameraPrefsChanged}
>
</ha-switch>
</ha-settings-row>
`
: ""}
<ha-expansion-panel <ha-expansion-panel
.header=${this.hass.localize( .header=${this.hass.localize(
"ui.dialogs.entity_registry.editor.advanced" "ui.dialogs.entity_registry.editor.advanced"
@@ -570,7 +632,15 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
if (ev.target.value === "") { if (ev.target.value === "") {
return; return;
} }
this._switchAs = ev.target.value;
// If value is "outlet" that means the user kept the "switch" domain, but actually changed
// the device_class of the switch to "outlet".
const switchAs = ev.target.value === "outlet" ? "switch" : ev.target.value;
this._switchAs = switchAs;
if (ev.target.value === "outlet" || ev.target.value === "switch") {
this._deviceClass = ev.target.value;
}
} }
private _areaPicked(ev: CustomEvent) { private _areaPicked(ev: CustomEvent) {
@@ -578,6 +648,26 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
this._areaId = ev.detail.value; this._areaId = ev.detail.value;
} }
private async _fetchCameraPrefs() {
this._cameraPrefs = await fetchCameraPrefs(this.hass, this.entry.entity_id);
}
private async _handleCameraPrefsChanged(ev) {
const checkbox = ev.currentTarget as HaSwitch;
try {
this._cameraPrefs = await updateCameraPrefs(
this.hass,
this.entry.entity_id,
{
preload_stream: checkbox.checked!,
}
);
} catch (err: any) {
showAlertDialog(this, { text: err.message });
checkbox.checked = !checkbox.checked;
}
}
private _viewStatusChanged(ev: CustomEvent): void { private _viewStatusChanged(ev: CustomEvent): void {
switch ((ev.target as any).value) { switch ((ev.target as any).value) {
case "enabled": case "enabled":
@@ -794,6 +884,12 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
ha-switch { ha-switch {
margin-right: 16px; margin-right: 16px;
} }
ha-settings-row {
padding: 0;
}
ha-settings-row ha-switch {
margin-right: 0;
}
ha-textfield { ha-textfield {
display: block; display: block;
margin: 8px 0; margin: 8px 0;

View File

@@ -976,6 +976,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
.selected-txt { .selected-txt {
font-weight: bold; font-weight: bold;
padding-left: 16px; padding-left: 16px;
padding-inline-start: 16px;
direction: var(--direction);
} }
.table-header .selected-txt { .table-header .selected-txt {
margin-top: 20px; margin-top: 20px;
@@ -985,6 +987,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
} }
.header-toolbar .header-btns { .header-toolbar .header-btns {
margin-right: -12px; margin-right: -12px;
margin-inline-end: -12px;
direction: var(--direction);
} }
.header-btns { .header-btns {
display: flex; display: flex;
@@ -999,7 +1003,9 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
.clear { .clear {
color: var(--primary-color); color: var(--primary-color);
padding-left: 8px; padding-left: 8px;
padding-inline-start: 8px;
text-transform: uppercase; text-transform: uppercase;
direction: var(--direction);
} }
`, `,
]; ];

View File

@@ -1,14 +1,18 @@
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item"; import "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical } from "@mdi/js"; import { mdiDotsVertical } from "@mdi/js";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/buttons/ha-progress-button"; import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-button-menu"; import "../../../components/ha-button-menu";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-clickable-list-item";
import "../../../components/ha-icon-next";
import "../../../components/ha-settings-row"; import "../../../components/ha-settings-row";
import { BOARD_NAMES } from "../../../data/hardware"; import { BOARD_NAMES, HardwareInfo } from "../../../data/hardware";
import { import {
extractApiErrorMessage, extractApiErrorMessage,
ignoreSupervisorError, ignoreSupervisorError,
@@ -28,6 +32,8 @@ import {
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showToast } from "../../../util/toast";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available"; import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
@customElement("ha-config-hardware") @customElement("ha-config-hardware")
@@ -42,14 +48,36 @@ class HaConfigHardware extends LitElement {
@state() private _hostData?: HassioHostInfo; @state() private _hostData?: HassioHostInfo;
@state() private _hardwareInfo?: HardwareInfo;
protected firstUpdated(changedProps: PropertyValues) { protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass, "hassio")) { this._load();
this._load();
}
} }
protected render(): TemplateResult { protected render(): TemplateResult {
let boardId: string | undefined;
let boardName: string | undefined;
let imageURL: string | undefined;
let documentationURL: string | undefined;
if (this._hardwareInfo?.hardware.length) {
const boardData = this._hardwareInfo!.hardware[0];
boardId = boardData.board.hassio_board_id;
boardName = boardData.name;
documentationURL = boardData.url;
imageURL = hardwareBrandsUrl({
category: "boards",
manufacturer: boardData.board.manufacturer,
model: boardData.board.model,
darkOptimized: this.hass.themes?.darkMode,
});
} else if (this._OSData?.board) {
boardId = this._OSData.board;
boardName = BOARD_NAMES[this._OSData.board];
}
return html` return html`
<hass-subpage <hass-subpage
back-path="/config/system" back-path="/config/system"
@@ -68,6 +96,20 @@ class HaConfigHardware extends LitElement {
"ui.panel.config.hardware.available_hardware.title" "ui.panel.config.hardware.available_hardware.title"
)}</mwc-list-item )}</mwc-list-item
> >
${this._hostData
? html`
<mwc-list-item class="warning" @click=${this._hostReboot}
>${this.hass.localize(
"ui.panel.config.hardware.reboot_host"
)}</mwc-list-item
>
<mwc-list-item class="warning" @click=${this._hostShutdown}
>${this.hass.localize(
"ui.panel.config.hardware.shutdown_host"
)}</mwc-list-item
>
`
: ""}
</ha-button-menu> </ha-button-menu>
${this._error ${this._error
? html` ? html`
@@ -76,57 +118,55 @@ class HaConfigHardware extends LitElement {
> >
` `
: ""} : ""}
${this._OSData || this._hostData ${boardName
? html` ? html`
<div class="content"> <div class="content">
<ha-card outlined> <ha-card outlined>
${this._OSData?.board <div class="card-content">
? html` <mwc-list>
<div class="card-content"> <mwc-list-item
<ha-settings-row> graphic=${ifDefined(imageURL ? "medium" : undefined)}
<span slot="heading" .twoline=${Boolean(boardId)}
>${BOARD_NAMES[this._OSData.board] || >
this.hass.localize( ${imageURL
"ui.panel.config.hardware.board" ? html`<img slot="graphic" src=${imageURL} />`
)}</span : ""}
<span class="primary-text">
${boardName ||
this.hass.localize("ui.panel.config.hardware.board")}
</span>
${boardId
? html`
<span class="secondary-text" slot="secondary"
>${boardId}</span
>
`
: ""}
</mwc-list-item>
${documentationURL
? html`
<ha-clickable-list-item
.href=${documentationURL}
openNewTab
twoline
hasMeta
> >
<div slot="description"> <span
<span class="value">${this._OSData.board}</span> >${this.hass.localize(
</div> "ui.panel.config.hardware.documentation"
</ha-settings-row> )}</span
</div> >
` <span slot="secondary"
: ""} >${this.hass.localize(
${this._hostData "ui.panel.config.hardware.documentation_description"
? html` )}</span
<div class="card-actions"> >
${this._hostData.features.includes("reboot") <ha-icon-next slot="meta"></ha-icon-next>
? html` </ha-clickable-list-item>
<ha-progress-button `
class="warning" : ""}
@click=${this._hostReboot} </mwc-list>
> </div>
${this.hass.localize(
"ui.panel.config.hardware.reboot_host"
)}
</ha-progress-button>
`
: ""}
${this._hostData.features.includes("shutdown")
? html`
<ha-progress-button
class="warning"
@click=${this._hostShutdown}
>
${this.hass.localize(
"ui.panel.config.hardware.shutdown_host"
)}
</ha-progress-button>
`
: ""}
</div>
`
: ""}
</ha-card> </ha-card>
</div> </div>
` `
@@ -136,9 +176,17 @@ class HaConfigHardware extends LitElement {
} }
private async _load() { private async _load() {
const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
try { try {
this._OSData = await fetchHassioHassOsInfo(this.hass); if (isComponentLoaded(this.hass, "hardware")) {
this._hostData = await fetchHassioHostInfo(this.hass); this._hardwareInfo = await this.hass.callWS({ type: "hardware/info" });
} else if (isHassioLoaded) {
this._OSData = await fetchHassioHassOsInfo(this.hass);
}
if (isHassioLoaded) {
this._hostData = await fetchHassioHostInfo(this.hass);
}
} catch (err: any) { } catch (err: any) {
this._error = err.message || err; this._error = err.message || err;
} }
@@ -148,10 +196,7 @@ class HaConfigHardware extends LitElement {
showhardwareAvailableDialog(this); showhardwareAvailableDialog(this);
} }
private async _hostReboot(ev: CustomEvent): Promise<void> { private async _hostReboot(): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
const confirmed = await showConfirmationDialog(this, { const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.hardware.reboot_host"), title: this.hass.localize("ui.panel.config.hardware.reboot_host"),
text: this.hass.localize("ui.panel.config.hardware.reboot_host_confirm"), text: this.hass.localize("ui.panel.config.hardware.reboot_host_confirm"),
@@ -160,10 +205,14 @@ class HaConfigHardware extends LitElement {
}); });
if (!confirmed) { if (!confirmed) {
button.progress = false;
return; return;
} }
showToast(this, {
message: this.hass.localize("ui.panel.config.hardware.rebooting_host"),
duration: 0,
});
try { try {
await rebootHost(this.hass); await rebootHost(this.hass);
} catch (err: any) { } catch (err: any) {
@@ -177,13 +226,9 @@ class HaConfigHardware extends LitElement {
}); });
} }
} }
button.progress = false;
} }
private async _hostShutdown(ev: CustomEvent): Promise<void> { private async _hostShutdown(): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
const confirmed = await showConfirmationDialog(this, { const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.hardware.shutdown_host"), title: this.hass.localize("ui.panel.config.hardware.shutdown_host"),
text: this.hass.localize( text: this.hass.localize(
@@ -194,10 +239,16 @@ class HaConfigHardware extends LitElement {
}); });
if (!confirmed) { if (!confirmed) {
button.progress = false;
return; return;
} }
showToast(this, {
message: this.hass.localize(
"ui.panel.config.hardware.host_shutting_down"
),
duration: 0,
});
try { try {
await shutdownHost(this.hass); await shutdownHost(this.hass);
} catch (err: any) { } catch (err: any) {
@@ -211,7 +262,6 @@ class HaConfigHardware extends LitElement {
}); });
} }
} }
button.progress = false;
} }
static styles = [ static styles = [
@@ -234,17 +284,18 @@ class HaConfigHardware extends LitElement {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: column; flex-direction: column;
padding: 16px 16px 0 16px; padding: 16px;
} }
ha-button-menu { ha-button-menu {
color: var(--secondary-text-color); color: var(--secondary-text-color);
--mdc-menu-min-width: 200px; --mdc-menu-min-width: 200px;
} }
.card-actions {
height: 48px; .primary-text {
display: flex; font-size: 16px;
justify-content: space-between; }
align-items: center; .secondary-text {
font-size: 14px;
} }
`, `,
]; ];

View File

@@ -4,9 +4,7 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-logo-svg"; import "../../../components/ha-logo-svg";
import { import {
fetchHassioHassOsInfo, fetchHassioHassOsInfo,
fetchHassioHostInfo,
HassioHassOSInfo, HassioHassOSInfo,
HassioHostInfo,
} from "../../../data/hassio/host"; } from "../../../data/hassio/host";
import { fetchHassioInfo, HassioInfo } from "../../../data/hassio/supervisor"; import { fetchHassioInfo, HassioInfo } from "../../../data/hassio/supervisor";
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
@@ -28,8 +26,6 @@ class HaConfigInfo extends LitElement {
@property({ attribute: false }) public route!: Route; @property({ attribute: false }) public route!: Route;
@state() private _hostInfo?: HassioHostInfo;
@state() private _osInfo?: HassioHassOSInfo; @state() private _osInfo?: HassioHassOSInfo;
@state() private _hassioInfo?: HassioInfo; @state() private _hassioInfo?: HassioInfo;
@@ -71,12 +67,6 @@ class HaConfigInfo extends LitElement {
${this._osInfo?.version ${this._osInfo?.version
? html`<h3>Home Assistant OS ${this._osInfo.version}</h3>` ? html`<h3>Home Assistant OS ${this._osInfo.version}</h3>`
: ""} : ""}
${this._hostInfo
? html`
<h4>Kernel version ${this._hostInfo.kernel}</h4>
<h4>Agent version ${this._hostInfo.agent_version}</h4>
`
: ""}
<p> <p>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.info.path_configuration", "ui.panel.config.info.path_configuration",
@@ -177,15 +167,13 @@ class HaConfigInfo extends LitElement {
} }
private async _loadSupervisorInfo(): Promise<void> { private async _loadSupervisorInfo(): Promise<void> {
const [hostInfo, osInfo, hassioInfo] = await Promise.all([ const [osInfo, hassioInfo] = await Promise.all([
fetchHassioHostInfo(this.hass),
fetchHassioHassOsInfo(this.hass), fetchHassioHassOsInfo(this.hass),
fetchHassioInfo(this.hass), fetchHassioInfo(this.hass),
]); ]);
this._hassioInfo = hassioInfo; this._hassioInfo = hassioInfo;
this._osInfo = osInfo; this._osInfo = osInfo;
this._hostInfo = hostInfo;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {

View File

@@ -46,7 +46,6 @@ import {
} from "../../../data/entity_registry"; } from "../../../data/entity_registry";
import { import {
domainToName, domainToName,
fetchIntegrationManifest,
fetchIntegrationManifests, fetchIntegrationManifests,
IntegrationManifest, IntegrationManifest,
} from "../../../data/integration"; } from "../../../data/integration";
@@ -156,17 +155,20 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
this._deviceRegistryEntries = entries; this._deviceRegistryEntries = entries;
}), }),
subscribeConfigFlowInProgress(this.hass, async (flowsInProgress) => { subscribeConfigFlowInProgress(this.hass, async (flowsInProgress) => {
const translationsPromisses: Promise<LocalizeFunc>[] = []; const integrations: Set<string> = new Set();
const manifests: Set<string> = new Set();
flowsInProgress.forEach((flow) => { flowsInProgress.forEach((flow) => {
// To render title placeholders // To render title placeholders
if (flow.context.title_placeholders) { if (flow.context.title_placeholders) {
translationsPromisses.push( integrations.add(flow.handler);
this.hass.loadBackendTranslation("config", flow.handler)
);
} }
this._fetchManifest(flow.handler); manifests.add(flow.handler);
}); });
await Promise.all(translationsPromisses); await this.hass.loadBackendTranslation(
"config",
Array.from(integrations)
);
this._fetchIntegrationManifests(manifests);
await nextRender(); await nextRender();
this._configEntriesInProgress = flowsInProgress.map((flow) => ({ this._configEntriesInProgress = flowsInProgress.map((flow) => ({
...flow, ...flow,
@@ -565,8 +567,8 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
await scanUSBDevices(this.hass); await scanUSBDevices(this.hass);
} }
private async _fetchManifests() { private async _fetchManifests(integrations?: string[]) {
const fetched = await fetchIntegrationManifests(this.hass); const fetched = await fetchIntegrationManifests(this.hass, integrations);
// Make a copy so we can keep track of previously loaded manifests // Make a copy so we can keep track of previously loaded manifests
// for discovered flows (which are not part of these results) // for discovered flows (which are not part of these results)
const manifests = { ...this._manifests }; const manifests = { ...this._manifests };
@@ -574,23 +576,25 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
this._manifests = manifests; this._manifests = manifests;
} }
private async _fetchManifest(domain: string) { private async _fetchIntegrationManifests(integrations: Set<string>) {
if (domain in this._manifests) { const manifestsToFetch: string[] = [];
return; for (const integration of integrations) {
} if (integration in this._manifests) {
if (this._extraFetchedManifests) { continue;
if (this._extraFetchedManifests.has(domain)) {
return;
} }
} else { if (this._extraFetchedManifests) {
this._extraFetchedManifests = new Set(); if (this._extraFetchedManifests.has(integration)) {
continue;
}
} else {
this._extraFetchedManifests = new Set();
}
this._extraFetchedManifests.add(integration);
manifestsToFetch.push(integration);
}
if (manifestsToFetch.length) {
await this._fetchManifests(manifestsToFetch);
} }
this._extraFetchedManifests.add(domain);
const manifest = await fetchIntegrationManifest(this.hass, domain);
this._manifests = {
...this._manifests,
[domain]: manifest,
};
} }
private _handleEntryRemoved(ev: HASSDomEvent<ConfigEntryRemovedEvent>) { private _handleEntryRemoved(ev: HASSDomEvent<ConfigEntryRemovedEvent>) {

View File

@@ -202,10 +202,9 @@ class DialogZWaveJSHealNetwork extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
const network: ZWaveJSNetwork = await fetchZwaveNetworkStatus( const network: ZWaveJSNetwork = await fetchZwaveNetworkStatus(this.hass!, {
this.hass!, entry_id: this.entry_id!,
this.entry_id! });
);
if (network.controller.is_heal_network_active) { if (network.controller.is_heal_network_active) {
this._status = "started"; this._status = "started";
this._subscribed = subscribeHealZwaveNetworkProgress( this._subscribed = subscribeHealZwaveNetworkProgress(

View File

@@ -22,10 +22,6 @@ import { ZWaveJSHealNodeDialogParams } from "./show-dialog-zwave_js-heal-node";
class DialogZWaveJSHealNode extends LitElement { class DialogZWaveJSHealNode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private entry_id?: string;
@state() private node_id?: number;
@state() private device?: DeviceRegistryEntry; @state() private device?: DeviceRegistryEntry;
@state() private _status?: string; @state() private _status?: string;
@@ -33,16 +29,12 @@ class DialogZWaveJSHealNode extends LitElement {
@state() private _error?: string; @state() private _error?: string;
public showDialog(params: ZWaveJSHealNodeDialogParams): void { public showDialog(params: ZWaveJSHealNodeDialogParams): void {
this.entry_id = params.entry_id;
this.device = params.device; this.device = params.device;
this.node_id = params.node_id;
this._fetchData(); this._fetchData();
} }
public closeDialog(): void { public closeDialog(): void {
this.entry_id = undefined;
this._status = undefined; this._status = undefined;
this.node_id = undefined;
this.device = undefined; this.device = undefined;
this._error = undefined; this._error = undefined;
@@ -50,7 +42,7 @@ class DialogZWaveJSHealNode extends LitElement {
} }
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.entry_id || !this.device) { if (!this.device) {
return html``; return html``;
} }
@@ -206,10 +198,9 @@ class DialogZWaveJSHealNode extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
const network: ZWaveJSNetwork = await fetchZwaveNetworkStatus( const network: ZWaveJSNetwork = await fetchZwaveNetworkStatus(this.hass!, {
this.hass!, device_id: this.device!.id,
this.entry_id! });
);
if (network.controller.is_heal_network_active) { if (network.controller.is_heal_network_active) {
this._status = "network-healing"; this._status = "network-healing";
} }
@@ -221,11 +212,7 @@ class DialogZWaveJSHealNode extends LitElement {
} }
this._status = "started"; this._status = "started";
try { try {
this._status = (await healZwaveNode( this._status = (await healZwaveNode(this.hass, this.device!.id))
this.hass,
this.entry_id!,
this.node_id!
))
? "finished" ? "finished"
: "failed"; : "failed";
} catch (err: any) { } catch (err: any) {

View File

@@ -15,9 +15,7 @@ import { ZWaveJSReinterviewNodeDialogParams } from "./show-dialog-zwave_js-reint
class DialogZWaveJSReinterviewNode extends LitElement { class DialogZWaveJSReinterviewNode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private entry_id?: string; @state() private device_id?: string;
@state() private node_id?: number;
@state() private _status?: string; @state() private _status?: string;
@@ -29,12 +27,11 @@ class DialogZWaveJSReinterviewNode extends LitElement {
params: ZWaveJSReinterviewNodeDialogParams params: ZWaveJSReinterviewNodeDialogParams
): Promise<void> { ): Promise<void> {
this._stages = undefined; this._stages = undefined;
this.entry_id = params.entry_id; this.device_id = params.device_id;
this.node_id = params.node_id;
} }
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.entry_id) { if (!this.device_id) {
return html``; return html``;
} }
@@ -159,8 +156,7 @@ class DialogZWaveJSReinterviewNode extends LitElement {
} }
this._subscribed = reinterviewZwaveNode( this._subscribed = reinterviewZwaveNode(
this.hass, this.hass,
this.entry_id!, this.device_id!,
this.node_id!,
this._handleMessage.bind(this) this._handleMessage.bind(this)
); );
} }
@@ -194,8 +190,7 @@ class DialogZWaveJSReinterviewNode extends LitElement {
} }
public closeDialog(): void { public closeDialog(): void {
this.entry_id = undefined; this.device_id = undefined;
this.node_id = undefined;
this._status = undefined; this._status = undefined;
this._stages = undefined; this._stages = undefined;

View File

@@ -18,9 +18,7 @@ import { ZWaveJSRemoveFailedNodeDialogParams } from "./show-dialog-zwave_js-remo
class DialogZWaveJSRemoveFailedNode extends LitElement { class DialogZWaveJSRemoveFailedNode extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private entry_id?: string; @state() private device_id?: string;
@state() private node_id?: number;
@state() private _status = ""; @state() private _status = "";
@@ -38,13 +36,12 @@ class DialogZWaveJSRemoveFailedNode extends LitElement {
public async showDialog( public async showDialog(
params: ZWaveJSRemoveFailedNodeDialogParams params: ZWaveJSRemoveFailedNodeDialogParams
): Promise<void> { ): Promise<void> {
this.entry_id = params.entry_id; this.device_id = params.device_id;
this.node_id = params.node_id;
} }
public closeDialog(): void { public closeDialog(): void {
this._unsubscribe(); this._unsubscribe();
this.entry_id = undefined; this.device_id = undefined;
this._status = ""; this._status = "";
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -56,7 +53,7 @@ class DialogZWaveJSRemoveFailedNode extends LitElement {
} }
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.entry_id || !this.node_id) { if (!this.device_id) {
return html``; return html``;
} }
@@ -166,8 +163,7 @@ class DialogZWaveJSRemoveFailedNode extends LitElement {
this._status = "started"; this._status = "started";
this._subscribed = removeFailedZwaveNode( this._subscribed = removeFailedZwaveNode(
this.hass, this.hass,
this.entry_id!, this.device_id!,
this.node_id!,
(message: any) => this._handleMessage(message) (message: any) => this._handleMessage(message)
).catch((error) => { ).catch((error) => {
this._status = "failed"; this._status = "failed";

View File

@@ -2,8 +2,6 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import { DeviceRegistryEntry } from "../../../../../data/device_registry"; import { DeviceRegistryEntry } from "../../../../../data/device_registry";
export interface ZWaveJSHealNodeDialogParams { export interface ZWaveJSHealNodeDialogParams {
entry_id: string;
node_id: number;
device: DeviceRegistryEntry; device: DeviceRegistryEntry;
} }

View File

@@ -1,8 +1,7 @@
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
export interface ZWaveJSReinterviewNodeDialogParams { export interface ZWaveJSReinterviewNodeDialogParams {
entry_id: string; device_id: string;
node_id: number;
} }
export const loadReinterviewNodeDialog = () => export const loadReinterviewNodeDialog = () =>

View File

@@ -1,8 +1,7 @@
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
export interface ZWaveJSRemoveFailedNodeDialogParams { export interface ZWaveJSRemoveFailedNodeDialogParams {
entry_id: string; device_id: string;
node_id: number;
} }
export const loadRemoveFailedNodeDialog = () => export const loadRemoveFailedNodeDialog = () =>

View File

@@ -1,4 +1,5 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { import {
mdiAlertCircle, mdiAlertCircle,
mdiCheckCircle, mdiCheckCircle,
@@ -11,21 +12,24 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import "../../../../../components/ha-card"; import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button"; import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-fab"; import "../../../../../components/ha-fab";
import "../../../../../components/ha-help-tooltip";
import "../../../../../components/ha-icon-next"; import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-svg-icon"; import "../../../../../components/ha-svg-icon";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
fetchZwaveDataCollectionStatus, fetchZwaveDataCollectionStatus,
fetchZwaveNetworkStatus, fetchZwaveNetworkStatus,
fetchZwaveNodeStatus,
fetchZwaveProvisioningEntries, fetchZwaveProvisioningEntries,
InclusionState, InclusionState,
setZwaveDataCollectionPreference, setZwaveDataCollectionPreference,
stopZwaveExclusion, stopZwaveExclusion,
stopZwaveInclusion, stopZwaveInclusion,
subscribeZwaveControllerStatistics,
ZWaveJSClient, ZWaveJSClient,
ZWaveJSControllerStatisticsUpdatedMessage,
ZWaveJSNetwork, ZWaveJSNetwork,
ZWaveJSNodeStatus,
ZwaveJSProvisioningEntry, ZwaveJSProvisioningEntry,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
import { import {
@@ -43,9 +47,10 @@ import { showZWaveJSRemoveNodeDialog } from "./show-dialog-zwave_js-remove-node"
import { configTabs } from "./zwave_js-config-router"; import { configTabs } from "./zwave_js-config-router";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow"; import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
import { computeRTL } from "../../../../../common/util/compute_rtl"; import { computeRTL } from "../../../../../common/util/compute_rtl";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
@customElement("zwave_js-config-dashboard") @customElement("zwave_js-config-dashboard")
class ZWaveJSConfigDashboard extends LitElement { class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
@property({ type: Object }) public hass!: HomeAssistant; @property({ type: Object }) public hass!: HomeAssistant;
@property({ type: Object }) public route!: Route; @property({ type: Object }) public route!: Route;
@@ -54,14 +59,12 @@ class ZWaveJSConfigDashboard extends LitElement {
@property({ type: Boolean }) public isWide!: boolean; @property({ type: Boolean }) public isWide!: boolean;
@property() public configEntryId?: string; @property() public configEntryId!: string;
@state() private _configEntry?: ConfigEntry; @state() private _configEntry?: ConfigEntry;
@state() private _network?: ZWaveJSNetwork; @state() private _network?: ZWaveJSNetwork;
@state() private _nodes?: ZWaveJSNodeStatus[];
@state() private _provisioningEntries?: ZwaveJSProvisioningEntry[]; @state() private _provisioningEntries?: ZwaveJSProvisioningEntry[];
@state() private _status?: ZWaveJSClient["state"]; @state() private _status?: ZWaveJSClient["state"];
@@ -70,12 +73,30 @@ class ZWaveJSConfigDashboard extends LitElement {
@state() private _dataCollectionOptIn?: boolean; @state() private _dataCollectionOptIn?: boolean;
@state()
private _statistics?: ZWaveJSControllerStatisticsUpdatedMessage;
protected firstUpdated() { protected firstUpdated() {
if (this.hass) { if (this.hass) {
this._fetchData(); this._fetchData();
} }
} }
public hassSubscribe(): Array<UnsubscribeFunc | Promise<UnsubscribeFunc>> {
return [
subscribeZwaveControllerStatistics(
this.hass,
this.configEntryId,
(message) => {
if (!this.hasUpdated) {
return;
}
this._statistics = message;
}
),
];
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this._configEntry) { if (!this._configEntry) {
return html``; return html``;
@@ -84,9 +105,8 @@ class ZWaveJSConfigDashboard extends LitElement {
if (ERROR_STATES.includes(this._configEntry.state)) { if (ERROR_STATES.includes(this._configEntry.state)) {
return this._renderErrorScreen(); return this._renderErrorScreen();
} }
const notReadyDevices = const notReadyDevices =
this._nodes?.filter((node) => !node.ready).length ?? 0; this._network?.controller.nodes.filter((node) => !node.ready).length ?? 0;
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage
@@ -216,22 +236,178 @@ class ZWaveJSConfigDashboard extends LitElement {
</ha-card> </ha-card>
<ha-card header="Diagnostics"> <ha-card header="Diagnostics">
<div class="card-content"> <div class="card-content">
${this.hass.localize( <div class="row">
"ui.panel.config.zwave_js.dashboard.driver_version" <span>
)}: ${this.hass.localize(
${this._network.client.driver_version}<br /> "ui.panel.config.zwave_js.dashboard.driver_version"
${this.hass.localize( )}:
"ui.panel.config.zwave_js.dashboard.server_version" </span>
)}: <span>${this._network.client.driver_version}</span>
${this._network.client.server_version}<br /> </div>
${this.hass.localize( <div class="row">
"ui.panel.config.zwave_js.dashboard.home_id" <span>
)}: ${this.hass.localize(
${this._network.controller.home_id}<br /> "ui.panel.config.zwave_js.dashboard.server_version"
${this.hass.localize( )}:
"ui.panel.config.zwave_js.dashboard.server_url" </span>
)}: <span>${this._network.client.server_version}</span>
${this._network.client.ws_server_url}<br /> </div>
<div class="row">
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.home_id"
)}:
</span>
<span>${this._network.controller.home_id}</span>
</div>
<div class="row">
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.server_url"
)}:
</span>
<span>${this._network.client.ws_server_url}</span>
</div>
<br />
<ha-expansion-panel
.header=${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.title"
)}
>
<mwc-list noninteractive>
<mwc-list-item twoline hasmeta>
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.messages_tx.label"
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.messages_tx.tooltip"
)}
</span>
<span slot="meta"
>${this._statistics?.messages_tx ?? 0}</span
>
</mwc-list-item>
<mwc-list-item twoline hasmeta>
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.messages_rx.label"
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.messages_rx.tooltip"
)}
</span>
<span slot="meta"
>${this._statistics?.messages_rx ?? 0}</span
>
</mwc-list-item>
<mwc-list-item twoline hasmeta>
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.messages_dropped_tx.label"
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.messages_dropped_tx.tooltip"
)}
</span>
<span slot="meta"
>${this._statistics?.messages_dropped_tx ?? 0}</span
>
</mwc-list-item>
<mwc-list-item twoline hasmeta>
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.messages_dropped_rx.label"
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.messages_dropped_rx.tooltip"
)}
</span>
<span slot="meta"
>${this._statistics?.messages_dropped_rx ?? 0}</span
>
</mwc-list-item>
<mwc-list-item twoline hasmeta>
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.nak.label"
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.nak.tooltip"
)}
</span>
<span slot="meta">${this._statistics?.nak ?? 0}</span>
</mwc-list-item>
<mwc-list-item twoline hasmeta>
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.can.label"
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.can.tooltip"
)}
</span>
<span slot="meta">${this._statistics?.can ?? 0}</span>
</mwc-list-item>
<mwc-list-item twoline hasmeta>
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.timeout_ack.label"
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.timeout_ack.tooltip"
)}
</span>
<span slot="meta"
>${this._statistics?.timeout_ack ?? 0}</span
>
</mwc-list-item>
<mwc-list-item twoline hasmeta>
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.timeout_response.label"
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.timeout_response.tooltip"
)}
</span>
<span slot="meta"
>${this._statistics?.timeout_response ?? 0}</span
>
</mwc-list-item>
<mwc-list-item twoline hasmeta>
<span>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.timeout_callback.label"
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.statistics.timeout_callback.tooltip"
)}
</span>
<span slot="meta"
>${this._statistics?.timeout_callback ?? 0}</span
>
</mwc-list-item>
</mwc-list>
</ha-expansion-panel>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<mwc-button <mwc-button
@@ -288,7 +464,7 @@ class ZWaveJSConfigDashboard extends LitElement {
data collected, can be found in the data collected, can be found in the
<a <a
target="_blank" target="_blank"
href="https://zwave-js.github.io/node-zwave-js/#/data-collection/data-collection?id=usage-statistics" href="https://zwave-js.github.io/node-zwave-js/#/data-collection/data-collection"
>Z-Wave JS data collection documentation</a >Z-Wave JS data collection documentation</a
>. >.
</p> </p>
@@ -388,7 +564,7 @@ class ZWaveJSConfigDashboard extends LitElement {
domain: "zwave_js", domain: "zwave_js",
}); });
this._configEntry = configEntries.find( this._configEntry = configEntries.find(
(entry) => entry.entry_id === this.configEntryId! (entry) => entry.entry_id === this.configEntryId
); );
if (ERROR_STATES.includes(this._configEntry!.state)) { if (ERROR_STATES.includes(this._configEntry!.state)) {
@@ -397,7 +573,7 @@ class ZWaveJSConfigDashboard extends LitElement {
const [network, dataCollectionStatus, provisioningEntries] = const [network, dataCollectionStatus, provisioningEntries] =
await Promise.all([ await Promise.all([
fetchZwaveNetworkStatus(this.hass!, this.configEntryId), fetchZwaveNetworkStatus(this.hass!, { entry_id: this.configEntryId }),
fetchZwaveDataCollectionStatus(this.hass!, this.configEntryId), fetchZwaveDataCollectionStatus(this.hass!, this.configEntryId),
fetchZwaveProvisioningEntries(this.hass!, this.configEntryId), fetchZwaveProvisioningEntries(this.hass!, this.configEntryId),
]); ]);
@@ -414,18 +590,6 @@ class ZWaveJSConfigDashboard extends LitElement {
this._dataCollectionOptIn = this._dataCollectionOptIn =
dataCollectionStatus.opted_in === true || dataCollectionStatus.opted_in === true ||
dataCollectionStatus.enabled === true; dataCollectionStatus.enabled === true;
this._fetchNodeStatus();
}
private async _fetchNodeStatus() {
if (!this._network) {
return;
}
const nodeStatePromisses = this._network.controller.nodes.map((nodeId) =>
fetchZwaveNodeStatus(this.hass, this.configEntryId!, nodeId)
);
this._nodes = await Promise.all(nodeStatePromisses);
} }
private async _addNodeClicked() { private async _addNodeClicked() {
@@ -525,6 +689,11 @@ class ZWaveJSConfigDashboard extends LitElement {
padding-right: 40px; padding-right: 40px;
} }
.row {
display: flex;
justify-content: space-between;
}
.network-status div.heading { .network-status div.heading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -547,6 +716,10 @@ class ZWaveJSConfigDashboard extends LitElement {
font-size: 1rem; font-size: 1rem;
} }
mwc-list-item {
height: 60px;
}
.card-header { .card-header {
display: flex; display: flex;
} }
@@ -563,12 +736,6 @@ class ZWaveJSConfigDashboard extends LitElement {
max-width: 600px; max-width: 600px;
} }
button.dump {
width: 100%;
text-align: center;
color: var(--secondary-text-color);
}
[hidden] { [hidden] {
display: none; display: none;
} }

View File

@@ -61,19 +61,6 @@ const getDevice = memoizeOne(
entries?.find((device) => device.id === deviceId) entries?.find((device) => device.id === deviceId)
); );
const getNodeId = memoizeOne(
(device: DeviceRegistryEntry): number | undefined => {
const identifier = device.identifiers.find(
(ident) => ident[0] === "zwave_js"
);
if (!identifier) {
return undefined;
}
return parseInt(identifier[1].split("-")[1]);
}
);
@customElement("zwave_js-node-config") @customElement("zwave_js-node-config")
class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) { class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -179,17 +166,6 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
</em> </em>
</p> </p>
</div> </div>
${this._nodeMetadata.comments?.length > 0
? html`
<div>
${this._nodeMetadata.comments.map(
(comment) => html`<ha-alert .alertType=${comment.level}>
${comment.text}
</ha-alert>`
)}
</div>
`
: ``}
<ha-card> <ha-card>
${Object.entries(this._config).map( ${Object.entries(this._config).map(
([id, item]) => html` <ha-settings-row ([id, item]) => html` <ha-settings-row
@@ -382,12 +358,10 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
} }
private async _updateConfigParameter(target, value) { private async _updateConfigParameter(target, value) {
const nodeId = getNodeId(this._device!);
try { try {
const result = await setZwaveNodeConfigParameter( const result = await setZwaveNodeConfigParameter(
this.hass, this.hass,
this.configEntryId!, this._device!.id,
nodeId!,
target.property, target.property,
value, value,
target.propertyKey ? target.propertyKey : undefined target.propertyKey ? target.propertyKey : undefined
@@ -429,15 +403,9 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
return; return;
} }
const nodeId = getNodeId(device);
if (!nodeId) {
this._error = "device_not_found";
return;
}
[this._nodeMetadata, this._config] = await Promise.all([ [this._nodeMetadata, this._config] = await Promise.all([
fetchZwaveNodeMetadata(this.hass, this.configEntryId, nodeId!), fetchZwaveNodeMetadata(this.hass, device.id),
fetchZwaveNodeConfigParameters(this.hass, this.configEntryId, nodeId!), fetchZwaveNodeConfigParameters(this.hass, device.id),
]); ]);
} }

View File

@@ -87,7 +87,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
${dashboard.default ${dashboard.default
? html` ? html`
<ha-svg-icon <ha-svg-icon
style="padding-left: 10px;" style="padding-left: 10px; padding-inline-start: 10px; direction: var(--direction);"
.path=${mdiCheckCircleOutline} .path=${mdiCheckCircleOutline}
></ha-svg-icon> ></ha-svg-icon>
<paper-tooltip animation-delay="0"> <paper-tooltip animation-delay="0">

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