fix(zigbee): scope incomplete caption to unknown non-root uplinks (#816)

Issue: #816
User-Visible: yes
This commit is contained in:
Matysh
2026-10-07 14:03:08 +00:00
committed by claude[bot]
parent 98f3ab0c00
commit 2e0f1a17a4
14 changed files with 159 additions and 9 deletions
+7 -1
View File
@@ -266,8 +266,14 @@ for (const provider of ['z2m', 'zha']) {
await page.evaluate(() => window.__hpTest.switchSpace('f1'));
for (const [id, name] of [['d_temp', 'unplaced'], ['d_light1', 'remote-count'], ['d_kettle', 'status-only']]) {
await hover(id, 0.25);
await root.locator('.route-status').waitFor({ state: 'visible' });
// #816 removes only Incomplete data for these confirmed routes; the
// parent bubble/incoming remote count remains the exact layout witness.
const caption = name === 'status-only' ? '.route-status'
: name === 'unplaced' ? '.parent-bubble' : '.remote';
await root.locator(caption).waitFor({ state: 'visible' });
const value = await witness(`${iso ? 'iso' : 'flat'}_${theme}_${name}`, id);
if (name !== 'status-only') out[`knownRouteNoIncomplete_${iso}_${theme}_${name}`]
= value.badges.every(badge => !/Incomplete data/i.test(badge.text));
out[`actualProjection_${iso}_${theme}_${name}`] = value.projectionIso === iso
&& (!iso || (value.isoWalls && value.sourceRaised));
if (name === 'remote-count') out[`remoteCount_${iso}_${theme}`] = value.badges.some((badge) => /1/.test(badge.text) && badge.kind === 'remote');
+24 -1
View File
@@ -239,6 +239,9 @@ 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.knownLocalRouteOmitsIncomplete = !/incomplete data/i.test(
overlay.shadowRoot.querySelector('.route-status')?.textContent || '',
);
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');
@@ -324,6 +327,9 @@ const result = await page.evaluate(async () => {
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');
out.unknownRouteRetainsIncomplete = /incomplete data/i.test(
overlay.shadowRoot.querySelector('.route-status')?.textContent || '',
);
unknownNeighbor.dispatchEvent(mouse('pointerover'));
await wait(() => overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'), 'coordinator incoming route');
out.coordinatorHasOnlyIncoming = !/no route data/i.test(
@@ -331,6 +337,9 @@ const result = await page.evaluate(async () => {
)
&& !overlay.shadowRoot.querySelector('[data-direction="toward-neighbor"]')
&& !!overlay.shadowRoot.querySelector('[data-direction="toward-origin"]');
out.coordinatorNeverShowsIncomplete = !/incomplete data/i.test(
overlay.shadowRoot.querySelector('.route-status')?.textContent || '',
);
card._commitSpace('garden', true);
card.requestUpdate();
@@ -350,6 +359,9 @@ const result = await page.evaluate(async () => {
?.getAttribute('data-kind') === 'remote-space'
&& !!activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-arrow"]')
&& !activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]');
out.knownRemoteRouteOmitsIncomplete = !/incomplete data/i.test(
activeOverlay.shadowRoot.querySelector('.route-status')?.textContent || '',
);
card._commitSpace('f1', true);
card.requestUpdate();
@@ -363,6 +375,9 @@ const result = await page.evaluate(async () => {
const missingParentBubble = activeOverlay.shadowRoot.querySelector('[data-kind="unplaced-device"]');
out.unplacedDeviceBubble = missingParentBubble?.textContent.trim()
=== 'Device is not on the plan (Hallway <router> & "target")';
out.knownUnplacedRouteOmitsIncomplete = !/incomplete data/i.test(
activeOverlay.shadowRoot.querySelector('.route-status')?.textContent || '',
);
out.unplacedNameIsSafeText = !missingParentBubble?.querySelector('router,img,script')
&& !missingParentBubble?.textContent.includes('Temperature sensor');
@@ -562,7 +577,8 @@ for (const theme of ['light', 'dark']) {
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)
// #816: a globally partial snapshot must not label this confirmed uplink incomplete.
&& /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');
@@ -1062,6 +1078,13 @@ result.realPointerEndpointWins = await page.evaluate(async () => {
> Number.parseInt(getComputedStyle(overlay.shadowRoot.querySelector('svg')).zIndex, 10);
});
result.realMouseKnownRouteOmitsIncomplete = await page.evaluate(async () => {
const overlay = window.__card.renderRoot.querySelector('hp-zigbee-topology-overlay');
await overlay.updateComplete;
return !!overlay.shadowRoot.querySelector('[data-direction="toward-neighbor"]')
&& !/incomplete data/i.test(overlay.shadowRoot.querySelector('.route-status')?.textContent || '');
});
await page.emulateMedia({ forcedColors: 'active' });
result.forcedColorsPreserved = await page.evaluate(() => {
const card = window.__card;
+5 -1
View File
@@ -660,7 +660,11 @@ 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:
updates age without requiring another HA event. The resolver's transient
`showIncomplete` distinguishes an unknown per-device uplink from global snapshot
`partial`: known uplinks and every matched coordinator suppress only that caption,
even if cross-provider reconciliation makes a coordinator's route unknown (#816).
Persistence and privacy:
[CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md).
Zigbee caption layout (#802) notifies the lazy live-hover runtime after the
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Zigbee hover: **Incomplete data** no longer appears on a device with a known
outgoing route (including remote/unplaced parents), or on a coordinator.
Incoming-only devices can still show it; stale/error statuses and the overall
snapshot status are unchanged ([#816](https://github.com/Matysh/houseplan-card/issues/816)).
- Kiosk: holding an empty spot of the plan with a mouse now opens **This
screen's sizes** every time. After the first hold the next one used to zoom
the plan instead of opening the dialog, and the plan stopped showing device
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Zigbee при наведении: **Неполные данные** больше не показываются у устройства
с известной исходящей связью (включая родителя на другом этаже или вне плана)
и у координатора. Одни входящие связи предупреждение не скрывают; отметки
устаревания/ошибок и общий статус снимка не изменились
([#816](https://github.com/Matysh/houseplan-card/issues/816)).
- Киоск: удержание пустого места плана мышью теперь каждый раз открывает
**«Размеры на этом экране»**. Раньше после первого удержания следующее вместо
диалога увеличивало план, а план переставал показывать изменения состояний
+5
View File
@@ -64,6 +64,11 @@ 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.
The #816 tests in `zigbee-provider-routes.test.mjs` cover per-hover incomplete
data suppression (local/remote/unplaced uplinks and conflicting coordinator roles),
incoming-only negatives and unchanged global `partial`. The same hover smoke
checks the mounted caption, retained stale/error messages and real mouse hover.
For tooltip layout (#802), also run `node demo/smoke_zigbee_tooltip_layout.mjs`:
it uses real mouse movement and screen-space overlap oracles, including a
negative control at the previous cursor-relative position. DOM presence alone
+1
View File
@@ -29,6 +29,7 @@ Everything computable from the tree and git; regenerate, never edit by hand
|---|---|
| Current local cycle | **v1.80.0-beta.5 candidate** — #792 battery indicators, #804 room creation from a long wall segment and #776 late smoke-error accounting passed review and are merged into `dev`. The release candidate rebuilds the committed bundle and tightens its ratchets; local targeted checks and full exact-SHA Validate precede publication. `main` remains on stable v1.79.0. |
| 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 incomplete captions | #816 suppresses only the hovered device's Incomplete data caption for confirmed outgoing routes and every coordinator; global snapshot partial and stale/provider messages stay independent. |
| Pipeline reliability | #811 checks the actual post-rebase issue range, parses each review's own verdict/findings, generates the final review index with the accepted tree's generator under the trusted dev controller, and reconciles only numeric browser-inventory cells. Manual content and the existing membership/200 warning policy remain unchanged. Evidence: `docs/analysis/811-pipeline-evidence-2026-10-07.md`. |
| Check reliability | #812 preserves mixed code/data input reachability, executes sandbox import closures and real workflow refusal comments, selects both static-card witnesses and verifies actual radar setup gestures. The performance harness honours zero warm-ups, reports the active profile and caps epoch stack capture; budgets, golden baselines and CI/reuse policy stay unchanged. Evidence: `docs/analysis/812-test-evidence-2026-10-07.md`. |
| Smoke exception guard | #776 makes `finish()` aggregate delivered page errors after awaited browser teardown, so a late rejection cannot print `EXC` followed by a false `OK`. Deterministic Node lifecycle tests complement the unchanged Chromium guard probes. |
+5 -1
View File
@@ -379,7 +379,11 @@ when space becomes available. No extra setting is needed. If the
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:
offline. No substitute line is drawn. **Incomplete data** appears only when
the snapshot is partial and this device's outgoing route is unknown; incoming
links alone do not suppress it. A coordinator never shows it, nor does a device
with a known outgoing route, including remote or unplaced parents. The overall
partial snapshot status in settings is unchanged. Staleness is marked separately:
a snapshot older than one hour is stale, and a failed refresh marks the last
snapshot stale at once. Lines and arrows pass over battery indicators but stay
under device faces and captions. A recent read of ZHA's
+6 -1
View File
@@ -408,7 +408,12 @@ LQI: 0 — красный, 128 — жёлтый, 255 — зелёный. Без
плане, короткая подпись сообщает об этом; доступное имя отсутствующего
устройства добавляется в скобках. «Нет данных о маршруте» означает отсутствие
однозначных сведений об исходящей связи, а не недоступность устройства.
Подменяющей линии нет. Неполные и устаревшие данные отмечены: снимок старше
Подменяющей линии нет. «Неполные данные» рядом с устройством показываются,
только если снимок неполный и исходящая связь устройства неизвестна; одних
входящих связей недостаточно, чтобы скрыть эту подпись. У координатора её нет
никогда, а у устройства с известной исходящей связью её нет даже при родителе
на другом этаже или вне плана. Общий статус неполноты в настройках сохраняется.
Устаревание отмечается отдельно: снимок старше
часа считается устаревшим, а ошибка обновления сразу оставляет последний снимок
с отметкой об устаревании. Линии и стрелки проходят поверх индикаторов заряда,
но под лицом устройства и подписями. Недавнее чтение кеша ZHA
@@ -36,8 +36,8 @@ that transition events alone prove disposal.
| Paint, cascade and layer composition | 47 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. |
| Pointer geometry and trusted interaction | 53 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. |
| Responsive DOM layout | 39 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. |
| Custom-element and HA browser lifecycle | 116 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **262 / 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`. |
| Custom-element and HA browser lifecycle | 117 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. |
| **Total** | **263 / 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
@@ -262,6 +262,12 @@ The invariant depends on measured element boxes, responsive breakpoints, native/
### Custom-element and HA browser lifecycle
`zigbee-hover-incomplete-uses-global-partial` (#816) checks the mounted lazy
overlay consuming the per-device warning under live provider state. Pure
resolver negatives cover role/route classification, not its rendered wiring.
- `zigbee-hover-incomplete-uses-global-partial`
`wall-face-lineage-skips-post-reconcile-settlement` (#804) needs the production room
dialog, post-reconciliation runtime wiring, history, asynchronous `config/set` and
reload. Pure lineage-helper tests cannot prove that the editor actually settles
+29
View File
@@ -9674,6 +9674,35 @@ const MUTANT_DEFINITIONS = [
+ ' && contentFingerprint(current) !== attempt.attemptedFingerprint)) return false;',
}],
},
{
id: 'zigbee-hover-incomplete-uses-global-partial',
guard: 'node demo/smoke_zigbee_topology_hover.mjs',
because: '#816: the mounted lazy custom element must consume the per-hover warning '
+ 'while preserving live provider statuses and arrows; a pure resolver cannot prove the UI wiring',
patches: [{
file: 'src/hp-zigbee-topology-overlay.ts',
find: "hover.showIncomplete ? topologyT(lang, 'route_partial') : ''",
replace: "hover.outgoing !== 'not-zigbee' && hover.partial ? topologyT(lang, 'route_partial') : ''",
}],
},
{
id: 'zigbee-hover-coordinator-incomplete',
guard: 'node --test --test-name-pattern="#816 coordinator never" test/zigbee-provider-routes.test.mjs',
patches: [{
file: 'src/zigbee-topology.ts',
find: "showIncomplete: partial && outgoing === 'unknown' && !isCoordinator,",
replace: "showIncomplete: partial && outgoing === 'unknown',",
}],
},
{
id: 'zigbee-hover-known-route-incomplete',
guard: 'node --test --test-name-pattern="#816 known uplink" test/zigbee-provider-routes.test.mjs',
patches: [{
file: 'src/zigbee-topology.ts',
find: "showIncomplete: partial && outgoing === 'unknown' && !isCoordinator,",
replace: 'showIncomplete: partial && !isCoordinator,',
}],
},
{
id: 'zigbee-topology-overlay-layer-lowered',
guard: 'node demo/smoke_zigbee_topology_hover.mjs',
+1 -1
View File
@@ -542,7 +542,7 @@ export class HpZigbeeTopologyOverlay extends LitElement {
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') : '',
hover.showIncomplete ? topologyT(lang, 'route_partial') : '',
].filter(Boolean).join(' · ');
this._setDesiredEndpointIds(lines.length || bubbles.length || hover.remoteCount
? [
+4 -1
View File
@@ -23,6 +23,7 @@ export interface ZigbeeHoverResolution {
parentTargets: ZigbeeParentTarget[];
outgoing: 'known' | 'unknown' | 'root' | 'not-zigbee';
partial: boolean;
showIncomplete: boolean;
obtainedAt?: number;
}
export type ZigbeeNodePlacement = { markerId: string; space: string };
@@ -150,7 +151,7 @@ export function resolveMappedTopologyHover(
const lines = new Map<string, ZigbeeHoverLine>(); const remote = new Set<string>();
const targets = new Map<string, ZigbeeParentTarget>(); const omitted = new Set<string>();
let outgoing: ZigbeeHoverResolution['outgoing'] = 'not-zigbee';
let partial = false; let obtainedAt: number | undefined;
let partial = false; let isCoordinator = false; let obtainedAt: number | undefined;
for (const map of mappedTopologies) {
const hovered = new Set([...map.placements].filter(([, placement]) => (
placement.markerId === hoveredMarkerId && placement.space === currentSpace
@@ -159,6 +160,7 @@ export function resolveMappedTopologyHover(
partial ||= map.partial;
obtainedAt = obtainedAt === undefined ? map.topology.obtainedAt : Math.min(obtainedAt, map.topology.obtainedAt);
for (const key of hovered) {
isCoordinator ||= map.nodes.get(key)?.role === 'coordinator';
const route = map.routes.get(key);
if (route?.kind === 'known') outgoing = 'known';
else if (route?.kind === 'root' && outgoing !== 'known') outgoing = 'root';
@@ -191,6 +193,7 @@ export function resolveMappedTopologyHover(
}
return { lines: [...lines.values()], remoteCount: remote.size, omittedCount: omitted.size,
parentTargets: [...targets.values()], outgoing, partial,
showIncomplete: partial && outgoing === 'unknown' && !isCoordinator,
...(obtainedAt === undefined ? {} : { obtainedAt }) };
}
export function resolveTopologyHover(
+53
View File
@@ -234,6 +234,59 @@ test('coordinator is incoming-only; unrelated and unknown hover states stay dist
assert.equal(hover([topology], 'm3').partial, true);
});
test('#816 known uplink omits incomplete regardless of placement or LQI; global partial survives', () => {
for (const lqi of [undefined, 0, 255]) for (const placement of ['local', 'remote', 'unplaced']) {
const topology = zha([row(1, 'Coordinator'), row(2, 'EndDevice', {
neighbors: [neighbor(1, { relationship: 'Parent', lqi })],
})]);
topology.warnings.push({ code: 'invalid_payload' });
const devices = placement === 'unplaced' ? markers.filter(m => m.id !== 'm1')
: markers.map(m => m.id === 'm1' && placement === 'remote' ? { ...m, space: 'other' } : m);
const before = JSON.stringify(topology), result = hover([topology], 'm2', devices);
assert.equal(result.outgoing, 'known'); assert.equal(result.partial, true);
assert.equal(result.showIncomplete, false);
assert.equal(result.lines.length + result.parentTargets.length, 1);
assert.equal(JSON.stringify(topology), before, 'presentation does not rewrite provider evidence');
}
});
test('#816 coordinator never incomplete even after cross-provider root conflict', () => {
const a = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0)] })]);
a.warnings.push({ code: 'invalid_payload' });
const b = z2m([zlink(2, 1, { routes: [{ status: 'ACTIVE', destinationAddress: 0, nextHopAddress: 2 }] })], [
{ ieeeAddr: ieee(1), networkAddress: 1, type: 'Router' },
{ ieeeAddr: ieee(2), networkAddress: 0, type: 'Coordinator' },
]);
const empty = zha([row(1, 'Coordinator')]); empty.warnings.push({ code: 'invalid_payload' });
for (const snapshots of [[a], [empty], [a, b], [b, a]]) {
const result = hover(snapshots, 'm1');
assert.equal(result.partial, true); assert.equal(result.showIncomplete, false);
if (snapshots.length === 2) assert.equal(result.outgoing, 'unknown', 'root conflict is not misreported known');
}
});
test('#816 incoming-only/unknown stay incomplete; fresh/non-Zigbee do not', () => {
const topology = zha([row(2, 'Router'), row(3, 'EndDevice', {
neighbors: [neighbor(2, { relationship: 'Parent' })],
})]);
topology.warnings.push({ code: 'invalid_payload' });
const incoming = hover([topology]);
assert.equal(incoming.outgoing, 'unknown'); assert.equal(incoming.showIncomplete, true);
assert.deepEqual(incoming.lines.map(line => line.routeDirection), ['toward-origin']);
assert.equal(hover([topology], 'm4').showIncomplete, false);
const mapped = mapTopologies([topology], markers, registry);
for (const map of mapped) map.partial = false;
const fresh = resolveMappedTopologyHover(mapped, 'main', 'm2');
assert.equal(fresh.outgoing, 'unknown'); assert.equal(fresh.showIncomplete, false);
const a = zha([row(1, 'Coordinator'), row(2, 'Router', { routes: [route(0)] }), row(4)]);
const b = z2m([zlink(4, 2, { routes: [{ status: 'ACTIVE', destinationAddress: 0, nextHopAddress: 4 }] })], [
{ ieeeAddr: ieee(1), networkAddress: 0, type: 'Coordinator' },
{ ieeeAddr: ieee(2), networkAddress: 2, type: 'Router' },
{ ieeeAddr: ieee(4), networkAddress: 4, type: 'Router' },
]);
assert.equal(hover([a, b]).outgoing, 'unknown'); assert.equal(hover([a, b]).showIncomplete, true);
});
test('remote/unplaced parent carries exact target name and observed LQI, never source name', () => {
const topology = zha([row(2, 'Router', { name: '<b>Provider parent</b>', neighbors: [neighbor(3, { relationship: 'Child', lqi: 128 })] }), row(3, 'EndDevice')]);
const onlyChild = markers.filter((device) => device.id === 'm3');