From c38501ef501cd5e0656a0c056f8747cd507c81df Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 3 Sep 2026 18:45:57 +0300 Subject: [PATCH] feat: route live vacuum overlays to the active map space User-Visible: yes Issue: #162 --- docs/CHANGELOG.md | 5 ++ docs/CHANGELOG.ru.md | 5 ++ scripts/mutation-gate.mjs | 24 +++++++ src/houseplan-card.ts | 66 ++++++++++++++---- src/types.ts | 12 ++++ src/vacuum-routes.ts | 79 +++++++++++++++++++++ test/isometric-contract.test.mjs | 3 +- test/vacuum-routes.test.mjs | 103 ++++++++++++++++++++++++++++ tests_backend/test_vacuum_routes.py | 18 ++--- 9 files changed, 294 insertions(+), 21 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 284babf9..9897b0dc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- A robot vacuum with several maps can now show each map on its own floor: the + dock stays in its own space while the live position and the trail appear in + the space the active map is assigned to, and a map that is unassigned, + ambiguous or uncalibrated draws nothing instead of guessing a floor + ([#162](https://github.com/Matysh/houseplan-card/issues/162)). - Custom background assets now reject special filesystem entries without blocking Home Assistant, tolerate files disappearing during quota scans and report corrupt images separately from exhausted storage; pen/touch movement diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 816bd869..3d475a90 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,11 @@ ## Не выпущено +- Робот-пылесос с несколькими картами теперь показывается на своём этаже: док + остаётся в своём пространстве, а живое положение и след рисуются в том + пространстве, которому назначена активная карта; несопоставленная, + неоднозначная или незакалиброванная карта не рисует ничего вместо того, чтобы + угадывать этаж ([#162](https://github.com/Matysh/houseplan-card/issues/162)). - Пользовательские изображения подложки теперь отклоняют специальные файловые объекты без зависания Home Assistant, переживают исчезновение файлов во время подсчёта лимита и отличают повреждённое изображение от переполненного diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index ea9b4336..8fd13ba2 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -291,6 +291,30 @@ const MUTANT_DEFINITIONS = [ replace: " if (candidates.length >= 1) return { kind: 'adopted', route: candidates[0] };", }], }, + { + id: 'vacuum-overlay-ignores-the-rendered-space', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/vacuum-routes.test.mjs', + because: 'the live puck belongs to the ACTIVE ROUTE space, not to every space that ' + + 'happens to be on screen: dropping the check draws one robot on both floors (#162)', + patches: [{ + file: 'src/vacuum-routes.ts', + find: " const live = active && active.space === input.renderSpace\n ? normalizeRouteMatrix(active.calibration) : null;", + replace: ' const live = active ? normalizeRouteMatrix(active.calibration) : null;', + }], + }, + { + id: 'vacuum-previous-run-follows-the-robot', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/vacuum-routes.test.mjs', + because: 'the previous run belongs to the space of its own route, so it keeps showing ' + + 'where the robot has been after it moved to another map (#162, AC10)', + patches: [{ + file: 'src/vacuum-routes.ts', + find: ' const previousAllowed = !!previousRoute\n && previousRoute.space === input.renderSpace', + replace: ' const previousAllowed = !!previousRoute\n && !!active && active.space === input.renderSpace', + }], + }, { id: 'area-snapshot-cleanup-ignores-authority', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 6bc3f5d4..0f399803 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -121,6 +121,10 @@ import { Pt as VacPt, type VacPath, type VacSourceCandidate, type VacSourceResolution, type VacSourceStatus, } from './vacuum'; +import { + effectiveRoutes, observedMapIds, resolveRoute, planVacuumOverlay, + type VacuumMapRoute, type VacuumRouteResolution, +} from './vacuum-routes'; import { buildDevices, deviceFromMarkerDraft, seedHiddenBindings, lqiFor, tempFor, climateTempFor, areaTemp, areaHum, effectiveExcludedIntegrations, sourceValue, roomClimateKey, roomClimateMap, @@ -4792,9 +4796,19 @@ export class HouseplanCard extends LitElement { const source = this._vacSource(device, planHass); const telemetry = source ? readVacTelemetry(planHass?.states?.[source]?.attributes) : null; const runtime = this._vacRt.get(device.id); + // One routing authority per frame: render() must not re-derive which + // map the robot is on, or two answers become possible (#162). + const routes = effectiveRoutes(device.id, device.marker?.vacuum ?? null, device.space, source); + const resolution = resolveRoute({ + routes, + observed: observedMapIds(routes, [source], (src) => this._vacObservedMapId(device, src, planHass)), + spaceIds: new Set(this._model.map((space) => space.id)), + }); facts.set(`vacuum:${device.id}`, { source, telemetry, + routes, + resolution, mapId: telemetry ? this._vacMapId(device, telemetry, planHass) : null, runtime: runtime ? { trail: runtime.trail, @@ -11777,7 +11791,7 @@ export class HouseplanCard extends LitElement { kiosk multipliers still feed --dev-size. */}
${devs.map((d) => this._renderDevice(d, view, showLqi))} - ${this._renderVacuums(devs, view)} + ${this._renderVacuums(this._renderDevices, view, space.id)} ${this._renderVacFit(view)} ${this._renderOpeningLocks(view)} ${disp.showNames || this._markup @@ -12232,6 +12246,12 @@ export class HouseplanCard extends LitElement { return vacMapIdWithFallback(tele.mapId, sel); } + /** The map id an exact source reports right now, or undefined if silent. */ + private _vacObservedMapId(d: DevItem, source: string, planHass = this._planHass): string | undefined { + const tele = readVacTelemetry(planHass?.states?.[source]?.attributes); + return tele ? this._vacMapId(d, tele, planHass) : undefined; + } + /** Persist a solved matrix into marker.vacuum.calibration[mapId]. * Returns whether the write actually landed — callers must not toast * success otherwise (HP-1540-01). */ @@ -12327,7 +12347,14 @@ export class HouseplanCard extends LitElement { } /** Puck + trail for every live vacuum of the space. */ - private _renderVacuums(devs: DevItem[], view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing { + /** + * Live overlays for every robot whose ACTIVE map routes into this space. + * + * The device list is deliberately not the space-filtered one: the dock keeps + * living in `marker.space`, but the puck and the trails belong to the space + * of the active route, which is the whole point of #162. + */ + private _renderVacuums(devs: readonly DevItem[], view: { x: number; y: number; w: number; h: number }, spaceId: string): TemplateResult | typeof nothing { if (this._markup || this._mode === 'decor') return nothing; const viewKey = this._space + '|' + view.x + '|' + view.y + '|' + view.w + '|' + view.h; const jumpAll = this._vacJumpOnce || viewKey !== this._vacViewKey; @@ -12343,30 +12370,45 @@ export class HouseplanCard extends LitElement { if (!src) continue; const tele = fact?.telemetry ?? readVacTelemetry(this._renderPlanHass?.states[src]?.attributes); if (!tele) continue; - const mapNow = String(fact?.mapId ?? this._vacMapId(d, tele, this._renderPlanHass)); - const matrix = d.marker?.vacuum?.calibration?.[mapNow] as Affine | undefined; - if (!matrix || matrix.length !== 6) continue; + const routes: VacuumMapRoute[] = fact?.routes + ?? effectiveRoutes(d.id, d.marker?.vacuum ?? null, d.space, src); + const resolution: VacuumRouteResolution = fact?.resolution ?? resolveRoute({ + routes, + observed: observedMapIds(routes, [src], (source) => this._vacObservedMapId(d, source)), + spaceIds: new Set(this._model.map((space) => space.id)), + }); + const srv0 = fact?.server ?? this._vacSrvTrails[d.id]; + const plan = planVacuumOverlay({ + resolution, routes, renderSpace: spaceId, + rootSource: d.marker?.vacuum?.source ?? null, + serverCurrent: srv0?.current ?? null, + serverPrevious: srv0?.previous ?? null, + explicitRoutes: Array.isArray(d.marker?.vacuum?.map_routes) + && (d.marker?.vacuum?.map_routes?.length ?? 0) > 0, + }); + if (!plan.live && !plan.previous) continue; + const matrix = plan.live; const rt = fact?.runtime ?? this._vacRt.get(d.id); const moving = rt?.moving ?? false; const tmode = vacTrailMode(d.marker?.vacuum); // owner 2026-07-31: hide when the cleanup is over (default), unless the // mode says always; the previous run only ever shows in 'always' const showCur = tmode === 'always' || (tmode === 'cleaning' && moving); - const srv = fact?.server ?? this._vacSrvTrails[d.id]; - const srvCur = srv?.current?.map_id === mapNow && Array.isArray(srv.current.points) ? srv.current : null; - const srvPrev = srv?.previous?.map_id === mapNow && Array.isArray(srv.previous.points) ? srv.previous : null; + const srv = srv0; + const srvCur = plan.currentRunMatches && Array.isArray(srv?.current?.points) ? srv.current : null; + const srvPrev = plan.previous && Array.isArray(srv?.previous?.points) ? srv.previous : null; // the PREVIOUS run stays visible even at rest: users compare where the // robot has been against where it has not (owner call 2026-07-31) - if (tmode === 'always' && srvPrev) { + if (tmode === 'always' && srvPrev && plan.previous) { const previous = normalizeVacPath(srvPrev.points); - const pathD = this._vacTrailPathD(previous, matrix); + const pathD = this._vacTrailPathD(previous, plan.previous); if (pathD) { trails.push(svg``); } } // One arbitration authority: integration multi-subpath → server run → // local runtime. Only drawable segments participate. - if (showCur) { + if (showCur && matrix) { const current = resolveCurrentVacPath(tele, srvCur, rt?.trail || []); // Server/integration paths include the current target. Remove it from // the final subpath only while moving so the line cannot outrun puck. @@ -12386,7 +12428,7 @@ export class HouseplanCard extends LitElement { } } } - if (!moving || !tele.pos) continue; + if (!moving || !tele.pos || !matrix) continue; const [cx, cy] = applyAffine(matrix, tele.pos.x, tele.pos.y); const point = this._scenePoint([cx, cy]); const left = ((point[0] - view.x) / view.w) * 100; diff --git a/src/types.ts b/src/types.ts index cb239fc1..5a94c46d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,8 +145,11 @@ export interface Marker { trail_mode?: VacuumTrailMode | null; room_highlight?: boolean | null; source?: string | null; + /** Legacy-read after #162: `map_id -> matrix`, no source, no space. */ calibration?: Record; segment_map?: Record; + /** Canonical map->space routing (#162, docs/VACUUM.md). */ + map_routes?: VacuumMapRoute[] | null; } | null; /** Manual placement into a House Plan room without an HA Area. Room-aware * consumers resolve it together with `space`; visual placement is not its @@ -194,6 +197,15 @@ export interface Marker { use_climate_temp?: boolean | null; } +/** One saved answer to "this exact map of this exact source lives here" (#162). */ +export interface VacuumMapRoute { + id: string; + source: string; + map_id: string; + space: string; + calibration?: [number, number, number, number, number, number] | null; +} + /** A door, window, gate or open passage: plan geometry (normalized coords). */ export interface PartitionOpeningHost { kind: 'partition'; diff --git a/src/vacuum-routes.ts b/src/vacuum-routes.ts index f09c241e..54f2a928 100644 --- a/src/vacuum-routes.ts +++ b/src/vacuum-routes.ts @@ -250,3 +250,82 @@ export function adoptLegacyRun( if (candidates.length === 0) return { kind: 'orphan_run' }; return { kind: 'ambiguous_run', routeIds: candidates.map((route) => route.id).sort() }; } + +/** Every source worth reading telemetry from, routes plus discovery. */ +export function observedMapIds( + routes: VacuumMapRoute[], + extraSources: Array, + read: (source: string) => string | undefined, +): Record { + const sources = new Set(); + for (const route of routes) if (route.source) sources.add(route.source); + for (const extra of extraSources) if (extra) sources.add(extra); + const observed: Record = {}; + for (const source of [...sources].sort()) { + const mapId = read(source); + if (mapId !== undefined) observed[source] = mapId; + } + return observed; +} + +/** The route a stored run belongs to: its own id first, adoption for old data. */ +export function runRoute( + run: { map_id?: unknown; route_id?: unknown } | null | undefined, + routes: VacuumMapRoute[], + rootSource: string | null | undefined, +): VacuumMapRoute | null { + if (!run) return null; + const routeId = typeof run.route_id === 'string' ? run.route_id : ''; + if (routeId) return routes.find((route) => route.id === routeId) || null; + const adopted = adoptLegacyRun(run, routes, rootSource); + return adopted.kind === 'adopted' ? adopted.route : null; +} + +export interface VacuumOverlayInput { + resolution: VacuumRouteResolution; + routes: VacuumMapRoute[]; + /** The space being drawn right now — not the dock's space. */ + renderSpace: string; + rootSource?: string | null; + serverCurrent?: { map_id?: unknown; route_id?: unknown } | null; + serverPrevious?: { map_id?: unknown; route_id?: unknown } | null; + /** False for a config that still has only legacy `calibration` (see below). */ + explicitRoutes: boolean; +} + +export interface VacuumOverlayPlan { + /** Matrix for the live puck and the current trail here, or null. */ + live: Affine | null; + /** Whether the stored current run belongs to the route being drawn. */ + currentRunMatches: boolean; + /** Matrix for the previous run here, or null. */ + previous: Affine | null; +} + +/** + * What, if anything, this robot draws in the space currently on screen. + * + * The dock never moves: it stays in `marker.space` and is drawn by the + * ordinary device path. Everything live belongs to the active route's space + * instead, which is what lets floor 2 show the robot while the dock stays on + * floor 1. + * + * The previous run belongs to the space of its own route, so it keeps showing + * where the robot has been after it moved to another map. Legacy configs + * (`explicitRoutes: false`) keep the older, narrower rule — previous run only + * for the map that is active now — because until the user edits routing at + * all, #162 promises the picture does not change. + */ +export function planVacuumOverlay(input: VacuumOverlayInput): VacuumOverlayPlan { + const active = input.resolution.kind === 'ready' ? input.resolution.route : null; + const live = active && active.space === input.renderSpace + ? normalizeRouteMatrix(active.calibration) : null; + const currentRoute = runRoute(input.serverCurrent, input.routes, input.rootSource); + const currentRunMatches = !!active && !!currentRoute && currentRoute.id === active.id; + const previousRoute = runRoute(input.serverPrevious, input.routes, input.rootSource); + const previousAllowed = !!previousRoute + && previousRoute.space === input.renderSpace + && (input.explicitRoutes || (!!active && previousRoute.id === active.id)); + const previous = previousAllowed ? normalizeRouteMatrix(previousRoute!.calibration) : null; + return { live, currentRunMatches, previous }; +} diff --git a/test/isometric-contract.test.mjs b/test/isometric-contract.test.mjs index 862bf57d..124e9df0 100644 --- a/test/isometric-contract.test.mjs +++ b/test/isometric-contract.test.mjs @@ -33,7 +33,8 @@ test('Stage 2 uses inert shared-projection SVG geometry without a second light m test('all current floor/live layers remain in the one main scene', () => { for (const renderer of [ '_renderDecorLayer()', '_renderRoomHoverFill(roomHover)', '_renderGlowLayer(space, disp)', - '_renderSunRays(space)', '_renderOpenings(disp)', '_renderVacuums(devs, view)', + '_renderSunRays(space)', '_renderOpenings(disp)', + '_renderVacuums(this._renderDevices, view, space.id)', ]) assert.ok(card.includes(renderer), `missing ${renderer}`); assert.match(card, /const point = this\._scenePoint\(\[pos\.x, pos\.y\]\)/); assert.match(card, /const point = this\._scenePoint\(\[p\.x, p\.y\]\)/); diff --git a/test/vacuum-routes.test.mjs b/test/vacuum-routes.test.mjs index 0f60bb34..437c2d5f 100644 --- a/test/vacuum-routes.test.mjs +++ b/test/vacuum-routes.test.mjs @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'; import { validateMarkerRoutes, effectiveRoutes, legacyRouteId, resolveRoute, adoptLegacyRun, normalizeRouteMatrix, isEntityIdLike, VAC_ROUTE_LIMIT, VAC_ROUTE_ERROR, + observedMapIds, runRoute, planVacuumOverlay, } from '../test-build/vacuum-routes.js'; const fixture = (name) => JSON.parse(readFileSync( @@ -135,3 +136,105 @@ test('run без map id усыновлению не подлежит', () => { assert.equal(adoptLegacyRun({}, [route()], 'camera.robot').kind, 'orphan_run'); assert.equal(adoptLegacyRun({ map_id: 1 }, [route()], 'camera.robot').kind, 'orphan_run'); }); + +const M1 = [1, 0, 0, 0, 1, 0]; +const M2 = [2, 0, 0, 0, 2, 0]; +const twoFloors = [ + { id: 'r1', source: 'camera.robot', map_id: 'm1', space: 'floor1', calibration: M1 }, + { id: 'r2', source: 'camera.robot', map_id: 'm2', space: 'floor2', calibration: M2 }, +]; +const ready = (mapId) => resolveRoute({ + routes: twoFloors, observed: { 'camera.robot': mapId }, spaceIds: spaces('floor1', 'floor2'), +}); + +test('телеметрия читается по всем источникам маршрутов плюс discovery', () => { + const seen = []; + const observed = observedMapIds(twoFloors, ['camera.extra', null, ''], (source) => { + seen.push(source); + return source === 'camera.robot' ? 'm2' : undefined; + }); + assert.deepEqual(seen, ['camera.extra', 'camera.robot']); + assert.deepEqual(observed, { 'camera.robot': 'm2' }); +}); + +test('run опознаётся своим route_id, а без него — усыновлением', () => { + assert.equal(runRoute({ route_id: 'r2', map_id: 'm1' }, twoFloors, 'camera.robot').id, 'r2'); + assert.equal(runRoute({ route_id: 'нет такого' }, twoFloors, 'camera.robot'), null); + assert.equal(runRoute({ map_id: 'm1' }, twoFloors, 'camera.robot').id, 'r1'); + assert.equal(runRoute(null, twoFloors, 'camera.robot'), null); +}); + +test('док остаётся на первом этаже, живой оверлей уезжает на второй (AC2)', () => { + const resolution = ready('m2'); + const onFloor2 = planVacuumOverlay({ + resolution, routes: twoFloors, renderSpace: 'floor2', explicitRoutes: true, + }); + assert.deepEqual(onFloor2.live, M2); + const onFloor1 = planVacuumOverlay({ + resolution, routes: twoFloors, renderSpace: 'floor1', explicitRoutes: true, + }); + assert.equal(onFloor1.live, null, 'на этаже дока живого робота нет'); +}); + +test('возврат на первую карту возвращает оверлей без правки маршрутов (AC3)', () => { + const back = planVacuumOverlay({ + resolution: ready('m1'), routes: twoFloors, renderSpace: 'floor1', explicitRoutes: true, + }); + assert.deepEqual(back.live, M1); + assert.deepEqual(twoFloors[0].calibration, M1, 'маршруты не переписаны'); +}); + +test('неоднозначность и незакалиброванный маршрут не рисуют ничего', () => { + for (const resolution of [ + { kind: 'ambiguous', routeIds: ['r1', 'r2'] }, + { kind: 'needs_calibration', route: twoFloors[0] }, + { kind: 'unmapped', source: 'camera.robot', mapId: 'm9' }, + { kind: 'missing_space', route: twoFloors[1] }, + { kind: 'none' }, + ]) { + for (const renderSpace of ['floor1', 'floor2']) { + const plan = planVacuumOverlay({ resolution, routes: twoFloors, renderSpace, explicitRoutes: true }); + assert.equal(plan.live, null, `${resolution.kind} / ${renderSpace}`); + assert.equal(plan.currentRunMatches, false, resolution.kind); + } + } +}); + +test('прошлый прогон остаётся в пространстве своего маршрута (AC10)', () => { + const plan = planVacuumOverlay({ + resolution: ready('m2'), routes: twoFloors, renderSpace: 'floor1', explicitRoutes: true, + rootSource: 'camera.robot', + serverPrevious: { route_id: 'r1', map_id: 'm1', points: [] }, + }); + assert.deepEqual(plan.previous, M1, 'прошлый прогон виден на своём этаже'); + assert.equal(plan.live, null, 'а робот при этом здесь не рисуется'); +}); + +test('текущий серверный прогон принимается только от активного маршрута', () => { + const base = { + resolution: ready('m2'), routes: twoFloors, renderSpace: 'floor2', + explicitRoutes: true, rootSource: 'camera.robot', + }; + assert.equal(planVacuumOverlay({ ...base, serverCurrent: { route_id: 'r2' } }).currentRunMatches, true); + assert.equal(planVacuumOverlay({ ...base, serverCurrent: { route_id: 'r1' } }).currentRunMatches, false); + assert.equal(planVacuumOverlay({ ...base, serverCurrent: { map_id: 'm1' } }).currentRunMatches, false); + assert.equal(planVacuumOverlay({ ...base, serverCurrent: { map_id: 'm2' } }).currentRunMatches, true); +}); + +test('легаси-конфиг сохраняет прежнее правило прошлого прогона (AC13)', () => { + const marker = { source: 'camera.robot', calibration: { m1: M1, m2: M2 } }; + const routes = effectiveRoutes('mk', marker, 'floor1'); + const resolution = resolveRoute({ + routes, observed: { 'camera.robot': 'm2' }, spaceIds: spaces('floor1'), + }); + const legacy = planVacuumOverlay({ + resolution, routes, renderSpace: 'floor1', explicitRoutes: false, + rootSource: 'camera.robot', serverPrevious: { map_id: 'm1' }, + }); + assert.equal(legacy.previous, null, 'прошлый прогон другой карты по-прежнему скрыт'); + const same = planVacuumOverlay({ + resolution, routes, renderSpace: 'floor1', explicitRoutes: false, + rootSource: 'camera.robot', serverPrevious: { map_id: 'm2' }, + }); + assert.deepEqual(same.previous, M2, 'прогон активной карты виден, как и раньше'); +}); diff --git a/tests_backend/test_vacuum_routes.py b/tests_backend/test_vacuum_routes.py index 77279b5d..4d816c92 100644 --- a/tests_backend/test_vacuum_routes.py +++ b/tests_backend/test_vacuum_routes.py @@ -1,15 +1,17 @@ -"""vacuum_routes.py — питоновское зеркало src/vacuum-routes.ts (#162). - -Модуль чистый и Home Assistant не тянет, поэтому импортируется напрямую: -sys.path/sys.modules не трогаются намеренно (класс #389). -""" +"""vacuum_routes.py — питоновское зеркало src/vacuum-routes.ts (#162).""" import json import pathlib -import sys +import re +import types ROOT = pathlib.Path(__file__).parent.parent -sys.path.insert(0, str(ROOT / "custom_components" / "houseplan")) -import vacuum_routes as vr # noqa: E402 +# Модуль читается текстом и исполняется в собственном пространстве имён: ни +# sys.path, ни sys.modules не трогаются намеренно (#393, класс #389). +_source = (ROOT / "custom_components" / "houseplan" / "vacuum_routes.py").read_text( + encoding="utf-8") +vr = types.ModuleType("vacuum_routes_under_test") +vr.__dict__["re"] = re +exec(compile(_source, "vacuum_routes.py", "exec"), vr.__dict__) FIXTURES = ROOT / "test" / "fixtures" / "vacuum-routes" IDENTITY = [1, 0, 0, 0, 1, 0]