diff --git a/packages/dashboard/src/components/dialog-box/input-dialog-box.ts b/packages/dashboard/src/components/dialog-box/input-dialog-box.ts new file mode 100644 index 00000000..8ae1c5c2 --- /dev/null +++ b/packages/dashboard/src/components/dialog-box/input-dialog-box.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025-2026 Open Home Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import "@material/web/button/text-button"; +import "@material/web/dialog/dialog"; +import "@material/web/textfield/outlined-text-field"; +import type { MdDialog } from "@material/web/dialog/dialog.js"; +import type { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field.js"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { InputDialogBoxParams } from "./show-dialog-box.js"; + +@customElement("input-dialog-box") +export class InputDialogBox extends LitElement { + @property({ attribute: false }) public params!: InputDialogBoxParams; + + @property({ attribute: false }) public dialogResult!: (result: string | null) => void; + + protected override render() { + const params = this.params; + return html` + + ${params.title ? html`
${params.title}
` : ""} +
+ ${params.text ? html`

${params.text}

` : ""} + +
+
+ ${params.cancelText ?? "Cancel"} + ${params.confirmText ?? "OK"} +
+
+ `; + } + + private _handleKeydown(e: KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + this._confirm(); + } + } + + private _cancel() { + if (this._resolved) return; + this._resolved = true; + this.dialogResult(null); + this.shadowRoot!.querySelector("md-dialog")!.close(); + } + + private _resolved = false; + + private _confirm() { + if (this._resolved) return; + this._resolved = true; + const textField = this.shadowRoot!.querySelector("md-outlined-text-field")!; + this.dialogResult(textField.value); + this.shadowRoot!.querySelector("md-dialog")!.close(); + } + + private _handleClosed() { + if (!this._resolved) { + this._resolved = true; + this.dialogResult(null); + } + this.remove(); + } + + static override styles = css` + md-outlined-text-field { + width: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "input-dialog-box": InputDialogBox; + } +} diff --git a/packages/dashboard/src/components/dialog-box/show-dialog-box.ts b/packages/dashboard/src/components/dialog-box/show-dialog-box.ts index 5a0f7f02..82d5909b 100644 --- a/packages/dashboard/src/components/dialog-box/show-dialog-box.ts +++ b/packages/dashboard/src/components/dialog-box/show-dialog-box.ts @@ -28,6 +28,25 @@ const showDialogBox = async (type: "alert" | "prompt", dialogParams: PromptDialo }); }; +export interface InputDialogBoxParams { + title: string; + text?: string; + label?: string; + defaultValue?: string; + confirmText?: string; + cancelText?: string; +} + export const showAlertDialog = (dialogParams: BaseDialogBoxParams) => showDialogBox("alert", dialogParams); export const showPromptDialog = (dialogParams: BaseDialogBoxParams) => showDialogBox("prompt", dialogParams); + +export const showInputDialog = async (dialogParams: InputDialogBoxParams): Promise => { + await import("./input-dialog-box.js"); + return new Promise(resolve => { + const dialog = document.createElement("input-dialog-box"); + dialog.params = dialogParams; + dialog.dialogResult = resolve; + document.body.appendChild(dialog); + }); +}; diff --git a/packages/dashboard/src/components/dialogs/settings/ha-integration-dialog.ts b/packages/dashboard/src/components/dialogs/settings/ha-integration-dialog.ts new file mode 100644 index 00000000..0f6b86f1 --- /dev/null +++ b/packages/dashboard/src/components/dialogs/settings/ha-integration-dialog.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2025-2026 Open Home Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import "@material/web/button/text-button"; +import "@material/web/dialog/dialog"; +import "@material/web/textfield/outlined-text-field"; +import type { MdDialog } from "@material/web/dialog/dialog.js"; +import type { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field.js"; +import { MatterClient } from "@matter-server/ws-client"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { handleAsync } from "../../../util/async-handler.js"; + +@customElement("ha-integration-dialog") +export class HaIntegrationDialog extends LitElement { + @property({ attribute: false }) + public client!: MatterClient; + + @state() private _saving = false; + @state() private _syncing = false; + @state() private _syncResult: string | null = null; + + @query("#ha-url") + private _urlField!: MdOutlinedTextField; + + @query("#ha-token") + private _tokenField!: MdOutlinedTextField; + + private get _haConfigured(): boolean { + return this.client.serverInfo.ha_credentials_set === true; + } + + private async _save() { + const url = this._urlField.value.trim(); + const token = this._tokenField.value.trim(); + + if (!url || !token) { + this._syncResult = "Please enter both URL and token."; + return; + } + + this._saving = true; + this._syncResult = null; + try { + await this.client.setHaCredentials(url, token); + this._syncResult = "Credentials saved."; + // Clear the token field after saving (it's stored server-side) + this._tokenField.value = ""; + } catch (err) { + this._syncResult = `Failed to save: ${err instanceof Error ? err.message : String(err)}`; + } finally { + this._saving = false; + } + } + + private async _sync() { + this._syncing = true; + this._syncResult = null; + try { + const result = await this.client.syncHaNames(); + if (result.errors.length > 0) { + this._syncResult = `Synced ${result.synced} name(s) with ${result.errors.length} error(s).`; + } else if (result.synced === 0) { + this._syncResult = "No new names to sync."; + } else { + this._syncResult = `Synced ${result.synced} name(s) from Home Assistant.`; + } + } catch (err) { + this._syncResult = `Sync failed: ${err instanceof Error ? err.message : String(err)}`; + } finally { + this._syncing = false; + } + } + + private async _clear() { + this._saving = true; + this._syncResult = null; + try { + await this.client.setHaCredentials("", ""); + this._syncResult = "Stored credentials cleared."; + this._urlField.value = ""; + this._tokenField.value = ""; + } catch (err) { + this._syncResult = `Failed to clear: ${err instanceof Error ? err.message : String(err)}`; + } finally { + this._saving = false; + } + } + + private _close() { + this.shadowRoot!.querySelector("md-dialog")!.close(); + } + + private _handleClosed() { + this.remove(); + } + + protected override render() { + return html` + +
Home Assistant Integration
+
+

