@matter-server/dashboard 0.7.1 → 0.7.2-alpha.0-20260602-cef59f5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/esm/components/dialogs/node-label-dialog/node-label-dialog.d.ts +28 -0
  2. package/dist/esm/components/dialogs/node-label-dialog/node-label-dialog.d.ts.map +1 -0
  3. package/dist/esm/components/dialogs/node-label-dialog/node-label-dialog.js +99 -0
  4. package/dist/esm/components/dialogs/node-label-dialog/node-label-dialog.js.map +6 -0
  5. package/dist/esm/components/dialogs/node-label-dialog/show-node-label-dialog.d.ts +8 -0
  6. package/dist/esm/components/dialogs/node-label-dialog/show-node-label-dialog.d.ts.map +1 -0
  7. package/dist/esm/components/dialogs/node-label-dialog/show-node-label-dialog.js +16 -0
  8. package/dist/esm/components/dialogs/node-label-dialog/show-node-label-dialog.js.map +6 -0
  9. package/dist/esm/components/dialogs/settings/log-level-section.d.ts.map +1 -1
  10. package/dist/esm/components/dialogs/settings/log-level-section.js +1 -0
  11. package/dist/esm/components/dialogs/settings/log-level-section.js.map +1 -1
  12. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts.map +1 -1
  13. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js +7 -18
  14. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js.map +1 -1
  15. package/dist/esm/pages/components/node-details.d.ts +1 -0
  16. package/dist/esm/pages/components/node-details.d.ts.map +1 -1
  17. package/dist/esm/pages/components/node-details.js +28 -2
  18. package/dist/esm/pages/components/node-details.js.map +1 -1
  19. package/dist/esm/pages/matter-network-view.d.ts.map +1 -1
  20. package/dist/esm/pages/matter-network-view.js +5 -7
  21. package/dist/esm/pages/matter-network-view.js.map +1 -1
  22. package/dist/esm/pages/network/thread-graph.d.ts +5 -6
  23. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -1
  24. package/dist/esm/pages/network/thread-graph.js +40 -18
  25. package/dist/esm/pages/network/thread-graph.js.map +1 -1
  26. package/dist/esm/util/device-icons.d.ts +13 -2
  27. package/dist/esm/util/device-icons.d.ts.map +1 -1
  28. package/dist/esm/util/device-icons.js +29 -5
  29. package/dist/esm/util/device-icons.js.map +1 -1
  30. package/dist/esm/util/node-label.d.ts +16 -0
  31. package/dist/esm/util/node-label.d.ts.map +1 -0
  32. package/dist/esm/util/node-label.js +20 -0
  33. package/dist/esm/util/node-label.js.map +6 -0
  34. package/dist/web/index.html +8 -0
  35. package/dist/web/js/{attribute-write-dialog-CMEJgY9e.js → attribute-write-dialog-DxoudqTg.js} +1 -1
  36. package/dist/web/js/{command-invoke-dialog-BmlCVREh.js → command-invoke-dialog-BfBkbgp6.js} +1 -1
  37. package/dist/web/js/{commission-node-dialog-C1H9TRDH.js → commission-node-dialog-BJNntAAz.js} +5 -5
  38. package/dist/web/js/{commission-node-existing-CDaSgrTM.js → commission-node-existing-CCTW_OsF.js} +2 -2
  39. package/dist/web/js/{commission-node-thread-CiCKtsE6.js → commission-node-thread-hxemHL-m.js} +2 -2
  40. package/dist/web/js/{commission-node-wifi-DjXZOV3e.js → commission-node-wifi-BVp8_5oD.js} +2 -2
  41. package/dist/web/js/{dialog-box-v7mOYgwS.js → dialog-box-GqaRe3Hh.js} +1 -1
  42. package/dist/web/js/{fire_event-DmJTXjDw.js → fire_event-BYpJ8gvz.js} +1 -1
  43. package/dist/web/js/main.js +4 -4
  44. package/dist/web/js/{matter-dashboard-app-CRf1z2eY.js → matter-dashboard-app-FxzNxVJ6.js} +159 -57
  45. package/dist/web/js/{node-binding-dialog-D9tXIOEj.js → node-binding-dialog-BUK6iP8t.js} +1 -1
  46. package/dist/web/js/node-label-dialog-BuNRrozE.js +80 -0
  47. package/dist/web/js/{settings-dialog-DlL0QdYN.js → settings-dialog-DxDp7265.js} +4 -1
  48. package/package.json +8 -8
  49. package/src/components/dialogs/node-label-dialog/node-label-dialog.ts +93 -0
  50. package/src/components/dialogs/node-label-dialog/show-node-label-dialog.ts +15 -0
  51. package/src/components/dialogs/settings/log-level-section.ts +1 -0
  52. package/src/pages/cluster-commands/clusters/basic-information-commands.ts +7 -26
  53. package/src/pages/components/node-details.ts +31 -2
  54. package/src/pages/matter-network-view.ts +5 -7
  55. package/src/pages/network/thread-graph.ts +47 -20
  56. package/src/util/device-icons.ts +59 -5
  57. package/src/util/node-label.ts +22 -0
