Add basic Thread network overview page (#15474)

This commit is contained in:
Bram Kragten 2023-02-16 16:29:56 +01:00 committed by GitHub
parent d98a26146b
commit 252e58d63b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 279 additions and 43 deletions

66
src/data/thread.ts Normal file
View File

@ -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<ThreadRouterDiscoveryEvent>(
(message) => callbackFunction(stream.processEvent(message)),
{
type: "thread/discover_routers",
}
);
};
export const listThreadDataSets = (
hass: HomeAssistant
): Promise<{ datasets: ThreadDataSet[] }> =>
hass.callWS({
type: "thread/list_datasets",
});

View File

@ -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`
<hass-subpage .narrow=${this.narrow} .hass=${this.hass} header="Thread">
<ha-button-menu slot="toolbar-icon" corner="BOTTOM_START">
<ha-icon-button
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
<mwc-list-item @click=${this._addOTBR}
>Add border router</mwc-list-item
>
</ha-button-menu>
<div class="content">
<ha-card header="Open Thread Border Router">
${isComponentLoaded(this.hass, "otbr")
? html`
<div class="card-content">
${!this._info
? html`<ha-circular-progress
active
></ha-circular-progress>`
: html`
<table>
<tr>
<td>URL</td>
<td>${this._info.url}</td>
</tr>
<tr>
<td>Active Dataset TLVs</td>
<td>${this._info.active_dataset_tlvs || "-"}</td>
</tr>
</table>
`}
</div>
`
: html`
<div class="card-content">No border routers found.</div>
<div class="card-actions">
<mwc-button
@click=${this._addOTBR}
label="Add border router"
></mwc-button>
</div>
`}
</ha-card>
${networks.preferred
? html`<h1>
${this.hass.localize("ui.panel.config.thread.my_network")}
</h1>
${this._renderNetwork(networks.preferred)}`
: ""}
${networks.networks.length
? html`<h3>
${this.hass.localize("ui.panel.config.thread.other_networks")}
</h3>
${networks.networks.map((network) =>
this._renderNetwork(network)
)}`
: ""}
</div>
</hass-subpage>
`;
}
private _renderNetwork(network: ThreadNetwork) {
return html`<ha-card>
<div class="card-header">
${network.name}${network.dataset
? html`<ha-icon-button
.networkDataset=${network.dataset}
.path=${mdiInformationOutline}
@click=${this._showDatasetInfo}
></ha-icon-button>`
: ""}
</div>
${network.routers?.length
? html`<div class="card-content routers">
<h4>
${this.hass.localize("ui.panel.config.thread.border_routers", {
count: network.routers.length,
})}
</h4>
</div>
${network.routers.map(
(router) =>
html`<ha-list-item noninteractive twoline graphic="avatar">
<img
slot="graphic"
.src=${brandsUrl({
domain: router.brand,
brand: true,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
alt=${router.brand}
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
${router.model_name || router.server.replace(".local.", "")}
<span slot="secondary">${router.server}</span>
</ha-list-item>`
)}`
: html`<div class="card-content no-routers">
<ha-svg-icon .path=${mdiDevices}></ha-svg-icon>
${this.hass.localize("ui.panel.config.thread.no_border_routers")}
</div>`}
</ha-card>`;
}
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}<br />
Dataset id: ${dataset.dataset_id}<br />
Pan id: ${dataset.pan_id}<br />
Extended Pan id: ${dataset.extended_pan_id}<br />
OTBR URL: ${otbrInfo.url}<br />
Active dataset TLVs: ${otbrInfo.active_dataset_tlvs}`,
});
return;
}
}
showAlertDialog(this, {
title: dataset.network_name,
text: html`Network name: ${dataset.network_name}<br />
Dataset id: ${dataset.dataset_id}<br />
Pan id: ${dataset.pan_id}<br />
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;
}
`,
];
}

View File

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