1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 08:33:31 +01:00

Reorganize Zigbee settings page (#29671)

Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Matthias de Baat
2026-02-24 15:11:36 +01:00
committed by GitHub
parent a1a634f6dc
commit 1b60e6e04e
11 changed files with 1499 additions and 602 deletions

View File

@@ -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<ZHAChangeChannelDialogParams>
{
@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<void> {
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}
>
<ha-alert alert-type="warning">
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.migration_warning_title"
)}
>
${this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.migration_warning"
)}
@@ -95,25 +109,25 @@ class DialogZHAChangeChannel extends LitElement {
)}
</p>
<p>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.new_channel"
)}
@selected=${this._newChannelChosen}
.value=${String(this._newChannel)}
.options=${VALID_CHANNELS.map((channel) => ({
value: String(channel),
label:
channel === "auto"
? this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.channel_auto"
)
: String(channel),
}))}
>
</ha-select>
</p>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.new_channel"
)}
autofocus
@selected=${this._newChannelChosen}
.value=${String(this._newChannel)}
.options=${VALID_CHANNELS.map((channel) => ({
value: String(channel),
label:
channel === "auto"
? this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.channel_auto"
)
: String(channel),
}))}
>
</ha-select>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"

View File

@@ -6,11 +6,10 @@ import "../../../../../components/ha-spinner";
import "../../../../../components/ha-textarea";
import type { ZHADevice } from "../../../../../data/zha";
import { DEVICE_MESSAGE_TYPES, LOG_OUTPUT } from "../../../../../data/zha";
import "../../../../../layouts/hass-tabs-subpage";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
import { zhaTabs } from "./zha-config-dashboard";
import "./zha-device-pairing-status-card";
@customElement("zha-add-devices-page")
@@ -74,11 +73,10 @@ class ZHAAddDevicesPage extends LitElement {
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route!}
.tabs=${zhaTabs}
.header=${this.hass.localize("ui.panel.config.zha.add_device")}
>
<ha-button
appearance="plain"
@@ -168,7 +166,7 @@ class ZHAAddDevicesPage extends LitElement {
>
</ha-textarea>`
: ""}
</hass-tabs-subpage>
</hass-subpage>
`;
}

View File

@@ -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);
}
}
}

View File

