import type { DevItem } from './types'; import type { HaRegistrySnapshot } from './ha-binding-status'; import { normalizeZ2mBaseTopic } from './zigbee-topology-settings'; export type ZigbeeProvider = 'zha' | 'z2m'; export type ZigbeeRole = 'coordinator' | 'router' | 'end' | 'unknown'; export interface ZigbeeDirectionalObservation { lqi?: number; relationship?: string; activeRoute?: boolean; } export interface ZigbeeTopologyNode { key: string; ieee: string; deviceId?: string; role: ZigbeeRole; available?: boolean; } export interface ZigbeeTopologyLink { a: string; b: string; aToB?: ZigbeeDirectionalObservation; bToA?: ZigbeeDirectionalObservation; } export type ZigbeeTopologyWarningCode = | 'invalid_payload' | 'duplicate_link' | 'self_link' | 'unmatched_device' | 'ambiguous_placement' | 'provider_scan_failure'; export interface ZigbeeTopologyWarning { code: ZigbeeTopologyWarningCode; nodeKey?: string; } export interface ZigbeeTopology { provider: ZigbeeProvider; instanceId: string; obtainedAt: number; freshness: 'provider-cache' | 'fresh-scan'; nodes: ZigbeeTopologyNode[]; links: ZigbeeTopologyLink[]; warnings: ZigbeeTopologyWarning[]; } export interface ZigbeeHoverLine { neighborMarkerId: string; lqi?: number; routeDirection?: 'toward-neighbor' | 'toward-origin'; } export type ZigbeeParentTarget = | { kind: 'remote-space'; spaceId: string } | { kind: 'unplaced-device' } | { kind: 'unplaced-coordinator' }; export interface ZigbeeRouteTree { coordinatorKey?: string; distances: Map; parents: Map; } export interface ZigbeeHoverResolution { lines: ZigbeeHoverLine[]; remoteCount: number; omittedCount: number; parentTargets: ZigbeeParentTarget[]; } export const TOPOLOGY_STALE_MS = 5 * 60 * 1000; export const TOPOLOGY_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024; export const TOPOLOGY_MAX_NODES = 1000; export const TOPOLOGY_MAX_LINKS = 6000; function recordOf(value: unknown): Record | null { return value !== null && typeof value === 'object' ? value as Record : null; } export function normalizeIeee(value: unknown): string | null { if (typeof value !== 'string' && typeof value !== 'number') return null; let text = String(value).trim().toLowerCase(); if (/^0x[0-9a-f]{16}$/.test(text)) text = text.slice(2); else text = text.replace(/[:-]/g, ''); return /^[0-9a-f]{16}$/.test(text) ? text : null; } function nodeKey(provider: ZigbeeProvider, instanceId: string, ieee: string): string { return `${provider}:${instanceId}:${ieee}`; } function roleOf(value: unknown): ZigbeeRole { const role = String(value || '').toLowerCase().replace(/[ _-]/g, ''); if (role.includes('coordinator')) return 'coordinator'; if (role.includes('router')) return 'router'; if (role.includes('enddevice') || role === 'end') return 'end'; return 'unknown'; } function lqiOf(value: unknown): number | undefined { const n = Number(value); return Number.isFinite(n) && n >= 0 && n <= 255 ? Math.round(n) : undefined; } const Z2M_RELATIONSHIPS = ['parent', 'child', 'sibling', 'none', 'previous_child'] as const; function relationshipOf(value: unknown): string | undefined { if (typeof value === 'number') return Z2M_RELATIONSHIPS[value]; if (typeof value !== 'string') return undefined; const compact = value.trim().toLowerCase().replace(/[\s_-]+/g, ''); return compact === 'previouschild' ? 'previous_child' : compact.slice(0, 40) || undefined; } function observation(value: unknown): ZigbeeDirectionalObservation { const record = recordOf(value); const lqi = lqiOf(record?.lqi ?? record?.linkquality ?? record?.link_quality); const relationship = relationshipOf(record?.relationship); return { ...(lqi === undefined ? {} : { lqi }), ...(relationship ? { relationship } : {}) }; } function boundedArray(value: unknown, max: number): unknown[] | null { return Array.isArray(value) && value.length <= max ? value : null; } function safePayload(value: unknown): boolean { if (typeof value === 'string') return value.length <= TOPOLOGY_MAX_PAYLOAD_BYTES; try { return JSON.stringify(value).length <= TOPOLOGY_MAX_PAYLOAD_BYTES; } catch { return false; } } function pushDirectionalLink( links: Map, warnings: ZigbeeTopologyWarning[], from: string, to: string, value: unknown, ): void { if (from === to) { warnings.push({ code: 'self_link', nodeKey: from }); return; } const forward = from < to; const pair = forward ? `${from}|${to}` : `${to}|${from}`; const current = links.get(pair) || { a: forward ? from : to, b: forward ? to : from }; const field = forward ? 'aToB' : 'bToA'; if (current[field]) warnings.push({ code: 'duplicate_link', nodeKey: from }); else current[field] = observation(value); links.set(pair, current); } /** Normalize cached ZHA neighbors. Route destinations deliberately do not create edges. */ export function normalizeZhaTopology(payload: unknown, now = Date.now()): ZigbeeTopology { const warnings: ZigbeeTopologyWarning[] = []; const rows = safePayload(payload) ? boundedArray(payload, TOPOLOGY_MAX_NODES) : null; if (!rows) return { provider: 'zha', instanceId: 'zha', obtainedAt: now, freshness: 'provider-cache', nodes: [], links: [], warnings: [{ code: 'invalid_payload' }] }; const nodes = new Map(); const links = new Map(); for (const row of rows) { const record = recordOf(row); const ieee = normalizeIeee(record?.ieee ?? record?.ieee_address); if (!ieee) { warnings.push({ code: 'invalid_payload' }); continue; } const key = nodeKey('zha', 'zha', ieee); nodes.set(key, { key, ieee, ...(typeof record?.device_reg_id === 'string' ? { deviceId: record.device_reg_id } : {}), role: roleOf(record?.device_type ?? record?.type), ...(typeof record?.available === 'boolean' ? { available: record.available } : {}), }); } for (const row of rows) { const record = recordOf(row); const ieee = normalizeIeee(record?.ieee ?? record?.ieee_address); if (!ieee) continue; const from = nodeKey('zha', 'zha', ieee); const neighbors = boundedArray(record?.neighbors, TOPOLOGY_MAX_LINKS); if (!neighbors) { if (record?.neighbors != null) warnings.push({ code: 'invalid_payload', nodeKey: from }); continue; } for (const neighbor of neighbors) { if (links.size >= TOPOLOGY_MAX_LINKS) { warnings.push({ code: 'invalid_payload' }); break; } const neighborRecord = recordOf(neighbor); const otherIeee = normalizeIeee(neighborRecord?.ieee ?? neighborRecord?.ieee_address); if (!otherIeee) { warnings.push({ code: 'invalid_payload', nodeKey: from }); continue; } const to = nodeKey('zha', 'zha', otherIeee); if (!nodes.has(to)) nodes.set(to, { key: to, ieee: otherIeee, role: roleOf(neighborRecord?.device_type) }); pushDirectionalLink(links, warnings, from, to, neighbor); } } return { provider: 'zha', instanceId: 'zha', obtainedAt: now, freshness: 'provider-cache', nodes: [...nodes.values()], links: [...links.values()], warnings }; } function z2mValue(payload: unknown): unknown { let value = payload; if (typeof value === 'string') { if (value.length > TOPOLOGY_MAX_PAYLOAD_BYTES) return null; try { value = JSON.parse(value); } catch { return null; } } const envelope = recordOf(value); const data = recordOf(envelope?.data); value = data?.value ?? envelope?.value ?? envelope?.data ?? value; if (typeof value === 'string') { if (value.length > TOPOLOGY_MAX_PAYLOAD_BYTES) return null; try { value = JSON.parse(value); } catch { return null; } } return value; } function endpointIeee(value: unknown, nodeById: Map): string | null { const record = recordOf(value); const direct = normalizeIeee( record?.ieeeAddr ?? record?.ieee_address ?? record?.ieee ?? value, ); if (direct) return direct; for (const id of [record?.id, record?.networkAddress, record?.network_address, value]) { if (id != null) { const ieee = nodeById.get(String(id)); if (ieee) return ieee; } } return null; } /** Normalize one explicit Zigbee2MQTT raw network-map response. */ export function normalizeZ2mTopology(payload: unknown, baseTopic: string, now = Date.now()): ZigbeeTopology { const warnings: ZigbeeTopologyWarning[] = []; const topic = normalizeZ2mBaseTopic(baseTopic) || 'invalid'; const raw = safePayload(payload) ? z2mValue(payload) : null; const rawRecord = recordOf(raw); const rows = boundedArray(rawRecord?.nodes, TOPOLOGY_MAX_NODES); const rawLinks = boundedArray(rawRecord?.links, TOPOLOGY_MAX_LINKS); if (!rows || !rawLinks) return { provider: 'z2m', instanceId: topic, obtainedAt: now, freshness: 'fresh-scan', nodes: [], links: [], warnings: [{ code: 'invalid_payload' }] }; const nodes = new Map(); const nodeById = new Map(); for (const row of rows) { const record = recordOf(row); const ieee = normalizeIeee(record?.ieeeAddr ?? record?.ieee_address ?? record?.ieee); if (!ieee) { warnings.push({ code: 'invalid_payload' }); continue; } const key = nodeKey('z2m', topic, ieee); nodes.set(key, { key, ieee, role: roleOf(record?.type ?? record?.device_type), ...(typeof record?.failed === 'boolean' ? { available: !record.failed } : {}) }); for (const id of [record?.id, record?.networkAddress, record?.network_address]) { if (id != null) nodeById.set(String(id), ieee); } } const links = new Map(); for (const row of rawLinks) { const record = recordOf(row); const fromIeee = normalizeIeee(record?.sourceIeeeAddr) ?? endpointIeee(record?.source, nodeById); const toIeee = normalizeIeee(record?.targetIeeeAddr) ?? endpointIeee(record?.target, nodeById); if (!fromIeee || !toIeee) { warnings.push({ code: 'invalid_payload' }); continue; } const from = nodeKey('z2m', topic, fromIeee); const to = nodeKey('z2m', topic, toIeee); if (!nodes.has(from)) nodes.set(from, { key: from, ieee: fromIeee, role: 'unknown' }); if (!nodes.has(to)) nodes.set(to, { key: to, ieee: toIeee, role: 'unknown' }); pushDirectionalLink(links, warnings, from, to, row); } return { provider: 'z2m', instanceId: topic, obtainedAt: now, freshness: 'fresh-scan', nodes: [...nodes.values()], links: [...links.values()], warnings }; } function ieeeFromRegistryIdentifier(value: unknown): string | null { if (typeof value !== 'string') return null; const direct = normalizeIeee(value); if (direct) return direct; const match = value.toLowerCase().match(/^zigbee2mqtt(?:_bridge)?_(.+)$/); return match ? normalizeIeee(match[1]) : null; } function deviceIdsForNode(node: ZigbeeTopologyNode, registry: HaRegistrySnapshot): string[] { if (node.deviceId && registry.devices[node.deviceId]) return [node.deviceId]; const ids = new Set(); for (const [id, rawDevice] of Object.entries(registry.devices as Record)) { const device = recordOf(rawDevice); const identifiers = Array.isArray(device?.identifiers) ? device.identifiers : []; if (identifiers.some((pair: unknown) => Array.isArray(pair) && ieeeFromRegistryIdentifier(pair[1]) === node.ieee)) ids.add(id); } if (ids.size) return [...ids]; for (const rawEntity of Object.values(registry.entities as Record)) { const entity = recordOf(rawEntity); if (typeof entity?.device_id !== 'string' || typeof entity.unique_id !== 'string') continue; if (ieeeFromRegistryIdentifier(entity.unique_id) === node.ieee) ids.add(entity.device_id); } return [...ids]; } function drawable(device: DevItem): boolean { return !device.hidden && !device.virtual && device.bindingStatus?.kind !== 'ha_disabled' && device.bindingStatus?.kind !== 'orphaned' && device.bindingStatus?.kind !== 'unverified'; } export type ZigbeeNodePlacement = { markerId: string; space: string }; export type ZigbeeMappedTopology = { topology: ZigbeeTopology; placements: Map; routes: ZigbeeRouteTree; }; type ZigbeeAdjacentLink = { neighborKey: string; observation?: ZigbeeDirectionalObservation }; function adjacencyOf(topology: ZigbeeTopology): Map { const nodeKeys = new Set(topology.nodes.map((node) => node.key)); const adjacency = new Map(); for (const key of [...nodeKeys].sort()) adjacency.set(key, []); for (const link of [...topology.links].sort((left, right) => ( `${left.a}\u0000${left.b}`.localeCompare(`${right.a}\u0000${right.b}`) ))) { if (!nodeKeys.has(link.a) || !nodeKeys.has(link.b)) continue; adjacency.get(link.a)!.push({ neighborKey: link.b, observation: link.aToB }); adjacency.get(link.b)!.push({ neighborKey: link.a, observation: link.bToA }); } for (const neighbors of adjacency.values()) { neighbors.sort((left, right) => left.neighborKey.localeCompare(right.neighborKey)); } return adjacency; } /** Build one deterministic shortest-path uplink tree without trusting stale provider direction. */ export function buildZigbeeRouteTree(topology: ZigbeeTopology): ZigbeeRouteTree { const distances = new Map(); const parents = new Map(); const coordinators = topology.nodes.filter((node) => node.role === 'coordinator') .map((node) => node.key).sort(); if (coordinators.length !== 1) return { distances, parents }; const coordinatorKey = coordinators[0]; const adjacency = adjacencyOf(topology); if (!adjacency.has(coordinatorKey)) return { distances, parents }; distances.set(coordinatorKey, 0); const queue = [coordinatorKey]; for (let index = 0; index < queue.length; index++) { const current = queue[index]; const nextDistance = distances.get(current)! + 1; for (const { neighborKey } of adjacency.get(current) || []) { if (distances.has(neighborKey)) continue; distances.set(neighborKey, nextDistance); queue.push(neighborKey); } } for (const nodeKey of [...distances.keys()].sort()) { const distance = distances.get(nodeKey)!; if (distance === 0) continue; const candidates = (adjacency.get(nodeKey) || []) .filter(({ neighborKey }) => distances.get(neighborKey) === distance - 1) .sort((left, right) => { const leftParent = left.observation?.relationship === 'parent' ? 1 : 0; const rightParent = right.observation?.relationship === 'parent' ? 1 : 0; if (leftParent !== rightParent) return rightParent - leftParent; const leftLqi = left.observation?.lqi ?? -1; const rightLqi = right.observation?.lqi ?? -1; return rightLqi - leftLqi || left.neighborKey.localeCompare(right.neighborKey); }); if (candidates[0]) parents.set(nodeKey, candidates[0].neighborKey); } return { coordinatorKey, distances, parents }; } /** Exact registry binding only; names, models and friendly names are never identities. */ export function mapTopologyNodes( topology: ZigbeeTopology, devices: readonly DevItem[], registry: HaRegistrySnapshot, ): { placements: Map; warnings: ZigbeeTopologyWarning[] } { const placements = new Map(); const warnings: ZigbeeTopologyWarning[] = []; for (const node of topology.nodes) { if (node.available === false) { warnings.push({ code: 'provider_scan_failure', nodeKey: node.key }); continue; } const deviceIds = deviceIdsForNode(node, registry); if (deviceIds.length !== 1) { warnings.push({ code: 'unmatched_device', nodeKey: node.key }); continue; } const deviceId = deviceIds[0]; let candidates = devices.filter((item) => drawable(item) && item.bindingKind === 'device' && item.bindingRef === deviceId); if (!candidates.length) candidates = devices.filter((item) => drawable(item) && item.bindingKind === 'entity' && !!item.bindingRef && registry.entities[item.bindingRef]?.device_id === deviceId); if (candidates.length === 1) placements.set(node.key, { markerId: candidates[0].id, space: candidates[0].space, }); else warnings.push({ code: candidates.length > 1 ? 'ambiguous_placement' : 'unmatched_device', nodeKey: node.key }); } return { placements, warnings }; } export function mapTopologies( topologies: readonly ZigbeeTopology[], devices: readonly DevItem[], registry: HaRegistrySnapshot, ): ZigbeeMappedTopology[] { return topologies.map((topology) => ({ topology, placements: mapTopologyNodes(topology, devices, registry).placements, routes: buildZigbeeRouteTree(topology), })); } export function resolveMappedTopologyHover( mappedTopologies: readonly ZigbeeMappedTopology[], currentSpace: string, hoveredMarkerId: string, ): ZigbeeHoverResolution { const lines = new Map> }>(); const remote = new Set(); const parentTargets = new Map(); let omittedCount = 0; for (const { topology, placements, routes } of mappedTopologies) { const hoveredNodes = new Set([...placements] .filter(([, placement]) => placement.markerId === hoveredMarkerId && placement.space === currentSpace) .map(([key]) => key)); if (!hoveredNodes.size) continue; for (const hoveredNode of hoveredNodes) { const parentKey = routes.parents.get(hoveredNode); if (!parentKey) continue; const parentPlacement = placements.get(parentKey); if (parentPlacement?.space === currentSpace) continue; let target: ZigbeeParentTarget; if (parentPlacement) target = { kind: 'remote-space', spaceId: parentPlacement.space }; else target = topology.nodes.find((node) => node.key === parentKey)?.role === 'coordinator' ? { kind: 'unplaced-coordinator' } : { kind: 'unplaced-device' }; const targetKey = target.kind === 'remote-space' ? `${target.kind}:${target.spaceId}` : target.kind; parentTargets.set(targetKey, target); } for (const link of topology.links) { const fromA = hoveredNodes.has(link.a); const fromB = hoveredNodes.has(link.b); if (!fromA && !fromB) continue; const hoveredNode = fromA ? link.a : link.b; const otherKey = fromA ? link.b : link.a; const other = placements.get(otherKey); if (!other || other.markerId === hoveredMarkerId) { omittedCount++; continue; } const isParent = routes.parents.get(hoveredNode) === otherKey; if (other.space !== currentSpace) { if (!isParent) remote.add(other.markerId); continue; } const obs = fromA ? link.aToB : link.bToA; const direction = isParent ? 'toward-neighbor' : routes.parents.get(otherKey) === hoveredNode ? 'toward-origin' : undefined; const existing = lines.get(other.markerId); if (!existing) { lines.set(other.markerId, { line: { neighborMarkerId: other.markerId, lqi: obs?.lqi }, directions: new Set(direction ? [direction] : []) }); } else { if (existing.line.lqi === undefined && obs?.lqi !== undefined) existing.line.lqi = obs.lqi; if (direction) existing.directions.add(direction); } } } return { lines: [...lines.values()].map(({ line, directions }) => directions.size === 1 ? { ...line, routeDirection: [...directions][0] } : line), remoteCount: remote.size, omittedCount, parentTargets: [...parentTargets.values()], }; } /** Resolve only the edges incident to the marker currently under the mouse. */ export function resolveTopologyHover( topologies: readonly ZigbeeTopology[], devices: readonly DevItem[], registry: HaRegistrySnapshot, currentSpace: string, hoveredMarkerId: string, ): ZigbeeHoverResolution { return resolveMappedTopologyHover( mapTopologies(topologies, devices, registry), currentSpace, hoveredMarkerId, ); }