From d7a5e1607cfeda62bca9c8538f2160c382220b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <938089+lboue@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:51:15 +0200 Subject: [PATCH 1/8] feat(dashboard): show client-mode clusters on endpoint view Endpoint cluster lists were derived purely from cached attribute keys, so client-mode clusters (bound to a remote device, no local attribute storage) never appeared even though the Descriptor's ClientList carries them. Merge ClientList cluster ids into the endpoint listing with a CLIENT badge, and note in the cluster detail view why a client-mode cluster has no attributes to show. --- .../src/pages/matter-cluster-view.ts | 27 ++++++++++++ .../src/pages/matter-endpoint-view.ts | 44 ++++++++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/dashboard/src/pages/matter-cluster-view.ts b/packages/dashboard/src/pages/matter-cluster-view.ts index 07a59c87..9a12a0ed 100644 --- a/packages/dashboard/src/pages/matter-cluster-view.ts +++ b/packages/dashboard/src/pages/matter-cluster-view.ts @@ -24,6 +24,7 @@ import { showCommandInvokeDialog } from "../components/dialogs/dev/show-command- import "../components/ha-svg-icon"; import "../pages/components/node-details"; // Cluster command components (auto-register on import) +import { sourceClientClusters } from "../util/binding.js"; import { computeActiveClusterFeatures } from "../util/cluster-features.js"; import { DevModeService } from "../util/dev-mode-service.js"; import { formatHex, formatNodeAddress, getEffectiveFabricIndex } from "../util/format_hex.js"; @@ -173,6 +174,7 @@ class MatterClusterView extends LitElement {
ClusterId ${this.cluster} (${formatHex(this.cluster)})
+ ${this._isClientOnlyCluster() ? this._renderClientOnlyNotice() : nothing} ${clusterAttributes(this.node.attributes, this.endpoint, this.cluster).map( (attribute, index) => html` @@ -353,6 +355,26 @@ class MatterClusterView extends LitElement { }); } + // Client-mode clusters bind to a server hosted elsewhere and hold no local attribute storage, + // so the attribute list below is always empty for them. + private _isClientOnlyCluster(): boolean { + if (!this.node || this.cluster === undefined) return false; + const hasAttributes = clusterAttributes(this.node.attributes, this.endpoint, this.cluster).length > 0; + if (hasAttributes) return false; + return sourceClientClusters(this.node, this.endpoint).includes(this.cluster); + } + + private _renderClientOnlyNotice(): TemplateResult { + return html` + +
+ This cluster is used in client mode on this endpoint. It binds to the cluster hosted on another + device rather than hosting it itself, so there are no local attributes to display. +
+
+ `; + } + private _renderClusterInfoPanel(): TemplateResult | typeof nothing { const sections = new Array(); for (const section of [this._renderFeaturesSection(), this._renderTagListSection()]) { @@ -651,6 +673,11 @@ class MatterClusterView extends LitElement { margin: 0; } + .client-only-notice { + color: var(--md-sys-color-on-surface-variant); + font-size: 0.9rem; + } + .command-list { list-style: none; margin: 0; diff --git a/packages/dashboard/src/pages/matter-endpoint-view.ts b/packages/dashboard/src/pages/matter-endpoint-view.ts index eb975ab1..b2c16a6a 100644 --- a/packages/dashboard/src/pages/matter-endpoint-view.ts +++ b/packages/dashboard/src/pages/matter-endpoint-view.ts @@ -20,6 +20,7 @@ import "./cluster-commands/clusters/binding-commands.js"; import { clientContext, tickContext } from "../client/client-context.js"; import { clusters } from "../client/models/descriptions.js"; import "../components/ha-svg-icon"; +import { sourceClientClusters } from "../util/binding.js"; import { getEndpointDeviceTypes } from "../util/endpoints.js"; import { formatHex, formatNodeAddress, getEffectiveFabricIndex } from "../util/format_hex.js"; import { notFoundStyles } from "../util/shared-styles.js"; @@ -43,6 +44,23 @@ function getUniqueClusters(node: MatterNode, endpoint: number) { }); } +interface EndpointCluster { + id: number; + isClient: boolean; +} + +// Client-mode clusters (bound to a remote device) hold no local attribute storage, so they never +// show up via getUniqueClusters; the Descriptor's ClientList is the only place they're recorded. +function getEndpointClusters(node: MatterNode, endpoint: number): EndpointCluster[] { + const serverClusters = getUniqueClusters(node, endpoint); + const serverSet = new Set(serverClusters); + const clientOnlyClusters = sourceClientClusters(node, endpoint).filter(id => !serverSet.has(id)); + return [ + ...serverClusters.map(id => ({ id, isClient: false })), + ...clientOnlyClusters.map(id => ({ id, isClient: true })), + ].sort((a, b) => a.id - b.id); +} + export { getEndpointDeviceTypes }; @customElement("matter-endpoint-view") @@ -117,15 +135,18 @@ class MatterEndpointView extends LitElement { .join(" / ")}
- ${guard([this.node?.attributes.length], () => - getUniqueClusters(this.node!, this.endpoint).map(cluster => { + ${guard([Object.keys(this.node?.attributes ?? {}).length], () => + getEndpointClusters(this.node!, this.endpoint).map(cluster => { return html` -
${clusters[cluster]?.label ?? "Custom/Unknown Cluster"}
-
ClusterId ${cluster} (${formatHex(cluster)})
+
+ ${clusters[cluster.id]?.label ?? "Custom/Unknown Cluster"} + ${cluster.isClient ? html`CLIENT` : nothing} +
+
ClusterId ${cluster.id} (${formatHex(cluster.id)})
`; @@ -178,6 +199,19 @@ class MatterEndpointView extends LitElement { font-weight: bold; font-size: 0.8em; } + + .client-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 6px; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--md-sys-color-on-secondary-container); + background: var(--md-sys-color-secondary-container); + vertical-align: middle; + } `, ]; } From f3fc54604c90be948bf2b1e1f4e005f844853913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <938089+lboue@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:53:22 +0200 Subject: [PATCH 2/8] docs: update changelog for client-mode cluster display Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c49317fd..cf5fc68c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ This page shows a detailed overview of the changes between versions without the ## **WORK IN PROGRESS** --> +## **WORK IN PROGRESS** + +- Feature: (lboue) Dashboard endpoint view now also lists client-mode clusters (from the Descriptor's ClientList), marked with a CLIENT badge, alongside the server clusters it already showed; the cluster detail view explains why a client-mode cluster has no attributes to display + ## 1.3.3 (2026-07-28) - Enhancement: (pkese) Dashboard network graphs space nodes by signal quality (Thread LQI, Wi-Fi RSSI) instead of using one fixed edge length From fd7ce21da1a9339cd56ad3ebe865b91508c5f2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <938089+lboue@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:53:40 +0200 Subject: [PATCH 3/8] fix(dashboard): move client-mode clusters into a gated Bound chip panel Per review feedback on the endpoint view: client-mode clusters were unclickable dead entries in the Clusters list. Show them instead as chips in a panel near the top, only when the endpoint has a Binding cluster, and mark clusters already covered by an existing binding as "Bound". --- CHANGELOG.md | 2 +- .../src/pages/matter-endpoint-view.ts | 107 ++++++++++++------ packages/dashboard/src/util/binding.ts | 8 ++ 3 files changed, 81 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5fc68c..86376254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ This page shows a detailed overview of the changes between versions without the ## **WORK IN PROGRESS** -- Feature: (lboue) Dashboard endpoint view now also lists client-mode clusters (from the Descriptor's ClientList), marked with a CLIENT badge, alongside the server clusters it already showed; the cluster detail view explains why a client-mode cluster has no attributes to display +- Feature: (lboue) Dashboard endpoint view shows a "Client Clusters" chip panel (from the Descriptor's ClientList) when the endpoint has a Binding cluster, marking clusters already covered by an existing binding as "Bound"; the cluster detail view explains why a client-mode cluster has no attributes to display ## 1.3.3 (2026-07-28) diff --git a/packages/dashboard/src/pages/matter-endpoint-view.ts b/packages/dashboard/src/pages/matter-endpoint-view.ts index b2c16a6a..37ef71de 100644 --- a/packages/dashboard/src/pages/matter-endpoint-view.ts +++ b/packages/dashboard/src/pages/matter-endpoint-view.ts @@ -13,14 +13,14 @@ import "@material/web/list/list-item"; import { consume } from "@lit/context"; import { MatterClient, MatterNode, isTestNodeId } from "@matter-server/ws-client"; import { mdiAlertCircleOutline, mdiChevronRight } from "@mdi/js"; -import { LitElement, css, html, nothing } from "lit"; +import { LitElement, type TemplateResult, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; import { guard } from "lit/directives/guard.js"; import "./cluster-commands/clusters/binding-commands.js"; import { clientContext, tickContext } from "../client/client-context.js"; import { clusters } from "../client/models/descriptions.js"; import "../components/ha-svg-icon"; -import { sourceClientClusters } from "../util/binding.js"; +import { boundClientClusterIds, sourceClientClusters } from "../util/binding.js"; import { getEndpointDeviceTypes } from "../util/endpoints.js"; import { formatHex, formatNodeAddress, getEffectiveFabricIndex } from "../util/format_hex.js"; import { notFoundStyles } from "../util/shared-styles.js"; @@ -44,23 +44,6 @@ function getUniqueClusters(node: MatterNode, endpoint: number) { }); } -interface EndpointCluster { - id: number; - isClient: boolean; -} - -// Client-mode clusters (bound to a remote device) hold no local attribute storage, so they never -// show up via getUniqueClusters; the Descriptor's ClientList is the only place they're recorded. -function getEndpointClusters(node: MatterNode, endpoint: number): EndpointCluster[] { - const serverClusters = getUniqueClusters(node, endpoint); - const serverSet = new Set(serverClusters); - const clientOnlyClusters = sourceClientClusters(node, endpoint).filter(id => !serverSet.has(id)); - return [ - ...serverClusters.map(id => ({ id, isClient: false })), - ...clientOnlyClusters.map(id => ({ id, isClient: true })), - ].sort((a, b) => a.id - b.id); -} - export { getEndpointDeviceTypes }; @customElement("matter-endpoint-view") @@ -108,6 +91,8 @@ class MatterEndpointView extends LitElement { + ${this._renderClientClustersSection()} + ${getUniqueClusters(this.node, this.endpoint).includes(30) ? html`
@@ -136,17 +121,14 @@ class MatterEndpointView extends LitElement {
${guard([Object.keys(this.node?.attributes ?? {}).length], () => - getEndpointClusters(this.node!, this.endpoint).map(cluster => { + getUniqueClusters(this.node!, this.endpoint).map(cluster => { return html` -
- ${clusters[cluster.id]?.label ?? "Custom/Unknown Cluster"} - ${cluster.isClient ? html`CLIENT` : nothing} -
-
ClusterId ${cluster.id} (${formatHex(cluster.id)})
+
${clusters[cluster]?.label ?? "Custom/Unknown Cluster"}
+
ClusterId ${cluster} (${formatHex(cluster)})
`; @@ -161,6 +143,38 @@ class MatterEndpointView extends LitElement { history.back(); } + // Client-mode clusters only matter if there's a Binding cluster to point them somewhere, so + // the section stays hidden otherwise rather than listing clusters nothing can act on. + private _renderClientClustersSection(): TemplateResult | typeof nothing { + if (!this.node || !getUniqueClusters(this.node, this.endpoint).includes(30)) return nothing; + const clientClusters = sourceClientClusters(this.node, this.endpoint); + if (clientClusters.length === 0) return nothing; + const boundClusterIds = boundClientClusterIds(this.node, this.endpoint); + + return html` +
+
+
+
Client Clusters
+
    + ${clientClusters.map(id => { + const bound = boundClusterIds.has(id); + return html` +
  • + ${clusters[id]?.label ?? "Custom/Unknown Cluster"} +
  • + `; + })} +
+
+
+
+ `; + } + static override styles = [ notFoundStyles, css` @@ -200,17 +214,40 @@ class MatterEndpointView extends LitElement { font-size: 0.8em; } - .client-badge { - display: inline-block; - margin-left: 8px; - padding: 1px 6px; - border-radius: 4px; - font-size: 0.7rem; - font-weight: 600; - letter-spacing: 0.04em; + .info-panel { + background-color: var(--md-sys-color-surface-container); + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: 12px; + padding: 14px 16px; + } + + .info-section-header { + font-weight: 500; + color: var(--md-sys-color-on-surface); + margin-bottom: 10px; + } + + .chip-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .chip { + font-size: 0.85rem; color: var(--md-sys-color-on-secondary-container); background: var(--md-sys-color-secondary-container); - vertical-align: middle; + padding: 4px 10px; + border-radius: 8px; + } + + .chip.chip-bound { + color: var(--md-sys-color-on-tertiary-container); + background: var(--md-sys-color-tertiary-container); + font-weight: 500; } `, ]; diff --git a/packages/dashboard/src/util/binding.ts b/packages/dashboard/src/util/binding.ts index 4ea87ff5..31cb2550 100644 --- a/packages/dashboard/src/util/binding.ts +++ b/packages/dashboard/src/util/binding.ts @@ -60,6 +60,14 @@ export function sourceClientClusters(node: MatterNode, endpoint: number): number return numberList(node, `${endpoint}/29/2`); } +// A binding entry without a Cluster field targets the whole endpoint (all its client clusters), +// per the Binding cluster spec, rather than a single cluster. +export function boundClientClusterIds(node: MatterNode, endpoint: number): Set { + const bindings = readBindings(node, endpoint); + if (bindings.some(b => b.cluster === undefined)) return new Set(sourceClientClusters(node, endpoint)); + return new Set(bindings.map(b => b.cluster).filter((c): c is number => c !== undefined)); +} + export interface BindableClusters { bindable: number[]; otherTarget: number[]; From ad82705c69178f3be811b9ffad440fe1f08196c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <938089+lboue@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:10:18 +0200 Subject: [PATCH 4/8] fix(dashboard): use a defined CSS variable for the Bound client-cluster chip --md-sys-color-tertiary-container is never defined in public/index.html (light or dark), so the "Bound" chip rendered with no background/text color at all. Switch to --md-sys-color-primary-container, which is defined for both themes. Co-Authored-By: Claude Sonnet 5 --- .../src/pages/matter-endpoint-view.ts | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/dashboard/src/pages/matter-endpoint-view.ts b/packages/dashboard/src/pages/matter-endpoint-view.ts index 4e145ce1..0aa6c5a6 100644 --- a/packages/dashboard/src/pages/matter-endpoint-view.ts +++ b/packages/dashboard/src/pages/matter-endpoint-view.ts @@ -94,17 +94,15 @@ class MatterEndpointView extends LitElement { ${this._renderClientClustersSection()} - ${ - getUniqueClusters(this.node, this.endpoint).includes(30) - ? html`
- -
` - : nothing - } + ${getUniqueClusters(this.node, this.endpoint).includes(30) + ? html`
+ +
` + : nothing}
@@ -247,8 +245,8 @@ class MatterEndpointView extends LitElement { } .chip.chip-bound { - color: var(--md-sys-color-on-tertiary-container); - background: var(--md-sys-color-tertiary-container); + color: var(--md-sys-color-on-primary-container); + background: var(--md-sys-color-primary-container); font-weight: 500; } `, From 3978f00977caa4df6ee3178391eff8b33e682963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <938089+lboue@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:10:28 +0200 Subject: [PATCH 5/8] style(dashboard): reformat with the oxfmt style pulled in from main npm run format now wraps these template expressions differently since the formatting rules changed upstream (merged from main); re-running the required format step touched these files with no logic change. Co-Authored-By: Claude Sonnet 5 --- .../src/components/avsum-ptz-strip.ts | 209 ++- .../src/components/dialog-box/dialog-box.ts | 32 +- .../dialogs/acl/node-acl-add-dialog.ts | 64 +- .../dialogs/binding/node-binding-dialog.ts | 144 +- .../commission-node-dialog.ts | 48 +- .../commission-node-thread.ts | 42 +- .../commission-node-wifi.ts | 42 +- .../dialogs/dev/attribute-write-dialog.ts | 8 +- .../dialogs/dev/command-invoke-dialog.ts | 22 +- .../dialogs/settings/log-level-section.ts | 40 +- .../dialogs/settings/settings-dialog.ts | 256 ++- .../src/components/webrtc-stream-view.ts | 52 +- .../dashboard/src/pages/camera-overlay.ts | 297 ++-- .../clusters/access-control-commands.ts | 78 +- .../clusters/avsum-commands.ts | 397 +++-- .../clusters/basic-information-commands.ts | 8 +- .../clusters/binding-commands.ts | 40 +- .../clusters/chime-commands.ts | 88 +- .../clusters/closure-control-commands.ts | 310 ++-- .../clusters/icd-management-commands.ts | 74 +- .../dashboard/src/pages/components/header.ts | 112 +- .../src/pages/components/node-details.ts | 159 +- .../src/pages/matter-cluster-view.ts | 170 +- .../src/pages/matter-network-view.ts | 66 +- .../dashboard/src/pages/matter-node-view.ts | 22 +- .../dashboard/src/pages/matter-server-view.ts | 20 +- .../src/pages/network/device-panel.ts | 38 +- .../src/pages/network/network-details.ts | 1376 ++++++++--------- .../src/pages/network/thread-graph.ts | 8 +- .../network/update-connections-dialog.ts | 116 +- 30 files changed, 1964 insertions(+), 2374 deletions(-) diff --git a/packages/dashboard/src/components/avsum-ptz-strip.ts b/packages/dashboard/src/components/avsum-ptz-strip.ts index 7de867f1..00c3c6d9 100644 --- a/packages/dashboard/src/components/avsum-ptz-strip.ts +++ b/packages/dashboard/src/components/avsum-ptz-strip.ts @@ -82,115 +82,106 @@ export class AvsumPtzStrip extends LitElement { return html`
- ${ - hasMech || features.dptz - ? html`
-
- - - this._move(effective, "tilt", this._step(e, 10)), - )} - > - - - - - this._move(effective, "pan", this._step(e, -10)), - )} - > - - - - - this._move(effective, "pan", this._step(e, 10)), - )} - > - - - - - this._move(effective, "tilt", this._step(e, -10)), - )} - > - - - -
-
- - this._move(effective, "zoom", this._step(e, 10)), - )} - > - - - - this._move(effective, "zoom", this._step(e, -10)), - )} - > - - -
- ${ - hasMech && features.dptz - ? html`
- - -
` - : nothing - } - ${ - effective === "digital" && this.activeVideoStreamId === null - ? html`Start stream to enable DPTZ` - : nothing - } -
` - : nothing - } - ${ - features.mPresets && presets.length > 0 - ? html`
- ${presets.map( - p => html``, - )} -
` - : nothing - } + ${hasMech || features.dptz + ? html`
+
+ + + this._move(effective, "tilt", this._step(e, 10)), + )} + > + + + + + this._move(effective, "pan", this._step(e, -10)), + )} + > + + + + + this._move(effective, "pan", this._step(e, 10)), + )} + > + + + + + this._move(effective, "tilt", this._step(e, -10)), + )} + > + + + +
+
+ + this._move(effective, "zoom", this._step(e, 10)), + )} + > + + + + this._move(effective, "zoom", this._step(e, -10)), + )} + > + + +
+ ${hasMech && features.dptz + ? html`
+ + +
` + : nothing} + ${effective === "digital" && this.activeVideoStreamId === null + ? html`Start stream to enable DPTZ` + : nothing} +
` + : nothing} + ${features.mPresets && presets.length > 0 + ? html`
+ ${presets.map( + p => html``, + )} +
` + : nothing}
${features.mPan && pos.pan !== null ? html`Pan ${this._fmt(pos.pan)}°` : nothing} ${features.mTilt && pos.tilt !== null ? html`Tilt ${this._fmt(pos.tilt)}°` : nothing} diff --git a/packages/dashboard/src/components/dialog-box/dialog-box.ts b/packages/dashboard/src/components/dialog-box/dialog-box.ts index 344013f1..b67200ad 100644 --- a/packages/dashboard/src/components/dialog-box/dialog-box.ts +++ b/packages/dashboard/src/components/dialog-box/dialog-box.ts @@ -24,27 +24,19 @@ export class DialogBox extends LitElement { return html` ${params.title ? html`
${params.title}
` : ""} - ${ - params.text - ? html`
- ${ - params.asCodeBlock && typeof params.text === "string" - ? html`${params.text}` - : params.text - } -
` - : "" - } + ${params.text + ? html`
+ ${params.asCodeBlock && typeof params.text === "string" + ? html`${params.text}` + : params.text} +
` + : ""}
- ${ - this.type === "prompt" - ? html` - ${params.cancelText ?? "Cancel"} - ` - : "" - } + ${this.type === "prompt" + ? html` + ${params.cancelText ?? "Cancel"} + ` + : ""} ${params.confirmText ?? "OK"}
diff --git a/packages/dashboard/src/components/dialogs/acl/node-acl-add-dialog.ts b/packages/dashboard/src/components/dialogs/acl/node-acl-add-dialog.ts index bd536d42..65f5ebfc 100644 --- a/packages/dashboard/src/components/dialogs/acl/node-acl-add-dialog.ts +++ b/packages/dashboard/src/components/dialogs/acl/node-acl-add-dialog.ts @@ -176,21 +176,19 @@ export class NodeAclAddDialog extends LitElement {
Subjects (nodes)
- ${ - this._subjects.length === 0 - ? html`none — add at least one` - : this._subjects.map(s => { - const known = this.client.nodes[nodeIdKey(s)]; - return html`${known ? getDeviceName(known) : "Node"} · ${s.toString()} - this._removeSubject(nodeIdKey(s))} - >`; - }) - } + ${this._subjects.length === 0 + ? html`none — add at least one` + : this._subjects.map(s => { + const known = this.client.nodes[nodeIdKey(s)]; + return html`${known ? getDeviceName(known) : "Node"} · ${s.toString()} + this._removeSubject(nodeIdKey(s))} + >`; + })}
Targets (optional — none means whole node)
- ${ - this._targets.length === 0 - ? html`whole node` - : this._targets.map( - (t, i) => - html`${t.endpoint != null ? `EP ${t.endpoint}` : "All endpoints"} - ${ - t.cluster != null - ? `· ${this._clusterLabel(t.cluster)}` - : "· all clusters" - } - this._removeTarget(i)} - >`, - ) - } + ${this._targets.length === 0 + ? html`whole node` + : this._targets.map( + (t, i) => + html`${t.endpoint != null ? `EP ${t.endpoint}` : "All endpoints"} + ${t.cluster != null + ? `· ${this._clusterLabel(t.cluster)}` + : "· all clusters"} + this._removeTarget(i)} + >`, + )}
All clusters (any eligible)
- ${ - split && split.bindable.length - ? html`
— Bindable —
- ${split.bindable.map( - c => - html`
${this._clusterLabel(c)}
`, - )}` - : nothing - } - ${ - split && split.otherTarget.length - ? html`
— Other target clusters (⚠) —
- ${split.otherTarget.map( - c => - html`
${this._clusterLabel(c)}
`, - )}` - : nothing - } + ${split && split.bindable.length + ? html`
— Bindable —
+ ${split.bindable.map( + c => + html`
${this._clusterLabel(c)}
`, + )}` + : nothing} + ${split && split.otherTarget.length + ? html`
— Other target clusters (⚠) —
+ ${split.otherTarget.map( + c => + html`
${this._clusterLabel(c)}
`, + )}` + : nothing}
Custom cluster id…
- ${ - this._clusterSelection === CUSTOM_CLUSTER - ? html` (this._customClusterInput = (e.target as HTMLInputElement).value)} - >` - : nothing - } - ${ - nonBindable - ? html`
- ⚠ This cluster is not a client cluster on the source endpoint. The binding may not function — - it will be added anyway on your request. -
` - : nothing - } + ${this._clusterSelection === CUSTOM_CLUSTER + ? html` (this._customClusterInput = (e.target as HTMLInputElement).value)} + >` + : nothing} + ${nonBindable + ? html`
+ ⚠ This cluster is not a client cluster on the source endpoint. The binding may not function — it + will be added anyway on your request. +
` + : nothing} `; } @@ -250,39 +242,35 @@ export class NodeBindingDialog extends LitElement { }} > - ${ - target - ? html` { - this._endpointInput = (e.target as HTMLSelectElement).value; - this._clusterSelection = ALL_CLUSTERS; - }} - > - ${endpoints.map(ep => { - const dt = getEndpointDeviceTypes(target, ep)[0]; - return html` -
EP ${ep}${dt ? ` · ${dt.label}` : ""}
-
`; - })} -
` - : html` (this._endpointInput = (e.target as HTMLInputElement).value)} - >` - } + ${target + ? html` { + this._endpointInput = (e.target as HTMLSelectElement).value; + this._clusterSelection = ALL_CLUSTERS; + }} + > + ${endpoints.map(ep => { + const dt = getEndpointDeviceTypes(target, ep)[0]; + return html` +
EP ${ep}${dt ? ` · ${dt.label}` : ""}
+
`; + })} +
` + : html` (this._endpointInput = (e.target as HTMLInputElement).value)} + >`} ${this._renderClusterField(target, endpoint)}
diff --git a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-dialog.ts b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-dialog.ts index 56348dd1..b8ce95f3 100644 --- a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-dialog.ts +++ b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-dialog.ts @@ -35,31 +35,29 @@ export class ComissionNodeDialog extends LitElement { @node-commissioned=${this._nodeCommissioned} @request-settings=${this._requestSettings} > - ${ - !this._mode - ? html` - Commission new WiFi device - Commission new Thread device - Commission existing device - ` - : this._mode === "wifi" - ? html` ` - : this._mode === "thread" - ? html` ` - : html` ` - } + ${!this._mode + ? html` + Commission new WiFi device + Commission new Thread device + Commission existing device + ` + : this._mode === "wifi" + ? html` ` + : this._mode === "thread" + ? html` ` + : html` `}
Cancel diff --git a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-thread.ts b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-thread.ts index 7dec1569..e6100bbf 100644 --- a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-thread.ts +++ b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-thread.ts @@ -140,28 +140,26 @@ export class CommissionNodeThread extends LitElement { Edit in Settings
- ${ - showPicker - ? html` { - this._selectedId = (e.target as HTMLSelectElement).value; - }} - > - ${selectable.map( - entry => html` - -
${entry.networkName || entry.id}
-
- `, - )} -
-
-
` - : nothing - } + ${showPicker + ? html` { + this._selectedId = (e.target as HTMLSelectElement).value; + }} + > + ${selectable.map( + entry => html` + +
${entry.networkName || entry.id}
+
+ `, + )} +
+
+
` + : nothing}

diff --git a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-wifi.ts b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-wifi.ts index 7ab6b6d4..57bd3d2c 100644 --- a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-wifi.ts +++ b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-wifi.ts @@ -145,28 +145,26 @@ export class CommissionNodeWifi extends LitElement { Edit in Settings
- ${ - showPicker - ? html` { - this._selectedId = (e.target as HTMLSelectElement).value; - }} - > - ${selectable.map( - entry => html` - -
${entry.ssid || entry.id}
-
- `, - )} -
-
-
` - : nothing - } + ${showPicker + ? html` { + this._selectedId = (e.target as HTMLSelectElement).value; + }} + > + ${selectable.map( + entry => html` + +
${entry.ssid || entry.id}
+
+ `, + )} +
+
+
` + : nothing}

diff --git a/packages/dashboard/src/components/dialogs/dev/attribute-write-dialog.ts b/packages/dashboard/src/components/dialogs/dev/attribute-write-dialog.ts index 802b7d1c..c7d0c511 100644 --- a/packages/dashboard/src/components/dialogs/dev/attribute-write-dialog.ts +++ b/packages/dashboard/src/components/dialogs/dev/attribute-write-dialog.ts @@ -89,11 +89,9 @@ export class AttributeWriteDialog extends LitElement { aria-describedby="write-path${this._error ? " write-error" : ""}" rows="10" > - ${ - this._error - ? html`` - : nothing - } + ${this._error + ? html`` + : nothing}
Cancel diff --git a/packages/dashboard/src/components/dialogs/dev/command-invoke-dialog.ts b/packages/dashboard/src/components/dialogs/dev/command-invoke-dialog.ts index 3d2d5478..0a99f4ca 100644 --- a/packages/dashboard/src/components/dialogs/dev/command-invoke-dialog.ts +++ b/packages/dashboard/src/components/dialogs/dev/command-invoke-dialog.ts @@ -112,20 +112,16 @@ export class CommandInvokeDialog extends LitElement { aria-describedby="invoke-path${this._error ? " invoke-error" : ""}" rows="8" > - ${ - this._error - ? html`` - : nothing - } + ${this._error + ? html`` + : nothing} ${this._success ? html`
Success
` : nothing} - ${ - this._response !== null - ? html` - -
${this._response}
- ` - : nothing - } + ${this._response !== null + ? html` + +
${this._response}
+ ` + : nothing}
Close diff --git a/packages/dashboard/src/components/dialogs/settings/log-level-section.ts b/packages/dashboard/src/components/dialogs/settings/log-level-section.ts index b4d03550..ff1d5b76 100644 --- a/packages/dashboard/src/components/dialogs/settings/log-level-section.ts +++ b/packages/dashboard/src/components/dialogs/settings/log-level-section.ts @@ -107,27 +107,25 @@ export class LogLevelSection extends LitElement { )}
- ${ - this._fileLevel !== null - ? html` -
- - - ${LOG_LEVELS.map( - level => html` - -
${level.label}
-
- `, - )} -
-
- ` - : nothing - } + ${this._fileLevel !== null + ? html` +
+ + + ${LOG_LEVELS.map( + level => html` + +
${level.label}
+
+ `, + )} +
+
+ ` + : nothing}
this._apply())} ?disabled=${this._applying}> ${this._applying ? "Applying..." : "Apply"} diff --git a/packages/dashboard/src/components/dialogs/settings/settings-dialog.ts b/packages/dashboard/src/components/dialogs/settings/settings-dialog.ts index 98622d1a..bb1c617b 100644 --- a/packages/dashboard/src/components/dialogs/settings/settings-dialog.ts +++ b/packages/dashboard/src/components/dialogs/settings/settings-dialog.ts @@ -311,11 +311,9 @@ export class SettingsDialog extends LitElement { Add
- ${ - entries.length === 0 && !this._wifiAdding - ? html`