@@ -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`
<hass-tabs-subpage
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${zhaTabs}
.header=${this.hass.localize("ui.panel.config.zha.network.caption")}
back-path="/config"
has-fab
>
<div class="container">
<ha-card class="content network-status">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon
.path=${deviceOnline ? mdiCheckCircle : mdiAlertCircle}
class=${deviceOnline ? "online" : "offline"}
></ha-svg-icon>
</div>
<div class="details">
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"}`
)}<br />
<small>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices",
{ count: this._totalDevices }
)}
</small>
<small class="offline">
${this._offlineDevices > 0
? html`(${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices_offline",
{ count: this._offlineDevices }
)})`
: nothing}
</small>
</div>
</div>
</div>
<div class="card-actions">
<ha-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
appearance="plain"
size="small"
>
${this.hass.localize(
"ui.panel.config.devices.caption"
)}</ha-button
>
<ha-button
appearance="plain"
size="small"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
${this.hass.localize(
"ui.panel.config.entities.caption"
)}</ha-button
>
</div>
</ha-card>
<ha-card
class="network-settings"
header=${this.hass.localize(
"ui.panel.config.zha.configuration_page.network_settings_title"
)}
>
${this._networkSettings
? html`<div class="card-content">
<ha-settings-row>
<span slot="description">PAN ID</span>
<span slot="heading"
>${this._networkSettings.settings.network_info
.pan_id}</span
>
</ha-settings-row>
<ha-settings-row>
<span slot="heading"
>${this._networkSettings.settings.network_info
.extended_pan_id}</span
>
<span slot="description">Extended PAN ID</span>
</ha-settings-row>
<ha-settings-row>
<span slot="description">Channel</span>
<span slot="heading"
>${this._networkSettings.settings.network_info
.channel}</span
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.zha.configuration_page.change_channel"
)}
.path=${mdiPencil}
@click=${this._showChannelMigrationDialog}
>
</ha-icon-button>
</ha-settings-row>
<ha-settings-row>
<span slot="description">Coordinator IEEE</span>
<span slot="heading"
>${this._networkSettings.settings.node_info.ieee}</span
>
</ha-settings-row>
<ha-settings-row>
<span slot="description">Radio type</span>
<span slot="heading"
>${this._networkSettings.radio_type}</span
>
</ha-settings-row>
<ha-settings-row>
<span slot="description">Serial port</span>
<span slot="heading"
>${this._networkSettings.device.path}</span
>
</ha-settings-row>
${this._networkSettings.device.baudrate &&
!this._networkSettings.device.path.startsWith("socket://")
? html`
<ha-settings-row>
<span slot="description">Baudrate</span>
<span slot="heading"
>${this._networkSettings.device.baudrate}</span
>
</ha-settings-row>
`
: nothing}
</div>`
: nothing}
<div class="card-actions">
<ha-progress-button
appearance="plain"
@click=${this._createAndDownloadBackup}
.progress=${this._generatingBackup}
.disabled=${!this._networkSettings || this._generatingBackup}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup"
)}
</ha-progress-button>
<ha-button
appearance="filled"
variant="brand"
@click=${this._openOptionFlow}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.migrate_radio"
)}
</ha-button>
</div>
</ha-card>
${this._configuration
? Object.entries(this._configuration.schemas).map(
([section, schema]) =>
html`<ha-card
header=${this.hass.localize(
`component.zha.config_panel.${section}.title`
)}
>
<div class="card-content">
<ha-form
.hass=${this.hass}
.schema=${schema}
.data=${this._configuration!.data[section]}
@value-changed=${this._dataChanged}
.section=${section}
.computeLabel=${this._computeLabelCallback(
this.hass.localize,
section
)}
></ha-form>
</div>
<div class="card-actions">
<ha-progress-button
appearance="filled"
variant="brand"
@click=${this._updateConfiguration}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.update_button"
)}
</ha-progress-button>
</div>
</ha-card>`
)
: nothing}
${this._renderNetworkStatus(deviceOnline)}
${this._renderMyNetworkCard()} ${this._renderNavigationCard()}
${this._renderBackupCard()}
</div>
<a href="/config/zha/add" slot="fab">
@@ -313,7 +103,240 @@ class ZHAConfigDashboard extends LitElement {
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</a>
</hass-tabs-subpage>
</hass-subpage>
`;
}
private _renderNetworkStatus(deviceOnline: boolean) {
return html`
<ha-card class="content network-status">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="card-content">
<div class="heading">
<div class="icon ${deviceOnline ? "success" : "error"}">
<ha-svg-icon
.path=${deviceOnline ? mdiCheck : mdiAlertCircleOutline}
></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize(
`ui.panel.config.zha.configuration_page.status_${deviceOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices",
{ count: this._totalDevices }
)}
</small>
<small class="offline">
${this._offlineDevices > 0
? html`(${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices_offline",
{ count: this._offlineDevices }
)})`
: nothing}
</small>
</div>
</div>
</div>
</ha-card>
`;
}
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<string>();
const entityCount = Object.values(this.hass.entities).filter(
(entity) => entity.device_id && deviceIds.has(entity.device_id)
).length;
return html`
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.my_network_title"
)}
<ha-button appearance="filled" href="/config/zha/visualization">
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.show_map"
)}
</ha-button>
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.device_count",
{ count: deviceIds.size }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
type="link"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.entity_count",
{ count: entityCount }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="link" href="/config/zha/groups">
<ha-svg-icon
slot="start"
.path=${mdiFolderMultipleOutline}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.group_count",
{ count: this._totalGroups }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderNavigationCard() {
const dynamicSections = this._configuration
? Object.keys(this._configuration.schemas).filter(
(section) => section !== "zha_options"
)
: [];
return html`
<ha-card class="nav-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item type="link" href="/config/zha/options">
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.options_title"
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.options_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="link" href="/config/zha/network-info">
<ha-svg-icon
slot="start"
.path=${mdiInformationOutline}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.network_info_title"
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.network_info_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
${dynamicSections.map(
(section) => html`
<ha-md-list-item
type="link"
href=${`/config/zha/section/${section}`}
>
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
`component.zha.config_panel.${section}.title`
) || section}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`
)}
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderBackupCard() {
return html`
<ha-card class="nav-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="small"
@click=${this._createAndDownloadBackup}
.disabled=${!this._networkSettings}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.migrate_radio"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.migrate_radio_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="small"
@click=${this._openOptionFlow}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.migrate_radio_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
@@ -330,45 +353,13 @@ class ZHAConfigDashboard extends LitElement {
this._configuration = await fetchZHAConfiguration(this.hass!);
}
private async _fetchSettings(): Promise<void> {
private async _fetchNetworkSettings(): Promise<void> {
this._networkSettings = await fetchZHANetworkSettings(this.hass!);
}
private async _fetchDevicesAndUpdateStatus(): Promise<void> {
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<void> {
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<void> {
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<any> {
const button = ev.currentTarget as HaProgressButton;
button.progress = true;
private async _fetchGroups(): Promise<void> {
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<void> {
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);
}

View File

@@ -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<void> {
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`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
`component.zha.config_panel.${this.sectionId}.title`
) || this.sectionId}
back-path="/config/zha/dashboard"
>
<div class="container">
<ha-card>
${schema && data
? html`
<div class="card-content">
<ha-form
.hass=${this.hass}
.schema=${schema}
.data=${data}
@value-changed=${this._dataChanged}
.computeLabel=${this._computeLabelCallback(
this.hass.localize,
this.sectionId
)}
></ha-form>
</div>
<div class="card-actions">
<ha-progress-button
appearance="filled"
variant="brand"
@click=${this._updateConfiguration}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.update_button"
)}
</ha-progress-button>
</div>
`
: nothing}
</ha-card>
</div>
</hass-subpage>
`;
}
private _dataChanged(ev) {
this._configuration!.data[this.sectionId] = ev.detail.value;
}
private async _updateConfiguration(ev: Event): Promise<void> {
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;
}
}

View File

@@ -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`
<hass-tabs-subpage-data-table
.tabs=${zhaTabs}
.tabs=${groupsTab}
back-path="/config/zha/dashboard"
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}

