ha-frontend/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts

536 lines
15 KiB
TypeScript

import "@material/mwc-button/mwc-button";
import "@material/mwc-list/mwc-list-item";
import {
mdiCheckCircle,
mdiCircle,
mdiCloseCircle,
mdiProgressClock,
} from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-select";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-switch";
import "../../../../../components/ha-textfield";
import { groupBy } from "../../../../../common/util/group-by";
import {
computeDeviceName,
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../../../data/device_registry";
import {
fetchZwaveNodeConfigParameters,
fetchZwaveNodeMetadata,
setZwaveNodeConfigParameter,
ZWaveJSNodeConfigParam,
ZWaveJSNodeConfigParams,
ZwaveJSNodeMetadata,
ZWaveJSSetConfigParamResult,
} from "../../../../../data/zwave_js";
import "../../../../../layouts/hass-error-screen";
import "../../../../../layouts/hass-loading-screen";
import "../../../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../ha-config-section";
import { configTabs } from "./zwave_js-config-router";
const icons = {
accepted: mdiCheckCircle,
queued: mdiProgressClock,
error: mdiCloseCircle,
};
const getDevice = memoizeOne(
(
deviceId: string,
entries?: DeviceRegistryEntry[]
): DeviceRegistryEntry | undefined =>
entries?.find((device) => device.id === deviceId)
);
@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 = false;
@property({ type: Boolean }) public isWide = false;
@property() public configEntryId?: string;
@property() public deviceId!: string;
@state() private _deviceRegistryEntries?: DeviceRegistryEntry[];
@state() private _nodeMetadata?: ZwaveJSNodeMetadata;
@state() private _config?: ZWaveJSNodeConfigParams;
@state() private _results: Record<string, ZWaveJSSetConfigParamResult> = {};
@state() 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 || !this._nodeMetadata) {
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}
vertical
>
<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
rel="noreferrer noopener"
href=${this._nodeMetadata?.device_database_url ||
"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>
${Object.entries(
groupBy(Object.entries(this._config), ([_, item]) =>
item.endpoint.toString()
)
).map(
([endpoint, configParamEntries]) =>
html`<div class="content">
<h3>
${this.hass.localize(
"ui.panel.config.zwave_js.node_config.endpoint",
{ endpoint }
)}
</h3>
<ha-card>
${configParamEntries
.sort(([_, paramA], [__, paramB]) =>
paramA.property !== paramB.property
? paramA.property - paramB.property
: paramA.property_key! - paramB.property_key!
)
.map(
([id, item]) =>
html` <ha-settings-row
class="config-item"
.configId=${id}
.narrow=${this.narrow}
>
${this._generateConfigBox(id, item)}
</ha-settings-row>`
)}
</ha-card>
</div>`
)}
</ha-config-section>
</hass-tabs-subpage>
`;
}
private _generateConfigBox(
id: string,
item: ZWaveJSNodeConfigParam
): TemplateResult {
const result = this._results[id];
const labelAndDescription = html`
<span slot="prefix" class="prefix">
${this.hass.localize("ui.panel.config.zwave_js.node_config.parameter")}
<br />
<span>${item.property}</span>
${item.property_key !== null
? html`<br />
${this.hass.localize(
"ui.panel.config.zwave_js.node_config.bitmask"
)}
<br />
<span>${item.property_key.toString(16)}</span>`
: nothing}
</span>
<span slot="heading" class="heading" .title=${item.metadata.label}>
${item.metadata.label}
</span>
<span slot="description">
${item.metadata.description}
${item.metadata.description !== null && !item.metadata.writeable
? html`<br />`
: nothing}
${!item.metadata.writeable
? html`<em>
${this.hass.localize(
"ui.panel.config.zwave_js.node_config.parameter_is_read_only"
)}
</em>`
: nothing}
${result?.status
? html`<p
class="result ${classMap({
[result.status]: true,
})}"
>
<ha-svg-icon
.path=${icons[result.status] ? icons[result.status] : mdiCircle}
class="result-icon"
slot="item-icon"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.zwave_js.node_config.set_param_${result.status}`
)}
${result.status === "error" && result.error
? html` <br /><em>${result.error}</em> `
: nothing}
</p>`
: nothing}
</span>
`;
// Numeric entries with a min value of 0 and max of 1 are considered boolean
if (
item.configuration_value_type === "boolean" ||
this._isEnumeratedBool(item)
) {
return html`
${labelAndDescription}
<div class="switch">
<ha-switch
.property=${item.property}
.endpoint=${item.endpoint}
.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 === "manual_entry") {
return html`${labelAndDescription}
<ha-textfield
type="number"
.value=${item.value}
.min=${item.metadata.min}
.max=${item.metadata.max}
.property=${item.property}
.endpoint=${item.endpoint}
.propertyKey=${item.property_key}
.key=${id}
.disabled=${!item.metadata.writeable}
@change=${this._numericInputChanged}
.suffix=${item.metadata.unit}
>
</ha-textfield>`;
}
if (item.configuration_value_type === "enumerated") {
return html`
${labelAndDescription}
<ha-select
.disabled=${!item.metadata.writeable}
.value=${item.value?.toString()}
.key=${id}
.property=${item.property}
.endpoint=${item.endpoint}
.propertyKey=${item.property_key}
@selected=${this._dropdownSelected}
>
${Object.entries(item.metadata.states).map(
([key, entityState]) => html`
<mwc-list-item .value=${key}>${entityState}</mwc-list-item>
`
)}
</ha-select>
`;
}
return html`${labelAndDescription}
<p>${item.value}</p>`;
}
private _isEnumeratedBool(item: ZWaveJSNodeConfigParam): 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 (Object.keys(item.metadata.states).length !== 2) {
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.setResult(ev.target.key, undefined);
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?.toString() === ev.target.value) {
return;
}
this.setResult(ev.target.key, undefined);
this._updateConfigParameter(ev.target, Number(ev.target.value));
}
private _numericInputChanged(ev) {
if (ev.target === undefined || this._config![ev.target.key] === undefined) {
return;
}
const value = Number(ev.target.value);
if (Number(this._config![ev.target.key].value) === value) {
return;
}
this.setResult(ev.target.key, undefined);
this._updateConfigParameter(ev.target, value);
}
private async _updateConfigParameter(target, value) {
try {
const result = await setZwaveNodeConfigParameter(
this.hass,
this._device!.id,
target.property,
target.endpoint,
value,
target.propertyKey ? target.propertyKey : undefined
);
this._config![target.key].value = value;
this.setResult(target.key, result.status);
} catch (err: any) {
this.setError(target.key, err.message);
}
}
private setResult(key: string, value: string | undefined) {
if (value === undefined) {
delete this._results[key];
this.requestUpdate();
} else {
this._results = { ...this._results, [key]: { status: value } };
}
}
private setError(key: string, message: string) {
const errorParam = { status: "error", error: message };
this._results = { ...this._results, [key]: errorParam };
}
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;
}
[this._nodeMetadata, this._config] = await Promise.all([
fetchZwaveNodeMetadata(this.hass, device.id),
fetchZwaveNodeConfigParameters(this.hass, device.id),
]);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.accepted {
color: var(--success-color);
}
.queued {
color: var(--warning-color);
}
.error {
color: var(--error-color);
}
.secondary {
color: var(--secondary-text-color);
}
.flex {
display: flex;
}
.flex .config-label,
.flex ha-select {
flex: 1;
}
.content {
margin-top: 24px;
}
.sectionHeader {
position: relative;
padding-right: 40px;
padding-inline-start: initial;
padding-inline-end: 40px;
}
ha-settings-row {
--settings-row-prefix-display: contents;
--settings-row-content-width: 100%;
--paper-time-input-justify-content: flex-end;
border-top: 1px solid var(--divider-color);
padding: 4px 16px;
}
ha-settings-row:first-child {
border-top: none;
}
.prefix {
color: var(--secondary-text-color);
text-align: center;
text-transform: uppercase;
font-size: 0.8em;
padding-right: 24px;
padding-inline-end: 24px;
padding-inline-start: initial;
line-height: 1.5em;
}
.prefix span {
font-size: 1.3em;
}
.heading {
white-space: normal;
}
:host(:not([narrow])) ha-settings-row ha-textfield {
text-align: right;
}
ha-card:last-child {
margin-bottom: 24px;
}
.switch {
text-align: right;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zwave_js-node-config": ZWaveJSNodeConfig;
}
}