/** * @license * Copyright 2025-2026 Open Home Foundation * SPDX-License-Identifier: Apache-2.0 */ import { ContextProvider } from "@lit/context"; import { MatterClient, MatterError } from "@matter-server/ws-client"; import { mdiRefresh } from "@mdi/js"; import { LitElement, PropertyValueMap, css, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { clientContext, tickContext } from "../client/client-context.js"; import "../components/ha-svg-icon"; import type { Route } from "../util/routing.js"; import "./components/header"; import type { ActiveView } from "./components/header.js"; import "./matter-cluster-view"; import "./matter-endpoint-view"; import "./matter-network-view"; import "./matter-node-view"; import "./matter-server-view"; import { categorizeDevices } from "./network/network-utils.js"; declare global { interface HTMLElementTagNameMap { "matter-dashboard-app": MatterDashboardApp; } } @customElement("matter-dashboard-app") class MatterDashboardApp extends LitElement { @state() private _route: Route = { prefix: "", path: [], }; @state() private _activeView: ActiveView = "nodes"; /** Initial selected node ID from URL (string to avoid BigInt precision loss) */ @state() private _initialSelectedNodeId: string | null = null; public client!: MatterClient; @state() private _state: "connecting" | "connected" | "error" | "disconnected" = "connecting"; @state() private _tick = 0; /** Track whether nodes have been loaded at least once (to avoid redirecting before data arrives) */ private _nodesLoaded = false; private clientProvider = new ContextProvider(this, { context: clientContext }); private tickProvider = new ContextProvider(this, { context: tickContext, initialValue: 0 }); /** Reference to updateRoute function so it can be called from event listeners */ private _updateRoute?: () => void; protected override firstUpdated(_changedProperties: PropertyValueMap | Map): void { super.firstUpdated(_changedProperties); this._connect(); // Handle history changes this._updateRoute = () => { const hash = location.hash.substring(1); const pathParts = hash.split("/"); // Reset initial selected node this._initialSelectedNodeId = null; // Get device counts for conditional navigation const { hasThreadDevices, hasWifiDevices } = this._getDeviceCounts(); // Determine active view from hash if (pathParts[0] === "thread") { // Only redirect if nodes have been loaded (avoid redirecting on initial load before data arrives) if (this._nodesLoaded && !hasThreadDevices) { location.hash = "#nodes"; return; } this._activeView = "thread"; // Check for node ID: #thread/123 - keep as string to avoid BigInt precision loss if (pathParts.length > 1 && pathParts[1]) { this._initialSelectedNodeId = pathParts[1]; } } else if (pathParts[0] === "wifi") { // Only redirect if nodes have been loaded (avoid redirecting on initial load before data arrives) if (this._nodesLoaded && !hasWifiDevices) { location.hash = "#nodes"; return; } this._activeView = "wifi"; // Check for node ID: #wifi/123 - keep as string to avoid BigInt precision loss if (pathParts.length > 1 && pathParts[1]) { this._initialSelectedNodeId = pathParts[1]; } } else if (hash === "nodes" || hash === "" || pathParts[0] === "node") { this._activeView = "nodes"; } this._route = { prefix: pathParts.length == 1 ? "" : pathParts[0], path: pathParts.length == 1 ? pathParts : pathParts.slice(1), }; }; window.addEventListener("hashchange", this._updateRoute); this._updateRoute(); } private _connect() { this.client.startListening().then( () => { // Publish context before flipping state so the connected render sees a defined client. this.clientProvider.setValue(this.client); this.tickProvider.setValue(++this._tick); this._state = "connected"; this._setupEventListeners(); }, (_err: MatterError) => { this._state = "error"; }, ); } private _setupEventListeners() { this.client.addEventListener("nodes_changed", () => { // Mark nodes as loaded and re-evaluate route (for redirect logic) const wasFirstLoad = !this._nodesLoaded; this._nodesLoaded = true; if (wasFirstLoad && this._updateRoute) { // Re-run route check now that nodes are available this._updateRoute(); } this.requestUpdate(); this.tickProvider.setValue(++this._tick); }); this.client.addEventListener("server_info_updated", () => { this.tickProvider.setValue(++this._tick); }); this.client.addEventListener("connection_lost", () => { this._state = "disconnected"; }); } private _reconnect = () => { this._state = "connecting"; this._connect(); }; /** * Get device counts for Thread and WiFi networks. */ private _getDeviceCounts(): { hasThreadDevices: boolean; hasWifiDevices: boolean } { if (!this.client?.nodes) { return { hasThreadDevices: false, hasWifiDevices: false }; } const categorized = categorizeDevices(this.client.nodes); return { hasThreadDevices: categorized.thread.length > 0, hasWifiDevices: categorized.wifi.length > 0 || categorized.ethernet.length > 0, }; } override render() { if (this._state === "connecting") { return html`

Connecting...

`; } if (this._state === "disconnected") { return html`

Connection lost

The connection to the Matter Server was lost. Please check if the server is running.

`; } if (this._state === "error") { return html`

No connection

Unable to connect to the Matter Server. Please check if the server is running.

`; } if (this._route.prefix === "node" && this._route.path.length == 3) { // cluster level return html` `; } if (this._route.prefix === "node" && this._route.path.length == 2) { // endpoint level return html` `; } if (this._route.prefix === "node") { // node level return html` `; } // 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``; } // Check for WiFi view (#wifi or #wifi/123) if (this._route.prefix === "wifi" || this._route.path[0] === "wifi") { return html``; } // root level: server overview (nodes view) return html``; } static override styles = css` :host { display: block; min-height: 100vh; background-color: var(--md-sys-color-background); } .status-page { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; } .status-message { font-size: 1.5rem; color: var(--md-sys-color-on-background); margin: 0 0 16px 0; } .status-message.error { color: var(--danger-color); } .status-hint { font-size: 1rem; color: var(--md-sys-color-on-surface-variant); margin: 0; max-width: 400px; } .retry-button { display: inline-flex; align-items: center; gap: 8px; margin-top: 24px; padding: 12px 24px; font-size: 1rem; background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); --icon-primary-color: var(--md-sys-color-on-primary); border: none; border-radius: 4px; cursor: pointer; } .retry-button:hover { opacity: 0.9; } .retry-button ha-svg-icon { width: 20px; height: 20px; } `; }