@@ -1,4 +1,4 @@
1
- import { i, c, a as clientContext, w as tickContext, r, e, j as i$1, f as fireAndForget, s as showAlertDialog, b, A, h as handleAsync, v as t, q as n, D as DevModeService, o as mdiWifi, k as mdiAccessPoint, n as mdiEyeOff, l as mdiEye } from './matter-dashboard-app-CRf1z2eY.js';
1
+ import { i, c, a as clientContext, t as tickContext, r, e, b as i$1, f as fireAndForget, s as showAlertDialog, d as b, A, h as handleAsync, g as t, n, D as DevModeService, m as mdiWifi, j as mdiAccessPoint, k as mdiEyeOff, l as mdiEye } from './matter-dashboard-app-FxzNxVJ6.js';
2
2
  import { p as preventDefault } from './prevent_default-D-ohDGsN.js';
3
3
  import './main.js';
4
4
 
@@ -19,6 +19,9 @@ const LOG_LEVELS = [{
19
19
  }, {
20
20
  value: "warning",
21
21
  label: "Warning"
22
+ }, {
23
+ value: "notice",
24
+ label: "Notice"
22
25
  }, {
23
26
  value: "info",
24
27
  label: "Info"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matter-server/dashboard",
3
- "version": "0.7.1",
3
+ "version": "0.7.2-alpha.0-20260602-cef59f5",
4
4
  "description": "Dashboard for OHF Matter Server",
5
5
  "homepage": "https://github.com/matter-js/matterjs-server",
6
6
  "bugs": {
@@ -33,23 +33,23 @@
33
33
  "dependencies": {
34
34
  "@lit/context": "^1.1.6",
35
35
  "@material/web": "^2.4.1",
36
- "@matter-server/custom-clusters": "0.7.1",
37
- "@matter-server/ws-client": "0.7.1",
36
+ "@matter-server/custom-clusters": "0.7.2-alpha.0-20260602-cef59f5",
37
+ "@matter-server/ws-client": "0.7.2-alpha.0-20260602-cef59f5",
38
38
  "@mdi/js": "^7.4.47",
39
39
  "lit": "^3.3.3",
40
40
  "tslib": "^2.8.1",
41
41
  "vis-network": "^10.1.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@babel/preset-env": "^7.29.5",
45
- "@matter/main": "0.17.0",
46
- "@rollup/plugin-babel": "^7.0.0",
47
- "@rollup/plugin-commonjs": "^29.0.2",
44
+ "@babel/preset-env": "^7.29.7",
45
+ "@matter/main": "0.17.1-alpha.0-20260601-9386e30f9",
46
+ "@rollup/plugin-babel": "^7.1.0",
47
+ "@rollup/plugin-commonjs": "^29.0.3",
48
48
  "@rollup/plugin-json": "^6.1.0",
49
49
  "@rollup/plugin-node-resolve": "^16.0.3",
50
50
  "@rollup/plugin-terser": "^1.0.0",
51
51
  "@rollup/plugin-typescript": "^12.3.0",
52
- "rollup": "^4.60.4",
52
+ "rollup": "^4.61.0",
53
53
  "rollup-plugin-copy": "^3.5.0",
54
54
  "serve": "^14.2.6"
55
55
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import "@material/web/button/text-button";
8
+ import "@material/web/dialog/dialog";
9
+ import "@material/web/textfield/outlined-text-field";
10
+ import type { MdDialog } from "@material/web/dialog/dialog.js";
11
+ import type { MatterClient, MatterNode } from "@matter-server/ws-client";
12
+ import { html, LitElement } from "lit";
13
+ import { customElement, property, state } from "lit/decorators.js";
14
+ import { MAX_NODE_LABEL_LENGTH, writeNodeLabel } from "../../../util/node-label.js";
15
+ import { preventDefault } from "../../../util/prevent_default.js";
16
+ import { showAlertDialog } from "../../dialog-box/show-dialog-box.js";
17
+
18
+ @customElement("node-label-dialog")
19
+ export class NodeLabelDialog extends LitElement {
20
+ @property({ attribute: false })
21
+ public client!: MatterClient;
22
+
23
+ @property({ attribute: false })
24
+ public node!: MatterNode;
25
+
26
+ @state()
27
+ private _nodeLabel: string = "";
28
+
29
+ @state()
30
+ private _saving: boolean = false;
31
+
32
+ protected override firstUpdated() {
33
+ this._nodeLabel = this.node.nodeLabel;
34
+ }
35
+
36
+ protected override render() {
37
+ return html`
38
+ <md-dialog open @cancel=${preventDefault} @closed=${this._handleClosed}>
39
+ <div slot="headline">Edit Node Label</div>
40
+ <div slot="content">
41
+ <md-outlined-text-field
42
+ label="Node Label"
43
+ .value=${this._nodeLabel}
44
+ @input=${this._handleInput}
45
+ maxlength=${MAX_NODE_LABEL_LENGTH}
46
+ ?disabled=${this._saving}
47
+ supporting-text="Max ${MAX_NODE_LABEL_LENGTH} characters"
48
+ style="width: 100%; margin-top: 8px;"
49
+ ></md-outlined-text-field>
50
+ </div>
51
+ <div slot="actions">
52
+ <md-text-button @click=${this._close} ?disabled=${this._saving}>Cancel</md-text-button>
53
+ <md-text-button @click=${this._save} ?disabled=${this._saving}>Save</md-text-button>
54
+ </div>
55
+ </md-dialog>
56
+ `;
57
+ }
58
+
59
+ private _handleInput(e: Event) {
60
+ const input = e.target as HTMLInputElement;
61
+ this._nodeLabel = input.value;
62
+ }
63
+
64
+ private _close() {
65
+ this.shadowRoot!.querySelector<MdDialog>("md-dialog")!.close();
66
+ }
67
+
68
+ private _handleClosed() {
69
+ this.parentNode!.removeChild(this);
70
+ }
71
+
72
+ private async _save() {
73
+ this._saving = true;
74
+ try {
75
+ await writeNodeLabel(this.client, this.node, this._nodeLabel);
76
+ this._close();
77
+ } catch (error) {
78
+ const errorMessage = error instanceof Error ? error.message : String(error);
79
+ showAlertDialog({
80
+ title: "Failed to set node label",
81
+ text: errorMessage,
82
+ });
83
+ } finally {
84
+ this._saving = false;
85
+ }
86
+ }
87
+ }
88
+
89
+ declare global {
90
+ interface HTMLElementTagNameMap {
91
+ "node-label-dialog": NodeLabelDialog;
92
+ }
93
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { MatterClient, MatterNode } from "@matter-server/ws-client";
8
+
9
+ export const showNodeLabelDialog = async (client: MatterClient, node: MatterNode) => {
10
+ await import("./node-label-dialog.js");
11
+ const dialog = document.createElement("node-label-dialog");
12
+ dialog.client = client;
13
+ dialog.node = node;
14
+ document.querySelector("matter-dashboard-app")?.renderRoot.appendChild(dialog);
15
+ };
@@ -20,6 +20,7 @@ const LOG_LEVELS: { value: LogLevelString; label: string }[] = [
20
20
  { value: "critical", label: "Critical" },
21
21
  { value: "error", label: "Error" },
22
22
  { value: "warning", label: "Warning" },
23
+ { value: "notice", label: "Notice" },
23
24
  { value: "info", label: "Info" },
24
25
  { value: "debug", label: "Debug" },
25
26
  ];
@@ -10,13 +10,10 @@ import { css, html, nothing, type CSSResultGroup } from "lit";
10
10
  import { customElement, state } from "lit/decorators.js";
11
11
  import { showAlertDialog } from "../../../components/dialog-box/show-dialog-box.js";
12
12
  import { handleAsync } from "../../../util/async-handler.js";
13
+ import { MAX_NODE_LABEL_LENGTH, NODE_LABEL_CLUSTER_ID, writeNodeLabel } from "../../../util/node-label.js";
13
14
  import { BaseClusterCommands } from "../base-cluster-commands.js";
14
15
  import { registerClusterCommands } from "../registry.js";
15
16
 
16
- const CLUSTER_ID = 0x28; // BasicInformation cluster (40 decimal)
17
- const NODE_LABEL_ATTRIBUTE_ID = 5;
18
- const MAX_NODE_LABEL_LENGTH = 32;
19
-
20
17
  /**
21
18
  * Command panel for BasicInformation cluster (ID: 0x28 / 40).
22
19
  * Provides ability to edit the NodeLabel attribute.
@@ -44,22 +41,7 @@ export class BasicInformationClusterCommands extends BaseClusterCommands {
44
41
  if (!this.node) {
45
42
  return;
46
43
  }
47
-
48
- // First check the direct nodeLabel property on the node
49
- if (this.node.nodeLabel) {
50
- this._nodeLabel = this.node.nodeLabel;
51
- return;
52
- }
53
-
54
- // Fallback to attribute path: endpoint/cluster/attribute = 0/40/5
55
- // BasicInformation cluster is always on endpoint 0 per Matter specification
56
- const attributePath = `0/${CLUSTER_ID}/${NODE_LABEL_ATTRIBUTE_ID}`;
57
- const currentValue = this.node.attributes[attributePath];
58
- if (typeof currentValue === "string") {
59
- this._nodeLabel = currentValue;
60
- } else {
61
- this._nodeLabel = "";
62
- }
44
+ this._nodeLabel = this.node.nodeLabel;
63
45
  }
64
46
 
65
47
  /**
@@ -115,13 +97,13 @@ export class BasicInformationClusterCommands extends BaseClusterCommands {
115
97
  this._saving = true;
116
98
 
117
99
  try {
118
- // BasicInformation cluster is always on endpoint 0 per Matter specification
119
- const attributePath = `0/${CLUSTER_ID}/${NODE_LABEL_ATTRIBUTE_ID}`;
120
- await this.client.writeAttribute(this.node.node_id, attributePath, this._nodeLabel);
100
+ const label = this._nodeLabel.trim();
101
+ await writeNodeLabel(this.client, this.node, label);
102
+ this._nodeLabel = label;
121
103
 
122
104
  showAlertDialog({
123
105
  title: "Success",
124
- text: `Node label set to "${this._nodeLabel}"`,
106
+ text: `Node label set to "${label}"`,
125
107
  });
126
108
  } catch (error) {
127
109
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -161,8 +143,7 @@ export class BasicInformationClusterCommands extends BaseClusterCommands {
161
143
  ];
162
144
  }
163
145
 
164
- // Register this component for cluster ID 0x28 (40 decimal)
165
- registerClusterCommands(CLUSTER_ID, "basic-information-cluster-commands");
146
+ registerClusterCommands(NODE_LABEL_CLUSTER_ID, "basic-information-cluster-commands");
166
147
 
167
148
  declare global {
168
149
  interface HTMLElementTagNameMap {
@@ -13,13 +13,14 @@ import "@material/web/list/list";
13
13
  import "@material/web/list/list-item";
14
14
  import { consume } from "@lit/context";
15
15
  import { MatterClient, MatterNode, UpdateSource } from "@matter-server/ws-client";
16
- import { mdiChatProcessing, mdiLink, mdiShareVariant, mdiTrashCan, mdiUpdate, mdiVideo } from "@mdi/js";
16
+ import { mdiChatProcessing, mdiLink, mdiPencil, mdiShareVariant, mdiTrashCan, mdiUpdate, mdiVideo } from "@mdi/js";
17
17
  import { LitElement, css, html, nothing } from "lit";
18
18
  import { customElement, property, state } from "lit/decorators.js";
19
19
  import { clientContext, tickContext } from "../../client/client-context.js";
20
20
  import { DeviceType } from "../../client/models/descriptions.js";
21
21
  import { showAlertDialog, showPromptDialog } from "../../components/dialog-box/show-dialog-box.js";
22
22
  import { showNodeBindingDialog } from "../../components/dialogs/binding/show-node-binding-dialog.js";
23
+ import { showNodeLabelDialog } from "../../components/dialogs/node-label-dialog/show-node-label-dialog.js";
23
24
  import { handleAsync } from "../../util/async-handler.js";
24
25
  import "../../components/ha-svg-icon";
25
26
  import "../camera-overlay.js";
@@ -86,8 +87,19 @@ export class NodeDetails extends LitElement {
86
87
  <md-list>
87
88
  <md-list-item>
88
89
  <ha-svg-icon slot="start" class="device-icon" .path=${getDeviceIcon(this.node)}></ha-svg-icon>
89
- <div slot="headline">
90
+ <div slot="headline" class="node-label-row">
90
91
  <b>${this.node.nodeLabel || "Node Info"}</b>
92
+ ${this.node.available
93
+ ? html`
94
+ <md-icon-button
95
+ @click=${() => this._editNodeLabel()}
96
+ aria-label="Edit node label"
97
+ title="Edit node label"
98
+ >
99
+ <ha-svg-icon .path=${mdiPencil}></ha-svg-icon>
100
+ </md-icon-button>
101
+ `
102
+ : nothing}
91
103
  ${this.node.available ? nothing : html` <span class="status">OFFLINE</span> `}
92
104
  </div>
93
105
  </md-list-item>
@@ -168,6 +180,10 @@ export class NodeDetails extends LitElement {
168
180
  `;
169
181
  }
170
182
 
183
+ private _editNodeLabel() {
184
+ showNodeLabelDialog(this.client, this.node!);
185
+ }
186
+
171
187
  private async _reinterview() {
172
188
  if (
173
189
  !(await showPromptDialog({
@@ -318,6 +334,19 @@ export class NodeDetails extends LitElement {
318
334
  }
319
335
 
320
336
  static override styles = css`
337
+ .node-label-row {
338
+ display: flex;
339
+ align-items: center;
340
+ gap: 8px;
341
+ }
342
+
343
+ .node-label-row md-icon-button {
344
+ width: 24px;
345
+ height: 24px;
346
+ --md-icon-button-state-layer-width: 32px;
347
+ --md-icon-button-state-layer-height: 32px;
348
+ }
349
+
321
350
  .device-icon {
322
351
  --icon-primary-color: var(--md-sys-color-on-surface-variant, #666);
323
352
  }
@@ -291,7 +291,7 @@ class MatterNetworkView extends LitElement {
291
291
  return;
292
292
  }
293
293
 
294
- const found = graph.selectByExtendedAddress(searchValue);
294
+ const found = graph.selectBySearch(searchValue);
295
295
  this._threadAddressSearchStatus = found ? "found" : "not-found";
296
296
  }
297
297
 
@@ -306,8 +306,8 @@ class MatterNetworkView extends LitElement {
306
306
  type="text"
307
307
  .value=${this._threadAddressSearch}
308
308
  @input=${this._handleThreadAddressSearchInput}
309
- placeholder="Search extended address"
310
- title="Find device by Thread extended address (EUI-64)"
309
+ placeholder="Search label, node id, or address"
310
+ title="Find a Thread device by node label, node id, or extended address (EUI-64)"
311
311
  />
312
312
  <button type="submit" class="search-button">Find</button>
313
313
  </form>
@@ -371,7 +371,7 @@ class MatterNetworkView extends LitElement {
371
371
  : html`<div class="thread-search-status ${this._threadAddressSearchStatus}">
372
372
  ${this._threadAddressSearchStatus === "found"
373
373
  ? "Node highlighted."
374
- : "No matching extended address found."}
374
+ : "No matching device found."}
375
375
  </div>`}
376
376
  <thread-graph
377
377
  .nodes=${this.nodes}
@@ -479,8 +479,6 @@ class MatterNetworkView extends LitElement {
479
479
  flex: 1 1 0;
480
480
  padding: 8px 16px;
481
481
  gap: 8px;
482
- max-width: 1600px;
483
- margin: 0 auto;
484
482
  width: 100%;
485
483
  box-sizing: border-box;
486
484
  min-height: 0;
@@ -683,7 +681,7 @@ class MatterNetworkView extends LitElement {
683
681
  }
684
682
 
685
683
  .details-sidebar {
686
- width: 320px;
684
+ width: clamp(320px, 22vw, 480px);
687
685
  flex-shrink: 0;
688
686
  display: none;
689
687
  min-height: 0;
@@ -24,6 +24,7 @@ import {
24
24
  buildExtAddrMap,
25
25
  buildRloc16Map,
26
26
  buildThreadEdgePairs,
27
+ decodeMeshcopStateBitmap,
27
28
  findUnknownDevices,
28
29
  getDeviceName,
29
30
  getEdgeSignalScore,
@@ -113,36 +114,56 @@ export class ThreadGraph extends BaseNetworkGraph {
113
114
  }
114
115
 
115
116
  /**
116
- * Searches for a Thread node (known or unknown) by extended address and selects it.
117
- * Accepts formats like:
118
- * - AABBCCDDEEFF0011
119
- * - AA:BB:CC:DD:EE:FF:00:11
120
- * - 0xAABBCCDDEEFF0011
117
+ * Searches for a Thread node (known or unknown) and selects it. Matches, in priority order:
118
+ * 1. Extended address (EUI-64), accepting `AABBCCDDEEFF0011`, `AA:BB:...`, or `0x...` forms.
119
+ * 2. Node id (exact).
120
+ * 3. Visible device label (case-insensitive substring).
121
121
  * Returns true when a match is found.
122
122
  */
123
- public selectByExtendedAddress(address: string): boolean {
124
- const normalized = normalizeExtendedAddressInput(address);
125
- if (!normalized) {
123
+ public selectBySearch(query: string): boolean {
124
+ const trimmed = query.trim();
125
+ if (!trimmed) {
126
126
  return false;
127
127
  }
128
128
 
129
- // Search commissioned Thread devices first
130
- for (const node of Object.values(this.nodes)) {
131
- if (getNetworkType(node) !== "thread") {
132
- continue;
129
+ // Only visible nodes are selectable — focusing a hidden node would report a
130
+ // match the user can't see on the graph.
131
+ const threadNodes = Object.values(this.nodes).filter(
132
+ node => getNetworkType(node) === "thread" && !(this.hideOfflineNodes && node.available === false),
133
+ );
134
+
135
+ // 1. Extended address — commissioned devices first, then unknown/external neighbors.
136
+ const normalized = normalizeExtendedAddressInput(trimmed);
137
+ if (normalized) {
138
+ for (const node of threadNodes) {
139
+ const extAddressHex = getThreadExtendedAddressHex(node);
140
+ if (extAddressHex && extAddressHex === normalized) {
141
+ this.selectNode(String(node.node_id));
142
+ return true;
143
+ }
133
144
  }
134
145
 
135
- const extAddressHex = getThreadExtendedAddressHex(node);
136
- if (extAddressHex && extAddressHex === normalized) {
146
+ for (const [unknownId, unknown] of this._unknownDevicesMapCache) {
147
+ if (normalizeExtendedAddressInput(unknown.extAddressHex) === normalized) {
148
+ this.selectNode(unknownId);
149
+ return true;
150
+ }
151
+ }
152
+ }
153
+
154
+ // 2. Node id (exact).
155
+ for (const node of threadNodes) {
156
+ if (String(node.node_id) === trimmed) {
137
157
  this.selectNode(String(node.node_id));
138
158
  return true;
139
159
  }
140
160
  }
141
161
 
142
- // Then search unknown/external Thread devices
143
- for (const [unknownId, unknown] of this._unknownDevicesMapCache) {
144
- if (normalizeExtendedAddressInput(unknown.extAddressHex) === normalized) {
145
- this.selectNode(unknownId);
162
+ // 3. Device label as shown on the graph (case-insensitive substring).
163
+ const needle = trimmed.toLowerCase();
164
+ for (const node of threadNodes) {
165
+ if (getDeviceName(node).toLowerCase().includes(needle)) {
166
+ this.selectNode(String(node.node_id));
146
167
  return true;
147
168
  }
148
169
  }
@@ -257,10 +278,13 @@ export class ThreadGraph extends BaseNetworkGraph {
257
278
  ? `\n${device.networkName}`
258
279
  : "";
259
280
  const label = `${top}${suffix}`;
281
+ const decodedState = decodeMeshcopStateBitmap(device.stateBitmapHex);
282
+ const isLeader = decodedState?.threadRoleValue === 3;
283
+ const isPrimaryBbr = decodedState?.bbr === true && decodedState.bbrFunction === "primary";
260
284
  graphNodes.push({
261
285
  id: device.id,
262
286
  label,
263
- image: createBorderRouterIconDataUrl(isSelected),
287
+ image: createBorderRouterIconDataUrl(isSelected, isLeader, isPrimaryBbr),
264
288
  shape: "image" as const,
265
289
  networkType: "thread" as const,
266
290
  isUnknown: false,
@@ -620,9 +644,12 @@ export class ThreadGraph extends BaseNetworkGraph {
620
644
  }
621
645
  const external = this._unknownDevicesMapCache.get(nodeId);
622
646
  if (external?.kind === "br") {
647
+ const decodedState = decodeMeshcopStateBitmap(external.stateBitmapHex);
648
+ const isLeader = decodedState?.threadRoleValue === 3;
649
+ const isPrimaryBbr = decodedState?.bbr === true && decodedState.bbrFunction === "primary";
623
650
  this._nodesDataSet.update({
624
651
  id: nodeId,
625
- image: createBorderRouterIconDataUrl(isHighlighted),
652
+ image: createBorderRouterIconDataUrl(isHighlighted, isLeader, isPrimaryBbr),
626
653
  });
627
654
  } else if (nodeId.startsWith("unknown_") || nodeId.startsWith("br_")) {
628
655
  this._nodesDataSet.update({
@@ -17,6 +17,8 @@ import {
17
17
  mdiCast,
18
18
  mdiCctv,
19
19
  mdiChip,
20
+ mdiCircleMedium,
21
+ mdiCrown,
20
22
  mdiDishwasher,
21
23
  mdiDoorbell,
22
24
  mdiDoorbellVideo,
@@ -40,12 +42,15 @@ import {
40
42
  mdiRobotVacuum,
41
43
  mdiRouter,
42
44
  mdiRouterWireless,
45
+ mdiSleep,
43
46
  mdiSmokeDetector,
44
47
  mdiSnowflakeAlert,
45
48
  mdiSolarPower,
46
49
  mdiSpeaker,
47
50
  mdiSprinkler,
51
+ mdiStar,
48
52
  mdiStove,
53
+ mdiSwapHorizontal,
49
54
  mdiTelevision,
50
55
  mdiThermometer,
51
56
  mdiToggleSwitch,
@@ -303,6 +308,19 @@ const threadRoleToIcon: Record<number, string> = {
303
308
  6: mdiAccessPoint, // Leader
304
309
  };
305
310
 
311
+ /**
312
+ * Corner badge marking a node's Thread RoutingRole (attr 0/53/1) — a role-rank indicator overlaid on
313
+ * the device icon. The Leader is the rare, high-signal exception (amber crown); routers and end
314
+ * devices use progressively lower-key glyphs. Unassigned/Unspecified and unknown roles get no badge.
315
+ */
316
+ const THREAD_ROLE_BADGES: Record<number, { iconPath: string; colorVar: string; colorFallback: string }> = {
317
+ 2: { iconPath: mdiSleep, colorVar: "--node-color-thread-enddevice", colorFallback: "#90a4ae" }, // Sleepy End Device
318
+ 3: { iconPath: mdiCircleMedium, colorVar: "--node-color-thread-enddevice", colorFallback: "#90a4ae" }, // End Device
319
+ 4: { iconPath: mdiCircleMedium, colorVar: "--node-color-thread-enddevice", colorFallback: "#90a4ae" }, // REED
320
+ 5: { iconPath: mdiSwapHorizontal, colorVar: "--node-color-thread-router", colorFallback: "#1e88e5" }, // Router
321
+ 6: { iconPath: mdiCrown, colorVar: "--node-color-thread-leader", colorFallback: "#f9a825" }, // Leader
322
+ };
323
+
306
324
  /**
307
325
  * Utility device types (per Matter spec) deprioritized when selecting the primary type for icon display.
308
326
  * These are commonly reported alongside the actual application type (e.g., a light also reports as
@@ -453,14 +471,26 @@ export function getNetworkTypeIcon(networkType: string): string {
453
471
  * @param iconPath - The MDI icon path
454
472
  * @param color - The icon color (CSS color string)
455
473
  * @param size - The icon size in pixels
474
+ * @param badge - Optional top-right corner badge: an MDI glyph filled white on a colored disc
456
475
  * @returns A data URL containing the SVG
457
476
  */
458
- export function createIconDataUrl(iconPath: string, color: string, size: number = 48): string {
477
+ export function createIconDataUrl(
478
+ iconPath: string,
479
+ color: string,
480
+ size: number = 48,
481
+ badge?: { iconPath: string; color: string },
482
+ ): string {
459
483
  // MDI icons use a 24x24 viewBox
484
+ const badgeMarkup =
485
+ badge !== undefined
486
+ ? `<circle cx="18" cy="6" r="4" fill="${badge.color}"/>
487
+ <path d="${badge.iconPath}" fill="white" transform="translate(14.64,2.64) scale(0.28)"/>`
488
+ : "";
460
489
  const svg = `
461
490
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="${size}" height="${size}">
462
491
  <circle cx="12" cy="12" r="11" fill="white" stroke="${color}" stroke-width="1"/>
463
492
  <path d="${iconPath}" fill="${color}" transform="scale(0.6) translate(8,8)"/>
493
+ ${badgeMarkup}
464
494
  </svg>
465
495
  `.trim();
466
496
 
@@ -492,7 +522,14 @@ export function createNodeIconDataUrl(
492
522
  } else {
493
523
  color = getDefaultIconColor(); // Theme-aware default
494
524
  }
495
- return createIconDataUrl(iconPath, color);
525
+ // Thread RoutingRole (incl. Leader) applies to any router node, not just BRs. Badge it over the
526
+ // device icon rather than replacing the icon, preserving device identity.
527
+ const roleBadge = threadRole !== undefined ? THREAD_ROLE_BADGES[threadRole] : undefined;
528
+ const badge =
529
+ roleBadge !== undefined
530
+ ? { iconPath: roleBadge.iconPath, color: getCssVar(roleBadge.colorVar, roleBadge.colorFallback) }
531
+ : undefined;
532
+ return createIconDataUrl(iconPath, color, 48, badge);
496
533
  }
497
534
 
498
535
  /**
@@ -511,14 +548,31 @@ export function createUnknownDeviceIconDataUrl(isRouter: boolean = false, isSele
511
548
 
512
549
  /**
513
550
  * Creates an SVG data URL for a Thread Border Router identified via mDNS.
551
+ *
552
+ * Thread Leader (mesh routing role) and Primary BBR (backbone role) are orthogonal: the central
553
+ * glyph reflects the mesh role (crown for leader, router otherwise) while a corner star badge marks
554
+ * the primary BBR. A BR that is both shows both.
555
+ *
514
556
  * @param isSelected - Whether the node is selected
557
+ * @param isLeader - Whether this BR is the Thread network leader (from MeshCoP state bitmap)
558
+ * @param isPrimaryBbr - Whether this BR is the primary Backbone Border Router (from MeshCoP state bitmap)
515
559
  * @returns A data URL containing the SVG
516
560
  */
517
- export function createBorderRouterIconDataUrl(isSelected: boolean = false): string {
561
+ export function createBorderRouterIconDataUrl(
562
+ isSelected: boolean = false,
563
+ isLeader: boolean = false,
564
+ isPrimaryBbr: boolean = false,
565
+ ): string {
566
+ const glyph = isLeader ? mdiCrown : mdiRouterWireless;
518
567
  const color = isSelected
519
568
  ? getCssVar("--node-color-selected", "#1976d2")
520
- : getCssVar("--md-sys-color-primary", "#03a9f4");
521
- return createIconDataUrl(mdiRouterWireless, color);
569
+ : isLeader
570
+ ? getCssVar("--node-color-thread-leader", "#f9a825")
571
+ : getCssVar("--md-sys-color-primary", "#03a9f4");
572
+ const badge = isPrimaryBbr
573
+ ? { iconPath: mdiStar, color: getCssVar("--node-color-primary-bbr", "#00897b") }
574
+ : undefined;
575
+ return createIconDataUrl(glyph, color, 48, badge);
522
576
  }
523
577
 
524
578
  /**
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { MatterClient, MatterNode } from "@matter-server/ws-client";
8
+
9
+ // BasicInformation cluster (0x28 / 40), always on endpoint 0 per Matter specification.
10
+ export const NODE_LABEL_CLUSTER_ID = 0x28;
11
+ export const NODE_LABEL_ATTRIBUTE_ID = 5;
12
+ export const MAX_NODE_LABEL_LENGTH = 32;
13
+
14
+ export const NODE_LABEL_ATTRIBUTE_PATH = `0/${NODE_LABEL_CLUSTER_ID}/${NODE_LABEL_ATTRIBUTE_ID}`;
15
+
16
+ /**
17
+ * Write the BasicInformation NodeLabel attribute for a node.
18
+ * Trims surrounding whitespace so the stored value matches what MatterNode.nodeLabel reads back.
19
+ */
20
+ export function writeNodeLabel(client: MatterClient, node: MatterNode, label: string): Promise<unknown> {
21
+ return client.writeAttribute(node.node_id, NODE_LABEL_ATTRIBUTE_PATH, label.trim());
22
+ }