No WiFi credentials configured

` - : nothing - } + ${entries.length === 0 && !this._wifiAdding + ? html`

No WiFi credentials configured

` + : nothing} ${entries.map(entry => this._renderWifiEntry(entry))} ${this._wifiAdding ? this._renderWifiForm(undefined, "") : nothing} @@ -358,21 +356,19 @@ export class SettingsDialog extends LitElement { > - ${ - isDefault - ? html` this._removeWifi("default"))} - .disabled=${this._credLoading} - >Clear` - : html` this._removeWifi(entry.id))} - .disabled=${this._credLoading} - > - - ` - } + ${isDefault + ? html` this._removeWifi("default"))} + .disabled=${this._credLoading} + >Clear` + : html` this._removeWifi(entry.id))} + .disabled=${this._credLoading} + > + + `} `; @@ -382,16 +378,14 @@ export class SettingsDialog extends LitElement { const isAdd = id === undefined; return html`
- ${ - isAdd - ? html`` - : nothing - } + ${isAdd + ? html`` + : nothing} Add
- ${ - entries.length === 0 && !this._threadAdding - ? html`

No Thread datasets configured

` - : nothing - } + ${entries.length === 0 && !this._threadAdding + ? html`

No Thread datasets configured

` + : nothing} ${entries.map(entry => this._renderThreadEntry(entry))} ${this._threadAdding ? this._renderThreadForm(undefined) : nothing} @@ -485,21 +477,19 @@ export class SettingsDialog extends LitElement { > - ${ - isDefault - ? html` this._removeThread("default"))} - .disabled=${this._credLoading} - >Clear` - : html` this._removeThread(entry.id))} - .disabled=${this._credLoading} - > - - ` - } + ${isDefault + ? html` this._removeThread("default"))} + .disabled=${this._credLoading} + >Clear` + : html` this._removeThread(entry.id))} + .disabled=${this._credLoading} + > + + `} `; @@ -509,16 +499,14 @@ export class SettingsDialog extends LitElement { const isAdd = id === undefined; return html`
- ${ - isAdd - ? html`` - : nothing - } + ${isAdd + ? html`` + : nothing} WiFi - ${ - this.client.serverInfo.wifi_credentials_set - ? html`${this.client.serverInfo.wifi_ssid}` - : html`Not configured` - } + ${this.client.serverInfo.wifi_credentials_set + ? html`${this.client.serverInfo.wifi_ssid}` + : html`Not configured`}
this._toggleExpand("wifi")} .disabled=${this._credLoading} >Edit - ${ - this._expandedRow === "wifi" - ? html`
+ ${this._expandedRow === "wifi" + ? html`
+ +
-
- - - - -
- ${this._renderCredError()} -
- Cancel - ${ - this.client.serverInfo.wifi_credentials_set - ? html` this._removeWifi())} - .disabled=${this._credLoading} - >Remove` - : nothing - } - this._saveWifi())} - .disabled=${this._credLoading} - >Save -
-
` - : nothing - } + + + +
+ ${this._renderCredError()} +
+ Cancel + ${this.client.serverInfo.wifi_credentials_set + ? html` this._removeWifi())} + .disabled=${this._credLoading} + >Remove` + : nothing} + this._saveWifi())} .disabled=${this._credLoading} + >Save +
+
` + : nothing} `; } @@ -606,48 +586,42 @@ export class SettingsDialog extends LitElement {
Thread - ${ - this.client.serverInfo.thread_credentials_set - ? html`Thread network set` - : html`Not configured` - } + ${this.client.serverInfo.thread_credentials_set + ? html`Thread network set` + : html`Not configured`}
this._toggleExpand("thread")} .disabled=${this._credLoading} >Edit - ${ - this._expandedRow === "thread" - ? html`
- + + ${this._renderCredError()} +
+ Cancel + ${this.client.serverInfo.thread_credentials_set + ? html` this._removeThread())} + .disabled=${this._credLoading} + >Remove` + : nothing} + this._saveThread())} .disabled=${this._credLoading} - > - ${this._renderCredError()} -
- Cancel - ${ - this.client.serverInfo.thread_credentials_set - ? html` this._removeThread())} - .disabled=${this._credLoading} - >Remove` - : nothing - } - this._saveThread())} - .disabled=${this._credLoading} - >Save -
-
` - : nothing - } + >Save +
+ ` + : nothing} `; } diff --git a/packages/dashboard/src/components/webrtc-stream-view.ts b/packages/dashboard/src/components/webrtc-stream-view.ts index 95044c20..3dc5c0da 100644 --- a/packages/dashboard/src/components/webrtc-stream-view.ts +++ b/packages/dashboard/src/components/webrtc-stream-view.ts @@ -299,36 +299,28 @@ export class WebRtcStreamView extends LitElement { disableremoteplayback ?hidden=${this._state !== "streaming"} > - ${ - this._state === "idle" - ? html`
- -
- ${ - this.liveViewSupported - ? html`Click Start to begin streaming` - : "Live view not supported — use Snapshot" - } -
-
` - : null - } - ${ - this._state === "connecting" - ? html`
-
-
Connecting…
-
` - : null - } - ${ - this._state === "error" - ? html`
- -
${this._errorMessage ?? "Stream error"}
-
` - : null - } + ${this._state === "idle" + ? html`
+ +
+ ${this.liveViewSupported + ? html`Click Start to begin streaming` + : "Live view not supported — use Snapshot"} +
+
` + : null} + ${this._state === "connecting" + ? html`
+
+
Connecting…
+
` + : null} + ${this._state === "error" + ? html`
+ +
${this._errorMessage ?? "Stream error"}
+
` + : null} `; } diff --git a/packages/dashboard/src/pages/camera-overlay.ts b/packages/dashboard/src/pages/camera-overlay.ts index 48fe30c8..0cd34c2a 100644 --- a/packages/dashboard/src/pages/camera-overlay.ts +++ b/packages/dashboard/src/pages/camera-overlay.ts @@ -297,180 +297,147 @@ export class CameraOverlay extends LitElement { Node ${this.nodeId} • Endpoint ${this.endpointId} - ${ - this._avsumPresent() - ? html`` + : nothing} +
+ ${this.client + ? html`` - : nothing - } -
- ${ - this.client - ? html`` - : html`
No Matter client available.
` - } - ${ - this._snapshotDataUri - ? html` -
- Snapshot - { - this._snapshotDataUri = null; - }} - aria-label="Close snapshot" - > - - -
- ` - : nothing - } + .liveViewSupported=${liveViewSupported} + .resolution=${this._selectedResolution} + .watermarkEnabled=${this._watermarkEnabled} + .osdEnabled=${this._osdEnabled} + .snapshotResolution=${this._selectedSnapshotResolution} + @streamstate=${this._onStreamState} + >` + : html`
No Matter client available.
`} + ${this._snapshotDataUri + ? html` +
+ Snapshot + { + this._snapshotDataUri = null; + }} + aria-label="Close snapshot" + > + + +
+ ` + : nothing} ${this._snapshotError ? html`
${this._snapshotError}
` : nothing}
${this._closing ? html`Closing…` : nothing} - ${ - !this._closing && this._state === "connecting" - ? html`Waiting for camera response…` - : nothing - } - ${ - !this._closing && this._state === "error" && this._errorMessage - ? html`${this._errorMessage}` - : nothing - } - ${ - canStart && this._resolutions.length > 0 - ? html` - - ${this._resolutions.map( - r => html` - -
${r.width}×${r.height}
-
- `, - )} -
- ` - : nothing - } - ${ - idleOrError && this._snapshotSupported && this._snapshotResolutions.length > 0 - ? html` - - ${this._snapshotResolutions.map( - r => html` - -
${r.width}×${r.height}
-
- `, - )} -
- ` - : nothing - } - ${ - canStart && this._avsmFeatures().wmark - ? html`` - : nothing - } - ${ - canStart && this._avsmFeatures().osd - ? html`` - : nothing - } - ${ - canStart - ? html`Waiting for camera response…` + : nothing} + ${!this._closing && this._state === "error" && this._errorMessage + ? html`${this._errorMessage}` + : nothing} + ${canStart && this._resolutions.length > 0 + ? html` + - ${this._state === "error" ? "Retry" : "Start"} - ` - : nothing - } - ${ - this._state === "streaming" - ? html`End - - - ${this._muted ? "Unmute" : "Mute"} - ` - : nothing - } - ${ - this._snapshotSupported - ? html` html` + +
${r.width}×${r.height}
+
+ `, + )} + + ` + : nothing} + ${idleOrError && this._snapshotSupported && this._snapshotResolutions.length > 0 + ? html` + - - ${this._snapshotBusy ? "Capturing…" : "Snapshot"} + ${this._snapshotResolutions.map( + r => html` + +
${r.width}×${r.height}
+
+ `, + )} +
+ ` + : nothing} + ${canStart && this._avsmFeatures().wmark + ? html`` + : nothing} + ${canStart && this._avsmFeatures().osd + ? html`` + : nothing} + ${canStart + ? html` + ${this._state === "error" ? "Retry" : "Start"} + ` + : nothing} + ${this._state === "streaming" + ? html`End + + + ${this._muted ? "Unmute" : "Mute"} ` - : nothing - } + : nothing} + ${this._snapshotSupported + ? html` + + ${this._snapshotBusy ? "Capturing…" : "Snapshot"} + ` + : nothing} Close
diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts index bfe58fbf..d3b98145 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts @@ -199,21 +199,19 @@ class AccessControlClusterCommands extends BaseClusterCommands {
Access Control — ACL Entries (${entries.length})
- ${ - overPrivilegedKeys.size >= 2 - ? html`` - : nothing - } + ${overPrivilegedKeys.size >= 2 + ? html`` + : nothing} @@ -250,39 +248,35 @@ class AccessControlClusterCommands extends BaseClusterCommands { ${PRIVILEGE_NAMES[entry.privilege] ?? entry.privilege} · ${entry.privilege} - ${ - overPrivileged - ? html`
- this._fix(new Set([aclEntryKey(entry)])))} - >Fix → Operate -
` - : nothing - } + ${overPrivileged + ? html`
+ this._fix(new Set([aclEntryKey(entry)])))} + >Fix → Operate +
` + : nothing} `; diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/avsum-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/avsum-commands.ts index 98436790..a9eed3ee 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/avsum-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/avsum-commands.ts @@ -75,221 +75,198 @@ class AvsumClusterCommands extends BaseClusterCommands { Camera AV Settings — Pan / Tilt / Zoom
- ${ - features.mPan && pos.pan !== null - ? html`Pan ${this._fmtDeg(pos.pan)}` - : nothing - } - ${ - features.mTilt && pos.tilt !== null - ? html`Tilt ${this._fmtDeg(pos.tilt)}` - : nothing - } + ${features.mPan && pos.pan !== null + ? html`Pan ${this._fmtDeg(pos.pan)}` + : nothing} + ${features.mTilt && pos.tilt !== null + ? html`Tilt ${this._fmtDeg(pos.tilt)}` + : nothing} ${features.mZoom && pos.zoom !== null ? html`Zoom ${pos.zoom}×` : nothing} - ${ - movement !== "unknown" - ? html`${movement === "moving" ? "Moving…" : "Idle"}` - : nothing - } - ${ - features.mPan && ranges.panMin !== null && ranges.panMax !== null - ? html`P [${ranges.panMin}°, ${ranges.panMax}°]` - : nothing - } - ${ - features.mTilt && ranges.tiltMin !== null && ranges.tiltMax !== null - ? html`T [${ranges.tiltMin}°, ${ranges.tiltMax}°]` - : nothing - } - ${ - features.mZoom && ranges.zoomMax !== null - ? html`Z [1×, ${ranges.zoomMax}×]` - : nothing - } + ${movement !== "unknown" + ? html`${movement === "moving" ? "Moving…" : "Idle"}` + : nothing} + ${features.mPan && ranges.panMin !== null && ranges.panMax !== null + ? html`P [${ranges.panMin}°, ${ranges.panMax}°]` + : nothing} + ${features.mTilt && ranges.tiltMin !== null && ranges.tiltMax !== null + ? html`T [${ranges.tiltMin}°, ${ranges.tiltMax}°]` + : nothing} + ${features.mZoom && ranges.zoomMax !== null + ? html`Z [1×, ${ranges.zoomMax}×]` + : nothing}
- ${ - features.mPan || features.mTilt || features.mZoom - ? html`
-
- - - this._move({ tiltDelta: this._stepFromEvent(e, 10) }), - )} - > - - - - - this._move({ panDelta: this._stepFromEvent(e, -10) }), - )} - > - - - - - this._move({ panDelta: this._stepFromEvent(e, 10) }), - )} - > - - - - - this._move({ tiltDelta: this._stepFromEvent(e, -10) }), - )} - > - - - -
- ${ - features.mZoom - ? html`
- - this._move({ zoomDelta: this._stepFromEvent(e, 10) }), - )} - > - - - zoom - - this._move({ zoomDelta: this._stepFromEvent(e, -10) }), - )} - > - - -
` - : nothing - } -
` - : nothing - } + ${features.mPan || features.mTilt || features.mZoom + ? html`
+
+ + + this._move({ tiltDelta: this._stepFromEvent(e, 10) }), + )} + > + + + + + this._move({ panDelta: this._stepFromEvent(e, -10) }), + )} + > + + + + + this._move({ panDelta: this._stepFromEvent(e, 10) }), + )} + > + + + + + this._move({ tiltDelta: this._stepFromEvent(e, -10) }), + )} + > + + + +
+ ${features.mZoom + ? html`
+ + this._move({ zoomDelta: this._stepFromEvent(e, 10) }), + )} + > + + + zoom + + this._move({ zoomDelta: this._stepFromEvent(e, -10) }), + )} + > + + +
` + : nothing} +
` + : nothing} ${this._toast ? html`
${this._toast}
` : nothing} - ${ - features.mPresets - ? (() => { - const { items, max } = readPresets(this.node, this.endpoint); - return html` -
-
- Presets - ${items.length} / ${max} -
-
- ${items.map( - p => html``, + )} + ${items.length === 0 + ? html`No presets saved.` + : nothing} +
+
+ Manage presets… + ${items.map( + p => html`
+ #${p.presetId} + ${p.name} + + p=${this._fmtDeg(p.settings.pan ?? 0)} · + t=${this._fmtDeg(p.settings.tilt ?? 0)} · + z=${p.settings.zoom ?? 1}× + + + this._savePresetUpdate(p.presetId))} > - ${p.name} - `, - )} - ${ - items.length === 0 - ? html`No presets saved.` - : nothing - } -
-
- Manage presets… - ${items.map( - p => html`
- #${p.presetId} - ${p.name} - - p=${this._fmtDeg(p.settings.pan ?? 0)} · - t=${this._fmtDeg(p.settings.tilt ?? 0)} · - z=${p.settings.zoom ?? 1}× - - - this._savePresetUpdate(p.presetId))} - > - - - - this._renamePreset(p.presetId, p.name), - )} - > - - - - this._removePreset(p.presetId, p.name), - )} - > - - -
`, - )} -
- - (this._newPresetName = (e.target as HTMLInputElement).value)} - /> - = max} - @click=${handleAsync(() => this._saveNewPreset())} + + + + this._renamePreset(p.presetId, p.name), + )} > - Save current MPTZ - -
-
-
- `; - })() - : nothing - } - ${ - features.dptz - ? (() => { - const streams = readDptzStreams(this.node, this.endpoint); - return html`
- Digital PTZ: ${streams.length} active - stream${streams.length === 1 ? "" : "s"} - (controls available during live view) -
`; - })() - : nothing - } + + + + this._removePreset(p.presetId, p.name), + )} + > + + +
`, + )} +
+ + (this._newPresetName = (e.target as HTMLInputElement).value)} + /> + = max} + @click=${handleAsync(() => this._saveNewPreset())} + > + Save current MPTZ + +
+ + + `; + })() + : nothing} + ${features.dptz + ? (() => { + const streams = readDptzStreams(this.node, this.endpoint); + return html`
+ Digital PTZ: ${streams.length} active stream${streams.length === 1 ? "" : "s"} + (controls available during live view) +
`; + })() + : nothing} `; diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/basic-information-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/basic-information-commands.ts index c36b961f..c3a18f14 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/basic-information-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/basic-information-commands.ts @@ -56,11 +56,9 @@ export class BasicInformationClusterCommands extends BaseClusterCommands {
Node Label
- ${ - !this._isNodeAvailable - ? html`
Node is offline - cannot edit label
` - : nothing - } + ${!this._isNodeAvailable + ? html`
Node is offline - cannot edit label
` + : nothing}
Bindings (${bindings.length})
- ${ - bindings.length === 0 - ? html`
No bindings on this endpoint.
` - : html`
${AUTH_MODE_NAMES[entry.authMode] ?? entry.authMode} ${this._renderSubjects(entry)} ${this._renderTargets(entry)} ${this._renderRelationship(rel)} - ${ - protectedEntry - ? html`` - : html` this._delete(entry))} - > - delete - ` - } + ${protectedEntry + ? html`` + : html` this._delete(entry))} + > + delete + `}
- - - - - - - - - - - ${bindings.map((b, i) => this._row(b, i))} - -
Target nodeEndpointClusterACL on target
` - } + ${bindings.length === 0 + ? html`
No bindings on this endpoint.
` + : html` + + + + + + + + + + + ${bindings.map((b, i) => this._row(b, i))} + +
Target nodeEndpointClusterACL on target
`} this._openAdd())} @@ -189,9 +187,9 @@ class BindingClusterCommands extends BaseClusterCommands { ${name}${ - b.node != null ? html` · ${b.node.toString()}` : nothing - }${name}${b.node != null + ? html` · ${b.node.toString()}` + : nothing} ${endpointText} diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/chime-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/chime-commands.ts index 646f4ef4..7c33447c 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/chime-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/chime-commands.ts @@ -130,56 +130,48 @@ class ChimeClusterCommands extends BaseClusterCommands {
Installed sounds (${sounds.length})
- ${ - sounds.length === 0 - ? html`
No sounds installed.
` - : sounds.map( - s => html` -
- setSelected(this.client, this.node.node_id, this.endpoint, s.chimeId), - )} - > - #${s.chimeId} - ${s.name} - ${ - s.chimeId === selected - ? html`✓ selected` - : nothing - } - - ${ - showPerRowPlay - ? html` { - e.stopPropagation(); - return chimePlay( - this.client, - this.node.node_id, - this.endpoint, - s.chimeId, - ); - })} - > - - ` - : nothing - } -
- `, - ) - } + ${sounds.length === 0 + ? html`
No sounds installed.
` + : sounds.map( + s => html` +
+ setSelected(this.client, this.node.node_id, this.endpoint, s.chimeId), + )} + > + #${s.chimeId} + ${s.name} + ${s.chimeId === selected + ? html`✓ selected` + : nothing} + + ${showPerRowPlay + ? html` { + e.stopPropagation(); + return chimePlay( + this.client, + this.node.node_id, + this.endpoint, + s.chimeId, + ); + })} + > + + ` + : nothing} +
+ `, + )}
- ${ - showLastPlayed - ? html`
- Last played: ${lastSoundName} · ${new Date(this._lastPlayed!.at).toLocaleTimeString()} -
` - : nothing - } + ${showLastPlayed + ? html`
+ Last played: ${lastSoundName} · ${new Date(this._lastPlayed!.at).toLocaleTimeString()} +
` + : nothing}
`; diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/closure-control-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/closure-control-commands.ts index f4b2693f..7f2cfaf7 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/closure-control-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/closure-control-commands.ts @@ -92,35 +92,29 @@ class ClosureControlClusterCommands extends BaseClusterCommands {
- ${ - mainState !== null - ? (MAIN_STATE_LABELS[mainState] ?? `Unknown (${mainState})`) - : "Unknown" - } + ${mainState !== null + ? (MAIN_STATE_LABELS[mainState] ?? `Unknown (${mainState})`) + : "Unknown"} - ${ - countdownTime !== null - ? html`~${countdownTime}s remaining` - : nothing - } + ${countdownTime !== null + ? html`~${countdownTime}s remaining` + : nothing}
- ${ - errors.length > 0 - ? html` -
- ${errors.map( - e => html` - - - ${CLOSURE_ERROR_LABELS[e] ?? `Error ${e}`} - - `, - )} -
- ` - : nothing - } + ${errors.length > 0 + ? html` +
+ ${errors.map( + e => html` + + + ${CLOSURE_ERROR_LABELS[e] ?? `Error ${e}`} + + `, + )} +
+ ` + : nothing}
@@ -134,117 +128,103 @@ class ClosureControlClusterCommands extends BaseClusterCommands {
- ${ - !features.instantaneous - ? html` this._handleStop())}> - - Stop - ` - : nothing - } - ${ - features.calibration - ? html` this._handleCalibrate())}> - - Calibrate - ` - : nothing - } + ${!features.instantaneous + ? html` this._handleStop())}> + + Stop + ` + : nothing} + ${features.calibration + ? html` this._handleCalibrate())}> + + Calibrate + ` + : nothing}
Move to
- ${ - features.positioning - ? html` - { - this._moveToPosition = (e.target as HTMLSelectElement).value; - }} - > - -
(unchanged)
-
- ${Object.entries(TARGET_POSITION_LABELS) - .filter( - ([id]) => - (id !== "2" || features.pedestrian) && - (id !== "3" || features.ventilation), - ) - .map( - ([id, label]) => html` - -
${label}
-
- `, - )} -
- ` - : nothing - } - ${ - features.motionLatching && - (latchControlModes.remoteLatching || latchControlModes.remoteUnlatching) - ? html` - { - this._moveToLatch = (e.target as HTMLSelectElement).value; - }} - > - -
(unchanged)
-
- ${ - latchControlModes.remoteLatching - ? html` -
Latch
-
` - : nothing - } - ${ - latchControlModes.remoteUnlatching - ? html` -
Unlatch
-
` - : nothing - } -
- ` - : nothing - } - ${ - features.speed - ? html` - { - this._moveToSpeed = (e.target as HTMLSelectElement).value; - }} - > - -
(unchanged)
-
- ${Object.entries(SPEED_LABELS).map( + ${features.positioning + ? html` + { + this._moveToPosition = (e.target as HTMLSelectElement).value; + }} + > + +
(unchanged)
+
+ ${Object.entries(TARGET_POSITION_LABELS) + .filter( + ([id]) => + (id !== "2" || features.pedestrian) && + (id !== "3" || features.ventilation), + ) + .map( ([id, label]) => html`
${label}
`, )} -
- ` - : nothing - } +
+ ` + : nothing} + ${features.motionLatching && + (latchControlModes.remoteLatching || latchControlModes.remoteUnlatching) + ? html` + { + this._moveToLatch = (e.target as HTMLSelectElement).value; + }} + > + +
(unchanged)
+
+ ${latchControlModes.remoteLatching + ? html` +
Latch
+
` + : nothing} + ${latchControlModes.remoteUnlatching + ? html` +
Unlatch
+
` + : nothing} +
+ ` + : nothing} + ${features.speed + ? html` + { + this._moveToSpeed = (e.target as HTMLSelectElement).value; + }} + > + +
(unchanged)
+
+ ${Object.entries(SPEED_LABELS).map( + ([id, label]) => html` + +
${label}
+
+ `, + )} +
+ ` + : nothing} this._handleMoveTo())} > Move @@ -261,52 +241,40 @@ class ClosureControlClusterCommands extends BaseClusterCommands { const positionLabels = "secureState" in state ? CURRENT_POSITION_LABELS : TARGET_POSITION_LABELS; return html`
- ${ - features.positioning - ? html`
- Position: - - ${ - state.position !== null - ? (positionLabels[state.position] ?? `#${state.position}`) - : "Unknown" - } - -
` - : nothing - } - ${ - features.motionLatching - ? html`
- Latch: - ${state.latch === null ? "Unknown" : state.latch ? "Latched" : "Unlatched"} -
` - : nothing - } - ${ - features.speed - ? html`
- Speed: - ${ - state.speed !== null - ? (SPEED_LABELS[state.speed] ?? `#${state.speed}`) - : "Unknown" - } -
` - : nothing - } - ${ - "secureState" in state - ? html`
- Secure: - - ${state.secureState === null ? "Unknown" : state.secureState ? "Secure" : "Not secure"} - -
` - : nothing - } + ${features.positioning + ? html`
+ Position: + + ${state.position !== null + ? (positionLabels[state.position] ?? `#${state.position}`) + : "Unknown"} + +
` + : nothing} + ${features.motionLatching + ? html`
+ Latch: + ${state.latch === null ? "Unknown" : state.latch ? "Latched" : "Unlatched"} +
` + : nothing} + ${features.speed + ? html`
+ Speed: + ${state.speed !== null + ? (SPEED_LABELS[state.speed] ?? `#${state.speed}`) + : "Unknown"} +
` + : nothing} + ${"secureState" in state + ? html`
+ Secure: + + ${state.secureState === null ? "Unknown" : state.secureState ? "Secure" : "Not secure"} + +
` + : nothing}
`; } diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts index 4879e360..6b71c957 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts @@ -165,27 +165,23 @@ export class IcdManagementClusterCommands extends BaseClusterCommands { Power & Sleep (ICD)

This device saves power by sleeping between short check-in windows.

- ${ - info.operatingMode === "LIT" - ? html`

- This device is currently in Battery Saver Mode: any action you trigger - (commands, reads, re-subscriptions) may take up to ${this._idleText} while the - device sleeps. Updates reported by the device itself (e.g. sensor changes) are not - delayed — the device wakes up on its own to report them. - ${ - this.node.available - ? nothing - : html`
The device is currently offline — reconnecting on its own can take - up to ${this._idleText}.` - } -

` - : html`

- Current mode: Standard — the device sleeps between short check-ins and - typically reacts within seconds to a few minutes. -

` - } + ${info.operatingMode === "LIT" + ? html`

+ This device is currently in Battery Saver Mode: any action you trigger (commands, + reads, re-subscriptions) may take up to ${this._idleText} while the device sleeps. + Updates reported by the device itself (e.g. sensor changes) are not delayed — the device + wakes up on its own to report them. + ${this.node.available + ? nothing + : html`
The device is currently offline — reconnecting on its own can take up to + ${this._idleText}.`} +

` + : html`

+ Current mode: Standard — the device sleeps between short check-ins and typically + reacts within seconds to a few minutes. +

`} ${info.features.userActiveModeTrigger ? this._renderWakeHint(info) : nothing} ${info.features.longIdleTimeSupport || this._registered ? this._renderIcdManagement() : nothing}
@@ -272,12 +268,10 @@ export class IcdManagementClusterCommands extends BaseClusterCommands { Every ecosystem (e.g. Apple, Google) this device is paired with must support Battery Saver Mode (called Matter LIT (Long Idle Time) ICD). You can switch back to Standard Mode later as long as no other ecosystem is registered for it. - ${ - !single && !this._registered - ? html` This device is already paired with ${this._commissionedFabrics - 1} other - ecosystem(s).` - : nothing - } + ${!single && !this._registered + ? html` This device is already paired with ${this._commissionedFabrics - 1} other + ecosystem(s).` + : nothing}

Resync state - ${ - this._busy - ? html` - ${this._busyLabel}` - : nothing - } + ${this._busy + ? html` + ${this._busyLabel}` + : nothing}
`; } @@ -322,15 +314,13 @@ export class IcdManagementClusterCommands extends BaseClusterCommands {

Important: - ${ - single - ? html`every ecosystem (e.g. Apple, Google) you pair this device with later must support Battery - Saver Mode (called Matter LIT (Long Idle Time) ICD). In an ecosystem without support the - device will appear offline or unresponsive.` - : html`this device is already paired with ${this._commissionedFabrics - 1} other - ecosystem(s). All of them must support Battery Saver Mode (called Matter LIT (Long Idle - Time) ICD), otherwise the device will appear offline or unresponsive in those ecosystems.` - } + ${single + ? html`every ecosystem (e.g. Apple, Google) you pair this device with later must support Battery + Saver Mode (called Matter LIT (Long Idle Time) ICD). In an ecosystem without support the device + will appear offline or unresponsive.` + : html`this device is already paired with ${this._commissionedFabrics - 1} other + ecosystem(s). All of them must support Battery Saver Mode (called Matter LIT (Long Idle Time) + ICD), otherwise the device will appear offline or unresponsive in those ecosystems.`}

You can switch back to Standard Mode later as long as no other ecosystem is registered for Battery Saver diff --git a/packages/dashboard/src/pages/components/header.ts b/packages/dashboard/src/pages/components/header.ts index 5c1f785e..e52d4acd 100644 --- a/packages/dashboard/src/pages/components/header.ts +++ b/packages/dashboard/src/pages/components/header.ts @@ -129,26 +129,22 @@ export class DashboardHeader extends LitElement { aria-current=${this.activeView === "nodes" ? "page" : nothing} >Nodes - ${ - showThreadTab - ? html`Thread` - : nothing - } - ${ - showWifiTab - ? html`WiFi` - : nothing - } + ${showThreadTab + ? html`Thread` + : nothing} + ${showWifiTab + ? html`WiFi` + : nothing} `; } @@ -157,16 +153,14 @@ export class DashboardHeader extends LitElement { return html`

- ${ - this.backButton - ? html` - - - - - ` - : "" - } + ${this.backButton + ? html` + + + + + ` + : ""}
${this.title ?? ""}
${this._renderNavTabs()} @@ -179,44 +173,38 @@ export class DashboardHeader extends LitElement { `; })} - ${ - this._devMode && this.client - ? html` - - ` - : nothing - } + ${this._devMode && this.client + ? html` + + ` + : nothing} - ${ - this.client - ? html` - - - - ` - : nothing - } + ${this.client + ? html` + + + + ` + : nothing} - ${ - this.client && !this.client.isProduction - ? html` - - - - ` - : nothing - } + ${this.client && !this.client.isProduction + ? html` + + + + ` + : nothing}
`; diff --git a/packages/dashboard/src/pages/components/node-details.ts b/packages/dashboard/src/pages/components/node-details.ts index ebef1ec0..5f28ec91 100644 --- a/packages/dashboard/src/pages/components/node-details.ts +++ b/packages/dashboard/src/pages/components/node-details.ts @@ -103,30 +103,26 @@ export class NodeDetails extends LitElement {
${this.node.nodeLabel || "Node Info"} - ${ - this.node.available - ? html` - this._editNodeLabel()} - aria-label="Edit node label" - title="Edit node label" - > - - - ` - : nothing - } + ${this.node.available + ? html` + this._editNodeLabel()} + aria-label="Edit node label" + title="Edit node label" + > + + + ` + : nothing} ${this.node.available ? nothing : html` OFFLINE`} - ${ - badge - ? html`ICD` - : nothing - } + ${badge + ? html`ICD` + : nothing}
@@ -140,62 +136,54 @@ export class NodeDetails extends LitElement {
Is bridge: ${this.node.is_bridge}
Serialnumber: ${this.node.serialNumber}
- ${ - this.node.matter_version - ? html`
- Matter version: ${this.node.matter_version} -
` - : nothing - } - ${ - this.node.is_bridge - ? "" - : html`
- All device types: ${getNodeDeviceTypes(this.node) - .map(deviceType => { - return deviceType.label; - }) - .join(" / ")} -
` - } + ${this.node.matter_version + ? html`
+ Matter version: ${this.node.matter_version} +
` + : nothing} + ${this.node.is_bridge + ? "" + : html`
+ All device types: ${getNodeDeviceTypes(this.node) + .map(deviceType => { + return deviceType.label; + }) + .join(" / ")} +
`}
this._reinterview())} >Interview - ${ - this._updateInitiated - ? html` Checking for updates` - : (this.node.updateState ?? 0) > 1 - ? html` ${getUpdateStateLabel( - this.node.updateState!, - this.node.updateStateProgress, - )}` - : html` this._searchUpdate())} - >Update` - } - ${ - isCamera - ? html` - this._openCameraOverlay()} - ?disabled=${!this.node.available} - > - ${isAudioOnly ? "Listen" : isLiveViewCamera ? "Live View" : "Snapshot"} - - - ` - : nothing - } + ${this._updateInitiated + ? html` Checking for updates` + : (this.node.updateState ?? 0) > 1 + ? html` ${getUpdateStateLabel( + this.node.updateState!, + this.node.updateStateProgress, + )}` + : html` this._searchUpdate())} + >Update`} + ${isCamera + ? html` + this._openCameraOverlay()} + ?disabled=${!this.node.available} + > + ${isAudioOnly ? "Listen" : isLiveViewCamera ? "Live View" : "Snapshot"} + + + ` + : nothing} this._openCommissioningWindow())} >Share @@ -292,26 +280,23 @@ export class NodeDetails extends LitElement { !(await showPromptDialog({ title: "Firmware update available", text: html`Found a firmware update for this node on ${nodeUpdate.update_source}. - ${ - isUnverifiedSource - ? html` -

- Warning: This update was found on an unverified source. Updates from test-net or - local sources have not been certified and may contain untested firmware that could - result in non-functional devices. Applying these updates is entirely at your own - risk. -

- ` - : nothing - } + > + Warning: This update was found on an unverified source. Updates from test-net or local + sources have not been certified and may contain untested firmware that could result in + non-functional devices. Applying these updates is entirely at your own risk. +

