Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { MatterClient, MatterNode } from "@matter-server/ws-client";
import { MatterNode } from "@matter-server/ws-client";

export const showNodeBindingDialog = async (client: MatterClient, node: MatterNode, endpoint: number) => {
export const showNodeBindingDialog = async (node: MatterNode, endpoint: number) => {
await import("./node-binding-dialog.js");
const dialog = document.createElement("node-binding-dialog");
dialog.client = client;
dialog.node = node;
dialog.endpoint = endpoint;
document.querySelector("matter-dashboard-app")?.renderRoot.appendChild(dialog);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@

import "@material/web/button/text-button";
import "@material/web/dialog/dialog";
import type { MdDialog } from "@material/web/dialog/dialog.js";
import { consume } from "@lit/context";
import "@material/web/list/list";
import "@material/web/list/list-item";
import type { MdDialog } from "@material/web/dialog/dialog.js";
import { MatterClient, MatterNode } from "@matter-server/ws-client";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { clientContext } from "../../../client/client-context.js";
import { preventDefault } from "../../../util/prevent_default.js";

@customElement("commission-node-dialog")
export class ComissionNodeDialog extends LitElement {
@property({ attribute: false }) public client!: MatterClient;
@consume({ context: clientContext, subscribe: true })
@property({ attribute: false })
public client!: MatterClient;

@state() private _mode?: "wifi" | "thread" | "existing";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { MatterClient } from "@matter-server/ws-client";

export const showCommissionNodeDialog = async (client: MatterClient) => {
export const showCommissionNodeDialog = async () => {
await import("./commission-node-dialog.js");
const dialog = document.createElement("commission-node-dialog");
dialog.client = client;
document.querySelector("matter-dashboard-app")?.renderRoot.appendChild(dialog);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@

import "@material/web/button/text-button";
import "@material/web/dialog/dialog";
import { consume } from "@lit/context";
import type { MdDialog } from "@material/web/dialog/dialog.js";
import { MatterClient } from "@matter-server/ws-client";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { clientContext } from "../../../client/client-context.js";
import { preventDefault } from "../../../util/prevent_default.js";
import "./log-level-section.js";

@customElement("log-level-dialog")
export class LogLevelDialog extends LitElement {
@consume({ context: clientContext, subscribe: true })
@property({ attribute: false })
public client!: MatterClient;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { MatterClient } from "@matter-server/ws-client";

export const showLogLevelDialog = async (client: MatterClient) => {
export const showLogLevelDialog = async () => {
await import("./log-level-dialog.js");
const dialog = document.createElement("log-level-dialog");
dialog.client = client;
document.querySelector("matter-dashboard-app")?.renderRoot.appendChild(dialog);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { consume } from "@lit/context";
import { MatterClient, MatterNode } from "@matter-server/ws-client";
import { LitElement, css, type CSSResultGroup } from "lit";
import { property } from "lit/decorators.js";
import { clientContext } from "../../client/client-context.js";
import { reducedMotionStyles } from "../../util/shared-styles.js";

/**
* Base class for cluster-specific command panels.
* Provides shared properties, styling, and helper methods for sending commands.
*/
export abstract class BaseClusterCommands extends LitElement {
@consume({ context: clientContext, subscribe: true })
@property({ attribute: false })
public client!: MatterClient;

Expand Down
6 changes: 5 additions & 1 deletion packages/dashboard/src/pages/components/header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import "@material/web/divider/divider";
import "@material/web/iconbutton/icon-button";
import "@material/web/list/list";
import "@material/web/list/list-item";
import { consume } from "@lit/context";
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 { clientContext } from "../../client/client-context.js";
import { showSettingsDialog } from "../../components/dialogs/settings/show-settings-dialog.js";
import "../../components/ha-svg-icon";
import { DevModeService } from "../../util/dev-mode-service.js";
Expand All @@ -35,7 +37,9 @@ export class DashboardHeader extends LitElement {
@property({ type: Boolean }) public hasThreadDevices?: boolean;
@property({ type: Boolean }) public hasWifiDevices?: boolean;

public client?: MatterClient;
@consume({ context: clientContext, subscribe: true })
@property({ attribute: false })
public client!: MatterClient;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Inconsistent migration — settings dialog chain still prop-drills.

_openSettings (line 74) still calls showSettingsDialog(this.client). Inside that chain:

  • show-settings-dialog.ts:9-12 — receives client, assigns dialog.client = client
  • settings-dialog.ts:23 — bare @property (no @consume)
  • settings-dialog.ts:92 — passes .client=${this.client} to <log-level-section>
  • log-level-section.ts — bare @property

Sibling dialogs (commission/log-level/binding) were migrated. Either finish the settings chain for consistency or scope-clarify in the PR description.

Also a type-lie nit on this line: client!: MatterClient (non-null assertion) but lines 73, 165, 178, 190 all guard with if (this.client) — runtime admits undefined. Original client?: MatterClient was more honest. Either keep ?: or remove the guards.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both addressed.

Settings chain: showSettingsDialog now takes no arguments. settings-dialog and log-level-section both use @consume({ context: clientContext, subscribe: true }). The dialog is appended to matter-dashboard-app's renderRoot, so it sits inside the provider's shadow tree and receives context naturally — consistent with how commission/log-level/binding dialogs were already handled.

Type lie: client!: MatterClientclient?: MatterClient, @property({ attribute: false }) removed (see nit below on @consume + @property stacking).


@state() private _themePreference: ThemePreference = ThemeService.preference;
@state() private _effectiveTheme: EffectiveTheme = ThemeService.effectiveTheme;
Expand Down
5 changes: 4 additions & 1 deletion packages/dashboard/src/pages/components/node-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { MatterClient, MatterNode, UpdateSource } from "@matter-server/ws-client
import { mdiChatProcessing, mdiLink, mdiShareVariant, mdiTrashCan, mdiUpdate } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { clientContext } from "../../client/client-context.js";
import { DeviceType } from "../../client/models/descriptions.js";
import { showAlertDialog, showPromptDialog } from "../../components/dialog-box/show-dialog-box.js";
import { showNodeBindingDialog } from "../../components/dialogs/binding/show-node-binding-dialog.js";
Expand Down Expand Up @@ -59,6 +60,8 @@ function getNodeDeviceTypes(node: MatterNode): DeviceType[] {

@customElement("node-details")
export class NodeDetails extends LitElement {
@consume({ context: clientContext, subscribe: true })
@property({ attribute: false })
public client!: MatterClient;

@property() public node?: MatterNode;
Expand Down Expand Up @@ -199,7 +202,7 @@ export class NodeDetails extends LitElement {

private async _binding() {
try {
showNodeBindingDialog(this.client, this.node!, this.endpoint);
showNodeBindingDialog(this.node!, this.endpoint);
} catch (err: unknown) {
console.error("Binding error:", err);
}
Expand Down
12 changes: 8 additions & 4 deletions packages/dashboard/src/pages/components/server-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,22 @@ import "@material/web/divider/divider";
import "@material/web/iconbutton/icon-button";
import "@material/web/list/list";
import "@material/web/list/list-item";
import { consume } from "@lit/context";
import { MatterClient } from "@matter-server/ws-client";
import { mdiFile, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement } from "lit/decorators.js";
import { customElement, property } from "lit/decorators.js";
import { clientContext } from "../../client/client-context.js";
import { showAlertDialog, showPromptDialog } from "../../components/dialog-box/show-dialog-box.js";
import { showCommissionNodeDialog } from "../../components/dialogs/commission-node-dialog/show-commission-node-dialog.js";
import "../../components/ha-svg-icon";
import { handleAsync } from "../../util/async-handler.js";

@customElement("server-details")
export class ServerDetails extends LitElement {
public client?: MatterClient;
@consume({ context: clientContext, subscribe: true })
@property({ attribute: false })
public client!: MatterClient;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type lie: client!: MatterClient paired with if (!this.client) return html\`;on line 32. The runtime guard says it can beundefined; the !says it can't. Originalclient?: MatterClientwas correct. Pick a stance — the codebase is inconsistent (this file asserts,header.ts` guards).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: client!: MatterClientclient?: MatterClient, @property({ attribute: false }) removed. The one access site not covered by the existing if (!this.client) return html``` guard — the importTestNodecall inside areader.onload` closure — was updated to use optional chaining.


protected override render() {
if (!this.client) return html``;
Expand Down Expand Up @@ -71,7 +75,7 @@ export class ServerDetails extends LitElement {
}

private _commissionNode() {
showCommissionNodeDialog(this.client!);
showCommissionNodeDialog();
}

private async _uploadDiagnosticsDumpFile() {
Expand All @@ -96,7 +100,7 @@ export class ServerDetails extends LitElement {
reader.readAsText(selectedFile, "UTF-8");
reader.onload = async () => {
try {
await this.client!.importTestNode(reader.result?.toString() ?? "");
await this.client.importTestNode(reader.result?.toString() ?? "");
} catch (err: any) {
showAlertDialog({
title: "Failed to import test node",
Expand Down
11 changes: 6 additions & 5 deletions packages/dashboard/src/pages/matter-cluster-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { provide } from "@lit/context";
import { consume, provide } from "@lit/context";
import "@material/web/button/outlined-button";
import "@material/web/divider/divider";
import "@material/web/iconbutton/icon-button";
Expand All @@ -16,6 +16,7 @@ import { mdiAlertCircleOutline, mdiPencil, mdiPlay, mdiRefresh } from "@mdi/js";
import { css, html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { clientContext } from "../client/client-context.js";
import { clusters } from "../client/models/descriptions.js";
import { showAlertDialog } from "../components/dialog-box/show-dialog-box.js";
import { showAttributeWriteDialog } from "../components/dialogs/dev/show-attribute-write-dialog.js";
Expand Down Expand Up @@ -74,6 +75,8 @@ function clusterAttributes(attributes: { [key: string]: any }, endpoint: number,

@customElement("matter-cluster-view")
class MatterClusterView extends LitElement {
@consume({ context: clientContext, subscribe: true })
@property({ attribute: false })
public client!: MatterClient;

@property()
Expand Down Expand Up @@ -130,12 +133,11 @@ class MatterClusterView extends LitElement {
<dashboard-header
.title=${`Node ${this.node.node_id} ${nodeHex} | Endpoint ${this.endpoint} | Cluster ${this.cluster} (${clusterName})`}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover .client= binding to flag — line 114 (the "Not found" branch) still passes .client=${this.client} to <dashboard-header>, even though <dashboard-header> now consumes from context. The success branch was cleaned up here; the not-found branch was missed. Drop for consistency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

.backButton=${`#node/${this.node.node_id}/${this.endpoint}`}
.client=${this.client}
></dashboard-header>

<!-- node details section -->
<div class="container">
<node-details .node=${this.node} .client=${this.client}></node-details>
<node-details .node=${this.node}></node-details>
</div>

<!-- Cluster commands section (if available for this cluster) -->
Expand Down Expand Up @@ -375,8 +377,7 @@ class MatterClusterView extends LitElement {
const container = this.shadowRoot?.getElementById("cluster-commands-container");
if (container) {
const commandsElement = container.firstElementChild as any;
if (commandsElement && this.node && this.client) {
commandsElement.client = this.client;
if (commandsElement && this.node) {
commandsElement.node = this.node;
commandsElement.endpoint = this.endpoint;
commandsElement.cluster = this.cluster;
Expand Down
13 changes: 2 additions & 11 deletions packages/dashboard/src/pages/matter-dashboard-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ class MatterDashboardApp extends LitElement {
this.client.startListening().then(
() => {
this._state = "connected";
this.provider.setValue(clone(this.client));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical: clone(this.client) causes msgId divergence → silent command collisions.

MatterClient.msgId is a TS-private primitive (packages/ws-client/src/client.ts:55) incremented by sendCommand (client.ts:371). result_futures (client.ts:46) is a shared object reference. The shallow Object.assign(Object.create(proto), orig) in util/clone_class.ts copies msgId by value but shares result_futures by reference.

Failure path:

  1. Original sends startListeningoriginal.msgId = V.
  2. setValue(clone-A) published; clone-A.msgId = V. UI sends command → clone-A.msgId = V+1, writes result_futures["V+1"] = {clone-A's resolve}.
  3. nodes_changed fires (frequent — every attribute storm) → setValue(clone-B) made from original whose msgId is still Vclone-B.msgId = V.
  4. UI sends another command via clone-Bclone-B.msgId = V+1, writes result_futures["V+1"] = {clone-B's resolve}, overwriting clone-A's pending entry.
  5. Server reply for the first command resolves clone-B's promise. Clone-A's promise hangs until commandTimeout (default 5 minutes) and rejects with CommandTimeoutError.

Silent, intermittent, scales with UI activity during attribute updates.

Fix: don't snapshot the client. Keep the context value as the live MatterClient ref and trigger consumer re-renders via a separate signal — e.g. a tickContext that increments, a custom event on the host, or a Lit reactive controller. Same applies to the two setValue(clone(...)) calls in _setupEventListeners.

Copy link
Copy Markdown
Contributor Author

@markvp markvp May 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — and updated further after reflection.

Initial fix replaced clone(this.client) with provider.setValue(this.client, true), which solved the identity problem but used force: true as an escape hatch rather than a proper design.

On review, your suggested approach is the right one. Switched to a dedicated tickContext:

  • clientContext is set once after startListening() resolves — a stable reference that never changes, so no force or subscription needed.
  • tickContext is a plain incrementing number, published on every nodes_changed / server_info_updated event. Consumers subscribe to this for re-renders.
  • The two concerns are now explicitly separated: stable ref for data, tick for change signal.

clone_class.ts is deleted. settings-dialog and log-level-dialog drop their client consume entirely — neither uses this.client directly after log-level-section migrated to context.

this._setupEventListeners();
Comment on lines 108 to 112
},
(_err: MatterError) => {
Expand Down Expand Up @@ -198,7 +199,6 @@ class MatterDashboardApp extends LitElement {
// cluster level
return html`
<matter-cluster-view
.client=${this.client}
.node=${this.client.nodes[this._route.path[0]]}
.endpoint=${parseInt(this._route.path[1], 10)}
.cluster=${parseInt(this._route.path[2], 10)}
Comment thread
markvp marked this conversation as resolved.
Expand All @@ -209,28 +209,21 @@ class MatterDashboardApp extends LitElement {
// endpoint level
return html`
<matter-endpoint-view
.client=${this.client}
.node=${this.client.nodes[this._route.path[0]]}
.endpoint=${parseInt(this._route.path[1], 10)}
></matter-endpoint-view>
`;
}
if (this._route.prefix === "node") {
// node level
return html`
<matter-node-view
.client=${this.client}
.node=${this.client.nodes[this._route.path[0]]}
></matter-node-view>
`;
return html` <matter-node-view .node=${this.client.nodes[this._route.path[0]]}></matter-node-view> `;
}
// Get device counts for conditional navigation
const { hasThreadDevices, hasWifiDevices } = this._getDeviceCounts();

// Check for Thread view (#thread or #thread/123)
if (this._route.prefix === "thread" || this._route.path[0] === "thread") {
return html`<matter-network-view
.client=${this.client}
.nodes=${this.client.nodes}
.activeView=${this._activeView}
.initialSelectedNodeId=${this._initialSelectedNodeId}
Expand All @@ -242,7 +235,6 @@ class MatterDashboardApp extends LitElement {
// Check for WiFi view (#wifi or #wifi/123)
if (this._route.prefix === "wifi" || this._route.path[0] === "wifi") {
return html`<matter-network-view
.client=${this.client}
.nodes=${this.client.nodes}
.activeView=${this._activeView}
.initialSelectedNodeId=${this._initialSelectedNodeId}
Expand All @@ -253,7 +245,6 @@ class MatterDashboardApp extends LitElement {
}
// root level: server overview (nodes view)
return html`<matter-server-view
.client=${this.client}
.nodes=${this.client.nodes}
.route=${this._route}
.activeView=${this._activeView}
Expand Down
7 changes: 5 additions & 2 deletions packages/dashboard/src/pages/matter-endpoint-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import "@material/web/divider/divider";
import "@material/web/iconbutton/icon-button";
import "@material/web/list/list";
import "@material/web/list/list-item";
import { consume } from "@lit/context";
import { MatterClient, MatterNode, isTestNodeId } from "@matter-server/ws-client";
import { mdiAlertCircleOutline, mdiChevronRight } from "@mdi/js";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import { clientContext } from "../client/client-context.js";
import { DeviceType, clusters, device_types } from "../client/models/descriptions.js";
import "../components/ha-svg-icon";
import { formatHex, formatNodeAddress, getEffectiveFabricIndex } from "../util/format_hex.js";
Expand Down Expand Up @@ -48,6 +50,8 @@ export function getEndpointDeviceTypes(node: MatterNode, endpoint: number): Devi

@customElement("matter-endpoint-view")
class MatterEndpointView extends LitElement {
@consume({ context: clientContext, subscribe: true })
@property({ attribute: false })
public client!: MatterClient;

@property()
Expand Down Expand Up @@ -79,12 +83,11 @@ class MatterEndpointView extends LitElement {
<dashboard-header
.title=${`Node ${this.node.node_id} ${nodeHex} | Endpoint ${this.endpoint}`}
.backButton=${`#node/${this.node.node_id}`}
.client=${this.client}
></dashboard-header>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover .client= binding — line 66 ("Not found" branch) still passes .client=${this.client} to <dashboard-header>. Success branch cleaned, not-found missed. Drop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.


<!-- node details section -->
<div class="container">
<node-details .node=${this.node} .client=${this.client}></node-details>
<node-details .node=${this.node}></node-details>
</div>

<!-- Endpoint clusters listing -->
Expand Down
5 changes: 4 additions & 1 deletion packages/dashboard/src/pages/matter-network-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { consume } from "@lit/context";
import type { MatterClient, MatterNode } from "@matter-server/ws-client";
import { mdiEyeOff, mdiFitToScreen, mdiMagnifyMinus, mdiMagnifyPlus, mdiPause, mdiPlay } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { clientContext } from "../client/client-context.js";
import "../components/ha-svg-icon";
import { reducedMotionStyles } from "../util/shared-styles.js";
import "./components/footer";
Expand Down Expand Up @@ -38,6 +40,8 @@ const HIDE_OPTIONS: readonly { key: HideOptionKey; label: string }[] = [

@customElement("matter-network-view")
class MatterNetworkView extends LitElement {
@consume({ context: clientContext, subscribe: true })
@property({ attribute: false })
public client!: MatterClient;

@property({ type: Object })
Expand Down Expand Up @@ -426,7 +430,6 @@ class MatterNetworkView extends LitElement {
return html`
<dashboard-header
title="Open Home Foundation Matter Server"
.client=${this.client}
.activeView=${this.activeView}
.hasThreadDevices=${this.hasThreadDevices}
.hasWifiDevices=${this.hasWifiDevices}
Expand Down
Loading