Add node config panel for Z-Wave JS (#8440)

This commit is contained in:
Charles Garwood 2021-03-22 18:25:42 -04:00 committed by GitHub
parent c9b620fdb2
commit 4b664cc142
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 556 additions and 11 deletions

View File

@ -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<ZWaveJSNodeConfigParams[]> =>
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<unknown> => {
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 {

View File

@ -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`
<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>
`;
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
a {
text-decoration: none;
}
`,
];
}
}

View File

@ -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`
<ha-device-info-zwave_js
.hass=${this.hass}
.device=${device}
></ha-device-info-zwave_js>
<div class="card-actions" slot="actions">
<ha-device-actions-zwave_js
.hass=${this.hass}
.device=${device}
></ha-device-actions-zwave_js>
</div>
`);
}
return templates;

View File

@ -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")) {

View File

@ -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`<hass-error-screen
.hass=${this.hass}
.error=${this.hass.localize(
`ui.panel.config.zwave_js.node_config.error_${this._error}`
)}
></hass-error-screen>`;
}
if (!this._config) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
const device = this._device!;
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${configTabs}
>
<ha-config-section .narrow=${this.narrow} .isWide=${this.isWide}>
<div slot="header">
${this.hass.localize("ui.panel.config.zwave_js.node_config.header")}
</div>
<div slot="introduction">
${device
? html`
<div class="device-info">
<h2>${computeDeviceName(device, this.hass)}</h2>
<p>${device.manufacturer} ${device.model}</p>
</div>
`
: ``}
${this.hass.localize(
"ui.panel.config.zwave_js.node_config.introduction"
)}
<p>
<em>
${this.hass.localize(
"ui.panel.config.zwave_js.node_config.attribution",
"device_database",
html`<a href="https://devices.zwave-js.io/" target="_blank"
>${this.hass.localize(
"ui.panel.config.zwave_js.node_config.zwave_js_device_database"
)}</a
>`
)}
</em>
</p>
</div>
<ha-card>
${this._config
? html`
${Object.entries(this._config).map(
([id, item]) => html` <ha-settings-row
class="content config-item"
.configId=${id}
.narrow=${this.narrow}
>
${this._generateConfigBox(id, item)}
</ha-settings-row>`
)}
`
: ``}
</ha-card>
</ha-config-section>
</hass-tabs-subpage>
`;
}
private _generateConfigBox(id, item): TemplateResult {
const labelAndDescription = html`
<span slot="heading">${item.metadata.label}</span>
<span slot="description">
${item.metadata.description}
${item.metadata.description !== null && !item.metadata.writeable
? html`<br />`
: ""}
${!item.metadata.writeable
? html`<em>
${this.hass.localize(
"ui.panel.config.zwave_js.node_config.parameter_is_read_only"
)}
</em>`
: ""}
</span>
`;
// 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}
<div class="toggle">
<ha-switch
.property=${item.property}
.propertyKey=${item.property_key}
.checked=${item.value === 1}
.key=${id}
@change=${this._switchToggled}
.disabled=${!item.metadata.writeable}
></ha-switch>
</div>
`;
}
if (item.configuration_value_type === "range") {
return html`${labelAndDescription}
<paper-input
type="number"
.value=${item.value}
.min=${item.metadata.min}
.max=${item.metadata.max}
.property=${item.property}
.propertyKey=${item.property_key}
.key=${id}
.disabled=${!item.metadata.writeable}
@value-changed=${this._numericInputChanged}
>
</paper-input> `;
}
if (item.configuration_value_type === "enumerated") {
return html`
${labelAndDescription}
<div class="flex">
<paper-dropdown-menu
dynamic-align
.disabled=${!item.metadata.writeable}
>
<paper-listbox
slot="dropdown-content"
.selected=${item.value}
attr-for-selected="value"
.key=${id}
.property=${item.property}
.propertyKey=${item.property_key}
@iron-select=${this._dropdownSelected}
>
${Object.entries(item.metadata.states).map(
([key, state]) => html`
<paper-item .value=${key}>${state}</paper-item>
`
)}
</paper-listbox>
</paper-dropdown-menu>
</div>
`;
}
return html`${labelAndDescription}
<p>${item.value}</p>`;
}
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;
}
}

View File

@ -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",