+ ` + : nothing}

Current version: ${this._formatSoftwareVersion()}
Do you want to update this node to version diff --git a/packages/dashboard/src/pages/matter-cluster-view.ts b/packages/dashboard/src/pages/matter-cluster-view.ts index 03b8eda6..9a12a0ed 100644 --- a/packages/dashboard/src/pages/matter-cluster-view.ts +++ b/packages/dashboard/src/pages/matter-cluster-view.ts @@ -179,32 +179,26 @@ class MatterClusterView extends LitElement { (attribute, index) => html`

- ${ - clusters[this.cluster!]?.attributes[attribute.key]?.label ?? - "Custom/Unknown Attribute" - } + ${clusters[this.cluster!]?.attributes[attribute.key]?.label ?? + "Custom/Unknown Attribute"}
AttributeId: ${attribute.key} (${formatHex(attribute.key)}) - Value type: ${clusters[this.cluster!]?.attributes[attribute.key]?.type ?? "unknown"}
- ${ - this._devMode - ? this._renderAttributeDevActions(attribute.key, attribute.value) - : nothing - } - ${ - toBigIntAwareJson(attribute.value).length > 30 - ? html` { - this._showAttributeValue(attribute.value); - }} - > - Show value - ` - : html`${toBigIntAwareJson(attribute.value)}` - } + ${this._devMode + ? this._renderAttributeDevActions(attribute.key, attribute.value) + : nothing} + ${toBigIntAwareJson(attribute.value).length > 30 + ? html` { + this._showAttributeValue(attribute.value); + }} + > + Show value + ` + : html`${toBigIntAwareJson(attribute.value)}`}
`, @@ -231,21 +225,19 @@ class MatterClusterView extends LitElement { > - ${ - meta?.writable - ? html` - this._openAttributeWriteDialog(attributeId, currentValue, meta.label)} - > - - - ` - : nothing - } + ${meta?.writable + ? html` + this._openAttributeWriteDialog(attributeId, currentValue, meta.label)} + > + + + ` + : nothing} `; } @@ -318,35 +310,33 @@ class MatterClusterView extends LitElement { Commands
- ${ - commands.length === 0 - ? html`

No invokable commands for this cluster.

` - : html` -
    - ${commands.map( - cmd => html` -
  • -
    - ${cmd.label} - CommandId ${cmd.id} (${formatHex(cmd.id)}) · - ${cmd.name} -
    - this._openCommandInvokeDialog(cmd.id, cmd.name)} + ${commands.length === 0 + ? html`

    No invokable commands for this cluster.

    ` + : html` +
      + ${commands.map( + cmd => html` +
    • +
      + ${cmd.label} + CommandId ${cmd.id} (${formatHex(cmd.id)}) · + ${cmd.name} - - Invoke - -
    • - `, - )} -
    - ` - } +
+ this._openCommandInvokeDialog(cmd.id, cmd.name)} + > + + Invoke + + + `, + )} + + `}
@@ -414,21 +404,19 @@ class MatterClusterView extends LitElement { return html`
Active Features
- ${ - activeFeatures.length === 0 - ? html`

No active features

` - : html` -
    - ${activeFeatures.map( - feature => html` -
  • - ${feature.label} -
  • - `, - )} -
- ` - } + ${activeFeatures.length === 0 + ? html`

No active features

` + : html` +
    + ${activeFeatures.map( + feature => html` +
  • + ${feature.label} +
  • + `, + )} +
+ `}
`; } @@ -443,20 +431,18 @@ class MatterClusterView extends LitElement { return html`
Semantic Tags (TagList)
- ${ - tagList.length === 0 - ? html`

No semantic tags

` - : html` -
    - ${tagList.map(entry => { - const { text, title, erroneous } = describeSemanticTagListEntry(entry); - return html`
  • - ${text} -
  • `; - })} -
- ` - } + ${tagList.length === 0 + ? html`

No semantic tags

` + : html` +
    + ${tagList.map(entry => { + const { text, title, erroneous } = describeSemanticTagListEntry(entry); + return html`
  • + ${text} +
  • `; + })} +
+ `}
`; } diff --git a/packages/dashboard/src/pages/matter-network-view.ts b/packages/dashboard/src/pages/matter-network-view.ts index c5b10bdf..721c1771 100644 --- a/packages/dashboard/src/pages/matter-network-view.ts +++ b/packages/dashboard/src/pages/matter-network-view.ts @@ -354,31 +354,29 @@ class MatterNetworkView extends LitElement { > - ${ - this._showHideMenu - ? html` -
- ${HIDE_OPTIONS.map( - option => html` - - `, - )} -
- ` - : "" - } + ${this._showHideMenu + ? html` +
+ ${HIDE_OPTIONS.map( + option => html` + + `, + )} +
+ ` + : ""}
- ` - : nothing - } + ${onlineSeenByNodes.length > 0 + ? html` + + ` + : nothing} @@ -1534,20 +1404,18 @@ export class NetworkDetails extends LitElement {
${this._renderUnknownDeviceInfo(this.selectedNodeId as string)}
- ${ - this._showUpdateDialog - ? html` - - ` - : nothing - } + ${this._showUpdateDialog + ? html` + + ` + : nothing} `; } @@ -1564,20 +1432,18 @@ export class NetworkDetails extends LitElement {

Border Router

- ${ - onlineSeenByNodes.length > 0 - ? html` - - ` - : nothing - } + ${onlineSeenByNodes.length > 0 + ? html` + + ` + : nothing} @@ -1585,20 +1451,18 @@ export class NetworkDetails extends LitElement {
${this._renderBorderRouterInfo(borderRouterId)}
- ${ - this._showUpdateDialog - ? html` - - ` - : nothing - } + ${this._showUpdateDialog + ? html` + + ` + : nothing} `; } @@ -1658,20 +1522,18 @@ export class NetworkDetails extends LitElement { ${this._formatNodeIdHex(this.selectedNodeId)}
- ${ - canUpdate - ? html` - - ` - : nothing - } + ${canUpdate + ? html` + + ` + : nothing} @@ -1684,20 +1546,18 @@ export class NetworkDetails extends LitElement { View node details
- ${ - this._showUpdateDialog - ? html` - - ` - : nothing - } + ${this._showUpdateDialog + ? html` + + ` + : nothing} `; } diff --git a/packages/dashboard/src/pages/network/thread-graph.ts b/packages/dashboard/src/pages/network/thread-graph.ts index f519daae..f520eabb 100644 --- a/packages/dashboard/src/pages/network/thread-graph.ts +++ b/packages/dashboard/src/pages/network/thread-graph.ts @@ -1011,11 +1011,9 @@ export class ThreadGraph extends BaseNetworkGraph {

${allOfflineFiltered ? "No online Thread devices" : "No Thread devices found"}

- ${ - allOfflineFiltered - ? 'Disable the "Offline nodes" filter to show offline devices' - : "Thread devices will appear here once commissioned" - } + ${allOfflineFiltered + ? 'Disable the "Offline nodes" filter to show offline devices' + : "Thread devices will appear here once commissioned"}

`; diff --git a/packages/dashboard/src/pages/network/update-connections-dialog.ts b/packages/dashboard/src/pages/network/update-connections-dialog.ts index 2e931301..61ce261e 100644 --- a/packages/dashboard/src/pages/network/update-connections-dialog.ts +++ b/packages/dashboard/src/pages/network/update-connections-dialog.ts @@ -280,68 +280,60 @@ export class UpdateConnectionsDialog extends LitElement { const plural = neighborCount !== 1 ? "s" : ""; return html` - ${ - sleepy - ? html` -

- "${this.selectedNodeName}" is a sleepy device (Matter LIT). It answers - only when it next wakes, so its own network data arrives later. -

- ` - : html`

Refresh network information for "${this.selectedNodeName}".

` - } - ${ - neighborCount > 0 - ? html` - - ` - : sleepy - ? html`

No online neighbor can report its current link data either.

` - : nothing - } + ${sleepy + ? html` +

+ "${this.selectedNodeName}" is a sleepy device (Matter LIT). It answers only + when it next wakes, so its own network data arrives later. +

+ ` + : html`

Refresh network information for "${this.selectedNodeName}".

`} + ${neighborCount > 0 + ? html` + + ` + : sleepy + ? html`

No online neighbor can report its current link data either.

` + : nothing} `; } private _renderOfflineContent(): unknown { return html`

"${this.selectedNodeName}" appears to be offline.

- ${ - this.onlineNeighborIds.length > 0 - ? html` -

- Update network data from its ${this.onlineNeighborIds.length} online - neighbor${this.onlineNeighborIds.length !== 1 ? "s" : ""} to refresh connection info. -

- ` - : html`

No online neighbors available to update.

` - } + ${this.onlineNeighborIds.length > 0 + ? html` +

+ Update network data from its ${this.onlineNeighborIds.length} online + neighbor${this.onlineNeighborIds.length !== 1 ? "s" : ""} to refresh connection info. +

+ ` + : html`

No online neighbors available to update.

`} `; } private _renderUnknownContent(): unknown { return html`

This device is not commissioned to this fabric and cannot be queried directly.

- ${ - this.onlineNeighborIds.length > 0 - ? html` -

- Update network data from ${this.onlineNeighborIds.length} - node${this.onlineNeighborIds.length !== 1 ? "s" : ""} that - see${this.onlineNeighborIds.length === 1 ? "s" : ""} this device to refresh info. -

- ` - : html`

No online nodes available that see this device.

` - } + ${this.onlineNeighborIds.length > 0 + ? html` +

+ Update network data from ${this.onlineNeighborIds.length} + node${this.onlineNeighborIds.length !== 1 ? "s" : ""} that + see${this.onlineNeighborIds.length === 1 ? "s" : ""} this device to refresh info. +

+ ` + : html`

No online nodes available that see this device.

`} `; } @@ -372,13 +364,11 @@ export class UpdateConnectionsDialog extends LitElement {
Update Connection Data
- ${ - this.selectedNodeType === "online" - ? this._renderOnlineContent() - : this.selectedNodeType === "offline" - ? this._renderOfflineContent() - : this._renderUnknownContent() - } + ${this.selectedNodeType === "online" + ? this._renderOnlineContent() + : this.selectedNodeType === "offline" + ? this._renderOfflineContent() + : this._renderUnknownContent()} ${this._renderLongIdleTimeNote()}
@@ -387,13 +377,11 @@ export class UpdateConnectionsDialog extends LitElement { @click=${this._executeUpdate} ?disabled=${this._isUpdating || this._updateCount === 0} > - ${ - this._isUpdating - ? html`${svg``}Updating...` - : buttonText - } + ${this._isUpdating + ? html`${svg``}Updating...` + : buttonText}
From dca4ecf4da0262440874e8969b76f43547f3ef0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= <938089+lboue@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:33:48 +0200 Subject: [PATCH 6/8] style(dashboard): reformat with pinned oxfmt 0.61.0 Local node_modules had a stale oxfmt 0.59.0 binary that formatted differently from the ^0.61.0 pinned in package.json, so CI's format-verify (installing fresh from the lockfile) flagged 31 files that passed locally. Reinstalling and reformatting syncs the tree with the pinned version. Co-Authored-By: Claude Sonnet 5 --- .../src/components/avsum-ptz-strip.ts | 209 +-- .../src/components/dialog-box/dialog-box.ts | 32 +- .../dialogs/acl/node-acl-add-dialog.ts | 64 +- .../dialogs/binding/node-binding-dialog.ts | 144 +- .../commission-node-dialog.ts | 48 +- .../commission-node-thread.ts | 42 +- .../commission-node-wifi.ts | 42 +- .../dialogs/dev/attribute-write-dialog.ts | 8 +- .../dialogs/dev/command-invoke-dialog.ts | 22 +- .../dialogs/settings/log-level-section.ts | 40 +- .../dialogs/settings/settings-dialog.ts | 256 +-- .../src/components/webrtc-stream-view.ts | 52 +- .../dashboard/src/pages/camera-overlay.ts | 297 ++-- .../clusters/access-control-commands.ts | 78 +- .../clusters/avsum-commands.ts | 397 ++--- .../clusters/basic-information-commands.ts | 8 +- .../clusters/binding-commands.ts | 40 +- .../clusters/chime-commands.ts | 88 +- .../clusters/closure-control-commands.ts | 310 ++-- .../clusters/icd-management-commands.ts | 74 +- .../dashboard/src/pages/components/header.ts | 112 +- .../src/pages/components/node-details.ts | 159 +- .../src/pages/matter-cluster-view.ts | 170 +- .../src/pages/matter-endpoint-view.ts | 20 +- .../src/pages/matter-network-view.ts | 66 +- .../dashboard/src/pages/matter-node-view.ts | 22 +- .../dashboard/src/pages/matter-server-view.ts | 20 +- .../src/pages/network/device-panel.ts | 38 +- .../src/pages/network/network-details.ts | 1376 +++++++++-------- .../src/pages/network/thread-graph.ts | 8 +- .../network/update-connections-dialog.ts | 116 +- 31 files changed, 2385 insertions(+), 1973 deletions(-) diff --git a/packages/dashboard/src/components/avsum-ptz-strip.ts b/packages/dashboard/src/components/avsum-ptz-strip.ts index 00c3c6d9..7de867f1 100644 --- a/packages/dashboard/src/components/avsum-ptz-strip.ts +++ b/packages/dashboard/src/components/avsum-ptz-strip.ts @@ -82,106 +82,115 @@ export class AvsumPtzStrip extends LitElement { return html`
- ${hasMech || features.dptz - ? html`
-
- - - this._move(effective, "tilt", this._step(e, 10)), - )} - > - - - - - this._move(effective, "pan", this._step(e, -10)), - )} - > - - - - - this._move(effective, "pan", this._step(e, 10)), - )} - > - - - - - this._move(effective, "tilt", this._step(e, -10)), - )} - > - - - -
-
- - this._move(effective, "zoom", this._step(e, 10)), - )} - > - - - - this._move(effective, "zoom", this._step(e, -10)), - )} - > - - -
- ${hasMech && features.dptz - ? html`
- - -
` - : nothing} - ${effective === "digital" && this.activeVideoStreamId === null - ? html`Start stream to enable DPTZ` - : nothing} -
` - : nothing} - ${features.mPresets && presets.length > 0 - ? html`
- ${presets.map( - p => html``, - )} -
` - : nothing} + ${ + hasMech || features.dptz + ? html`
+
+ + + this._move(effective, "tilt", this._step(e, 10)), + )} + > + + + + + this._move(effective, "pan", this._step(e, -10)), + )} + > + + + + + this._move(effective, "pan", this._step(e, 10)), + )} + > + + + + + this._move(effective, "tilt", this._step(e, -10)), + )} + > + + + +
+
+ + this._move(effective, "zoom", this._step(e, 10)), + )} + > + + + + this._move(effective, "zoom", this._step(e, -10)), + )} + > + + +
+ ${ + hasMech && features.dptz + ? html`
+ + +
` + : nothing + } + ${ + effective === "digital" && this.activeVideoStreamId === null + ? html`Start stream to enable DPTZ` + : nothing + } +
` + : nothing + } + ${ + features.mPresets && presets.length > 0 + ? html`
+ ${presets.map( + p => html``, + )} +
` + : nothing + }
${features.mPan && pos.pan !== null ? html`Pan ${this._fmt(pos.pan)}°` : nothing} ${features.mTilt && pos.tilt !== null ? html`Tilt ${this._fmt(pos.tilt)}°` : nothing} diff --git a/packages/dashboard/src/components/dialog-box/dialog-box.ts b/packages/dashboard/src/components/dialog-box/dialog-box.ts index b67200ad..344013f1 100644 --- a/packages/dashboard/src/components/dialog-box/dialog-box.ts +++ b/packages/dashboard/src/components/dialog-box/dialog-box.ts @@ -24,19 +24,27 @@ export class DialogBox extends LitElement { return html` ${params.title ? html`
${params.title}
` : ""} - ${params.text - ? html`
- ${params.asCodeBlock && typeof params.text === "string" - ? html`${params.text}` - : params.text} -
` - : ""} + ${ + params.text + ? html`
+ ${ + params.asCodeBlock && typeof params.text === "string" + ? html`${params.text}` + : params.text + } +
` + : "" + }
- ${this.type === "prompt" - ? html` - ${params.cancelText ?? "Cancel"} - ` - : ""} + ${ + this.type === "prompt" + ? html` + ${params.cancelText ?? "Cancel"} + ` + : "" + } ${params.confirmText ?? "OK"}
diff --git a/packages/dashboard/src/components/dialogs/acl/node-acl-add-dialog.ts b/packages/dashboard/src/components/dialogs/acl/node-acl-add-dialog.ts index 65f5ebfc..bd536d42 100644 --- a/packages/dashboard/src/components/dialogs/acl/node-acl-add-dialog.ts +++ b/packages/dashboard/src/components/dialogs/acl/node-acl-add-dialog.ts @@ -176,19 +176,21 @@ export class NodeAclAddDialog extends LitElement {
Subjects (nodes)
- ${this._subjects.length === 0 - ? html`none — add at least one` - : this._subjects.map(s => { - const known = this.client.nodes[nodeIdKey(s)]; - return html`${known ? getDeviceName(known) : "Node"} · ${s.toString()} - this._removeSubject(nodeIdKey(s))} - >`; - })} + ${ + this._subjects.length === 0 + ? html`none — add at least one` + : this._subjects.map(s => { + const known = this.client.nodes[nodeIdKey(s)]; + return html`${known ? getDeviceName(known) : "Node"} · ${s.toString()} + this._removeSubject(nodeIdKey(s))} + >`; + }) + }
Targets (optional — none means whole node)
- ${this._targets.length === 0 - ? html`whole node` - : this._targets.map( - (t, i) => - html`${t.endpoint != null ? `EP ${t.endpoint}` : "All endpoints"} - ${t.cluster != null - ? `· ${this._clusterLabel(t.cluster)}` - : "· all clusters"} - this._removeTarget(i)} - >`, - )} + ${ + this._targets.length === 0 + ? html`whole node` + : this._targets.map( + (t, i) => + html`${t.endpoint != null ? `EP ${t.endpoint}` : "All endpoints"} + ${ + t.cluster != null + ? `· ${this._clusterLabel(t.cluster)}` + : "· all clusters" + } + this._removeTarget(i)} + >`, + ) + }
All clusters (any eligible)
- ${split && split.bindable.length - ? html`
— Bindable —
- ${split.bindable.map( - c => - html`
${this._clusterLabel(c)}
`, - )}` - : nothing} - ${split && split.otherTarget.length - ? html`
— Other target clusters (⚠) —
- ${split.otherTarget.map( - c => - html`
${this._clusterLabel(c)}
`, - )}` - : nothing} + ${ + split && split.bindable.length + ? html`
— Bindable —
+ ${split.bindable.map( + c => + html`
${this._clusterLabel(c)}
`, + )}` + : nothing + } + ${ + split && split.otherTarget.length + ? html`
— Other target clusters (⚠) —
+ ${split.otherTarget.map( + c => + html`
${this._clusterLabel(c)}
`, + )}` + : nothing + }
Custom cluster id…
- ${this._clusterSelection === CUSTOM_CLUSTER - ? html` (this._customClusterInput = (e.target as HTMLInputElement).value)} - >` - : nothing} - ${nonBindable - ? html`
- ⚠ This cluster is not a client cluster on the source endpoint. The binding may not function — it - will be added anyway on your request. -
` - : nothing} + ${ + this._clusterSelection === CUSTOM_CLUSTER + ? html` (this._customClusterInput = (e.target as HTMLInputElement).value)} + >` + : nothing + } + ${ + nonBindable + ? html`
+ ⚠ This cluster is not a client cluster on the source endpoint. The binding may not function — + it will be added anyway on your request. +
` + : nothing + } `; } @@ -242,35 +250,39 @@ export class NodeBindingDialog extends LitElement { }} > - ${target - ? html` { - this._endpointInput = (e.target as HTMLSelectElement).value; - this._clusterSelection = ALL_CLUSTERS; - }} - > - ${endpoints.map(ep => { - const dt = getEndpointDeviceTypes(target, ep)[0]; - return html` -
EP ${ep}${dt ? ` · ${dt.label}` : ""}
-
`; - })} -
` - : html` (this._endpointInput = (e.target as HTMLInputElement).value)} - >`} + ${ + target + ? html` { + this._endpointInput = (e.target as HTMLSelectElement).value; + this._clusterSelection = ALL_CLUSTERS; + }} + > + ${endpoints.map(ep => { + const dt = getEndpointDeviceTypes(target, ep)[0]; + return html` +
EP ${ep}${dt ? ` · ${dt.label}` : ""}
+
`; + })} +
` + : html` (this._endpointInput = (e.target as HTMLInputElement).value)} + >` + } ${this._renderClusterField(target, endpoint)}
diff --git a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-dialog.ts b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-dialog.ts index b8ce95f3..56348dd1 100644 --- a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-dialog.ts +++ b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-dialog.ts @@ -35,29 +35,31 @@ export class ComissionNodeDialog extends LitElement { @node-commissioned=${this._nodeCommissioned} @request-settings=${this._requestSettings} > - ${!this._mode - ? html` - Commission new WiFi device - Commission new Thread device - Commission existing device - ` - : this._mode === "wifi" - ? html` ` - : this._mode === "thread" - ? html` ` - : html` `} + ${ + !this._mode + ? html` + Commission new WiFi device + Commission new Thread device + Commission existing device + ` + : this._mode === "wifi" + ? html` ` + : this._mode === "thread" + ? html` ` + : html` ` + }
Cancel diff --git a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-thread.ts b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-thread.ts index e6100bbf..7dec1569 100644 --- a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-thread.ts +++ b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-thread.ts @@ -140,26 +140,28 @@ export class CommissionNodeThread extends LitElement { Edit in Settings
- ${showPicker - ? html` { - this._selectedId = (e.target as HTMLSelectElement).value; - }} - > - ${selectable.map( - entry => html` - -
${entry.networkName || entry.id}
-
- `, - )} -
-
-
` - : nothing} + ${ + showPicker + ? html` { + this._selectedId = (e.target as HTMLSelectElement).value; + }} + > + ${selectable.map( + entry => html` + +
${entry.networkName || entry.id}
+
+ `, + )} +
+
+
` + : nothing + }

diff --git a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-wifi.ts b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-wifi.ts index 57bd3d2c..7ab6b6d4 100644 --- a/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-wifi.ts +++ b/packages/dashboard/src/components/dialogs/commission-node-dialog/commission-node-wifi.ts @@ -145,26 +145,28 @@ export class CommissionNodeWifi extends LitElement { Edit in Settings - ${showPicker - ? html` { - this._selectedId = (e.target as HTMLSelectElement).value; - }} - > - ${selectable.map( - entry => html` - -
${entry.ssid || entry.id}
-
- `, - )} -
-
-
` - : nothing} + ${ + showPicker + ? html` { + this._selectedId = (e.target as HTMLSelectElement).value; + }} + > + ${selectable.map( + entry => html` + +
${entry.ssid || entry.id}
+
+ `, + )} +
+
+
` + : nothing + }

diff --git a/packages/dashboard/src/components/dialogs/dev/attribute-write-dialog.ts b/packages/dashboard/src/components/dialogs/dev/attribute-write-dialog.ts index c7d0c511..802b7d1c 100644 --- a/packages/dashboard/src/components/dialogs/dev/attribute-write-dialog.ts +++ b/packages/dashboard/src/components/dialogs/dev/attribute-write-dialog.ts @@ -89,9 +89,11 @@ export class AttributeWriteDialog extends LitElement { aria-describedby="write-path${this._error ? " write-error" : ""}" rows="10" > - ${this._error - ? html`` - : nothing} + ${ + this._error + ? html`` + : nothing + }
Cancel diff --git a/packages/dashboard/src/components/dialogs/dev/command-invoke-dialog.ts b/packages/dashboard/src/components/dialogs/dev/command-invoke-dialog.ts index 0a99f4ca..3d2d5478 100644 --- a/packages/dashboard/src/components/dialogs/dev/command-invoke-dialog.ts +++ b/packages/dashboard/src/components/dialogs/dev/command-invoke-dialog.ts @@ -112,16 +112,20 @@ export class CommandInvokeDialog extends LitElement { aria-describedby="invoke-path${this._error ? " invoke-error" : ""}" rows="8" > - ${this._error - ? html`` - : nothing} + ${ + this._error + ? html`` + : nothing + } ${this._success ? html`
Success
` : nothing} - ${this._response !== null - ? html` - -
${this._response}
- ` - : nothing} + ${ + this._response !== null + ? html` + +
${this._response}
+ ` + : nothing + }
Close diff --git a/packages/dashboard/src/components/dialogs/settings/log-level-section.ts b/packages/dashboard/src/components/dialogs/settings/log-level-section.ts index ff1d5b76..b4d03550 100644 --- a/packages/dashboard/src/components/dialogs/settings/log-level-section.ts +++ b/packages/dashboard/src/components/dialogs/settings/log-level-section.ts @@ -107,25 +107,27 @@ export class LogLevelSection extends LitElement { )}
- ${this._fileLevel !== null - ? html` -
- - - ${LOG_LEVELS.map( - level => html` - -
${level.label}
-
- `, - )} -
-
- ` - : nothing} + ${ + this._fileLevel !== null + ? html` +
+ + + ${LOG_LEVELS.map( + level => html` + +
${level.label}
+
+ `, + )} +
+
+ ` + : nothing + }
this._apply())} ?disabled=${this._applying}> ${this._applying ? "Applying..." : "Apply"} diff --git a/packages/dashboard/src/components/dialogs/settings/settings-dialog.ts b/packages/dashboard/src/components/dialogs/settings/settings-dialog.ts index bb1c617b..98622d1a 100644 --- a/packages/dashboard/src/components/dialogs/settings/settings-dialog.ts +++ b/packages/dashboard/src/components/dialogs/settings/settings-dialog.ts @@ -311,9 +311,11 @@ export class SettingsDialog extends LitElement { Add
- ${entries.length === 0 && !this._wifiAdding - ? html`

No WiFi credentials configured

` - : nothing} + ${ + entries.length === 0 && !this._wifiAdding + ? html`

No WiFi credentials configured

` + : nothing + } ${entries.map(entry => this._renderWifiEntry(entry))} ${this._wifiAdding ? this._renderWifiForm(undefined, "") : nothing} @@ -356,19 +358,21 @@ export class SettingsDialog extends LitElement { > - ${isDefault - ? html` this._removeWifi("default"))} - .disabled=${this._credLoading} - >Clear` - : html` this._removeWifi(entry.id))} - .disabled=${this._credLoading} - > - - `} + ${ + isDefault + ? html` this._removeWifi("default"))} + .disabled=${this._credLoading} + >Clear` + : html` this._removeWifi(entry.id))} + .disabled=${this._credLoading} + > + + ` + } `; @@ -378,14 +382,16 @@ export class SettingsDialog extends LitElement { const isAdd = id === undefined; return html`
- ${isAdd - ? html`` - : nothing} + ${ + isAdd + ? html`` + : nothing + } Add
- ${entries.length === 0 && !this._threadAdding - ? html`

No Thread datasets configured

` - : nothing} + ${ + entries.length === 0 && !this._threadAdding + ? html`

No Thread datasets configured

` + : nothing + } ${entries.map(entry => this._renderThreadEntry(entry))} ${this._threadAdding ? this._renderThreadForm(undefined) : nothing} @@ -477,19 +485,21 @@ export class SettingsDialog extends LitElement { > - ${isDefault - ? html` this._removeThread("default"))} - .disabled=${this._credLoading} - >Clear` - : html` this._removeThread(entry.id))} - .disabled=${this._credLoading} - > - - `} + ${ + isDefault + ? html` this._removeThread("default"))} + .disabled=${this._credLoading} + >Clear` + : html` this._removeThread(entry.id))} + .disabled=${this._credLoading} + > + + ` + } `; @@ -499,14 +509,16 @@ export class SettingsDialog extends LitElement { const isAdd = id === undefined; return html`
- ${isAdd - ? html`` - : nothing} + ${ + isAdd + ? html`` + : nothing + } WiFi - ${this.client.serverInfo.wifi_credentials_set - ? html`${this.client.serverInfo.wifi_ssid}` - : html`Not configured`} + ${ + this.client.serverInfo.wifi_credentials_set + ? html`${this.client.serverInfo.wifi_ssid}` + : html`Not configured` + }
this._toggleExpand("wifi")} .disabled=${this._credLoading} >Edit - ${this._expandedRow === "wifi" - ? html`
- -
+ ${ + this._expandedRow === "wifi" + ? html`
- - - -
- ${this._renderCredError()} -
- Cancel - ${this.client.serverInfo.wifi_credentials_set - ? html` this._removeWifi())} - .disabled=${this._credLoading} - >Remove` - : nothing} - this._saveWifi())} .disabled=${this._credLoading} - >Save -
-
` - : nothing} +
+ + + + +
+ ${this._renderCredError()} +
+ Cancel + ${ + this.client.serverInfo.wifi_credentials_set + ? html` this._removeWifi())} + .disabled=${this._credLoading} + >Remove` + : nothing + } + this._saveWifi())} + .disabled=${this._credLoading} + >Save +
+
` + : nothing + } `; } @@ -586,42 +606,48 @@ export class SettingsDialog extends LitElement {
Thread - ${this.client.serverInfo.thread_credentials_set - ? html`Thread network set` - : html`Not configured`} + ${ + this.client.serverInfo.thread_credentials_set + ? html`Thread network set` + : html`Not configured` + }
this._toggleExpand("thread")} .disabled=${this._credLoading} >Edit - ${this._expandedRow === "thread" - ? html`
- - ${this._renderCredError()} -
- Cancel - ${this.client.serverInfo.thread_credentials_set - ? html` this._removeThread())} - .disabled=${this._credLoading} - >Remove` - : nothing} - this._saveThread())} + ${ + this._expandedRow === "thread" + ? html`
+ Save -
-
` - : nothing} + > + ${this._renderCredError()} +
+ Cancel + ${ + this.client.serverInfo.thread_credentials_set + ? html` this._removeThread())} + .disabled=${this._credLoading} + >Remove` + : nothing + } + this._saveThread())} + .disabled=${this._credLoading} + >Save +
+
` + : nothing + } `; } diff --git a/packages/dashboard/src/components/webrtc-stream-view.ts b/packages/dashboard/src/components/webrtc-stream-view.ts index 3dc5c0da..95044c20 100644 --- a/packages/dashboard/src/components/webrtc-stream-view.ts +++ b/packages/dashboard/src/components/webrtc-stream-view.ts @@ -299,28 +299,36 @@ export class WebRtcStreamView extends LitElement { disableremoteplayback ?hidden=${this._state !== "streaming"} > - ${this._state === "idle" - ? html`
- -
- ${this.liveViewSupported - ? html`Click Start to begin streaming` - : "Live view not supported — use Snapshot"} -
-
` - : null} - ${this._state === "connecting" - ? html`
-
-
Connecting…
-
` - : null} - ${this._state === "error" - ? html`
- -
${this._errorMessage ?? "Stream error"}
-
` - : null} + ${ + this._state === "idle" + ? html`
+ +
+ ${ + this.liveViewSupported + ? html`Click Start to begin streaming` + : "Live view not supported — use Snapshot" + } +
+
` + : null + } + ${ + this._state === "connecting" + ? html`
+
+
Connecting…
+
` + : null + } + ${ + this._state === "error" + ? html`
+ +
${this._errorMessage ?? "Stream error"}
+
` + : null + } `; } diff --git a/packages/dashboard/src/pages/camera-overlay.ts b/packages/dashboard/src/pages/camera-overlay.ts index 0cd34c2a..48fe30c8 100644 --- a/packages/dashboard/src/pages/camera-overlay.ts +++ b/packages/dashboard/src/pages/camera-overlay.ts @@ -297,147 +297,180 @@ export class CameraOverlay extends LitElement { Node ${this.nodeId} • Endpoint ${this.endpointId} - ${this._avsumPresent() - ? html`` - : nothing} -
- ${this.client - ? html`` - : html`
No Matter client available.
`} - ${this._snapshotDataUri - ? html` -
- Snapshot - { - this._snapshotDataUri = null; - }} - aria-label="Close snapshot" - > - - -
- ` - : nothing} + .activeVideoStreamId=${this._activeVideoStreamId} + .sensorSize=${this._getSensorSize()} + >` + : nothing + } +
+ ${ + this.client + ? html`` + : html`
No Matter client available.
` + } + ${ + this._snapshotDataUri + ? html` +
+ Snapshot + { + this._snapshotDataUri = null; + }} + aria-label="Close snapshot" + > + + +
+ ` + : nothing + } ${this._snapshotError ? html`
${this._snapshotError}
` : nothing}
${this._closing ? html`Closing…` : nothing} - ${!this._closing && this._state === "connecting" - ? html`Waiting for camera response…` - : nothing} - ${!this._closing && this._state === "error" && this._errorMessage - ? html`${this._errorMessage}` - : nothing} - ${canStart && this._resolutions.length > 0 - ? html` - Waiting for camera response…` + : nothing + } + ${ + !this._closing && this._state === "error" && this._errorMessage + ? html`${this._errorMessage}` + : nothing + } + ${ + canStart && this._resolutions.length > 0 + ? html` + + ${this._resolutions.map( + r => html` + +
${r.width}×${r.height}
+
+ `, + )} +
+ ` + : nothing + } + ${ + idleOrError && this._snapshotSupported && this._snapshotResolutions.length > 0 + ? html` + + ${this._snapshotResolutions.map( + r => html` + +
${r.width}×${r.height}
+
+ `, + )} +
+ ` + : nothing + } + ${ + canStart && this._avsmFeatures().wmark + ? html`` + : nothing + } + ${ + canStart && this._avsmFeatures().osd + ? html`` + : nothing + } + ${ + canStart + ? html` - ${this._resolutions.map( - r => html` - -
${r.width}×${r.height}
-
- `, - )} -
- ` - : nothing} - ${idleOrError && this._snapshotSupported && this._snapshotResolutions.length > 0 - ? html` - ` + : nothing + } + ${ + this._state === "streaming" + ? html`End + + + ${this._muted ? "Unmute" : "Mute"} + ` + : nothing + } + ${ + this._snapshotSupported + ? html` - ${this._snapshotResolutions.map( - r => html` - -
${r.width}×${r.height}
-
- `, - )} -
- ` - : nothing} - ${canStart && this._avsmFeatures().wmark - ? html`` - : nothing} - ${canStart && this._avsmFeatures().osd - ? html`` - : nothing} - ${canStart - ? html` - ${this._state === "error" ? "Retry" : "Start"} - ` - : nothing} - ${this._state === "streaming" - ? html`End - - - ${this._muted ? "Unmute" : "Mute"} + + ${this._snapshotBusy ? "Capturing…" : "Snapshot"} ` - : nothing} - ${this._snapshotSupported - ? html` - - ${this._snapshotBusy ? "Capturing…" : "Snapshot"} - ` - : nothing} + : nothing + } Close
diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts index d3b98145..bfe58fbf 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts @@ -199,19 +199,21 @@ class AccessControlClusterCommands extends BaseClusterCommands {
Access Control — ACL Entries (${entries.length})
- ${overPrivilegedKeys.size >= 2 - ? html`` - : nothing} + ${ + overPrivilegedKeys.size >= 2 + ? html`` + : nothing + } @@ -248,35 +250,39 @@ class AccessControlClusterCommands extends BaseClusterCommands { ${PRIVILEGE_NAMES[entry.privilege] ?? entry.privilege} · ${entry.privilege} - ${overPrivileged - ? html`
- this._fix(new Set([aclEntryKey(entry)])))} - >Fix → Operate -
` - : nothing} + ${ + overPrivileged + ? html`
+ this._fix(new Set([aclEntryKey(entry)])))} + >Fix → Operate +
` + : nothing + } `; diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/avsum-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/avsum-commands.ts index a9eed3ee..98436790 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/avsum-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/avsum-commands.ts @@ -75,198 +75,221 @@ class AvsumClusterCommands extends BaseClusterCommands { Camera AV Settings — Pan / Tilt / Zoom
- ${features.mPan && pos.pan !== null - ? html`Pan ${this._fmtDeg(pos.pan)}` - : nothing} - ${features.mTilt && pos.tilt !== null - ? html`Tilt ${this._fmtDeg(pos.tilt)}` - : nothing} + ${ + features.mPan && pos.pan !== null + ? html`Pan ${this._fmtDeg(pos.pan)}` + : nothing + } + ${ + features.mTilt && pos.tilt !== null + ? html`Tilt ${this._fmtDeg(pos.tilt)}` + : nothing + } ${features.mZoom && pos.zoom !== null ? html`Zoom ${pos.zoom}×` : nothing} - ${movement !== "unknown" - ? html`${movement === "moving" ? "Moving…" : "Idle"}` - : nothing} - ${features.mPan && ranges.panMin !== null && ranges.panMax !== null - ? html`P [${ranges.panMin}°, ${ranges.panMax}°]` - : nothing} - ${features.mTilt && ranges.tiltMin !== null && ranges.tiltMax !== null - ? html`T [${ranges.tiltMin}°, ${ranges.tiltMax}°]` - : nothing} - ${features.mZoom && ranges.zoomMax !== null - ? html`Z [1×, ${ranges.zoomMax}×]` - : nothing} + ${ + movement !== "unknown" + ? html`${movement === "moving" ? "Moving…" : "Idle"}` + : nothing + } + ${ + features.mPan && ranges.panMin !== null && ranges.panMax !== null + ? html`P [${ranges.panMin}°, ${ranges.panMax}°]` + : nothing + } + ${ + features.mTilt && ranges.tiltMin !== null && ranges.tiltMax !== null + ? html`T [${ranges.tiltMin}°, ${ranges.tiltMax}°]` + : nothing + } + ${ + features.mZoom && ranges.zoomMax !== null + ? html`Z [1×, ${ranges.zoomMax}×]` + : nothing + }
- ${features.mPan || features.mTilt || features.mZoom - ? html`
-
- - - this._move({ tiltDelta: this._stepFromEvent(e, 10) }), - )} - > - - - - - this._move({ panDelta: this._stepFromEvent(e, -10) }), - )} - > - - - - - this._move({ panDelta: this._stepFromEvent(e, 10) }), - )} - > - - - - - this._move({ tiltDelta: this._stepFromEvent(e, -10) }), - )} - > - - - -
- ${features.mZoom - ? html`
- - this._move({ zoomDelta: this._stepFromEvent(e, 10) }), - )} - > - - - zoom - - this._move({ zoomDelta: this._stepFromEvent(e, -10) }), - )} - > - - -
` - : nothing} -
` - : nothing} - ${this._toast ? html`
${this._toast}
` : nothing} - ${features.mPresets - ? (() => { - const { items, max } = readPresets(this.node, this.endpoint); - return html` -
-
- Presets - ${items.length} / ${max} -
-
- ${items.map( - p => html``, + ${ + features.mPan || features.mTilt || features.mZoom + ? html`
+
+ + + this._move({ tiltDelta: this._stepFromEvent(e, 10) }), )} - ${items.length === 0 - ? html`No presets saved.` - : nothing} -
-
- Manage presets… - ${items.map( - p => html`
- #${p.presetId} - ${p.name} - - p=${this._fmtDeg(p.settings.pan ?? 0)} · - t=${this._fmtDeg(p.settings.tilt ?? 0)} · - z=${p.settings.zoom ?? 1}× - - - this._savePresetUpdate(p.presetId))} - > - - - - this._renamePreset(p.presetId, p.name), - )} - > - - - - this._removePreset(p.presetId, p.name), - )} - > - - -
`, + > + + + + + this._move({ panDelta: this._stepFromEvent(e, -10) }), )} -
- - (this._newPresetName = (e.target as HTMLInputElement).value)} - /> - = max} - @click=${handleAsync(() => this._saveNewPreset())} - > - Save current MPTZ - -
-
+ > + + + + + this._move({ panDelta: this._stepFromEvent(e, 10) }), + )} + > + + + + + this._move({ tiltDelta: this._stepFromEvent(e, -10) }), + )} + > + + +
- `; - })() - : nothing} - ${features.dptz - ? (() => { - const streams = readDptzStreams(this.node, this.endpoint); - return html`
- Digital PTZ: ${streams.length} active stream${streams.length === 1 ? "" : "s"} - (controls available during live view) -
`; - })() - : nothing} + ${ + features.mZoom + ? html`
+ + this._move({ zoomDelta: this._stepFromEvent(e, 10) }), + )} + > + + + zoom + + this._move({ zoomDelta: this._stepFromEvent(e, -10) }), + )} + > + + +
` + : nothing + } +
` + : nothing + } + ${this._toast ? html`
${this._toast}
` : nothing} + ${ + features.mPresets + ? (() => { + const { items, max } = readPresets(this.node, this.endpoint); + return html` +
+
+ Presets + ${items.length} / ${max} +
+
+ ${items.map( + p => html``, + )} + ${ + items.length === 0 + ? html`No presets saved.` + : nothing + } +
+
+ Manage presets… + ${items.map( + p => html`
+ #${p.presetId} + ${p.name} + + p=${this._fmtDeg(p.settings.pan ?? 0)} · + t=${this._fmtDeg(p.settings.tilt ?? 0)} · + z=${p.settings.zoom ?? 1}× + + + this._savePresetUpdate(p.presetId))} + > + + + + this._renamePreset(p.presetId, p.name), + )} + > + + + + this._removePreset(p.presetId, p.name), + )} + > + + +
`, + )} +
+ + (this._newPresetName = (e.target as HTMLInputElement).value)} + /> + = max} + @click=${handleAsync(() => this._saveNewPreset())} + > + Save current MPTZ + +
+
+
+ `; + })() + : nothing + } + ${ + features.dptz + ? (() => { + const streams = readDptzStreams(this.node, this.endpoint); + return html`
+ Digital PTZ: ${streams.length} active + stream${streams.length === 1 ? "" : "s"} + (controls available during live view) +
`; + })() + : nothing + }
`; diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/basic-information-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/basic-information-commands.ts index c3a18f14..c36b961f 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/basic-information-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/basic-information-commands.ts @@ -56,9 +56,11 @@ export class BasicInformationClusterCommands extends BaseClusterCommands {
Node Label
- ${!this._isNodeAvailable - ? html`
Node is offline - cannot edit label
` - : nothing} + ${ + !this._isNodeAvailable + ? html`
Node is offline - cannot edit label
` + : nothing + }
Bindings (${bindings.length})
- ${bindings.length === 0 - ? html`
No bindings on this endpoint.
` - : html`
${AUTH_MODE_NAMES[entry.authMode] ?? entry.authMode} ${this._renderSubjects(entry)} ${this._renderTargets(entry)} ${this._renderRelationship(rel)} - ${protectedEntry - ? html`` - : html` this._delete(entry))} - > - delete - `} + ${ + protectedEntry + ? html`` + : html` this._delete(entry))} + > + delete + ` + }
- - - - - - - - - - - ${bindings.map((b, i) => this._row(b, i))} - -
Target nodeEndpointClusterACL on target
`} + ${ + bindings.length === 0 + ? html`
No bindings on this endpoint.
` + : html` + + + + + + + + + + + ${bindings.map((b, i) => this._row(b, i))} + +
Target nodeEndpointClusterACL on target
` + } this._openAdd())} @@ -187,9 +189,9 @@ class BindingClusterCommands extends BaseClusterCommands { ${name}${b.node != null - ? html` · ${b.node.toString()}` - : nothing}${name}${ + b.node != null ? html` · ${b.node.toString()}` : nothing + } ${endpointText} diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/chime-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/chime-commands.ts index 7c33447c..646f4ef4 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/chime-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/chime-commands.ts @@ -130,48 +130,56 @@ class ChimeClusterCommands extends BaseClusterCommands {
Installed sounds (${sounds.length})
- ${sounds.length === 0 - ? html`
No sounds installed.
` - : sounds.map( - s => html` -
- setSelected(this.client, this.node.node_id, this.endpoint, s.chimeId), - )} - > - #${s.chimeId} - ${s.name} - ${s.chimeId === selected - ? html`✓ selected` - : nothing} - - ${showPerRowPlay - ? html` { - e.stopPropagation(); - return chimePlay( - this.client, - this.node.node_id, - this.endpoint, - s.chimeId, - ); - })} - > - - ` - : nothing} -
- `, - )} + ${ + sounds.length === 0 + ? html`
No sounds installed.
` + : sounds.map( + s => html` +
+ setSelected(this.client, this.node.node_id, this.endpoint, s.chimeId), + )} + > + #${s.chimeId} + ${s.name} + ${ + s.chimeId === selected + ? html`✓ selected` + : nothing + } + + ${ + showPerRowPlay + ? html` { + e.stopPropagation(); + return chimePlay( + this.client, + this.node.node_id, + this.endpoint, + s.chimeId, + ); + })} + > + + ` + : nothing + } +
+ `, + ) + }
- ${showLastPlayed - ? html`
- Last played: ${lastSoundName} · ${new Date(this._lastPlayed!.at).toLocaleTimeString()} -
` - : nothing} + ${ + showLastPlayed + ? html`
+ Last played: ${lastSoundName} · ${new Date(this._lastPlayed!.at).toLocaleTimeString()} +
` + : nothing + }
`; diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/closure-control-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/closure-control-commands.ts index 7f2cfaf7..f4b2693f 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/closure-control-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/closure-control-commands.ts @@ -92,29 +92,35 @@ class ClosureControlClusterCommands extends BaseClusterCommands {
- ${mainState !== null - ? (MAIN_STATE_LABELS[mainState] ?? `Unknown (${mainState})`) - : "Unknown"} + ${ + mainState !== null + ? (MAIN_STATE_LABELS[mainState] ?? `Unknown (${mainState})`) + : "Unknown" + } - ${countdownTime !== null - ? html`~${countdownTime}s remaining` - : nothing} + ${ + countdownTime !== null + ? html`~${countdownTime}s remaining` + : nothing + }
- ${errors.length > 0 - ? html` -
- ${errors.map( - e => html` - - - ${CLOSURE_ERROR_LABELS[e] ?? `Error ${e}`} - - `, - )} -
- ` - : nothing} + ${ + errors.length > 0 + ? html` +
+ ${errors.map( + e => html` + + + ${CLOSURE_ERROR_LABELS[e] ?? `Error ${e}`} + + `, + )} +
+ ` + : nothing + }
@@ -128,103 +134,117 @@ class ClosureControlClusterCommands extends BaseClusterCommands {
- ${!features.instantaneous - ? html` this._handleStop())}> - - Stop - ` - : nothing} - ${features.calibration - ? html` this._handleCalibrate())}> - - Calibrate - ` - : nothing} + ${ + !features.instantaneous + ? html` this._handleStop())}> + + Stop + ` + : nothing + } + ${ + features.calibration + ? html` this._handleCalibrate())}> + + Calibrate + ` + : nothing + }
Move to
- ${features.positioning - ? html` - { - this._moveToPosition = (e.target as HTMLSelectElement).value; - }} - > - -
(unchanged)
-
- ${Object.entries(TARGET_POSITION_LABELS) - .filter( - ([id]) => - (id !== "2" || features.pedestrian) && - (id !== "3" || features.ventilation), - ) - .map( + ${ + features.positioning + ? html` + { + this._moveToPosition = (e.target as HTMLSelectElement).value; + }} + > + +
(unchanged)
+
+ ${Object.entries(TARGET_POSITION_LABELS) + .filter( + ([id]) => + (id !== "2" || features.pedestrian) && + (id !== "3" || features.ventilation), + ) + .map( + ([id, label]) => html` + +
${label}
+
+ `, + )} +
+ ` + : nothing + } + ${ + features.motionLatching && + (latchControlModes.remoteLatching || latchControlModes.remoteUnlatching) + ? html` + { + this._moveToLatch = (e.target as HTMLSelectElement).value; + }} + > + +
(unchanged)
+
+ ${ + latchControlModes.remoteLatching + ? html` +
Latch
+
` + : nothing + } + ${ + latchControlModes.remoteUnlatching + ? html` +
Unlatch
+
` + : nothing + } +
+ ` + : nothing + } + ${ + features.speed + ? html` + { + this._moveToSpeed = (e.target as HTMLSelectElement).value; + }} + > + +
(unchanged)
+
+ ${Object.entries(SPEED_LABELS).map( ([id, label]) => html`
${label}
`, )} -
- ` - : nothing} - ${features.motionLatching && - (latchControlModes.remoteLatching || latchControlModes.remoteUnlatching) - ? html` - { - this._moveToLatch = (e.target as HTMLSelectElement).value; - }} - > - -
(unchanged)
-
- ${latchControlModes.remoteLatching - ? html` -
Latch
-
` - : nothing} - ${latchControlModes.remoteUnlatching - ? html` -
Unlatch
-
` - : nothing} -
- ` - : nothing} - ${features.speed - ? html` - { - this._moveToSpeed = (e.target as HTMLSelectElement).value; - }} - > - -
(unchanged)
-
- ${Object.entries(SPEED_LABELS).map( - ([id, label]) => html` - -
${label}
-
- `, - )} -
- ` - : nothing} +
+ ` + : nothing + } this._handleMoveTo())} > Move @@ -241,40 +261,52 @@ class ClosureControlClusterCommands extends BaseClusterCommands { const positionLabels = "secureState" in state ? CURRENT_POSITION_LABELS : TARGET_POSITION_LABELS; return html`
- ${features.positioning - ? html`
- Position: - - ${state.position !== null - ? (positionLabels[state.position] ?? `#${state.position}`) - : "Unknown"} - -
` - : nothing} - ${features.motionLatching - ? html`
- Latch: - ${state.latch === null ? "Unknown" : state.latch ? "Latched" : "Unlatched"} -
` - : nothing} - ${features.speed - ? html`
- Speed: - ${state.speed !== null - ? (SPEED_LABELS[state.speed] ?? `#${state.speed}`) - : "Unknown"} -
` - : nothing} - ${"secureState" in state - ? html`
- Secure: - - ${state.secureState === null ? "Unknown" : state.secureState ? "Secure" : "Not secure"} - -
` - : nothing} + ${ + features.positioning + ? html`
+ Position: + + ${ + state.position !== null + ? (positionLabels[state.position] ?? `#${state.position}`) + : "Unknown" + } + +
` + : nothing + } + ${ + features.motionLatching + ? html`
+ Latch: + ${state.latch === null ? "Unknown" : state.latch ? "Latched" : "Unlatched"} +
` + : nothing + } + ${ + features.speed + ? html`
+ Speed: + ${ + state.speed !== null + ? (SPEED_LABELS[state.speed] ?? `#${state.speed}`) + : "Unknown" + } +
` + : nothing + } + ${ + "secureState" in state + ? html`
+ Secure: + + ${state.secureState === null ? "Unknown" : state.secureState ? "Secure" : "Not secure"} + +
` + : nothing + }
`; } diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts index 6b71c957..4879e360 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts @@ -165,23 +165,27 @@ export class IcdManagementClusterCommands extends BaseClusterCommands { Power & Sleep (ICD)

This device saves power by sleeping between short check-in windows.

- ${info.operatingMode === "LIT" - ? html`

- This device is currently in Battery Saver Mode: any action you trigger (commands, - reads, re-subscriptions) may take up to ${this._idleText} while the device sleeps. - Updates reported by the device itself (e.g. sensor changes) are not delayed — the device - wakes up on its own to report them. - ${this.node.available - ? nothing - : html`
The device is currently offline — reconnecting on its own can take up to - ${this._idleText}.`} -

` - : html`

- Current mode: Standard — the device sleeps between short check-ins and typically - reacts within seconds to a few minutes. -

`} + ${ + info.operatingMode === "LIT" + ? html`

+ This device is currently in Battery Saver Mode: any action you trigger + (commands, reads, re-subscriptions) may take up to ${this._idleText} while the + device sleeps. Updates reported by the device itself (e.g. sensor changes) are not + delayed — the device wakes up on its own to report them. + ${ + this.node.available + ? nothing + : html`
The device is currently offline — reconnecting on its own can take + up to ${this._idleText}.` + } +

` + : html`

+ Current mode: Standard — the device sleeps between short check-ins and + typically reacts within seconds to a few minutes. +

` + } ${info.features.userActiveModeTrigger ? this._renderWakeHint(info) : nothing} ${info.features.longIdleTimeSupport || this._registered ? this._renderIcdManagement() : nothing}
@@ -268,10 +272,12 @@ export class IcdManagementClusterCommands extends BaseClusterCommands { Every ecosystem (e.g. Apple, Google) this device is paired with must support Battery Saver Mode (called Matter LIT (Long Idle Time) ICD). You can switch back to Standard Mode later as long as no other ecosystem is registered for it. - ${!single && !this._registered - ? html` This device is already paired with ${this._commissionedFabrics - 1} other - ecosystem(s).` - : nothing} + ${ + !single && !this._registered + ? html` This device is already paired with ${this._commissionedFabrics - 1} other + ecosystem(s).` + : nothing + }

Resync state - ${this._busy - ? html` - ${this._busyLabel}` - : nothing} + ${ + this._busy + ? html` + ${this._busyLabel}` + : nothing + }
`; } @@ -314,13 +322,15 @@ export class IcdManagementClusterCommands extends BaseClusterCommands {

Important: - ${single - ? html`every ecosystem (e.g. Apple, Google) you pair this device with later must support Battery - Saver Mode (called Matter LIT (Long Idle Time) ICD). In an ecosystem without support the device - will appear offline or unresponsive.` - : html`this device is already paired with ${this._commissionedFabrics - 1} other - ecosystem(s). All of them must support Battery Saver Mode (called Matter LIT (Long Idle Time) - ICD), otherwise the device will appear offline or unresponsive in those ecosystems.`} + ${ + single + ? html`every ecosystem (e.g. Apple, Google) you pair this device with later must support Battery + Saver Mode (called Matter LIT (Long Idle Time) ICD). In an ecosystem without support the + device will appear offline or unresponsive.` + : html`this device is already paired with ${this._commissionedFabrics - 1} other + ecosystem(s). All of them must support Battery Saver Mode (called Matter LIT (Long Idle + Time) ICD), otherwise the device will appear offline or unresponsive in those ecosystems.` + }

You can switch back to Standard Mode later as long as no other ecosystem is registered for Battery Saver diff --git a/packages/dashboard/src/pages/components/header.ts b/packages/dashboard/src/pages/components/header.ts index e52d4acd..5c1f785e 100644 --- a/packages/dashboard/src/pages/components/header.ts +++ b/packages/dashboard/src/pages/components/header.ts @@ -129,22 +129,26 @@ export class DashboardHeader extends LitElement { aria-current=${this.activeView === "nodes" ? "page" : nothing} >Nodes - ${showThreadTab - ? html`Thread` - : nothing} - ${showWifiTab - ? html`WiFi` - : nothing} + ${ + showThreadTab + ? html`Thread` + : nothing + } + ${ + showWifiTab + ? html`WiFi` + : nothing + } `; } @@ -153,14 +157,16 @@ export class DashboardHeader extends LitElement { return html`

- ${this.backButton - ? html` - - - - - ` - : ""} + ${ + this.backButton + ? html` + + + + + ` + : "" + }
${this.title ?? ""}
${this._renderNavTabs()} @@ -173,38 +179,44 @@ export class DashboardHeader extends LitElement { `; })} - ${this._devMode && this.client - ? html` - - ` - : nothing} + ${ + this._devMode && this.client + ? html` + + ` + : nothing + } - ${this.client - ? html` - - - - ` - : nothing} + ${ + this.client + ? html` + + + + ` + : nothing + } - ${this.client && !this.client.isProduction - ? html` - - - - ` - : nothing} + ${ + this.client && !this.client.isProduction + ? html` + + + + ` + : nothing + }
`; diff --git a/packages/dashboard/src/pages/components/node-details.ts b/packages/dashboard/src/pages/components/node-details.ts index 5f28ec91..ebef1ec0 100644 --- a/packages/dashboard/src/pages/components/node-details.ts +++ b/packages/dashboard/src/pages/components/node-details.ts @@ -103,26 +103,30 @@ export class NodeDetails extends LitElement {
${this.node.nodeLabel || "Node Info"} - ${this.node.available - ? html` - this._editNodeLabel()} - aria-label="Edit node label" - title="Edit node label" - > - - - ` - : nothing} + ${ + this.node.available + ? html` + this._editNodeLabel()} + aria-label="Edit node label" + title="Edit node label" + > + + + ` + : nothing + } ${this.node.available ? nothing : html` OFFLINE`} - ${badge - ? html`ICD` - : nothing} + ${ + badge + ? html`ICD` + : nothing + }
@@ -136,54 +140,62 @@ export class NodeDetails extends LitElement {
Is bridge: ${this.node.is_bridge}
Serialnumber: ${this.node.serialNumber}
- ${this.node.matter_version - ? html`
- Matter version: ${this.node.matter_version} -
` - : nothing} - ${this.node.is_bridge - ? "" - : html`
- All device types: ${getNodeDeviceTypes(this.node) - .map(deviceType => { - return deviceType.label; - }) - .join(" / ")} -
`} + ${ + this.node.matter_version + ? html`
+ Matter version: ${this.node.matter_version} +
` + : nothing + } + ${ + this.node.is_bridge + ? "" + : html`
+ All device types: ${getNodeDeviceTypes(this.node) + .map(deviceType => { + return deviceType.label; + }) + .join(" / ")} +
` + }
this._reinterview())} >Interview - ${this._updateInitiated - ? html` Checking for updates` - : (this.node.updateState ?? 0) > 1 - ? html` ${getUpdateStateLabel( - this.node.updateState!, - this.node.updateStateProgress, - )}` - : html` this._searchUpdate())} - >Update`} - ${isCamera - ? html` - this._openCameraOverlay()} - ?disabled=${!this.node.available} - > - ${isAudioOnly ? "Listen" : isLiveViewCamera ? "Live View" : "Snapshot"} - - - ` - : nothing} + ${ + this._updateInitiated + ? html` Checking for updates` + : (this.node.updateState ?? 0) > 1 + ? html` ${getUpdateStateLabel( + this.node.updateState!, + this.node.updateStateProgress, + )}` + : html` this._searchUpdate())} + >Update` + } + ${ + isCamera + ? html` + this._openCameraOverlay()} + ?disabled=${!this.node.available} + > + ${isAudioOnly ? "Listen" : isLiveViewCamera ? "Live View" : "Snapshot"} + + + ` + : nothing + } this._openCommissioningWindow())} >Share @@ -280,23 +292,26 @@ export class NodeDetails extends LitElement { !(await showPromptDialog({ title: "Firmware update available", text: html`Found a firmware update for this node on ${nodeUpdate.update_source}. - ${isUnverifiedSource - ? html` -

- Warning: This update was found on an unverified source. Updates from test-net or local - sources have not been certified and may contain untested firmware that could result in - non-functional devices. Applying these updates is entirely at your own risk. -

- ` - : nothing} + > + Warning: This update was found on an unverified source. Updates from test-net or + local sources have not been certified and may contain untested firmware that could + result in non-functional devices. Applying these updates is entirely at your own + risk. +

