Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +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" 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
Comment thread
Apollon77 marked this conversation as resolved.

## 1.3.3 (2026-07-28)

Expand Down
1 change: 1 addition & 0 deletions packages/dashboard/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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))
Expand Down
39 changes: 2 additions & 37 deletions packages/dashboard/src/pages/matter-cluster-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,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";

Expand Down Expand Up @@ -504,6 +504,7 @@ class MatterClusterView extends LitElement {

static override styles = [
notFoundStyles,
infoPanelStyles,
css`
:host {
display: block;
Expand Down Expand Up @@ -725,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);
Expand Down
83 changes: 65 additions & 18 deletions packages/dashboard/src/pages/matter-endpoint-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ 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 { 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 {
Expand Down Expand Up @@ -77,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`
<dashboard-header
Expand All @@ -90,14 +94,16 @@ class MatterEndpointView extends LitElement {
<node-details .node=${this.node}></node-details>
</div>

${hasBindingCluster ? this._renderClientClustersSection(this.node) : nothing}

<!-- Binding editor (when this endpoint has a Binding cluster) -->
${
getUniqueClusters(this.node, this.endpoint).includes(30)
hasBindingCluster
? html`<div class="container">
<binding-cluster-commands
.node=${this.node}
.endpoint=${this.endpoint}
.cluster=${30}
.cluster=${BINDING_CLUSTER_ID}
></binding-cluster-commands>
</div>`
: nothing
Expand All @@ -119,19 +125,20 @@ class MatterEndpointView extends LitElement {
.join(" / ")}
</div>
</md-list-item>
${guard([this.node?.attributes.length], () =>
getUniqueClusters(this.node!, this.endpoint).map(cluster => {
return html`
<md-list-item
type="link"
href=${`#node/${this.node!.node_id}/${this.endpoint}/${cluster}`}
>
<div slot="headline">${clusters[cluster]?.label ?? "Custom/Unknown Cluster"}</div>
<div slot="supporting-text">ClusterId ${cluster} (${formatHex(cluster)})</div>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</md-list-item>
`;
}),
${guard(
[nodeId, this.endpoint, this.node.attributes, Object.keys(this.node.attributes).length],
() =>
endpointClusters.map(cluster => {
return html`
<md-list-item type="link" href=${`#node/${nodeId}/${this.endpoint}/${cluster}`}>
<div slot="headline">
${clusters[cluster]?.label ?? "Custom/Unknown Cluster"}
</div>
<div slot="supporting-text">ClusterId ${cluster} (${formatHex(cluster)})</div>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</md-list-item>
`;
}),
)}
Comment thread
Apollon77 marked this conversation as resolved.
</md-list>
</div>
Expand All @@ -142,8 +149,36 @@ class MatterEndpointView extends LitElement {
history.back();
}

private _renderClientClustersSection(node: MatterNode): TemplateResult | typeof nothing {
const clientClusters = sourceClientClusters(node, this.endpoint);
if (clientClusters.length === 0) return nothing;
const boundClusterIds = boundClientClusterIds(node, this.endpoint);

return html`
<div class="container">
<div class="info-panel">
<div class="info-section">
<div class="info-section-header">Client Clusters</div>
<ul class="chip-list" role="list">
${clientClusters.map(id => {
const bound = boundClusterIds.has(id);
return html`
<li class=${bound ? "chip chip-bound" : "chip"}>
${clusters[id]?.label ?? "Custom/Unknown Cluster"} ${formatHex(id)}
<span class="chip-state">${bound ? "Bound" : "Not bound"}</span>
</li>
`;
})}
</ul>
</div>
</div>
</div>
`;
}

static override styles = [
notFoundStyles,
infoPanelStyles,
css`
:host {
display: block;
Expand Down Expand Up @@ -180,6 +215,18 @@ class MatterEndpointView extends LitElement {
font-weight: bold;
font-size: 0.8em;
}

.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;
}
`,
];
}
28 changes: 24 additions & 4 deletions packages/dashboard/src/util/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import {
subjectsInclude,
} from "./access-control.js";

export const BINDING_CLUSTER_ID = 30;

const BINDING_KEY_RE = /^(\d+)\/30\/0$/;

Comment thread
Apollon77 marked this conversation as resolved.
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),
);
}
Expand All @@ -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<number>();
return raw.map(v => Number(v));
return attributeArray(node.attributes[key]).map(v => Number(v));
}

export function targetServerClusters(node: MatterNode, endpoint: number): number[] {
Expand All @@ -60,6 +60,26 @@ export function sourceClientClusters(node: MatterNode, endpoint: number): number
return numberList(node, `${endpoint}/29/2`);
}

/**
* 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<number> {
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;
}
Comment thread
Apollon77 marked this conversation as resolved.

export interface BindableClusters {
bindable: number[];
otherTarget: number[];
Expand Down
39 changes: 39 additions & 0 deletions packages/dashboard/src/util/shared-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading