-
${this.node.nodeLabel || "Node Info"}
+
${this.node.customLabel || this.node.nodeLabel || "Node Info"}
+
this._editCustomLabel())}
+ >
+
+
${
this.node.available
? nothing
@@ -164,6 +171,44 @@ export class NodeDetails extends LitElement {
`;
}
+ private async _editCustomLabel() {
+ const newLabel = await showInputDialog({
+ title: "Edit Node Label",
+ text: "Enter a custom label for this node. Leave empty to clear.",
+ label: "Custom label",
+ defaultValue: this.node!.customLabel,
+ confirmText: "Save",
+ });
+ if (newLabel === null) return; // cancelled
+ try {
+ await this.client.setCustomNodeLabel(this.node!.node_id, newLabel);
+
+ // Offer to push the new label to Home Assistant if configured
+ if (newLabel && this.client.serverInfo.ha_credentials_set) {
+ const pushToHa = await showPromptDialog({
+ title: "Update Home Assistant?",
+ text: "Also update this device name in Home Assistant?",
+ confirmText: "Update HA",
+ });
+ if (pushToHa) {
+ try {
+ await this.client.pushNodeLabelToHa(this.node!.node_id);
+ } catch (haErr) {
+ showAlertDialog({
+ title: "Failed to update Home Assistant",
+ text: haErr instanceof Error ? haErr.message : String(haErr),
+ });
+ }
+ }
+ }
+ } catch (err) {
+ showAlertDialog({
+ title: "Failed to set node label",
+ text: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
private async _reinterview() {
if (
!(await showPromptDialog({
@@ -340,5 +385,12 @@ export class NodeDetails extends LitElement {
font-weight: bold;
font-size: 0.8em;
}
+
+ .edit-label-btn {
+ --md-icon-button-icon-size: 18px;
+ --md-icon-button-state-layer-height: 28px;
+ --md-icon-button-state-layer-width: 28px;
+ vertical-align: middle;
+ }
`;
}
diff --git a/packages/dashboard/src/pages/components/server-details.ts b/packages/dashboard/src/pages/components/server-details.ts
index c636beec..fdbc240f 100644
--- a/packages/dashboard/src/pages/components/server-details.ts
+++ b/packages/dashboard/src/pages/components/server-details.ts
@@ -57,6 +57,15 @@ export class ServerDetails extends LitElement {
Node count:
${Object.keys(this.client.nodes).length}
+
+
HA integration:
${
+ this.client.serverInfo.ha_credentials_set === undefined
+ ? "N/A"
+ : this.client.serverInfo.ha_credentials_set
+ ? "Configured"
+ : "Not configured"
+ }
+
diff --git a/packages/dashboard/src/pages/matter-server-view.ts b/packages/dashboard/src/pages/matter-server-view.ts
index 527c0e7b..6932bea7 100644
--- a/packages/dashboard/src/pages/matter-server-view.ts
+++ b/packages/dashboard/src/pages/matter-server-view.ts
@@ -106,7 +106,7 @@ class MatterServerView extends LitElement {
}
- ${node.nodeLabel ? `${node.nodeLabel} | ` : nothing} ${node.vendorName} |
+ ${node.customLabel ? `${node.customLabel} | ` : nothing}${node.nodeLabel ? `${node.nodeLabel} | ` : nothing}${node.vendorName} |
${node.productName}
diff --git a/packages/dashboard/src/pages/network/network-utils.ts b/packages/dashboard/src/pages/network/network-utils.ts
index 89265a48..ca8cbe70 100644
--- a/packages/dashboard/src/pages/network/network-utils.ts
+++ b/packages/dashboard/src/pages/network/network-utils.ts
@@ -494,6 +494,10 @@ export function getSignalColorFromLqi(lqi: number): string {
* Format: nodeLabel || productName (serialNumber)
*/
export function getDeviceName(node: MatterNode): string {
+ if (node.customLabel) {
+ return node.customLabel;
+ }
+
if (node.nodeLabel) {
return node.nodeLabel;
}
diff --git a/packages/matter-server/test/HomeAssistantClientTest.ts b/packages/matter-server/test/HomeAssistantClientTest.ts
new file mode 100644
index 00000000..19ccf314
--- /dev/null
+++ b/packages/matter-server/test/HomeAssistantClientTest.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2025-2026 Open Home Foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { HomeAssistantClient, type HaDevice } from "@matter-server/ws-controller";
+
+function makeDevice(
+ id: string,
+ name: string,
+ nameByUser: string | null,
+ identifiers: Array<[string, string]>,
+): HaDevice {
+ return { id, name, name_by_user: nameByUser, identifiers };
+}
+
+describe("HomeAssistantClient", () => {
+ describe("matchDevicesToNodes", () => {
+ const fabricId = 12345678901234567890n;
+ const prefix = `deviceid_${fabricId}-`;
+
+ it("should match devices by compressed_fabric_id and node_id", () => {
+ const devices: HaDevice[] = [
+ makeDevice("dev-1", "Kitchen Light", "My Kitchen", [["matter", `${prefix}1-1`]]),
+ makeDevice("dev-2", "Bedroom Plug", null, [["matter", `${prefix}2-1`]]),
+ ];
+
+ const matches = HomeAssistantClient.matchDevicesToNodes(devices, fabricId);
+
+ expect(matches.size).to.equal(2);
+ expect(matches.get("1")!.name).to.equal("My Kitchen");
+ expect(matches.get("1")!.deviceId).to.equal("dev-1");
+ expect(matches.get("2")!.name).to.equal("Bedroom Plug"); // Falls back to device name
+ });
+
+ it("should prefer endpoint 0 when multiple endpoints exist for same node", () => {
+ const devices: HaDevice[] = [
+ makeDevice("dev-ep1", "Light EP1", "EP1 Name", [["matter", `${prefix}1-1`]]),
+ makeDevice("dev-ep0", "Light EP0", "EP0 Name", [["matter", `${prefix}1-0`]]),
+ makeDevice("dev-ep2", "Light EP2", "EP2 Name", [["matter", `${prefix}1-2`]]),
+ ];
+
+ const matches = HomeAssistantClient.matchDevicesToNodes(devices, fabricId);
+
+ expect(matches.size).to.equal(1);
+ expect(matches.get("1")!.deviceId).to.equal("dev-ep0");
+ expect(matches.get("1")!.name).to.equal("EP0 Name");
+ });
+
+ it("should ignore devices from other domains", () => {
+ const devices: HaDevice[] = [
+ makeDevice("dev-zb", "Zigbee Device", null, [["zha", "00:11:22:33"]]),
+ makeDevice("dev-m", "Matter Device", "My Device", [["matter", `${prefix}5-0`]]),
+ ];
+
+ const matches = HomeAssistantClient.matchDevicesToNodes(devices, fabricId);
+
+ expect(matches.size).to.equal(1);
+ expect(matches.has("5")).to.be.true;
+ });
+
+ it("should ignore devices from different fabrics", () => {
+ const otherFabric = 99999999999999999999n;
+ const otherPrefix = `deviceid_${otherFabric}-`;
+ const devices: HaDevice[] = [
+ makeDevice("dev-other", "Other Fabric", null, [["matter", `${otherPrefix}1-0`]]),
+ makeDevice("dev-ours", "Our Fabric", "Ours", [["matter", `${prefix}1-0`]]),
+ ];
+
+ const matches = HomeAssistantClient.matchDevicesToNodes(devices, fabricId);
+
+ expect(matches.size).to.equal(1);
+ expect(matches.get("1")!.deviceId).to.equal("dev-ours");
+ });
+
+ it("should return empty map when no Matter devices exist", () => {
+ const devices: HaDevice[] = [makeDevice("dev-1", "Non-Matter", null, [["zha", "addr"]])];
+
+ const matches = HomeAssistantClient.matchDevicesToNodes(devices, fabricId);
+
+ expect(matches.size).to.equal(0);
+ });
+
+ it("should use name_by_user over default name", () => {
+ const devices: HaDevice[] = [
+ makeDevice("dev-1", "Default Name", "User Name", [["matter", `${prefix}1-0`]]),
+ ];
+
+ const matches = HomeAssistantClient.matchDevicesToNodes(devices, fabricId);
+
+ expect(matches.get("1")!.name).to.equal("User Name");
+ });
+
+ it("should fall back to default name when name_by_user is null", () => {
+ const devices: HaDevice[] = [makeDevice("dev-1", "Default Name", null, [["matter", `${prefix}1-0`]])];
+
+ const matches = HomeAssistantClient.matchDevicesToNodes(devices, fabricId);
+
+ expect(matches.get("1")!.name).to.equal("Default Name");
+ });
+ });
+});
diff --git a/packages/matter-server/test/IntegrationTest.ts b/packages/matter-server/test/IntegrationTest.ts
index 0926d764..45fcb329 100644
--- a/packages/matter-server/test/IntegrationTest.ts
+++ b/packages/matter-server/test/IntegrationTest.ts
@@ -558,6 +558,54 @@ describe("Integration Test", function () {
});
});
+ // =========================================================================
+ // Custom Node Label Tests
+ // =========================================================================
+
+ describe("Custom Node Label", function () {
+ it("should set custom node label and receive node_updated event", async function () {
+ client.clearEvents();
+
+ await client.setCustomNodeLabel(commissionedNodeId, "My Kitchen Plug");
+
+ // Should receive node_updated event with custom_label
+ const event = await client.waitForEvent(
+ "node_updated",
+ data => Number((data as { node_id: number }).node_id) === commissionedNodeId,
+ 5_000,
+ );
+ expect(event).to.exist;
+
+ const node = event.data as { node_id: number; custom_label?: string };
+ expect(node.custom_label).to.equal("My Kitchen Plug");
+ });
+
+ it("should include custom_label in get_node response", async function () {
+ // Label was set in the previous test
+ const nodes = await client.getNodes();
+ const node = nodes.find(n => Number(n.node_id) === commissionedNodeId);
+ expect(node).to.exist;
+ expect(node!.custom_label).to.equal("My Kitchen Plug");
+ });
+
+ it("should clear custom label with empty string", async function () {
+ client.clearEvents();
+
+ await client.setCustomNodeLabel(commissionedNodeId, "");
+
+ const event = await client.waitForEvent(
+ "node_updated",
+ data => Number((data as { node_id: number }).node_id) === commissionedNodeId,
+ 5_000,
+ );
+ expect(event).to.exist;
+
+ // custom_label should be absent or empty
+ const node = event.data as { node_id: number; custom_label?: string };
+ expect(node.custom_label ?? "").to.equal("");
+ });
+ });
+
// =========================================================================
// Commissioning Window Tests
// =========================================================================
diff --git a/packages/ws-client/src/client.ts b/packages/ws-client/src/client.ts
index c28c40c5..21ea5a5e 100644
--- a/packages/ws-client/src/client.ts
+++ b/packages/ws-client/src/client.ts
@@ -189,6 +189,26 @@ export class MatterClient {
await this.sendCommand("interview_node", 0, { node_id: nodeId }, timeout);
}
+ async setCustomNodeLabel(nodeId: number | bigint, label: string, timeout?: number): Promise {
+ // Set a custom user-defined label for a node. Empty string to clear.
+ await this.sendCommand("set_custom_node_label", 0, { node_id: nodeId, label }, timeout);
+ }
+
+ async setHaCredentials(url: string, token: string, timeout?: number): Promise {
+ // Set Home Assistant URL and long-lived access token for name sync.
+ await this.sendCommand("set_ha_credentials", 0, { url, token }, timeout);
+ }
+
+ async syncHaNames(timeout?: number): Promise<{ synced: number; errors: string[] }> {
+ // Pull device names from Home Assistant and sync as custom node labels.
+ return await this.sendCommand("sync_ha_names", 0, {}, timeout);
+ }
+
+ async pushNodeLabelToHa(nodeId: number | bigint, timeout?: number): Promise {
+ // Push a node's custom label to Home Assistant's device registry.
+ await this.sendCommand("push_node_label_to_ha", 0, { node_id: nodeId }, timeout);
+ }
+
async importTestNode(dump: string, timeout?: number): Promise {
// Import test node(s) from a HA or Matter server diagnostics dump.
await this.sendCommand("import_test_node", 0, { dump }, timeout);
@@ -506,8 +526,6 @@ export class MatterClient {
}
private _handleEventMessage(event: EventMessage) {
- console.log("Incoming event", event);
-
// Allow subclasses to hook into raw events (for testing)
this.onRawEvent(event);
diff --git a/packages/ws-client/src/models/model.ts b/packages/ws-client/src/models/model.ts
index 48fd2dbe..f4634beb 100644
--- a/packages/ws-client/src/models/model.ts
+++ b/packages/ws-client/src/models/model.ts
@@ -182,6 +182,22 @@ export interface APICommands {
requestArgs: { console_loglevel?: LogLevelString; file_loglevel?: LogLevelString };
response: LogLevelResponse;
};
+ set_custom_node_label: {
+ requestArgs: { node_id: number | bigint; label: string };
+ response: null;
+ };
+ set_ha_credentials: {
+ requestArgs: { url: string; token: string };
+ response: null;
+ };
+ sync_ha_names: {
+ requestArgs: Record;
+ response: { synced: number; errors: string[] };
+ };
+ push_node_label_to_ha: {
+ requestArgs: { node_id: number | bigint };
+ response: null;
+ };
}
/** Utility type to extract request args for a command */
@@ -257,6 +273,8 @@ export interface ServerInfoMessage {
wifi_credentials_set: boolean;
thread_credentials_set: boolean;
bluetooth_enabled: boolean;
+ /** Whether Home Assistant credentials are configured. Optional - not available in Python Matter Server. */
+ ha_credentials_set?: boolean;
}
/** WebSocket event types and their data payloads */
diff --git a/packages/ws-client/src/models/node.ts b/packages/ws-client/src/models/node.ts
index 5762b992..55203cc3 100644
--- a/packages/ws-client/src/models/node.ts
+++ b/packages/ws-client/src/models/node.ts
@@ -22,6 +22,11 @@ export interface MatterNodeData {
* Optional - not available in Python Matter Server.
*/
matter_version?: string;
+ /**
+ * Custom user-defined label for the node (optional).
+ * Stored server-side, separate from Matter's NodeLabel attribute (0/40/5).
+ */
+ custom_label?: string;
}
export class MatterNode {
@@ -39,6 +44,11 @@ export class MatterNode {
* Optional - not available in Python Matter Server.
*/
matter_version?: string;
+ /**
+ * Custom user-defined label for the node (optional).
+ * Stored server-side, separate from Matter's NodeLabel attribute (0/40/5).
+ */
+ custom_label?: string;
constructor(public data: MatterNodeData) {
this.node_id = data.node_id;
@@ -50,6 +60,11 @@ export class MatterNode {
this.attributes = data.attributes;
this.attribute_subscriptions = data.attribute_subscriptions;
this.matter_version = data.matter_version;
+ this.custom_label = data.custom_label;
+ }
+
+ get customLabel(): string {
+ return this.custom_label ?? "";
}
get nodeLabel(): string {
diff --git a/packages/ws-client/test/WsClientTest.ts b/packages/ws-client/test/WsClientTest.ts
index 600ab448..b2909975 100644
--- a/packages/ws-client/test/WsClientTest.ts
+++ b/packages/ws-client/test/WsClientTest.ts
@@ -10,6 +10,7 @@ import {
ConnectionClosedError,
DEFAULT_COMMAND_TIMEOUT,
MatterClient,
+ MatterNode,
WebSocketLike,
} from "../src/index.js";
import { parseBigIntAwareJson, toBigIntAwareJson } from "../src/json-utils.js";
@@ -301,6 +302,72 @@ describe("ws-client", () => {
expect(receivedDump).to.equal(dumpWithLargeNumber);
});
+ it("should send set_custom_node_label command", async () => {
+ let receivedArgs: { node_id: number | bigint; label: string } | undefined;
+ server.onCommand("set_custom_node_label", args => {
+ receivedArgs = args as { node_id: number | bigint; label: string };
+ return null;
+ });
+ await client.connect();
+
+ await client.setCustomNodeLabel(BigInt(1), "My Custom Label");
+
+ expect(receivedArgs).to.exist;
+ expect(receivedArgs!.label).to.equal("My Custom Label");
+ });
+
+ it("should send empty label to clear custom node label", async () => {
+ let receivedArgs: { node_id: number | bigint; label: string } | undefined;
+ server.onCommand("set_custom_node_label", args => {
+ receivedArgs = args as { node_id: number | bigint; label: string };
+ return null;
+ });
+ await client.connect();
+
+ await client.setCustomNodeLabel(BigInt(1), "");
+
+ expect(receivedArgs).to.exist;
+ expect(receivedArgs!.label).to.equal("");
+ });
+
+ it("should send set_ha_credentials command", async () => {
+ let receivedArgs: { url: string; token: string } | undefined;
+ server.onCommand("set_ha_credentials", args => {
+ receivedArgs = args as { url: string; token: string };
+ return null;
+ });
+ await client.connect();
+
+ await client.setHaCredentials("http://ha.local:8123", "my-token");
+
+ expect(receivedArgs).to.exist;
+ expect(receivedArgs!.url).to.equal("http://ha.local:8123");
+ expect(receivedArgs!.token).to.equal("my-token");
+ });
+
+ it("should send sync_ha_names command", async () => {
+ server.onCommand("sync_ha_names", () => ({ synced: 3, errors: [] }));
+ await client.connect();
+
+ const result = await client.syncHaNames();
+
+ expect(result.synced).to.equal(3);
+ expect(result.errors).to.deep.equal([]);
+ });
+
+ it("should send push_node_label_to_ha command", async () => {
+ let receivedArgs: { node_id: number | bigint } | undefined;
+ server.onCommand("push_node_label_to_ha", args => {
+ receivedArgs = args as { node_id: number | bigint };
+ return null;
+ });
+ await client.connect();
+
+ await client.pushNodeLabelToHa(BigInt(1));
+
+ expect(receivedArgs).to.exist;
+ });
+
it("should handle error responses", async () => {
server.onCommand("remove_node", () => {
throw new Error("Node not found");
@@ -323,9 +390,12 @@ describe("ws-client", () => {
await client.startListening();
const nodeId = BigInt("18446744069414584320");
- let nodesChangedCalled = false;
- client.addEventListener("nodes_changed", () => {
- nodesChangedCalled = true;
+
+ const eventReceived = new Promise(resolve => {
+ const removeListener = client.addEventListener("nodes_changed", () => {
+ removeListener();
+ resolve();
+ });
});
server.sendEvent("node_added", {
@@ -338,10 +408,7 @@ describe("ws-client", () => {
attributes: {},
});
- // Wait for event to be processed
- await new Promise(resolve => setTimeout(resolve, 100));
-
- expect(nodesChangedCalled).to.be.true;
+ await eventReceived;
const nodeKey = String(nodeId);
expect(client.nodes[nodeKey]).to.exist;
});
@@ -364,23 +431,98 @@ describe("ws-client", () => {
await client.startListening();
- let nodesChangedCalled = false;
- client.addEventListener("nodes_changed", () => {
- nodesChangedCalled = true;
+ const eventReceived = new Promise(resolve => {
+ const removeListener = client.addEventListener("nodes_changed", () => {
+ removeListener();
+ resolve();
+ });
});
// Send attribute update event
server.sendEvent("attribute_updated", [nodeId, "1/6/0", true]);
- // Wait for event to be processed
- await new Promise(resolve => setTimeout(resolve, 100));
-
- expect(nodesChangedCalled).to.be.true;
+ await eventReceived;
const nodeKey = String(nodeId);
expect(client.nodes[nodeKey]?.attributes["1/6/0"]).to.equal(true);
});
});
+ describe("MatterNode model", () => {
+ it("should return customLabel from custom_label field", () => {
+ const node = new MatterNode({
+ node_id: 1,
+ date_commissioned: "2025-01-01T00:00:00.000000",
+ last_interview: "2025-01-01T00:00:00.000000",
+ interview_version: 6,
+ available: true,
+ is_bridge: false,
+ attributes: {},
+ attribute_subscriptions: [],
+ custom_label: "My Device",
+ });
+ expect(node.customLabel).to.equal("My Device");
+ expect(node.custom_label).to.equal("My Device");
+ });
+
+ it("should return empty string when custom_label is undefined", () => {
+ const node = new MatterNode({
+ node_id: 1,
+ date_commissioned: "2025-01-01T00:00:00.000000",
+ last_interview: "2025-01-01T00:00:00.000000",
+ interview_version: 6,
+ available: true,
+ is_bridge: false,
+ attributes: {},
+ attribute_subscriptions: [],
+ });
+ expect(node.customLabel).to.equal("");
+ expect(node.custom_label).to.be.undefined;
+ });
+
+ it("should preserve custom_label through update()", () => {
+ const node = new MatterNode({
+ node_id: 1,
+ date_commissioned: "2025-01-01T00:00:00.000000",
+ last_interview: "2025-01-01T00:00:00.000000",
+ interview_version: 6,
+ available: true,
+ is_bridge: false,
+ attributes: {},
+ attribute_subscriptions: [],
+ custom_label: "Original Label",
+ });
+ const updated = node.update({ available: false });
+ expect(updated.customLabel).to.equal("Original Label");
+ expect(updated.available).to.be.false;
+ });
+
+ it("should receive custom_label via node_updated event", async () => {
+ server.onCommand("start_listening", () => []);
+ await client.startListening();
+
+ server.sendEvent("node_updated", {
+ node_id: 1,
+ date_commissioned: "2025-01-01T00:00:00.000000",
+ last_interview: "2025-01-01T00:00:00.000000",
+ interview_version: 6,
+ available: true,
+ is_bridge: false,
+ attributes: {},
+ custom_label: "Event Label",
+ });
+
+ await new Promise(resolve => {
+ const removeListener = client.addEventListener("nodes_changed", () => {
+ removeListener();
+ resolve();
+ });
+ });
+
+ expect(client.nodes["1"]).to.exist;
+ expect(client.nodes["1"].customLabel).to.equal("Event Label");
+ });
+ });
+
describe("raw message handling", () => {
it("should correctly parse messages with large numbers only as JSON values", async () => {
await client.connect();
diff --git a/packages/ws-controller/src/index.ts b/packages/ws-controller/src/index.ts
index 722aa768..5ff5ae78 100644
--- a/packages/ws-controller/src/index.ts
+++ b/packages/ws-controller/src/index.ts
@@ -20,6 +20,7 @@ export * from "./model/ModelMapper.js";
// Export server handlers and types
export * from "./server/ConfigStorage.js";
export * from "./server/Converters.js";
+export * from "./server/HomeAssistantClient.js";
export * from "./server/WebSocketControllerHandler.js";
export * from "./types/WebServer.js";
diff --git a/packages/ws-controller/src/server/ConfigStorage.ts b/packages/ws-controller/src/server/ConfigStorage.ts
index 7fe63c3e..ce95c594 100644
--- a/packages/ws-controller/src/server/ConfigStorage.ts
+++ b/packages/ws-controller/src/server/ConfigStorage.ts
@@ -8,7 +8,7 @@ import { Environment, Logger, StorageContext, StorageManager, StorageService } f
const logger = new Logger("ConfigStorage");
-const SENSITIVE_KEYS: ReadonlySet = new Set(["wifiCredentials", "threadDataset"]);
+const SENSITIVE_KEYS: ReadonlySet = new Set(["wifiCredentials", "threadDataset", "haToken"]);
function sanitizeForLog(key: string, value: unknown): string {
if (SENSITIVE_KEYS.has(key as keyof ConfigData)) {
@@ -24,6 +24,8 @@ interface ConfigData {
wifiSsid?: string;
wifiCredentials?: string;
threadDataset?: string;
+ haUrl?: string;
+ haToken?: string;
}
export class ConfigStorage {
@@ -31,12 +33,16 @@ export class ConfigStorage {
#storageService?: StorageService;
#storage?: StorageManager;
#configStore?: StorageContext;
+ #nodeLabelStore?: StorageContext;
+ readonly #nodeLabels = new Map();
readonly #data: ConfigData = {
nextNodeId: 1,
fabricLabel: "HomeAssistant",
wifiSsid: undefined,
wifiCredentials: undefined,
threadDataset: undefined,
+ haUrl: undefined,
+ haToken: undefined,
};
static async create(env: Environment) {
@@ -79,7 +85,25 @@ export class ConfigStorage {
const threadDataset = (await this.#configStore.has("threadDataset"))
? await this.#configStore.get("threadDataset", "")
: undefined;
- await this.set({ fabricLabel, nextNodeId, wifiSsid, wifiCredentials, threadDataset });
+ const haUrl = (await this.#configStore.has("haUrl"))
+ ? await this.#configStore.get("haUrl", "")
+ : undefined;
+ const haToken = (await this.#configStore.has("haToken"))
+ ? await this.#configStore.get("haToken", "")
+ : undefined;
+ await this.set({ fabricLabel, nextNodeId, wifiSsid, wifiCredentials, threadDataset, haUrl, haToken });
+
+ // Load custom node labels
+ this.#nodeLabelStore = this.#storage.createContext("node-labels");
+ for (const key of await this.#nodeLabelStore.keys()) {
+ const label = await this.#nodeLabelStore.get(key);
+ if (label) {
+ this.#nodeLabels.set(key, label);
+ }
+ }
+ if (this.#nodeLabels.size > 0) {
+ logger.info(`Loaded ${this.#nodeLabels.size} custom node label(s)`);
+ }
}
get fabricLabel() {
@@ -97,6 +121,36 @@ export class ConfigStorage {
get threadDataset() {
return this.#data.threadDataset;
}
+ get haUrl() {
+ return this.#data.haUrl;
+ }
+ get haToken() {
+ return this.#data.haToken;
+ }
+
+ /** True if HA credentials are configured (either via storage or SUPERVISOR_TOKEN env var) */
+ get haConfigured(): boolean {
+ return !!(this.#data.haUrl && this.#data.haToken) || !!process.env.SUPERVISOR_TOKEN;
+ }
+
+ getNodeLabel(nodeId: string): string | undefined {
+ return this.#nodeLabels.get(nodeId);
+ }
+
+ async setNodeLabel(nodeId: string, label: string) {
+ if (!this.#nodeLabelStore) {
+ throw new Error("Storage not open");
+ }
+ if (label) {
+ this.#nodeLabels.set(nodeId, label);
+ await this.#nodeLabelStore.set(nodeId, label);
+ logger.debug(`Set custom label for node ${nodeId}`);
+ } else {
+ this.#nodeLabels.delete(nodeId);
+ await this.#nodeLabelStore.delete(nodeId);
+ logger.debug(`Cleared custom label for node ${nodeId}`);
+ }
+ }
async set(data: Partial) {
if (!this.#configStore) {
diff --git a/packages/ws-controller/src/server/HomeAssistantClient.ts b/packages/ws-controller/src/server/HomeAssistantClient.ts
new file mode 100644
index 00000000..9cadbae1
--- /dev/null
+++ b/packages/ws-controller/src/server/HomeAssistantClient.ts
@@ -0,0 +1,192 @@
+/**
+ * @license
+ * Copyright 2025-2026 Open Home Foundation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Logger } from "@matter/main";
+import type { ConfigStorage } from "./ConfigStorage.js";
+
+const logger = Logger.get("HomeAssistantClient");
+
+/** HA device registry entry (subset of fields we need) */
+export interface HaDevice {
+ id: string;
+ name: string;
+ name_by_user: string | null;
+ identifiers: Array<[string, string]>;
+}
+
+/** Result of matching HA devices to Matter nodes */
+export interface HaNodeMatch {
+ /** HA device ID */
+ deviceId: string;
+ /** HA user-assigned name (name_by_user), or default name */
+ name: string;
+ /** The identifier string that matched */
+ identifier: string;
+ /** The endpoint number extracted from the identifier */
+ endpoint: number;
+}
+
+/**
+ * Stateless HTTP client for Home Assistant REST API.
+ * Each call is independent — no persistent connection.
+ */
+export class HomeAssistantClient {
+ constructor(
+ private readonly baseUrl: string,
+ private readonly token: string,
+ ) {}
+
+ /**
+ * Create a client from the SUPERVISOR_TOKEN env var (HA add-on mode).
+ * Returns undefined if not running as an add-on.
+ */
+ static fromSupervisor(): HomeAssistantClient | undefined {
+ const token = process.env.SUPERVISOR_TOKEN;
+ if (!token) return undefined;
+ logger.info("Detected Home Assistant Supervisor environment");
+ return new HomeAssistantClient("http://supervisor/core", token);
+ }
+
+ /**
+ * Create a client from stored HA credentials in ConfigStorage.
+ * Returns undefined if credentials are not configured.
+ */
+ static fromConfig(config: ConfigStorage): HomeAssistantClient | undefined {
+ const url = config.haUrl;
+ const token = config.haToken;
+ if (!url || !token) return undefined;
+ return new HomeAssistantClient(url, token);
+ }
+
+ /**
+ * Create a client, preferring stored config over Supervisor token.
+ */
+ static create(config: ConfigStorage): HomeAssistantClient | undefined {
+ return HomeAssistantClient.fromConfig(config) ?? HomeAssistantClient.fromSupervisor();
+ }
+
+ /**
+ * Test the HA connection by fetching the API status.
+ */
+ async testConnection(): Promise {
+ try {
+ const response = await this.#fetch("/api/");
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Fetch all devices from the HA device registry.
+ */
+ async getDeviceRegistry(): Promise {
+ const response = await this.#fetch("/api/config/device_registry/list");
+ if (!response.ok) {
+ throw new Error(`HA device registry request failed: ${response.status} ${response.statusText}`);
+ }
+ return (await response.json()) as HaDevice[];
+ }
+
+ /**
+ * Update a device's user-assigned name in HA.
+ */
+ async updateDeviceName(deviceId: string, name: string): Promise {
+ const response = await this.#fetch("/api/config/device_registry/update", {
+ method: "POST",
+ body: JSON.stringify({
+ device_id: deviceId,
+ name_by_user: name || null,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(`HA device update failed: ${response.status} ${response.statusText}`);
+ }
+ }
+
+ /**
+ * Match HA Matter devices to matterjs node IDs.
+ *
+ * HA uses identifiers like ["matter", "deviceid_--"].
+ * We match on compressed_fabric_id and node_id, preferring endpoint 0.
+ *
+ * @returns Map of node_id (string) → HaNodeMatch
+ */
+ static matchDevicesToNodes(devices: HaDevice[], compressedFabricId: bigint): Map {
+ const fabricStr = compressedFabricId.toString();
+ const prefix = `deviceid_${fabricStr}-`;
+ const matches = new Map();
+
+ for (const device of devices) {
+ for (const [domain, identifier] of device.identifiers) {
+ if (domain !== "matter" || !identifier.startsWith(prefix)) continue;
+
+ // Parse: deviceid_--
+ const suffix = identifier.slice(prefix.length);
+ const dashIdx = suffix.indexOf("-");
+ if (dashIdx === -1) continue;
+
+ const nodeIdStr = suffix.slice(0, dashIdx);
+ const endpointStr = suffix.slice(dashIdx + 1);
+ // Ensure nodeId and endpoint are strictly decimal digits to avoid partial numeric parses
+ if (!/^\d+$/.test(nodeIdStr) || !/^\d+$/.test(endpointStr)) continue;
+ const endpoint = Number(endpointStr);
+ if (!Number.isSafeInteger(endpoint)) continue;
+
+ // Prefer endpoint 0 (root), otherwise keep lowest endpoint
+ const existing = matches.get(nodeIdStr);
+ if (!existing) {
+ matches.set(nodeIdStr, {
+ deviceId: device.id,
+ name: device.name_by_user?.trim() || device.name,
+ identifier,
+ endpoint,
+ });
+ } else if (endpoint === 0 || (existing.endpoint !== 0 && endpoint < existing.endpoint)) {
+ matches.set(nodeIdStr, {
+ deviceId: device.id,
+ name: device.name_by_user?.trim() || device.name,
+ identifier,
+ endpoint,
+ });
+ }
+ }
+ }
+
+ return matches;
+ }
+
+ /** Default timeout for HA API requests (30 seconds) */
+ static readonly REQUEST_TIMEOUT_MS = 30_000;
+
+ async #fetch(path: string, init?: RequestInit): Promise {
+ const url = `${this.baseUrl}${path}`;
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), HomeAssistantClient.REQUEST_TIMEOUT_MS);
+ try {
+ const headers = new Headers(init?.headers);
+ headers.set("Authorization", `Bearer ${this.token}`);
+ if (!headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ return await fetch(url, {
+ ...init,
+ signal: controller.signal,
+ headers,
+ });
+ } catch (err) {
+ if (err instanceof DOMException && err.name === "AbortError") {
+ throw new Error(
+ `Home Assistant request timed out after ${HomeAssistantClient.REQUEST_TIMEOUT_MS}ms: ${path}`,
+ );
+ }
+ throw err;
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+}
diff --git a/packages/ws-controller/src/server/WebSocketControllerHandler.ts b/packages/ws-controller/src/server/WebSocketControllerHandler.ts
index 36de390a..09b35e93 100644
--- a/packages/ws-controller/src/server/WebSocketControllerHandler.ts
+++ b/packages/ws-controller/src/server/WebSocketControllerHandler.ts
@@ -39,6 +39,7 @@ import {
splitAttributePath,
toBigIntAwareJson,
} from "./Converters.js";
+import { HomeAssistantClient } from "./HomeAssistantClient.js";
const logger = Logger.get("WebSocketControllerHandler");
@@ -75,6 +76,8 @@ export class WebSocketControllerHandler implements WebServerHandler {
#eventHistory: MatterNodeEvent[] = [];
/** Track when each node was last interviewed (connected) - keyed by nodeId */
#lastInterviewDates = new Map();
+ /** Home Assistant API client (undefined if not configured) */
+ #haClient?: HomeAssistantClient;
constructor(controller: MatterController, config: ConfigStorage, serverVersion: string) {
this.#controller = controller;
@@ -82,6 +85,11 @@ export class WebSocketControllerHandler implements WebServerHandler {
this.#testNodeHandler = new TestNodeCommandHandler();
this.#config = config;
this.#serverVersion = serverVersion;
+ // Auto-detect Home Assistant (stored config preferred over Supervisor token)
+ this.#haClient = HomeAssistantClient.create(config);
+ if (this.#haClient) {
+ logger.info("Home Assistant integration enabled");
+ }
}
/**
@@ -142,7 +150,8 @@ export class WebSocketControllerHandler implements WebServerHandler {
case "node_updated": {
try {
const nodeDetails = this.#collectNodeDetails(nodeId);
- logger.debug(
+ (
+ (
`[${connId}] Sending ${eventName} event for Node ${this.#commandHandler.formatNode(nodeId)}`,
);
ws.send(toBigIntAwareJson({ event: eventName, data: nodeDetails }));
@@ -483,6 +492,18 @@ export class WebSocketControllerHandler implements WebServerHandler {
case "set_loglevel":
result = this.#handleSetLogLevel(args);
break;
+ case "set_custom_node_label":
+ result = await this.#handleSetCustomNodeLabel(args);
+ break;
+ case "set_ha_credentials":
+ result = await this.#handleSetHaCredentials(args);
+ break;
+ case "sync_ha_names":
+ result = await this.#handleSyncHaNames();
+ break;
+ case "push_node_label_to_ha":
+ result = await this.#handlePushNodeLabelToHa(args);
+ break;
default:
throw ServerError.invalidCommand(command);
}
@@ -531,6 +552,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
wifi_credentials_set: !!(this.#config.wifiSsid && this.#config.wifiCredentials),
thread_credentials_set: !!this.#config.threadDataset,
bluetooth_enabled: this.#commandHandler.bleEnabled,
+ ha_credentials_set: this.#config.haConfigured,
};
}
@@ -680,6 +702,7 @@ export class WebSocketControllerHandler implements WebServerHandler {
// Include test nodes
for (const testNode of this.#testNodeHandler.getNodes()) {
if (!only_available || testNode.available) {
+ this.#applyCustomLabel(testNode);
nodeDetails.push(testNode);
}
}
@@ -696,10 +719,13 @@ export class WebSocketControllerHandler implements WebServerHandler {
}
// Pass the last interview date for real nodes
+ let details: MatterNode;
if (handler === this.#commandHandler) {
- return this.#commandHandler.getNodeDetails(nodeId, this.#lastInterviewDates.get(nodeId));
+ details = this.#commandHandler.getNodeDetails(nodeId, this.#lastInterviewDates.get(nodeId));
+ } else {
+ details = await handler.getNodeDetails(nodeId);
}
- return handler.getNodeDetails(nodeId);
+ return this.#applyCustomLabel(details);
}
async #handleGetNodeIpAddresses(
@@ -992,9 +1018,177 @@ export class WebSocketControllerHandler implements WebServerHandler {
return await this.#commandHandler.updateNode(NodeId(node_id), targetVersion);
}
+ async #handleSetCustomNodeLabel(
+ args: ArgsOf<"set_custom_node_label">,
+ ): Promise> {
+ const { node_id, label } = args;
+ const normalizedLabel = label?.trim() ?? "";
+ const nodeId = NodeId(node_id);
+ const handler = this.#handlerFor(node_id);
+
+ if (!handler.hasNode(nodeId)) {
+ throw ServerError.nodeNotExists(node_id);
+ }
+
+ await this.#config.setNodeLabel(String(nodeId), normalizedLabel);
+
+ // Broadcast node_updated so all connected clients see the new label
+ if (handler === this.#commandHandler) {
+ this.#broadcastEvent("node_updated", this.#collectNodeDetails(nodeId));
+ } else {
+ const details = await handler.getNodeDetails(nodeId);
+ this.#broadcastEvent("node_updated", this.#applyCustomLabel(details));
+ }
+
+ return null;
+ }
+
+ async #handleSetHaCredentials(args: ArgsOf<"set_ha_credentials">): Promise> {
+ const { url, token } = args;
+ // Normalize: trim whitespace and strip trailing slash from URL
+ const normalizedUrl = url.trim().replace(/\/+$/, "");
+ const trimmedToken = token.trim();
+
+ // Enforce both-or-neither: partial config is invalid
+ if ((normalizedUrl && !trimmedToken) || (!normalizedUrl && trimmedToken)) {
+ throw ServerError.invalidArguments("Both URL and token must be provided, or both empty to clear");
+ }
+
+ // Validate non-empty URLs
+ if (normalizedUrl) {
+ try {
+ const parsed = new URL(normalizedUrl);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw ServerError.invalidArguments(`Unsupported URL protocol: ${parsed.protocol}`);
+ }
+ } catch (err) {
+ if (err instanceof ServerError) throw err;
+ throw ServerError.invalidArguments(`Invalid Home Assistant URL: ${normalizedUrl}`);
+ }
+ }
+
+ await this.#config.set({ haUrl: normalizedUrl, haToken: trimmedToken });
+
+ // Prefer explicit credentials from config; if missing, fall back to Supervisor/env client
+ const clientFromConfig = HomeAssistantClient.fromConfig(this.#config);
+ if (clientFromConfig) {
+ this.#haClient = clientFromConfig;
+ logger.debug(`Home Assistant credentials configured for ${normalizedUrl}`);
+ } else {
+ const supervisorClient = HomeAssistantClient.create(this.#config);
+ if (supervisorClient) {
+ this.#haClient = supervisorClient;
+ logger.info("Home Assistant stored credentials missing; falling back to Supervisor configuration");
+ } else {
+ this.#haClient = undefined;
+ logger.info("Home Assistant credentials cleared; Home Assistant integration disabled");
+ }
+ }
+
+ try {
+ await this.#broadcastServerInfoUpdated();
+ } catch (error) {
+ logger.warn("Failed to broadcast server info update", error);
+ }
+ return null;
+ }
+
+ async #handleSyncHaNames(): Promise> {
+ if (!this.#haClient) {
+ throw ServerError.invalidArguments("Home Assistant is not configured");
+ }
+
+ const errors: string[] = [];
+ const updatedNodeIds: NodeId[] = [];
+
+ try {
+ const devices = await this.#haClient.getDeviceRegistry();
+ const { compressedFabricId } = await this.#commandHandler.getCommissionerFabricData();
+ const matches = HomeAssistantClient.matchDevicesToNodes(devices, compressedFabricId);
+
+ for (const [nodeIdStr, match] of matches) {
+ try {
+ const nodeId = NodeId(BigInt(nodeIdStr));
+ if (!this.#commandHandler.hasNode(nodeId)) continue;
+
+ const currentLabel = this.#config.getNodeLabel(nodeIdStr);
+ if (currentLabel === match.name) continue; // Already in sync
+
+ await this.#config.setNodeLabel(nodeIdStr, match.name);
+ updatedNodeIds.push(nodeId);
+ } catch (err) {
+ errors.push(`Node ${nodeIdStr}: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+ } catch (err) {
+ throw ServerError.unknownError(
+ `Failed to sync from Home Assistant: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ // Broadcast updates after all labels are persisted to avoid per-node WS traffic bursts
+ for (const nodeId of updatedNodeIds) {
+ this.#broadcastEvent("node_updated", this.#collectNodeDetails(nodeId));
+ }
+
+ if (updatedNodeIds.length > 0) {
+ logger.info(`Synced ${updatedNodeIds.length} node name(s) from Home Assistant`);
+ }
+ return { synced: updatedNodeIds.length, errors };
+ }
+
+ async #handlePushNodeLabelToHa(
+ args: ArgsOf<"push_node_label_to_ha">,
+ ): Promise> {
+ if (!this.#haClient) {
+ throw ServerError.invalidArguments("Home Assistant is not configured");
+ }
+
+ const { node_id } = args;
+ const nodeId = NodeId(node_id);
+ const handler = this.#handlerFor(node_id);
+
+ if (!handler.hasNode(nodeId)) {
+ throw ServerError.nodeNotExists(node_id);
+ }
+
+ const nodeIdStr = String(nodeId);
+ const label = this.#config.getNodeLabel(nodeIdStr);
+ if (!label) {
+ throw ServerError.invalidArguments(`Node ${node_id} has no custom label to push`);
+ }
+
+ try {
+ const devices = await this.#haClient.getDeviceRegistry();
+ const { compressedFabricId } = await this.#commandHandler.getCommissionerFabricData();
+ const matches = HomeAssistantClient.matchDevicesToNodes(devices, compressedFabricId);
+ const match = matches.get(nodeIdStr);
+
+ if (!match) {
+ throw ServerError.invalidArguments(`Node ${node_id} not found in Home Assistant device registry`);
+ }
+
+ await this.#haClient.updateDeviceName(match.deviceId, label);
+ logger.debug(`Pushed custom label to Home Assistant for node ${node_id}`);
+ } catch (err) {
+ if (err instanceof ServerError) throw err;
+ throw ServerError.unknownError(
+ `Failed to push label to Home Assistant: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ return null;
+ }
+
+ #applyCustomLabel(details: MatterNode): MatterNode {
+ const customLabel = this.#config.getNodeLabel(String(details.node_id));
+ return customLabel ? { ...details, custom_label: customLabel } : details;
+ }
+
#collectNodeDetails(nodeId: NodeId): MatterNode {
const lastInterviewDate = this.#lastInterviewDates.get(nodeId);
- return this.#commandHandler.getNodeDetails(nodeId, lastInterviewDate);
+ const details = this.#commandHandler.getNodeDetails(nodeId, lastInterviewDate);
+ return this.#applyCustomLabel(details);
}
#convertCommandDataToWebSocket(