diff --git a/demo/smoke_vacuum_multifloor.mjs b/demo/smoke_vacuum_multifloor.mjs new file mode 100644 index 00000000..8dc6719b --- /dev/null +++ b/demo/smoke_vacuum_multifloor.mjs @@ -0,0 +1,106 @@ +// #162: карты робота живут на разных этажах, док — на своём. +// +// Смок проверяет ровно то, чего не мог проверить ни один прежний: раньше +// маркер робота отбирался по пространству дока, поэтому на другом этаже +// оверлея не существовало в принципе. +import { launch, checkAll, finish } from './serve.mjs'; +const { page, browser } = await launch(); + +const out = await page.evaluate(async () => { + const c = window.__card; + const o = {}; + const sr = () => c.shadowRoot || c.renderRoot; + const M1 = [0.5, 0, 0, 0, 0.5, 0]; + const M2 = [0.25, 0, 0, 0, 0.25, 0]; + const attrs = (mapName) => ({ vacuum_position: { x: 600, y: 800, a: 45 }, map_name: mapName }); + + c.hass = { ...c.hass, + entities: { ...c.hass.entities, + 'vacuum.robo': { entity_id: 'vacuum.robo', platform: 'demo', disabled_by: null }, + 'camera.robo_map': { entity_id: 'camera.robo_map', platform: 'demo', disabled_by: null } }, + states: { ...c.hass.states, + 'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } }, + 'camera.robo_map': { state: 'idle', attributes: attrs('m1') }, + } }; + const cfg = c._serverCfg; + cfg.markers = cfg.markers || []; + cfg.markers.push({ + id: 'e_vacuum_robo', binding: 'entity:vacuum.robo', space: 'f1', + vacuum: { + source: 'camera.robo_map', + map_routes: [ + { id: 'vr1', source: 'camera.robo_map', map_id: 'm1', space: 'f1', calibration: M1 }, + { id: 'vr2', source: 'camera.robo_map', map_id: 'm2', space: 'garden', calibration: M2 }, + ], + }, + }); + c._layout['e_vacuum_robo'] = { s: 'f1', x: 0.1, y: 0.1 }; + c._regSignature = ''; + c._setMode('view'); + c._space = 'f1'; + await c.updateComplete; await new Promise((r) => setTimeout(r, 60)); + + const puck = () => sr().querySelector('.vacpuck'); + const dock = () => sr().querySelector('.dev[data-id="e_vacuum_robo"]'); + const warn = () => sr().querySelector('.vacwarn'); + + // Карта m1 → первый этаж: робот и док на одном этаже, как и раньше. + o.floor1DockThere = !!dock(); + o.floor1PuckThere = !!puck(); + o.floor1NoWarning = !warn(); + + // Робот переехал на карту m2 → второй этаж. + c.hass = { ...c.hass, states: { ...c.hass.states, + 'camera.robo_map': { state: 'idle', attributes: attrs('m2') } } }; + await c.updateComplete; await new Promise((r) => setTimeout(r, 60)); + o.floor1DockStays = !!dock(); + o.floor1PuckGone = !puck(); + + c._space = 'garden'; + await c.updateComplete; await new Promise((r) => setTimeout(r, 60)); + o.floor2PuckThere = !!puck(); + o.floor2NoDock = !dock(); + + // Возврат на m1 возвращает картину без единой правки конфигурации. + const before = JSON.stringify(c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum); + c.hass = { ...c.hass, states: { ...c.hass.states, + 'camera.robo_map': { state: 'idle', attributes: attrs('m1') } } }; + await c.updateComplete; await new Promise((r) => setTimeout(r, 60)); + o.floor2PuckGoneAfterReturn = !puck(); + c._space = 'f1'; + await c.updateComplete; await new Promise((r) => setTimeout(r, 60)); + o.floor1PuckBack = !!puck(); + o.routesUntouched = JSON.stringify( + c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum) === before; + + // Несопоставленная карта: нигде не рисуем, но у дока говорим почему. + c.hass = { ...c.hass, states: { ...c.hass.states, + 'camera.robo_map': { state: 'idle', attributes: attrs('m9') } } }; + await c.updateComplete; await new Promise((r) => setTimeout(r, 60)); + o.unmappedNoPuck = !puck(); + o.unmappedWarns = !!warn(); + o.unmappedWarnLabelled = !!warn()?.getAttribute('aria-label'); + c._space = 'garden'; + await c.updateComplete; await new Promise((r) => setTimeout(r, 60)); + o.unmappedNoGhostOnOtherFloor = !puck(); + + // Робот встал — предупреждение снимается: тревожить нечем. + c._space = 'f1'; + c.hass = { ...c.hass, states: { ...c.hass.states, + 'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } } } }; + await c.updateComplete; await new Promise((r) => setTimeout(r, 60)); + o.dockedNoWarning = !warn(); + + return o; +}); + +checkAll(out, { + floor1DockThere: true, floor1PuckThere: true, floor1NoWarning: true, + floor1DockStays: true, floor1PuckGone: true, + floor2PuckThere: true, floor2NoDock: true, + floor2PuckGoneAfterReturn: true, floor1PuckBack: true, routesUntouched: true, + unmappedNoPuck: true, unmappedWarns: true, unmappedWarnLabelled: true, + unmappedNoGhostOnOtherFloor: true, + dockedNoWarning: true, +}); +await finish(browser); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ba2c12ef..a1cf2b49 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -254,6 +254,29 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the source-glow fill mode: a light pool is spatial information, not a replacement for the universal working-state plate. +### Vacuum map-to-space routing authority + +`src/vacuum-routes.ts` owns the answer to "which map is on which floor" and is +the only place that answers it. `effectiveRoutes()` reads explicit +`marker.vacuum.map_routes` or, for a plan that predates #162, the legacy +`calibration` dictionary as routes into the dock's space. `resolveRoute()` +turns the observed map id per exact source into one of six results, never into +a guess: two candidates are `ambiguous`, not "the first one". The result is +computed once per frame into `render-device-snapshot.ts` +(`facts.get('vacuum:')`), so `render()` cannot derive a second answer, and +`planVacuumOverlay()` decides what the space currently on screen draws — the +dock stays in `marker.space` while the live overlay follows the active route. + +The editing half lives apart, in the lazy editor graph: `vacuum-route-edit.ts` +(add, re-target, delete, legacy conversion, matrix write, fit target) and +`editors/vacuum-maps-section.ts` (the "Maps and floors" block). The View card +must not pay for code it can never run. `custom_components/houseplan/ +vacuum_routes.py` is a byte-for-byte mirror of the resolver and the legacy-run +adoption rule, driven by the shared fixtures in +`test/fixtures/vacuum-routes/`: the recorder files each point under the route +that produced it, and a divergence between the two sides shows up as a robot on +the wrong floor. + ### Vacuum telemetry authority `src/vacuum.ts` owns pure normalization and arbitration. Telemetry paths are diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index afb3ecd3..d640777e 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -77,6 +77,36 @@ new frontend restores the disabled behavior after upgrade. Full backup/import preserves the setting and the privacy-safe support projection includes only a validated boolean. +## Vacuum map routes (#162) + +`marker.vacuum.map_routes` is an optional array of +`{ id, source, map_id, space, calibration? }`, at most 32 per marker, with `id` +and the pair `(source, map_id)` unique inside the marker. It requires no model +or store version bump: absence reads as the historical behaviour. + +Reading is lossless in both directions. Without `map_routes` every valid +`calibration[map_id]` is an effective route into the dock's space, so nothing +is migrated on load and an ordinary save of other marker fields leaves the +vacuum block untouched. The first explicit routing edit converts the whole +legacy dictionary at once and needs an exact source to do it; partial +conversion is refused, and `calibration` is removed only after the config write +succeeds. + +Semantic validation is change-aware: an untouched legacy or future-shaped block +never blocks an unrelated save, while an edited one must be valid or the write +is refused atomically with `invalid_vacuum_map_route`. A full export/import +round-trips routes verbatim. A single-space export drops routes that point at +other spaces and counts them in `dropped_marker_links`, because their target +would not exist in the imported document. + +Downgrade: an older frontend ignores `map_routes` and falls back to the legacy +`calibration` dictionary, which the conversion removed — such a plan shows the +robot only where a matrix still exists, and re-upgrading restores routing +without data loss because the routes themselves are preserved by the +unknown-fields policy. Server trails written by a newer backend carry +`route_id`/`source`; an older frontend ignores both and matches runs by +`map_id` exactly as before. + ## Stable wall identity — model v8 (#282) Model v8 adds `space.wall_segments[]`, ordered `rooms[].wall_ids[]`, IDs on diff --git a/docs/TESTING.md b/docs/TESTING.md index 19821d3b..55186690 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -45,6 +45,43 @@ доказывается unit/smoke-счётчиками и состоянием runtime, а не regex по исходнику (#440). +## Многоэтажный робот: карты и пространства (#162) + +- [ ] Чистый резолвер разбирает все шесть исходов на общей фикстуре и не + зависит от порядка списка маршрутов; усыновление легаси-прогона даёт + ровно три исхода, оба отрицательных — fail-closed + [unit: `test/vacuum-routes.test.mjs`, `tests_backend/test_vacuum_routes.py`, + фикстуры `test/fixtures/vacuum-routes/*.json`]. +- [ ] Живой оверлей рисуется в пространстве активного маршрута, док остаётся в + своём; прошлый прогон виден в пространстве СВОЕГО маршрута, а легаси-конфиг + сохраняет прежнее правило [unit: `test/vacuum-routes.test.mjs`]. +- [ ] Рекордер подписывается на источники всех маршрутов и пишет прогон под + маршрутом, который его породил; смена маршрута начинает новый прогон даже + при том же `map_id` + [backend: `tests_backend/test_trail_recorder.py`, `test_trails.py`]. +- [ ] Правка маршрутов проверяется семантически на `config/set`, optimize и обоих + импортах, нетронутые легаси/будущие данные не блокируют чужое сохранение + [backend: `tests_backend/test_vacuum_route_validation.py`]. +- [ ] Удаление пространства называет число чужих карт и уносит только их + маршруты; экспорт одного пространства отбрасывает кросс-пространственные + маршруты и считает их в `dropped_marker_links` + [unit: `test/space-deletion.test.mjs`, backend: `tests_backend/test_ha_import_export.py`]. +- [ ] Восемь мутантов краснеют: `vacuum-route-ambiguity-takes-the-first`, + `vacuum-route-missing-space-falls-back-to-dock`, + `vacuum-route-unmapped-draws-anyway`, + `vacuum-route-identity-duplicates-allowed`, + `vacuum-legacy-run-adopts-the-first-candidate`, + `vacuum-overlay-ignores-the-rendered-space`, + `vacuum-previous-run-follows-the-robot`, + `vacuum-route-warning-stays-silent`, плюс серверные + `vacuum-run-forgets-its-route`, `vacuum-retargeted-route-keeps-its-old-trails`, + `vacuum-route-validation-accepts-a-dead-space` и + `space-delete-keeps-foreign-vacuum-routes` + [mutation: `scripts/mutation-gate.mjs`]. +- [ ] Продакшен-бандл показывает робота на этаже активной карты, а на этаже дока + не показывает; предупреждение у дока появляется на движущемся роботе с + несопоставленной картой [auto: `demo/smoke_vacuum_multifloor.mjs`]. + ## Vacuum trail smoothing (#209) - [ ] Pure `smoothVacPath` tests prove exact endpoints, separate subpaths, diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 624c9f98..6b0fe4dd 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1501,7 +1501,12 @@ Map Extractor, dreame-vacuum (Tasshack) и Valetudo-подобные камер | Автоматическая | В карте робота и плане совпадают минимум три названия комнат | Вычисляется аффинное преобразование; при ошибке до 40 см оно сохраняется сразу | | Ручная | Доступна карта/контуры робота | Полупрозрачную карту можно двигать, масштабировать, поворачивать на 90° и зеркалить | -Для каждого `map_id` хранится собственная калибровка, поэтому многоэтажный робот может работать с несколькими пространствами. +Многоэтажный робот настраивается в блоке «Карты и этажи»: каждую карту +нужно сопоставить пространству и откалибровать именно в нём. Док остаётся +в своём пространстве, а живая позиция и след появляются там, куда назначена +текущая карта. Пока карта не сопоставлена, не откалибрована или неотличима +от другой, робот не рисуется нигде — у дока появляется предупреждение +с причиной. Если максимальное расхождение больше 40 см, настройки не меняются до выбора «Применить». Можно вместо этого открыть ручную подгонку или отменить операцию. diff --git a/docs/VACUUM.md b/docs/VACUUM.md index 0a26f0f6..4c29c154 100644 --- a/docs/VACUUM.md +++ b/docs/VACUUM.md @@ -63,9 +63,52 @@ evidence even when a full entity-registry response has no row. A disabled row still wins. A selected camera without position data gets the XCME attribute hint; arbitrary unselected cameras do not. +## Maps and floors (#162) + +One robot can hold several maps, and each map belongs to one space. The dock +marker never moves: it stays in `marker.space`, while the live puck, the +current trail and the room highlight belong to the space of the map that is +active right now. + +A **route** is one saved answer to "this exact map of this exact source lives +here": `{ id, source, map_id, space, calibration }`. The exact source is part +of the identity, because two cameras can both report `default` and a map id +alone cannot tell two floors apart. + +Route resolution has exactly one answer per frame: + +| Result | What is drawn | When | +|---|---|---| +| `ready` | puck and trails in `route.space` | one route matches, its space exists, matrix is six finite numbers | +| `needs_calibration` | nothing | the matching route has no matrix yet | +| `unmapped` | nothing | the observed map is not assigned to any floor | +| `ambiguous` | nothing | two routes match at once — list order never picks a floor | +| `missing_space` | nothing | the route points at a space that was deleted | +| `none` | nothing | no source reports telemetry | + +Every negative result is fail-closed on purpose: a robot drawn on a guessed +floor makes the plan a false statement about the house. While the robot is +moving, the dock shows an amber `mdi:alert-outline` badge whose accessible name +carries the exact reason. + +Rules that follow from the identity being exact: + +- source and `map_id` of a saved route are immutable; a wrong identity is + deleted and added again, never silently re-pointed; +- changing the target space is a NEW route identity: the matrix was solved + against the old space's geometry and the recorded runs were filed under the + old id, so both are dropped after an explicit confirmation; +- deleting a space removes the routes that pointed at it — the confirmation + states how many — and leaves the dock and the other routes alone; +- `default` stays a valid single-map id, but it is not proof of a stable + multi-floor identity; the editor says so next to such a route. + ## Calibration -The stored transform is a six-number affine matrix per map: +Calibration belongs to the route: the matrix is solved against the rooms of +`route.space`, and the manual fit opens on that space rather than the dock's. + +The legacy transform is a six-number affine matrix per map: `marker.vacuum.calibration[map_id]`. Existing matrices are not migrated. - **Automatic:** at least three room names must match. Robot anchors use @@ -142,12 +185,27 @@ registry subscription is installed. ```text marker.vacuum = { live?, trail?, trail_mode?, source?, - calibration?: { [map_id]: [a,b,c,d,e,f] }, + calibration?: { [map_id]: [a,b,c,d,e,f] }, // legacy-read after #162 + map_routes?: [{ id, source, map_id, space, calibration? }], room_highlight?, segment_map? } ``` -All fields are optional and old plans remain readable. Hiding retains the +All fields are optional and old plans remain readable. Without `map_routes` +every valid `calibration[map_id]` is read as an effective route into the dock's +space, so a plan made before #162 renders byte for byte as before. The first +explicit routing edit converts the whole dictionary at once — all matrices or +none — and needs an exact source to do it; `calibration` is dropped only after +the config write succeeds. Where both exist, `map_routes` is the only +authority and the legacy dictionary takes no part in rendering. + +A stored run carries the route that wrote it: +`{ route_id, source, map_id, started, ended, points }`. A run recorded before +#162 has neither `route_id` nor `source`; it is adopted at read time by the +routes of the same marker whose `map_id` matches, narrowed by the marker's root +`vacuum.source` when that still exists. Exactly one candidate means the run is +drawn in that route's space; zero or more than one means it is drawn nowhere +and left untouched on disk. Hiding retains the configuration. Deleting a vacuum marker removes its layout and server trails, creates the normal removal tombstone and makes the HA device available for a fresh add without resurrecting old runs. The backend reconciles both a removal diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 064cae7f..6620f183 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -381,6 +381,18 @@ const MUTANT_DEFINITIONS = [ replace: ' const kept = routes;', }], }, + { + id: 'vacuum-overlay-back-to-the-dock-space-filter', + guard: 'npm run bundle:sync && node demo/smoke_vacuum_multifloor.mjs', + because: 'the overlay layer must see every robot of the plan, not only those whose DOCK ' + + 'is in the space on screen: the old filter is exactly why a multi-floor robot could ' + + 'never appear on its second floor (#162, AC2)', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' ${this._renderVacuums(this._renderDevices, view, space.id)}', + replace: ' ${this._renderVacuums(devs, view, space.id)}', + }], + }, { id: 'area-snapshot-cleanup-ignores-authority', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '