From 4b664cc14239b4dc3a57a5e9310864e703fbc4ac Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Mon, 22 Mar 2021 18:25:42 -0400 Subject: [PATCH] Add node config panel for Z-Wave JS (#8440) --- src/data/zwave_js.ts | 58 +++ .../zwave_js/ha-device-actions-zwave_js.ts | 56 +++ .../config/devices/ha-config-device-page.ts | 9 + .../zwave_js/zwave_js-config-router.ts | 7 +- .../zwave_js/zwave_js-node-config.ts | 418 ++++++++++++++++++ src/translations/en.json | 19 +- 6 files changed, 556 insertions(+), 11 deletions(-) create mode 100644 src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index 8dd77a3335..d6db70bd21 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -28,6 +28,34 @@ export interface ZWaveJSNode { status: number; } +export interface ZWaveJSNodeConfigParams { + property: number; + value: any; + configuration_value_type: string; + metadata: ZWaveJSNodeConfigParamMetadata; +} + +export interface ZWaveJSNodeConfigParamMetadata { + description: string; + label: string; + max: number; + min: number; + readable: boolean; + writeable: boolean; + type: string; + unit: string; + states: { [key: number]: string }; +} + +export interface ZWaveJSSetConfigParamData { + type: string; + entry_id: string; + node_id: number; + property: number; + property_key?: number; + value: string | number; +} + export enum NodeStatus { Unknown, Asleep, @@ -58,6 +86,36 @@ export const fetchNodeStatus = ( node_id, }); +export const fetchNodeConfigParameters = ( + hass: HomeAssistant, + entry_id: string, + node_id: number +): Promise => + hass.callWS({ + type: "zwave_js/get_config_parameters", + entry_id, + node_id, + }); + +export const setNodeConfigParameter = ( + hass: HomeAssistant, + entry_id: string, + node_id: number, + property: number, + value: number, + property_key?: number +): Promise => { + const data: ZWaveJSSetConfigParamData = { + type: "zwave_js/set_config_parameter", + entry_id, + node_id, + property, + value, + property_key, + }; + return hass.callWS(data); +}; + export const getIdentifiersFromDevice = function ( device: DeviceRegistryEntry ): ZWaveJSNodeIdentifiers | undefined { diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts new file mode 100644 index 0000000000..13c7995243 --- /dev/null +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts @@ -0,0 +1,56 @@ +import "@material/mwc-button/mwc-button"; +import { + css, + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, + PropertyValues, + TemplateResult, +} from "lit-element"; +import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; +import { haStyle } from "../../../../../../resources/styles"; + +import { HomeAssistant } from "../../../../../../types"; + +@customElement("ha-device-actions-zwave_js") +export class HaDeviceActionsZWaveJS extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public device!: DeviceRegistryEntry; + + @internalProperty() private _entryId?: string; + + protected updated(changedProperties: PropertyValues) { + if (changedProperties.has("device")) { + this._entryId = this.device.config_entries[0]; + } + } + + protected render(): TemplateResult { + return html` + + + ${this.hass.localize( + "ui.panel.config.zwave_js.device_info.device_config" + )} + + + `; + } + + static get styles(): CSSResult[] { + return [ + haStyle, + css` + a { + text-decoration: none; + } + `, + ]; + } +} diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 3d7129e354..8b04716baf 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -622,11 +622,20 @@ export class HaConfigDevicePage extends LitElement { import( "./device-detail/integration-elements/zwave_js/ha-device-info-zwave_js" ); + import( + "./device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js" + ); templates.push(html` +
+ +
`); } return templates; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts index 659a7fb964..a30eedaebf 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-router.ts @@ -37,6 +37,10 @@ class ZWaveJSConfigRouter extends HassRouterPage { tag: "zwave_js-config-dashboard", load: () => import("./zwave_js-config-dashboard"), }, + node_config: { + tag: "zwave_js-node-config", + load: () => import("./zwave_js-node-config"), + }, }, }; @@ -46,9 +50,6 @@ class ZWaveJSConfigRouter extends HassRouterPage { el.isWide = this.isWide; el.narrow = this.narrow; el.configEntryId = this._configEntry; - if (this._currentPage === "node") { - el.nodeId = this.routeTail.path.substr(1); - } const searchParams = new URLSearchParams(window.location.search); if (this._configEntry && !searchParams.has("config_entry")) { diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts new file mode 100644 index 0000000000..0d77e14046 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts @@ -0,0 +1,418 @@ +import "../../../../../components/ha-settings-row"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-listbox/paper-listbox"; +import "@polymer/paper-dropdown-menu/paper-dropdown-menu"; +import "@material/mwc-button/mwc-button"; +import "@material/mwc-icon-button/mwc-icon-button"; +import { + css, + CSSResultArray, + customElement, + html, + internalProperty, + LitElement, + property, + PropertyValues, + TemplateResult, +} from "lit-element"; +import { debounce } from "../../../../../common/util/debounce"; +import "../../../../../components/ha-card"; +import "../../../../../components/ha-svg-icon"; +import "../../../../../components/ha-icon-next"; +import "../../../../../components/ha-switch"; +import { + fetchNodeConfigParameters, + setNodeConfigParameter, + ZWaveJSNodeConfigParams, +} from "../../../../../data/zwave_js"; +import "../../../../../layouts/hass-tabs-subpage"; +import { haStyle } from "../../../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../../../types"; +import "../../../ha-config-section"; +import { configTabs } from "./zwave_js-config-router"; +import { + DeviceRegistryEntry, + computeDeviceName, + subscribeDeviceRegistry, +} from "../../../../../data/device_registry"; +import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import memoizeOne from "memoize-one"; + +const getDevice = memoizeOne( + ( + deviceId: string, + entries?: DeviceRegistryEntry[] + ): DeviceRegistryEntry | undefined => + 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") +class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow!: boolean; + + @property({ type: Boolean }) public isWide!: boolean; + + @property() public configEntryId?: string; + + @property() public deviceId!: string; + + @property({ type: Array }) + private _deviceRegistryEntries?: DeviceRegistryEntry[]; + + @internalProperty() private _config?: ZWaveJSNodeConfigParams[]; + + @internalProperty() private _error?: string; + + public connectedCallback(): void { + super.connectedCallback(); + this.deviceId = this.route.path.substr(1); + } + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeDeviceRegistry(this.hass.connection, (entries) => { + this._deviceRegistryEntries = entries; + }), + ]; + } + + protected updated(changedProps: PropertyValues): void { + if ( + (!this._config || changedProps.has("deviceId")) && + changedProps.has("_deviceRegistryEntries") + ) { + this._fetchData(); + } + } + + protected render(): TemplateResult { + if (this._error) { + return html``; + } + + if (!this._config) { + return html``; + } + + const device = this._device!; + + return html` + + +
+ ${this.hass.localize("ui.panel.config.zwave_js.node_config.header")} +
+ +
+ ${device + ? html` +
+

${computeDeviceName(device, this.hass)}

+

${device.manufacturer} ${device.model}

+
+ ` + : ``} + ${this.hass.localize( + "ui.panel.config.zwave_js.node_config.introduction" + )} +

+ + ${this.hass.localize( + "ui.panel.config.zwave_js.node_config.attribution", + "device_database", + html`${this.hass.localize( + "ui.panel.config.zwave_js.node_config.zwave_js_device_database" + )}` + )} + +

+
+ + ${this._config + ? html` + ${Object.entries(this._config).map( + ([id, item]) => html` + ${this._generateConfigBox(id, item)} + ` + )} + ` + : ``} + +
+
+ `; + } + + private _generateConfigBox(id, item): TemplateResult { + const labelAndDescription = html` + ${item.metadata.label} + + ${item.metadata.description} + ${item.metadata.description !== null && !item.metadata.writeable + ? html`
` + : ""} + ${!item.metadata.writeable + ? html` + ${this.hass.localize( + "ui.panel.config.zwave_js.node_config.parameter_is_read_only" + )} + ` + : ""} +
+ `; + + // Numeric entries with a min value of 0 and max of 1 are considered boolean + if ( + (item.configuration_value_type === "range" && + item.metadata.min === 0 && + item.metadata.max === 1) || + this._isEnumeratedBool(item) + ) { + return html` + ${labelAndDescription} +
+ +
+ `; + } + + if (item.configuration_value_type === "range") { + return html`${labelAndDescription} + + `; + } + + if (item.configuration_value_type === "enumerated") { + return html` + ${labelAndDescription} +
+ + + ${Object.entries(item.metadata.states).map( + ([key, state]) => html` + ${state} + ` + )} + + +
+ `; + } + + return html`${labelAndDescription} +

${item.value}

`; + } + + private _isEnumeratedBool(item): boolean { + // Some Z-Wave config values use a states list with two options where index 0 = Disabled and 1 = Enabled + // We want those to be considered boolean and show a toggle switch + const disabledStates = ["disable", "disabled"]; + const enabledStates = ["enable", "enabled"]; + + if (item.configuration_value_type !== "enumerated") { + return false; + } + if (!("states" in item.metadata)) { + return false; + } + if (!(0 in item.metadata.states) || !(1 in item.metadata.states)) { + return false; + } + if ( + disabledStates.includes(item.metadata.states[0].toLowerCase()) && + enabledStates.includes(item.metadata.states[1].toLowerCase()) + ) { + return true; + } + return false; + } + + private _switchToggled(ev) { + this._updateConfigParameter(ev.target, ev.target.checked ? 1 : 0); + } + + private _dropdownSelected(ev) { + if (ev.target === undefined || this._config![ev.target.key] === undefined) { + return; + } + if (this._config![ev.target.key].value === ev.target.selected) { + return; + } + + this._updateConfigParameter(ev.target, parseInt(ev.target.selected)); + } + + private debouncedUpdate = debounce((target) => { + const value = parseInt(target.value); + this._config![target.key].value = value; + + this._updateConfigParameter(target, value); + }, 1000); + + private _numericInputChanged(ev) { + if (ev.target === undefined || this._config![ev.target.key] === undefined) { + return; + } + if (this._config![ev.target.key].value === parseInt(ev.target.value)) { + return; + } + this.debouncedUpdate(ev.target); + } + + private _updateConfigParameter(target, value) { + const nodeId = getNodeId(this._device!); + setNodeConfigParameter( + this.hass, + this.configEntryId!, + nodeId!, + target.property, + value, + target.propertyKey ? target.propertyKey : undefined + ); + this._config![target.key].value = value; + } + + private get _device(): DeviceRegistryEntry | undefined { + return getDevice(this.deviceId, this._deviceRegistryEntries); + } + + private async _fetchData() { + if (!this.configEntryId || !this._deviceRegistryEntries) { + return; + } + + const device = this._device; + if (!device) { + this._error = "device_not_found"; + return; + } + + const nodeId = getNodeId(device); + if (!nodeId) { + this._error = "device_not_found"; + return; + } + + this._config = await fetchNodeConfigParameters( + this.hass, + this.configEntryId, + nodeId! + ); + } + + static get styles(): CSSResultArray { + return [ + haStyle, + css` + .secondary { + color: var(--secondary-text-color); + } + + .flex { + display: flex; + } + + .flex .config-label, + .flex paper-dropdown-menu { + flex: 1; + } + + .content { + margin-top: 24px; + } + + .sectionHeader { + position: relative; + padding-right: 40px; + } + + ha-card { + margin: 0 auto; + max-width: 600px; + } + + ha-settings-row { + --paper-time-input-justify-content: flex-end; + border-top: 1px solid var(--divider-color); + } + + :host(:not([narrow])) ha-settings-row paper-input { + width: 30%; + text-align: right; + } + + ha-card:last-child { + margin-bottom: 24px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "zwave_js-node-config": ZWaveJSNodeConfig; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 098bd882da..5b5f1a1f92 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2521,7 +2521,17 @@ "device_info": { "zwave_info": "Z-Wave Info", "node_status": "Node Status", - "node_ready": "Node Ready" + "node_ready": "Node Ready", + "device_config": "Configure Device" + }, + "node_config": { + "header": "Z-Wave Device Configuration", + "introduction": "Manage and adjust device (node) specific configuration parameters for the selected device", + "attribution": "Device configuration parameters and descriptions are provided by the {device_database}", + "zwave_js_device_database": "Z-Wave JS Device Database", + "battery_device_notice": "Battery devices must be awake to update their config. Please refer to your device manual for instructions on how to wake the device.", + "parameter_is_read_only": "This parameter is read-only.", + "error_device_not_found": "Device not found" }, "node_status": { "unknown": "Unknown", @@ -3452,7 +3462,6 @@ "addon": { "failed_to_reset": "Failed to reset add-on configuration, {error}", "failed_to_save": "Failed to save add-on configuration, {error}", - "state": { "installed": "Add-on is installed", "not_installed": "Add-on is not installed", @@ -3502,13 +3511,11 @@ "uninstall": "uninstall", "rebuild": "rebuild", "open_web_ui": "Open web UI", - "protection_mode": { "title": "Warning: Protection mode is disabled!", "content": "Protection mode on this add-on is disabled! This gives the add-on full access to the entire system, which adds security risks, and could damage your system when used incorrectly. Only disable the protection mode if you know, need AND trust the source of this add-on.", "enable": "Enable Protection mode" }, - "capability": { "stage": { "title": "Add-on Stage", @@ -3575,7 +3582,6 @@ "admin": "admin" } }, - "option": { "boot": { "title": "Start on boot", @@ -3598,7 +3604,6 @@ "description": "Blocks elevated system access from the add-on" } }, - "action_error": { "uninstall": "Failed to uninstall add-on", "install": "Failed to install add-on", @@ -3642,7 +3647,6 @@ "update_available": "{count, plural,\n one {Update}\n other {{count} Updates}\n} pending", "update": "Update", "version": "Version", - "error": { "unknown": "Unknown error", "update_failed": "Update failed" @@ -3787,7 +3791,6 @@ "password_protection": "Password protection", "password_protected": "password protected", "enter_password": "Please enter a password.", - "folder": { "homeassistant": "Home Assistant configuration", "ssl": "SSL",