View File

@@ -0,0 +1,226 @@
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
import type {
NetworkData,
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type { ZHADevice } from "../../../../../data/zha";
import type { HomeAssistant } from "../../../../../types";
function getLQIWidth(lqi: number): number {
return lqi > 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 };
}

View File

@@ -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<void> {
this._networkSettings = await fetchZHANetworkSettings(this.hass!);
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.zha.configuration_page.network_info_title"
)}
back-path="/config/zha/dashboard"
>
<div class="container">
<ha-card>
${this._networkSettings
? html`<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.channel_label"
)}</span
>
<span slot="supporting-text"
>${this._networkSettings.settings.network_info
.channel}</span
>
<ha-icon-button
slot="end"
.label=${this.hass.localize(
"ui.panel.config.zha.configuration_page.change_channel"
)}
.path=${mdiPencil}
@click=${this._showChannelMigrationDialog}
></ha-icon-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">PAN ID</span>
<span slot="supporting-text"
>${this._networkSettings.settings.network_info
.pan_id}</span
>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">Extended PAN ID</span>
<span slot="supporting-text"
>${this._networkSettings.settings.network_info
.extended_pan_id}</span
>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">Coordinator IEEE</span>
<span slot="supporting-text"
>${this._networkSettings.settings.node_info.ieee}</span
>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.radio_type"
)}</span
>
<span slot="supporting-text"
>${this._networkSettings.radio_type}</span
>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.serial_port"
)}</span
>
<span slot="supporting-text"
>${this._networkSettings.device.path}</span
>
</ha-md-list-item>
${this._networkSettings.device.baudrate &&
!this._networkSettings.device.path.startsWith("socket://")
? html`
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.baudrate"
)}</span
>
<span slot="supporting-text"
>${this._networkSettings.device.baudrate}</span
>
</ha-md-list-item>
`
: nothing}
</ha-md-list>`
: nothing}
</ha-card>
</div>
</hass-subpage>
`;
}
private async _showChannelMigrationDialog(): Promise<void> {
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;
}
}

View File

@@ -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`
<hass-tabs-subpage
.tabs=${zhaTabs}
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
.route=${this.route}
header=${this.hass.localize("ui.panel.config.zha.visualization.header")}
.header=${this.hass.localize(
"ui.panel.config.zha.visualization.header"
)}
>
<ha-network-graph
.hass=${this.hass}
@@ -76,13 +71,17 @@ export class ZHANetworkVisualizationPage extends LitElement {
)}
></ha-icon-button>
</ha-network-graph>
</hass-tabs-subpage>
</hass-subpage>
`;
}
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 {

View File

@@ -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<void> {
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`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.zha.configuration_page.options_title"
)}
back-path="/config/zha/dashboard"
>
<div class="container">
<ha-card>
${this._configuration
? html`
<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_identify_on_join_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_identify_on_join_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.enable_identify_on_join as boolean) ?? true}
@change=${this._enableIdentifyOnJoinChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.default_light_transition_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.default_light_transition_description"
)}</span
>
<ha-textfield
slot="end"
type="number"
.value=${String(
(this._configuration.data.zha_options
?.default_light_transition as number) ?? 0
)}
.suffix=${"s"}
.min=${0}
.step=${0.5}
@change=${this._defaultLightTransitionChanged}
></ha-textfield>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enhanced_light_transition_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enhanced_light_transition_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.enhanced_light_transition as boolean) ?? false}
@change=${this._enhancedLightTransitionChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.light_transitioning_flag_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.light_transitioning_flag_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.light_transitioning_flag as boolean) ?? true}
@change=${this._lightTransitioningFlagChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.group_members_assume_state_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.group_members_assume_state_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.group_members_assume_state as boolean) ?? true}
@change=${this._groupMembersAssumeStateChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_mains_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_mains_description"
)}</span
>
<ha-select
slot="end"
.value=${this._getUnavailableDropdownValue(
this._configuration.data.zha_options
?.consider_unavailable_mains,
this._customMains
)}
.options=${this._getUnavailableTimeoutOptions(7200)}
@selected=${this._mainsUnavailableChanged}
></ha-select>
</ha-md-list-item>
${this._customMains
? html`
<ha-md-list-item>
<ha-textfield
slot="end"
type="number"
.value=${String(
(this._configuration.data.zha_options
?.consider_unavailable_mains as number) ??
7200
)}
.suffix=${"s"}
.min=${1}
.step=${1}
@change=${this._customMainsSecondsChanged}
></ha-textfield>
</ha-md-list-item>
`
: nothing}
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_battery_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_battery_description"
)}</span
>
<ha-select
slot="end"
.value=${this._getUnavailableDropdownValue(
this._configuration.data.zha_options
?.consider_unavailable_battery,
this._customBattery
)}
.options=${this._getUnavailableTimeoutOptions(21600)}
@selected=${this._batteryUnavailableChanged}
></ha-select>
</ha-md-list-item>
${this._customBattery
? html`
<ha-md-list-item>
<ha-textfield
slot="end"
type="number"
.value=${String(
(this._configuration.data.zha_options
?.consider_unavailable_battery as number) ??
21600
)}
.suffix=${"s"}
.min=${1}
.step=${1}
@change=${this._customBatterySecondsChanged}
></ha-textfield>
</ha-md-list-item>
`
: nothing}
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_mains_startup_polling_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_mains_startup_polling_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.enable_mains_startup_polling as boolean) ?? true}
@change=${this._enableMainsStartupPollingChanged}
></ha-switch>
</ha-md-list-item>
</ha-md-list>
<div class="card-actions">
<ha-progress-button
appearance="filled"
variant="brand"
@click=${this._updateConfiguration}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.update_button"
)}
</ha-progress-button>
</div>
`
: nothing}
</ha-card>
</div>
</hass-subpage>
`;
}
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<void> {
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;
}
}

View File

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