Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9bea46e
feat: add custom node labels (server-side naming)
markvp Mar 23, 2026
dbd8c87
feat: add server-side Home Assistant name sync
markvp Mar 23, 2026
b969865
feat: add dashboard UI for HA integration and settings menu
markvp Mar 23, 2026
4c2fa5f
Update packages/ws-controller/src/server/HomeAssistantClient.ts
markvp Mar 23, 2026
391ec6b
Update packages/ws-controller/src/server/WebSocketControllerHandler.ts
markvp Mar 23, 2026
695102c
Update packages/ws-controller/src/server/ConfigStorage.ts
markvp Mar 23, 2026
c3c361c
Update packages/dashboard/src/pages/components/node-details.ts
markvp Mar 23, 2026
1d3cadb
Update packages/dashboard/src/pages/components/server-details.ts
markvp Mar 23, 2026
3e93f81
fix: add timeout to HA API requests via AbortController
markvp Mar 23, 2026
80ed341
Address PR #413 review feedback
markvp Mar 23, 2026
714c517
Address second round of PR #413 review feedback
markvp Mar 23, 2026
87ed998
Address third round of PR #413 review feedback
markvp Mar 24, 2026
5e8e34c
Address fourth round of PR #413 review feedback
markvp Mar 24, 2026
c4774b2
Address fifth round of PR #413 review feedback
markvp Mar 24, 2026
2c6a66d
Address sixth round of PR #413 review feedback
markvp Mar 24, 2026
de556aa
Proactive cleanup addressing review patterns across PR #413
markvp Mar 24, 2026
1740f3f
Proactive hardening: validation, batching, accessibility, comments
markvp Mar 24, 2026
672a2e7
Fix HA client precedence, nodeId key consistency, empty name guard
markvp Mar 24, 2026
cff4d7d
Merge branch 'main' into feat/custom-node-labels
markvp Mar 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/dashboard/src/components/dialog-box/input-dialog-box.ts
Original file line number Diff line number Diff line change
@@ -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`
<md-dialog open @closed=${this._handleClosed}>
${params.title ? html`<div slot="headline">${params.title}</div>` : ""}
<div slot="content">
${params.text ? html`<p>${params.text}</p>` : ""}
<md-outlined-text-field
.value=${params.defaultValue ?? ""}
.label=${params.label ?? ""}
aria-label=${params.label || params.title || "Input"}
@keydown=${this._handleKeydown}
></md-outlined-text-field>
</div>
<div slot="actions">
<md-text-button @click=${this._cancel}>${params.cancelText ?? "Cancel"}</md-text-button>
<md-text-button @click=${this._confirm}>${params.confirmText ?? "OK"}</md-text-button>
</div>
</md-dialog>
`;
}

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<MdDialog>("md-dialog")!.close();
}

private _resolved = false;

private _confirm() {
if (this._resolved) return;
this._resolved = true;
const textField = this.shadowRoot!.querySelector<MdOutlinedTextField>("md-outlined-text-field")!;
this.dialogResult(textField.value);
this.shadowRoot!.querySelector<MdDialog>("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;
}
}
19 changes: 19 additions & 0 deletions packages/dashboard/src/components/dialog-box/show-dialog-box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> => {
await import("./input-dialog-box.js");
return new Promise<string | null>(resolve => {
const dialog = document.createElement("input-dialog-box");
dialog.params = dialogParams;
dialog.dialogResult = resolve;
document.body.appendChild(dialog);
});
};
Original file line number Diff line number Diff line change
@@ -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<MdDialog>("md-dialog")!.close();
}

private _handleClosed() {
this.remove();
}

protected override render() {
return html`
<md-dialog open @closed=${this._handleClosed}>
<div slot="headline">Home Assistant Integration</div>
<div slot="content">
<p class="hint">
Connect to Home Assistant to sync device names.
${
this._haConfigured
? html`
<br /><span class="status-ok">Credentials saved</span>
`
: nothing
}
</p>
<div class="form-field">
<md-outlined-text-field
id="ha-url"
label="Home Assistant URL"
placeholder="http://homeassistant.local:8123"
type="url"
></md-outlined-text-field>
</div>
<div class="form-field">
<md-outlined-text-field
id="ha-token"
label="Access Token"
placeholder="Long-lived access token"
type="password"
></md-outlined-text-field>
</div>
${this._syncResult ? html`<p class="sync-result">${this._syncResult}</p>` : nothing}
</div>
<div slot="actions">
<md-text-button
@click=${handleAsync(() => this._sync())}
?disabled=${!this._haConfigured || this._syncing}
>
${this._syncing ? "Syncing..." : "Sync Names from HA"}
</md-text-button>
<md-text-button
@click=${handleAsync(() => this._clear())}
?disabled=${!this._haConfigured || this._saving}
>
Clear
</md-text-button>
<md-text-button @click=${this._close}>Cancel</md-text-button>
<md-text-button
@click=${handleAsync(() => this._save())}
?disabled=${this._saving}
>
${this._saving ? "Saving..." : "Save"}
</md-text-button>
</div>
</md-dialog>
`;
}

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;
}
}
Original file line number Diff line number Diff line change
@@ -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<MdDialog>("md-dialog")!.close();
}

private _handleClosed() {
this.remove();
}

protected override render() {
return html`
<md-dialog open @closed=${this._handleClosed}>
<div slot="headline">Settings</div>
<div slot="content">
<md-list>
<md-list-item type="button" @click=${this._openLogLevel}>
<ha-svg-icon slot="start" .path=${mdiMathLog}></ha-svg-icon>
<div slot="headline">Log Level</div>
<div slot="supporting-text">Configure server log verbosity</div>
</md-list-item>
<md-list-item type="button" @click=${this._openHaIntegration}>
<ha-svg-icon slot="start" .path=${mdiHomeAssistant}></ha-svg-icon>
<div slot="headline">Home Assistant</div>
<div slot="supporting-text">Sync device names with Home Assistant</div>
</md-list-item>
</md-list>
</div>
</md-dialog>
`;
}

static override styles = css`
md-list {
padding: 0;
}
`;
}

declare global {
interface HTMLElementTagNameMap {
"settings-menu-dialog": SettingsMenuDialog;
}
}
Original file line number Diff line number Diff line change
@@ -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);
};
Loading
Loading