diff --git a/demo/benchmark_zigbee_topology.mjs b/demo/benchmark_zigbee_topology.mjs index 5e203164..b00a8759 100644 --- a/demo/benchmark_zigbee_topology.mjs +++ b/demo/benchmark_zigbee_topology.mjs @@ -1,13 +1,14 @@ /** #54: bounded provider normalization and incident-only hover performance. */ import { performance } from 'node:perf_hooks'; -import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js'; +import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover, resolveProviderUplinks } from '../test-build/zigbee-topology.js'; const NODES = 500; const SAMPLES = 9; const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 }; const hex = (index) => index.toString(16).padStart(16, '0'); const rows = Array.from({ length: NODES }, (_, index) => ({ - ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator', + ieee: hex(index + 1), nwk: index, device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator', + routes: index ? [{ dest_nwk: '0x0000', next_hop: index - 1, route_status: 'Active' }] : [], neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => { const other = index + offset; return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : []; @@ -39,7 +40,13 @@ const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', ' const repeatedHoverMs = measured(() => { for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`); }); -const result = { nodes: topology.nodes.length, links: topology.links.length, +const confirmedRoutes = [...resolveProviderUplinks(topology).values()].filter((route) => route.kind === 'known').length; +const witness = resolveMappedTopologyHover(mapped, 'main', 'm1'); +if (confirmedRoutes !== NODES - 1 || witness.outgoing !== 'known' + || witness.parentTargets.length !== 1 || witness.lines.length !== 1) { + throw new Error('benchmark must exercise a nonempty provider-confirmed graph, outgoing remote target and incoming edge'); +} +const result = { nodes: topology.nodes.length, links: topology.links.length, confirmedRoutes, normalizeMs, mapMs, firstHoverMs, repeatedHoverMs }; console.log(JSON.stringify(result, null, 2)); for (const [name, budget] of Object.entries(BUDGETS)) { diff --git a/demo/smoke_zigbee_topology_hover.mjs b/demo/smoke_zigbee_topology_hover.mjs index dd89f044..b88a7369 100644 --- a/demo/smoke_zigbee_topology_hover.mjs +++ b/demo/smoke_zigbee_topology_hover.mjs @@ -1,5 +1,9 @@ +import { mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { launch, checkAll, finish } from './serve.mjs'; +const visualArtifacts = new URL('../artifacts/zigbee-topology-798/', import.meta.url); +mkdirSync(visualArtifacts, { recursive: true }); const { page, browser } = await launch(); const result = await page.evaluate(async () => { const card = window.__card; @@ -24,33 +28,41 @@ const result = await page.evaluate(async () => { .some((entry) => /zigbee-topology-runtime|hp-zigbee-topology-overlay/.test(entry.name)); let zhaCalls = 0; + window.__zigbeeSmokeFetchCount = () => zhaCalls; const originalHass = card.hass; card.hass = { ...originalHass, callWS: async (message) => { if (message.type !== 'zha/devices') return originalHass.callWS(message); zhaCalls++; return [ - { ieee: '00124b0000000001', device_reg_id: 'd_light1', device_type: 'Router', neighbors: [ + { ieee: '00124b0000000001', nwk: 1, device_reg_id: 'd_light1', device_type: 'Router', + routes: [{ dest_nwk: 0, next_hop: 0, route_status: 'Active' }], neighbors: [ { ieee: '00124b0000000002', relationship: 'Parent' }, { ieee: '00124b0000000003', lqi: 80, relationship: 'Child' }, - { ieee: '00124b0000000005', lqi: 125, relationship: 'Sibling' }, + { ieee: '00124b0000000005', lqi: 128, relationship: 'Child' }, + { ieee: '00124b0000000007', lqi: 255, relationship: 'Sibling' }, ] }, - { ieee: '00124b0000000002', device_reg_id: 'd_lamp', device_type: 'Coordinator', neighbors: [ - { ieee: '00124b0000000001', lqi: 170, relationship: 'Child' }, + { ieee: '00124b0000000002', nwk: 0, device_reg_id: 'd_lamp', device_type: 'Coordinator', neighbors: [ + { ieee: '00124b0000000001', relationship: 'Child' }, { ieee: '00124b0000000006', lqi: 140, relationship: 'Child' }, ] }, - { ieee: '00124b0000000003', device_reg_id: 'd_mower', device_type: 'EndDevice', neighbors: [ + { ieee: '00124b0000000003', nwk: 3, device_reg_id: 'd_mower', device_type: 'EndDevice', neighbors: [ { ieee: '00124b0000000001', lqi: 75, relationship: 'Parent' }, ] }, - { ieee: '00124b0000000004', device_reg_id: 'd_temp', device_type: 'EndDevice', neighbors: [ + { ieee: '00124b0000000004', nwk: 4, device_reg_id: 'd_temp', device_type: 'EndDevice', neighbors: [ { ieee: '00124b0000000006', lqi: 20, relationship: 'Parent' }, ] }, - { ieee: '00124b0000000005', device_reg_id: 'd_tv', device_type: 'EndDevice', neighbors: [ - { ieee: '00124b0000000001', lqi: 120, relationship: 'Sibling' }, + { ieee: '00124b0000000005', nwk: 5, device_reg_id: 'd_tv', device_type: 'EndDevice', neighbors: [ + { ieee: '00124b0000000001', lqi: 128, relationship: 'Parent' }, ] }, - { ieee: '00124b0000000006', device_reg_id: 'not_on_plan', device_type: 'Router', neighbors: [ + { ieee: '00124b0000000006', nwk: 6, device_reg_id: 'not_on_plan', device_type: 'Router', + name: 'Hallway & "target"', + routes: [{ dest_nwk: '0x0000', next_hop: '0x0000', route_status: 'Active' }], neighbors: [ { ieee: '00124b0000000002', lqi: 130, relationship: 'Parent' }, { ieee: '00124b0000000004', lqi: 25, relationship: 'Child' }, ] }, + { ieee: '00124b0000000007', nwk: 7, device_reg_id: 'd_kettle', device_type: 'EndDevice', neighbors: [ + { ieee: '00124b0000000001', lqi: 255, relationship: 'Sibling' }, + ] }, ]; } }; await card._ensureEditorRuntime(); @@ -73,9 +85,22 @@ const result = await page.evaluate(async () => { await settings._readZha(); await wait(() => settings._snapshot?.states?.zha?.phase === 'ready', 'ZHA ready'); out.explicitZhaRead = zhaCalls === 1; + const settingsSnapshot = settings._snapshot; + const renderedProviderStatus = () => [...settings.shadowRoot.querySelectorAll('.status,.hpf-actions .hpf-hint')] + .map((node) => node.textContent).find((value) => /Received /.test(value)) || ''; + settings._acceptSnapshot({ ...settingsSnapshot, states: { ...settingsSnapshot.states, + zha: { phase: 'ready', obtainedAt: Date.now() - 5 * 60 * 1000 + 300 }, + } }); + await settings.updateComplete; + const initiallyFreshSettings = !/stale/i.test(renderedProviderStatus()); + await wait(() => /stale/i.test(renderedProviderStatus()), 'mounted settings snapshot ages'); + out.settingsAgeTimerWithoutRefetch = initiallyFreshSettings && zhaCalls === 1; + settings._acceptSnapshot(settingsSnapshot); + await settings.updateComplete; card._settingsDialog = null; card.requestUpdate(); await card.updateComplete; + out.settingsDisconnectClearsTimer = !settings.isConnected && settings._staleTimer === undefined; const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); @@ -196,10 +221,13 @@ const result = await page.evaluate(async () => { const arrowBox = routeArrow?.getBoundingClientRect(); out.localRouteArrow = !!routeLine && routeArrow?.getAttribute('data-direction') === 'toward-neighbor' && !!lineBox && !!arrowBox && arrowBox.width > 3 && arrowBox.height > 3; + out.onlyConfirmedArrows = overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-arrow"]').length === 2 + && !overlay.shadowRoot.querySelector('line[data-direction="none"]') + && !root().querySelector('.dev[data-id="d_kettle"]').hasAttribute('data-hp-zigbee-topology-endpoint'); out.crossSpaceCount = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]') ?.textContent.trim() === '+1 in other spaces'; out.pointerTransparent = getComputedStyle(overlay).pointerEvents === 'none' - && [...overlay.shadowRoot.querySelectorAll('svg,line,polygon,.halo,.remote,.parent-bubble')] + && [...overlay.shadowRoot.querySelectorAll('svg,line,polygon,.halo,.remote,.parent-bubble,.route-status')] .every((node) => getComputedStyle(node).pointerEvents === 'none'); const unrelatedRect = unrelated.getBoundingClientRect(); const hitTarget = root().elementFromPoint( @@ -216,23 +244,34 @@ const result = await page.evaluate(async () => { out.pointerHitTarget = !!hitTarget && unrelated.contains(hitTarget) && clickReachedMarker; const casing = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line-casing"]'); const unknownCore = overlay.shadowRoot.querySelector( - '[data-hp="zigbee-topology-line"][stroke-dasharray="5 5"]', + '[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', + ); + const knownCore = overlay.shadowRoot.querySelector( + '[data-hp="zigbee-topology-line"][data-direction="toward-origin"]', ); - const knownCore = [...overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-line"]')] - .find((line) => !line.hasAttribute('stroke-dasharray')); out.unknownCasingContract = !!casing && !!unknownCore && !!knownCore && overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-line-casing"]').length === 1 - && casing.getAttribute('stroke') === '#2e2e2e' - && casing.getAttribute('stroke-width') === '4' - && unknownCore.getAttribute('stroke-width') === '2' - && casing.getAttribute('stroke-dasharray') === unknownCore.getAttribute('stroke-dasharray') - && casing.getAttribute('stroke-dashoffset') === unknownCore.getAttribute('stroke-dashoffset') + && getComputedStyle(casing).stroke === 'rgb(0, 0, 0)' + && Math.abs(Number.parseFloat(getComputedStyle(casing).strokeWidth) - (2 + 2 * card._zoom)) < 0.001 + && Number.parseFloat(getComputedStyle(unknownCore).strokeWidth) === 2 + && getComputedStyle(unknownCore).stroke === 'rgb(145, 155, 165)' && casing.getAttribute('x1') === unknownCore.getAttribute('x1') && casing.getAttribute('y1') === unknownCore.getAttribute('y1') && casing.getAttribute('x2') === unknownCore.getAttribute('x2') && casing.getAttribute('y2') === unknownCore.getAttribute('y2') && getComputedStyle(casing).strokeLinecap === getComputedStyle(unknownCore).strokeLinecap && getComputedStyle(casing).vectorEffect === 'non-scaling-stroke'; + out.unknownArrowOutline = !!routeArrow + && getComputedStyle(routeArrow).fill === 'rgb(145, 155, 165)' + && getComputedStyle(routeArrow).stroke === 'rgb(0, 0, 0)' + && Math.abs(Number.parseFloat(getComputedStyle(routeArrow).strokeWidth) - 2 * card._zoom) < 0.001 + && getComputedStyle(routeArrow).vectorEffect === 'non-scaling-stroke'; + out.allLinksSolid = [...overlay.shadowRoot.querySelectorAll('line,polygon')] + .every((node) => getComputedStyle(node).strokeDasharray === 'none'); + out.knownLineArrowMatch = getComputedStyle(knownCore).stroke === 'rgb(255, 255, 0)' + && getComputedStyle(overlay.shadowRoot.querySelector( + '[data-hp="zigbee-topology-arrow"][data-direction="toward-origin"]', + )).fill === getComputedStyle(knownCore).stroke; const oldNeighbor = unknownNeighbor; const replacementNeighbor = oldNeighbor.cloneNode(true); @@ -246,6 +285,7 @@ const result = await page.evaluate(async () => { 'endpoint ownership returned to original marker'); const runtimeBeforeInvalidation = overlay._runtime; + window.__zigbeeSmokeBaseline = runtimeBeforeInvalidation; overlay._acceptRuntime({ revision: runtimeBeforeInvalidation.revision + 1, topologies: [], @@ -260,6 +300,20 @@ const result = await page.evaluate(async () => { await overlay.updateComplete; await wait(() => overlay.shadowRoot.querySelector('line'), 'route restored after runtime invalidation'); + const noRoute = root().querySelector('.dev[data-id="d_kettle"]'); + noRoute.dispatchEvent(mouse('pointerover')); + await wait(() => overlay.shadowRoot.querySelector('.route-status'), 'unknown route label'); + out.unknownRouteHasNoInventedLine = !overlay.shadowRoot.querySelector('line,polygon') + && /no route data/i.test(overlay.shadowRoot.querySelector('.route-status').textContent) + && !noRoute.hasAttribute('data-hp-zigbee-topology-endpoint'); + unknownNeighbor.dispatchEvent(mouse('pointerover')); + await wait(() => overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'), 'coordinator incoming route'); + out.coordinatorHasOnlyIncoming = !/no route data/i.test( + overlay.shadowRoot.querySelector('.route-status')?.textContent || '', + ) + && !overlay.shadowRoot.querySelector('[data-direction="toward-neighbor"]') + && !!overlay.shadowRoot.querySelector('[data-direction="toward-origin"]'); + card._commitSpace('garden', true); card.requestUpdate(); await card.updateComplete; @@ -287,8 +341,11 @@ const result = await page.evaluate(async () => { await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot ?.querySelector('[data-kind="unplaced-device"]'), 'unplaced device bubble'); activeOverlay = root().querySelector('hp-zigbee-topology-overlay'); - out.unplacedDeviceBubble = activeOverlay.shadowRoot.querySelector('[data-kind="unplaced-device"]') - ?.textContent.trim() === 'device is not on the plan'; + const missingParentBubble = activeOverlay.shadowRoot.querySelector('[data-kind="unplaced-device"]'); + out.unplacedDeviceBubble = missingParentBubble?.textContent.trim() + === 'Device is not on the plan (Hallway & "target")'; + out.unplacedNameIsSafeText = !missingParentBubble?.querySelector('router,img,script') + && !missingParentBubble?.textContent.includes('Temperature sensor'); activeOverlay.devices = activeOverlay.devices.map((device) => ( device.id === 'd_lamp' ? { ...device, hidden: true } : device @@ -301,8 +358,19 @@ const result = await page.evaluate(async () => { out.unplacedCoordinatorBubble = activeOverlay.shadowRoot .querySelector('[data-kind="unplaced-coordinator"]')?.textContent.trim() === 'coordinator is not on the plan'; - out.parentRouteHasNoCasing = !!activeOverlay.shadowRoot.querySelector('line.parent-route') - && !activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line-casing"]'); + const parentCore = activeOverlay.shadowRoot.querySelector('line.parent-route'); + const parentArrow = activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-arrow"]'); + out.parentRouteHasUnknownOutline = !!parentCore && !!parentArrow + && getComputedStyle(parentCore).stroke === 'rgb(145, 155, 165)' + && getComputedStyle(parentArrow).fill === 'rgb(145, 155, 165)' + && getComputedStyle(parentArrow).stroke === 'rgb(0, 0, 0)' + && Math.abs(Number.parseFloat(getComputedStyle(parentArrow).strokeWidth) - 2 * card._zoom) < 0.001 + && [...activeOverlay.shadowRoot.querySelectorAll('line')].some((line) => ( + getComputedStyle(line).stroke === 'rgb(0, 0, 0)' + && Math.abs(Number.parseFloat(getComputedStyle(line).strokeWidth) - (2 + 2 * card._zoom)) < 0.001 + )) + && [...activeOverlay.shadowRoot.querySelectorAll('line,polygon')] + .every((node) => getComputedStyle(node).strokeDasharray === 'none'); router.dispatchEvent(mouse('pointerout', root().querySelector('.stage'))); await activeOverlay.updateComplete; @@ -357,6 +425,172 @@ const result = await page.evaluate(async () => { return out; }); +// Exercise the actual rendered line/arrow pair, not the palette helper in isolation. +// Remote and unplaced targets used to be permanently grey even with known LQI. +for (const theme of ['light', 'dark']) { + await page.emulateMedia({ colorScheme: theme }); + const visual = await page.evaluate(async ({ theme }) => { + const card = window.__card; + const root = () => card.shadowRoot || card.renderRoot; + const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const wait = async (predicate, label = 'render') => { + const started = performance.now(); + while (!predicate()) { + if (performance.now() - started > 5000) throw new Error(`topology visual contract timeout: ${theme} ${label}; ` + + root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot?.textContent?.slice(-600)); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }; + const hover = (marker) => marker.dispatchEvent(new PointerEvent('pointerover', { + pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, + })); + document.documentElement.style.colorScheme = theme; + card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: true }, + themes: { ...(card.hass.themes || {}), darkMode: theme === 'dark' } }; + card.setAttribute('data-pointer-hover', ''); + await window.__hpTest.setMode('view'); + card.requestUpdate(); + await card.updateComplete; + await wait(() => root().querySelector('hp-zigbee-topology-overlay')); + // The preceding editor/View probe starts a real camera transition. Let its + // saved View camera settle before measuring the explicit zoom matrix. + await wait(() => !card._modeTransitionBusy, 'View camera settled'); + await frame(); + const expectedColors = [ + [0, 'rgb(255, 0, 0)'], [64, 'rgb(255, 128, 0)'], [128, 'rgb(255, 255, 0)'], + [192, 'rgb(126, 255, 0)'], [255, 'rgb(0, 255, 0)'], [null, 'rgb(145, 155, 165)'], + ]; + const out = {}; + const diagnostics = []; + let revision = window.__zigbeeSmokeBaseline.revision + 100; + const snapshot = (lqi) => ({ + ...window.__zigbeeSmokeBaseline, revision: revision++, + topologies: window.__zigbeeSmokeBaseline.topologies.map((topology) => ({ + ...topology, obtainedAt: Date.now(), + uplinkEvidence: topology.uplinkEvidence.map((evidence) => ({ ...evidence, lqi: lqi ?? undefined })), + links: topology.links.map((link) => ({ ...link, + aToB: link.aToB ? { ...link.aToB, lqi: lqi ?? undefined } : undefined, + bToA: link.bToA ? { ...link.bToA, lqi: lqi ?? undefined } : undefined, + })), + })), + }); + for (const [kind, space, markerId] of [ + ['local', 'f1', 'd_light1'], ['remote', 'garden', 'd_mower'], ['unplaced', 'f1', 'd_temp'], + ]) { + card._commitSpace(space, true); + card.requestUpdate(); + await card.updateComplete; + for (const [zoomName, zoom] of [['min', 1 / 3], ['default', 1], ['max', 8]]) { + card._applyView(zoom, 0.32, 0.36); + card.requestUpdate(); + await card.updateComplete; + await frame(); + const overlay = root().querySelector('hp-zigbee-topology-overlay'); + await overlay.updateComplete; + const marker = root().querySelector(`.dev[data-id="${markerId}"]`); + hover(marker); + for (const [lqi, expected] of expectedColors) { + overlay._acceptRuntime(snapshot(lqi)); + await overlay.updateComplete; + const cores = [...overlay.shadowRoot.querySelectorAll('.link-core,.parent-route')]; + const arrows = [...overlay.shadowRoot.querySelectorAll('.route-arrow')]; + const casings = [...overlay.shadowRoot.querySelectorAll('.link-casing')]; + const key = `${kind}_${zoomName}_${lqi ?? 'unknown'}`; + out[`${key}_requestedZoom`] = Math.abs(card._zoom - zoom) < 0.000001 + && Math.abs(overlay.zoom - zoom) < 0.000001; + out[`${key}_lineAndArrowColor`] = cores.length > 0 && arrows.length === cores.length + && cores.every((line) => getComputedStyle(line).stroke === expected) + && arrows.every((arrow) => getComputedStyle(arrow).fill === expected); + out[`${key}_solid`] = [...cores, ...arrows, ...casings] + .every((node) => getComputedStyle(node).strokeDasharray === 'none'); + out[`${key}_outline`] = lqi === null + ? casings.length === cores.length + && casings.every((line) => getComputedStyle(line).stroke === 'rgb(0, 0, 0)' + && Math.abs(Number.parseFloat(getComputedStyle(line).strokeWidth) - (2 + 2 * zoom)) < 0.001 + && getComputedStyle(line).vectorEffect === 'non-scaling-stroke') + && cores.every((line) => Number.parseFloat(getComputedStyle(line).strokeWidth) === 2 + && getComputedStyle(line).vectorEffect === 'non-scaling-stroke') + && arrows.every((arrow) => getComputedStyle(arrow).stroke === 'rgb(0, 0, 0)' + && Math.abs(Number.parseFloat(getComputedStyle(arrow).strokeWidth) - 2 * zoom) < 0.001 + && getComputedStyle(arrow).vectorEffect === 'non-scaling-stroke' + && getComputedStyle(arrow).paintOrder.startsWith('stroke')) + : casings.length === 0 && arrows.every((arrow) => getComputedStyle(arrow).stroke === 'none'); + if (!out[`${key}_outline`]) diagnostics.push({ key, zoom, cardZoom: card._zoom, overlayZoom: overlay.zoom, + cores: cores.map((line) => ({ width: getComputedStyle(line).strokeWidth, effect: getComputedStyle(line).vectorEffect })), + casings: casings.map((line) => ({ width: getComputedStyle(line).strokeWidth, color: getComputedStyle(line).stroke, + effect: getComputedStyle(line).vectorEffect })), + arrows: arrows.map((arrow) => ({ width: getComputedStyle(arrow).strokeWidth, color: getComputedStyle(arrow).stroke, + effect: getComputedStyle(arrow).vectorEffect, order: getComputedStyle(arrow).paintOrder })), + }); + } + } + } + + card._commitSpace('f1', true); + card.requestUpdate(); + await card.updateComplete; + await frame(); + const overlay = root().querySelector('hp-zigbee-topology-overlay'); + await overlay.updateComplete; + const knownSource = root().querySelector('.dev[data-id="d_light1"]'); + hover(knownSource); + const stale = snapshot(null); + stale.topologies = stale.topologies.map((topology) => ({ ...topology, + obtainedAt: Date.now() - 6 * 60 * 1000, + warnings: [...topology.warnings, { code: 'provider_scan_failure' }], + })); + overlay._acceptRuntime(stale); + await overlay.updateComplete; + const staleStatus = overlay.shadowRoot.querySelector('.route-status'); + out.stalePartialRetainsKnownRoute = staleStatus?.getAttribute('data-outgoing') === 'known' + && /stale data/i.test(staleStatus.textContent) && /incomplete data/i.test(staleStatus.textContent) + && !!overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]') + && [...overlay.shadowRoot.querySelectorAll('line,polygon')] + .every((node) => getComputedStyle(node).strokeDasharray === 'none'); + const failedRefresh = snapshot(null); + failedRefresh.states = { ...failedRefresh.states, zha: { + phase: 'error', error: 'provider', obtainedAt: failedRefresh.topologies[0].obtainedAt, stale: true, + } }; + overlay._acceptRuntime(failedRefresh); + await overlay.updateComplete; + out.failedRefreshMarksRetainedRouteStale = /stale data/i.test( + overlay.shadowRoot.querySelector('.route-status')?.textContent || '', + ) && !!overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'); + const expiring = snapshot(null); + expiring.topologies = expiring.topologies.map((topology) => ({ + ...topology, obtainedAt: Date.now() - 5 * 60 * 1000 + 300, + })); + const fetchCount = window.__zigbeeSmokeFetchCount(); + overlay._acceptRuntime(expiring); + await overlay.updateComplete; + const initiallyFresh = !/stale data/i.test(overlay.shadowRoot.querySelector('.route-status')?.textContent || ''); + await wait(() => /stale data/i.test(overlay.shadowRoot.querySelector('.route-status')?.textContent || '')); + out.ageTimerUpdatesWithoutRefetch = initiallyFresh && window.__zigbeeSmokeFetchCount() === fetchCount + && !!overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'); + overlay._acceptRuntime(window.__zigbeeSmokeBaseline); + await overlay.updateComplete; + if (theme === 'dark') { + const originalHass = overlay.hass; + overlay.hass = { ...overlay.hass, language: 'ru', locale: { ...overlay.hass.locale, language: 'ru' } }; + await overlay.updateComplete; + hover(root().querySelector('.dev[data-id="d_temp"]')); + await wait(() => overlay.shadowRoot.querySelector('[data-kind="unplaced-device"]')?.textContent + .includes('Устройства нет на плане'), 'localized missing target'); + const named = overlay.shadowRoot.querySelector('[data-kind="unplaced-device"]'); + out.localizedTargetName = named.textContent.trim() === 'Устройства нет на плане (Hallway & "target")' + && !named.querySelector('router,img,script'); + hover(root().querySelector('.dev[data-id="d_kettle"]')); + await wait(() => overlay.shadowRoot.querySelector('.route-status')?.textContent.includes('Нет данных о маршруте')); + out.localizedUnknownRoute = !overlay.shadowRoot.querySelector('line,polygon'); + overlay.hass = originalHass; + await overlay.updateComplete; + } + return { checks: out, diagnostics }; + }, { theme }); + if (visual.diagnostics.length) console.log('Topology visual diagnostics:', JSON.stringify(visual.diagnostics)); + for (const [name, value] of Object.entries(visual.checks)) result[`visual_${theme}_${name}`] = value; +} + for (const [aspect, viewport] of Object.entries({ wide: { width: 1100, height: 500 }, tall: { width: 480, height: 900 }, @@ -476,8 +710,10 @@ for (const [aspect, viewport] of Object.entries({ } } +for (const theme of ['light', 'dark']) { +await page.emulateMedia({ colorScheme: theme }); await page.setViewportSize({ width: 1100, height: 620 }); -const rasterProbe = await page.evaluate(async () => { +const rasterProbe = await page.evaluate(async ({ theme }) => { const card = window.__card; const root = card.shadowRoot || card.renderRoot; const wait = (predicate, timeout = 5000) => new Promise((resolve, reject) => { @@ -490,7 +726,9 @@ const rasterProbe = await page.evaluate(async () => { tick(); }); document.querySelector('#host').style.width = '1060px'; - card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: true } }; + card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: true }, + themes: { ...(card.hass.themes || {}), darkMode: theme === 'dark' } }; + document.documentElement.style.colorScheme = theme; card._setMode('view'); card._commitSpace('f1', true); card._zoom = 1; @@ -516,6 +754,7 @@ const rasterProbe = await page.evaluate(async () => { await card.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const renderedLabel = root.querySelector('.roomlabel'); + root.querySelectorAll('[data-hp="zigbee-topology-room-label-raster-control"]').forEach((node) => node.remove()); label = renderedLabel?.cloneNode(true) || document.createElement('div'); label.className = 'roomlabel'; label.dataset.hp = 'zigbee-topology-room-label-raster-control'; @@ -534,7 +773,10 @@ const rasterProbe = await page.evaluate(async () => { await overlay.updateComplete; const svg = overlay.shadowRoot.querySelector('svg'); const line = overlay.shadowRoot.querySelector( - '[data-hp="zigbee-topology-line"][stroke-dasharray="5 5"]', + '[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', + ); + const arrow = overlay.shadowRoot.querySelector( + '[data-hp="zigbee-topology-arrow"][data-direction="toward-neighbor"]', ); const matrix = svg.getScreenCTM(); const start = new DOMPoint(Number(line.getAttribute('x1')), Number(line.getAttribute('y1'))) @@ -546,6 +788,11 @@ const rasterProbe = await page.evaluate(async () => { return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; }; const labelCentre = label ? centre(label) : null; + const arrowPoints = arrow.getAttribute('points').trim().split(/\s+/).map((pair) => { + const [x, y] = pair.split(',').map(Number); + const point = new DOMPoint(x, y).matrixTransform(matrix); + return { x: point.x, y: point.y }; + }); return { dpr: devicePixelRatio, line: { start: { x: start.x, y: start.y }, end: { x: end.x, y: end.y } }, @@ -554,9 +801,11 @@ const rasterProbe = await page.evaluate(async () => { unrelated: centre(unrelated), label: labelCentre ? { x: labelCentre.x, y: start.y } : null, labelContainsLine: !labelCentre || Math.abs(labelCentre.y - start.y) <= 14, + arrowPoints, }; -}); -const activeRaster = await page.screenshot({ animations: 'disabled' }); +}, { theme }); +const activeRaster = await page.screenshot({ animations: 'disabled', + path: fileURLToPath(new URL(`known-route-missing-lqi-${theme}.png`, visualArtifacts)) }); await page.evaluate(() => { const card = window.__card; (card.shadowRoot || card.renderRoot).querySelector('hp-zigbee-topology-overlay').style.visibility = 'hidden'; @@ -580,7 +829,7 @@ const rasterEvidence = await page.evaluate(async ({ active, baseline, probe }) = const b = await decode(baseline); if (a.width !== b.width || a.height !== b.height) return { unrelatedAbove: false, labelAbove: false, endpointsAbove: false, - casingInk: false, transparentGaps: false, + casingInk: false, continuousCore: false, arrowBlackOutline: false, }; const changed = (x, y, threshold = 24) => { const px = Math.max(0, Math.min(a.width - 1, Math.round(x * probe.dpr))); @@ -605,35 +854,164 @@ const rasterEvidence = await page.evaluate(async ({ active, baseline, probe }) = const length = Math.hypot(dx, dy); const ux = dx / length; const uy = dy / length; const nx = -uy; const ny = ux; - let coreAndCasing = 0; let transparentGap = 0; - for (let distance = 35; distance < Math.min(length - 35, length * 0.42); distance += 1) { + let coreAndCasing = 0; let paintedCore = 0; let samples = 0; + for (let distance = Math.max(35, length * 0.18); distance < Math.min(length - 35, length * 0.38); distance += 1) { const x = probe.line.start.x + ux * distance; const y = probe.line.start.y + uy * distance; const core = changed(x, y); const edge = changed(x + nx * 1.75, y + ny * 1.75) || changed(x - nx * 1.75, y - ny * 1.75); - const quietCore = !changed(x, y, 60); - const quietEdge = !changed(x + nx * 1.75, y + ny * 1.75, 60) - && !changed(x - nx * 1.75, y - ny * 1.75, 60); + samples++; + if (core) paintedCore++; if (core && edge) coreAndCasing++; - if (quietCore && quietEdge) transparentGap++; + } + // Sample just outside both sloping polygon sides. A fill-only triangle cannot + // supply dark pixels beyond its geometry; the black 1px outer outline can. + const darkPixel = (x, y) => { + const px = Math.max(0, Math.min(a.width - 1, Math.floor(x * probe.dpr))); + const py = Math.max(0, Math.min(a.height - 1, Math.floor(y * probe.dpr))); + const i = (py * a.width + px) * 4; + return Math.max(a.data[i], a.data[i + 1], a.data[i + 2]) < 85; + }; + const arrowCentre = probe.arrowPoints.reduce((sum, point) => ({ + x: sum.x + point.x / 3, y: sum.y + point.y / 3, + }), { x: 0, y: 0 }); + let blackSides = 0; + for (const side of [1, 2]) { + const tip = probe.arrowPoints[0]; const corner = probe.arrowPoints[side]; + const dx = corner.x - tip.x; const dy = corner.y - tip.y; + const sideLength = Math.hypot(dx, dy); + const mid = { x: (tip.x + corner.x) / 2, y: (tip.y + corner.y) / 2 }; + let nx = -dy / sideLength; let ny = dx / sideLength; + if ((arrowCentre.x - mid.x) * nx + (arrowCentre.y - mid.y) * ny > 0) { nx = -nx; ny = -ny; } + let ink = 0; + for (const t of [0.25, 0.4, 0.55, 0.7]) { + const x = tip.x + dx * t + nx * 0.5; + const y = tip.y + dy * t + ny * 0.5; + if (darkPixel(x, y) && changed(x, y)) ink++; + } + if (ink >= 2) blackSides++; } return { unrelatedAbove: unrelatedInk >= 3, labelAbove: labelInk >= 2, endpointsAbove: endpointInk <= 2, casingInk: coreAndCasing >= 6, - transparentGaps: transparentGap >= 1, + continuousCore: samples >= 12 && paintedCore / samples >= 0.98, + arrowBlackOutline: blackSides === 2, }; }, { active: activeRaster.toString('base64'), baseline: baselineRaster.toString('base64'), probe: rasterProbe, }); -result.rasterLabelContainsLine = rasterProbe.labelContainsLine; +result[`raster_${theme}_labelContainsLine`] = rasterProbe.labelContainsLine; for (const [name, value] of Object.entries(rasterEvidence)) { - result[`raster_${name}`] = value; + result[`raster_${theme}_${name}`] = value; } +} + +// Owner contract: the outline grows with the plan, including the transient +// camera transform on the common device layer. Use actual screen pixels here: +// computed SVG stroke widths alone cannot distinguish ancestor CSS scaling. +await page.evaluate(async () => { + const card = window.__card; + const root = card.shadowRoot || card.renderRoot; + const layer = root.querySelector('.devlayer'); + const source = root.querySelector('.dev[data-id="d_light1"]'); + const target = root.querySelector('.dev[data-id="d_lamp"]'); + window.__zigbeeOutlineProbeStyles = [layer, source, target].map((node) => [node, node.getAttribute('style')]); + // The camera owns the layer's inline transform and can clear it on a queued + // commit. Keep this synthetic live-transform fixture stable across captures. + layer.setAttribute('data-hp-zigbee-outline-probe', ''); + const style = document.createElement('style'); + root.append(style); + window.__zigbeeOutlineProbeStyle = style; + source.style.left = '10%'; source.style.top = '30%'; + target.style.left = '42%'; target.style.top = '30%'; + const overlay = root.querySelector('hp-zigbee-topology-overlay'); + overlay.viewKey = { outlineGrowth: true }; + source.dispatchEvent(new PointerEvent('pointerover', { + pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, + })); + await overlay.updateComplete; +}); +const outlinePixelMass = []; +for (const scale of [0.5, 1, 2]) { + const probe = await page.evaluate(async ({ scale }) => { + const card = window.__card; + const root = card.shadowRoot || card.renderRoot; + window.__zigbeeOutlineProbeStyle.textContent = `[data-hp-zigbee-outline-probe] { + transform: scale(${scale}) !important; transform-origin: 0 0 !important; background: #fff !important; + }`; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const overlay = root.querySelector('hp-zigbee-topology-overlay'); + const svg = overlay.shadowRoot.querySelector('svg'); + const line = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]'); + const arrow = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"][data-direction="toward-neighbor"]'); + const point = new DOMPoint( + Number(line.getAttribute('x1')) * 0.55 + Number(line.getAttribute('x2')) * 0.45, + Number(line.getAttribute('y1')), + ).matrixTransform(svg.getScreenCTM()); + const rect = arrow.getBoundingClientRect(); + return { point: { x: point.x, y: point.y }, scale, dpr: devicePixelRatio, + arrow: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } }; + }, { scale }); + const pixels = await page.screenshot({ animations: 'disabled', + path: fileURLToPath(new URL(`outline-live-scale-${scale}.png`, visualArtifacts)) }); + outlinePixelMass.push(await page.evaluate(async ({ image, probe }) => { + const bitmap = await createImageBitmap(await (await fetch(`data:image/png;base64,${image}`)).blob()); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0); + const pixels = ctx.getImageData(0, 0, bitmap.width, bitmap.height).data; + // White fixture backdrop and grey core contribute zero. Black casing + // contributes one per pixel; antialiased coverage contributes a fraction. + const black = (x, y) => { + const i = (y * bitmap.width + x) * 4; + return Math.max(0, (165 - Math.max(pixels[i], pixels[i + 1], pixels[i + 2])) / 165); + }; + const x = Math.round(probe.point.x * probe.dpr); const y = Math.round(probe.point.y * probe.dpr); + let line = 0; + const radius = Math.ceil(4 * probe.scale * probe.dpr); + for (let dx = -3; dx <= 3; dx++) for (let dy = -radius; dy <= radius; dy++) { + line += black(x + dx, y + dy) / (7 * probe.dpr); + } + const pad = 2 * probe.scale; + const bounds = { + left: Math.floor((probe.arrow.x - pad) * probe.dpr), + top: Math.floor((probe.arrow.y - pad) * probe.dpr), + right: Math.ceil((probe.arrow.x + probe.arrow.width + pad) * probe.dpr), + bottom: Math.ceil((probe.arrow.y + probe.arrow.height + pad) * probe.dpr), + }; + let arrow = 0; + const inFrame = bounds.left >= 0 && bounds.top >= 0 + && bounds.right < bitmap.width && bounds.bottom < bitmap.height; + if (inFrame) for (let py = bounds.top; py <= bounds.bottom; py++) for (let px = bounds.left; px <= bounds.right; px++) { + arrow += black(px, py) / (probe.dpr * probe.dpr); + } + return { scale: probe.scale, line, arrow, inFrame }; + }, { image: pixels.toString('base64'), probe })); +} +result.livePlanScaleGrowsLineOutline = outlinePixelMass.every((sample) => sample.inFrame && sample.line > 0) + && outlinePixelMass[1].line > outlinePixelMass[0].line * 1.4 + && outlinePixelMass[2].line > outlinePixelMass[1].line * 1.4; +result.livePlanScaleGrowsArrowOutline = outlinePixelMass.every((sample) => sample.arrow > 0) + && outlinePixelMass[1].arrow > outlinePixelMass[0].arrow * 2.5 + && outlinePixelMass[2].arrow > outlinePixelMass[1].arrow * 2.5; +console.log('Topology live outline pixel mass:', JSON.stringify(outlinePixelMass)); +await page.evaluate(async () => { + window.__zigbeeOutlineProbeStyle.remove(); + for (const [node, style] of window.__zigbeeOutlineProbeStyles) { + node.removeAttribute('data-hp-zigbee-outline-probe'); + if (style === null) node.removeAttribute('style'); + else node.setAttribute('style', style); + } + const card = window.__card; + const overlay = (card.shadowRoot || card.renderRoot).querySelector('hp-zigbee-topology-overlay'); + overlay.viewKey = { outlineGrowth: false }; + await overlay.updateComplete; +}); const realHoverPoint = await page.evaluate(() => { const card = window.__card; @@ -668,10 +1046,10 @@ result.forcedColorsPreserved = await page.evaluate(() => { const overlay = root.querySelector('hp-zigbee-topology-overlay'); const casing = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line-casing"]'); const core = overlay.shadowRoot.querySelector( - '[data-hp="zigbee-topology-line"][stroke-dasharray="5 5"]', + '[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]', ); return !!casing && !!core && getComputedStyle(casing).stroke === getComputedStyle(core).stroke - && getComputedStyle(casing).stroke !== 'rgb(46, 46, 46)'; + && getComputedStyle(casing).stroke !== 'rgb(0, 0, 0)'; }); await page.emulateMedia({ forcedColors: 'none' }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 796326ba..62bf87d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -591,18 +591,31 @@ overlay bridge; the overlay chunk loads only for a saved `settings.zigbee_topology.enabled === true`, a real HA admin, full-card View and a non-kiosk surface. General Settings loads provider transport only when an enabled setting needs status or the admin presses a provider action. -`zigbee-topology.ts` normalises ZHA and Zigbee2MQTT into unordered edge pairs -with directional observations, maps IEEE nodes through exact registry ownership -and resolves only edges incident to the hovered marker, never inventing -neighbours. `zigbee-topology-runtime.ts` keeps a per-connection memory cache with +`zigbee-topology.ts` normalises ZHA and Zigbee2MQTT into directional observations +and provider evidence (#798): Parent/reverse Child for end devices, active +destination-zero routes for routers. NWK addresses are provider-scoped; exact +registry ownership maps nodes to markers. Only unique evidence becomes an +arrow. Conflicts, cycles and unknown roles fail closed, including merged +providers; no BFS or strongest-LQI fallback remains. A known next hop need not +prove the complete chain. Mapping/resolution are memoized outside hover. +`zigbee-topology-runtime.ts` keeps a per-connection memory cache with in-flight dedupe: ZHA reads `zha/devices` without a scan; Z2M checks the retained -bridge-info topic, sends one correlated raw `routes:false` request through +bridge-info topic, sends one correlated raw `routes:true` request through `mqtt.publish`, rejects retained/foreign/late replies and always unsubscribes. +A shared deadline bounds subscriptions, publish and response, including late +subscription cleanup. Refresh failure retains the last-good snapshot with a +stale flag; ZHA cache retrieval is never described as a fresh radio scan. The pointer-transparent overlay is a child of the `.devlayer` camera, projected once with the markers by `live-viewport.ts`; only the source and drawable neighbour markers are promoted above it, through transient attributes the -overlay owns and clears. Unknown-LQI links are a 4 px `#2e2e2e` casing under the -2 px grey core. Persistence, privacy: [CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md). +overlay owns and clears. All routes are solid; `zigbee-topology-style.ts` owns +the independent RGB 0/red–128/yellow–255/green palette. Unknown-LQI links have +a black casing under a 2 px grey core and an outer arrow outline. The outline +is 1 px at zoom=1 and scales with the plan, including temporary camera CSS +projection, per the owner's #798 clarification. Local and remote/unplaced routes share one renderer. +Unknown/partial/stale captions never trigger network work; one expiry timer +updates age without requiring another HA event. Persistence and privacy: +[CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md). ## Live viewport: a transform per frame, a `viewBox` on a budget (#531, #579) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 07f11c73..48ca3f7d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Zigbee hover arrows now use parents and active next hops reported by ZHA or + Zigbee2MQTT instead of an inferred neighbour tree. Unknown, stale or incomplete + routes are identified explicitly; all arrows are solid, with a full-range LQI + colour scale or a grey/black outline when quality is unknown. Unplaced parent + labels include the device name ([#798](https://github.com/Matysh/houseplan-card/issues/798)). + - LED Glow temporarily uses a lighter gradient while zooming, without hiding the light; full quality returns automatically when zooming pauses. Panning and stationary plans keep their original quality diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 5822871f..2719e7c1 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,12 @@ ## Не выпущено +- Стрелки Zigbee теперь показывают родителей и активные следующие узлы по + сведениям ZHA/Zigbee2MQTT, а не вычисленное дерево соседей. Неизвестные, + устаревшие и неполные данные явно отмечены. Все стрелки сплошные, с плавной + шкалой LQI 0–255; без LQI — серые с чёрной обводкой. В подписи отсутствующего + на плане родителя добавлено его имя ([#798](https://github.com/Matysh/houseplan-card/issues/798)). + - При зуме свечение LED-лент временно использует облегчённый градиент, не исчезая с плана; после паузы полное качество возвращается автоматически. Панорамирование и неподвижный план сохраняют прежнее качество diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index 0e575b23..de454af7 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -258,6 +258,11 @@ diagnostics, and disappear with the HA connection/page. Older frontends ignore the settings object; the backend's unknown-settings policy preserves it. No model or store version migration is required. +#798 replaces inferred routes with provider evidence in that runtime only. +Next-hop addresses, target names and warnings are never persisted. Failed +refresh preserves the last successful snapshot and explicitly marks it stale; +reading cached ZHA tables does not establish their radio-scan time. + ## Vacuum map routes (#162) `marker.vacuum.map_routes` is an optional array of diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4285debb..fee1aedd 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -22,6 +22,14 @@ the degradation is accepted. ## Local contour in 5 minutes (локальный контур за 5 минут, #633) +For Zigbee route changes (#798), focused Node tests cover provider formats, +conflicts, exact mapping and async lifecycle. After `bundle:sync`, run +`node demo/smoke_zigbee_topology_hover.mjs` in WSL/Linux: it checks solid arrows, +unknown-quality outlines, target names, gates and camera anchoring. The +topology benchmark must use confirmed route evidence, not an empty graph. +Neighbour-only raw fixtures remain negative controls; do not add guessed +routes to make them draw. Ordinary LQI badges keep their separate palette. + For warm-remount changes, `node demo/smoke_warm_mode_adoption.mjs` records intermediate camera frames and screen-space points, observes every memo size publication, and exercises delayed permission/runtime races and a touch floor diff --git a/docs/STATUS.md b/docs/STATUS.md index d31065b1..1dd31455 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -29,6 +29,7 @@ Everything computable from the tree and git; regenerate, never edit by hand |---|---| | Current local cycle | **Stable v1.79.0 candidate** — promotion of published beta.7, with no new feature source. The line adds LED strips and the moon on any background, stabilizes 2.5D layout, and improves room-fill transitions and warm remount. Exact-SHA full gates precede publication; `main` is promoted only to the tested candidate. | | Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. | +| Zigbee routes | #798 replaces inferred neighbour trees with integration-reported end-parent and active coordinator next-hop evidence. Unknown/conflicting routes are not guessed; stale/partial snapshots remain labelled. Solid arrows have a separate 0–255 palette; ordinary device LQI colours are unchanged. | | LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. | | LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. | | LED performance work | #789 batches source entry, reuses render-local barriers and temporarily paints 24 of 48 retained gradient bands during actual zoom; full quality returns after 160 ms of scale inactivity. Emitters, clips and idle pixels are unchanged. Exact-SHA 50×50 acceptance includes restoration and quick restart; implementation alone does not certify the camera budget. | diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 165622db..0f26220e 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -347,26 +347,29 @@ network-map scan for each entered base topic (default `zigbee2mqtt`). The latter may take 10 seconds to 2 minutes and can temporarily slow the Zigbee network. After data is loaded, moving a real mouse over a mapped Zigbee marker shows -only its observed direct neighbours. Links to markers on the current space are -lines; drawable neighbours on other spaces are summarized as a temporary -count. An arrow on a line shows the next step towards the coordinator: an -ordinary device points to its parent, while arrows pointing into a router show -devices whose path goes through it. Neighbour links outside the derived path -tree remain plain lines. +its parent (end device) or active next hop towards the coordinator (router), +as reported by the integration. There is at most one outgoing arrow; confirmed +incoming arrows show devices routing through this node. The coordinator has +incoming arrows only. Ordinary neighbour links are hidden; LQI is not used to +guess a route. Confirmed incoming devices on other spaces are counted. The active diagnostic layer is deliberately drawn above room names and devices that are not part of the shown link, so a busy plan cannot hide the route. The complete source and locally connected device markers remain above the lines. -An unknown-quality gray dashed link has a thin dark outline for contrast; this -does not change its meaning. The whole layer is pointer-transparent, so device +All lines and arrows are solid and share a separate LQI scale: red at 0, +yellow at 128, green at 255. Unknown quality is grey with a black outline +that scales with the plan. Device LQI badges and room colours are unchanged. +The whole layer is pointer-transparent, so device and room actions continue to work normally. If the next step is in another space, a short bubble names that space. If the -needed router or coordinator is not placed on the plan, the bubble says so. If -the snapshot has no coordinator or the graph is disconnected, House Plan does -not invent a direction and leaves the link without an arrow. This is a stable -path approximation derived from the neighbour snapshot, not the route used by -every current packet. The layer does not appear on touch/pen, in kiosk, in +needed router or coordinator is not placed on the plan, the bubble says so; +an unplaced device's available name is included in parentheses. “No route data” +means the outgoing route is unknown or conflicting, not that the device is +offline. No substitute line is drawn. Stale and incomplete data are marked; +refresh failure keeps the last snapshot marked stale. A recent read of ZHA's +cache does not prove a recent radio scan. These are integration records, not +live packet tracing or proof of the entire chain. The layer does not appear on touch/pen, in kiosk, in editors or in the static card, and hovering never starts a scan. The editor grid continues across the whole working canvas; View does not show diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 2026f7ed..8f228481 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -365,27 +365,31 @@ Zigbee2MQTT для каждого указанного базового топи снизить отзывчивость Zigbee-сети. После загрузки данных наведение реальной мыши на сопоставленный Zigbee-маркер -показывает только его наблюдаемых прямых соседей. Соседи в текущем пространстве -соединяются линиями, а доступные маркеры в других пространствах учитываются во -временном счётчике. Стрелка на линии показывает следующий шаг в сторону -координатора: у обычного устройства она направлена к родителю, а входящие -стрелки к роутеру показывают устройства, чей путь проходит через него. Связи, -не вошедшие в вычисленное дерево пути, остаются линиями без стрелок. +показывает родителя конечного устройства или активный следующий узел роутера к +координатору по сведениям интеграции. Исходящая стрелка не более одной, +входящие — подтверждённые устройства, идущие через этот узел. У координатора +только входящие. Обычные соседские связи скрыты; LQI не используется для +угадывания маршрута. Подтверждённые входящие с других пространств учитываются +во временном счётчике. Активный диагностический слой намеренно рисуется поверх названий комнат и устройств, которые не участвуют в показанной связи, поэтому насыщенный план не скрывает маршрут. Полные маркеры исходного и локально связанного устройств -остаются поверх линий. Серый пунктир неизвестного качества получает тонкую -тёмную обводку для контраста; его смысл от этого не меняется. Весь слой +остаются поверх линий. Все линии и стрелки сплошные; отдельная плавная шкала +LQI: 0 — красный, 128 — жёлтый, 255 — зелёный. Без LQI — серые с чёрной +обводкой, масштабируемой вместе с планом. LQI-бейджи и заливки комнат не меняются. Весь слой прозрачен для указателя, поэтому действия комнат и устройств работают как раньше. Если следующий шаг находится в другом пространстве, рядом с устройством появляется его название. Если нужный роутер или координатор не размещён на -плане, короткая подпись сообщает об этом. При отсутствии координатора в снимке -или разрыве графа House Plan не выдумывает направление и оставляет линию без -стрелки. Это устойчивое приближение пути по снимку соседства, а не фактический -маршрут каждого текущего пакета. На touch/pen, в киоске, редакторах и +плане, короткая подпись сообщает об этом; доступное имя отсутствующего +устройства добавляется в скобках. «Нет данных о маршруте» означает отсутствие +однозначных сведений об исходящей связи, а не недоступность устройства. +Подменяющей линии нет. Неполные и устаревшие данные отмечены; ошибка обновления +оставляет последний снимок с отметкой об устаревании. Недавнее чтение кеша ZHA +не доказывает свежесть радиосканирования. Это записи интеграции, а не маршрут +каждого текущего пакета или гарантия всей цепочки. На touch/pen, в киоске, редакторах и статической карточке слой не появляется; само наведение никогда не запускает сканирование. diff --git a/docs/reviews/SPEC-REVIEW-798-r1.md b/docs/reviews/SPEC-REVIEW-798-r1.md new file mode 100644 index 00000000..946a2d66 --- /dev/null +++ b/docs/reviews/SPEC-REVIEW-798-r1.md @@ -0,0 +1,38 @@ +# Саморевью ТЗ #798 — маршруты Zigbee по сведениям интеграций + +Вердикт: зелёный. High: 0 · Medium: 0 · Low: 0. + +Материал: тело issue #798, раздел `## ТЗ`, 2026-10-05. +SHA-256 тела после уточнения владельца об обводке: `3b6e5ef45b85ea85e7c71f867c813c16d20868c714f5368f1a9924b41ca13c10`. +База: `0d645476438d5be4bba89433f16408e59e773d5f`. + +Это саморевью автора по прямому исключению владельца, не независимый вердикт +недоступной модели. Разрешение и занятие зафиксированы в +[комментарии](https://github.com/Matysh/houseplan-card/issues/798#issuecomment-5993608466). +S4/S7 не запускаются; S8 допустим только после проверок и push кода в dev. +Выпуск и закрытие issue не разрешены этим исключением. + +## Проверка DoR + +- Сценарий соответствует SCOPE: необязательная контекстная диагностика на плане, + по умолчанию скрыта, не самостоятельный сетевой анализатор. +- Ответы владельца Q1/Q2/Q3, новая палитра и имя отсутствующей цели отражены. +- Уточнение в ходе работы: обводка масштабируется вместе с планом, не остаётся + экранным CSS-пикселем. AC5 и проверки обновлены; повторное саморевью дельты зелёное. +- ТЗ разделяет родителя конечного устройства и маршрут роутера к destination 0. + Обратный Child допустим; Parent роутера и обычное соседство не доказывают маршрут. +- Источники форматов проверены: ZHA `helpers.async_get_device_info` и zigpy + topology; Z2M `networkMap` и zigbee-herdsman routing entries. У Z2M владелец + соседской таблицы — target raw link, next hop — source; фильтра ACTIVE + upstream недостаточно ожидать, он нужен в нормализаторе. +- Заданы конфликт, цикл, неизвестные роль/адрес/данные, неполная цепочка и + объединение провайдеров. Нет BFS, выбора сильнейшего или первого родителя. +- Для AC1–AC8 есть observable unit/browser evidence, отрицательные пробы, + проверка размера/стоимости, async cleanup и старого снимка при ошибке. +- Данные runtime-only, схема не меняется; нет миграции или радиоопроса hover. +- i18n покрывает четыре текущие локали, имя остаётся текстом. Forced-colors, + touch/admin/view gates и LQI-бейджи не меняются. +- Риски, rollback, файлы, changelog и release-артефакты перечислены. + +Проверено чтением ТЗ, кода и контрактов провайдеров. Продуктовая реализация +и её тестовые результаты этим документом не подтверждаются. diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index cb5210e4..762b6404 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -33,11 +33,11 @@ that transition events alone prove disposal. | --- | ---: | --- | | Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. | | Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. | -| Paint, cascade and layer composition | 37 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | +| Paint, cascade and layer composition | 40 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | | Pointer geometry and trusted interaction | 49 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. | | Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. | | Custom-element and HA browser lifecycle | 103 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | -| **Total** | **235 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. | +| **Total** | **238 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. | ## Measured effect @@ -93,6 +93,11 @@ The mutation breaks page-error, round-trip or page-registration observation in t The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. +The #798 Zigbee witnesses exercise the mounted SVG for local and parent-bubble +routes in both themes and at minimum/default/maximum plan zoom. Unknown-LQI +outlines must scale with the plan, including live CSS projection; known-LQI +parent routes must retain the same line/arrow color as local routes. + - `daycycle-outline-not-promoted` - `daycycle-outline-promoted-on-inner-paper` - `daycycle-static-outline-promoted-on-inner-paper` @@ -130,6 +135,9 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p - `value-static-icon-keeps-live-vacuum` - `value-static-icon-keeps-route-warning` - `value-static-icon-keeps-vacuum-overlay` +- `zigbee-topology-parent-lqi-discarded` +- `zigbee-topology-unknown-arrow-outline-removed` +- `zigbee-topology-unknown-outline-ignores-plan-zoom` ### Pointer geometry and trusted interaction @@ -332,6 +340,6 @@ The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy - `zigbee-topology-hovered-endpoint-elevation-removed` - `zigbee-topology-overlay-double-live-projection` - `zigbee-topology-overlay-layer-lowered` -- `zigbee-topology-unknown-casing-gaps-filled` +- `zigbee-topology-solid-route-dashed` - `zigbee-topology-unknown-casing-removed` - `zigbee-topology-unrelated-markers-raised` diff --git a/scripts/monolith-baseline.json b/scripts/monolith-baseline.json index d7bd7b8c..484c7d91 100644 --- a/scripts/monolith-baseline.json +++ b/scripts/monolith-baseline.json @@ -4,5 +4,5 @@ "hostRefs": 5064, "portPrivates": 103, "harnessPrivates": 100, - "bundleBytes": 2678324 + "bundleBytes": 2685888 } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index c1d24375..2470d898 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -9609,28 +9609,61 @@ const MUTANT_DEFINITIONS = [ { id: 'zigbee-topology-unknown-casing-removed', guard: 'node demo/smoke_zigbee_topology_hover.mjs', - because: 'an unknown-LQI dash needs one CSS pixel of dark casing on each side instead of ' - + 'the old low-contrast gray-only stroke (#464 AC3)', + because: 'an unknown-LQI route needs black casing on each side, scaling with the plan ' + + 'instead of a gray-only stroke, including remote/unplaced parents (#464 AC3, #798)', patches: [{ file: 'src/hp-zigbee-topology-overlay.ts', - find: ' stroke-width="4" stroke-dasharray="5 5" stroke-dashoffset="0" opacity=".9">` : nothing}', - replace: ' stroke-width="2" stroke-dasharray="5 5" stroke-dashoffset="0" opacity=".9">` : nothing}', + find: ' stroke="#000000" stroke-width=${2 + 2 * outline} data-direction=${direction}>` : nothing}', + replace: ' stroke="#000000" stroke-width="2" data-direction=${direction}>` : nothing}', }], }, { - id: 'zigbee-topology-unknown-casing-gaps-filled', + id: 'zigbee-topology-solid-route-dashed', guard: 'node demo/smoke_zigbee_topology_hover.mjs', - because: 'the wider casing must share the core dash rhythm; a solid casing would silently ' - + 'turn the unknown-quality link into a continuous route (#464 AC3)', + because: 'every confirmed Zigbee route is solid; missing quality and stale data must not ' + + 'restore the superseded dashed-link encoding (#798)', patches: [{ file: 'src/hp-zigbee-topology-overlay.ts', - find: ' stroke-width="4" stroke-dasharray="5 5" stroke-dashoffset="0" opacity=".9">` : nothing}', - replace: ' stroke-width="4" stroke-dasharray="none" stroke-dashoffset="0" opacity=".9">` : nothing}', + find: ' line { vector-effect: non-scaling-stroke; stroke-linecap: round; }', + replace: ' line { vector-effect: non-scaling-stroke; stroke-linecap: round; stroke-dasharray: 5 5; }', + }], + }, + { + id: 'zigbee-topology-unknown-arrow-outline-removed', + guard: 'node demo/smoke_zigbee_topology_hover.mjs', + because: 'the missing-LQI black outline includes the arrowhead, not only the shaft, ' + + 'and grows with the plan from one pixel outside the polygon at 100% (#798)', + patches: [{ + file: 'src/hp-zigbee-topology-overlay.ts', + find: " stroke=${lqi === undefined ? '#000000' : nothing} stroke-width=${lqi === undefined ? 2 * outline : nothing}", + replace: " stroke=${lqi === undefined ? '#000000' : nothing} stroke-width=${lqi === undefined ? 0 : nothing}", + }], + }, + { + id: 'zigbee-topology-unknown-outline-ignores-plan-zoom', + guard: 'node demo/smoke_zigbee_topology_hover.mjs', + because: 'the owner requested the missing-LQI outline to grow and shrink with the plan ' + + 'rather than remaining a constant CSS pixel at settled zoom (#798)', + patches: [{ + file: 'src/hp-zigbee-topology-overlay.ts', + find: ' stroke="#000000" stroke-width=${2 + 2 * outline} data-direction=${direction}>` : nothing}', + replace: ' stroke="#000000" stroke-width="4" data-direction=${direction}>` : nothing}', + }], + }, + { + id: 'zigbee-topology-parent-lqi-discarded', + guard: 'node demo/smoke_zigbee_topology_hover.mjs', + because: 'remote-space and unplaced target routes use the same full-range LQI color ' + + 'as local routes instead of becoming permanently grey (#798)', + patches: [{ + file: 'src/hp-zigbee-topology-overlay.ts', + find: " ${bubbles.map((bubble) => this._route(origin, bubble.point, bubble.target.lqi, 'toward-neighbor', bubble.arrow, true))}", + replace: " ${bubbles.map((bubble) => this._route(origin, bubble.point, undefined, 'toward-neighbor', bubble.arrow, true))}", }], }, { id: 'zigbee-topology-zha-read-starts-scan', - guard: 'node --test test/zigbee-topology.test.mjs', + guard: 'node --test --test-name-pattern="ZHA runtime is explicit" test/zigbee-topology.test.mjs', because: 'hover diagnostics may read the existing ZHA snapshot but must never turn a read ' + 'into an implicit radio scan (#54 AC4)', patches: [{ @@ -9641,7 +9674,7 @@ const MUTANT_DEFINITIONS = [ }, { id: 'zigbee-topology-ambiguous-marker-selected', - guard: 'node --test test/zigbee-topology.test.mjs', + guard: 'node --test --test-name-pattern="exact device/entity ownership" test/zigbee-provider-routes.test.mjs', because: 'a Zigbee node with multiple drawable placements must fail closed instead of ' + 'drawing a plausible but false neighbour line (#54 AC8)', patches: [{ @@ -9665,31 +9698,30 @@ const MUTANT_DEFINITIONS = [ }, { id: 'topology-help-drops-arrow-legend', - guard: 'node --test --test-name-pattern="все шесть пунктов легенды|не путь пакета" ' + guard: 'node --test --test-name-pattern="подсказка описывает provider evidence" ' + 'test/zigbee-topology.test.mjs', - because: 'a hint that names only colour and dashes passes «the help exists» while answering ' - + 'none of the questions the arrows raise — the very content this task waited for #457 to ' - + 'write (#459 AC3, AC3b)', + because: 'the hint must explain provider-reported end parents and coordinator next hops, ' + + 'not merely colours; the retired inferred-tree explanation cannot describe #798 routes', patches: [{ file: 'src/i18n/topology/ru.json', - find: ' Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен.', + find: 'Показывает родителя конечного устройства или активный следующий узел роутера к координатору по данным интеграции. Исходящая стрелка не более одной, входящие — устройства, идущие через этот узел. Обычные соседи скрыты. ', replace: '', }], }, { id: 'zigbee-topology-z2m-camelcase-node-rejected', - guard: 'node --test --test-name-pattern="real anonymized camelCase" test/zigbee-topology.test.mjs', + guard: 'node --test --test-name-pattern="real anonymized routes:false" test/zigbee-provider-routes.test.mjs', because: 'the Zigbee2MQTT raw network-map contract uses ieeeAddr; accepting only the ' + 'bridge/devices snake_case spelling makes every real scan fail as invalid (#450 AC1)', patches: [{ - file: 'src/zigbee-topology.ts', - find: ' const ieee = normalizeIeee(record?.ieeeAddr ?? record?.ieee_address ?? record?.ieee);', - replace: ' const ieee = normalizeIeee(record?.ieee_address ?? record?.ieee);', + file: 'src/zigbee-provider-routes.ts', + find: ' const value = recordOf(row); const ieee = normalizeIeee(value?.ieeeAddr ?? value?.ieee_address ?? value?.ieee);', + replace: ' const value = recordOf(row); const ieee = normalizeIeee(value?.ieee_address ?? value?.ieee);', }], }, { id: 'zigbee-topology-z2m-foreign-response-accepted', - guard: 'node --test test/zigbee-topology.test.mjs', + guard: 'node --test --test-name-pattern="Z2M requests routes and ignores" test/zigbee-topology-runtime-routes.test.mjs', because: 'parallel Zigbee2MQTT requests share one response topic; only the matching ' + 'transaction may complete this snapshot (#54 AC6)', patches: [{ @@ -9700,68 +9732,113 @@ const MUTANT_DEFINITIONS = [ }, { id: 'zigbee-topology-z2m-malformed-response-waits-for-timeout', - guard: 'node --test --test-name-pattern="malformed response immediately" test/zigbee-topology.test.mjs', + guard: 'node --test --test-name-pattern="malformed response immediately|a malformed response rejects" ' + + 'test/zigbee-topology.test.mjs test/zigbee-topology-runtime-routes.test.mjs', because: 'a malformed response on the subscribed network-map topic must report invalid payload ' + 'immediately instead of leaving the settings dialog loading for the full timeout (#54 AC16)', patches: [{ file: 'src/zigbee-topology-runtime.ts', - find: " if (value === null) {\n if (responseActive) responseReject?.(fail('invalid_payload'));\n return;\n }", + find: " if (value === null) {\n responseReject?.(fail('invalid_payload'));\n return;\n }", replace: ' if (value === null) return;', }], }, { id: 'zigbee-topology-z2m-subscriptions-leak', - guard: 'node --test test/zigbee-topology.test.mjs', + guard: 'node --test test/zigbee-topology-runtime-routes.test.mjs', because: 'both MQTT subscriptions must be released after success or failure so one manual ' + 'refresh cannot leave listeners processing later payloads (#54 AC6)', patches: [{ file: 'src/zigbee-topology-runtime.ts', - find: ' try { unsubscribe(); } catch { /* cleanup is best effort */ }', - replace: ' try { void unsubscribe; } catch { /* cleanup is best effort */ }', + find: ' try { unsubscribe(); } catch { /* cleanup is best effort */ }', + replace: ' try { void unsubscribe; } catch { /* cleanup is best effort */ }', }], }, { id: 'zigbee-route-relationship-separators-normalized', - guard: 'node --test --test-name-pattern="Z2M relationship strings" test/zigbee-topology.test.mjs', - because: 'provider relationship strings must ignore separators before the parent tie-break ' - + 'uses them (#457 section 6.3 and code-review r1)', + guard: 'node --test --test-name-pattern="relationship strings normalize separators" test/zigbee-provider-routes.test.mjs', + because: 'provider relationship spelling is normalized before accepting explicit Parent/Child ' + + 'evidence; PreviousChild remains non-evidence rather than a parent tie-break (#798)', patches: [{ - file: 'src/zigbee-topology.ts', - find: " const compact = value.trim().toLowerCase().replace(/[\\s_-]+/g, '');", - replace: " const compact = value.trim().toLowerCase();", + file: 'src/zigbee-provider-routes.ts', + find: " const compact = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\\s_-]+/g, '') : '';", + replace: " const compact = typeof value === 'string' ? value.trim().toLowerCase() : '';", }], }, { - id: 'zigbee-route-parent-keeps-bfs-level', - guard: 'node --test --test-name-pattern="uplink tree is deterministic" test/zigbee-topology.test.mjs', - because: 'every parent must be one BFS level nearer the coordinator; accepting a same-level ' - + 'neighbour can create a cycle and break the defining #457 AC1 invariant', + id: 'zigbee-provider-route-cycles-not-suppressed', + guard: 'node --test --test-name-pattern="provider cycles are suppressed" test/zigbee-provider-routes.test.mjs', + because: 'provider next-hop cycles must be suppressed rather than repaired with inferred ' + + 'BFS edges; a valid earlier hop remains usable without claiming a complete chain (#798)', patches: [{ - file: 'src/zigbee-topology.ts', - find: ' .filter(({ neighborKey }) => distances.get(neighborKey) === distance - 1)', - replace: ' .filter(({ neighborKey }) => distances.get(neighborKey) === distance)', + file: 'src/zigbee-provider-routes.ts', + find: ' suppressUplinkCycles(routes);', + replace: ' void routes; // mutant: keep provider cycles', }], }, { id: 'zigbee-route-local-arrow-not-inverted', - guard: 'node --test --test-name-pattern="hover projects local route directions" test/zigbee-topology.test.mjs', + guard: 'node --test --test-name-pattern="Z2M reverses raw link" test/zigbee-provider-routes.test.mjs', because: 'the arrow on the hovered device uplink must point to its parent rather than away ' + 'from the coordinator (#457 AC4)', patches: [{ file: 'src/zigbee-topology.ts', - find: " const direction = isParent ? 'toward-neighbor'", - replace: " const direction = isParent ? 'toward-origin'", + find: " lqi: route.lqi, routeDirection: isOutgoing ? 'toward-neighbor' : 'toward-origin' });", + replace: " lqi: route.lqi, routeDirection: isOutgoing ? 'toward-origin' : 'toward-origin' });", }], }, { id: 'zigbee-route-parent-not-counted-twice', - guard: 'node --test --test-name-pattern="hover projects local route directions" test/zigbee-topology.test.mjs', + guard: 'node --test --test-name-pattern="remote/unplaced parent carries" test/zigbee-provider-routes.test.mjs', because: 'a remote parent already has a named route bubble and must not also inflate the ' + 'legacy cross-space neighbour count (#457 AC6)', patches: [{ file: 'src/zigbee-topology.ts', - find: ' if (!isParent) remote.add(other.markerId);', - replace: ' remote.add(other.markerId);', + find: ' } else if (isOutgoing) {', + replace: ' } else if (isOutgoing) {\n if (other) remote.add(identity);', + }], + }, + { + id: 'zigbee-provider-inactive-route-accepted', + guard: 'node --test --test-name-pattern="only ACTIVE status" test/zigbee-provider-routes.test.mjs', + because: 'Z2M network-map links include non-active routing-table entries; only ACTIVE ' + + 'destination-zero evidence may become a router uplink (#798)', + patches: [{ + file: 'src/zigbee-provider-routes.ts', + find: " if (route?.status !== 'ACTIVE' || !target) continue;", + replace: ' if (!route || !target) continue;', + }], + }, + { + id: 'zigbee-provider-cross-source-conflict-accepted', + guard: 'node --test --test-name-pattern="cross-provider outgoing conflict" test/zigbee-provider-routes.test.mjs', + because: 'different provider claims for the same HA source must not draw two outgoing ' + + 'edges or reappear as incoming edges on either target (#798)', + patches: [{ + file: 'src/zigbee-topology.ts', + find: ' if (hardUnknown || targets.size > 1 || (root && known.length)) {', + replace: ' if (hardUnknown || (root && known.length)) {', + }], + }, + { + id: 'zigbee-provider-zero-lqi-discarded', + guard: 'node --test --test-name-pattern="missing or invalid LQI" test/zigbee-provider-routes.test.mjs', + because: 'numeric LQI zero is a measured value, not missing data; the route must keep ' + + 'that value so presentation can use the red endpoint of the scale (#798)', + patches: [{ + file: 'src/zigbee-provider-routes.ts', + find: ' return Number.isFinite(number) && number >= 0 && number <= 255 ? Math.round(number) : undefined;', + replace: ' return Number.isFinite(number) && number > 0 && number <= 255 ? Math.round(number) : undefined;', + }], + }, + { + id: 'zigbee-runtime-routes-not-requested', + guard: 'node --test --test-name-pattern="Z2M requests routes and ignores" test/zigbee-topology-runtime-routes.test.mjs', + because: 'an explicit Z2M refresh must request routing-table evidence instead of silently ' + + 'returning a neighbors-only map that cannot support router uplinks (#798)', + patches: [{ + file: 'src/zigbee-topology-runtime.ts', + find: " payload: JSON.stringify({ type: 'raw', routes: true, transaction }),", + replace: " payload: JSON.stringify({ type: 'raw', routes: false, transaction }),", }], }, { diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index e7b9c67e..a8346c03 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -11249,7 +11249,7 @@ export class HouseplanCard extends LitElement { : nothing} ${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing} ${''/* #649: one floor-shadow layer (z below every tile), after the markers in DOM order */}${iso ? html`` : nothing} - ${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, spaces: this._serverCfg?.spaces, viewKey: view, view: this._mode === 'view', kiosk: this._kiosk })} + ${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, spaces: this._serverCfg?.spaces, viewKey: view, zoom: this._zoom, view: this._mode === 'view', kiosk: this._kiosk })}
${this._wallDialog diff --git a/src/hp-zigbee-topology-overlay.ts b/src/hp-zigbee-topology-overlay.ts index a22b2efb..71bea999 100644 --- a/src/hp-zigbee-topology-overlay.ts +++ b/src/hp-zigbee-topology-overlay.ts @@ -1,10 +1,10 @@ import { LitElement, css, html, svg, nothing, type PropertyValues } from 'lit'; import { langOf } from './i18n'; -import { lqiColor } from './logic'; +import { zigbeeLinkColor } from './zigbee-topology-style'; import { TOPOLOGY_LANGUAGE_RUNTIME, topologyT } from './i18n/topology'; import { mapTopologies, resolveMappedTopologyHover, type ZigbeeMappedTopology, - type ZigbeeParentTarget, + TOPOLOGY_STALE_MS, type ZigbeeParentTarget, } from './zigbee-topology'; import { zigbeeArrowGeometry, type ZigbeePixelPoint } from './zigbee-topology-geometry'; import { @@ -26,6 +26,7 @@ export class HpZigbeeTopologyOverlay extends LitElement { currentSpace: { type: String, attribute: 'current-space' }, spaces: { attribute: false }, viewKey: { attribute: false }, + zoom: { type: Number }, }; hass!: ZigbeeTopologyHass; @@ -34,8 +35,10 @@ export class HpZigbeeTopologyOverlay extends LitElement { currentSpace = ''; spaces?: readonly { id?: unknown; title?: unknown }[]; viewKey: unknown; + zoom = 1; private _runtime = EMPTY_RUNTIME; private _hovered = ''; + private _staleTimer?: ReturnType; private _release?: () => void; private _parent?: HTMLElement; private _hoverGateObserver?: MutationObserver; @@ -51,14 +54,14 @@ export class HpZigbeeTopologyOverlay extends LitElement { svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } line { vector-effect: non-scaling-stroke; stroke-linecap: round; } .route-arrow { vector-effect: non-scaling-stroke; } - svg, line, polygon, .halo, .remote, .parent-bubble { pointer-events: none; } + svg, line, polygon, .halo, .remote, .parent-bubble, .route-status { pointer-events: none; } .halo { position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22); height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%); box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%; box-shadow: 0 0 0 4px rgba(120, 190, 220, .16); } - .remote, .parent-bubble { + .remote, .parent-bubble, .route-status { position: absolute; transform: translate(12px, calc(-100% - 12px)); border: 1px solid rgba(255,255,255,.7); border-radius: 999px; padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88); @@ -67,11 +70,12 @@ export class HpZigbeeTopologyOverlay extends LitElement { } .parent-bubble.right { transform: translate(0, -50%); } .parent-bubble.left { transform: translate(-100%, -50%); } + .route-status { transform: translate(-50%, 12px); max-width: 240px; white-space: normal; text-align: center; } @media (forced-colors: active) { line { stroke: Highlight !important; } - .route-arrow { fill: Highlight !important; } + .route-arrow { fill: Highlight !important; stroke: CanvasText !important; } .halo { border-color: Highlight; box-shadow: none; } - .remote, .parent-bubble { color: CanvasText; background: Canvas; border-color: CanvasText; } + .remote, .parent-bubble, .route-status { color: CanvasText; background: Canvas; border-color: CanvasText; } } `; @@ -88,6 +92,7 @@ export class HpZigbeeTopologyOverlay extends LitElement { } disconnectedCallback(): void { + clearTimeout(this._staleTimer); this._release?.(); this._release = undefined; this._disconnectParent(); @@ -126,9 +131,23 @@ export class HpZigbeeTopologyOverlay extends LitElement { this._endpointSetDirty = false; this._runtime = next; this._mappedMemo = undefined; + this._scheduleStaleUpdate(); this.requestUpdate(); } + // Age changes without a new HA event; redraw once at expiry, never fetch. + private _scheduleStaleUpdate(): void { + clearTimeout(this._staleTimer); + const now = Date.now(); + const expiry = this._runtime.topologies.map((item) => item.obtainedAt + TOPOLOGY_STALE_MS + 1) + .filter((value) => value > now); + if (expiry.length) this._staleTimer = setTimeout(() => { + if (!this.isConnected) return; + this.requestUpdate(); + this._scheduleStaleUpdate(); + }, Math.min(...expiry) - now); + } + private _connectParent(): void { const parent = this.parentElement; if (!parent || parent === this._parent) return; @@ -253,8 +272,10 @@ export class HpZigbeeTopologyOverlay extends LitElement { return (typeof title === 'string' && title.trim()) || topologyT(langOf(this.hass), 'route_other_space'); } - return topologyT(langOf(this.hass), target.kind === 'unplaced-coordinator' - ? 'route_coordinator_not_on_plan' : 'route_device_not_on_plan'); + if (target.kind === 'unplaced-coordinator') return topologyT(langOf(this.hass), 'route_coordinator_not_on_plan'); + return target.deviceName + ? topologyT(langOf(this.hass), 'route_device_not_on_plan_named', { name: target.deviceName }) + : topologyT(langOf(this.hass), 'route_device_not_on_plan'); } private _markerClearance(position: MarkerPosition): number { @@ -265,6 +286,25 @@ export class HpZigbeeTopologyOverlay extends LitElement { return points.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(' '); } + private _route(origin: ZigbeePixelPoint, point: ZigbeePixelPoint, lqi: number | undefined, + direction: 'toward-neighbor' | 'toward-origin', + arrow: ReturnType, parent = false) { + const color = zigbeeLinkColor(lqi); + const outline = Number.isFinite(this.zoom) && this.zoom > 0 ? this.zoom : 1; + return svg`${lqi === undefined ? svg`` : nothing} + + ${arrow ? svg`` : nothing}`; + } + protected render() { // #627: the ru/de/fr topology strings are a lazy chunk. Request it once on // mount — before the first hover can paint a label — and draw nothing @@ -277,7 +317,7 @@ export class HpZigbeeTopologyOverlay extends LitElement { this._setDesiredEndpointIds([]); return nothing; } - if (!this._hovered || !this.registry || !this._runtime.topologies.length) { + if (this.hass?.user?.is_admin !== true || !this._hovered || !this.registry || !this._runtime.topologies.length) { this._setDesiredEndpointIds([]); return nothing; } @@ -297,12 +337,11 @@ export class HpZigbeeTopologyOverlay extends LitElement { ...line, point: this._position(line.neighborMarkerId, width, height), })).filter((line) => !!line.point).map((line) => { - const color = line.lqi === undefined ? 'rgba(145,155,165,.85)' : lqiColor(line.lqi); const arrow = line.routeDirection ? zigbeeArrowGeometry( origin, line.point!, this._markerClearance(origin), this._markerClearance(line.point!), line.routeDirection, ) : null; - return { ...line, color, arrow }; + return { ...line, arrow }; }); const placeLeft = origin.x > width / 2; const bubbleGap = this._markerClearance(origin) + 18; @@ -316,6 +355,21 @@ export class HpZigbeeTopologyOverlay extends LitElement { origin, point, this._markerClearance(origin), 0, 'toward-neighbor', ) }; }); + const involved = mapped.filter((item) => [...item.placements.values()] + .some((placement) => placement.markerId === this._hovered && placement.space === this.currentSpace)); + const providerStates = involved.map(({ topology }) => { + const key = topology.provider === 'zha' ? 'zha' : `z2m:${topology.instanceId}`; + return this._runtime.states[key]; + }); + const stale = providerStates.some((state) => state?.stale) + || involved.some(({ topology }) => Date.now() - topology.obtainedAt > TOPOLOGY_STALE_MS); + const status = [ + hover.outgoing === 'unknown' ? topologyT(lang, 'route_unknown') : '', + providerStates.some((state) => state?.phase === 'error') ? topologyT(lang, 'error_provider') : '', + providerStates.some((state) => state?.phase === 'loading') ? topologyT(lang, 'status_loading') : '', + hover.outgoing !== 'not-zigbee' && stale ? topologyT(lang, 'route_stale') : '', + hover.outgoing !== 'not-zigbee' && hover.partial ? topologyT(lang, 'route_partial') : '', + ].filter(Boolean).join(' · '); this._setDesiredEndpointIds(lines.length || bubbles.length || hover.remoteCount ? [ this._hovered, @@ -324,27 +378,8 @@ export class HpZigbeeTopologyOverlay extends LitElement { return html` ${lines.length || bubbles.length ? svg`` : nothing} ${lines.map((line) => html`
`)} @@ -355,6 +390,8 @@ export class HpZigbeeTopologyOverlay extends LitElement { style="left:${origin.x}px;top:${origin.y}px">${topologyT( langOf(this.hass), 'remote_count', { n: hover.remoteCount }, )}` : nothing} + ${status ? html`
${status}
` : nothing} `; } } diff --git a/src/hp-zigbee-topology-settings.ts b/src/hp-zigbee-topology-settings.ts index 514204c1..22d49687 100644 --- a/src/hp-zigbee-topology-settings.ts +++ b/src/hp-zigbee-topology-settings.ts @@ -6,7 +6,7 @@ import { ensureFormKitStyles } from './editors/form-kit'; import { normalizeZ2mBaseTopic, type ZigbeeTopologySettings, } from './zigbee-topology-settings'; -import { mapTopologyNodes, TOPOLOGY_STALE_MS } from './zigbee-topology'; +import { mapTopologyNodes, resolveProviderUplinks, TOPOLOGY_STALE_MS } from './zigbee-topology'; import type { DevItem } from './types'; import type { HaRegistrySnapshot } from './ha-binding-status'; import type { ZigbeeTopologyHass, ZigbeeTopologyRuntimeSnapshot } from './zigbee-topology-runtime'; @@ -33,6 +33,7 @@ export class HpZigbeeTopologySettings extends LitElement { registry?: HaRegistrySnapshot; private _runtime: typeof import('./zigbee-topology-runtime') | null = null; private _snapshot = EMPTY_RUNTIME; + private _staleTimer?: ReturnType; private _release?: () => void; private _topicText = 'zigbee2mqtt'; private _invalidTopic = false; @@ -79,6 +80,8 @@ export class HpZigbeeTopologySettings extends LitElement { `; disconnectedCallback(): void { + clearTimeout(this._staleTimer); + this._staleTimer = undefined; this._release?.(); this._release = undefined; super.disconnectedCallback(); @@ -86,12 +89,21 @@ export class HpZigbeeTopologySettings extends LitElement { protected updated(changed: PropertyValues): void { if (changed.has('hass')) { + clearTimeout(this._staleTimer); + this._staleTimer = undefined; this._release?.(); this._release = undefined; this._runtime = null; this._snapshot = EMPTY_RUNTIME; } - if (this.savedEnabled && this._admin) void this._ensureRuntime(); + if (this.savedEnabled && this._admin) { + this._scheduleStaleUpdate(); + void this._ensureRuntime(); + } + else { + clearTimeout(this._staleTimer); + this._staleTimer = undefined; + } } protected willUpdate(changed: PropertyValues): void { @@ -131,16 +143,38 @@ export class HpZigbeeTopologySettings extends LitElement { private async _ensureRuntime(): Promise { if (!this._runtime) { this._runtime = await import('./zigbee-topology-runtime'); - this._snapshot = this._runtime.zigbeeTopologyRuntimeSnapshot(this.hass); + this._acceptSnapshot(this._runtime.zigbeeTopologyRuntimeSnapshot(this.hass)); this._release = this._runtime.subscribeZigbeeTopology(this.hass, () => { - this._snapshot = this._runtime!.zigbeeTopologyRuntimeSnapshot(this.hass); - this.requestUpdate(); + this._acceptSnapshot(this._runtime!.zigbeeTopologyRuntimeSnapshot(this.hass)); }); - this.requestUpdate(); } return this._runtime; } + private _acceptSnapshot(snapshot: ZigbeeTopologyRuntimeSnapshot): void { + this._snapshot = snapshot; + this._scheduleStaleUpdate(); + this.requestUpdate(); + } + + // Status ages even when HA sends no events. This one-shot redraw never fetches. + private _scheduleStaleUpdate(): void { + clearTimeout(this._staleTimer); + this._staleTimer = undefined; + if (!this.isConnected || !this.savedEnabled || !this._admin) return; + const now = Date.now(); + const expiry = Object.values(this._snapshot.states) + .filter((current) => current.obtainedAt !== undefined && !current.stale) + .map((current) => current.obtainedAt! + TOPOLOGY_STALE_MS + 1) + .filter((value) => value > now); + if (expiry.length) this._staleTimer = setTimeout(() => { + this._staleTimer = undefined; + if (!this.isConnected) return; + this.requestUpdate(); + this._scheduleStaleUpdate(); + }, Math.min(...expiry) - now); + } + private _emit(value: ZigbeeTopologySettings): void { if (!this._admin) return; this.dispatchEvent(new CustomEvent('hp-topology-settings-change', { @@ -164,9 +198,9 @@ export class HpZigbeeTopologySettings extends LitElement { private _status(key: string): string { const current = this._snapshot.states[key]; if (!current) return this._t('status_idle'); - if (current.phase === 'loading') return this._t('status_loading'); - if (current.phase === 'error') return this._t((`error_${current.error || 'provider'}`) as TopologyI18nKey); - if (current.phase !== 'ready' || !current.obtainedAt) return this._t('status_idle'); + const phase = current.phase === 'loading' ? this._t('status_loading') + : current.phase === 'error' ? this._t((`error_${current.error || 'provider'}`) as TopologyI18nKey) : ''; + if (current.obtainedAt === undefined) return phase || this._t('status_idle'); const time = new Date(current.obtainedAt).toLocaleTimeString(langOf(this.hass), { hour: '2-digit', minute: '2-digit', }); @@ -174,9 +208,14 @@ export class HpZigbeeTopologySettings extends LitElement { (key === 'zha' ? item.provider === 'zha' : `z2m:${item.instanceId}` === key)); const mappingPartial = !!topology && !!this.registry && mapTopologyNodes(topology, this.devices, this.registry).warnings.length > 0; - if (current.partial || mappingPartial) return this._t('status_partial', { time }); - if (topology && !topology.links.length) return this._t('status_no_links', { time }); - return this._t(Date.now() - current.obtainedAt > TOPOLOGY_STALE_MS ? 'status_stale' : 'status_ready', { time }); + const uplinks = topology ? [...resolveProviderUplinks(topology).values()] : []; + const stale = current.stale || Date.now() - current.obtainedAt > TOPOLOGY_STALE_MS; + const partial = current.partial || mappingPartial || uplinks.some((route) => route.kind === 'unknown'); + return [phase, this._t(stale ? 'status_stale' : 'status_ready', { time }), + partial ? this._t('route_partial') : '', + topology && !uplinks.some((route) => route.kind === 'known') ? this._t('route_unknown') : '', + topology?.freshness === 'provider-cache' ? this._t('status_cache') : '', + ].filter(Boolean).join(' · '); } private _busy(key: string): boolean { diff --git a/src/i18n/topology/de.json b/src/i18n/topology/de.json index 786e0f34..3202595a 100644 --- a/src/i18n/topology/de.json +++ b/src/i18n/topology/de.json @@ -1,8 +1,8 @@ { "title": "Zigbee-Verbindungen", "toggle": "Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen", - "hint": "Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.", - "help": "Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; Verbindungen sind nur mit der Maus sichtbar.", + "hint": "Zeigt Eltern und nächste Hops laut Integration. Beim Darüberfahren werden keine Daten abgerufen.", + "help": "Zeigt den Elternknoten eines Endgeräts oder den aktiven nächsten Hop eines Routers zum Koordinator laut Integration. Es gibt höchstens einen ausgehenden Pfeil; eingehende Pfeile zeigen Geräte, die über diesen Knoten routen. Gewöhnliche Nachbarn werden ausgeblendet. Alle Linien sind durchgezogen. Linie und Pfeil verwenden LQI 0–255: rot bei 0, gelb bei 128, grün bei 255. Ohne LQI sind sie grau mit schwarzer Kontur. Eine Beschriftung am Pfeilende nennt ein Ziel in einem anderen Bereich oder außerhalb des Plans. Keine Routendaten bedeutet fehlende eindeutige Angaben, nicht dass das Gerät offline ist. Dies sind zuletzt empfangene Integrationsdaten, keine Live-Paketverfolgung oder Garantie des gesamten Pfads. Der ZHA-Cache kann beim Lesen bereits alt sein; veraltete und unvollständige Daten werden markiert. Verbindungen sind nur mit der Maus sichtbar.", "help_aria": "Hilfe: Zigbee-Verbindungen", "admin_only": "Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.", "save_first": "Speichere diese Einstellung, bevor Topologiedaten geladen werden.", @@ -17,8 +17,8 @@ "status_loading": "Wird geladen…", "status_ready": "Empfangen: {time}", "status_stale": "Empfangen: {time} · veraltet", - "status_partial": "Empfangen: {time} · einige Knoten fehlen", - "status_no_links": "Empfangen: {time} · keine beobachteten Verbindungen gefunden", + "status_partial": "Empfangen: {time} · unvollständige Daten", + "status_no_links": "Empfangen: {time} · keine bestätigten Routen", "error_permission": "Administratorrechte sind erforderlich.", "error_unsupported": "Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.", "error_timeout": "Der Anbieter hat nicht rechtzeitig geantwortet.", @@ -28,5 +28,10 @@ "remote_count": "+{n} in anderen Bereichen", "route_device_not_on_plan": "Gerät ist nicht im Plan", "route_coordinator_not_on_plan": "Koordinator ist nicht im Plan", - "route_other_space": "anderer Bereich" + "route_other_space": "anderer Bereich", + "route_device_not_on_plan_named": "Gerät ist nicht im Plan ({name})", + "route_unknown": "Keine Routendaten", + "route_stale": "Veraltete Daten", + "route_partial": "Unvollständige Daten", + "status_cache": "Integrationscache; Zeitpunkt des Funkscans unbekannt" } diff --git a/src/i18n/topology/en.json b/src/i18n/topology/en.json index a6cf394f..b2a0ee9c 100644 --- a/src/i18n/topology/en.json +++ b/src/i18n/topology/en.json @@ -1,8 +1,8 @@ { "title": "Zigbee links", "toggle": "Show Zigbee links when hovering over a device", - "hint": "Shows observed direct neighbours only. Nothing is fetched while you hover.", - "help": "Shows which Zigbee devices each device talks to directly. Line colour is link quality (LQI) on the same scale as the device's LQI reading: red at 40 and below, green at 180 and above. A dashed line means quality was not reported. An arrow points to the next device on the way to the coordinator: one arrow leads out, incoming arrows are the devices that route through this one. A line without an arrow is a spare neighbour. This is the route tree House Plan builds, not the path a packet takes right now: a label at the end of an arrow means its target is not on this plan, and no arrow at all means the route is unknown. Data comes from the last load and goes stale; links are shown with a mouse only.", + "hint": "Shows parents and next hops reported by the integration. Nothing is fetched while you hover.", + "help": "Shows an end device's parent or a router's active next hop to the coordinator, as reported by the integration. There is at most one outgoing arrow; incoming arrows identify devices routing through this node. Ordinary neighbours are hidden. All lines are solid. Line and arrow colour use LQI 0–255: red at 0, yellow at 128, green at 255. Without LQI they are grey with a black outline. A label at the end names a target on another space or not on the plan. No route data means that no outgoing route was reported unambiguously, not that the device is offline. These are last-received integration records, not a live packet trace or a guarantee of the whole path. Cached ZHA tables may already be old when read; stale and incomplete snapshots are marked. Links are shown with a mouse only.", "help_aria": "Help: Zigbee links", "admin_only": "Topology data is available to Home Assistant administrators only.", "save_first": "Save this setting before loading topology data.", @@ -17,8 +17,8 @@ "status_loading": "Loading…", "status_ready": "Received {time}", "status_stale": "Received {time} · stale", - "status_partial": "Received {time} · some nodes were omitted", - "status_no_links": "Received {time} · no observed links found", + "status_partial": "Received {time} · incomplete data", + "status_no_links": "Received {time} · no confirmed routes", "error_permission": "Administrator permission is required.", "error_unsupported": "This provider or Home Assistant API is unavailable.", "error_timeout": "The provider did not respond in time.", @@ -26,7 +26,12 @@ "error_invalid_payload": "The provider returned unsupported topology data.", "error_provider": "Topology data could not be loaded.", "remote_count": "+{n} in other spaces", - "route_device_not_on_plan": "device is not on the plan", + "route_device_not_on_plan": "Device is not on the plan", "route_coordinator_not_on_plan": "coordinator is not on the plan", - "route_other_space": "another space" + "route_other_space": "another space", + "route_device_not_on_plan_named": "Device is not on the plan ({name})", + "route_unknown": "No route data", + "route_stale": "Stale data", + "route_partial": "Incomplete data", + "status_cache": "Integration cache; radio-scan time unknown" } diff --git a/src/i18n/topology/fr.json b/src/i18n/topology/fr.json index 1d8a17b9..76fa858b 100644 --- a/src/i18n/topology/fr.json +++ b/src/i18n/topology/fr.json @@ -1,8 +1,8 @@ { "title": "Liens Zigbee", "toggle": "Afficher les liens Zigbee au survol d’un appareil", - "hint": "Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.", - "help": "Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; les liens ne sont visibles qu'à la souris.", + "hint": "Affiche les parents et prochains sauts signalés par l’intégration. Le survol ne lance aucune requête.", + "help": "Affiche le parent d’un appareil terminal ou le prochain saut actif d’un routeur vers le coordinateur selon l’intégration. Il y a au plus une flèche sortante ; les flèches entrantes indiquent les appareils passant par ce nœud. Les simples voisins sont masqués. Toutes les lignes sont continues. Ligne et flèche utilisent le LQI 0–255 : rouge à 0, jaune à 128, vert à 255. Sans LQI, elles sont grises avec un contour noir. Une étiquette au bout de la flèche nomme une cible dans un autre espace ou absente du plan. Aucune donnée de route indique l’absence d’information univoque, pas un appareil hors ligne. Il s’agit des derniers relevés de l’intégration, pas du trajet des paquets en direct ni d’une garantie du chemin entier. Le cache ZHA peut déjà être ancien à la lecture ; les données anciennes et incomplètes sont signalées. Les liens ne sont visibles qu’à la souris.", "help_aria": "Aide : liens Zigbee", "admin_only": "Les données de topologie sont réservées aux administrateurs Home Assistant.", "save_first": "Enregistrez ce réglage avant de charger les données de topologie.", @@ -17,8 +17,8 @@ "status_loading": "Chargement…", "status_ready": "Reçu : {time}", "status_stale": "Reçu : {time} · obsolète", - "status_partial": "Reçu : {time} · certains nœuds sont omis", - "status_no_links": "Reçu : {time} · aucun lien observé trouvé", + "status_partial": "Reçu : {time} · données incomplètes", + "status_no_links": "Reçu : {time} · aucune route confirmée", "error_permission": "Les droits administrateur sont requis.", "error_unsupported": "Ce fournisseur ou l’API Home Assistant n’est pas disponible.", "error_timeout": "Le fournisseur n’a pas répondu à temps.", @@ -28,5 +28,10 @@ "remote_count": "+{n} dans d’autres espaces", "route_device_not_on_plan": "appareil absent du plan", "route_coordinator_not_on_plan": "coordinateur absent du plan", - "route_other_space": "autre espace" + "route_other_space": "autre espace", + "route_device_not_on_plan_named": "Appareil absent du plan ({name})", + "route_unknown": "Aucune donnée de route", + "route_stale": "Données anciennes", + "route_partial": "Données incomplètes", + "status_cache": "Cache de l’intégration ; date de l’analyse radio inconnue" } diff --git a/src/i18n/topology/ru.json b/src/i18n/topology/ru.json index c6952727..09720d8b 100644 --- a/src/i18n/topology/ru.json +++ b/src/i18n/topology/ru.json @@ -1,8 +1,8 @@ { "title": "Связи Zigbee", "toggle": "Показывать связи Zigbee при наведении на устройство", - "hint": "Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.", - "help": "Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.", + "hint": "Показываются родители и следующие узлы по сведениям интеграции. Наведение ничего не запрашивает.", + "help": "Показывает родителя конечного устройства или активный следующий узел роутера к координатору по данным интеграции. Исходящая стрелка не более одной, входящие — устройства, идущие через этот узел. Обычные соседи скрыты. Все линии сплошные. Цвет линии и стрелки — LQI 0–255: красный при 0, жёлтый при 128, зелёный при 255. Без LQI они серые с чёрной обводкой. Подпись на конце стрелки указывает цель на другом пространстве или не на плане. «Нет данных о маршруте» означает отсутствие однозначных сведений об исходящем маршруте, а не недоступность устройства. Это последние записи интеграции, не путь пакета в реальном времени и не гарантия всей цепочки. Кеш ZHA может быть старым уже при чтении; устаревшие и неполные данные отмечаются. Связи видны только при работе мышью.", "help_aria": "Справка: связи Zigbee", "admin_only": "Данные топологии доступны только администраторам Home Assistant.", "save_first": "Сохраните настройку перед загрузкой данных топологии.", @@ -17,8 +17,8 @@ "status_loading": "Загрузка…", "status_ready": "Получено: {time}", "status_stale": "Получено: {time} · данные устарели", - "status_partial": "Получено: {time} · часть узлов не показана", - "status_no_links": "Получено: {time} · наблюдаемые связи не найдены", + "status_partial": "Получено: {time} · неполные данные", + "status_no_links": "Получено: {time} · подтверждённые маршруты не найдены", "error_permission": "Нужны права администратора.", "error_unsupported": "Провайдер или API Home Assistant недоступен.", "error_timeout": "Провайдер не ответил вовремя.", @@ -26,7 +26,12 @@ "error_invalid_payload": "Провайдер вернул неподдерживаемые данные топологии.", "error_provider": "Не удалось загрузить данные топологии.", "remote_count": "+{n} в других пространствах", - "route_device_not_on_plan": "устройства нет на плане", + "route_device_not_on_plan": "Устройства нет на плане", "route_coordinator_not_on_plan": "координатора нет на плане", - "route_other_space": "другое пространство" + "route_other_space": "другое пространство", + "route_device_not_on_plan_named": "Устройства нет на плане ({name})", + "route_unknown": "Нет данных о маршруте", + "route_stale": "Данные устарели", + "route_partial": "Неполные данные", + "status_cache": "Кеш интеграции; время радиосканирования неизвестно" } diff --git a/src/zigbee-provider-routes.ts b/src/zigbee-provider-routes.ts new file mode 100644 index 00000000..4844403e --- /dev/null +++ b/src/zigbee-provider-routes.ts @@ -0,0 +1,333 @@ +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 } +export interface ZigbeeTopologyNode { + key: string; ieee: string; deviceId?: string; role: ZigbeeRole; + available?: boolean; nwk?: number; name?: string; +} +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' | 'route_unknown' | 'route_unresolved'; +export interface ZigbeeTopologyWarning { code: ZigbeeTopologyWarningCode; nodeKey?: string } +export interface ZigbeeUplinkEvidence { + from: string; + /** Missing target deliberately preserves an unresolvable claim, rather than discarding a conflict. */ + to?: string; + kind: 'end-parent' | 'route-to-coordinator'; + lqi?: number; +} +export type ZigbeeUplink = + | { kind: 'known'; targetKey: string; evidence: ZigbeeUplinkEvidence['kind']; lqi?: number } + | { kind: 'unknown'; reason: 'missing' | 'conflict' | 'unresolved' | 'cycle' | 'unknown-role' } + | { kind: 'root' }; +export interface ZigbeeTopology { + provider: ZigbeeProvider; instanceId: string; obtainedAt: number; + freshness: 'provider-cache' | 'fresh-scan'; + nodes: ZigbeeTopologyNode[]; links: ZigbeeTopologyLink[]; + uplinkEvidence: ZigbeeUplinkEvidence[]; warnings: ZigbeeTopologyWarning[]; +} +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; + +export function recordOf(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? 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; +} +export function normalizeZigbeeNwk(value: unknown): number | undefined { + if (typeof value !== 'number' && (typeof value !== 'string' + || !/^(?:0x[0-9a-f]{1,4}|\d+)$/i.test(value.trim()))) return undefined; + const number = Number(value); + return Number.isInteger(number) && number >= 0 && number <= 0xffff ? number : undefined; +} +function lqiOf(value: unknown): number | undefined { + if (typeof value !== 'number' && (typeof value !== 'string' + || !/^\d+(?:\.\d+)?$/.test(value.trim()))) return undefined; + const number = Number(value); + return Number.isFinite(number) && number >= 0 && number <= 255 ? Math.round(number) : undefined; +} +export function topologyName(value: unknown): string | undefined { + return typeof value === 'string' ? value.trim().slice(0, 255) || undefined : undefined; +} +function roleOf(value: unknown): ZigbeeRole { + const role = String(value || '').toLowerCase().replace(/[ _-]/g, ''); + return role === 'coordinator' ? 'coordinator' : role === 'router' ? 'router' + : role === 'enddevice' || role === 'end' ? 'end' : 'unknown'; +} +function relationshipOf(value: unknown): string | undefined { + if (typeof value === 'number') return ['parent', 'child', 'sibling', 'none', 'previous_child'][value]; + const compact = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s_-]+/g, '') : ''; + return compact === 'previouschild' ? 'previous_child' : compact.slice(0, 40) || undefined; +} +function observation(value: Record): ZigbeeDirectionalObservation { + return { lqi: lqiOf(value.lqi ?? value.linkquality ?? value.link_quality), + relationship: relationshipOf(value.relationship) }; +} +function safePayload(value: unknown): boolean { + try { return (typeof value === 'string' ? value : JSON.stringify(value)).length <= TOPOLOGY_MAX_PAYLOAD_BYTES; } + catch { return false; } +} +function boundedArray(value: unknown, max: number): unknown[] | null { + return Array.isArray(value) && value.length <= max ? value : null; +} +function topologyOf(provider: ZigbeeProvider, instanceId: string, now: number): ZigbeeTopology { + return { provider, instanceId, obtainedAt: now, freshness: provider === 'zha' ? 'provider-cache' : 'fresh-scan', + nodes: [], links: [], uplinkEvidence: [], warnings: [] }; +} +function warn(topology: ZigbeeTopology, code: ZigbeeTopologyWarningCode, nodeKey?: string): void { + if (topology.warnings.length < TOPOLOGY_MAX_LINKS) topology.warnings.push({ code, ...(nodeKey ? { nodeKey } : {}) }); + else if (code === 'route_unresolved') topology.warnings[topology.warnings.length - 1] = { code }; +} +function keyOf(topology: ZigbeeTopology, ieee: string): string { + return `${topology.provider}:${topology.instanceId}:${ieee}`; +} +function addNode(topology: ZigbeeTopology, nodes: Map, node: ZigbeeTopologyNode): void { + const previous = nodes.get(node.key); + if (!previous) { nodes.set(node.key, node); return; } + if (previous.role !== node.role || previous.nwk !== node.nwk || previous.deviceId !== node.deviceId) { + // Conflicting identity metadata must not pick a source role or serve as a valid target. + warn(topology, 'route_unresolved', node.key); + previous.role = 'unknown'; previous.nwk = undefined; previous.deviceId = undefined; + } +} +function indexAddresses(nodes: Iterable): Map> { + const addresses = new Map>(); + for (const node of nodes) if (node.nwk !== undefined) { + const keys = addresses.get(node.nwk) || new Set(); + keys.add(node.key); addresses.set(node.nwk, keys); + } + return addresses; +} +function uniqueAddress(addresses: Map>, nwk: number | undefined): string | undefined { + const keys = nwk === undefined ? undefined : addresses.get(nwk); + return keys?.size === 1 ? [...keys][0] : undefined; +} +function pushEvidence(topology: ZigbeeTopology, evidence: ZigbeeUplinkEvidence): void { + if (topology.uplinkEvidence.length < TOPOLOGY_MAX_LINKS) topology.uplinkEvidence.push(evidence); + else warn(topology, 'route_unresolved'); +} +function pushNeighbor(topology: ZigbeeTopology, links: Map, + reporter: string, neighbor: string, row: Record): void { + if (reporter === neighbor) { + warn(topology, 'self_link', reporter); + const relationship = relationshipOf(row.relationship); + if (relationship === 'parent' || relationship === 'child') { + pushEvidence(topology, { from: reporter, kind: 'end-parent' }); + } + return; + } + const forward = reporter < neighbor; + const pair = forward ? `${reporter}|${neighbor}` : `${neighbor}|${reporter}`; + const link = links.get(pair) || { a: forward ? reporter : neighbor, b: forward ? neighbor : reporter }; + const field = forward ? 'aToB' : 'bToA'; + const obs = observation(row); + if (link[field]) warn(topology, 'duplicate_link', reporter); + else link[field] = obs; + links.set(pair, link); + if (obs.relationship === 'parent') pushEvidence(topology, { from: reporter, to: neighbor, kind: 'end-parent', lqi: obs.lqi }); + if (obs.relationship === 'child') pushEvidence(topology, { from: neighbor, to: reporter, kind: 'end-parent', lqi: obs.lqi }); +} + +/** Suppress only cycle members; a known earlier hop does not promise the whole chain. */ +export function suppressUplinkCycles(routes: Map): void { + const done = new Set(); + for (const start of routes.keys()) { + const path: string[] = []; const positions = new Map(); + let key = start; + while (!done.has(key)) { + const seen = positions.get(key); + if (seen !== undefined) { + for (const member of path.slice(seen)) routes.set(member, { kind: 'unknown', reason: 'cycle' }); + break; + } + const route = routes.get(key); + if (route?.kind !== 'known') break; + positions.set(key, path.length); path.push(key); key = route.targetKey; + } + for (const member of path) done.add(member); + } +} + +/** Accept provider claims only. No shortest-path, signal ranking or role inference. */ +export function resolveProviderUplinks(topology: ZigbeeTopology): Map { + const nodes = new Map(topology.nodes.map((node) => [node.key, node])); + const candidates = new Map(); + for (const evidence of topology.uplinkEvidence) { + const role = nodes.get(evidence.from)?.role; + if ((role === 'end' && evidence.kind === 'end-parent') + || (role === 'router' && evidence.kind === 'route-to-coordinator')) { + const list = candidates.get(evidence.from) || []; list.push(evidence); candidates.set(evidence.from, list); + } + } + const routes = new Map(); + const truncated = topology.warnings.some((item) => item.code === 'route_unresolved' && !item.nodeKey); + for (const node of topology.nodes) { + if (node.role === 'coordinator') { routes.set(node.key, { kind: 'root' }); continue; } + const evidence = candidates.get(node.key) || []; + const targets = new Set(evidence.map((item) => item.to)); + const targetKey = [...targets][0]; + const target = targetKey ? nodes.get(targetKey) : undefined; + if (truncated || topology.warnings.some((item) => item.code === 'route_unresolved' && item.nodeKey === node.key)) { + routes.set(node.key, { kind: 'unknown', reason: 'unresolved' }); + } else if (node.role === 'unknown') routes.set(node.key, { kind: 'unknown', reason: 'unknown-role' }); + else if (!evidence.length) routes.set(node.key, { kind: 'unknown', reason: 'missing' }); + else if (targets.has(undefined) || !target || (target.role !== 'router' && target.role !== 'coordinator')) { + routes.set(node.key, { kind: 'unknown', reason: 'unresolved' }); + } else if (targets.size !== 1) routes.set(node.key, { kind: 'unknown', reason: 'conflict' }); + else routes.set(node.key, { kind: 'known', targetKey: target.key, evidence: evidence[0].kind, + lqi: evidence.find((item) => item.lqi !== undefined)?.lqi }); + } + suppressUplinkCycles(routes); + return routes; +} +function finish(topology: ZigbeeTopology, nodes: Map, links: Map): ZigbeeTopology { + topology.nodes = [...nodes.values()]; topology.links = [...links.values()]; + // A route can be reported without an LQI observation in that direction. + const pairs = new Map(topology.links.map((link) => [`${link.a}|${link.b}`, link])); + for (const evidence of topology.uplinkEvidence) if (evidence.lqi === undefined && evidence.to) { + const forward = evidence.from < evidence.to; + const pair = pairs.get(forward ? `${evidence.from}|${evidence.to}` : `${evidence.to}|${evidence.from}`); + evidence.lqi = (forward ? pair?.aToB?.lqi : pair?.bToA?.lqi) + ?? (forward ? pair?.bToA?.lqi : pair?.aToB?.lqi); + } + for (const [key, route] of resolveProviderUplinks(topology)) { + if (route.kind === 'unknown') warn(topology, 'route_unknown', key); + } + return topology; +} + +/** ZHA neighbors belong to each reporting row; route addresses are its routing table. */ +export function normalizeZhaTopology(payload: unknown, now = Date.now()): ZigbeeTopology { + const topology = topologyOf('zha', 'zha', now); + const rows = safePayload(payload) ? boundedArray(payload, TOPOLOGY_MAX_NODES) : null; + if (!rows) { warn(topology, 'invalid_payload'); return topology; } + const nodes = new Map(); const links = new Map(); + for (const row of rows) { + const value = recordOf(row); const ieee = normalizeIeee(value?.ieee ?? value?.ieee_address); + if (!ieee || !value) { warn(topology, 'invalid_payload'); continue; } + const key = keyOf(topology, ieee); + addNode(topology, nodes, { key, ieee, role: roleOf(value.device_type ?? value.type), nwk: normalizeZigbeeNwk(value.nwk), + name: topologyName(value.user_given_name) ?? topologyName(value.name), + ...(typeof value.device_reg_id === 'string' ? { deviceId: value.device_reg_id } : {}), + ...(typeof value.available === 'boolean' ? { available: value.available } : {}) }); + } + let neighborsRead = 0; + for (const row of rows) { + const value = recordOf(row); const ieee = normalizeIeee(value?.ieee ?? value?.ieee_address); + if (!ieee || !value) continue; + const reporter = keyOf(topology, ieee); + const neighbors = boundedArray(value.neighbors, TOPOLOGY_MAX_LINKS); + if (!neighbors) { + if (Array.isArray(value.neighbors)) warn(topology, 'route_unresolved'); + else if (value.neighbors != null) warn(topology, 'invalid_payload', reporter); + continue; + } + for (const neighbor of neighbors) { + if (++neighborsRead > TOPOLOGY_MAX_LINKS) { warn(topology, 'route_unresolved'); break; } + const item = recordOf(neighbor); const other = normalizeIeee(item?.ieee ?? item?.ieee_address); + if (!other || !item) { + warn(topology, 'invalid_payload', reporter); + if (relationshipOf(item?.relationship) === 'parent') pushEvidence(topology, { from: reporter, kind: 'end-parent' }); + continue; + } + const key = keyOf(topology, other); + if (!nodes.has(key)) { + if (nodes.size >= TOPOLOGY_MAX_NODES) { warn(topology, 'route_unresolved'); continue; } + nodes.set(key, { key, ieee: other, role: roleOf(item.device_type), nwk: normalizeZigbeeNwk(item.nwk), name: topologyName(item.name) }); + } + pushNeighbor(topology, links, reporter, key, item); + } + } + const addresses = indexAddresses(nodes.values()); let routesRead = 0; + for (const row of rows) { + const value = recordOf(row); const ieee = normalizeIeee(value?.ieee ?? value?.ieee_address); + if (!ieee || !value) continue; + const from = keyOf(topology, ieee); const routes = boundedArray(value.routes, TOPOLOGY_MAX_LINKS); + if (!routes) { if (value.routes != null) warn(topology, 'route_unresolved', from); continue; } + for (const rawRoute of routes) { + if (++routesRead > TOPOLOGY_MAX_LINKS) { warn(topology, 'route_unresolved'); break; } + const route = recordOf(rawRoute); + if (route?.route_status !== 'Active') continue; + const destination = normalizeZigbeeNwk(route.dest_nwk); + if (destination !== 0 && destination !== undefined) continue; + pushEvidence(topology, { from, kind: 'route-to-coordinator', to: destination === undefined ? undefined + : uniqueAddress(addresses, normalizeZigbeeNwk(route.next_hop)) }); + } + } + return finish(topology, nodes, links); +} + +function z2mValue(payload: unknown): unknown { + try { + let value = typeof payload === 'string' ? JSON.parse(payload) as unknown : payload; + const envelope = recordOf(value); const data = recordOf(envelope?.data); + value = data?.value ?? envelope?.value ?? envelope?.data ?? value; + return typeof value === 'string' ? JSON.parse(value) as unknown : value; + } catch { return null; } +} +/** Z2M source is the neighbor, target is the reporting/table-owning device. */ +export function normalizeZ2mTopology(payload: unknown, baseTopic: string, now = Date.now()): ZigbeeTopology { + const topology = topologyOf('z2m', normalizeZ2mBaseTopic(baseTopic) || 'invalid', now); + const raw = recordOf(safePayload(payload) ? z2mValue(payload) : null); + const rows = boundedArray(raw?.nodes, TOPOLOGY_MAX_NODES); const rawLinks = boundedArray(raw?.links, TOPOLOGY_MAX_LINKS); + if (!rows || !rawLinks) { warn(topology, 'invalid_payload'); return topology; } + const nodes = new Map(); const links = new Map(); + for (const row of rows) { + const value = recordOf(row); const ieee = normalizeIeee(value?.ieeeAddr ?? value?.ieee_address ?? value?.ieee); + if (!ieee || !value) { warn(topology, 'invalid_payload'); continue; } + const key = keyOf(topology, ieee); + addNode(topology, nodes, { key, ieee, role: roleOf(value.type ?? value.device_type), + nwk: normalizeZigbeeNwk(value.networkAddress ?? value.network_address ?? value.id), + name: topologyName(value.friendlyName ?? value.friendly_name), + ...(typeof value.failed === 'boolean' ? { available: !value.failed } : {}) }); + if (Array.isArray(value.failed) && value.failed.length) warn(topology, 'provider_scan_failure', key); + } + const addresses = indexAddresses(nodes.values()); + const endpoint = (flat: unknown, value: unknown): string | undefined => { + const record = recordOf(value); + const ieee = normalizeIeee(flat) ?? normalizeIeee(record?.ieeeAddr ?? record?.ieee_address ?? record?.ieee ?? value); + if (ieee) { + const key = keyOf(topology, ieee); + if (!nodes.has(key) && nodes.size < TOPOLOGY_MAX_NODES) nodes.set(key, { key, ieee, role: 'unknown' }); + return nodes.has(key) ? key : undefined; + } + return uniqueAddress(addresses, normalizeZigbeeNwk(record?.networkAddress ?? record?.network_address ?? record?.id ?? value)); + }; + let routesRead = 0; + for (const rawLink of rawLinks) { + const row = recordOf(rawLink); if (!row) { warn(topology, 'invalid_payload'); continue; } + const source = endpoint(row.sourceIeeeAddr, row.source); const target = endpoint(row.targetIeeeAddr, row.target); + if (source && target) pushNeighbor(topology, links, target, source, row); + else { + warn(topology, 'invalid_payload', target); + const relation = relationshipOf(row.relationship); + if (relation === 'parent' && target) pushEvidence(topology, { from: target, kind: 'end-parent' }); + if (relation === 'child' && source) pushEvidence(topology, { from: source, kind: 'end-parent' }); + } + const routes = boundedArray(row.routes, TOPOLOGY_MAX_LINKS); + if (!routes) { if (row.routes != null) warn(topology, 'route_unresolved', target); continue; } + for (const rawRoute of routes) { + if (++routesRead > TOPOLOGY_MAX_LINKS) { warn(topology, 'route_unresolved'); break; } + const route = recordOf(rawRoute); + if (route?.status !== 'ACTIVE' || !target) continue; + const destination = normalizeZigbeeNwk(route.destinationAddress); + if (destination !== 0 && destination !== undefined) continue; + const hop = uniqueAddress(addresses, normalizeZigbeeNwk(route.nextHopAddress)); + pushEvidence(topology, { from: target, kind: 'route-to-coordinator', + to: destination === 0 && source === hop ? hop : undefined, lqi: observation(row).lqi }); + } + } + return finish(topology, nodes, links); +} diff --git a/src/zigbee-topology-overlay-bridge.ts b/src/zigbee-topology-overlay-bridge.ts index 7214da84..cbaeaa67 100644 --- a/src/zigbee-topology-overlay-bridge.ts +++ b/src/zigbee-topology-overlay-bridge.ts @@ -12,6 +12,7 @@ export function renderZigbeeTopologyOverlay(input: { currentSpace: string; spaces?: readonly { id?: unknown; title?: unknown }[]; viewKey: unknown; + zoom: number; view: boolean; kiosk: boolean; }): TemplateResult | typeof nothing { @@ -20,5 +21,5 @@ export function renderZigbeeTopologyOverlay(input: { void import('./hp-zigbee-topology-overlay'); return html``; + .viewKey=${input.viewKey} .zoom=${input.zoom}>`; } diff --git a/src/zigbee-topology-runtime.ts b/src/zigbee-topology-runtime.ts index dca35344..bdbe33a6 100644 --- a/src/zigbee-topology-runtime.ts +++ b/src/zigbee-topology-runtime.ts @@ -1,4 +1,6 @@ -import { normalizeZ2mTopology, normalizeZhaTopology, type ZigbeeTopology } from './zigbee-topology'; +import { + normalizeZ2mTopology, normalizeZhaTopology, TOPOLOGY_MAX_PAYLOAD_BYTES, type ZigbeeTopology, +} from './zigbee-topology'; import { normalizeZ2mBaseTopic } from './zigbee-topology-settings'; export type ZigbeeTopologyErrorCode = @@ -13,6 +15,8 @@ export type ZigbeeProviderState = { phase: 'idle' | 'loading' | 'ready' | 'error'; obtainedAt?: number; partial?: boolean; + /** A failed refresh retained the last good snapshot; age is checked separately. */ + stale?: boolean; error?: ZigbeeTopologyErrorCode; }; @@ -98,14 +102,21 @@ function fail(code: ZigbeeTopologyErrorCode): Error & { code: ZigbeeTopologyErro function run(cache: Cache, key: string, task: () => Promise): Promise { const existing = cache.inflight.get(key); if (existing) return existing; - state(cache, key, { phase: 'loading' }); + const previous = cache.states[key]; + const retained = previous?.obtainedAt === undefined ? {} : { + obtainedAt: previous.obtainedAt, partial: previous.partial, stale: previous.stale, + }; + state(cache, key, { ...retained, phase: 'loading' }); const promise = task().then((topology) => { if (!topology.nodes.length && topology.warnings.some((item) => item.code === 'invalid_payload')) { throw fail('invalid_payload'); } store(cache, topology); }).catch((error) => { - state(cache, key, { phase: 'error', error: errorCode(error) }); + state(cache, key, { + ...retained, ...(retained.obtainedAt === undefined ? {} : { stale: true }), + phase: 'error', error: errorCode(error), + }); }).finally(() => cache.inflight.delete(key)); cache.inflight.set(key, promise); return promise; @@ -152,6 +163,7 @@ function parseMessage(message: unknown): unknown { const record = recordOf(message); const raw = record?.payload ?? message; if (typeof raw !== 'string') return raw; + if (raw.length > TOPOLOGY_MAX_PAYLOAD_BYTES) return null; try { return JSON.parse(raw); } catch { return null; } } @@ -169,6 +181,10 @@ function randomTransaction(): string { } async function withTimeout(promise: Promise, ms: number): Promise { + if (ms <= 0) { + void promise.catch(() => undefined); + throw fail('timeout'); + } let id: ReturnType | undefined; try { return await Promise.race([ @@ -198,6 +214,7 @@ export function refreshZ2mTopology( if (typeof subscribe !== 'function' || typeof hass?.callService !== 'function') throw fail('unsupported'); const transaction = randomTransaction(); const deadline = Date.now() + Math.max(1, timeoutMs); + let finished = false; let responseActive = false; let infoResolve: (() => void) | null = null; let responseResolve: ((value: unknown) => void) | null = null; @@ -207,38 +224,56 @@ export function refreshZ2mTopology( responseResolve = resolve; responseReject = reject; }); + // The response can reject synchronously inside publish, before its promise is awaited. + void response.catch(() => undefined); const unsubscribers: Array<() => void> = []; + const cleanup = (unsubscribe: () => void): void => { + try { unsubscribe(); } catch { /* cleanup is best effort */ } + }; + const active = (): boolean => !finished && Date.now() < deadline; + const subscribeTo = async (suffix: string, callback: (message: unknown) => void): Promise => { + if (!active()) throw fail('timeout'); + const pending = subscribe.call(connection, callback, { + type: 'mqtt/subscribe', topic: `${topic}/${suffix}`, + }).then((unsubscribe) => { + if (typeof unsubscribe !== 'function') return; + // A timed-out subscribe can still complete; do not leak its subscription. + if (finished) cleanup(unsubscribe); + else unsubscribers.push(unsubscribe); + }); + await withTimeout(pending, deadline - Date.now()); + }; try { - const unsubInfo = await subscribe.call(connection, (message: unknown) => { - if (recordOf(message)?.retain === true && parseMessage(message)) infoResolve?.(); - }, { type: 'mqtt/subscribe', topic: `${topic}/bridge/info` }); - if (typeof unsubInfo === 'function') unsubscribers.push(unsubInfo); - const unsubResponse = await subscribe.call(connection, (message: unknown) => { - if (recordOf(message)?.retain === true) return; + await subscribeTo('bridge/info', (message: unknown) => { + if (active() && recordOf(message)?.retain === true && parseMessage(message)) infoResolve?.(); + }); + await subscribeTo('bridge/response/networkmap', (message: unknown) => { + if (!active() || !responseActive || recordOf(message)?.retain === true) return; const value = parseMessage(message); if (value === null) { - if (responseActive) responseReject?.(fail('invalid_payload')); + responseReject?.(fail('invalid_payload')); return; } if (value && transactionOf(value) === transaction) responseResolve?.(value); - }, { type: 'mqtt/subscribe', topic: `${topic}/bridge/response/networkmap` }); - if (typeof unsubResponse === 'function') unsubscribers.push(unsubResponse); - await withTimeout(info, Math.min(4000, Math.max(1, deadline - Date.now()))); + }); + await withTimeout(info, Math.min(4000, deadline - Date.now())); + if (!active()) throw fail('timeout'); responseActive = true; - await hass.callService('mqtt', 'publish', { + const published = hass.callService('mqtt', 'publish', { topic: `${topic}/bridge/request/networkmap`, - payload: JSON.stringify({ type: 'raw', routes: false, transaction }), + payload: JSON.stringify({ type: 'raw', routes: true, transaction }), qos: 0, retain: false, }); - const value = await withTimeout(response, deadline - Date.now()); + const [, value] = await withTimeout(Promise.all([published, response]), deadline - Date.now()); + if (!active()) throw fail('timeout'); const status = recordOf(value)?.status; if (status && status !== 'ok') throw fail('provider'); return normalizeZ2mTopology(value, topic); } finally { - for (const unsubscribe of unsubscribers) { - try { unsubscribe(); } catch { /* cleanup is best effort */ } - } + finished = true; + responseActive = false; + for (const unsubscribe of unsubscribers) cleanup(unsubscribe); } }); } diff --git a/src/zigbee-topology-style.ts b/src/zigbee-topology-style.ts new file mode 100644 index 00000000..5037b179 --- /dev/null +++ b/src/zigbee-topology-style.ts @@ -0,0 +1,7 @@ +/** #798: route colours deliberately do not change device/room LQI colours. */ +export function zigbeeLinkColor(lqi: number | undefined): string { + if (lqi === undefined || !Number.isFinite(lqi) || lqi < 0 || lqi > 255) return '#919ba5'; + const red = lqi <= 128 ? 255 : Math.round(255 * (255 - lqi) / 127); + const green = lqi >= 128 ? 255 : Math.round(255 * lqi / 128); + return `rgb(${red}, ${green}, 0)`; +} diff --git a/src/zigbee-topology.ts b/src/zigbee-topology.ts index fce9eb27..f67ae24b 100644 --- a/src/zigbee-topology.ts +++ b/src/zigbee-topology.ts @@ -1,263 +1,40 @@ 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[]; -} +import { + normalizeIeee, recordOf, resolveProviderUplinks, suppressUplinkCycles, topologyName, + type ZigbeeTopology, type ZigbeeTopologyNode, type ZigbeeTopologyWarning, type ZigbeeUplink, +} from './zigbee-provider-routes'; +export * from './zigbee-provider-routes'; export interface ZigbeeHoverLine { neighborMarkerId: string; lqi?: number; - routeDirection?: 'toward-neighbor' | 'toward-origin'; + routeDirection: 'toward-neighbor' | 'toward-origin'; } - -export type ZigbeeParentTarget = +export type ZigbeeParentTarget = ( | { kind: 'remote-space'; spaceId: string } | { kind: 'unplaced-device' } - | { kind: 'unplaced-coordinator' }; - -export interface ZigbeeRouteTree { - coordinatorKey?: string; - distances: Map; - parents: Map; -} - + | { kind: 'unplaced-coordinator' } +) & { nodeKey: string; deviceName?: string; lqi?: number }; export interface ZigbeeHoverResolution { lines: ZigbeeHoverLine[]; remoteCount: number; omittedCount: number; parentTargets: ZigbeeParentTarget[]; + outgoing: 'known' | 'unknown' | 'root' | 'not-zigbee'; + partial: boolean; + obtainedAt?: number; } - -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 }; -} +export type ZigbeeNodePlacement = { markerId: string; space: string }; +export type ZigbeeMappedTopology = { + topology: ZigbeeTopology; + placements: Map; + routes: Map; + nodes: Map; + names: Map; + identities: Map; + partial: boolean; +}; function ieeeFromRegistryIdentifier(value: unknown): string | null { if (typeof value !== 'string') return null; @@ -266,7 +43,6 @@ function ieeeFromRegistryIdentifier(value: unknown): string | null { 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(); @@ -284,77 +60,11 @@ function deviceIdsForNode(node: ZigbeeTopologyNode, registry: HaRegistrySnapshot } 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, @@ -379,82 +89,113 @@ export function mapTopologyNodes( return { placements, warnings }; } +/** Cross-provider authority is exact HA ownership, even for an unplaced device. */ +function reconcileSources(mapped: ZigbeeMappedTopology[]): void { + const groups = new Map>(); + for (const map of mapped) for (const [key, route] of map.routes) { + const identity = map.identities.get(key)!; + const group = groups.get(identity) || []; group.push({ map, key, route }); groups.set(identity, group); + } + const combined = new Map(); + for (const [identity, group] of groups) { + const known = group.filter((item) => item.route.kind === 'known'); + const hardUnknown = group.find(({ route }) => route.kind === 'unknown' + && route.reason !== 'missing' && route.reason !== 'unknown-role'); + const root = group.some(({ route }) => route.kind === 'root'); + const targets = new Set(known.map(({ map, route }) => route.kind === 'known' + ? map.identities.get(route.targetKey) : undefined)); + if (hardUnknown || targets.size > 1 || (root && known.length)) { + combined.set(identity, { kind: 'unknown', reason: 'conflict' }); + } else if (known.length) { + const first = known[0].route; + if (first.kind === 'known') combined.set(identity, { ...first, targetKey: [...targets][0]! }); + } else combined.set(identity, root ? { kind: 'root' } : { kind: 'unknown', reason: 'missing' }); + } + suppressUplinkCycles(combined); + for (const [identity, group] of groups) { + const resolved = combined.get(identity)!; + if (resolved.kind === 'unknown' && resolved.reason !== 'missing') { + for (const { map, key } of group) { map.routes.set(key, resolved); map.partial = true; } + } + } +} + +/** Normalization and source reconciliation are memoized by the overlay's snapshot revision. */ 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), - })); + const mapped = topologies.map((topology): ZigbeeMappedTopology => { + const placement = mapTopologyNodes(topology, devices, registry); + const names = new Map(); const identities = new Map(); + for (const node of topology.nodes) { + const ids = deviceIdsForNode(node, registry); + const id = ids.length === 1 ? ids[0] : undefined; + const device = id ? recordOf(registry.devices[id]) : null; + identities.set(node.key, id ? `ha:${id}` : node.key); + const name = topologyName(device?.name_by_user) ?? topologyName(device?.name) ?? node.name; + if (name) names.set(node.key, name); + } + return { topology, placements: placement.placements, routes: resolveProviderUplinks(topology), + nodes: new Map(topology.nodes.map((node) => [node.key, node])), names, identities, + partial: topology.warnings.length > 0 || placement.warnings.length > 0 }; + }); + reconcileSources(mapped); + return mapped; } +/** Project only confirmed edges incident to the hovered marker, never neighbor observations. */ 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); + const lines = new Map(); const remote = new Set(); + const targets = new Map(); const omitted = new Set(); + let outgoing: ZigbeeHoverResolution['outgoing'] = 'not-zigbee'; + let partial = false; let obtainedAt: number | undefined; + for (const map of mappedTopologies) { + const hovered = new Set([...map.placements].filter(([, placement]) => ( + placement.markerId === hoveredMarkerId && placement.space === currentSpace + )).map(([key]) => key)); + if (!hovered.size) continue; + partial ||= map.partial; + obtainedAt = obtainedAt === undefined ? map.topology.obtainedAt : Math.min(obtainedAt, map.topology.obtainedAt); + for (const key of hovered) { + const route = map.routes.get(key); + if (route?.kind === 'known') outgoing = 'known'; + else if (route?.kind === 'root' && outgoing !== 'known') outgoing = 'root'; + else if (outgoing === 'not-zigbee') outgoing = 'unknown'; } - 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); - } + for (const [from, route] of map.routes) { + if (route.kind !== 'known') continue; + const isOutgoing = hovered.has(from); const isIncoming = hovered.has(route.targetKey); + if (!isOutgoing && !isIncoming) continue; + const otherKey = isOutgoing ? route.targetKey : from; + const other = map.placements.get(otherKey); + const identity = map.identities.get(otherKey)!; + if (other?.markerId === hoveredMarkerId) continue; + if (other?.space === currentSpace) { + const line = lines.get(other.markerId); + if (!line) lines.set(other.markerId, { neighborMarkerId: other.markerId, + lqi: route.lqi, routeDirection: isOutgoing ? 'toward-neighbor' : 'toward-origin' }); + else if (line.lqi === undefined) line.lqi = route.lqi; + } else if (isOutgoing) { + const extra = { nodeKey: otherKey, deviceName: map.names.get(otherKey), lqi: route.lqi }; + const existing = targets.get(identity); + if (!existing) targets.set(identity, other + ? { kind: 'remote-space', spaceId: other.space, ...extra } + : { kind: map.nodes.get(otherKey)?.role === 'coordinator' ? 'unplaced-coordinator' : 'unplaced-device', ...extra }); + else if (existing.lqi === undefined) existing.lqi = route.lqi; + if (!other) omitted.add(identity); + } else if (other) remote.add(identity); + else omitted.add(identity); } } - return { - lines: [...lines.values()].map(({ line, directions }) => directions.size === 1 - ? { ...line, routeDirection: [...directions][0] } : line), - remoteCount: remote.size, - omittedCount, - parentTargets: [...parentTargets.values()], - }; + return { lines: [...lines.values()], remoteCount: remote.size, omittedCount: omitted.size, + parentTargets: [...targets.values()], outgoing, partial, + ...(obtainedAt === undefined ? {} : { obtainedAt }) }; } - -/** 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, - ); + return resolveMappedTopologyHover(mapTopologies(topologies, devices, registry), currentSpace, hoveredMarkerId); } diff --git a/test/zigbee-provider-routes.test.mjs b/test/zigbee-provider-routes.test.mjs new file mode 100644 index 00000000..f78f9eef --- /dev/null +++ b/test/zigbee-provider-routes.test.mjs @@ -0,0 +1,337 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + normalizeZhaTopology, normalizeZ2mTopology, normalizeZigbeeNwk, resolveProviderUplinks, + mapTopologyNodes, mapTopologies, resolveTopologyHover, resolveMappedTopologyHover, + TOPOLOGY_MAX_NODES, TOPOLOGY_MAX_LINKS, TOPOLOGY_MAX_PAYLOAD_BYTES, +} from '../test-build/zigbee-topology.js'; + +const ieee = (id) => id.toString(16).padStart(16, '0'); +const key = (id, provider = 'zha', instance = 'zha') => `${provider}:${instance}:${ieee(id)}`; +const row = (id, role = 'Router', extra = {}) => ({ + ieee: ieee(id), nwk: id === 1 ? 0 : id, device_reg_id: `d${id}`, + device_type: role, neighbors: [], routes: [], ...extra, +}); +const neighbor = (id, extra = {}) => ({ ieee: ieee(id), ...extra }); +const route = (hop, extra = {}) => ({ dest_nwk: '0x0000', next_hop: hop, route_status: 'Active', ...extra }); +const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] }; +const markers = [1, 2, 3, 4].map((id) => ({ + id: `m${id}`, name: `Marker ${id}`, model: '', area: '', space: 'main', icon: '', entities: [], + bindingKind: 'device', bindingRef: `d${id}`, bindingStatus: active, +})); +const registry = { + revision: 1, authoritative: true, access: 'full', lastSuccess: 1, entities: {}, + devices: Object.fromEntries([1, 2, 3, 4].map((id) => [`d${id}`, { + id: `d${id}`, identifiers: [['zha', ieee(id)]], name: `Registry ${id}`, + }])), +}; +const hover = (topologies, marker = 'm2', devices = markers, reg = registry) => ( + resolveTopologyHover(topologies, devices, reg, 'main', marker) +); +const zha = (rows) => normalizeZhaTopology(rows, 123); +const selected = (topology, id) => resolveProviderUplinks(topology).get(key(id, topology.provider, topology.instanceId)); +const z2m = (links, nodes = [ + { ieeeAddr: ieee(1), networkAddress: 0, type: 'Coordinator' }, + { ieeeAddr: ieee(2), networkAddress: 2, type: 'Router' }, + { ieeeAddr: ieee(3), networkAddress: 3, type: 'EndDevice' }, +]) => normalizeZ2mTopology({ nodes, links }, 'zigbee2mqtt', 123); +const zlink = (source, target, extra = {}) => ({ + source: { ieeeAddr: ieee(source) }, target: { ieeeAddr: ieee(target) }, ...extra, +}); + +test('ZHA route next hop wins over a shorter stronger neighbor and router Parent', () => { + const topology = zha([ + row(1, 'Coordinator'), row(2, 'Router', { + neighbors: [neighbor(1, { lqi: 255, relationship: 'Parent' }), neighbor(4, { lqi: 10 })], + routes: [route('0x0004')], + }), row(4), + ]); + assert.deepEqual(selected(topology, 2), { kind: 'known', targetKey: key(4), evidence: 'route-to-coordinator', lqi: 10 }); + assert.deepEqual(hover([topology]).lines, [{ neighborMarkerId: 'm4', routeDirection: 'toward-neighbor', lqi: 10 }]); + assert.equal(hover([topology], 'm4').lines[0].routeDirection, 'toward-origin'); + assert.equal(hover([topology], 'm4').outgoing, 'unknown', 'known first hop does not require the rest of the chain'); +}); + +test('ZHA Parent and reverse Child prove the same end parent; sibling and previous child do not', () => { + for (const own of [true, false]) { + const topology = zha([row(1, 'Coordinator'), row(2, 'Router', { + neighbors: own ? [] : [neighbor(3, { relationship: 'Child', lqi: 97 })], + }), row(3, 'EndDevice', { + neighbors: own ? [neighbor(2, { relationship: 'Parent', lqi: 97 })] : [], + })]); + assert.equal(selected(topology, 3).targetKey, key(2)); + assert.deepEqual(hover([topology], 'm3').lines, [{ neighborMarkerId: 'm2', lqi: 97, routeDirection: 'toward-neighbor' }]); + } + for (const relationship of ['Sibling', 'PreviousChild', 'NoneOfTheAbove', undefined]) { + const topology = zha([row(2, 'Router', { neighbors: [neighbor(3, { relationship, lqi: 255 })] }), row(3, 'EndDevice')]); + assert.equal(selected(topology, 3).kind, 'unknown'); + assert.deepEqual(hover([topology], 'm3').lines, []); + } +}); + +test('Z2M reverses raw link for routing table but Child=1 points source child to target parent', () => { + const topology = z2m([ + zlink(1, 2, { lqi: 150, relationship: 2, routes: [{ status: 'ACTIVE', destinationAddress: 0, nextHopAddress: 0 }] }), + zlink(3, 2, { lqi: 0, relationship: 1 }), + ]); + assert.equal(selected(topology, 2).targetKey, key(1, 'z2m', 'zigbee2mqtt')); + assert.equal(selected(topology, 3).targetKey, key(2, 'z2m', 'zigbee2mqtt')); + assert.deepEqual(hover([topology]).lines, [ + { neighborMarkerId: 'm1', routeDirection: 'toward-neighbor', lqi: 150 }, + { neighborMarkerId: 'm3', routeDirection: 'toward-origin', lqi: 0 }, + ]); + assert.equal(selected(z2m([zlink(2, 3, { relationship: 0 })]), 3).kind, 'known'); + assert.equal(selected(z2m([zlink(3, 2, { relationship: 2 })]), 3).kind, 'unknown'); +}); + +test('Z2M flat IEEE and snake_case nodes remain compatible; addresses are provider-local', () => { + const topology = normalizeZ2mTopology({ data: { value: JSON.stringify({ + nodes: [ + { ieee_address: ieee(1), network_address: 0, type: 'Coordinator' }, + { ieee_address: ieee(2), network_address: 2, type: 'Router' }, + ], links: [{ sourceIeeeAddr: ieee(1), targetIeeeAddr: ieee(2), + source: { ieeeAddr: ieee(4) }, target: { ieeeAddr: ieee(4) }, + routes: [{ status: 'ACTIVE', destinationAddress: 0, nextHopAddress: 0 }] }], + }) } }, 'second', 123); + assert.equal(resolveProviderUplinks(topology).get(key(2, 'z2m', 'second')).targetKey, key(1, 'z2m', 'second')); +}); + +test('relationship strings normalize separators without promoting previous child', () => { + for (const relationship of [' Pa_rent ', 'PARENT', 'par-ent']) { + assert.equal(selected(z2m([zlink(2, 3, { relationship })]), 3).kind, 'known'); + } + for (const relationship of [' Ch_ild ', 'CHILD', 'ch-ild']) { + assert.equal(selected(z2m([zlink(3, 2, { relationship })]), 3).kind, 'known'); + } + for (const relationship of [' Previous_Child ', 'previous-child', 'previous child']) { + assert.equal(selected(z2m([zlink(3, 2, { relationship })]), 3).kind, 'unknown'); + } +}); + +test('real anonymized routes:false fixture stays neighbors-only, never fake active evidence', () => { + const fixture = JSON.parse(readFileSync(new URL('./fixtures/zigbee2mqtt-networkmap-real-anonymized.json', import.meta.url), 'utf8')); + const topology = normalizeZ2mTopology(fixture, 'zigbee2mqtt', 123); + assert.equal(topology.nodes.length, 3); assert.equal(topology.links.length, 2); + assert.equal(topology.uplinkEvidence.length, 0); + assert.ok([...resolveProviderUplinks(topology).values()].every((item) => item.kind !== 'known')); + assert.ok(topology.warnings.some((item) => item.code === 'provider_scan_failure')); +}); + +test('only ACTIVE status and coordinator destination qualify in either provider', () => { + for (const status of ['Inactive', 'Discovery_Underway', 'Discovery_Failed', 'Validation_Underway', '', undefined]) { + assert.equal(selected(zha([row(1, 'Coordinator'), row(2, 'Router', { + routes: [route(0, { route_status: status })], neighbors: [neighbor(1, { relationship: 'Parent', lqi: 255 })], + })]), 2).kind, 'unknown'); + } + for (const status of ['INACTIVE', 'DISCOVERY_UNDERWAY', 'DISCOVERY_FAILED', 'VALIDATION_UNDERWAY', 'UNKNOWN', undefined]) { + assert.equal(selected(z2m([zlink(1, 2, { routes: [{ status, destinationAddress: 0, nextHopAddress: 0 }] })]), 2).kind, 'unknown'); + } + for (const destination of [1, 3, 0xffff]) { + assert.equal(selected(zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0, { dest_nwk: destination })] })]), 2).kind, 'unknown'); + assert.equal(selected(z2m([zlink(1, 2, { routes: [{ status: 'ACTIVE', destinationAddress: destination, nextHopAddress: 0 }] })]), 2).kind, 'unknown'); + } +}); + +test('unknown roles never acquire an inferred parent or router route', () => { + for (const role of ['', 'unknown', 'notRouter']) { + const topology = zha([row(1, 'Coordinator'), row(2, role, { + neighbors: [neighbor(1, { relationship: 'Parent' })], routes: [route(0)], + })]); + assert.deepEqual(selected(topology, 2), { kind: 'unknown', reason: 'unknown-role' }); + } +}); + +test('addresses reject null/empty/bool/malformed and accept exact decimal or hex zero', () => { + for (const value of [null, undefined, '', ' ', false, true, '0junk', '1.2', -1, 1.2, 65536, NaN, Infinity]) { + assert.equal(normalizeZigbeeNwk(value), undefined); + const topology = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(value)] })]); + assert.equal(selected(topology, 2).kind, 'unknown'); + assert.equal(selected(zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0, { dest_nwk: value })] })]), 2).kind, 'unknown'); + } + for (const value of [0, '0', '0x0000', ' 0x0000 ']) assert.equal(normalizeZigbeeNwk(value), 0); + assert.equal(normalizeZigbeeNwk('0xABcd'), 0xabcd); +}); + +test('missing or invalid LQI stays undefined; numeric zero remains known', () => { + for (const lqi of [undefined, null, '', ' ', false, true, -1, 256, NaN, 'bad']) { + const topology = zha([row(2, 'Router', { neighbors: [neighbor(3, { relationship: 'Child', lqi })] }), row(3, 'EndDevice')]); + assert.equal(selected(topology, 3).kind, 'known'); + assert.equal(selected(topology, 3).lqi, undefined); + } + for (const lqi of [0, '0', 128, 255]) { + const topology = zha([row(2, 'Router', { neighbors: [neighbor(3, { relationship: 'Child', lqi })] }), row(3, 'EndDevice')]); + assert.equal(selected(topology, 3).lqi, Number(lqi)); + } +}); + +test('duplicate evidence merges but conflicting parent/next hop disappears on incoming side too', () => { + const duplicate = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0), route(0)] })]); + assert.equal(hover([duplicate]).lines.length, 1); + const routerConflict = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0), route(4)] }), row(4)]); + assert.equal(selected(routerConflict, 2).reason, 'conflict'); + assert.deepEqual(hover([routerConflict], 'm1').lines, []); + assert.deepEqual(hover([routerConflict], 'm4').lines, []); + const childConflict = zha([row(2, 'Router', { neighbors: [neighbor(3, { relationship: 'Child', lqi: 255 })] }), + row(4, 'Router', { neighbors: [neighbor(3, { relationship: 'Child', lqi: 1 })] }), row(3, 'EndDevice')]); + assert.equal(selected(childConflict, 3).reason, 'conflict'); + assert.deepEqual(hover([childConflict]).lines, []); + assert.deepEqual(hover([childConflict], 'm4').lines, []); +}); + +test('unresolvable candidate poisons a concurrent good claim; ambiguous NWK is not first-match', () => { + const unresolved = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0), route(1234)] })]); + assert.equal(selected(unresolved, 2).reason, 'unresolved'); + const ambiguous = zha([row(1, 'Coordinator'), row(4, 'Router', { nwk: 0 }), row(2, 'Router', { routes: [route(0)] })]); + assert.equal(selected(ambiguous, 2).kind, 'unknown'); + const badZ2m = z2m([zlink(1, 2, { routes: [{ status: 'ACTIVE', destinationAddress: 0, nextHopAddress: 3 }] })]); + assert.equal(selected(badZ2m, 2).reason, 'unresolved'); +}); + +test('provider cycles are suppressed, not repaired, including incoming appearance', () => { + const topology = zha([row(2, 'Router', { routes: [route(4)] }), row(4, 'Router', { routes: [route(2)] }), + row(3, 'EndDevice', { neighbors: [neighbor(2, { relationship: 'Parent' })] })]); + assert.equal(selected(topology, 2).reason, 'cycle'); + assert.equal(selected(topology, 4).reason, 'cycle'); + assert.equal(selected(topology, 3).kind, 'known', 'earlier confirmed hop remains useful'); + assert.deepEqual(hover([topology], 'm4').lines, []); + assert.deepEqual(hover([topology]).lines.map((line) => line.neighborMarkerId), ['m3']); +}); + +test('cross-provider outgoing conflict is suppressed both ways; missing evidence does not veto known', () => { + const a = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0)] }), row(4)]); + const b = normalizeZ2mTopology({ nodes: [ + { ieeeAddr: ieee(1), networkAddress: 0, type: 'Coordinator' }, + { ieeeAddr: ieee(2), networkAddress: 2, type: 'Router' }, + { ieeeAddr: ieee(4), networkAddress: 4, type: 'Router' }, + ], links: [zlink(4, 2, { routes: [{ status: 'ACTIVE', destinationAddress: 0, nextHopAddress: 4 }] })] }, 'second', 124); + assert.deepEqual(hover([a, b]).lines, []); + assert.equal(hover([a, b]).outgoing, 'unknown'); + assert.deepEqual(hover([a, b], 'm1').lines, []); + assert.deepEqual(hover([a, b], 'm4').lines, []); + const missing = { ...b, uplinkEvidence: [] }; + assert.equal(hover([a, missing]).outgoing, 'known'); + assert.equal(hover([a, missing]).lines.length, 1); +}); + +test('cycles assembled across providers are suppressed too', () => { + const a = zha([row(2, 'Router', { routes: [route(4)] }), row(4)]); + const b = normalizeZ2mTopology({ nodes: [ + { ieeeAddr: ieee(2), networkAddress: 2, type: 'Router' }, + { ieeeAddr: ieee(4), networkAddress: 4, type: 'Router' }, + ], links: [zlink(2, 4, { routes: [{ status: 'ACTIVE', destinationAddress: 0, nextHopAddress: 2 }] })] }, 'second', 124); + assert.deepEqual(hover([a, b]).lines, []); + assert.deepEqual(hover([a, b], 'm4').lines, []); +}); + +test('coordinator is incoming-only; unrelated and unknown hover states stay distinct', () => { + const topology = zha([row(1, 'Coordinator', { routes: [route(2)] }), row(2, 'Router', { routes: [route(0)] }), row(3, 'EndDevice')]); + assert.equal(hover([topology], 'm1').outgoing, 'root'); + assert.equal(hover([topology], 'm1').lines[0].routeDirection, 'toward-origin'); + assert.equal(hover([topology], 'm3').outgoing, 'unknown'); + assert.equal(hover([topology], 'm4').outgoing, 'not-zigbee'); + assert.equal(hover([topology], 'm3').obtainedAt, 123); + assert.equal(hover([topology], 'm3').partial, true); +}); + +test('remote/unplaced parent carries exact target name and observed LQI, never source name', () => { + const topology = zha([row(2, 'Router', { name: 'Provider parent', neighbors: [neighbor(3, { relationship: 'Child', lqi: 128 })] }), row(3, 'EndDevice')]); + const onlyChild = markers.filter((device) => device.id === 'm3'); + const reg = { ...registry, devices: { ...registry.devices, d2: { ...registry.devices.d2, name_by_user: ' User parent' } } }; + const target = hover([topology], 'm3', onlyChild, reg).parentTargets[0]; + assert.deepEqual(target, { kind: 'unplaced-device', nodeKey: key(2), deviceName: ' User parent', lqi: 128 }); + assert.equal(hover([topology], 'm3', onlyChild).parentTargets[0].deviceName, 'Registry 2'); + const namelessRegistry = { ...registry, devices: { ...registry.devices, d2: { id: 'd2' } } }; + assert.equal(hover([topology], 'm3', onlyChild, namelessRegistry).parentTargets[0].deviceName, 'Provider parent'); + const remoteMarkers = markers.map((device) => device.id === 'm2' ? { ...device, space: 'other' } : device); + const remote = hover([topology], 'm3', remoteMarkers); + assert.equal(remote.parentTargets[0].spaceId, 'other'); + assert.equal(remote.remoteCount, 0, 'the named outgoing bubble is not also an incoming count'); + assert.equal(remote.omittedCount, 0); + const unnamed = zha([row(2), row(3, 'EndDevice', { neighbors: [neighbor(2, { relationship: 'Parent' })] })]); + assert.equal(hover([unnamed], 'm3', onlyChild, namelessRegistry).parentTargets[0].deviceName, undefined); +}); + +test('exact device/entity ownership and hidden/ambiguous placements still fail closed', () => { + const topology = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0)] })]); + assert.equal(mapTopologyNodes(topology, [...markers, { ...markers[1], id: 'duplicate' }], registry).placements.has(key(2)), false); + assert.equal(mapTopologyNodes(topology, markers.map((device) => ({ ...device, hidden: true })), registry).placements.size, 0); + const entityRegistry = { ...registry, entities: { 'sensor.router': { device_id: 'd2' } } }; + const entityMarkers = markers.map((device) => device.id === 'm2' + ? { ...device, bindingKind: 'entity', bindingRef: 'sensor.router' } : device); + assert.equal(hover([topology], 'm2', entityMarkers, entityRegistry).lines.length, 1); + assert.equal(hover([topology], 'm2', markers.filter((device) => device.id !== 'm1')).parentTargets[0].kind, 'unplaced-coordinator'); +}); + +test('node/link/payload bounds remain enforced with no unbounded evidence collection', () => { + assert.equal(zha(Array.from({ length: TOPOLOGY_MAX_NODES + 1 }, (_, index) => row(index + 1))).nodes.length, 0); + assert.equal(normalizeZ2mTopology('x'.repeat(TOPOLOGY_MAX_PAYLOAD_BYTES + 1), 'z2m').nodes.length, 0); + const excessive = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: Array.from({ length: TOPOLOGY_MAX_LINKS + 1 }, () => route(0)) })]); + assert.equal(selected(excessive, 2).kind, 'unknown'); + assert.ok(excessive.uplinkEvidence.length <= TOPOLOGY_MAX_LINKS); + const neighbors = Array.from({ length: TOPOLOGY_MAX_NODES + 5 }, (_, index) => neighbor(index + 10, { device_type: 'Router' })); + assert.ok(zha([row(1, 'Coordinator', { neighbors })]).nodes.length <= TOPOLOGY_MAX_NODES); +}); + +test('aggregate scan/evidence truncation cannot retain a misleading first route', () => { + const topology = zha([row(1, 'Coordinator'), + row(2, 'Router', { routes: Array.from({ length: 3001 }, () => route(0)) }), + row(2, 'Router', { routes: [...Array.from({ length: 3000 }, () => route(0)), route(4)] }), row(4), + ]); + assert.equal(selected(topology, 2).kind, 'unknown'); + assert.ok(topology.uplinkEvidence.length <= TOPOLOGY_MAX_LINKS); + const parents = zha([row(2), row(3, 'EndDevice', { neighbors: [neighbor(2, { relationship: 'Parent' }), + ...Array.from({ length: TOPOLOGY_MAX_LINKS }, () => neighbor(4, { relationship: 'Parent' }))] }), row(4)]); + assert.equal(selected(parents, 3).kind, 'unknown'); +}); + +test('an unresolvable parent observation is not silently discarded beside a known parent', () => { + const topology = zha([row(2), row(3, 'EndDevice', { neighbors: [ + neighbor(2, { relationship: 'Parent' }), { relationship: 'Parent', ieee: 'malformed' }, + ] })]); + assert.equal(selected(topology, 3).reason, 'unresolved'); + const mqtt = z2m([zlink(3, 2, { relationship: 1 }), { + source: { ieeeAddr: ieee(3) }, target: { networkAddress: 9999 }, relationship: 1, + }]); + assert.equal(selected(mqtt, 3).reason, 'unresolved'); +}); + +test('self Parent or Child cannot be discarded beside a valid parent in either provider', () => { + for (const relationship of ['Parent', 'Child']) { + const topology = zha([row(2), row(3, 'EndDevice', { neighbors: [ + neighbor(2, { relationship: 'Parent' }), neighbor(3, { relationship }), + ] })]); + assert.equal(selected(topology, 3).reason, 'unresolved'); + assert.deepEqual(hover([topology], 'm2').lines, []); + } + for (const relationship of [0, 1]) { + const topology = z2m([zlink(3, 2, { relationship: 1 }), zlink(3, 3, { relationship })]); + assert.equal(selected(topology, 3).reason, 'unresolved'); + assert.deepEqual(hover([topology], 'm2').lines, []); + } + assert.equal(selected(z2m([zlink(3, 2, { relationship: 1 }), zlink(3, 3, { relationship: 2 })]), 3).kind, 'known'); +}); + +test('conflicting duplicate node metadata cannot select a source role or target address', () => { + const topology = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0)] }), + row(2, 'EndDevice', { neighbors: [neighbor(4, { relationship: 'Parent' })] }), row(4), + row(3, 'EndDevice', { neighbors: [neighbor(2, { relationship: 'Parent' })] })]); + assert.equal(selected(topology, 2).reason, 'unresolved'); + assert.equal(selected(topology, 3).reason, 'unresolved'); + assert.deepEqual(hover([topology], 'm4').lines, []); + const nodes = [{ ieeeAddr: ieee(1), networkAddress: 0, type: 'Coordinator' }, + { ieeeAddr: ieee(2), networkAddress: 2, type: 'Router' }, + { ieeeAddr: ieee(2), networkAddress: 4, type: 'Router' }]; + const mqtt = z2m([zlink(1, 2, { routes: [{ status: 'ACTIVE', destinationAddress: 0, nextHopAddress: 0 }] })], nodes); + assert.equal(selected(mqtt, 2).reason, 'unresolved'); + const identical = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0)] }), row(2)]); + assert.equal(selected(identical, 2).kind, 'known'); +}); + +test('memoized mapping contains provider selections and hover cannot mutate them', () => { + const topology = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0)] })]); + const mapped = mapTopologies([topology], markers, registry); + const before = JSON.stringify([...mapped[0].routes]); + for (let i = 0; i < 20; i++) assert.equal(resolveMappedTopologyHover(mapped, 'main', 'm2').lines.length, 1); + assert.equal(JSON.stringify([...mapped[0].routes]), before); +}); diff --git a/test/zigbee-topology-runtime-routes.test.mjs b/test/zigbee-topology-runtime-routes.test.mjs new file mode 100644 index 00000000..050fb6f0 --- /dev/null +++ b/test/zigbee-topology-runtime-routes.test.mjs @@ -0,0 +1,301 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + readZhaTopology, refreshZ2mTopology, zigbeeTopologyRuntimeSnapshot, +} from '../test-build/zigbee-topology-runtime.js'; +import { TOPOLOGY_MAX_PAYLOAD_BYTES } from '../test-build/zigbee-topology.js'; + +const TOPIC = 'zigbee2mqtt'; +const KEY = `z2m:${TOPIC}`; +const INFO = `${TOPIC}/bridge/info`; +const RESPONSE = `${TOPIC}/bridge/response/networkmap`; +const COORDINATOR = '00124b0000000001'; +const DEVICE = '00124b0000000002'; +const OTHER = '00124b0000000003'; +const turn = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; + +function networkmap(transaction, ieee = DEVICE) { + return { + status: 'ok', transaction, data: { value: { + nodes: [ + { ieeeAddr: COORDINATOR, type: 'Coordinator', networkAddress: 0 }, + { ieeeAddr: ieee, type: 'EndDevice', networkAddress: 1 }, + ], + links: [{ sourceIeeeAddr: ieee, targetIeeeAddr: COORDINATOR, relationship: 1, lqi: 0 }], + } }, + }; +} + +function bridge(options = {}) { + const control = { + listeners: new Map(), subscriptions: [], cleanups: [], publishes: [], + emit(value, retain = false) { + this.listeners.get(RESPONSE)?.({ retain, payload: JSON.stringify(value) }); + }, + }; + control.hass = { + user: { is_admin: true }, + connection: { + async subscribeMessage(callback, message) { + const { topic } = message; + control.listeners.set(topic, callback); + control.subscriptions.push({ topic, callback }); + const unsubscribe = () => { + control.cleanups.push(topic); + if (control.listeners.get(topic) === callback) control.listeners.delete(topic); + }; + if (topic === INFO && options.confirm !== false) { + queueMicrotask(() => callback({ retain: true, payload: '{}' })); + } + return options.subscribe?.({ control, topic, callback, unsubscribe }) ?? unsubscribe; + }, + }, + async callService(domain, service, data) { + assert.equal(`${domain}.${service}`, 'mqtt.publish'); + assert.equal(data.topic, `${TOPIC}/bridge/request/networkmap`); + assert.equal(data.retain, false); + const request = JSON.parse(data.payload); + control.publishes.push(request); + if (options.publish) return options.publish({ control, request }); + control.emit(networkmap(request.transaction)); + }, + }; + return control; +} + +test('#798 ZHA retains the last successful cache timestamp and partial status through refresh and failure', async () => { + let result = Promise.resolve([ + { ieee: COORDINATOR, device_type: 'Coordinator', neighbors: [] }, + { ieee: 'invalid-ieee' }, + ]); + let calls = 0; + const hass = { user: { is_admin: true }, connection: {}, callWS(message) { + calls++; + assert.deepEqual(message, { type: 'zha/devices' }); + return result; + } }; + await readZhaTopology(hass); + const successful = zigbeeTopologyRuntimeSnapshot(hass); + assert.equal(successful.states.zha.partial, true); + const pending = deferred(); + result = pending.promise; + const refresh = readZhaTopology(hass); + assert.equal(readZhaTopology(hass), refresh, 'concurrent readers share the same pending request'); + const loading = zigbeeTopologyRuntimeSnapshot(hass); + assert.equal(loading.topologies[0], successful.topologies[0]); + assert.equal(loading.states.zha.phase, 'loading'); + assert.equal(loading.states.zha.obtainedAt, successful.states.zha.obtainedAt); + assert.equal(loading.states.zha.partial, true); + pending.reject(new Error('provider unavailable')); + await refresh; + const failed = zigbeeTopologyRuntimeSnapshot(hass); + assert.equal(failed.topologies[0], successful.topologies[0]); + assert.equal(failed.states.zha.phase, 'error'); + assert.equal(failed.states.zha.obtainedAt, successful.states.zha.obtainedAt); + assert.equal(failed.states.zha.partial, true); + assert.equal(failed.states.zha.stale, true); + result = Promise.resolve([{ ieee: COORDINATOR, device_type: 'Coordinator', neighbors: [] }]); + await readZhaTopology(hass); + const recovered = zigbeeTopologyRuntimeSnapshot(hass); + assert.equal(recovered.states.zha.phase, 'ready'); + assert.notEqual(recovered.states.zha.stale, true); + assert.equal(recovered.states.zha.error, undefined); + assert.equal(recovered.states.zha.partial, false); + assert.equal(calls, 3); +}); + +test('#798 Z2M requests routes and ignores matching premature, retained, and foreign responses', async (t) => { + t.mock.method(globalThis.crypto, 'randomUUID', () => 'premature'); + const control = bridge({ + subscribe({ topic, callback }) { + if (topic === RESPONSE) callback({ retain: false, + payload: JSON.stringify(networkmap('houseplan-premature', OTHER)) }); + }, + publish({ control: current, request }) { + assert.equal(request.type, 'raw'); + assert.equal(request.routes, true); + current.emit(networkmap(request.transaction, OTHER), true); + current.emit(networkmap('foreign', OTHER)); + current.emit(networkmap(request.transaction)); + }, + }); + await refreshZ2mTopology(control.hass, TOPIC, 100); + const snapshot = zigbeeTopologyRuntimeSnapshot(control.hass); + assert.equal(snapshot.states[KEY].phase, 'ready'); + assert.deepEqual(snapshot.topologies[0].nodes.map((node) => node.ieee), [COORDINATOR, DEVICE]); + assert.equal(control.publishes.length, 1); + assert.deepEqual(control.cleanups, [INFO, RESPONSE]); + assert.equal(control.listeners.size, 0); +}); + +for (const error of ['provider', 'invalid_payload', 'timeout']) { + test(`#798 Z2M ${error} retains the good snapshot, marks it stale, and a retry recovers`, async () => { + let failing = false; + const control = bridge({ publish({ control: current, request }) { + if (!failing) return current.emit(networkmap(request.transaction)); + if (error === 'provider') return current.emit({ status: 'error', transaction: request.transaction }); + if (error === 'invalid_payload') { + current.listeners.get(RESPONSE)?.({ retain: false, payload: 'not-json' }); + } + } }); + await refreshZ2mTopology(control.hass, TOPIC, 100); + const successful = zigbeeTopologyRuntimeSnapshot(control.hass); + failing = true; + const refresh = refreshZ2mTopology(control.hass, TOPIC, 20); + assert.equal(refreshZ2mTopology(control.hass, TOPIC, 20), refresh); + const loading = zigbeeTopologyRuntimeSnapshot(control.hass); + assert.equal(loading.states[KEY].phase, 'loading'); + assert.equal(loading.states[KEY].obtainedAt, successful.states[KEY].obtainedAt); + await refresh; + const failed = zigbeeTopologyRuntimeSnapshot(control.hass); + assert.equal(failed.topologies[0], successful.topologies[0]); + assert.equal(failed.states[KEY].error, error); + assert.equal(failed.states[KEY].stale, true); + assert.equal(failed.states[KEY].obtainedAt, successful.states[KEY].obtainedAt); + assert.equal(control.publishes.length, 2); + assert.equal(control.cleanups.length, 4); + failing = false; + const retry = refreshZ2mTopology(control.hass, TOPIC, 100); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).states[KEY].stale, true); + await retry; + const recovered = zigbeeTopologyRuntimeSnapshot(control.hass); + assert.equal(recovered.states[KEY].phase, 'ready'); + assert.notEqual(recovered.states[KEY].stale, true); + assert.equal(recovered.states[KEY].error, undefined); + assert.equal(control.cleanups.length, 6); + }); +} + +for (const blockedTopic of [INFO, RESPONSE]) { + test(`#798 late ${blockedTopic} subscription is cleaned after the shared deadline`, async () => { + const pending = deferred(); + let lateUnsubscribe; + const control = bridge({ subscribe({ topic, unsubscribe }) { + if (topic !== blockedTopic) return; + lateUnsubscribe = unsubscribe; + return pending.promise; + } }); + await refreshZ2mTopology(control.hass, TOPIC, 20); + const timedOut = zigbeeTopologyRuntimeSnapshot(control.hass); + assert.equal(timedOut.states[KEY].error, 'timeout'); + assert.equal(timedOut.states[KEY].obtainedAt, undefined); + assert.notEqual(timedOut.states[KEY].stale, true, 'first failure does not invent a stale snapshot'); + assert.equal(control.publishes.length, 0); + pending.resolve(lateUnsubscribe); + await turn(); + assert.equal(control.listeners.size, 0); + assert.equal(control.cleanups.length, blockedTopic === INFO ? 1 : 2); + assert.equal(new Set(control.cleanups).size, control.cleanups.length, 'unsubscribe exactly once'); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).revision, timedOut.revision); + }); +} + +test('#798 publish timeout is bounded and late publish/response cannot install data', async () => { + const published = deferred(); + let responseCallback; + const control = bridge({ publish({ control: current, request }) { + responseCallback = current.listeners.get(RESPONSE); + current.emit(networkmap(request.transaction)); + return published.promise; + } }); + await refreshZ2mTopology(control.hass, TOPIC, 20); + const timedOut = zigbeeTopologyRuntimeSnapshot(control.hass); + assert.equal(timedOut.states[KEY].error, 'timeout'); + assert.equal(timedOut.topologies.length, 0, 'response alone cannot finish a hanging publish'); + assert.deepEqual(control.cleanups, [INFO, RESPONSE]); + published.resolve(); + responseCallback({ retain: false, payload: JSON.stringify(networkmap(control.publishes[0].transaction)) }); + await turn(); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).revision, timedOut.revision); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).topologies.length, 0); +}); + +test('#798 subscription setup consumes the same deadline and cannot grant publish a fresh timeout', async (t) => { + let now = 1000; + t.mock.method(Date, 'now', () => now); + const control = bridge({ subscribe() { now += 60; } }); + await refreshZ2mTopology(control.hass, TOPIC, 100); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).states[KEY].error, 'timeout'); + assert.equal(control.publishes.length, 0); + assert.deepEqual(control.cleanups, [INFO, RESPONSE]); +}); + +test('#798 a malformed response rejects even while publish is pending and late rejection is handled', async () => { + const published = deferred(); + const control = bridge({ publish({ control: current }) { + current.listeners.get(RESPONSE)?.({ retain: false, payload: '{invalid' }); + return published.promise; + } }); + await refreshZ2mTopology(control.hass, TOPIC, 100); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).states[KEY].error, 'invalid_payload'); + assert.deepEqual(control.cleanups, [INFO, RESPONSE]); + published.reject(new Error('late publish failure')); + await turn(); +}); + +test('#798 oversized MQTT data is rejected and one throwing unsubscribe does not leak the other', async () => { + const control = bridge({ + subscribe({ topic, unsubscribe }) { + return () => { + unsubscribe(); + if (topic === INFO) throw new Error('connection already closed'); + }; + }, + publish({ control: current, request }) { + const value = networkmap(request.transaction); + value.padding = ' '.repeat(TOPOLOGY_MAX_PAYLOAD_BYTES); + current.emit(value); + }, + }); + await refreshZ2mTopology(control.hass, TOPIC, 100); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).states[KEY].error, 'invalid_payload'); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).topologies.length, 0); + assert.deepEqual(control.cleanups, [INFO, RESPONSE]); + assert.equal(control.listeners.size, 0); +}); + +test('#798 an expired request cannot overwrite a later refresh through an old or new callback', async () => { + const started = deferred(); + const control = bridge({ publish({ control: current }) { + if (current.publishes.length === 2) started.resolve(); + } }); + await refreshZ2mTopology(control.hass, TOPIC, 20); + const oldCallback = control.subscriptions.find((item) => item.topic === RESPONSE).callback; + const oldTransaction = control.publishes[0].transaction; + const refresh = refreshZ2mTopology(control.hass, TOPIC, 100); + await started.promise; + oldCallback({ retain: false, payload: JSON.stringify(networkmap(oldTransaction, OTHER)) }); + control.emit(networkmap(oldTransaction, OTHER)); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).states[KEY].phase, 'loading'); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).topologies.length, 0); + control.emit(networkmap(control.publishes[1].transaction)); + await refresh; + const successful = zigbeeTopologyRuntimeSnapshot(control.hass); + oldCallback({ retain: false, payload: JSON.stringify(networkmap(oldTransaction, OTHER)) }); + await turn(); + assert.deepEqual(successful.topologies[0].nodes.map((node) => node.ieee), [COORDINATOR, DEVICE]); + assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).revision, successful.revision); + assert.equal(control.cleanups.length, 4); +}); + +test('#798 only an admin can request a map, and retained bridge confirmation is required', async () => { + const denied = bridge(); + denied.hass.user.is_admin = false; + await refreshZ2mTopology(denied.hass, TOPIC, 100); + assert.equal(zigbeeTopologyRuntimeSnapshot(denied.hass).states[KEY].error, 'permission'); + assert.equal(denied.subscriptions.length, 0); + assert.equal(denied.publishes.length, 0); + const unconfirmed = bridge({ confirm: false, subscribe({ topic, callback }) { + if (topic === INFO) callback({ retain: false, payload: '{}' }); + } }); + await refreshZ2mTopology(unconfirmed.hass, TOPIC, 20); + assert.equal(zigbeeTopologyRuntimeSnapshot(unconfirmed.hass).states[KEY].error, 'timeout'); + assert.equal(unconfirmed.publishes.length, 0); + assert.deepEqual(unconfirmed.cleanups, [INFO, RESPONSE]); +}); diff --git a/test/zigbee-topology-style.test.mjs b/test/zigbee-topology-style.test.mjs new file mode 100644 index 00000000..cb58cc40 --- /dev/null +++ b/test/zigbee-topology-style.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { zigbeeLinkColor } from '../test-build/zigbee-topology-style.js'; + +test('#798 topology palette uses the whole range, including valid zero, without changing legacy LQI', async () => { + assert.equal(zigbeeLinkColor(0), 'rgb(255, 0, 0)'); + assert.equal(zigbeeLinkColor(64), 'rgb(255, 128, 0)'); + assert.equal(zigbeeLinkColor(128), 'rgb(255, 255, 0)'); + assert.equal(zigbeeLinkColor(192), 'rgb(126, 255, 0)'); + assert.equal(zigbeeLinkColor(255), 'rgb(0, 255, 0)'); + for (const value of [undefined, NaN, Infinity, -1, 256]) assert.equal(zigbeeLinkColor(value), '#919ba5'); + const channels = (value) => zigbeeLinkColor(value).match(/\d+/g).map(Number); + for (let value = 1; value <= 255; value++) { + const previous = channels(value - 1); + const current = channels(value); + assert.ok(previous.every((channel, index) => Math.abs(channel - current[index]) <= 3)); + } + const { lqiColor } = await import('../test-build/logic.js'); + assert.match(lqiColor(40), /hsl\(0,/); + assert.match(lqiColor(180), /hsl\(120,/); +}); diff --git a/test/zigbee-topology.test.mjs b/test/zigbee-topology.test.mjs index 42fb63a6..245a91fb 100644 --- a/test/zigbee-topology.test.mjs +++ b/test/zigbee-topology.test.mjs @@ -2,8 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { - buildZigbeeRouteTree, mapTopologyNodes, normalizeIeee, normalizeZ2mTopology, - normalizeZhaTopology, resolveTopologyHover, + normalizeIeee, } from '../test-build/zigbee-topology.js'; import { zigbeeArrowGeometry } from '../test-build/zigbee-topology-geometry.js'; import { @@ -21,25 +20,6 @@ const topologySmoke = readFileSync( new URL('../demo/smoke_zigbee_topology_hover.mjs', import.meta.url), 'utf8', ); -const registry = { - revision: 1, authoritative: true, access: 'full', lastSuccess: 1, - devices: { - da: { id: 'da', identifiers: [['zha', '00:12:4b:00:00:00:00:01']] }, - db: { id: 'db', identifiers: [['mqtt', 'zigbee2mqtt_0x00124b0000000002']] }, - dc: { id: 'dc', identifiers: [['mqtt', 'zigbee2mqtt_bridge_00124b0000000003']] }, - }, - entities: { - 'sensor.b': { entity_id: 'sensor.b', device_id: 'db', unique_id: 'b_lqi' }, - }, -}; - -const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] }; -const devices = [ - { id: 'ma', name: 'A', model: '', area: 'a', space: 'one', icon: '', entities: [], bindingKind: 'device', bindingRef: 'da', bindingStatus: active }, - { id: 'mb', name: 'B', model: '', area: 'b', space: 'one', icon: '', entities: [], bindingKind: 'entity', bindingRef: 'sensor.b', bindingStatus: active }, - { id: 'mc', name: 'C', model: '', area: 'c', space: 'two', icon: '', entities: [], bindingKind: 'device', bindingRef: 'dc', bindingStatus: active }, -]; - test('Stage 3 topology smoke follows actual raised DOM centres before and after pan/zoom', () => { // #649: 2.5D is the General settings switch; the harness applies it like a save. assert.doesNotMatch(topologySmoke, /hp_alpha/); @@ -76,234 +56,8 @@ test('IEEE normalization is exact and rejects partial identifiers', () => { assert.equal(normalizeIeee('124b1'), null); }); -test('ZHA normalization keeps directional observations and never infers route edges', () => { - const topology = normalizeZhaTopology([ - { ieee: '00124b0000000001', device_reg_id: 'da', device_type: 'Router', - neighbors: [{ ieee: '00124b0000000002', lqi: 170 }], - routes: [{ dest_nwk: 77, next_hop: 88 }] }, - { ieee: '00124b0000000002', device_reg_id: 'db', device_type: 'EndDevice', - neighbors: [{ ieee: '00124b0000000001', lqi: 90 }] }, - ], 123); - assert.equal(topology.links.length, 1); - assert.equal(topology.links[0].aToB.lqi, 170); - assert.equal(topology.links[0].bToA.lqi, 90); - assert.equal(topology.obtainedAt, 123); -}); - -test('Z2M normalization accepts a real anonymized camelCase raw network map', () => { - const topology = normalizeZ2mTopology(z2mNetworkmapFixture, 'zigbee2mqtt', 456); - assert.deepEqual(topology.nodes.map(({ ieee, role, available }) => ({ ieee, role, available })), [ - { ieee: '187a3efffe000002', role: 'coordinator', available: undefined }, - { ieee: 'c02cedfffe000001', role: 'router', available: undefined }, - { ieee: '00158d0000000003', role: 'end', available: undefined }, - ]); - assert.ok(topology.nodes.every((node) => !Object.hasOwn(node, 'available'))); - assert.equal(topology.links.length, 2); - assert.deepEqual( - topology.links.map((link) => link.aToB?.lqi ?? link.bToA?.lqi).sort((a, b) => a - b), - [97, 182], - ); - assert.ok(topology.links.some((link) => ( - link.aToB?.relationship === 'sibling' || link.bToA?.relationship === 'sibling' - ))); - assert.deepEqual(topology.warnings, []); -}); - -test('Z2M normalization keeps snake_case compatibility and prefers flat link IEEE fields', () => { - const topology = normalizeZ2mTopology({ data: { value: JSON.stringify({ - nodes: [ - { ieee_address: '00124b0000000001', network_address: 1, type: 'Coordinator', failed: true }, - { ieee_address: '00124b0000000002', type: 'Router' }, - { ieee_address: '00124b0000000003', type: 'Router' }, - ], - links: [ - { sourceIeeeAddr: '00124b0000000001', targetIeeeAddr: '00124b0000000002', - source: { ieee_address: '00124b0000000003' }, target: { ieee_address: '00124b0000000003' }, linkquality: 'bad' }, - { source: { ieee_address: '00124b0000000001' }, target: { ieee_address: '00124b0000000001' }, linkquality: 255 }, - ], - }) } }, 'zigbee2mqtt', 456); - assert.equal(topology.links.length, 1); - assert.deepEqual([topology.links[0].a, topology.links[0].b], [ - 'z2m:zigbee2mqtt:00124b0000000001', - 'z2m:zigbee2mqtt:00124b0000000002', - ]); - assert.equal(topology.links[0].aToB.lqi, undefined); - assert.equal(topology.nodes.find((node) => node.ieee === '00124b0000000001')?.available, false); - assert.ok(topology.warnings.some((item) => item.code === 'self_link')); -}); - -test('Z2M relationship strings ignore case and separators', () => { - const topology = normalizeZ2mTopology({ data: { value: JSON.stringify({ - nodes: [ - { ieeeAddr: '00124b0000000001', type: 'Coordinator' }, - { ieeeAddr: '00124b0000000002', type: 'Router' }, - { ieeeAddr: '00124b0000000003', type: 'EndDevice' }, - ], - links: [ - { sourceIeeeAddr: '00124b0000000001', targetIeeeAddr: '00124b0000000002', - relationship: ' PREVIOUS-child ' }, - { sourceIeeeAddr: '00124b0000000002', targetIeeeAddr: '00124b0000000003', - relationship: ' P_a-r ent ' }, - ], - }) } }, 'zigbee2mqtt'); - assert.deepEqual(topology.links.map((link) => link.aToB.relationship), ['previous_child', 'parent']); -}); - -test('exact device/entity mapping yields local lines and a deduplicated remote count', () => { - const topology = normalizeZhaTopology([ - { ieee: '00124b0000000001', device_reg_id: 'da', neighbors: [ - { ieee: '00124b0000000002', lqi: 180 }, - { ieee: '00124b0000000003', lqi: 70 }, - ] }, - { ieee: '00124b0000000002', device_reg_id: 'db', neighbors: [] }, - { ieee: '00124b0000000003', device_reg_id: 'dc', neighbors: [] }, - ]); - const mapped = mapTopologyNodes(topology, devices, registry); - assert.deepEqual([...mapped.placements.values()], [ - { markerId: 'ma', space: 'one' }, { markerId: 'mb', space: 'one' }, { markerId: 'mc', space: 'two' }, - ]); - assert.deepEqual(resolveTopologyHover([topology], devices, registry, 'one', 'ma'), { - lines: [{ neighborMarkerId: 'mb', lqi: 180 }], remoteCount: 1, omittedCount: 0, - parentTargets: [], - }); - assert.deepEqual(resolveTopologyHover([topology], devices, registry, 'one', 'mb'), { - lines: [{ neighborMarkerId: 'ma', lqi: undefined }], remoteCount: 0, omittedCount: 0, - parentTargets: [], - }); -}); - -test('hidden and ambiguous placements fail closed', () => { - const topology = normalizeZhaTopology([ - { ieee: '00124b0000000001', device_reg_id: 'da', neighbors: [{ ieee: '00124b0000000002', lqi: 100 }] }, - { ieee: '00124b0000000002', device_reg_id: 'db', neighbors: [] }, - ]); - const ambiguous = [...devices, { ...devices[0], id: 'ma2' }]; - assert.equal(mapTopologyNodes(topology, ambiguous, registry).placements.has(topology.nodes[0].key), false); - const hidden = devices.map((item) => item.id === 'mb' ? { ...item, hidden: true } : item); - assert.deepEqual(resolveTopologyHover([topology], hidden, registry, 'one', 'ma'), { - lines: [], remoteCount: 0, omittedCount: 1, parentTargets: [], - }); -}); - -test('uplink tree is deterministic, acyclic and always reaches the sole coordinator', () => { - const topology = { - provider: 'zha', instanceId: 'zha', obtainedAt: 1, freshness: 'provider-cache', warnings: [], - nodes: [ - { key: 'c', ieee: '0000000000000001', role: 'coordinator' }, - { key: 'a', ieee: '0000000000000002', role: 'router' }, - { key: 'b', ieee: '0000000000000003', role: 'router' }, - { key: 'd', ieee: '0000000000000004', role: 'end' }, - { key: 'x', ieee: '0000000000000005', role: 'router' }, - ], - links: [ - { a: 'c', b: 'a', bToA: { lqi: 90 } }, - { a: 'c', b: 'b', bToA: { lqi: 220 } }, - { a: 'a', b: 'b', aToB: { relationship: 'parent', lqi: 255 } }, - { a: 'a', b: 'd', bToA: { relationship: 'parent', lqi: 40 } }, - { a: 'b', b: 'd', bToA: { relationship: 'sibling', lqi: 240 } }, - ], - }; - const tree = buildZigbeeRouteTree(topology); - assert.equal(tree.coordinatorKey, 'c'); - assert.deepEqual(Object.fromEntries(tree.distances), { c: 0, a: 1, b: 1, d: 2 }); - assert.deepEqual(Object.fromEntries(tree.parents), { a: 'c', b: 'c', d: 'a' }); - assert.equal(tree.parents.has('x'), false); - for (const key of tree.parents.keys()) { - const visited = new Set(); - let current = key; - while (current !== tree.coordinatorKey) { - assert.equal(visited.has(current), false, `cycle from ${key}`); - visited.add(current); - const parent = tree.parents.get(current); - assert.ok(parent, `missing parent from ${current}`); - assert.equal(tree.distances.get(parent), tree.distances.get(current) - 1); - current = parent; - } - } - const permuted = buildZigbeeRouteTree({ - ...topology, nodes: [...topology.nodes].reverse(), links: [...topology.links].reverse(), - }); - assert.deepEqual(Object.fromEntries(permuted.parents), Object.fromEntries(tree.parents)); -}); - -test('uplink parent tie-break uses direct LQI then stable key and ambiguous roots fail closed', () => { - const base = { - provider: 'zha', instanceId: 'zha', obtainedAt: 1, freshness: 'provider-cache', warnings: [], - nodes: [ - { key: 'c', ieee: '0000000000000001', role: 'coordinator' }, - { key: 'a', ieee: '0000000000000002', role: 'router' }, - { key: 'b', ieee: '0000000000000003', role: 'router' }, - { key: 'd', ieee: '0000000000000004', role: 'end' }, - ], - links: [ - { a: 'c', b: 'a' }, { a: 'c', b: 'b' }, - { a: 'a', b: 'd', bToA: { lqi: 100 } }, - { a: 'b', b: 'd', bToA: { lqi: 150 } }, - ], - }; - assert.equal(buildZigbeeRouteTree(base).parents.get('d'), 'b'); - const tied = { ...base, links: base.links.map((link) => ( - link.a === 'b' && link.b === 'd' ? { ...link, bToA: { lqi: 100 } } : link - )) }; - assert.equal(buildZigbeeRouteTree(tied).parents.get('d'), 'a'); - assert.equal(buildZigbeeRouteTree({ ...base, nodes: base.nodes.filter((node) => node.key !== 'c') }) - .parents.size, 0); - assert.equal(buildZigbeeRouteTree({ - ...base, nodes: [...base.nodes, { key: 'c2', ieee: '0000000000000005', role: 'coordinator' }], - }).parents.size, 0); -}); - -test('hover projects local route directions and keeps remote children in the old count', () => { - const topology = normalizeZhaTopology([ - { ieee: '00124b0000000001', device_reg_id: 'da', device_type: 'Coordinator', - neighbors: [{ ieee: '00124b0000000002', lqi: 180 }] }, - { ieee: '00124b0000000002', device_reg_id: 'db', device_type: 'Router', neighbors: [ - { ieee: '00124b0000000001', lqi: 150, relationship: 'Parent' }, - { ieee: '00124b0000000003', lqi: 70, relationship: 'Child' }, - ] }, - { ieee: '00124b0000000003', device_reg_id: 'dc', device_type: 'EndDevice', - neighbors: [{ ieee: '00124b0000000002', lqi: 60, relationship: 'Parent' }] }, - ]); - assert.deepEqual(resolveTopologyHover([topology], devices, registry, 'one', 'mb'), { - lines: [{ neighborMarkerId: 'ma', lqi: 150, routeDirection: 'toward-neighbor' }], - remoteCount: 1, omittedCount: 0, parentTargets: [], - }); - assert.deepEqual(resolveTopologyHover([topology], devices, registry, 'one', 'ma'), { - lines: [{ neighborMarkerId: 'mb', lqi: 180, routeDirection: 'toward-origin' }], - remoteCount: 0, omittedCount: 0, parentTargets: [], - }); - assert.deepEqual(resolveTopologyHover([topology], devices, registry, 'two', 'mc'), { - lines: [], remoteCount: 0, omittedCount: 0, - parentTargets: [{ kind: 'remote-space', spaceId: 'one' }], - }); -}); - -test('hover distinguishes an unplaced coordinator from an unplaced router and never bubbles a child', () => { - const topology = normalizeZhaTopology([ - { ieee: '00124b0000000001', device_reg_id: 'missing-coordinator', device_type: 'Coordinator', - neighbors: [{ ieee: '00124b0000000002', lqi: 180 }] }, - { ieee: '00124b0000000002', device_reg_id: 'missing-router', device_type: 'Router', neighbors: [ - { ieee: '00124b0000000001', lqi: 150, relationship: 'parent' }, - { ieee: '00124b0000000003', lqi: 70 }, - ] }, - { ieee: '00124b0000000003', device_reg_id: 'dc', device_type: 'EndDevice', - neighbors: [{ ieee: '00124b0000000002', lqi: 60, relationship: 'parent' }] }, - ]); - const routerDevice = { ...devices[1], bindingKind: 'device', bindingRef: 'missing-router' }; - const localRegistry = { ...registry, devices: { - ...registry.devices, - 'missing-coordinator': { id: 'missing-coordinator' }, - 'missing-router': { id: 'missing-router' }, - } }; - assert.deepEqual(resolveTopologyHover([topology], [routerDevice, devices[2]], localRegistry, 'one', 'mb'), { - lines: [], remoteCount: 1, omittedCount: 1, - parentTargets: [{ kind: 'unplaced-coordinator' }], - }); - assert.deepEqual(resolveTopologyHover([topology], [devices[2]], localRegistry, 'two', 'mc'), { - lines: [], remoteCount: 0, omittedCount: 1, - parentTargets: [{ kind: 'unplaced-device' }], - }); -}); +// Provider normalization, exact mapping and provider-only route resolution: +// test/zigbee-provider-routes.test.mjs (#798 replaces the inferred BFS-tree contract). test('screen-pixel arrow geometry points at the requested endpoint and respects clearance', () => { const origin = { x: 0, y: 20 }; @@ -361,7 +115,7 @@ test('Z2M runtime verifies retained bridge info, correlates transaction and clea assert.equal(`${domain}.${service}`, 'mqtt.publish'); const request = JSON.parse(data.payload); assert.equal(request.type, 'raw'); - assert.equal(request.routes, false); + assert.equal(request.routes, true); listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({ retain: true, payload: JSON.stringify({ status: 'ok', transaction: request.transaction }) }); listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({ retain: false, @@ -381,6 +135,7 @@ test('Z2M runtime verifies retained bridge info, correlates transaction and clea assert.equal(snapshot.states['z2m:zigbee2mqtt'].phase, 'ready'); assert.equal(snapshot.topologies[0].nodes.length, 3); assert.equal(snapshot.topologies[0].links.length, 2); + assert.equal(snapshot.topologies[0].uplinkEvidence.length, 0, 'neighbors-only fixture is not route evidence'); }); test('Z2M runtime rejects a malformed response immediately instead of timing out', async () => { @@ -432,46 +187,24 @@ const topologyDict = (code) => JSON.parse(readFileSync( )); const TOPOLOGY_LANGS = ['en', 'ru', 'de', 'fr']; -test('подсказка называет все шесть пунктов легенды (#459 AC3)', () => { +test('подсказка описывает provider evidence, unknown и новый сплошной рисунок (#798)', () => { const help = topologyDict('ru').help; - const claims = [ - // шкала LQI — обе границы, и они не выдуманы, а взяты из lqiColor (AC4) - [/\bLQI\b/, 'качество связи названо аббревиатурой LQI'], - [/\b40\b/, 'нижняя граница шкалы'], - [/\b180\b/, 'верхняя граница шкалы'], - [/[Пп]унктир/, 'пунктир как отдельное состояние линии'], - [/исходящ/i, 'исходящая стрелка'], - [/координатор/i, 'исходящая стрелка ведёт к координатору'], - [/входящ/i, 'входящие стрелки'], - [/без стрелки/i, 'линия без стрелки — запасной сосед'], - [/подпись на конце стрелки/i, 'подпись = цель не на этом плане'], - [/отсутствие стрелки/i, 'нет стрелки = путь неизвестен'], - ]; - for (const [pattern, why] of claims) { - assert.match(help, pattern, `подсказка не называет: ${why}`); - } -}); - -test('подсказка предупреждает, что стрелки — не путь пакета (#459 AC3b)', () => { - // Оговорка унаследована от §6 ТЗ #457: дерево аплинков строим мы, и между - // роутерами это приближение. Без неё администратор примет стрелку за истину. - const help = topologyDict('ru').help; - // `\w` в JS-регулярке ASCII-словесный: «дерев\w+» на кириллице не совпадёт - // никогда. Ловушка та же, что с `\b` в счётчике раундов ревью (#454). - assert.match(help, /дерево маршрут/i); - assert.match(help, /не путь пакета/i); -}); - -test('границы шкалы в подсказке — те же, что у lqiColor (#459 AC4)', async () => { - const { lqiColor } = await import('../test-build/logic.js'); - const hueOf = (value) => Number(/hsl\((\d+)/.exec(lqiColor(value))[1]); - // Красный край и зелёный край берутся из функции, а не из константы в тесте: - // сдвинется реализация — тест назовёт другие числа и подсказка разойдётся. - assert.equal(hueOf(40), 0, 'красный край шкалы'); - assert.equal(hueOf(180), 120, 'зелёный край шкалы'); - const help = topologyDict('ru').help; - assert.match(help, new RegExp(`\\b40\\b`)); - assert.match(help, new RegExp(`\\b180\\b`)); + for (const [pattern, reason] of [ + [/родител/i, 'parent evidence'], + [/координатор/i, 'destination coordinator'], + [/интеграц/i, 'provider source'], + [/данн/i, 'unknown route'], + [/пакет/i, 'not packet tracing'], + [/LQI/i, 'LQI named'], + [/\b0\b/, 'zero LQI scale anchor'], + [/\b128\b/, 'midpoint LQI scale anchor'], + [/\b255\b/, 'full-range LQI scale anchor'], + [/сплошн/i, 'solid lines'], + [/сер/i, 'unknown-LQI color'], + [/обвод/i, 'unknown-LQI outline'], + [/ч[её]рн/i, 'unknown-LQI black outline'], + ]) assert.match(help, pattern, reason); + assert.doesNotMatch(help, /запасн[а-яё]* сосед|пунктир|40.*180|дерево маршрут/i); }); test('подсказка не полагается на переносы строк (#459 AC5)', () => { diff --git a/tsconfig.test.json b/tsconfig.test.json index 54229681..60b54297 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -13,7 +13,7 @@ "src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/i18n/namespace-language.ts", "src/i18n/settings.ts", "src/i18n/support.ts", "src/i18n/topology.ts", "src/i18n/tools.ts", "src/i18n/led.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts", "src/device-face.ts", "src/device-hit-owner.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts", "src/summary-runtime-loader.ts", "src/card-version.ts", "src/device-area-relocation.ts", "src/ha-binding-status.ts", - "src/zigbee-topology-settings.ts", "src/zigbee-topology.ts", "src/zigbee-topology-geometry.ts", "src/zigbee-topology-runtime.ts", + "src/zigbee-topology-settings.ts", "src/zigbee-topology.ts", "src/zigbee-topology-geometry.ts", "src/zigbee-topology-runtime.ts", "src/zigbee-topology-style.ts", "src/radar-model.ts", "src/radar-editor.ts", "src/radar-geometry.ts", "src/radar-setup.ts", "src/radar-render.ts", "src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/vacuum-route-edit.ts", "src/sun.ts", "src/moon.ts", "src/moon-gate.ts", "src/moon-runtime.ts", "src/moon-art.generated.ts",