src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/inspection.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/inspection.ts')
-rw-r--r--src/inspection.ts272
1 files changed, 272 insertions, 0 deletions
diff --git a/src/inspection.ts b/src/inspection.ts
new file mode 100644
index 0000000..b022b60
--- /dev/null
+++ b/src/inspection.ts
@@ -0,0 +1,272 @@
+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<string, boolean>;
+}
+
+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<string, boolean>;
+ featureMap: unknown;
+ supportedCommands: Record<string, boolean>;
+ 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<InspectionReport> {
+ 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<number, Endpoint>();
+ 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<string, boolean>,
+ }));
+
+ 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<TimeSyncSummary | null> {
+ 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 = `<read failed: ${cause instanceof Error ? cause.message : String(cause)}>`;
+ }
+ 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<string, boolean>,
+ 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 "<not read>";
+ return JSON.stringify(value, (_key, inner) => (typeof inner === "bigint" ? inner.toString() : inner));
+}