From 1b60e6e04e81710928ad040c21d4a23e8722d1ec Mon Sep 17 00:00:00 2001 From: Matthias de Baat Date: Tue, 24 Feb 2026 15:11:36 +0100 Subject: [PATCH] Reorganize Zigbee settings page (#29671) Co-authored-by: TheJulianJES Co-authored-by: Norbert Rittel Co-authored-by: Bram Kragten --- .../zha/dialog-zha-change-channel.ts | 62 +- .../zha/zha-add-devices-page.ts | 10 +- .../zha/zha-config-dashboard-router.ts | 14 + .../zha/zha-config-dashboard.ts | 659 +++++++++--------- .../zha/zha-config-section-page.ts | 143 ++++ .../zha/zha-groups-dashboard.ts | 15 +- .../zha/zha-network-data.ts | 226 ++++++ .../zha/zha-network-info-page.ts | 191 +++++ .../zha/zha-network-visualization-page.ts | 249 +------ .../zha/zha-options-page.ts | 468 +++++++++++++ src/translations/en.json | 64 +- 11 files changed, 1499 insertions(+), 602 deletions(-) create mode 100644 src/panels/config/integrations/integration-panels/zha/zha-config-section-page.ts create mode 100644 src/panels/config/integrations/integration-panels/zha/zha-network-data.ts create mode 100644 src/panels/config/integrations/integration-panels/zha/zha-network-info-page.ts create mode 100644 src/panels/config/integrations/integration-panels/zha/zha-options-page.ts diff --git a/src/panels/config/integrations/integration-panels/zha/dialog-zha-change-channel.ts b/src/panels/config/integrations/integration-panels/zha/dialog-zha-change-channel.ts index a64ccdb7b1..4e89ce3020 100644 --- a/src/panels/config/integrations/integration-panels/zha/dialog-zha-change-channel.ts +++ b/src/panels/config/integrations/integration-panels/zha/dialog-zha-change-channel.ts @@ -10,6 +10,7 @@ import "../../../../../components/ha-dialog"; import "../../../../../components/ha-select"; import type { HaSelectSelectEvent } from "../../../../../components/ha-select"; import { changeZHANetworkChannel } from "../../../../../data/zha"; +import type { HassDialog } from "../../../../../dialogs/make-dialog-manager"; import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box"; import type { HomeAssistant } from "../../../../../types"; import type { ZHAChangeChannelDialogParams } from "./show-dialog-zha-change-channel"; @@ -35,7 +36,10 @@ const VALID_CHANNELS = [ ]; @customElement("dialog-zha-change-channel") -class DialogZHAChangeChannel extends LitElement { +class DialogZHAChangeChannel + extends LitElement + implements HassDialog +{ @property({ attribute: false }) public hass!: HomeAssistant; @state() private _migrationInProgress = false; @@ -46,19 +50,24 @@ class DialogZHAChangeChannel extends LitElement { @state() private _open = false; - public async showDialog(params: ZHAChangeChannelDialogParams): Promise { + public showDialog(params: ZHAChangeChannelDialogParams): void { this._params = params; this._newChannel = "auto"; this._open = true; } - public closeDialog() { + public closeDialog(): boolean { + if (this._migrationInProgress) { + return false; + } this._open = false; + return true; } - private _dialogClosed() { + private _dialogClosed(): void { this._params = undefined; this._newChannel = undefined; + this._migrationInProgress = false; fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -77,7 +86,12 @@ class DialogZHAChangeChannel extends LitElement { prevent-scrim-close @closed=${this._dialogClosed} > - + ${this.hass.localize( "ui.panel.config.zha.change_channel_dialog.migration_warning" )} @@ -95,25 +109,25 @@ class DialogZHAChangeChannel extends LitElement { )}

-

- ({ - value: String(channel), - label: - channel === "auto" - ? this.hass.localize( - "ui.panel.config.zha.change_channel_dialog.channel_auto" - ) - : String(channel), - }))} - > - -

