From 64285d5155aabdea4d2c8c6a9c1565430244cd5c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 7 Nov 2024 07:00:51 +0100 Subject: [PATCH] Add zwave expert UI / Installer settings (#21897) * Add zwave expert UI / Installer settings * Fix zwave invoceCC api function name * Fix function calls of invokeZWaveCCApi * Add zwave node-installer translations and endpoint separation * Add zwave capability-control error handling, translations and thermostat setback * Fix zwave capability thermostat setback --------- Co-authored-by: Wendelin --- src/data/zwave_js.ts | 39 +++ .../zwave_js/device-actions.ts | 8 + ...js-capability-control-multilevel-switch.ts | 149 +++++++++++ ...s-capability-control-thermostat-setback.ts | 240 ++++++++++++++++++ .../zwave_js/zwave_js-config-router.ts | 4 + .../zwave_js/zwave_js-node-installer.ts | 215 ++++++++++++++++ src/translations/en.json | 40 +++ 7 files changed, 695 insertions(+) create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-multilevel-switch.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-thermostat-setback.ts create mode 100644 src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index af7fe06ae3..8cc704db2a 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -209,6 +209,17 @@ export interface ZWaveJSNodeStatus { has_firmware_update_cc: boolean; } +export type ZWaveJSNodeCapabilities = { + [endpoint: number]: ZWaveJSEndpointCapability[]; +}; + +export interface ZWaveJSEndpointCapability { + id: number; + name: string; + version: number; + is_secure: boolean; +} + export interface ZwaveJSNodeMetadata { node_id: number; exclusion: string; @@ -404,6 +415,25 @@ export interface RequestedGrant { clientSideAuth: boolean; } +export const invokeZWaveCCApi = ( + hass: HomeAssistant, + device_id: string, + command_class: number, + endpoint: number | undefined, + method_name: string, + parameters: any[], + wait_for_result?: boolean +): Promise => + hass.callWS({ + type: "zwave_js/invoke_cc_api", + device_id, + command_class, + endpoint, + method_name, + parameters, + wait_for_result, + }); + export const fetchZwaveNetworkStatus = ( hass: HomeAssistant, device_or_entry_id: { @@ -579,6 +609,15 @@ export const fetchZwaveNodeStatus = ( device_id, }); +export const fetchZwaveNodeCapabilities = ( + hass: HomeAssistant, + device_id: string +): Promise => + hass.callWS({ + type: "zwave_js/node_capabilities", + device_id, + }); + export const subscribeZwaveNodeStatus = ( hass: HomeAssistant, device_id: string, diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts index 06d8fe5578..d7b61fa84b 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts @@ -5,6 +5,7 @@ import { mdiHospitalBox, mdiInformation, mdiUpload, + mdiWrench, } from "@mdi/js"; import { getConfigEntries } from "../../../../../../data/config_entries"; import type { DeviceRegistryEntry } from "../../../../../../data/device_registry"; @@ -98,6 +99,13 @@ export const getZwaveDeviceActions = async ( showZWaveJSNodeStatisticsDialog(el, { device, }), + }, + { + label: hass.localize( + "ui.panel.config.zwave_js.device_info.installer_settings" + ), + icon: mdiWrench, + href: `/config/zwave_js/node_installer/${device.id}?config_entry=${entryId}`, } ); } diff --git a/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-multilevel-switch.ts b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-multilevel-switch.ts new file mode 100644 index 0000000000..d7ca52ca9a --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-multilevel-switch.ts @@ -0,0 +1,149 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../../../../components/buttons/ha-progress-button"; +import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; +import { HomeAssistant } from "../../../../../../types"; +import { invokeZWaveCCApi } from "../../../../../../data/zwave_js"; +import "../../../../../../components/ha-textfield"; +import "../../../../../../components/ha-select"; +import "../../../../../../components/ha-list-item"; +import type { HaProgressButton } from "../../../../../../components/buttons/ha-progress-button"; +import type { HaSelect } from "../../../../../../components/ha-select"; +import type { HaTextField } from "../../../../../../components/ha-textfield"; +import type { HaSwitch } from "../../../../../../components/ha-switch"; +import { extractApiErrorMessage } from "../../../../../../data/hassio/common"; + +@customElement("zwave_js-capability-control-multilevel_switch") +class ZWaveJSCapabilityMultiLevelSwitch extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public device!: DeviceRegistryEntry; + + @property({ type: Number }) public endpoint!: number; + + @property({ type: Number }) public command_class!: number; + + @property({ type: Number }) public version!: number; + + @state() private _error?: string; + + protected render() { + return html` +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.title" + )} +

+ ${this._error + ? html`${this._error}` + : ""} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.up" + )} + ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.down" + )} + + + + + +
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.start_transition" + )} + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.stop_transition" + )} + +
+ `; + } + + private async _controlTransition(ev: any) { + const control = ev.currentTarget!.control; + const button = ev.currentTarget as HaProgressButton; + button.progress = true; + + const direction = (this.shadowRoot!.getElementById("direction") as HaSelect) + .value; + + const ignoreStartLevel = ( + this.shadowRoot!.getElementById("ignore_start_level") as HaSwitch + ).checked; + + const startLevel = Number( + (this.shadowRoot!.getElementById("start_level") as HaTextField).value + ); + + try { + button.actionSuccess(); + await invokeZWaveCCApi( + this.hass, + this.device.id, + this.command_class, + this.endpoint, + control, + [{ direction, ignoreStartLevel, startLevel }], + true + ); + } catch (err) { + button.actionError(); + this._error = this.hass.localize( + "ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.control_failed", + { error: extractApiErrorMessage(err) } + ); + } + + button.progress = false; + } + + static styles = css` + ha-select, + ha-formfield, + ha-textfield { + display: block; + margin-bottom: 8px; + } + .actions { + display: flex; + justify-content: flex-end; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave_js-capability-control-multilevel_switch": ZWaveJSCapabilityMultiLevelSwitch; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-thermostat-setback.ts b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-thermostat-setback.ts new file mode 100644 index 0000000000..744152480d --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-thermostat-setback.ts @@ -0,0 +1,240 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; +import { HomeAssistant } from "../../../../../../types"; +import { invokeZWaveCCApi } from "../../../../../../data/zwave_js"; +import "../../../../../../components/ha-button"; +import "../../../../../../components/buttons/ha-progress-button"; +import "../../../../../../components/ha-textfield"; +import "../../../../../../components/ha-select"; +import "../../../../../../components/ha-list-item"; +import type { HaSelect } from "../../../../../../components/ha-select"; +import type { HaTextField } from "../../../../../../components/ha-textfield"; +import { extractApiErrorMessage } from "../../../../../../data/hassio/common"; +import type { HaProgressButton } from "../../../../../../components/buttons/ha-progress-button"; + +// enum with special states +enum SpecialState { + frost_protection = "Frost Protection", + energy_saving = "Energy Saving", + unused = "Unused", +} + +const SETBACK_TYPE_OPTIONS = ["none", "temporary", "permanent"]; + +@customElement("zwave_js-capability-control-thermostat_setback") +class ZWaveJSCapabilityThermostatSetback extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public device!: DeviceRegistryEntry; + + @property({ type: Number }) public endpoint!: number; + + @property({ type: Number }) public command_class!: number; + + @property({ type: Number }) public version!: number; + + @state() private _disableSetbackState = false; + + @query("#setback_type") private _setbackTypeInput!: HaSelect; + + @query("#setback_state") private _setbackStateInput!: HaTextField; + + @query("#setback_special_state") + private _setbackSpecialStateSelect!: HaSelect; + + @state() private _error?: string; + + @state() private _loading = true; + + protected render() { + return html` +

+ ${this.hass.localize( + `ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.title` + )} +

+ ${this._error + ? html`${this._error}` + : ""} + + ${SETBACK_TYPE_OPTIONS.map( + (translationKey, index) => + html` + ${this.hass.localize( + `ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_type.${translationKey}` + )} + ` + )} + +
+ + + + ${Object.entries(SpecialState).map( + ([translationKey, value]) => + html` + ${this.hass.localize( + `ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_special_state.${translationKey}` + )} + ` + )} + +
+
+ ${this.hass.localize("ui.common.clear")} + + ${this.hass.localize("ui.common.save")} + +
+ `; + } + + protected firstUpdated() { + this._loadSetback(); + } + + private async _loadSetback() { + this._loading = true; + try { + const { setbackType, setbackState } = (await invokeZWaveCCApi( + this.hass, + this.device.id, + this.command_class, + this.endpoint, + "get", + [], + true + )) as { setbackType: number; setbackState: number | SpecialState }; + + this._setbackTypeInput.value = String(setbackType); + if (typeof setbackState === "number") { + this._setbackStateInput.value = String(setbackState); + this._setbackSpecialStateSelect.value = ""; + } else { + this._setbackSpecialStateSelect.value = setbackState; + } + } catch (err) { + this._error = this.hass.localize( + "ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.get_setback_failed", + { error: extractApiErrorMessage(err) } + ); + } + + this._loading = false; + } + + private _changeSpecialState() { + this._disableSetbackState = !!this._setbackSpecialStateSelect.value; + } + + private async _saveSetback(ev: CustomEvent) { + const button = ev.currentTarget as HaProgressButton; + button.progress = true; + + this._error = undefined; + const setbackType = this._setbackTypeInput.value; + + let setbackState: number | string = Number(this._setbackStateInput.value); + if (this._setbackSpecialStateSelect.value) { + setbackState = this._setbackSpecialStateSelect.value; + } + + try { + await invokeZWaveCCApi( + this.hass, + this.device.id, + this.command_class, + this.endpoint, + "set", + [Number(setbackType), setbackState], + true + ); + + button.actionSuccess(); + } catch (err) { + button.actionError(); + this._error = this.hass.localize( + "ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.save_setback_failed", + { error: extractApiErrorMessage(err) } + ); + } + + button.progress = false; + } + + private _clear() { + this._loadSetback(); + } + + static styles = css` + :host { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + :host > ha-select { + width: 100%; + } + .actions { + width: 100%; + display: flex; + justify-content: flex-end; + } + .actions .clear-button { + --mdc-theme-primary: var(--red-color); + } + .setback-state { + width: 100%; + display: flex; + gap: 16px; + } + .setback-state ha-select, + ha-textfield { + flex: 1; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "zwave_js-capability-control-thermostat_setback": ZWaveJSCapabilityThermostatSetback; + } +} 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 0666dedf47..008f8a5149 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 @@ -48,6 +48,10 @@ class ZWaveJSConfigRouter extends HassRouterPage { tag: "zwave_js-node-config", load: () => import("./zwave_js-node-config"), }, + node_installer: { + tag: "zwave_js-node-installer", + load: () => import("./zwave_js-node-installer"), + }, logs: { tag: "zwave_js-logs", load: () => import("./zwave_js-logs"), diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts new file mode 100644 index 0000000000..a0198b499c --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-installer.ts @@ -0,0 +1,215 @@ +import "@material/mwc-button/mwc-button"; +import "@material/mwc-list/mwc-list-item"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + css, + html, + nothing, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive"; +import "../../../../../components/ha-card"; +import { computeDeviceName } from "../../../../../data/device_registry"; +import { + ZWaveJSNodeCapabilities, + ZwaveJSNodeMetadata, + fetchZwaveNodeCapabilities, + fetchZwaveNodeMetadata, +} from "../../../../../data/zwave_js"; +import "../../../../../layouts/hass-error-screen"; +import "../../../../../layouts/hass-loading-screen"; +import "../../../../../layouts/hass-subpage"; +import { haStyle } from "../../../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../../../types"; +import "../../../ha-config-section"; +import "./capability-controls/zwave_js-capability-control-multilevel-switch"; +import "./capability-controls/zwave_js-capability-control-thermostat-setback"; + +const CAPABILITY_CONTROLS = { + 38: "multilevel_switch", + 71: "thermostat_setback", +}; + +@customElement("zwave_js-node-installer") +class ZWaveJSNodeInstaller extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean }) public isWide = false; + + @property() public configEntryId?: string; + + @property() public deviceId!: string; + + @state() private _nodeMetadata?: ZwaveJSNodeMetadata; + + @state() private _capabilities?: ZWaveJSNodeCapabilities; + + @state() private _error?: string; + + public connectedCallback(): void { + super.connectedCallback(); + this.deviceId = this.route.path.substr(1); + } + + protected updated(changedProps: PropertyValues): void { + if (!this._capabilities || changedProps.has("deviceId")) { + this._fetchData(); + } + } + + protected render(): TemplateResult { + if (this._error) { + return html``; + } + + if (!this._capabilities || !this._nodeMetadata) { + return html``; + } + + const device = this.hass.devices[this.deviceId]; + + const endpoints = Object.entries(this._capabilities).filter( + ([_endpoint, capabilities]) => { + const filteredCapabilities = capabilities.filter( + (capability) => capability.id in CAPABILITY_CONTROLS + ); + return filteredCapabilities.length > 0; + } + ); + + return html` + + +
+ ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.header" + )} +
+ +
+ ${device + ? html` +
+

${computeDeviceName(device, this.hass)}

+

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

+
+ ` + : ``} + ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.introduction" + )} +
+ ${endpoints.length + ? endpoints.map( + ([endpoint, capabilities]) => html` +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.endpoint" + )}: + ${endpoint} +

+ + ${capabilities.map( + (capability) => html` + ${capability.id in CAPABILITY_CONTROLS + ? html`
+

+ ${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.command_class" + )}: + ${capability.name} +

+ ${dynamicElement( + `zwave_js-capability-control-${CAPABILITY_CONTROLS[capability.id]}`, + { + hass: this.hass, + device: device, + endpoint: endpoint, + command_class: capability.id, + version: capability.version, + is_secure: capability.is_secure, + } + )} +
` + : nothing} + ` + )} +
+ ` + ) + : html`${this.hass.localize( + "ui.panel.config.zwave_js.node_installer.no_settings" + )}`} +
+
+ `; + } + + private async _fetchData() { + if (!this.configEntryId) { + return; + } + + const device = this.hass.devices[this.deviceId]; + if (!device) { + this._error = "device_not_found"; + return; + } + + [this._nodeMetadata, this._capabilities] = await Promise.all([ + fetchZwaveNodeMetadata(this.hass, device.id), + fetchZwaveNodeCapabilities(this.hass, device.id), + ]); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + ha-card { + margin-bottom: 40px; + margin-top: 0; + } + .capability { + border-bottom: 1px solid var(--divider-color); + padding: 4px 16px; + } + .capability:last-child { + border-bottom: none; + } + .empty { + margin-top: 32px; + padding: 24px 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "zwave_js-node-installer": ZWaveJSNodeInstaller; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 2762e21788..951478a2e4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4841,6 +4841,7 @@ "node_id": "ID", "node_ready": "Ready", "device_config": "Configure", + "installer_settings": "Installer settings", "reinterview_device": "Re-interview", "rebuild_routes": "Rebuild routes", "remove_failed": "Remove failed", @@ -5132,6 +5133,45 @@ "subscribed_to_logs": "Subscribed to Z-Wave JS log messages…", "log_level_changed": "Log Level changed to: {level}", "download_logs": "Download logs" + }, + "node_installer": { + "header": "Installer Settings", + "introduction": "Configure your device installer settings.", + "endpoint": "Endpoint", + "no_settings": "This device does not have any installer settings.", + "command_class": "Command Class", + "capability_controls": { + "thermostat_setback": { + "title": "Thermostat Setback", + "setback_state_label": "Setback in 1/10 degrees (Kelvin)", + "setback_state_helper": "Min: -12.8, max: 12.0", + "setback_special_state": { + "label": "Setback special state", + "frost_protection": "Frost protection", + "energy_saving": "Energy saving", + "unused": "Unused" + }, + "setback_type": { + "label": "Setback Type", + "none": "None", + "temporary": "Temporary", + "permanent": "Permanent" + }, + "get_setback_failed": "Failed to get setback state. {error}", + "save_setback_failed": "Failed to save setback state. {error}" + }, + "multilevel_switch": { + "title": "Transition", + "direction": "Direction", + "up": "Up", + "down": "Down", + "ignore_start_level": "Ignore start level", + "start_level": "Start level", + "start_transition": "Start transition", + "stop_transition": "Stop transition", + "control_failed": "Failed to control transition. {error}" + } + } } }, "matter": {