+ Connect to Home Assistant to sync device names. + ${ + this._haConfigured + ? html` +
Credentials saved + ` + : nothing + } +

+
+ +
+
+ +
+ ${this._syncResult ? html`

${this._syncResult}

` : nothing} +
+
+ this._sync())} + ?disabled=${!this._haConfigured || this._syncing} + > + ${this._syncing ? "Syncing..." : "Sync Names from HA"} + + this._clear())} + ?disabled=${!this._haConfigured || this._saving} + > + Clear + + Cancel + this._save())} + ?disabled=${this._saving} + > + ${this._saving ? "Saving..." : "Save"} + +
+
+ `; + } + + static override styles = css` + .hint { + font-size: 0.875rem; + color: var(--md-sys-color-on-surface-variant); + margin: 0 0 16px 0; + } + + .status-ok { + color: var(--md-sys-color-primary); + font-weight: 500; + } + + .form-field { + margin-bottom: 16px; + } + + md-outlined-text-field { + width: 100%; + } + + .sync-result { + font-size: 0.875rem; + color: var(--md-sys-color-on-surface-variant); + margin: 8px 0 0 0; + font-style: italic; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-integration-dialog": HaIntegrationDialog; + } +} diff --git a/packages/dashboard/src/components/dialogs/settings/settings-menu-dialog.ts b/packages/dashboard/src/components/dialogs/settings/settings-menu-dialog.ts new file mode 100644 index 00000000..252d6e1a --- /dev/null +++ b/packages/dashboard/src/components/dialogs/settings/settings-menu-dialog.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025-2026 Open Home Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import "@material/web/dialog/dialog"; +import "@material/web/list/list"; +import "@material/web/list/list-item"; +import type { MdDialog } from "@material/web/dialog/dialog.js"; +import { MatterClient } from "@matter-server/ws-client"; +import { mdiHomeAssistant, mdiMathLog } from "@mdi/js"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import "../../../components/ha-svg-icon"; +import { showHaIntegrationDialog } from "./show-ha-integration-dialog.js"; +import { showLogLevelDialog } from "./show-log-level-dialog.js"; + +@customElement("settings-menu-dialog") +export class SettingsMenuDialog extends LitElement { + @property({ attribute: false }) + public client!: MatterClient; + + private _openLogLevel() { + this._close(); + showLogLevelDialog(this.client); + } + + private _openHaIntegration() { + this._close(); + showHaIntegrationDialog(this.client); + } + + private _close() { + this.shadowRoot!.querySelector("md-dialog")!.close(); + } + + private _handleClosed() { + this.remove(); + } + + protected override render() { + return html` + +
Settings
+
+ + + +
Log Level
+
Configure server log verbosity
+
+ + +
Home Assistant
+
Sync device names with Home Assistant
+
+
+
+
+ `; + } + + static override styles = css` + md-list { + padding: 0; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "settings-menu-dialog": SettingsMenuDialog; + } +} diff --git a/packages/dashboard/src/components/dialogs/settings/show-ha-integration-dialog.ts b/packages/dashboard/src/components/dialogs/settings/show-ha-integration-dialog.ts new file mode 100644 index 00000000..ad7fe380 --- /dev/null +++ b/packages/dashboard/src/components/dialogs/settings/show-ha-integration-dialog.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2025-2026 Open Home Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MatterClient } from "@matter-server/ws-client"; + +export const showHaIntegrationDialog = async (client: MatterClient) => { + await import("./ha-integration-dialog.js"); + const dialog = document.createElement("ha-integration-dialog"); + dialog.client = client; + document.body.appendChild(dialog); +}; diff --git a/packages/dashboard/src/components/dialogs/settings/show-settings-menu-dialog.ts b/packages/dashboard/src/components/dialogs/settings/show-settings-menu-dialog.ts new file mode 100644 index 00000000..a6df88f4 --- /dev/null +++ b/packages/dashboard/src/components/dialogs/settings/show-settings-menu-dialog.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2025-2026 Open Home Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MatterClient } from "@matter-server/ws-client"; + +export const showSettingsMenuDialog = async (client: MatterClient) => { + await import("./settings-menu-dialog.js"); + const dialog = document.createElement("settings-menu-dialog"); + dialog.client = client; + document.body.appendChild(dialog); +}; diff --git a/packages/dashboard/src/pages/components/header.ts b/packages/dashboard/src/pages/components/header.ts index 79cd349d..5db66379 100644 --- a/packages/dashboard/src/pages/components/header.ts +++ b/packages/dashboard/src/pages/components/header.ts @@ -13,7 +13,7 @@ import { MatterClient } from "@matter-server/ws-client"; import { mdiArrowLeft, mdiBrightnessAuto, mdiCog, mdiLogout, mdiWeatherNight, mdiWeatherSunny } from "@mdi/js"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { showLogLevelDialog } from "../../components/dialogs/settings/show-log-level-dialog.js"; +import { showSettingsMenuDialog } from "../../components/dialogs/settings/show-settings-menu-dialog.js"; import "../../components/ha-svg-icon"; import { EffectiveTheme, ThemePreference, ThemeService } from "../../util/theme-service.js"; @@ -59,7 +59,7 @@ export class DashboardHeader extends LitElement { private _openSettings() { if (this.client) { - showLogLevelDialog(this.client); + showSettingsMenuDialog(this.client); } } diff --git a/packages/dashboard/src/pages/components/node-details.ts b/packages/dashboard/src/pages/components/node-details.ts index 81ee6566..9f7979e0 100644 --- a/packages/dashboard/src/pages/components/node-details.ts +++ b/packages/dashboard/src/pages/components/node-details.ts @@ -13,11 +13,11 @@ import "@material/web/list/list"; import "@material/web/list/list-item"; import { consume } from "@lit/context"; import { MatterClient, MatterNode, UpdateSource } from "@matter-server/ws-client"; -import { mdiChatProcessing, mdiLink, mdiShareVariant, mdiTrashCan, mdiUpdate } from "@mdi/js"; +import { mdiChatProcessing, mdiLink, mdiPencil, mdiShareVariant, mdiTrashCan, mdiUpdate } from "@mdi/js"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { DeviceType } from "../../client/models/descriptions.js"; -import { showAlertDialog, showPromptDialog } from "../../components/dialog-box/show-dialog-box.js"; +import { showAlertDialog, showInputDialog, showPromptDialog } from "../../components/dialog-box/show-dialog-box.js"; import { showNodeBindingDialog } from "../../components/dialogs/binding/show-node-binding-dialog.js"; import { handleAsync } from "../../util/async-handler.js"; import "../../components/ha-svg-icon"; @@ -80,7 +80,14 @@ export class NodeDetails extends LitElement {
- ${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(