+ ({ + value: String(channel), + label: + channel === "auto" + ? this.hass.localize( + "ui.panel.config.zha.change_channel_dialog.channel_auto" + ) + : String(channel), + }))} + > + + ` : ""} - + `; } diff --git a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard-router.ts b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard-router.ts index 30a6029545..338524533b 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard-router.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard-router.ts @@ -39,6 +39,18 @@ class ZHAConfigDashboardRouter extends HassRouterPage { tag: "zha-network-visualization-page", load: () => import("./zha-network-visualization-page"), }, + options: { + tag: "zha-options-page", + load: () => import("./zha-options-page"), + }, + "network-info": { + tag: "zha-network-info-page", + load: () => import("./zha-network-info-page"), + }, + section: { + tag: "zha-config-section-page", + load: () => import("./zha-config-section-page"), + }, }, }; @@ -53,6 +65,8 @@ class ZHAConfigDashboardRouter extends HassRouterPage { el.ieee = this.routeTail.path.substr(1); } else if (this._currentPage === "visualization") { el.zoomedDeviceIdFromURL = this.routeTail.path.substr(1); + } else if (this._currentPage === "section") { + el.sectionId = this.routeTail.path.substr(1); } } } diff --git a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts index ed137756d4..2430a379fc 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts @@ -1,24 +1,25 @@ import { - mdiAlertCircle, - mdiCheckCircle, + mdiAlertCircleOutline, + mdiCheck, + mdiDevices, + mdiDownload, mdiFolderMultipleOutline, - mdiLan, - mdiNetwork, - mdiPencil, + mdiInformationOutline, mdiPlus, + mdiShape, + mdiTune, + mdiVectorPolyline, } from "@mdi/js"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import "../../../../../components/buttons/ha-progress-button"; import "../../../../../components/ha-alert"; import "../../../../../components/ha-button"; import "../../../../../components/ha-card"; import "../../../../../components/ha-fab"; -import "../../../../../components/ha-form/ha-form"; -import "../../../../../components/ha-icon-button"; import "../../../../../components/ha-icon-next"; -import "../../../../../components/ha-settings-row"; +import "../../../../../components/ha-md-list"; +import "../../../../../components/ha-md-list-item"; import "../../../../../components/ha-svg-icon"; import type { ConfigEntry } from "../../../../../data/config_entries"; import { getConfigEntries } from "../../../../../data/config_entries"; @@ -30,40 +31,16 @@ import type { import { createZHANetworkBackup, fetchDevices, + fetchGroups, fetchZHAConfiguration, fetchZHANetworkSettings, - updateZHAConfiguration, } from "../../../../../data/zha"; import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow"; import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box"; -import "../../../../../layouts/hass-tabs-subpage"; -import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage"; +import { fileDownload } from "../../../../../util/file_download"; +import "../../../../../layouts/hass-subpage"; import { haStyle } from "../../../../../resources/styles"; import type { HomeAssistant, Route } from "../../../../../types"; -import { fileDownload } from "../../../../../util/file_download"; -import "../../../ha-config-section"; -import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel"; -import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button"; - -const MULTIPROTOCOL_ADDON_URL = "socket://core-silabs-multiprotocol:9999"; - -export const zhaTabs: PageNavigation[] = [ - { - translationKey: "ui.panel.config.zha.network.caption", - path: `/config/zha/dashboard`, - iconPath: mdiNetwork, - }, - { - translationKey: "ui.panel.config.zha.groups.caption", - path: `/config/zha/groups`, - iconPath: mdiFolderMultipleOutline, - }, - { - translationKey: "ui.panel.config.zha.visualization.caption", - path: `/config/zha/visualization`, - iconPath: mdiLan, - }, -]; @customElement("zha-config-dashboard") class ZHAConfigDashboard extends LitElement { @@ -79,15 +56,15 @@ class ZHAConfigDashboard extends LitElement { @state() private _configuration?: ZHAConfiguration; - @state() private _networkSettings?: ZHANetworkSettings; - @state() private _totalDevices = 0; @state() private _offlineDevices = 0; - @state() private _error?: string; + @state() private _totalGroups = 0; - @state() private _generatingBackup = false; + @state() private _networkSettings?: ZHANetworkSettings; + + @state() private _error?: string; protected firstUpdated(changedProperties: PropertyValues) { super.firstUpdated(changedProperties); @@ -95,8 +72,9 @@ class ZHAConfigDashboard extends LitElement { this.hass.loadBackendTranslation("config_panel", "zha", false); this._fetchConfigEntry(); this._fetchConfiguration(); - this._fetchSettings(); this._fetchDevicesAndUpdateStatus(); + this._fetchGroups(); + this._fetchNetworkSettings(); } } @@ -104,205 +82,17 @@ class ZHAConfigDashboard extends LitElement { const deviceOnline = this._offlineDevices < this._totalDevices || this._totalDevices === 0; return html` -
- - ${this._error - ? html`${this._error}` - : nothing} -
-
-
- -
-
- ZHA - ${this.hass.localize( - "ui.panel.config.zha.configuration_page.status_title" - )}: - ${this.hass.localize( - `ui.panel.config.zha.configuration_page.status_${deviceOnline ? "online" : "offline"}` - )}
- - ${this.hass.localize( - "ui.panel.config.zha.configuration_page.devices", - { count: this._totalDevices } - )} - - - ${this._offlineDevices > 0 - ? html`(${this.hass.localize( - "ui.panel.config.zha.configuration_page.devices_offline", - { count: this._offlineDevices } - )})` - : nothing} - -
-
-
-
- - ${this.hass.localize( - "ui.panel.config.devices.caption" - )} - - ${this.hass.localize( - "ui.panel.config.entities.caption" - )} -
-
- - ${this._networkSettings - ? html`
- - PAN ID - ${this._networkSettings.settings.network_info - .pan_id} - - - - ${this._networkSettings.settings.network_info - .extended_pan_id} - Extended PAN ID - - - - Channel - ${this._networkSettings.settings.network_info - .channel} - - - - - - - Coordinator IEEE - ${this._networkSettings.settings.node_info.ieee} - - - - Radio type - ${this._networkSettings.radio_type} - - - - Serial port - ${this._networkSettings.device.path} - - - ${this._networkSettings.device.baudrate && - !this._networkSettings.device.path.startsWith("socket://") - ? html` - - Baudrate - ${this._networkSettings.device.baudrate} - - ` - : nothing} -
` - : nothing} -
- - ${this.hass.localize( - "ui.panel.config.zha.configuration_page.download_backup" - )} - - - ${this.hass.localize( - "ui.panel.config.zha.configuration_page.migrate_radio" - )} - -
-
- ${this._configuration - ? Object.entries(this._configuration.schemas).map( - ([section, schema]) => - html` -
- -
-
- - ${this.hass.localize( - "ui.panel.config.zha.configuration_page.update_button" - )} - -
-
` - ) - : nothing} + ${this._renderNetworkStatus(deviceOnline)} + ${this._renderMyNetworkCard()} ${this._renderNavigationCard()} + ${this._renderBackupCard()}
@@ -313,7 +103,240 @@ class ZHAConfigDashboard extends LitElement { -
+ + `; + } + + private _renderNetworkStatus(deviceOnline: boolean) { + return html` + + ${this._error + ? html`${this._error}` + : nothing} +
+
+
+ +
+
+ ${this.hass.localize( + `ui.panel.config.zha.configuration_page.status_${deviceOnline ? "online" : "offline"}` + )}
+ + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.devices", + { count: this._totalDevices } + )} + + + ${this._offlineDevices > 0 + ? html`(${this.hass.localize( + "ui.panel.config.zha.configuration_page.devices_offline", + { count: this._offlineDevices } + )})` + : nothing} + +
+
+
+
+ `; + } + + private _renderMyNetworkCard() { + const deviceIds = this._configEntry + ? new Set( + Object.values(this.hass.devices) + .filter((device) => + device.config_entries.includes(this._configEntry!.entry_id) + ) + .map((device) => device.id) + ) + : new Set(); + const entityCount = Object.values(this.hass.entities).filter( + (entity) => entity.device_id && deviceIds.has(entity.device_id) + ).length; + + return html` + +
+ ${this.hass.localize( + "ui.panel.config.zha.configuration_page.my_network_title" + )} + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.show_map" + )} + +
+
+ + + +
+ ${this.hass.localize( + "ui.panel.config.zha.configuration_page.device_count", + { count: deviceIds.size } + )} +
+ +
+ + +
+ ${this.hass.localize( + "ui.panel.config.zha.configuration_page.entity_count", + { count: entityCount } + )} +
+ +
+ + +
+ ${this.hass.localize( + "ui.panel.config.zha.configuration_page.group_count", + { count: this._totalGroups } + )} +
+ +
+
+
+
+ `; + } + + private _renderNavigationCard() { + const dynamicSections = this._configuration + ? Object.keys(this._configuration.schemas).filter( + (section) => section !== "zha_options" + ) + : []; + + return html` + +
+ + + +
+ ${this.hass.localize( + "ui.panel.config.zha.configuration_page.options_title" + )} +
+
+ ${this.hass.localize( + "ui.panel.config.zha.configuration_page.options_description" + )} +
+ +
+ + +
+ ${this.hass.localize( + "ui.panel.config.zha.configuration_page.network_info_title" + )} +
+
+ ${this.hass.localize( + "ui.panel.config.zha.configuration_page.network_info_description" + )} +
+ +
+ ${dynamicSections.map( + (section) => html` + + +
+ ${this.hass.localize( + `component.zha.config_panel.${section}.title` + ) || section} +
+ +
+ ` + )} +
+
+
+ `; + } + + private _renderBackupCard() { + return html` + +
+ + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.download_backup" + )} + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.download_backup_description" + )} + + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.download_backup_action" + )} + + + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.migrate_radio" + )} + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.migrate_radio_description" + )} + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.migrate_radio_action" + )} + + + +
+
`; } @@ -330,45 +353,13 @@ class ZHAConfigDashboard extends LitElement { this._configuration = await fetchZHAConfiguration(this.hass!); } - private async _fetchSettings(): Promise { + private async _fetchNetworkSettings(): Promise { this._networkSettings = await fetchZHANetworkSettings(this.hass!); } - private async _fetchDevicesAndUpdateStatus(): Promise { - try { - const devices = await fetchDevices(this.hass); - this._totalDevices = devices.length; - this._offlineDevices = - this._totalDevices - devices.filter((d) => d.available).length; - } catch (err: any) { - this._error = err.message || err; - } - } - - private async _showChannelMigrationDialog(): Promise { - if (this._networkSettings!.device.path === MULTIPROTOCOL_ADDON_URL) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.panel.config.zha.configuration_page.channel_dialog.title" - ), - text: this.hass.localize( - "ui.panel.config.zha.configuration_page.channel_dialog.text" - ), - warning: true, - }); - return; - } - - showZHAChangeChannelDialog(this, { - currentChannel: this._networkSettings!.settings.network_info.channel, - }); - } - private async _createAndDownloadBackup(): Promise { let backup_and_metadata: ZHANetworkBackupAndMetadata; - this._generatingBackup = true; - try { backup_and_metadata = await createZHANetworkBackup(this.hass!); } catch (err: any) { @@ -378,8 +369,6 @@ class ZHAConfigDashboard extends LitElement { warning: true, }); return; - } finally { - this._generatingBackup = false; } if (!backup_and_metadata.is_complete) { @@ -411,28 +400,24 @@ class ZHAConfigDashboard extends LitElement { showOptionsFlowDialog(this, this._configEntry); } - private _dataChanged(ev) { - this._configuration!.data[ev.currentTarget!.section] = ev.detail.value; - } - - private async _updateConfiguration(ev): Promise { - const button = ev.currentTarget as HaProgressButton; - button.progress = true; + private async _fetchGroups(): Promise { try { - await updateZHAConfiguration(this.hass!, this._configuration!.data); - button.actionSuccess(); - } catch (_err: any) { - button.actionError(); - } finally { - button.progress = false; + const groups = await fetchGroups(this.hass); + this._totalGroups = groups.length; + } catch (_err) { + // Groups are optional } } - private _computeLabelCallback(localize, section: string) { - // Returns a callback for ha-form to calculate labels per schema object - return (schema) => - localize(`component.zha.config_panel.${section}.${schema.name}`) || - schema.name; + private async _fetchDevicesAndUpdateStatus(): Promise { + try { + const devices = await fetchDevices(this.hass); + this._totalDevices = devices.length; + this._offlineDevices = + this._totalDevices - devices.filter((d) => d.available).length; + } catch (err: any) { + this._error = err.message || err; + } } static get styles(): CSSResultGroup { @@ -441,73 +426,99 @@ class ZHAConfigDashboard extends LitElement { css` ha-card { margin: auto; - margin-top: 16px; - max-width: 500px; + margin-top: var(--ha-space-4); + max-width: 600px; } - ha-card .card-actions { + .nav-card .card-header { display: flex; - justify-content: flex-end; + align-items: center; + justify-content: space-between; + padding-bottom: var(--ha-space-2); } - .network-settings ha-settings-row { - padding-left: 0; - padding-right: 0; - padding-inline-start: 0; - padding-inline-end: 0; + .nav-card { + overflow: hidden; } - .network-settings ha-settings-row span[slot="heading"] { - white-space: normal; - word-break: break-all; - text-indent: -1em; - padding-left: 1em; - padding-inline-start: 1em; - padding-inline-end: initial; - } - - .network-settings ha-settings-row ha-icon-button { - margin-top: -16px; - margin-bottom: -16px; + .nav-card .card-content { + padding: 0; } .content { - margin-top: 24px; + margin-top: var(--ha-space-6); + } + + ha-md-list { + background: none; + padding: 0; + } + + ha-md-list-item { + --md-item-overflow: visible; + } + + ha-button[size="small"] ha-svg-icon { + --mdc-icon-size: 16px; } .network-status div.heading { display: flex; align-items: center; + column-gap: var(--ha-space-4); } .network-status div.heading .icon { - margin-inline-end: 16px; + position: relative; + border-radius: var(--ha-border-radius-2xl); + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + --icon-color: var(--primary-color); } - .network-status div.heading ha-svg-icon { - --mdc-icon-size: 48px; + .network-status div.heading .icon.success { + --icon-color: var(--success-color); + } + + .network-status div.heading .icon.error { + --icon-color: var(--error-color); + } + + .network-status div.heading .icon::before { + display: block; + content: ""; + position: absolute; + inset: 0; + background-color: var(--icon-color); + opacity: 0.2; + } + + .network-status div.heading .icon ha-svg-icon { + color: var(--icon-color); + width: 24px; + height: 24px; } .network-status div.heading .details { font-size: var(--ha-font-size-xl); + font-weight: var(--ha-font-weight-normal); + line-height: var(--ha-line-height-condensed); + color: var(--primary-text-color); } .network-status small { font-size: var(--ha-font-size-m); - } - - .network-status small.offline { + font-weight: var(--ha-font-weight-normal); + line-height: var(--ha-line-height-condensed); + letter-spacing: 0.25px; color: var(--secondary-text-color); } - .network-status .online { - color: var(--state-on-color, var(--success-color)); - } - - .network-status .offline { - color: var(--error-color, var(--error-color)); - } - .container { padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4); } diff --git a/src/panels/config/integrations/integration-panels/zha/zha-config-section-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-config-section-page.ts new file mode 100644 index 0000000000..a39b119243 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zha/zha-config-section-page.ts @@ -0,0 +1,143 @@ +import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../../../components/buttons/ha-progress-button"; +import "../../../../../components/ha-card"; +import "../../../../../components/ha-form/ha-form"; +import type { ZHAConfiguration } from "../../../../../data/zha"; +import { + fetchZHAConfiguration, + updateZHAConfiguration, +} from "../../../../../data/zha"; +import "../../../../../layouts/hass-subpage"; +import { haStyle } from "../../../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../../../types"; + +@customElement("zha-config-section-page") +class ZHAConfigSectionPage extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: "is-wide", type: Boolean }) public isWide = false; + + @property({ attribute: "section-id" }) public sectionId!: string; + + @state() private _configuration?: ZHAConfiguration; + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + if (this.hass) { + this.hass.loadBackendTranslation("config_panel", "zha", false); + this._fetchConfiguration(); + } + } + + private async _fetchConfiguration(): Promise { + this._configuration = await fetchZHAConfiguration(this.hass!); + } + + protected render(): TemplateResult { + const schema = this._configuration?.schemas[this.sectionId]; + const data = this._configuration?.data[this.sectionId]; + + return html` + +
+ + ${schema && data + ? html` +
+ +
+
+ + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.update_button" + )} + +
+ ` + : nothing} +
+
+
+ `; + } + + private _dataChanged(ev) { + this._configuration!.data[this.sectionId] = ev.detail.value; + } + + private async _updateConfiguration(ev: Event): Promise { + const button = ev.currentTarget as HTMLElement & { + progress: boolean; + actionSuccess: () => void; + actionError: () => void; + }; + button.progress = true; + try { + await updateZHAConfiguration(this.hass!, this._configuration!.data); + button.actionSuccess(); + } catch (_err: any) { + button.actionError(); + } finally { + button.progress = false; + } + } + + private _computeLabelCallback(localize, section: string) { + return (schema) => + localize(`component.zha.config_panel.${section}.${schema.name}`) || + schema.name; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .container { + padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4); + } + + ha-card { + max-width: 600px; + margin: auto; + } + + .card-actions { + display: flex; + justify-content: flex-end; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "zha-config-section-page": ZHAConfigSectionPage; + } +} diff --git a/src/panels/config/integrations/integration-panels/zha/zha-groups-dashboard.ts b/src/panels/config/integrations/integration-panels/zha/zha-groups-dashboard.ts index ddb91aaf91..b8917d40e3 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-groups-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-groups-dashboard.ts @@ -1,4 +1,4 @@ -import { mdiPlus } from "@mdi/js"; +import { mdiFolderMultipleOutline, mdiPlus } from "@mdi/js"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; @@ -15,10 +15,18 @@ import "../../../../../components/ha-icon-button"; import type { ZHAGroup } from "../../../../../data/zha"; import { fetchGroups } from "../../../../../data/zha"; import "../../../../../layouts/hass-tabs-subpage-data-table"; +import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage"; import { haStyle } from "../../../../../resources/styles"; import type { HomeAssistant, Route } from "../../../../../types"; import { formatAsPaddedHex, sortZHAGroups } from "./functions"; -import { zhaTabs } from "./zha-config-dashboard"; + +const groupsTab: PageNavigation[] = [ + { + translationKey: "ui.panel.config.zha.groups.caption", + path: "/config/zha/groups", + iconPath: mdiFolderMultipleOutline, + }, +]; export interface GroupRowData extends ZHAGroup { group?: GroupRowData; @@ -100,7 +108,8 @@ export class ZHAGroupsDashboard extends LitElement { protected render(): TemplateResult { return html` 200 ? 3 : lqi > 100 ? 2 : 1; +} + +export function createZHANetworkChartData( + devices: ZHADevice[], + hass: HomeAssistant, + element: Element +): NetworkData { + const style = getComputedStyle(element); + + const primaryColor = style.getPropertyValue("--primary-color"); + const routerColor = style.getPropertyValue("--cyan-color"); + const endDeviceColor = style.getPropertyValue("--teal-color"); + const offlineColor = style.getPropertyValue("--error-color"); + const nodes: NetworkNode[] = []; + const links: NetworkLink[] = []; + const categories = [ + { + name: hass.localize("ui.panel.config.zha.visualization.coordinator"), + symbol: "roundRect", + itemStyle: { color: primaryColor }, + }, + { + name: hass.localize("ui.panel.config.zha.visualization.router"), + symbol: "circle", + itemStyle: { color: routerColor }, + }, + { + name: hass.localize("ui.panel.config.zha.visualization.end_device"), + symbol: "circle", + itemStyle: { color: endDeviceColor }, + }, + { + name: hass.localize("ui.panel.config.zha.visualization.offline"), + symbol: "circle", + itemStyle: { color: offlineColor }, + }, + ]; + + // Create all the nodes and links + devices.forEach((device) => { + const isCoordinator = device.device_type === "Coordinator"; + let category: number; + if (!device.available) { + category = 3; // Offline + } else if (isCoordinator) { + category = 0; + } else if (device.device_type === "Router") { + category = 1; + } else { + category = 2; // End Device + } + + const haDevice = hass.devices[device.device_reg_id] as + | DeviceRegistryEntry + | undefined; + const area = haDevice ? getDeviceContext(haDevice, hass).area : undefined; + // Create node + nodes.push({ + id: device.ieee, + name: device.user_given_name || device.name || device.ieee, + context: area?.name, + category, + value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1, + symbolSize: isCoordinator + ? 40 + : device.device_type === "Router" + ? 30 + : 20, + symbol: isCoordinator ? "roundRect" : "circle", + itemStyle: { + color: device.available + ? isCoordinator + ? primaryColor + : device.device_type === "Router" + ? routerColor + : endDeviceColor + : offlineColor, + }, + polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9, + fixed: isCoordinator, + }); + + // Create links (edges) + const existingLinks = links.filter( + (link) => link.source === device.ieee || link.target === device.ieee + ); + if (device.routes && device.routes.length > 0) { + device.routes.forEach((route) => { + const neighbor = device.neighbors.find((n) => n.nwk === route.next_hop); + if (!neighbor) { + return; + } + const existingLink = existingLinks.find( + (link) => + link.source === neighbor.ieee || link.target === neighbor.ieee + ); + + if (existingLink) { + if (existingLink.source === device.ieee) { + existingLink.value = Math.max( + existingLink.value!, + parseInt(neighbor.lqi) + ); + } else { + existingLink.reverseValue = Math.max( + existingLink.reverseValue ?? 0, + parseInt(neighbor.lqi) + ); + } + const width = getLQIWidth(parseInt(neighbor.lqi)); + existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9 + existingLink.lineStyle = { + ...existingLink.lineStyle, + width, + color: + route.route_status === "Active" + ? primaryColor + : existingLink.lineStyle!.color, + type: ["Child", "Parent"].includes(neighbor.relationship) + ? "solid" + : existingLink.lineStyle!.type, + }; + } else { + // Create a new link + const width = getLQIWidth(parseInt(neighbor.lqi)); + const link: NetworkLink = { + source: device.ieee, + target: neighbor.ieee, + value: parseInt(neighbor.lqi), + lineStyle: { + width, + color: + route.route_status === "Active" + ? primaryColor + : style.getPropertyValue("--dark-primary-color"), + type: ["Child", "Parent"].includes(neighbor.relationship) + ? "solid" + : "dotted", + }, + symbolSize: (width / 4) * 6 + 3, // range 3-9 + // By default, all links should be ignored for force layout + // unless it's a route to the coordinator + ignoreForceLayout: route.dest_nwk !== "0x0000", + }; + links.push(link); + existingLinks.push(link); + } + }); + } else if (existingLinks.length === 0) { + // If there are no links, create a link to the closest neighbor + const neighbors: { ieee: string; lqi: string }[] = device.neighbors ?? []; + if (neighbors.length === 0) { + // If there are no neighbors, look for links from other devices + devices.forEach((d) => { + if (d.neighbors && d.neighbors.length > 0) { + const neighbor = d.neighbors.find((n) => n.ieee === device.ieee); + if (neighbor) { + neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi }); + } + } + }); + } + const closestNeighbor = neighbors.sort( + (a, b) => parseInt(b.lqi) - parseInt(a.lqi) + )[0]; + if (closestNeighbor) { + links.push({ + source: device.ieee, + target: closestNeighbor.ieee, + value: parseInt(closestNeighbor.lqi), + symbolSize: 5, + lineStyle: { + width: 1, + color: style.getPropertyValue("--dark-primary-color"), + type: "dotted", + }, + ignoreForceLayout: true, + }); + } + } + }); + + // Now set ignoreForceLayout to false for the best connection of each device + // Except for the coordinator which can have multiple strong connections + devices.forEach((device) => { + if (device.device_type === "Coordinator") { + links.forEach((link) => { + if (link.source === device.ieee || link.target === device.ieee) { + link.ignoreForceLayout = false; + } + }); + } else { + // Find the link that corresponds to this strongest connection + let bestLink: NetworkLink | undefined; + const alreadyHasBestLink = links.some((link) => { + if (link.source === device.ieee || link.target === device.ieee) { + if (!link.ignoreForceLayout) { + return true; + } + if (link.value! > (bestLink?.value ?? -1)) { + bestLink = link; + } + } + return false; + }); + + if (!alreadyHasBestLink && bestLink) { + bestLink.ignoreForceLayout = false; + } + } + }); + + return { nodes, links, categories }; +} diff --git a/src/panels/config/integrations/integration-panels/zha/zha-network-info-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-network-info-page.ts new file mode 100644 index 0000000000..6361b83d89 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zha/zha-network-info-page.ts @@ -0,0 +1,191 @@ +import { mdiPencil } from "@mdi/js"; +import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../../../components/ha-card"; +import "../../../../../components/ha-icon-button"; +import "../../../../../components/ha-md-list"; +import "../../../../../components/ha-md-list-item"; +import type { ZHANetworkSettings } from "../../../../../data/zha"; +import { fetchZHANetworkSettings } from "../../../../../data/zha"; +import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box"; +import "../../../../../layouts/hass-subpage"; +import { haStyle } from "../../../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../../../types"; +import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel"; + +const MULTIPROTOCOL_ADDON_URL = "socket://core-silabs-multiprotocol:9999"; + +@customElement("zha-network-info-page") +class ZHANetworkInfoPage extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: "is-wide", type: Boolean }) public isWide = false; + + @state() private _networkSettings?: ZHANetworkSettings; + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + if (this.hass) { + this._fetchSettings(); + } + } + + private async _fetchSettings(): Promise { + this._networkSettings = await fetchZHANetworkSettings(this.hass!); + } + + protected render(): TemplateResult { + return html` + +
+ + ${this._networkSettings + ? html` + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.channel_label" + )} + ${this._networkSettings.settings.network_info + .channel} + + + + PAN ID + ${this._networkSettings.settings.network_info + .pan_id} + + + Extended PAN ID + ${this._networkSettings.settings.network_info + .extended_pan_id} + + + Coordinator IEEE + ${this._networkSettings.settings.node_info.ieee} + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.radio_type" + )} + ${this._networkSettings.radio_type} + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.serial_port" + )} + ${this._networkSettings.device.path} + + ${this._networkSettings.device.baudrate && + !this._networkSettings.device.path.startsWith("socket://") + ? html` + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.baudrate" + )} + ${this._networkSettings.device.baudrate} + + ` + : nothing} + ` + : nothing} + +
+
+ `; + } + + private async _showChannelMigrationDialog(): Promise { + if (this._networkSettings!.device.path === MULTIPROTOCOL_ADDON_URL) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.zha.configuration_page.channel_dialog.title" + ), + text: this.hass.localize( + "ui.panel.config.zha.configuration_page.channel_dialog.text" + ), + warning: true, + }); + return; + } + + showZHAChangeChannelDialog(this, { + currentChannel: this._networkSettings!.settings.network_info.channel, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .container { + padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4); + } + + ha-card { + max-width: 600px; + margin: auto; + } + + ha-md-list { + background: none; + padding: 0; + } + + ha-md-list-item { + --md-item-overflow: visible; + --md-list-item-supporting-text-size: var( + --md-list-item-label-text-size, + var(--md-sys-typescale-body-large-size, 1rem) + ); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "zha-network-info-page": ZHANetworkInfoPage; + } +} diff --git a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts index 3c87cdd8b7..53b69870ab 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts @@ -9,18 +9,14 @@ import { customElement, property, state } from "lit/decorators"; import { getDeviceContext } from "../../../../../common/entity/context/get_device_context"; import { navigate } from "../../../../../common/navigate"; import "../../../../../components/chart/ha-network-graph"; -import type { - NetworkData, - NetworkLink, - NetworkNode, -} from "../../../../../components/chart/ha-network-graph"; +import type { NetworkData } from "../../../../../components/chart/ha-network-graph"; import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry"; import type { ZHADevice } from "../../../../../data/zha"; import { fetchDevices, refreshTopology } from "../../../../../data/zha"; -import "../../../../../layouts/hass-tabs-subpage"; +import "../../../../../layouts/hass-subpage"; import type { HomeAssistant, Route } from "../../../../../types"; import { formatAsPaddedHex } from "./functions"; -import { zhaTabs } from "./zha-config-dashboard"; +import { createZHANetworkChartData } from "./zha-network-data"; @customElement("zha-network-visualization-page") export class ZHANetworkVisualizationPage extends LitElement { @@ -52,13 +48,12 @@ export class ZHANetworkVisualizationPage extends LitElement { protected render() { return html` - - + `; } private async _fetchData() { this._devices = await fetchDevices(this.hass!); - this._networkData = this._createChartData(this._devices); + this._networkData = createZHANetworkChartData( + this._devices, + this.hass, + this + ); } private _tooltipFormatter = (params: TopLevelFormatterParams): string => { @@ -158,228 +157,6 @@ export class ZHANetworkVisualizationPage extends LitElement { `, ]; } - - private _createChartData(devices: ZHADevice[]): NetworkData { - const style = getComputedStyle(this); - - const primaryColor = style.getPropertyValue("--primary-color"); - const routerColor = style.getPropertyValue("--cyan-color"); - const endDeviceColor = style.getPropertyValue("--teal-color"); - const offlineColor = style.getPropertyValue("--error-color"); - const nodes: NetworkNode[] = []; - const links: NetworkLink[] = []; - const categories = [ - { - name: this.hass.localize( - "ui.panel.config.zha.visualization.coordinator" - ), - symbol: "roundRect", - itemStyle: { color: primaryColor }, - }, - { - name: this.hass.localize("ui.panel.config.zha.visualization.router"), - symbol: "circle", - itemStyle: { color: routerColor }, - }, - { - name: this.hass.localize( - "ui.panel.config.zha.visualization.end_device" - ), - symbol: "circle", - itemStyle: { color: endDeviceColor }, - }, - { - name: this.hass.localize("ui.panel.config.zha.visualization.offline"), - symbol: "circle", - itemStyle: { color: offlineColor }, - }, - ]; - - // Create all the nodes and links - devices.forEach((device) => { - const isCoordinator = device.device_type === "Coordinator"; - let category: number; - if (!device.available) { - category = 3; // Offline - } else if (isCoordinator) { - category = 0; - } else if (device.device_type === "Router") { - category = 1; - } else { - category = 2; // End Device - } - - const haDevice = this.hass.devices[device.device_reg_id] as - | DeviceRegistryEntry - | undefined; - const area = haDevice - ? getDeviceContext(haDevice, this.hass).area - : undefined; - // Create node - nodes.push({ - id: device.ieee, - name: device.user_given_name || device.name || device.ieee, - context: area?.name, - category, - value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1, - symbolSize: isCoordinator - ? 40 - : device.device_type === "Router" - ? 30 - : 20, - symbol: isCoordinator ? "roundRect" : "circle", - itemStyle: { - color: device.available - ? isCoordinator - ? primaryColor - : device.device_type === "Router" - ? routerColor - : endDeviceColor - : offlineColor, - }, - polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9, - fixed: isCoordinator, - }); - - // Create links (edges) - const existingLinks = links.filter( - (link) => link.source === device.ieee || link.target === device.ieee - ); - if (device.routes && device.routes.length > 0) { - device.routes.forEach((route) => { - const neighbor = device.neighbors.find( - (n) => n.nwk === route.next_hop - ); - if (!neighbor) { - return; - } - const existingLink = existingLinks.find( - (link) => - link.source === neighbor.ieee || link.target === neighbor.ieee - ); - - if (existingLink) { - if (existingLink.source === device.ieee) { - existingLink.value = Math.max( - existingLink.value!, - parseInt(neighbor.lqi) - ); - } else { - existingLink.reverseValue = Math.max( - existingLink.reverseValue ?? 0, - parseInt(neighbor.lqi) - ); - } - const width = this._getLQIWidth(parseInt(neighbor.lqi)); - existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9 - existingLink.lineStyle = { - ...existingLink.lineStyle, - width, - color: - route.route_status === "Active" - ? primaryColor - : existingLink.lineStyle!.color, - type: ["Child", "Parent"].includes(neighbor.relationship) - ? "solid" - : existingLink.lineStyle!.type, - }; - } else { - // Create a new link - const width = this._getLQIWidth(parseInt(neighbor.lqi)); - const link: NetworkLink = { - source: device.ieee, - target: neighbor.ieee, - value: parseInt(neighbor.lqi), - lineStyle: { - width, - color: - route.route_status === "Active" - ? primaryColor - : style.getPropertyValue("--dark-primary-color"), - type: ["Child", "Parent"].includes(neighbor.relationship) - ? "solid" - : "dotted", - }, - symbolSize: (width / 4) * 6 + 3, // range 3-9 - // By default, all links should be ignored for force layout - // unless it's a route to the coordinator - ignoreForceLayout: route.dest_nwk !== "0x0000", - }; - links.push(link); - existingLinks.push(link); - } - }); - } else if (existingLinks.length === 0) { - // If there are no links, create a link to the closest neighbor - const neighbors: { ieee: string; lqi: string }[] = - device.neighbors ?? []; - if (neighbors.length === 0) { - // If there are no neighbors, look for links from other devices - devices.forEach((d) => { - if (d.neighbors && d.neighbors.length > 0) { - const neighbor = d.neighbors.find((n) => n.ieee === device.ieee); - if (neighbor) { - neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi }); - } - } - }); - } - const closestNeighbor = neighbors.sort( - (a, b) => parseInt(b.lqi) - parseInt(a.lqi) - )[0]; - if (closestNeighbor) { - links.push({ - source: device.ieee, - target: closestNeighbor.ieee, - value: parseInt(closestNeighbor.lqi), - symbolSize: 5, - lineStyle: { - width: 1, - color: style.getPropertyValue("--dark-primary-color"), - type: "dotted", - }, - ignoreForceLayout: true, - }); - } - } - }); - - // Now set ignoreForceLayout to false for the best connection of each device - // Except for the coordinator which can have multiple strong connections - devices.forEach((device) => { - if (device.device_type === "Coordinator") { - links.forEach((link) => { - if (link.source === device.ieee || link.target === device.ieee) { - link.ignoreForceLayout = false; - } - }); - } else { - // Find the link that corresponds to this strongest connection - let bestLink: NetworkLink | undefined; - const alreadyHasBestLink = links.some((link) => { - if (link.source === device.ieee || link.target === device.ieee) { - if (!link.ignoreForceLayout) { - return true; - } - if (link.value! > (bestLink?.value ?? -1)) { - bestLink = link; - } - } - return false; - }); - - if (!alreadyHasBestLink && bestLink) { - bestLink.ignoreForceLayout = false; - } - } - }); - - return { nodes, links, categories }; - } - - private _getLQIWidth(lqi: number): number { - return lqi > 200 ? 3 : lqi > 100 ? 2 : 1; - } } declare global { diff --git a/src/panels/config/integrations/integration-panels/zha/zha-options-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-options-page.ts new file mode 100644 index 0000000000..15537d7ec1 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zha/zha-options-page.ts @@ -0,0 +1,468 @@ +import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../../../components/buttons/ha-progress-button"; +import "../../../../../components/ha-card"; +import "../../../../../components/ha-md-list"; +import "../../../../../components/ha-md-list-item"; +import "../../../../../components/ha-select"; +import "../../../../../components/ha-textfield"; +import "../../../../../components/ha-switch"; +import type { ZHAConfiguration } from "../../../../../data/zha"; +import { + fetchZHAConfiguration, + updateZHAConfiguration, +} from "../../../../../data/zha"; +import "../../../../../layouts/hass-subpage"; +import { haStyle } from "../../../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../../../types"; + +const PREDEFINED_TIMEOUTS = [1800, 3600, 7200, 21600, 43200, 86400]; + +@customElement("zha-options-page") +class ZHAOptionsPage extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: "is-wide", type: Boolean }) public isWide = false; + + @state() private _configuration?: ZHAConfiguration; + + @state() private _customMains = false; + + @state() private _customBattery = false; + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + if (this.hass) { + this._fetchConfiguration(); + } + } + + private async _fetchConfiguration(): Promise { + this._configuration = await fetchZHAConfiguration(this.hass!); + const mainsValue = this._configuration.data.zha_options + ?.consider_unavailable_mains as number | undefined; + const batteryValue = this._configuration.data.zha_options + ?.consider_unavailable_battery as number | undefined; + this._customMains = + mainsValue !== undefined && !PREDEFINED_TIMEOUTS.includes(mainsValue); + this._customBattery = + batteryValue !== undefined && !PREDEFINED_TIMEOUTS.includes(batteryValue); + } + + private _getUnavailableTimeoutOptions(defaultSeconds: number) { + const defaultLabel = ` (${this.hass.localize("ui.panel.config.zha.configuration_page.timeout_default")})`; + const options: { value: string; seconds: number; key: string }[] = [ + { value: "1800", seconds: 1800, key: "timeout_30_min" }, + { value: "3600", seconds: 3600, key: "timeout_1_hour" }, + { value: "7200", seconds: 7200, key: "timeout_2_hours" }, + { value: "21600", seconds: 21600, key: "timeout_6_hours" }, + { value: "43200", seconds: 43200, key: "timeout_12_hours" }, + { value: "86400", seconds: 86400, key: "timeout_24_hours" }, + ]; + return [ + ...options.map((opt) => ({ + value: opt.value, + label: this.hass.localize( + `ui.panel.config.zha.configuration_page.${opt.key}`, + { default: opt.seconds === defaultSeconds ? defaultLabel : "" } + ), + })), + { + value: "custom", + label: this.hass.localize( + "ui.panel.config.zha.configuration_page.timeout_custom" + ), + }, + ]; + } + + private _getUnavailableDropdownValue( + seconds: unknown, + isCustom: boolean + ): string { + if (isCustom) { + return "custom"; + } + const value = (seconds as number) ?? 7200; + if (PREDEFINED_TIMEOUTS.includes(value)) { + return String(value); + } + return "custom"; + } + + protected render(): TemplateResult { + return html` + +
+ + ${this._configuration + ? html` + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.enable_identify_on_join_label" + )} + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.enable_identify_on_join_description" + )} + + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.default_light_transition_label" + )} + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.default_light_transition_description" + )} + + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.enhanced_light_transition_label" + )} + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.enhanced_light_transition_description" + )} + + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.light_transitioning_flag_label" + )} + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.light_transitioning_flag_description" + )} + + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.group_members_assume_state_label" + )} + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.group_members_assume_state_description" + )} + + + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.consider_unavailable_mains_label" + )} + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.consider_unavailable_mains_description" + )} + + + ${this._customMains + ? html` + + + + ` + : nothing} + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.consider_unavailable_battery_label" + )} + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.consider_unavailable_battery_description" + )} + + + ${this._customBattery + ? html` + + + + ` + : nothing} + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.enable_mains_startup_polling_label" + )} + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.enable_mains_startup_polling_description" + )} + + + +
+ + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.update_button" + )} + +
+ ` + : nothing} +
+
+
+ `; + } + + private _enableIdentifyOnJoinChanged(ev: Event): void { + const checked = (ev.target as HTMLInputElement).checked; + this._configuration!.data.zha_options.enable_identify_on_join = checked; + this.requestUpdate(); + } + + private _enhancedLightTransitionChanged(ev: Event): void { + const checked = (ev.target as HTMLInputElement).checked; + this._configuration!.data.zha_options.enhanced_light_transition = checked; + this.requestUpdate(); + } + + private _lightTransitioningFlagChanged(ev: Event): void { + const checked = (ev.target as HTMLInputElement).checked; + this._configuration!.data.zha_options.light_transitioning_flag = checked; + this.requestUpdate(); + } + + private _groupMembersAssumeStateChanged(ev: Event): void { + const checked = (ev.target as HTMLInputElement).checked; + this._configuration!.data.zha_options.group_members_assume_state = checked; + this.requestUpdate(); + } + + private _enableMainsStartupPollingChanged(ev: Event): void { + const checked = (ev.target as HTMLInputElement).checked; + this._configuration!.data.zha_options.enable_mains_startup_polling = + checked; + this.requestUpdate(); + } + + private _defaultLightTransitionChanged(ev: Event): void { + const value = Number((ev.target as HTMLInputElement).value); + this._configuration!.data.zha_options.default_light_transition = value; + this.requestUpdate(); + } + + private _customMainsSecondsChanged(ev: Event): void { + const seconds = Number((ev.target as HTMLInputElement).value); + this._configuration!.data.zha_options.consider_unavailable_mains = seconds; + this.requestUpdate(); + } + + private _customBatterySecondsChanged(ev: Event): void { + const seconds = Number((ev.target as HTMLInputElement).value); + this._configuration!.data.zha_options.consider_unavailable_battery = + seconds; + this.requestUpdate(); + } + + private _mainsUnavailableChanged(ev: CustomEvent): void { + const value = ev.detail.value; + if (value === "custom") { + this._customMains = true; + } else { + this._customMains = false; + this._configuration!.data.zha_options.consider_unavailable_mains = + Number(value); + } + this.requestUpdate(); + } + + private _batteryUnavailableChanged(ev: CustomEvent): void { + const value = ev.detail.value; + if (value === "custom") { + this._customBattery = true; + } else { + this._customBattery = false; + this._configuration!.data.zha_options.consider_unavailable_battery = + Number(value); + } + this.requestUpdate(); + } + + private async _updateConfiguration(ev: Event): Promise { + const button = ev.currentTarget as HTMLElement & { + progress: boolean; + actionSuccess: () => void; + actionError: () => void; + }; + button.progress = true; + try { + await updateZHAConfiguration(this.hass!, this._configuration!.data); + button.actionSuccess(); + } catch (_err: any) { + button.actionError(); + } finally { + button.progress = false; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .container { + padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4); + } + + ha-card { + max-width: 600px; + margin: auto; + } + + ha-md-list { + background: none; + padding: 0; + } + + ha-md-list-item { + --md-item-overflow: visible; + } + + ha-select, + ha-textfield { + min-width: 210px; + } + + .card-actions { + display: flex; + justify-content: flex-end; + } + + @media all and (max-width: 450px) { + ha-select, + ha-textfield { + min-width: 160px; + width: 160px; + } + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "zha-options-page": ZHAOptionsPage; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 1dd5eb6ec1..41fe01d2c6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6851,14 +6851,59 @@ }, "configuration_page": { "status_title": "status", - "status_online": "online", - "status_offline": "offline", + "status_online": "Online", + "status_offline": "Offline", "devices": "{count} {count, plural,\n one {device}\n other {devices}\n}", "devices_offline": "{count} offline", + "device_count": "{count} {count, plural,\n one {device}\n other {devices}\n}", + "entity_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}", + "group_count": "{count} {count, plural,\n one {group}\n other {groups}\n}", + "show_map": "Show map", "update_button": "Update configuration", "download_backup": "Download backup", + "download_backup_description": "Save your Zigbee network configuration to a file", + "download_backup_action": "Download", "migrate_radio": "Migrate adapter", - "network_settings_title": "Network settings", + "migrate_radio_description": "Move your Zigbee network to a different adapter", + "migrate_radio_action": "Migrate", + "group_members_assume_state_label": "Assume state of group", + "enable_identify_on_join_label": "Identify on join", + "default_light_transition_label": "Light transition time", + "enhanced_light_transition_label": "Smooth transition power-on", + "light_transitioning_flag_label": "Prevent slider jumping during transitions", + "consider_unavailable_mains_label": "Consider mains-powered devices unavailable after", + "consider_unavailable_battery_label": "Consider battery-powered devices unavailable after", + "enable_mains_startup_polling_label": "Refresh mains-powered devices state on startup", + "my_network_title": "My network", + "options_title": "Options", + "options_description": "Configure device behavior and network timeouts", + "network_info_title": "Network information", + "network_info_description": "View channel and coordinator details", + "backup_restore_title": "Backup and restore", + "backup_restore_description_short": "Download or migrate your Zigbee network backup", + "backup_restore_description": "Back up or restore your Zigbee adapter. The backup only contains your network information.", + "group_members_assume_state_description": "The group entity state is optimistically based on the states of its members", + "enable_identify_on_join_description": "Show an identify effect on devices when they join the network", + "channel_description": "The Zigbee channel used by the network", + "default_light_transition_description": "Default transition time for light changes in seconds", + "enhanced_light_transition_description": "Lights fade in directly to the chosen color or warmth, without briefly flashing the old setting", + "light_transitioning_flag_description": "Keeps the brightness slider at the chosen level during light fades so it does not jump around", + "consider_unavailable_mains_description": "How long to wait before showing a mains-powered device as unavailable when it stops responding", + "consider_unavailable_battery_description": "How long to wait before showing a battery-powered device as unavailable when it stops responding", + "enable_mains_startup_polling_description": "Get the current state of mains-powered devices when Home Assistant starts", + "channel_label": "Channel", + "radio_type": "Radio type", + "serial_port": "Serial port", + "baudrate": "Baudrate", + "custom_seconds": "Custom", + "timeout_30_min": "30 minutes{default}", + "timeout_1_hour": "1 hour{default}", + "timeout_2_hours": "2 hours{default}", + "timeout_6_hours": "6 hours{default}", + "timeout_12_hours": "12 hours{default}", + "timeout_24_hours": "24 hours{default}", + "timeout_default": "default", + "timeout_custom": "Custom", "change_channel": "Change channel", "channel_dialog": { "title": "Multiprotocol app in use", @@ -6907,7 +6952,7 @@ "INITIALIZED_status_text": "The device is ready to use" }, "network": { - "caption": "Network" + "caption": "Zigbee" }, "groups": { "add_group": "Create group", @@ -6930,7 +6975,7 @@ "delete": "Delete group" }, "visualization": { - "header": "Network visualization", + "header": "Zigbee visualization", "caption": "Visualization", "refresh_topology": "Refresh topology", "device": "Device", @@ -6965,12 +7010,13 @@ "depth": "Depth" }, "change_channel_dialog": { - "title": "Change network channel", + "title": "Change Zigbee channel", "new_channel": "New channel", "change_channel": "Change channel", - "migration_warning": "Zigbee channel migration is an experimental feature and relies on devices on your network to support it. Device support for this feature varies and only a portion of your network may end up migrating! It may take up to an hour for changes to propagate to all devices.", - "description": "Change your Zigbee channel only after you have eliminated all other sources of 2.4GHz interference by using a USB extension cable and moving your coordinator away from USB 3.0 devices and ports, SSDs, 2.4GHz Wi-Fi networks on the same channel, motherboards, and so on.", - "smart_explanation": "It is recommended to use the \"Smart\" option once your environment is optimized as opposed to manually choosing a channel, as it picks the best channel for you after scanning all Zigbee channels. This does not configure ZHA to automatically change channels in the future, it only changes the channel a single time.", + "migration_warning_title": "Experimental feature", + "migration_warning": "Zigbee channel migration is experimental and depends on device support. Some devices may not migrate. It can take up to an hour for all supported devices to update.", + "description": "Only change your Zigbee channel after reducing other 2.4 GHz interference, for example by using a USB extension cable and keeping the coordinator away from USB 3.0 devices, SSDs, and nearby Wi-Fi.", + "smart_explanation": "Once your setup is optimized, the Smart option is recommended. It scans all Zigbee channels and selects the best one, but it only changes the channel once and does not switch automatically later.", "channel_has_been_changed": "Network channel has been changed", "devices_will_rejoin": "Devices will re-join the network over time. This may take a few minutes.", "channel_auto": "Smart"