/** * @license * Copyright 2025-2026 Open Home Foundation * SPDX-License-Identifier: Apache-2.0 */ import "@material/web/button/filled-button"; import "@material/web/button/outlined-button"; import "@material/web/button/text-button"; 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, UpdateSource } from "@matter-server/ws-client"; import { mdiChatProcessing, mdiPencil, mdiShareVariant, mdiTrashCan, mdiUpdate, mdiVideo } from "@mdi/js"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { clientContext, tickContext } from "../../client/client-context.js"; import { DeviceType } from "../../client/models/descriptions.js"; import { showAlertDialog, showPromptDialog } from "../../components/dialog-box/show-dialog-box.js"; import { showNodeLabelDialog } from "../../components/dialogs/node-label-dialog/show-node-label-dialog.js"; import { handleAsync } from "../../util/async-handler.js"; import "../../components/ha-svg-icon"; import "../camera-overlay.js"; import { getDeviceIcon } from "../../util/device-icons.js"; import { getEndpointDeviceTypes } from "../../util/endpoints.js"; import { bindingContext } from "./context.js"; /** Map updateState values to user-friendly labels */ const UPDATE_STATE_LABELS: Record = { 1: "Idle", 2: "Querying", 3: "Waiting (Querying)", 4: "Downloading", 5: "Applying", 6: "Waiting (Applying)", 7: "Rolling back", 8: "Waiting for consent", }; function getUpdateStateLabel(state: number, progress?: number): string { const label = UPDATE_STATE_LABELS[state] ?? `Unknown (${state})`; // Show progress only for downloading state if (state === 4 && progress !== undefined) { return `${label} (${progress}%)`; } return label; } function getNodeDeviceTypes(node: MatterNode): DeviceType[] { const uniqueEndpoints = new Set(Object.keys(node.attributes).map(key => Number(key.split("/")[0]))); const allDeviceTypes: Set = new Set(); uniqueEndpoints.forEach(endpointId => { getEndpointDeviceTypes(node, endpointId).forEach(deviceType => { allDeviceTypes.add(deviceType); }); }); return Array.from(allDeviceTypes); } @customElement("node-details") export class NodeDetails extends LitElement { @consume({ context: clientContext }) public client!: MatterClient; @consume({ context: tickContext, subscribe: true }) protected _tick = 0; @property() public node?: MatterNode; @state() private _updateInitiated: boolean = false; @consume({ context: bindingContext }) endpoint!: number; protected override render() { if (!this.node) return html``; const deviceTypeIds = getEndpointDeviceTypes(this.node, this.endpoint).map(d => d.id); const isCamera = deviceTypeIds.includes(0x0142) || deviceTypeIds.includes(0x0143); return html`
${this.node.nodeLabel || "Node Info"} ${this.node.available ? html` this._editNodeLabel()} aria-label="Edit node label" title="Edit node label" > ` : nothing} ${this.node.available ? nothing : html` OFFLINE `}
VendorName: ${this.node.vendorName}
ProductName: ${this.node.productName}
Commissioned: ${this.node.date_commissioned}
Last interviewed: ${this.node.last_interview}
Is bridge: ${this.node.is_bridge}
Serialnumber: ${this.node.serialNumber}
${this.node.matter_version ? html`
Matter version: ${this.node.matter_version}
` : nothing} ${this.node.is_bridge ? "" : html`
All device types: ${getNodeDeviceTypes(this.node) .map(deviceType => { return deviceType.label; }) .join(" / ")}
`}
this._reinterview())} >Interview ${this._updateInitiated ? html` Checking for updates` : (this.node.updateState ?? 0) > 1 ? html` ${getUpdateStateLabel( this.node.updateState!, this.node.updateStateProgress, )}` : html` this._searchUpdate())} >Update`} ${isCamera ? html` this._openCameraOverlay()} ?disabled=${!this.node.available} > Live View ` : nothing} this._openCommissioningWindow())} >Share this._remove())} >Remove
`; } private _editNodeLabel() { showNodeLabelDialog(this.client, this.node!); } private async _reinterview() { if ( !(await showPromptDialog({ title: "Reinterview", text: "Are you sure you want to reinterview this node?", confirmText: "Reinterview", })) ) { return; } try { await this.client.interviewNode(this.node!.node_id); showAlertDialog({ title: "Reinterview node", text: "Success!", }); location.hash = "#"; } catch (err: any) { showAlertDialog({ title: "Failed to reinterview node", text: err.message, }); } } private async _remove() { if ( !(await showPromptDialog({ title: "Remove", text: "Are you sure you want to remove this node?", confirmText: "Remove", })) ) { return; } try { await this.client.removeNode(this.node!.node_id); // make sure to navigate back to the root if node details was opened location.replace("#"); } catch (err: any) { showAlertDialog({ title: "Failed to remove node", text: err.message, }); } } private _openCameraOverlay(): void { const overlay = document.createElement("camera-overlay"); overlay.nodeId = this.node!.node_id; overlay.endpointId = this.endpoint; const root = document.querySelector("matter-dashboard-app"); if (root) { root.renderRoot.appendChild(overlay); } else { document.body.appendChild(overlay); } } private async _searchUpdate() { const nodeUpdate = await this.client.checkNodeUpdate(this.node!.node_id); if (!nodeUpdate) { showAlertDialog({ title: "No update available", text: "No update available for this node", }); return; } const isUnverifiedSource = nodeUpdate.update_source !== UpdateSource.MAIN_NET_DCL; if ( !(await showPromptDialog({ title: "Firmware update available", text: html`Found a firmware update for this node on ${nodeUpdate.update_source}. ${isUnverifiedSource ? html`

Warning: This update was found on an unverified source. Updates from test-net or local sources have not been certified and may contain untested firmware that could result in non-functional devices. Applying these updates is entirely at your own risk.

` : nothing}

Do you want to update this node to version ${nodeUpdate.software_version_string}?

Note that updating firmware is at your own risk and may cause the device to malfunction or needs additional handling such as power cycling it and/or recommissioning it. Use with care.

${nodeUpdate.firmware_information ? html`

${nodeUpdate.firmware_information}

` : nothing}`, confirmText: "Start Update", })) ) { return; } try { this._updateInitiated = true; await this.client.updateNode(this.node!.node_id, nodeUpdate.software_version); } catch (err: any) { showAlertDialog({ title: "Failed to update node", text: err.message, }); } finally { this._updateInitiated = false; } } private async _openCommissioningWindow() { if ( !(await showPromptDialog({ title: "Share device", text: "Do you want to share this device with another Matter controller (open commissioning window)?", confirmText: "Share", })) ) { return; } try { const shareCode = await this.client.openCommissioningWindow(this.node!.node_id); showAlertDialog({ title: "Share device", text: `Setup code: ${shareCode.setup_manual_code}`, }); } catch (err: any) { showAlertDialog({ title: "Failed to open commissioning window on node", text: err.message, }); } } static override styles = css` .node-label-row { display: flex; align-items: center; gap: 8px; } .node-label-row md-icon-button { width: 24px; height: 24px; --md-icon-button-state-layer-width: 32px; --md-icon-button-state-layer-height: 32px; } .device-icon { --icon-primary-color: var(--md-sys-color-on-surface-variant, #666); } .btn-row { --md-outlined-button-container-shape: 0px; display: flex; flex-wrap: wrap; gap: 8px; } .btn-row md-outlined-button { max-width: 100%; } .left { min-width: 120px; display: inline-block; } @media (min-width: 600px) { .left { min-width: 150px; } } .whitespace { height: 15px; } .status { color: var(--danger-color); font-weight: bold; font-size: 0.8em; } `; }