Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ 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
- 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)

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
11 changes: 8 additions & 3 deletions packages/dashboard/src/components/dialogs/acl/acl-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccessControlEntryStruct[]> {
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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -45,12 +46,25 @@ function requireFabricIndex(res: Record<string, unknown>, 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<string, unknown>, 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<AccessControlEntryStruct[]> {
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));
}

Expand All @@ -59,8 +73,9 @@ async function freshOurBindings(
nodeId: number | bigint,
endpoint: number,
): Promise<BindingEntryStruct[]> {
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
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 All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,15 +444,22 @@ 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<number> {
const ourFabricIndexRaw = node.attributes[CURRENT_FABRIC_INDEX_PATH];
const result = await this.client.readAttribute(
node.node_id,
[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);
Expand Down
44 changes: 5 additions & 39 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 @@ -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;
}
Expand Down Expand Up @@ -504,6 +505,7 @@ class MatterClusterView extends LitElement {

static override styles = [
notFoundStyles,
infoPanelStyles,
css`
:host {
display: block;
Expand Down Expand Up @@ -725,42 +727,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>
`;
}),
)}
</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;
}
`,
];
}
Loading