+ ` + : nothing + }

Current version: ${this._formatSoftwareVersion()}
Do you want to update this node to version diff --git a/packages/dashboard/src/pages/matter-cluster-view.ts b/packages/dashboard/src/pages/matter-cluster-view.ts index 9a12a0ed..03b8eda6 100644 --- a/packages/dashboard/src/pages/matter-cluster-view.ts +++ b/packages/dashboard/src/pages/matter-cluster-view.ts @@ -179,26 +179,32 @@ class MatterClusterView extends LitElement { (attribute, index) => html`

- ${clusters[this.cluster!]?.attributes[attribute.key]?.label ?? - "Custom/Unknown Attribute"} + ${ + clusters[this.cluster!]?.attributes[attribute.key]?.label ?? + "Custom/Unknown Attribute" + }
AttributeId: ${attribute.key} (${formatHex(attribute.key)}) - Value type: ${clusters[this.cluster!]?.attributes[attribute.key]?.type ?? "unknown"}
- ${this._devMode - ? this._renderAttributeDevActions(attribute.key, attribute.value) - : nothing} - ${toBigIntAwareJson(attribute.value).length > 30 - ? html` { - this._showAttributeValue(attribute.value); - }} - > - Show value - ` - : html`${toBigIntAwareJson(attribute.value)}`} + ${ + this._devMode + ? this._renderAttributeDevActions(attribute.key, attribute.value) + : nothing + } + ${ + toBigIntAwareJson(attribute.value).length > 30 + ? html` { + this._showAttributeValue(attribute.value); + }} + > + Show value + ` + : html`${toBigIntAwareJson(attribute.value)}` + }
`, @@ -225,19 +231,21 @@ class MatterClusterView extends LitElement { > - ${meta?.writable - ? html` - this._openAttributeWriteDialog(attributeId, currentValue, meta.label)} - > - - - ` - : nothing} + ${ + meta?.writable + ? html` + this._openAttributeWriteDialog(attributeId, currentValue, meta.label)} + > + + + ` + : nothing + } `; } @@ -310,33 +318,35 @@ class MatterClusterView extends LitElement { Commands
- ${commands.length === 0 - ? html`

No invokable commands for this cluster.

` - : html` -
    - ${commands.map( - cmd => html` -
  • -
    - ${cmd.label} - CommandId ${cmd.id} (${formatHex(cmd.id)}) · - ${cmd.name}No invokable commands for this cluster.

    ` + : html` +
      + ${commands.map( + cmd => html` +
    • +
      + ${cmd.label} + CommandId ${cmd.id} (${formatHex(cmd.id)}) · + ${cmd.name} +
      + this._openCommandInvokeDialog(cmd.id, cmd.name)} > -
    - this._openCommandInvokeDialog(cmd.id, cmd.name)} - > - - Invoke - -
  • - `, - )} -
- `} + + Invoke + + + `, + )} + + ` + }
@@ -404,19 +414,21 @@ class MatterClusterView extends LitElement { return html`
Active Features
- ${activeFeatures.length === 0 - ? html`

No active features

` - : html` -
    - ${activeFeatures.map( - feature => html` -
  • - ${feature.label} -
  • - `, - )} -
- `} + ${ + activeFeatures.length === 0 + ? html`

No active features

` + : html` +
    + ${activeFeatures.map( + feature => html` +
  • + ${feature.label} +
  • + `, + )} +
+ ` + }
`; } @@ -431,18 +443,20 @@ class MatterClusterView extends LitElement { return html`
Semantic Tags (TagList)
- ${tagList.length === 0 - ? html`

No semantic tags

` - : html` -
    - ${tagList.map(entry => { - const { text, title, erroneous } = describeSemanticTagListEntry(entry); - return html`
  • - ${text} -
  • `; - })} -
- `} + ${ + tagList.length === 0 + ? html`

No semantic tags

` + : html` +
    + ${tagList.map(entry => { + const { text, title, erroneous } = describeSemanticTagListEntry(entry); + return html`
  • + ${text} +
  • `; + })} +
+ ` + }
`; } diff --git a/packages/dashboard/src/pages/matter-endpoint-view.ts b/packages/dashboard/src/pages/matter-endpoint-view.ts index 0aa6c5a6..a8ced01e 100644 --- a/packages/dashboard/src/pages/matter-endpoint-view.ts +++ b/packages/dashboard/src/pages/matter-endpoint-view.ts @@ -94,15 +94,17 @@ class MatterEndpointView extends LitElement { ${this._renderClientClustersSection()} - ${getUniqueClusters(this.node, this.endpoint).includes(30) - ? html`
- -
` - : nothing} + ${ + getUniqueClusters(this.node, this.endpoint).includes(30) + ? html`
+ +
` + : nothing + }
diff --git a/packages/dashboard/src/pages/matter-network-view.ts b/packages/dashboard/src/pages/matter-network-view.ts index 721c1771..c5b10bdf 100644 --- a/packages/dashboard/src/pages/matter-network-view.ts +++ b/packages/dashboard/src/pages/matter-network-view.ts @@ -354,29 +354,31 @@ class MatterNetworkView extends LitElement { > - ${this._showHideMenu - ? html` -
- ${HIDE_OPTIONS.map( - option => html` - - `, - )} -
- ` - : ""} + ${ + this._showHideMenu + ? html` +
+ ${HIDE_OPTIONS.map( + option => html` + + `, + )} +
+ ` + : "" + }
- ${this._threadAddressSearchStatus === "idle" - ? "" - : html`
- ${this._threadAddressSearchStatus === "found" - ? "Node highlighted." - : "No matching device found."} -
`} + ${ + this._threadAddressSearchStatus === "idle" + ? "" + : html`
+ ${ + this._threadAddressSearchStatus === "found" + ? "Node highlighted." + : "No matching device found." + } +
` + }

Node ${this.node.node_id} ${nodeHex}

- ${showGraphButton - ? html` - - - Show in graph - - ` - : ""} + ${ + showGraphButton + ? html` + + + Show in graph + + ` + : "" + } diff --git a/packages/dashboard/src/pages/matter-server-view.ts b/packages/dashboard/src/pages/matter-server-view.ts index 37d01ccd..34c1a0e7 100644 --- a/packages/dashboard/src/pages/matter-server-view.ts +++ b/packages/dashboard/src/pages/matter-server-view.ts @@ -105,15 +105,17 @@ class MatterServerView extends LitElement { )}) ${node.available ? "" : html` OFFLINE`} - ${badge - ? html` e.stopPropagation()} - >ICD` - : ""} + ${ + badge + ? html` e.stopPropagation()} + >ICD` + : "" + }
${node.nodeLabel ? `${node.nodeLabel} | ` : nothing} ${node.vendorName} | diff --git a/packages/dashboard/src/pages/network/device-panel.ts b/packages/dashboard/src/pages/network/device-panel.ts index 019586b2..11bfb3ed 100644 --- a/packages/dashboard/src/pages/network/device-panel.ts +++ b/packages/dashboard/src/pages/network/device-panel.ts @@ -110,24 +110,26 @@ export class DevicePanel extends LitElement { class="expand-icon" >
- ${this._isExpanded - ? html` - - ${this.nodeIds.map(nodeId => { - const node = this.nodes[nodeId.toString()]; - if (!node) return nothing; - - return html` - this._handleNodeClick(nodeId)}> -
Node ${nodeId}
-
${getDeviceName(node)}
- -
- `; - })} -
- ` - : nothing} + ${ + this._isExpanded + ? html` + + ${this.nodeIds.map(nodeId => { + const node = this.nodes[nodeId.toString()]; + if (!node) return nothing; + + return html` + this._handleNodeClick(nodeId)}> +
Node ${nodeId}
+
${getDeviceName(node)}
+ +
+ `; + })} +
+ ` + : nothing + } `; } diff --git a/packages/dashboard/src/pages/network/network-details.ts b/packages/dashboard/src/pages/network/network-details.ts index b0fa9677..65fed348 100644 --- a/packages/dashboard/src/pages/network/network-details.ts +++ b/packages/dashboard/src/pages/network/network-details.ts @@ -176,46 +176,56 @@ export class NetworkDetails extends LitElement { return html`

WiFi Network

- ${wifiDiag.bssid - ? html` -
- BSSID: - ${wifiDiag.bssid} -
- ` - : nothing} - ${wifiDiag.rssi !== null - ? html` -
- Signal: - ${wifiDiag.rssi} dBm -
- ` - : nothing} - ${wifiDiag.channel !== null - ? html` -
- Channel: - ${wifiDiag.channel} -
- ` - : nothing} - ${wifiDiag.securityType !== null - ? html` -
- Security: - ${getWiFiSecurityTypeName(wifiDiag.securityType)} -
- ` - : nothing} - ${wifiDiag.wifiVersion !== null - ? html` -
- WiFi version: - ${getWiFiVersionName(wifiDiag.wifiVersion)} -
- ` - : nothing} + ${ + wifiDiag.bssid + ? html` +
+ BSSID: + ${wifiDiag.bssid} +
+ ` + : nothing + } + ${ + wifiDiag.rssi !== null + ? html` +
+ Signal: + ${wifiDiag.rssi} dBm +
+ ` + : nothing + } + ${ + wifiDiag.channel !== null + ? html` +
+ Channel: + ${wifiDiag.channel} +
+ ` + : nothing + } + ${ + wifiDiag.securityType !== null + ? html` +
+ Security: + ${getWiFiSecurityTypeName(wifiDiag.securityType)} +
+ ` + : nothing + } + ${ + wifiDiag.wifiVersion !== null + ? html` +
+ WiFi version: + ${getWiFiVersionName(wifiDiag.wifiVersion)} +
+ ` + : nothing + }
`; } @@ -242,30 +252,36 @@ export class NetworkDetails extends LitElement { ${getThreadRoleName(threadRole)}

${getThreadRoleDescription(threadRole)}

- ${threadVersion !== undefined - ? html` -
- Thread version: - ${formatThreadVersion(threadVersion)} -
- ` - : nothing} - ${channel !== undefined - ? html` -
- Channel: - ${channel} -
- ` - : nothing} - ${extAddressHex - ? html` -
- Extended address: - ${extAddressHex} -
- ` - : nothing} + ${ + threadVersion !== undefined + ? html` +
+ Thread version: + ${formatThreadVersion(threadVersion)} +
+ ` + : nothing + } + ${ + channel !== undefined + ? html` +
+ Channel: + ${channel} +
+ ` + : nothing + } + ${ + extAddressHex + ? html` +
+ Extended address: + ${extAddressHex} +
+ ` + : nothing + }
Direct neighbors: ${connections.length} @@ -283,86 +299,98 @@ export class NetworkDetails extends LitElement { })()}
- ${connections.length > 0 - ? html` - -
-

Connections (${connections.length})

-
- ${connections - .toSorted((a, b) => { - const score = (conn: NodeConnection): number => { - if (conn.rssi !== null && conn.rssi !== undefined) { - return conn.rssi; - } - if (conn.lqi !== null && conn.lqi !== undefined) { - return conn.lqi; - } - return -Infinity; - }; - return score(b) - score(a); - }) - .map((conn: NodeConnection) => { - return html` -
this._handleSelectNode(conn.connectedNodeId)} - @keydown=${(e: KeyboardEvent) => - this._handleKeyDown(e, conn.connectedNodeId)} - > - -
-
- ${conn.connectedNode - ? html`Node ${conn.connectedNodeId} - ${this._formatNodeIdHex( - conn.connectedNodeId, - )}: ${getDeviceName(conn.connectedNode)}` - : this._getExternalDeviceLabel(conn)} -
-
- ${conn.rssi !== null - ? html`RSSI: ${conn.rssi} dBm` - : nothing}${conn.rssi !== null && conn.lqi !== null - ? ", " - : nothing}${conn.lqi !== null - ? html`LQI: ${conn.lqi}` - : nothing}${conn.bidirectionalLqi !== undefined - ? html`, Bidir: ${conn.bidirectionalLqi}` - : nothing}${conn.pathCost !== undefined - ? html`, Cost: ${conn.pathCost}` - : nothing} - ${conn.isReverseOnly - ? html` - ← one-way - ` - : !conn.isOutgoing - ? html` (reverse) ` - : nothing} + ${ + connections.length > 0 + ? html` + +
+

Connections (${connections.length})

+
+ ${connections + .toSorted((a, b) => { + const score = (conn: NodeConnection): number => { + if (conn.rssi !== null && conn.rssi !== undefined) { + return conn.rssi; + } + if (conn.lqi !== null && conn.lqi !== undefined) { + return conn.lqi; + } + return -Infinity; + }; + return score(b) - score(a); + }) + .map((conn: NodeConnection) => { + return html` +
this._handleSelectNode(conn.connectedNodeId)} + @keydown=${(e: KeyboardEvent) => + this._handleKeyDown(e, conn.connectedNodeId)} + > + +
+
+ ${ + conn.connectedNode + ? html`Node ${conn.connectedNodeId} + ${this._formatNodeIdHex( + conn.connectedNodeId, + )}: ${getDeviceName(conn.connectedNode)}` + : this._getExternalDeviceLabel(conn) + } +
+
+ ${ + conn.rssi !== null + ? html`RSSI: ${conn.rssi} dBm` + : nothing + }${conn.rssi !== null && conn.lqi !== null ? ", " : nothing}${ + conn.lqi !== null ? html`LQI: ${conn.lqi}` : nothing + }${ + conn.bidirectionalLqi !== undefined + ? html`, Bidir: ${conn.bidirectionalLqi}` + : nothing + }${ + conn.pathCost !== undefined + ? html`, Cost: ${conn.pathCost}` + : nothing + } + ${ + conn.isReverseOnly + ? html` + ← one-way + ` + : !conn.isOutgoing + ? html` + (reverse) + ` + : nothing + } +
-
- `; - })} + `; + })} +
-
- ` - : nothing} + ` + : nothing + } `; } @@ -384,14 +412,16 @@ export class NetworkDetails extends LitElement { Product: ${node.productName ?? "Unknown"}
- ${node.serialNumber - ? html` -
- Serial: - ${node.serialNumber} -
- ` - : nothing} + ${ + node.serialNumber + ? html` +
+ Serial: + ${node.serialNumber} +
+ ` + : nothing + }
Network: ${networkType.charAt(0).toUpperCase() + networkType.slice(1)} @@ -404,18 +434,22 @@ export class NetworkDetails extends LitElement {
- ${networkType === "thread" - ? html` - - ${this._renderThreadInfo(node)} ${this._renderNodeDiagnostics(node)} - ` - : nothing} - ${networkType === "wifi" - ? html` - - ${this._renderWiFiInfo(node)} - ` - : nothing} + ${ + networkType === "thread" + ? html` + + ${this._renderThreadInfo(node)} ${this._renderNodeDiagnostics(node)} + ` + : nothing + } + ${ + networkType === "wifi" + ? html` + + ${this._renderWiFiInfo(node)} + ` + : nothing + } `; } @@ -459,30 +493,36 @@ export class NetworkDetails extends LitElement { Role: ${isRouter ? "Router" : "End Device"}
- ${extMac !== undefined - ? html` -
- Extended address: - ${extMac} -
- ` - : nothing} - ${rlocHex !== undefined - ? html` -
- RLOC16: - ${rlocHex} -
- ` - : nothing} - ${extPanId !== undefined - ? html` -
- Extended PAN ID: - ${extPanId} -
- ` - : nothing} + ${ + extMac !== undefined + ? html` +
+ Extended address: + ${extMac} +
+ ` + : nothing + } + ${ + rlocHex !== undefined + ? html` +
+ RLOC16: + ${rlocHex} +
+ ` + : nothing + } + ${ + extPanId !== undefined + ? html` +
+ Extended PAN ID: + ${extPanId} +
+ ` + : nothing + } @@ -506,37 +546,45 @@ export class NetworkDetails extends LitElement { Type: ${unknown.isRouter ? "Router (external)" : "End Device (external)"} - ${unknown.isRouter - ? html`

${EXTERNAL_ROUTER_CAPABLE_NOTE}

` - : nothing} + ${ + unknown.isRouter + ? html`

${EXTERNAL_ROUTER_CAPABLE_NOTE}

` + : nothing + }
Extended address: ${unknown.extAddressHex}
- ${unknown.networkName !== undefined - ? html` -
- Thread network: - ${unknown.networkName} -
- ` - : nothing} - ${unknown.extendedPanIdHex !== undefined - ? html` -
- Extended PAN ID: - ${unknown.extendedPanIdHex} -
- ` - : nothing} - ${unknown.bestRssi !== null - ? html` -
- Best RSSI: - ${unknown.bestRssi} dBm -
- ` - : nothing} + ${ + unknown.networkName !== undefined + ? html` +
+ Thread network: + ${unknown.networkName} +
+ ` + : nothing + } + ${ + unknown.extendedPanIdHex !== undefined + ? html` +
+ Extended PAN ID: + ${unknown.extendedPanIdHex} +
+ ` + : nothing + } + ${ + unknown.bestRssi !== null + ? html` +
+ Best RSSI: + ${unknown.bestRssi} dBm +
+ ` + : nothing + } ${this._renderExternalDeviceNeighbors(deviceId)} @@ -624,62 +672,76 @@ export class NetworkDetails extends LitElement { */ private _renderBorderRouterIdentityRows(br: BorderRouterEntry, includeExtAddr: boolean): TemplateResult { return html` - ${br.networkName - ? html` -
- Network name: - ${br.networkName} -
- ` - : nothing} - ${br.vendorName - ? html` -
- Vendor: - ${br.vendorName} -
- ` - : nothing} - ${br.modelName - ? html` -
- Model: - ${br.modelName} -
- ` - : nothing} - ${br.threadVersion - ? html` -
- Thread version: - ${br.threadVersion} -
- ` - : nothing} - ${br.swVersion - ? html` -
- SW version: - ${br.swVersion} -
- ` - : nothing} - ${br.recordVersion - ? html` -
- Record version: - ${br.recordVersion} -
- ` - : nothing} - ${includeExtAddr - ? html` -
- Extended address: - ${br.extAddressHex} -
- ` - : nothing} + ${ + br.networkName + ? html` +
+ Network name: + ${br.networkName} +
+ ` + : nothing + } + ${ + br.vendorName + ? html` +
+ Vendor: + ${br.vendorName} +
+ ` + : nothing + } + ${ + br.modelName + ? html` +
+ Model: + ${br.modelName} +
+ ` + : nothing + } + ${ + br.threadVersion + ? html` +
+ Thread version: + ${br.threadVersion} +
+ ` + : nothing + } + ${ + br.swVersion + ? html` +
+ SW version: + ${br.swVersion} +
+ ` + : nothing + } + ${ + br.recordVersion + ? html` +
+ Record version: + ${br.recordVersion} +
+ ` + : nothing + } + ${ + includeExtAddr + ? html` +
+ Extended address: + ${br.extAddressHex} +
+ ` + : nothing + } `; } @@ -726,16 +788,18 @@ export class NetworkDetails extends LitElement { ePSKc: ${decoded.epskcSupported ? "supported" : "not supported"} - ${decoded.multiAilStateValue !== 0 - ? html` -
- Multi-AIL: - - ${decoded.multiAilState ?? `reserved (${decoded.multiAilStateValue})`} - -
- ` - : nothing} + ${ + decoded.multiAilStateValue !== 0 + ? html` +
+ Multi-AIL: + + ${decoded.multiAilState ?? `reserved (${decoded.multiAilStateValue})`} + +
+ ` + : nothing + }
State bitmap (raw): ${hex} @@ -758,47 +822,57 @@ export class NetworkDetails extends LitElement { if (!hasAny) return nothing; return html` - ${br.extendedPanIdHex - ? html` -
- Extended PAN ID: - ${br.extendedPanIdHex} -
- ` - : nothing} - ${br.partitionIdHex - ? html` -
- Partition ID: - ${br.partitionIdHex} -
- ` - : nothing} - ${br.activeTimestampHex - ? html` -
- Active timestamp: - ${br.activeTimestampHex} -
- ` - : nothing} + ${ + br.extendedPanIdHex + ? html` +
+ Extended PAN ID: + ${br.extendedPanIdHex} +
+ ` + : nothing + } + ${ + br.partitionIdHex + ? html` +
+ Partition ID: + ${br.partitionIdHex} +
+ ` + : nothing + } + ${ + br.activeTimestampHex + ? html` +
+ Active timestamp: + ${br.activeTimestampHex} +
+ ` + : nothing + } ${this._renderStateBitmap(br.stateBitmapHex)} - ${br.domainName - ? html` -
- Domain: - ${br.domainName} -
- ` - : nothing} - ${br.borderAgentIdHex - ? html` -
- Border agent ID: - ${br.borderAgentIdHex} -
- ` - : nothing} + ${ + br.domainName + ? html` +
+ Domain: + ${br.domainName} +
+ ` + : nothing + } + ${ + br.borderAgentIdHex + ? html` +
+ Border agent ID: + ${br.borderAgentIdHex} +
+ ` + : nothing + } `; } @@ -815,14 +889,16 @@ export class NetworkDetails extends LitElement { if (!hasAny) return nothing; return html` - ${br.hostname - ? html` -
- Hostname: - ${br.hostname} -
- ` - : nothing} + ${ + br.hostname + ? html` +
+ Hostname: + ${br.hostname} +
+ ` + : nothing + } ${br.addresses.map( addr => html`
@@ -831,30 +907,36 @@ export class NetworkDetails extends LitElement {
`, )} - ${br.meshcopPort !== undefined - ? html` -
- MeshCoP port: - ${br.meshcopPort} -
- ` - : nothing} - ${br.trelPort !== undefined - ? html` -
- TREL port: - ${br.trelPort} -
- ` - : nothing} - ${br.sources.length > 0 - ? html` -
- Sources: - ${br.sources.join(", ")} -
- ` - : nothing} + ${ + br.meshcopPort !== undefined + ? html` +
+ MeshCoP port: + ${br.meshcopPort} +
+ ` + : nothing + } + ${ + br.trelPort !== undefined + ? html` +
+ TREL port: + ${br.trelPort} +
+ ` + : nothing + } + ${ + br.sources.length > 0 + ? html` +
+ Sources: + ${br.sources.join(", ")} +
+ ` + : nothing + } `; } @@ -930,70 +1012,84 @@ export class NetworkDetails extends LitElement {
${heading}${errorBanner} - ${batch.source !== "none" - ? html` -
- Source: - ${batch.source} -
- ` - : nothing} + ${ + batch.source !== "none" + ? html` +
+ Source: + ${batch.source} +
+ ` + : nothing + }
Updated: ${collected}
- ${routerCount > 0 - ? html` -
- Routers: - ${routerCount} -
- ` - : nothing} - ${brNode?.leaderData !== undefined - ? html` -
- Leader router ID: - ${brNode.leaderData.leaderRouterId} -
-
- Partition ID: - ${brNode.leaderData.partitionId.toString(16)} -
- ` - : nothing} - ${childCount > 0 - ? html` -
- Children: - ${childCount} -
- ` - : nothing} - ${brNode?.vendorName !== undefined - ? html` -
- Diagnostic vendor: - ${brNode.vendorName} -
- ` - : nothing} - ${brNode?.vendorModel !== undefined - ? html` -
- Diagnostic model: - ${brNode.vendorModel} -
- ` - : nothing} - ${brNode?.threadStackVersion !== undefined - ? html` -
- Stack version: - ${brNode.threadStackVersion} -
- ` - : nothing} + ${ + routerCount > 0 + ? html` +
+ Routers: + ${routerCount} +
+ ` + : nothing + } + ${ + brNode?.leaderData !== undefined + ? html` +
+ Leader router ID: + ${brNode.leaderData.leaderRouterId} +
+
+ Partition ID: + ${brNode.leaderData.partitionId.toString(16)} +
+ ` + : nothing + } + ${ + childCount > 0 + ? html` +
+ Children: + ${childCount} +
+ ` + : nothing + } + ${ + brNode?.vendorName !== undefined + ? html` +
+ Diagnostic vendor: + ${brNode.vendorName} +
+ ` + : nothing + } + ${ + brNode?.vendorModel !== undefined + ? html` +
+ Diagnostic model: + ${brNode.vendorModel} +
+ ` + : nothing + } + ${ + brNode?.threadStackVersion !== undefined + ? html` +
+ Stack version: + ${brNode.threadStackVersion} +
+ ` + : nothing + }
`; } @@ -1014,62 +1110,76 @@ export class NetworkDetails extends LitElement {

Thread Diagnostics

- ${diagNode.vendorName !== undefined - ? html` -
- Diagnostic vendor: - ${diagNode.vendorName} -
- ` - : nothing} - ${diagNode.vendorModel !== undefined - ? html` -
- Diagnostic model: - ${diagNode.vendorModel} -
- ` - : nothing} - ${diagNode.vendorSwVersion !== undefined - ? html` -
- SW version: - ${diagNode.vendorSwVersion} -
- ` - : nothing} - ${diagNode.threadStackVersion !== undefined - ? html` -
- Stack version: - ${diagNode.threadStackVersion} -
- ` - : nothing} - ${ipv6Count > 0 - ? html` -
- IPv6 addresses: - ${ipv6Count} -
- ` - : nothing} - ${macTx !== undefined && macRx !== undefined - ? html` -
- MAC tx/rx: - ${macTx} / ${macRx} -
- ` - : nothing} - ${diagNode.mleCounters?.parentChanges !== undefined - ? html` -
- Parent changes: - ${diagNode.mleCounters.parentChanges} -
- ` - : nothing} + ${ + diagNode.vendorName !== undefined + ? html` +
+ Diagnostic vendor: + ${diagNode.vendorName} +
+ ` + : nothing + } + ${ + diagNode.vendorModel !== undefined + ? html` +
+ Diagnostic model: + ${diagNode.vendorModel} +
+ ` + : nothing + } + ${ + diagNode.vendorSwVersion !== undefined + ? html` +
+ SW version: + ${diagNode.vendorSwVersion} +
+ ` + : nothing + } + ${ + diagNode.threadStackVersion !== undefined + ? html` +
+ Stack version: + ${diagNode.threadStackVersion} +
+ ` + : nothing + } + ${ + ipv6Count > 0 + ? html` +
+ IPv6 addresses: + ${ipv6Count} +
+ ` + : nothing + } + ${ + macTx !== undefined && macRx !== undefined + ? html` +
+ MAC tx/rx: + ${macTx} / ${macRx} +
+ ` + : nothing + } + ${ + diagNode.mleCounters?.parentChanges !== undefined + ? html` +
+ Parent changes: + ${diagNode.mleCounters.parentChanges} +
+ ` + : nothing + }
`; } @@ -1087,34 +1197,40 @@ export class NetworkDetails extends LitElement {

Border Router

${this._renderBorderRouterIdentityRows(br, true)} - ${br.bestRssi !== null + ${ + br.bestRssi !== null + ? html` +
+ Best RSSI: + ${br.bestRssi} dBm +
+ ` + : nothing + } +
+ + ${ + networkRows !== nothing ? html` -
- Best RSSI: - ${br.bestRssi} dBm + +
+

Thread Network

+ ${networkRows}
` - : nothing} -
- - ${networkRows !== nothing - ? html` - -
-

Thread Network

- ${networkRows} -
- ` - : nothing} - ${addressRows !== nothing - ? html` - -
-

Addresses

- ${addressRows} -
- ` - : nothing} + : nothing + } + ${ + addressRows !== nothing + ? html` + +
+

Addresses

+ ${addressRows} +
+ ` + : nothing + } ${this._renderBorderRouterDiagnostics(br)} ${this._renderExternalDeviceNeighbors(deviceId)} `; } @@ -1278,51 +1394,59 @@ export class NetworkDetails extends LitElement { ${ap.connectedNodes.length}
- ${ap.connectedNodes.length > 0 - ? html` - -
-

Connected Nodes

-
- ${ap.connectedNodes - .toSorted((a, b) => { - const nodeA = this.nodes[a.toString()]; - const nodeB = this.nodes[b.toString()]; - const rssiA = nodeA ? (getWiFiDiagnostics(nodeA)?.rssi ?? -Infinity) : -Infinity; - const rssiB = nodeB ? (getWiFiDiagnostics(nodeB)?.rssi ?? -Infinity) : -Infinity; - return rssiB - rssiA; - }) - .map(nodeId => { - const node = this.nodes[nodeId.toString()]; - if (!node) return nothing; - const wifiDiag = getWiFiDiagnostics(node); - const signalColor = getSignalColorFromRssi(wifiDiag.rssi); - - return html` -
this._handleSelectNode(nodeId)} - @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, nodeId)} - > -
- Node ${nodeId} - ${this._formatNodeIdHex(nodeId)}: - ${getDeviceName(node)} + ${ + ap.connectedNodes.length > 0 + ? html` + +
+

Connected Nodes

+
+ ${ap.connectedNodes + .toSorted((a, b) => { + const nodeA = this.nodes[a.toString()]; + const nodeB = this.nodes[b.toString()]; + const rssiA = nodeA + ? (getWiFiDiagnostics(nodeA)?.rssi ?? -Infinity) + : -Infinity; + const rssiB = nodeB + ? (getWiFiDiagnostics(nodeB)?.rssi ?? -Infinity) + : -Infinity; + return rssiB - rssiA; + }) + .map(nodeId => { + const node = this.nodes[nodeId.toString()]; + if (!node) return nothing; + const wifiDiag = getWiFiDiagnostics(node); + const signalColor = getSignalColorFromRssi(wifiDiag.rssi); + + return html` +
this._handleSelectNode(nodeId)} + @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, nodeId)} + > +
+ Node ${nodeId} + ${this._formatNodeIdHex(nodeId)}: + ${getDeviceName(node)} +
+ ${ + wifiDiag.rssi !== null + ? html`
+ ${wifiDiag.rssi} dBm +
` + : nothing + }
- ${wifiDiag.rssi !== null - ? html`
- ${wifiDiag.rssi} dBm -
` - : nothing} -
- `; - })} + `; + })} +
-
- ` - : nothing} + ` + : nothing + }

@@ -1350,18 +1474,22 @@ export class NetworkDetails extends LitElement {

Also a Border Router

${this._renderBorderRouterIdentityRows(br, false)} - ${networkRows !== nothing - ? html` -
Thread Network
- ${networkRows} - ` - : nothing} - ${addressRows !== nothing - ? html` -
Addresses
- ${addressRows} - ` - : nothing} + ${ + networkRows !== nothing + ? html` +
Thread Network
+ ${networkRows} + ` + : nothing + } + ${ + addressRows !== nothing + ? html` +
Addresses
+ ${addressRows} + ` + : nothing + }
`; } @@ -1385,18 +1513,20 @@ export class NetworkDetails extends LitElement {

External Device

- ${onlineSeenByNodes.length > 0 - ? html` - - ` - : nothing} + ${ + onlineSeenByNodes.length > 0 + ? html` + + ` + : nothing + } @@ -1404,18 +1534,20 @@ export class NetworkDetails extends LitElement {
${this._renderUnknownDeviceInfo(this.selectedNodeId as string)}
- ${this._showUpdateDialog - ? html` - - ` - : nothing} + ${ + this._showUpdateDialog + ? html` + + ` + : nothing + } `; } @@ -1432,18 +1564,20 @@ export class NetworkDetails extends LitElement {

Border Router

- ${onlineSeenByNodes.length > 0 - ? html` - - ` - : nothing} + ${ + onlineSeenByNodes.length > 0 + ? html` + + ` + : nothing + } @@ -1451,18 +1585,20 @@ export class NetworkDetails extends LitElement {
${this._renderBorderRouterInfo(borderRouterId)}
- ${this._showUpdateDialog - ? html` - - ` - : nothing} + ${ + this._showUpdateDialog + ? html` + + ` + : nothing + } `; } @@ -1522,18 +1658,20 @@ export class NetworkDetails extends LitElement { ${this._formatNodeIdHex(this.selectedNodeId)}
- ${canUpdate - ? html` - - ` - : nothing} + ${ + canUpdate + ? html` + + ` + : nothing + } @@ -1546,18 +1684,20 @@ export class NetworkDetails extends LitElement { View node details
- ${this._showUpdateDialog - ? html` - - ` - : nothing} + ${ + this._showUpdateDialog + ? html` + + ` + : nothing + } `; } diff --git a/packages/dashboard/src/pages/network/thread-graph.ts b/packages/dashboard/src/pages/network/thread-graph.ts index f520eabb..f519daae 100644 --- a/packages/dashboard/src/pages/network/thread-graph.ts +++ b/packages/dashboard/src/pages/network/thread-graph.ts @@ -1011,9 +1011,11 @@ export class ThreadGraph extends BaseNetworkGraph {

${allOfflineFiltered ? "No online Thread devices" : "No Thread devices found"}

- ${allOfflineFiltered - ? 'Disable the "Offline nodes" filter to show offline devices' - : "Thread devices will appear here once commissioned"} + ${ + allOfflineFiltered + ? 'Disable the "Offline nodes" filter to show offline devices' + : "Thread devices will appear here once commissioned" + }

`; diff --git a/packages/dashboard/src/pages/network/update-connections-dialog.ts b/packages/dashboard/src/pages/network/update-connections-dialog.ts index 61ce261e..2e931301 100644 --- a/packages/dashboard/src/pages/network/update-connections-dialog.ts +++ b/packages/dashboard/src/pages/network/update-connections-dialog.ts @@ -280,60 +280,68 @@ export class UpdateConnectionsDialog extends LitElement { const plural = neighborCount !== 1 ? "s" : ""; return html` - ${sleepy - ? html` -

- "${this.selectedNodeName}" is a sleepy device (Matter LIT). It answers only - when it next wakes, so its own network data arrives later. -

- ` - : html`

Refresh network information for "${this.selectedNodeName}".

`} - ${neighborCount > 0 - ? html` - - ` - : sleepy - ? html`

No online neighbor can report its current link data either.

` - : nothing} + ${ + sleepy + ? html` +

+ "${this.selectedNodeName}" is a sleepy device (Matter LIT). It answers + only when it next wakes, so its own network data arrives later. +

+ ` + : html`

Refresh network information for "${this.selectedNodeName}".

` + } + ${ + neighborCount > 0 + ? html` + + ` + : sleepy + ? html`

No online neighbor can report its current link data either.

` + : nothing + } `; } private _renderOfflineContent(): unknown { return html`

"${this.selectedNodeName}" appears to be offline.

- ${this.onlineNeighborIds.length > 0 - ? html` -

- Update network data from its ${this.onlineNeighborIds.length} online - neighbor${this.onlineNeighborIds.length !== 1 ? "s" : ""} to refresh connection info. -

- ` - : html`

No online neighbors available to update.

`} + ${ + this.onlineNeighborIds.length > 0 + ? html` +

+ Update network data from its ${this.onlineNeighborIds.length} online + neighbor${this.onlineNeighborIds.length !== 1 ? "s" : ""} to refresh connection info. +

+ ` + : html`

No online neighbors available to update.

` + } `; } private _renderUnknownContent(): unknown { return html`

This device is not commissioned to this fabric and cannot be queried directly.

- ${this.onlineNeighborIds.length > 0 - ? html` -

- Update network data from ${this.onlineNeighborIds.length} - node${this.onlineNeighborIds.length !== 1 ? "s" : ""} that - see${this.onlineNeighborIds.length === 1 ? "s" : ""} this device to refresh info. -

- ` - : html`

No online nodes available that see this device.

`} + ${ + this.onlineNeighborIds.length > 0 + ? html` +

+ Update network data from ${this.onlineNeighborIds.length} + node${this.onlineNeighborIds.length !== 1 ? "s" : ""} that + see${this.onlineNeighborIds.length === 1 ? "s" : ""} this device to refresh info. +

+ ` + : html`

No online nodes available that see this device.

` + } `; } @@ -364,11 +372,13 @@ export class UpdateConnectionsDialog extends LitElement {
Update Connection Data
- ${this.selectedNodeType === "online" - ? this._renderOnlineContent() - : this.selectedNodeType === "offline" - ? this._renderOfflineContent() - : this._renderUnknownContent()} + ${ + this.selectedNodeType === "online" + ? this._renderOnlineContent() + : this.selectedNodeType === "offline" + ? this._renderOfflineContent() + : this._renderUnknownContent() + } ${this._renderLongIdleTimeNote()}
@@ -377,11 +387,13 @@ export class UpdateConnectionsDialog extends LitElement { @click=${this._executeUpdate} ?disabled=${this._isUpdating || this._updateCount === 0} > - ${this._isUpdating - ? html`${svg``}Updating...` - : buttonText} + ${ + this._isUpdating + ? html`${svg``}Updating...` + : buttonText + }
From b845bdcc77bbfdff5482a8d529bcde345f48496c Mon Sep 17 00:00:00 2001 From: Ingo Fischer Date: Sat, 1 Aug 2026 12:14:59 +0200 Subject: [PATCH 7/8] fix(dashboard): scope client-cluster Bound state to our fabric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit attribute reads are not fabric-filtered, so the cached binding attribute can hold other fabrics' entries. boundClientClusterIds now filters on the node's CurrentFabricIndex, mirroring freshOurBindings and the ACL helpers; without it a second ecosystem's binding — or its cluster-less entry — marked client clusters Bound as soon as the binding editor's read landed. A cluster-less entry now unions with the cluster-specific ones instead of replacing them, and an entry targeting neither Group nor Endpoint no longer expands to every client cluster. Drops the client-only notice from the cluster view: nothing links to a client-mode cluster page, so it was unreachable except by hand-typed URL. Extracts the duplicated info-panel/chip CSS into shared-styles, adds the light-theme --md-sys-color-on-secondary-container the chips rely on, widens the endpoint view's guard dependencies so the cluster list tracks the viewed endpoint, and states Bound/Not bound as text rather than colour alone. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +- packages/dashboard/public/index.html | 1 + .../clusters/binding-commands.ts | 8 +- .../src/pages/matter-cluster-view.ts | 66 +------------ .../src/pages/matter-endpoint-view.ts | 98 +++++++------------ packages/dashboard/src/util/binding.ts | 30 ++++-- packages/dashboard/src/util/shared-styles.ts | 39 ++++++++ packages/dashboard/test/BindingUtilTest.ts | 67 +++++++++++++ 8 files changed, 173 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fc1cfe..a8fdd907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ This page shows a detailed overview of the changes between versions without the - Enhancement: Introduces Websocket Schema version 13 (backward compatible) - (MindFreeze) Adds Websocket command `get_network_topology` and event `network_topology_updated` to expose the Thread and WiFi network details for external visualization -- Feature: (lboue) Dashboard endpoint view shows a "Client Clusters" chip panel (from the Descriptor's ClientList) when the endpoint has a Binding cluster, marking clusters already covered by an existing binding as "Bound"; the cluster detail view explains why a client-mode cluster has no attributes to display +- Feature: (lboue) Dashboard endpoint view shows a "Client Clusters" panel listing the Descriptor's ClientList clusters when the endpoint has a Binding cluster, marking each as "Bound" or "Not bound" +- Fix: Dashboard endpoint view refreshes its cluster list when a cluster appears or disappears on the node, or the viewed endpoint changes ## 1.3.3 (2026-07-28) diff --git a/packages/dashboard/public/index.html b/packages/dashboard/public/index.html index 0bc496a8..30d4749b 100644 --- a/packages/dashboard/public/index.html +++ b/packages/dashboard/public/index.html @@ -37,6 +37,7 @@ --md-sys-color-surface-container-high: #fff; --md-sys-color-surface-container-highest: #f5f5f5; --md-sys-color-secondary-container: #e0e0e0; + --md-sys-color-on-secondary-container: #1d192b; --md-sys-color-error: #b3261e; --md-sys-color-on-error: #fff; --md-sys-color-error-container: #f9dedc; diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts index 1a6fcf18..3184cf89 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts @@ -21,13 +21,13 @@ import type { BindingEntryStruct } from "../../../components/dialogs/binding/mod import { showNodeBindingDialog } from "../../../components/dialogs/binding/show-node-binding-dialog.js"; import { nodeIdKey } from "../../../util/access-control.js"; import { handleAsync } from "../../../util/async-handler.js"; -import { readBindings, reverseAclState, type ReverseAclState } from "../../../util/binding.js"; +import { BINDING_CLUSTER_ID, readBindings, reverseAclState, type ReverseAclState } from "../../../util/binding.js"; import { getEndpointDeviceTypes } from "../../../util/endpoints.js"; import { getDeviceName } from "../../../util/node-name.js"; import { BaseClusterCommands } from "../base-cluster-commands.js"; import { registerClusterCommands } from "../registry.js"; -const CLUSTER_ID = 30; +const CLUSTER_ID = BINDING_CLUSTER_ID; @customElement("binding-cluster-commands") class BindingClusterCommands extends BaseClusterCommands { @@ -48,7 +48,7 @@ class BindingClusterCommands extends BaseClusterCommands { this._unsubscribe?.(); } - /** Read the (fabric-scoped) binding attribute and each target's ACL into the cache on open. */ + /** Read the binding attribute and each target's ACL into the cache on open. */ private async _ensureLoaded() { if (!this.client || !this.node || !this.node.available || this.endpoint === undefined) return; const node = this.node; @@ -57,7 +57,7 @@ class BindingClusterCommands extends BaseClusterCommands { if (this._loadedKey === key) return; this._loadedKey = key; try { - await this._readInto(node.node_id, [`${endpoint}/30/0`, "0/62/5"]); + await this._readInto(node.node_id, [`${endpoint}/${CLUSTER_ID}/0`, "0/62/5"]); const targets = new Set( readBindings(node, endpoint) .map(b => (b.node != null ? nodeIdKey(b.node) : undefined)) diff --git a/packages/dashboard/src/pages/matter-cluster-view.ts b/packages/dashboard/src/pages/matter-cluster-view.ts index 03b8eda6..66b851b9 100644 --- a/packages/dashboard/src/pages/matter-cluster-view.ts +++ b/packages/dashboard/src/pages/matter-cluster-view.ts @@ -24,7 +24,6 @@ import { showCommandInvokeDialog } from "../components/dialogs/dev/show-command- import "../components/ha-svg-icon"; import "../pages/components/node-details"; // Cluster command components (auto-register on import) -import { sourceClientClusters } from "../util/binding.js"; import { computeActiveClusterFeatures } from "../util/cluster-features.js"; import { DevModeService } from "../util/dev-mode-service.js"; import { formatHex, formatNodeAddress, getEffectiveFabricIndex } from "../util/format_hex.js"; @@ -34,7 +33,7 @@ import { DESCRIPTOR_CLUSTER_ID, TAG_LIST_ATTR, } from "../util/semantic-tags.js"; -import { notFoundStyles } from "../util/shared-styles.js"; +import { infoPanelStyles, notFoundStyles } from "../util/shared-styles.js"; import { BaseClusterCommands, getClusterCommandsTag } from "./cluster-commands/index.js"; import { bindingContext } from "./components/context.js"; @@ -174,7 +173,6 @@ class MatterClusterView extends LitElement {
ClusterId ${this.cluster} (${formatHex(this.cluster)})
- ${this._isClientOnlyCluster() ? this._renderClientOnlyNotice() : nothing} ${clusterAttributes(this.node.attributes, this.endpoint, this.cluster).map( (attribute, index) => html` @@ -365,26 +363,6 @@ class MatterClusterView extends LitElement { }); } - // Client-mode clusters bind to a server hosted elsewhere and hold no local attribute storage, - // so the attribute list below is always empty for them. - private _isClientOnlyCluster(): boolean { - if (!this.node || this.cluster === undefined) return false; - const hasAttributes = clusterAttributes(this.node.attributes, this.endpoint, this.cluster).length > 0; - if (hasAttributes) return false; - return sourceClientClusters(this.node, this.endpoint).includes(this.cluster); - } - - private _renderClientOnlyNotice(): TemplateResult { - return html` - -
- This cluster is used in client mode on this endpoint. It binds to the cluster hosted on another - device rather than hosting it itself, so there are no local attributes to display. -
-
- `; - } - private _renderClusterInfoPanel(): TemplateResult | typeof nothing { const sections = new Array(); for (const section of [this._renderFeaturesSection(), this._renderTagListSection()]) { @@ -526,6 +504,7 @@ class MatterClusterView extends LitElement { static override styles = [ notFoundStyles, + infoPanelStyles, css` :host { display: block; @@ -687,11 +666,6 @@ class MatterClusterView extends LitElement { margin: 0; } - .client-only-notice { - color: var(--md-sys-color-on-surface-variant); - font-size: 0.9rem; - } - .command-list { list-style: none; margin: 0; @@ -752,42 +726,6 @@ class MatterClusterView extends LitElement { border-radius: 3px; } - .info-panel { - background-color: var(--md-sys-color-surface-container); - border: 1px solid var(--md-sys-color-outline-variant); - border-radius: 12px; - padding: 14px 16px; - } - - .info-section + .info-section { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--md-sys-color-outline-variant); - } - - .info-section-header { - font-weight: 500; - color: var(--md-sys-color-on-surface); - margin-bottom: 10px; - } - - .chip-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .chip { - font-size: 0.85rem; - color: var(--md-sys-color-on-secondary-container); - background: var(--md-sys-color-secondary-container); - padding: 4px 10px; - border-radius: 8px; - } - .chip.chip-error { color: var(--md-sys-color-on-error-container); background: var(--md-sys-color-error-container); diff --git a/packages/dashboard/src/pages/matter-endpoint-view.ts b/packages/dashboard/src/pages/matter-endpoint-view.ts index a8ced01e..be4688b1 100644 --- a/packages/dashboard/src/pages/matter-endpoint-view.ts +++ b/packages/dashboard/src/pages/matter-endpoint-view.ts @@ -20,10 +20,10 @@ import "./cluster-commands/clusters/binding-commands.js"; import { clientContext, tickContext } from "../client/client-context.js"; import { clusters } from "../client/models/descriptions.js"; import "../components/ha-svg-icon"; -import { boundClientClusterIds, sourceClientClusters } from "../util/binding.js"; +import { BINDING_CLUSTER_ID, boundClientClusterIds, sourceClientClusters } from "../util/binding.js"; import { getEndpointDeviceTypes } from "../util/endpoints.js"; import { formatHex, formatNodeAddress, getEffectiveFabricIndex } from "../util/format_hex.js"; -import { notFoundStyles } from "../util/shared-styles.js"; +import { infoPanelStyles, notFoundStyles } from "../util/shared-styles.js"; import { bindingContext } from "./components/context.js"; declare global { @@ -78,7 +78,10 @@ class MatterEndpointView extends LitElement { this.client.serverInfo.fabric_index, isTestNodeId(this.node.node_id), ); - const nodeHex = formatNodeAddress(fabricIndex, this.node.node_id); + const nodeId = this.node.node_id; + const nodeHex = formatNodeAddress(fabricIndex, nodeId); + const endpointClusters = getUniqueClusters(this.node, this.endpoint); + const hasBindingCluster = endpointClusters.includes(BINDING_CLUSTER_ID); return html`
- ${this._renderClientClustersSection()} + ${hasBindingCluster ? this._renderClientClustersSection(this.node) : nothing} ${ - getUniqueClusters(this.node, this.endpoint).includes(30) + hasBindingCluster ? html`
` : nothing @@ -122,19 +125,20 @@ class MatterEndpointView extends LitElement { .join(" / ")}
- ${guard([Object.keys(this.node?.attributes ?? {}).length], () => - getUniqueClusters(this.node!, this.endpoint).map(cluster => { - return html` - -
${clusters[cluster]?.label ?? "Custom/Unknown Cluster"}
-
ClusterId ${cluster} (${formatHex(cluster)})
- -
- `; - }), + ${guard( + [nodeId, this.endpoint, this.node.attributes, Object.keys(this.node.attributes).length], + () => + endpointClusters.map(cluster => { + return html` + +
+ ${clusters[cluster]?.label ?? "Custom/Unknown Cluster"} +
+
ClusterId ${cluster} (${formatHex(cluster)})
+ +
+ `; + }), )} @@ -145,28 +149,23 @@ class MatterEndpointView extends LitElement { history.back(); } - // Client-mode clusters only matter if there's a Binding cluster to point them somewhere, so - // the section stays hidden otherwise rather than listing clusters nothing can act on. - private _renderClientClustersSection(): TemplateResult | typeof nothing { - if (!this.node || !getUniqueClusters(this.node, this.endpoint).includes(30)) return nothing; - const clientClusters = sourceClientClusters(this.node, this.endpoint); + private _renderClientClustersSection(node: MatterNode): TemplateResult | typeof nothing { + const clientClusters = sourceClientClusters(node, this.endpoint); if (clientClusters.length === 0) return nothing; - const boundClusterIds = boundClientClusterIds(this.node, this.endpoint); + const boundClusterIds = boundClientClusterIds(node, this.endpoint); return html`
Client Clusters
-
    +
      ${clientClusters.map(id => { const bound = boundClusterIds.has(id); return html` -
    • - ${clusters[id]?.label ?? "Custom/Unknown Cluster"} +
    • + ${clusters[id]?.label ?? "Custom/Unknown Cluster"} ${formatHex(id)} + ${bound ? "Bound" : "Not bound"}
    • `; })} @@ -179,6 +178,7 @@ class MatterEndpointView extends LitElement { static override styles = [ notFoundStyles, + infoPanelStyles, css` :host { display: block; @@ -216,40 +216,16 @@ class MatterEndpointView extends LitElement { font-size: 0.8em; } - .info-panel { - background-color: var(--md-sys-color-surface-container); - border: 1px solid var(--md-sys-color-outline-variant); - border-radius: 12px; - padding: 14px 16px; - } - - .info-section-header { - font-weight: 500; - color: var(--md-sys-color-on-surface); - margin-bottom: 10px; - } - - .chip-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .chip { - font-size: 0.85rem; - color: var(--md-sys-color-on-secondary-container); - background: var(--md-sys-color-secondary-container); - padding: 4px 10px; - border-radius: 8px; - } - .chip.chip-bound { color: var(--md-sys-color-on-primary-container); background: var(--md-sys-color-primary-container); + } + + .chip-state { font-weight: 500; + margin-left: 8px; + padding-left: 8px; + border-left: 1px solid currentcolor; } `, ]; diff --git a/packages/dashboard/src/util/binding.ts b/packages/dashboard/src/util/binding.ts index 31cb2550..ac8ffc09 100644 --- a/packages/dashboard/src/util/binding.ts +++ b/packages/dashboard/src/util/binding.ts @@ -20,10 +20,12 @@ import { subjectsInclude, } from "./access-control.js"; +export const BINDING_CLUSTER_ID = 30; + const BINDING_KEY_RE = /^(\d+)\/30\/0$/; export function readBindings(node: MatterNode, endpoint: number): BindingEntryStruct[] { - return attributeArray(node.attributes[`${endpoint}/30/0`]).map(value => + return attributeArray(node.attributes[`${endpoint}/${BINDING_CLUSTER_ID}/0`]).map(value => BindingEntryDataTransformer.transform(value), ); } @@ -47,9 +49,7 @@ export function readAllBindings(node: MatterNode): EndpointBinding[] { } function numberList(node: MatterNode, key: string): number[] { - const raw = node.attributes[key]; - if (!Array.isArray(raw)) return new Array(); - return raw.map(v => Number(v)); + return attributeArray(node.attributes[key]).map(v => Number(v)); } export function targetServerClusters(node: MatterNode, endpoint: number): number[] { @@ -60,12 +60,24 @@ export function sourceClientClusters(node: MatterNode, endpoint: number): number return numberList(node, `${endpoint}/29/2`); } -// A binding entry without a Cluster field targets the whole endpoint (all its client clusters), -// per the Binding cluster spec, rather than a single cluster. +/** + * Client clusters on the endpoint that an existing binding of our fabric already covers. + * + * Explicit reads are not fabric-filtered, so the cached binding attribute can hold other fabrics' + * entries; those must not be reported as bound. Per the Binding cluster spec an entry with no + * Cluster field covers every client cluster on the endpoint — whole-endpoint unicast and group + * bindings both take that form. + */ export function boundClientClusterIds(node: MatterNode, endpoint: number): Set { - const bindings = readBindings(node, endpoint); - if (bindings.some(b => b.cluster === undefined)) return new Set(sourceClientClusters(node, endpoint)); - return new Set(bindings.map(b => b.cluster).filter((c): c is number => c !== undefined)); + const fabricIndex = nodeFabricIndex(node); + const bindings = readBindings(node, endpoint).filter( + b => fabricIndex === undefined || b.fabricIndex === fabricIndex, + ); + const bound = new Set(bindings.map(b => b.cluster).filter((c): c is number => c !== undefined)); + if (bindings.some(b => b.cluster === undefined && (b.group !== undefined || b.endpoint !== undefined))) { + for (const cluster of sourceClientClusters(node, endpoint)) bound.add(cluster); + } + return bound; } export interface BindableClusters { diff --git a/packages/dashboard/src/util/shared-styles.ts b/packages/dashboard/src/util/shared-styles.ts index ae9bfb66..39e059a1 100644 --- a/packages/dashboard/src/util/shared-styles.ts +++ b/packages/dashboard/src/util/shared-styles.ts @@ -34,6 +34,45 @@ export const reducedMotionStyles = css` } `; +/** Bordered panel holding one or more `.info-section` blocks, each optionally listing `.chip`s. */ +export const infoPanelStyles = css` + .info-panel { + background-color: var(--md-sys-color-surface-container); + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: 12px; + padding: 14px 16px; + } + + .info-section + .info-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--md-sys-color-outline-variant); + } + + .info-section-header { + font-weight: 500; + color: var(--md-sys-color-on-surface); + margin-bottom: 10px; + } + + .chip-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .chip { + font-size: 0.85rem; + color: var(--md-sys-color-on-secondary-container); + background: var(--md-sys-color-secondary-container); + padding: 4px 10px; + border-radius: 8px; + } +`; + export const notFoundStyles = css` .not-found { display: flex; diff --git a/packages/dashboard/test/BindingUtilTest.ts b/packages/dashboard/test/BindingUtilTest.ts index 7a9e929f..5e5dfc8e 100644 --- a/packages/dashboard/test/BindingUtilTest.ts +++ b/packages/dashboard/test/BindingUtilTest.ts @@ -8,6 +8,7 @@ import { MatterNode, type MatterNodeData } from "@matter-server/ws-client"; import type { BindingEntryStruct } from "../src/components/dialogs/binding/model.js"; import { bindableClusters, + boundClientClusterIds, readAllBindings, readBindings, reverseAclState, @@ -64,6 +65,72 @@ describe("binding util", () => { expect(result.otherTarget).to.deep.equal([8]); }); + it("boundClientClusterIds collects the clusters covered by existing binding entries", () => { + const n = node({ "0/62/5": 1, "1/29/2": [6, 768], "1/30/0": [{ "1": 2, "3": 1, "4": 6, "254": 1 }] }); + expect([...boundClientClusterIds(n, 1)]).to.deep.equal([6]); + }); + + it("boundClientClusterIds returns an empty set when the endpoint has no bindings", () => { + const n = node({ "0/62/5": 1, "1/29/2": [6, 768] }); + expect(boundClientClusterIds(n, 1).size).to.equal(0); + }); + + it("boundClientClusterIds ignores entries belonging to another fabric", () => { + // Explicit reads are not fabric-filtered, so the cache can hold foreign-fabric entries. + const n = node({ + "0/62/5": 1, + "1/29/2": [6, 768], + "1/30/0": [ + { "1": 2, "3": 1, "4": 6, "254": 1 }, + { "1": 3, "3": 1, "4": 768, "254": 2 }, + ], + }); + expect([...boundClientClusterIds(n, 1)]).to.deep.equal([6]); + const foreignWildcard = node({ + "0/62/5": 1, + "1/29/2": [6, 768], + "1/30/0": [{ "1": 3, "3": 1, "254": 2 }], + }); + expect(boundClientClusterIds(foreignWildcard, 1).size).to.equal(0); + }); + + it("boundClientClusterIds treats a cluster-less entry as covering every client cluster", () => { + // Whole-endpoint unicast binding: Node + Endpoint, no Cluster. + const wholeEndpoint = node({ "0/62/5": 1, "1/29/2": [6, 768], "1/30/0": [{ "1": 2, "3": 1, "254": 1 }] }); + expect([...boundClientClusterIds(wholeEndpoint, 1)].sort((a, b) => a - b)).to.deep.equal([6, 768]); + // Group binding: Group only, no Cluster. + const groupBinding = node({ "0/62/5": 1, "1/29/2": [6, 768], "1/30/0": [{ "2": 5, "254": 1 }] }); + expect([...boundClientClusterIds(groupBinding, 1)].sort((a, b) => a - b)).to.deep.equal([6, 768]); + }); + + it("boundClientClusterIds keeps cluster-specific entries alongside a cluster-less one", () => { + // 8 is bound but absent from the ClientList, so the wildcard expansion must not replace it. + const n = node({ + "0/62/5": 1, + "1/29/2": [6, 768], + "1/30/0": [ + { "1": 2, "3": 1, "4": 8, "254": 1 }, + { "1": 2, "3": 1, "254": 1 }, + ], + }); + expect([...boundClientClusterIds(n, 1)].sort((a, b) => a - b)).to.deep.equal([6, 8, 768]); + }); + + it("boundClientClusterIds does not expand an entry that targets nothing", () => { + const n = node({ "0/62/5": 1, "1/29/2": [6, 768], "1/30/0": [{ "254": 1 }] }); + expect(boundClientClusterIds(n, 1).size).to.equal(0); + }); + + it("boundClientClusterIds only looks at the requested endpoint", () => { + const n = node({ + "0/62/5": 1, + "1/29/2": [6, 768], + "1/30/0": [{ "1": 2, "3": 1, "4": 6, "254": 1 }], + "2/30/0": [{ "1": 2, "3": 1, "4": 768, "254": 1 }], + }); + expect([...boundClientClusterIds(n, 1)]).to.deep.equal([6]); + }); + it("reverseAclState returns present/missing/overPrivileged/cannotVerify", () => { const target = node( { "0/62/5": 1, "0/31/0": [{ "1": 3, "2": 2, "3": [1], "4": [{ "0": 6, "1": 1 }], "254": 1 }] }, From e62fd741fe8ec59ce98368eebe387ad338b3e12b Mon Sep 17 00:00:00 2001 From: Ingo Fischer Date: Sat, 1 Aug 2026 13:04:35 +0200 Subject: [PATCH 8/8] fix(dashboard): fabric-filter every read that feeds the attribute cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matter.js only integrates a one-shot read into the subscribed datasource when the read's filtering matches the subscription's, so an unfiltered read returns values the server deliberately never caches. Merging such a response into the dashboard's own node.attributes left other fabrics' entries in a cache nothing refreshes: the ACL panel, the binding editor and the dev-mode attribute refresh all did this. The binding editor's phantom rows also shifted the positional index that the delete button hands to deleteBindingAtIndex, which filters by fabric — so deleting row N could remove a different binding. The ICD RegisteredClients read stays unfiltered because it must count other fabrics' clients, but it no longer merges that value into the cache. Also guards the read-modify-write paths: a read reports a per-path failure by omitting the path, and these list attributes are rewritten whole, so an absent ACL list was written back as an empty one — locking every controller out of the node. They now abort instead. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + .../src/components/dialogs/acl/acl-actions.ts | 11 ++- .../dialogs/binding/binding-actions.ts | 27 +++++-- .../clusters/access-control-commands.ts | 2 +- .../clusters/binding-commands.ts | 7 +- .../clusters/icd-management-commands.ts | 11 ++- .../src/pages/matter-cluster-view.ts | 5 +- packages/dashboard/src/util/binding.ts | 9 +-- packages/dashboard/test/BindingUtilTest.ts | 1 - packages/dashboard/test/WritePathGuardTest.ts | 79 +++++++++++++++++++ 10 files changed, 133 insertions(+), 21 deletions(-) create mode 100644 packages/dashboard/test/WritePathGuardTest.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fdd907..5b392e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ This page shows a detailed overview of the changes between versions without the - (MindFreeze) Adds Websocket command `get_network_topology` and event `network_topology_updated` to expose the Thread and WiFi network details for external visualization - Feature: (lboue) Dashboard endpoint view shows a "Client Clusters" panel listing the Descriptor's ClientList clusters when the endpoint has a Binding cluster, marking each as "Bound" or "Not bound" - Fix: Dashboard endpoint view refreshes its cluster list when a cluster appears or disappears on the node, or the viewed endpoint changes +- Fix: Dashboard attribute reads that feed its local cache are now fabric-filtered like the subscription; an unfiltered read is data the server deliberately does not cache, so other fabrics' entries could linger in the dashboard's view — including phantom binding rows that shifted the index the delete button acts on +- Fix: Dashboard ACL and binding edits abort instead of writing back a truncated list when the preceding read did not return the list; a partial read failure could previously wipe a node's access control list ## 1.3.3 (2026-07-28) diff --git a/packages/dashboard/src/components/dialogs/acl/acl-actions.ts b/packages/dashboard/src/components/dialogs/acl/acl-actions.ts index 2440d3b4..1c29a20c 100644 --- a/packages/dashboard/src/components/dialogs/acl/acl-actions.ts +++ b/packages/dashboard/src/components/dialogs/acl/acl-actions.ts @@ -23,11 +23,16 @@ function toApiAcl(e: AccessControlEntryStruct): AccessControlEntry { } /** - * Read the node's ACL + CurrentFabricIndex fresh (explicit reads are not fabric-filtered) and narrow - * to our fabric. Fails rather than risk writing back other fabrics' entries if the index is unknown. + * Read the node's ACL + CurrentFabricIndex fresh and narrow to our fabric. Fails rather than risk + * writing back other fabrics' entries if the index is unknown. */ async function freshOurAcl(client: MatterClient, nodeId: number | bigint): Promise { - const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"]); + const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"], undefined, true); + // A read reports per-path failures by omitting the path, and the ACL is rewritten whole — an + // absent list would be written back as an empty one, locking every controller out of the node. + if (!("0/31/0" in res)) { + throw new Error(`Could not read the access control list (0/31/0) from node ${nodeId}`); + } const all = attributeArray(res["0/31/0"]).map(v => AccessControlEntryDataTransformer.transform(v)); const fi = res["0/62/5"]; if (typeof fi !== "number") { diff --git a/packages/dashboard/src/components/dialogs/binding/binding-actions.ts b/packages/dashboard/src/components/dialogs/binding/binding-actions.ts index 38e1fec6..30d788ca 100644 --- a/packages/dashboard/src/components/dialogs/binding/binding-actions.ts +++ b/packages/dashboard/src/components/dialogs/binding/binding-actions.ts @@ -15,6 +15,7 @@ import { nodeIdKey, subjectsInclude, } from "../../../util/access-control.js"; +import { BINDING_CLUSTER_ID } from "../../../util/binding.js"; import { AccessControlEntryDataTransformer, type AccessControlEntryStruct } from "../acl/model.js"; import { BindingEntryDataTransformer, type BindingEntryStruct } from "./model.js"; @@ -45,12 +46,25 @@ function requireFabricIndex(res: Record, nodeId: number | bigin } /** - * Read the node's ACL + CurrentFabricIndex fresh (explicit reads are not fabric-filtered) and narrow - * to our fabric. Fails rather than risk writing back other fabrics' entries if the index is unknown. + * A read reports per-path failures by omitting the path, and these lists are rewritten whole — an + * absent list would be written back as an empty one, wiping every entry of our fabric. + */ +function requireList(res: Record, path: string, nodeId: number | bigint): unknown { + if (!(path in res)) { + throw new Error(`Could not read ${path} from node ${nodeId}`); + } + return res[path]; +} + +/** + * Read the node's ACL + CurrentFabricIndex fresh and narrow to our fabric. Fails rather than risk + * writing back other fabrics' entries if the index is unknown. */ async function freshOurAcl(client: MatterClient, nodeId: number | bigint): Promise { - const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"]); - const all = attributeArray(res["0/31/0"]).map(v => AccessControlEntryDataTransformer.transform(v)); + const res = await client.readAttribute(nodeId, ["0/31/0", "0/62/5"], undefined, true); + const all = attributeArray(requireList(res, "0/31/0", nodeId)).map(v => + AccessControlEntryDataTransformer.transform(v), + ); return entriesForFabric(all, requireFabricIndex(res, nodeId)); } @@ -59,8 +73,9 @@ async function freshOurBindings( nodeId: number | bigint, endpoint: number, ): Promise { - const res = await client.readAttribute(nodeId, [`${endpoint}/30/0`, "0/62/5"]); - const all = attributeArray(res[`${endpoint}/30/0`]).map(v => BindingEntryDataTransformer.transform(v)); + const path = `${endpoint}/${BINDING_CLUSTER_ID}/0`; + const res = await client.readAttribute(nodeId, [path, "0/62/5"], undefined, true); + const all = attributeArray(requireList(res, path, nodeId)).map(v => BindingEntryDataTransformer.transform(v)); const fabricIndex = requireFabricIndex(res, nodeId); return all.filter(b => b.fabricIndex === fabricIndex); } diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts index bfe58fbf..ac7dc15c 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/access-control-commands.ts @@ -64,7 +64,7 @@ class AccessControlClusterCommands extends BaseClusterCommands { if (this._loadedKey === key) return; this._loadedKey = key; try { - const res = await this.client.readAttribute(node.node_id, ["0/31/0", "0/62/5"]); + const res = await this.client.readAttribute(node.node_id, ["0/31/0", "0/62/5"], undefined, true); if (!this.isSameContext(node, endpoint)) return; for (const [k, v] of Object.entries(res)) this.node.attributes[k] = v; this.requestUpdate(); diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts index 3184cf89..fb48b878 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/binding-commands.ts @@ -76,8 +76,13 @@ class BindingClusterCommands extends BaseClusterCommands { } } + /** + * Fabric-filtered so the merged values match what the subscription caches: matter.js only feeds + * a one-shot read back into the datasource when its filtering matches the subscription's, so an + * unfiltered response would put other fabrics' entries into the cache and nothing would clear them. + */ private async _readInto(nodeId: number | bigint, path: string | string[]) { - const res = await this.client.readAttribute(nodeId, path); + const res = await this.client.readAttribute(nodeId, path, undefined, true); const node = this.client.nodes[nodeIdKey(nodeId)]; if (node) for (const [k, v] of Object.entries(res)) node.attributes[k] = v; } diff --git a/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts b/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts index 4879e360..c1d62d49 100644 --- a/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts +++ b/packages/dashboard/src/pages/cluster-commands/clusters/icd-management-commands.ts @@ -444,7 +444,11 @@ export class IcdManagementClusterCommands extends BaseClusterCommands { }); } - /** Non-fabric-filtered RegisteredClients read; counts clients on other fabrics. */ + /** + * Non-fabric-filtered RegisteredClients read; counts clients on other fabrics. Its result must not + * reach the attribute cache: node ids are per-fabric and can collide, so a foreign entry could + * false-positive `isRegisteredByUs`, and the subscription keeps that attribute fabric-filtered. + */ private async _otherClientCount(node: MatterNode, endpoint: number): Promise { const ourFabricIndexRaw = node.attributes[CURRENT_FABRIC_INDEX_PATH]; const result = await this.client.readAttribute( @@ -452,7 +456,10 @@ export class IcdManagementClusterCommands extends BaseClusterCommands { [REGISTERED_CLIENTS_PATH, CURRENT_FABRIC_INDEX_PATH], this._actionTimeoutMs, ); - if (this.isSameContext(node, endpoint)) Object.assign(this.node.attributes, result); + const currentFabricIndex = result[CURRENT_FABRIC_INDEX_PATH]; + if (this.isSameContext(node, endpoint) && typeof currentFabricIndex === "number") { + this.node.attributes[CURRENT_FABRIC_INDEX_PATH] = currentFabricIndex; + } const clients = decodeRegisteredClients(result[REGISTERED_CLIENTS_PATH]); const ourFabricIndex = result[CURRENT_FABRIC_INDEX_PATH] ?? ourFabricIndexRaw; return otherFabricClientCount(clients, typeof ourFabricIndex === "number" ? ourFabricIndex : undefined); diff --git a/packages/dashboard/src/pages/matter-cluster-view.ts b/packages/dashboard/src/pages/matter-cluster-view.ts index 66b851b9..16bd4ad3 100644 --- a/packages/dashboard/src/pages/matter-cluster-view.ts +++ b/packages/dashboard/src/pages/matter-cluster-view.ts @@ -261,9 +261,10 @@ class MatterClusterView extends LitElement { this._refreshState = { ...this._refreshState, [attributeId]: "loading" }; try { - const result = await this.client.readAttribute(nodeId, path); + // Fabric-filtered to match the subscription: matter.js discards a read whose filtering + // differs, so an unfiltered one would put values into the cache that nothing ever refreshes. + const result = await this.client.readAttribute(nodeId, path, undefined, true); if (!isSameContext()) return; - // Defensive merge — attribute_updated events usually do this already. for (const [key, value] of Object.entries(result)) { this.node.attributes[key] = value; } diff --git a/packages/dashboard/src/util/binding.ts b/packages/dashboard/src/util/binding.ts index ac8ffc09..ea74d3fd 100644 --- a/packages/dashboard/src/util/binding.ts +++ b/packages/dashboard/src/util/binding.ts @@ -22,7 +22,7 @@ import { export const BINDING_CLUSTER_ID = 30; -const BINDING_KEY_RE = /^(\d+)\/30\/0$/; +const BINDING_KEY_RE = new RegExp(`^(\\d+)/${BINDING_CLUSTER_ID}/0$`); export function readBindings(node: MatterNode, endpoint: number): BindingEntryStruct[] { return attributeArray(node.attributes[`${endpoint}/${BINDING_CLUSTER_ID}/0`]).map(value => @@ -63,10 +63,9 @@ export function sourceClientClusters(node: MatterNode, endpoint: number): number /** * Client clusters on the endpoint that an existing binding of our fabric already covers. * - * Explicit reads are not fabric-filtered, so the cached binding attribute can hold other fabrics' - * entries; those must not be reported as bound. Per the Binding cluster spec an entry with no - * Cluster field covers every client cluster on the endpoint — whole-endpoint unicast and group - * bindings both take that form. + * Per the Binding cluster spec an entry with no Cluster field covers every client cluster on the + * endpoint — whole-endpoint unicast and group bindings both take that form. The fabric filter keeps + * the result correct even if a caller ever populates the cache from an unfiltered read. */ export function boundClientClusterIds(node: MatterNode, endpoint: number): Set { const fabricIndex = nodeFabricIndex(node); diff --git a/packages/dashboard/test/BindingUtilTest.ts b/packages/dashboard/test/BindingUtilTest.ts index 5e5dfc8e..2c8f8cb4 100644 --- a/packages/dashboard/test/BindingUtilTest.ts +++ b/packages/dashboard/test/BindingUtilTest.ts @@ -76,7 +76,6 @@ describe("binding util", () => { }); it("boundClientClusterIds ignores entries belonging to another fabric", () => { - // Explicit reads are not fabric-filtered, so the cache can hold foreign-fabric entries. const n = node({ "0/62/5": 1, "1/29/2": [6, 768], diff --git a/packages/dashboard/test/WritePathGuardTest.ts b/packages/dashboard/test/WritePathGuardTest.ts new file mode 100644 index 00000000..b03fa688 --- /dev/null +++ b/packages/dashboard/test/WritePathGuardTest.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025-2026 Open Home Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MatterNode, type MatterClient, type MatterNodeData } from "@matter-server/ws-client"; +import { deleteAclEntry } from "../src/components/dialogs/acl/acl-actions.js"; +import { addBinding, deleteBindingAtIndex } from "../src/components/dialogs/binding/binding-actions.js"; + +function node(attributes: Record, node_id: number | bigint = 1): MatterNode { + const data: MatterNodeData = { + node_id, + date_commissioned: "", + last_interview: "", + interview_version: 1, + available: true, + is_bridge: false, + attributes, + attribute_subscriptions: [], + }; + return new MatterNode(data); +} + +interface Writes { + acl: number; + binding: number; +} + +/** A read that answers only the paths in `res` — a per-path failure shows up as an omitted path. */ +function clientReading(res: Record): { client: MatterClient; writes: Writes } { + const writes: Writes = { acl: 0, binding: 0 }; + const client = { + nodes: {}, + readAttribute: async () => res, + setACLEntry: async () => { + writes.acl++; + }, + setNodeBinding: async () => { + writes.binding++; + }, + } as unknown as MatterClient; + return { client, writes }; +} + +describe("fabric-scoped write paths", () => { + it("deleteAclEntry refuses to write when the ACL read returned no list", async () => { + const { client, writes } = clientReading({ "0/62/5": 1 }); + await expect(deleteAclEntry(client, 1, "whatever")).to.be.rejectedWith(/0\/31\/0/); + expect(writes.acl).to.equal(0); + }); + + it("deleteAclEntry writes the remaining entries when the read succeeded", async () => { + const { client, writes } = clientReading({ + "0/62/5": 1, + "0/31/0": [{ "1": 5, "2": 2, "3": [9], "254": 1 }], + }); + await deleteAclEntry(client, 1, "whatever"); + expect(writes.acl).to.equal(1); + }); + + it("deleteBindingAtIndex refuses to write when the binding read returned no list", async () => { + const { client, writes } = clientReading({ "0/62/5": 1 }); + await expect(deleteBindingAtIndex(client, node({}), 1, 0)).to.be.rejectedWith(/1\/30\/0/); + expect(writes.binding).to.equal(0); + }); + + it("addBinding refuses to write when the binding read returned no list", async () => { + // The target's ACL already grants Operate on 1/6, so ensureBindingAcl passes and the run + // reaches the binding read, which answers only 0/62/5. + const { client, writes } = clientReading({ + "0/62/5": 1, + "0/31/0": [{ "1": 3, "2": 2, "3": [1], "4": [{ "0": 6, "1": 1 }], "254": 1 }], + }); + await expect(addBinding(client, node({}, 1), 1, 2, 1, 6)).to.be.rejectedWith(/1\/30\/0/); + expect(writes.acl).to.equal(0); + expect(writes.binding).to.equal(0); + }); +});