From 252e58d63ba8c673178c0355584253d330744dc8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 16 Feb 2023 16:29:56 +0100 Subject: [PATCH] Add basic Thread network overview page (#15474) --- src/data/thread.ts | 66 +++++ .../thread/thread-config-panel.ts | 248 +++++++++++++++--- src/translations/en.json | 8 + 3 files changed, 279 insertions(+), 43 deletions(-) create mode 100644 src/data/thread.ts diff --git a/src/data/thread.ts b/src/data/thread.ts new file mode 100644 index 0000000000..b760495654 --- /dev/null +++ b/src/data/thread.ts @@ -0,0 +1,66 @@ +import { HomeAssistant } from "../types"; + +export interface ThreadRouter { + brand: "google" | "apple" | "homeassistant"; + server: string; + extended_pan_id: string; + model_name: string | null; + network_name: string; + vendor_name: string; +} + +export interface ThreadDataSet { + created; + dataset_id; + extended_pan_id; + network_name: string; + pan_id; + preferred: boolean; + source; +} + +export interface ThreadRouterDiscoveryEvent { + key: string; + type: "router_discovered" | "router_removed"; + data: ThreadRouter; +} + +class DiscoveryStream { + hass: HomeAssistant; + + routers: { [key: string]: ThreadRouter }; + + constructor(hass: HomeAssistant) { + this.hass = hass; + this.routers = {}; + } + + processEvent(streamMessage: ThreadRouterDiscoveryEvent): ThreadRouter[] { + if (streamMessage.type === "router_discovered") { + this.routers[streamMessage.key] = streamMessage.data; + } else if (streamMessage.type === "router_removed") { + delete this.routers[streamMessage.key]; + } + return Object.values(this.routers); + } +} + +export const subscribeDiscoverThreadRouters = ( + hass: HomeAssistant, + callbackFunction: (routers: ThreadRouter[]) => void +) => { + const stream = new DiscoveryStream(hass); + return hass.connection.subscribeMessage( + (message) => callbackFunction(stream.processEvent(message)), + { + type: "thread/discover_routers", + } + ); +}; + +export const listThreadDataSets = ( + hass: HomeAssistant +): Promise<{ datasets: ThreadDataSet[] }> => + hass.callWS({ + type: "thread/list_datasets", + }); diff --git a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts index 78ed4c91b5..aefd881e60 100644 --- a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts @@ -1,75 +1,215 @@ import "@material/mwc-button"; +import { mdiDevices, mdiDotsVertical, mdiInformationOutline } from "@mdi/js"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { isComponentLoaded } from "../../../../../common/config/is_component_loaded"; +import { stringCompare } from "../../../../../common/string/compare"; import "../../../../../components/ha-card"; +import { getOTBRInfo } from "../../../../../data/otbr"; +import { + listThreadDataSets, + subscribeDiscoverThreadRouters, + ThreadDataSet, + ThreadRouter, +} from "../../../../../data/thread"; +import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow"; +import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box"; import "../../../../../layouts/hass-subpage"; +import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; import { haStyle } from "../../../../../resources/styles"; import { HomeAssistant } from "../../../../../types"; -import { getOTBRInfo, OTBRInfo } from "../../../../../data/otbr"; -import { isComponentLoaded } from "../../../../../common/config/is_component_loaded"; -import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow"; +import { brandsUrl } from "../../../../../util/brands-url"; + +interface ThreadNetwork { + name: string; + dataset?: ThreadDataSet; + routers?: ThreadRouter[]; +} @customElement("thread-config-panel") -export class ThreadConfigPanel extends LitElement { +export class ThreadConfigPanel extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public narrow!: boolean; - @state() private _info?: OTBRInfo; + @state() private _routers: ThreadRouter[] = []; + + @state() private _datasets: ThreadDataSet[] = []; protected render(): TemplateResult { + const networks = this._groupRoutersByNetwork(this._routers, this._datasets); + return html` + + + Add border router +
- - ${isComponentLoaded(this.hass, "otbr") - ? html` -
- ${!this._info - ? html`` - : html` - - - - - - - - - -
URL${this._info.url}
Active Dataset TLVs${this._info.active_dataset_tlvs || "-"}
- `} -
- ` - : html` -
No border routers found.
-
- -
- `} -
+ ${networks.preferred + ? html`

+ ${this.hass.localize("ui.panel.config.thread.my_network")} +

+ ${this._renderNetwork(networks.preferred)}` + : ""} + ${networks.networks.length + ? html`

+ ${this.hass.localize("ui.panel.config.thread.other_networks")} +

+ ${networks.networks.map((network) => + this._renderNetwork(network) + )}` + : ""}
`; } + private _renderNetwork(network: ThreadNetwork) { + return html` +
+ ${network.name}${network.dataset + ? html`` + : ""} +
+ ${network.routers?.length + ? html`
+

+ ${this.hass.localize("ui.panel.config.thread.border_routers", { + count: network.routers.length, + })} +

+
+ ${network.routers.map( + (router) => + html` + ${router.brand} + ${router.model_name || router.server.replace(".local.", "")} + ${router.server} + ` + )}` + : html`
+ + ${this.hass.localize("ui.panel.config.thread.no_border_routers")} +
`} +
`; + } + + private async _showDatasetInfo(ev: Event) { + const dataset = (ev.currentTarget as any).networkDataset as ThreadDataSet; + if (isComponentLoaded(this.hass, "otbr")) { + const otbrInfo = await getOTBRInfo(this.hass); + if (otbrInfo.active_dataset_tlvs.includes(dataset.extended_pan_id)) { + showAlertDialog(this, { + title: dataset.network_name, + text: html`Network name: ${dataset.network_name}
+ Dataset id: ${dataset.dataset_id}
+ Pan id: ${dataset.pan_id}
+ Extended Pan id: ${dataset.extended_pan_id}
+ OTBR URL: ${otbrInfo.url}
+ Active dataset TLVs: ${otbrInfo.active_dataset_tlvs}`, + }); + return; + } + } + showAlertDialog(this, { + title: dataset.network_name, + text: html`Network name: ${dataset.network_name}
+ Dataset id: ${dataset.dataset_id}
+ Pan id: ${dataset.pan_id}
+ Extended Pan id: ${dataset.extended_pan_id}`, + }); + } + + private _onImageError(ev) { + ev.target.style.display = "none"; + } + + private _onImageLoad(ev) { + ev.target.style.display = ""; + } + + hassSubscribe() { + return [ + subscribeDiscoverThreadRouters(this.hass, (routers: ThreadRouter[]) => { + this._routers = routers; + }), + ]; + } + protected override firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); this._refresh(); } - private _refresh() { - if (isComponentLoaded(this.hass, "otbr")) { - getOTBRInfo(this.hass).then((info) => { - this._info = info; - }); + private _groupRoutersByNetwork = memoizeOne( + ( + routers: ThreadRouter[], + datasets: ThreadDataSet[] + ): { preferred?: ThreadNetwork; networks: ThreadNetwork[] } => { + let preferred: ThreadNetwork | undefined; + const networks: { [key: string]: ThreadNetwork } = {}; + for (const router of routers) { + const network = router.network_name; + if (network in networks) { + networks[network].routers!.push(router); + } else { + networks[network] = { name: network, routers: [router] }; + } + } + for (const dataset of datasets) { + const network = dataset.network_name; + if (dataset.preferred) { + preferred = { + name: network, + dataset: dataset, + routers: networks[network]?.routers, + }; + delete networks[network]; + continue; + } + if (network in networks) { + networks[network].dataset = dataset; + } else { + networks[network] = { name: network, dataset: dataset }; + } + } + return { + preferred, + networks: Object.values(networks).sort((a, b) => + stringCompare(a.name, b.name, this.hass.locale.language) + ), + }; } + ); + + private _refresh() { + listThreadDataSets(this.hass).then((datasets) => { + this._datasets = datasets.datasets; + }); } private _addOTBR() { @@ -91,9 +231,31 @@ export class ThreadConfigPanel extends LitElement { margin: 0 auto; direction: ltr; } - ha-card:first-child { + routers { + padding-bottom: 0; + } + .no-routers { + display: flex; + flex-direction: column; + align-items: center; + } + .no-routers ha-svg-icon { + background-color: var(--light-primary-color); + color: var(--secondary-text-color); + padding: 16px; + border-radius: 50%; + margin-bottom: 8px; + } + ha-card { margin-bottom: 16px; } + h4 { + margin: 0; + } + .card-header { + display: flex; + justify-content: space-between; + } `, ]; } diff --git a/src/translations/en.json b/src/translations/en.json index ddb975ab62..b4aa14c47e 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3263,6 +3263,14 @@ "qos": "QoS", "retain": "Retain" }, + "thread": { + "other_networks": "Other networks", + "my_network": "My network", + "no_border_routers": "No border routers found", + "border_routers": "{count} border {count, plural,\n one {router}\n other {routers}\n}", + "managed_by_home_assistant": "Managed by Home Assistant", + "operational_dataset": "Operational dataset" + }, "zha": { "common": { "clusters": "Clusters",