import { ClusterId } from "@matter/main"; import { SupportedAttributeClient } from "@project-chip/matter.js/cluster"; import type { Endpoint, PairedNode } from "@project-chip/matter.js/device"; import { Log } from "./logging.js"; const log = new Log("inspection"); const TIME_SYNC_CLUSTER_ID = ClusterId(0x38); /** Standard cluster IDs are <= 0x7fff; anything above is manufacturer-specific. */ const LAST_STANDARD_CLUSTER_ID = 0x7fff; export interface ClusterSummary { id: string; name: string; revision: number; isUnknown: boolean; vendorSpecific: boolean; supportedFeatures: Record; } export interface EndpointSummary { endpointId: number; name: string; deviceTypes: { name: string; code: string; revision: number }[]; serverClusters: ClusterSummary[]; /** Client-side cluster IDs from the endpoint's descriptor. */ clientClusters: string[]; } export interface TimeSyncAttribute { id: string; name: string; value: unknown; } export interface TimeSyncSummary { endpointId: number; clusterRevision: number; supportedFeatures: Record; featureMap: unknown; supportedCommands: Record; attributes: TimeSyncAttribute[]; } export interface InspectionReport { nodeId: string; basicInformation: { vendorName: string; vendorId: number; productName: string; productId: number; hardwareVersion: string; softwareVersion: string; serialNumber?: string; uniqueId?: string; } | null; endpoints: EndpointSummary[]; timeSynchronization: TimeSyncSummary | null; vendorSpecificClusters: { endpointId: number; clusterId: string; name: string }[]; } /** Commands whose availability drives the capability-based sync stages. */ const TIME_SYNC_COMMANDS = [ "setUtcTime", "setTrustedTimeSource", "setTimeZone", "setDstOffset", "setDefaultNtp", ] as const; export async function inspectNode(node: PairedNode): Promise { const endpoints = collectEndpoints(node); const endpointSummaries = endpoints.map(endpoint => summarizeEndpoint(endpoint)); const info = node.basicInformation; const basicInformation = info ? { vendorName: info.vendorName, vendorId: Number(info.vendorId), productName: info.productName, productId: info.productId, hardwareVersion: info.hardwareVersionString, softwareVersion: info.softwareVersionString, serialNumber: info.serialNumber, uniqueId: info.uniqueId, } : null; const timeSynchronization = await summarizeTimeSync(endpoints); if (timeSynchronization === null) { log.warn("Time Synchronization cluster not found on any endpoint"); } else { log.info(`Time Synchronization cluster found on endpoint ${timeSynchronization.endpointId}`); } const vendorSpecificClusters = endpointSummaries.flatMap(endpoint => endpoint.serverClusters .filter(cluster => cluster.vendorSpecific) .map(cluster => ({ endpointId: endpoint.endpointId, clusterId: cluster.id, name: cluster.name })), ); return { nodeId: BigInt(node.nodeId).toString(), basicInformation, endpoints: endpointSummaries, timeSynchronization, vendorSpecificClusters, }; } function collectEndpoints(node: PairedNode): Endpoint[] { const seen = new Map(); const root = node.getRootEndpoint(); if (root?.number !== undefined) seen.set(root.number, root); for (const [number, endpoint] of node.parts) { if (!seen.has(number)) seen.set(number, endpoint); } return [...seen.entries()].sort(([a], [b]) => a - b).map(([, endpoint]) => endpoint); } function summarizeEndpoint(endpoint: Endpoint): EndpointSummary { const serverClusters = endpoint.getAllClusterClients().map(client => ({ id: formatClusterId(client.id), name: client.name, revision: client.revision, isUnknown: client.isUnknown, vendorSpecific: client.id > LAST_STANDARD_CLUSTER_ID, supportedFeatures: Object.fromEntries( Object.entries(client.supportedFeatures ?? {}).filter(([, enabled]) => typeof enabled === "boolean"), ) as Record, })); let clientClusters: string[] = []; try { clientClusters = (endpoint.state.descriptor.clientList ?? []).map(id => formatClusterId(id)); } catch { // Descriptor state unavailable; leave the client list empty. } return { endpointId: endpoint.number ?? -1, name: endpoint.name, deviceTypes: endpoint.getDeviceTypes().map(type => ({ name: type.name, code: `0x${type.code.toString(16).padStart(4, "0")}`, revision: type.revision, })), serverClusters, clientClusters, }; } async function summarizeTimeSync(endpoints: Endpoint[]): Promise { for (const endpoint of endpoints) { const client = endpoint.getClusterClientById(TIME_SYNC_CLUSTER_ID); if (client === undefined) continue; const attributes: TimeSyncAttribute[] = []; for (const [name, attribute] of Object.entries(client.attributes)) { if (!(attribute instanceof SupportedAttributeClient)) continue; let value: unknown; try { value = attribute.getLocal(); if (value === undefined) value = await attribute.get(); } catch (cause) { value = ``; } attributes.push({ id: `0x${attribute.id.toString(16)}`, name, value }); } const supportedCommands = Object.fromEntries( TIME_SYNC_COMMANDS.map(command => [command, client.isCommandSupportedByName(command)]), ); const featureMap = attributes.find(attribute => attribute.name === "featureMap")?.value ?? null; return { endpointId: endpoint.number ?? -1, clusterRevision: client.revision, supportedFeatures: Object.fromEntries( Object.entries(client.supportedFeatures ?? {}).filter(([, enabled]) => typeof enabled === "boolean"), ) as Record, featureMap, supportedCommands, attributes, }; } return null; } function formatClusterId(id: number): string { return `0x${Number(id).toString(16).padStart(4, "0")}`; } /** JSON output with bigints rendered as decimal strings. */ export function inspectionToJson(report: InspectionReport): string { return JSON.stringify(report, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2); } export function formatInspection(report: InspectionReport): string { const lines: string[] = []; lines.push(`Node ${report.nodeId}`); if (report.basicInformation) { const info = report.basicInformation; lines.push(` Vendor: ${info.vendorName} (0x${info.vendorId.toString(16).padStart(4, "0")})`); lines.push(` Product: ${info.productName} (0x${info.productId.toString(16).padStart(4, "0")})`); lines.push(` Hardware: ${info.hardwareVersion} Software: ${info.softwareVersion}`); if (info.serialNumber) lines.push(` Serial: ${info.serialNumber}`); } for (const endpoint of report.endpoints) { lines.push(""); const types = endpoint.deviceTypes.map(t => `${t.name} (${t.code}, rev ${t.revision})`).join(", "); lines.push(`Endpoint ${endpoint.endpointId}: ${types}`); if (endpoint.serverClusters.length > 0) { lines.push(" Server clusters:"); for (const cluster of endpoint.serverClusters) { const features = Object.entries(cluster.supportedFeatures) .filter(([, enabled]) => enabled) .map(([name]) => name); const notes = [ `rev ${cluster.revision}`, ...(features.length > 0 ? [`features: ${features.join(", ")}`] : []), ...(cluster.vendorSpecific ? ["vendor-specific"] : []), ...(cluster.isUnknown ? ["unknown to matter.js"] : []), ]; lines.push(` ${cluster.id} ${cluster.name} (${notes.join("; ")})`); } } if (endpoint.clientClusters.length > 0) { lines.push(` Client clusters: ${endpoint.clientClusters.join(", ")}`); } } lines.push(""); if (report.timeSynchronization) { const ts = report.timeSynchronization; lines.push(`Time Synchronization cluster (endpoint ${ts.endpointId}, revision ${ts.clusterRevision})`); const features = Object.entries(ts.supportedFeatures) .filter(([, enabled]) => enabled) .map(([name]) => name); lines.push(` Features: ${features.length > 0 ? features.join(", ") : "(none)"}`); lines.push(" Commands:"); for (const [command, supported] of Object.entries(ts.supportedCommands)) { lines.push(` ${command}: ${supported ? "supported" : "not supported"}`); } lines.push(" Attributes:"); for (const attribute of ts.attributes) { lines.push(` ${attribute.name} (${attribute.id}): ${renderValue(attribute.value)}`); } } else { lines.push("Time Synchronization cluster: NOT FOUND"); lines.push(" This device cannot be synchronized through the standard Matter mechanism."); } if (report.vendorSpecificClusters.length > 0) { lines.push(""); lines.push("Vendor-specific clusters:"); for (const cluster of report.vendorSpecificClusters) { lines.push(` endpoint ${cluster.endpointId}: ${cluster.clusterId} ${cluster.name}`); } } return lines.join("\n"); } function renderValue(value: unknown): string { if (value === undefined) return ""; return JSON.stringify(value, (_key, inner) => (typeof inner === "bigint" ? inner.toString() : inner)); }