Files
houseplan-card/docs/specs/126-ha-area-marker-relocation.md
T
2026-08-31 09:45:53 +03:00

490 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #126 — HA Area переносит marker в новую комнату
- **Issue:** https://github.com/Matysh/houseplan-card/issues/126
- **Статус документа:** принято на `dev@4683a493cca25a4a527f2b96d3d11723aa9da74c`, реализуется
- **Приоритет:** P1
- **Тип:** bug, полный трек
- **Пользовательское изменение:** да
## 1. Продуктовый сценарий
Персона — Home admin; поверхность — View, Devices editor и hosted Static; момент —
после изменения Area устройства или standalone entity средствами Home Assistant.
Администратор меняет Area в HA. Если принадлежность marker комнате не
переопределена в House Plan, объект должен автоматически оказаться в автосетке
комнаты, связанной с новой Area, и получить существующую красную метку
«требует внимания».
До: сохранённая ручная позиция удерживает marker в прежней комнате, если старая
и новая комнаты находятся в одном пространстве. После: authoritative HA Area
перебивает сохранённую раскладку, и все View-поверхности показывают marker в
новой комнате.
Задача закрывает J6 из `docs/SCOPE.md`: план не должен продолжать показывать
устройство там, где его больше нет.
## 2. Подтверждённая причина на текущем dev
`buildDevices()` уже пересчитывает `DevItem.area` и `DevItem.space` из registry.
Однако `_livePos()` принимает сохранённую запись layout, пока совпадает только
`saved.s === device.space`. Комната/Area в layout не записана. Поэтому:
- между разными spaces сохранённая позиция уже перестаёт применяться;
- между комнатами одного space старая позиция продолжает побеждать новую
`_defaultPositions()`;
- `_syncNewDevices()` сравнивает только identity устройства, поэтому Area
transition не добавляет `new_device_ids`.
Hosted Static повторяет дефект через `space-geometry.ts::markerPos()`.
## 3. Решения владельца
1. Сохранённая после drag позиция — раскладка, а не принадлежность; смена HA Area
её перебивает.
2. Явные `marker.area` и `marker.room_id` всегда побеждают registry Area.
3. Новое место выбирает обычная автосетка комнаты, не строгий центр.
4. Используется существующая красная метка `new_device_ids`, без нового вида
badge или уведомления.
5. Охвачены direct device и entity markers, если effective room получена из HA
registry. Автоматические composite light groups не переносятся по Area одного
участника.
## 4. Выпущенные соседние контракты
### #29 — каталог и lifecycle устройств
- `settings.new_device_ids` остаётся persisted источником красной метки;
- переехавший marker остаётся в категории «На плане» и получает признак «Новое»;
- открытие каталога и Find не подтверждают метку, открытие Device editor —
подтверждает существующим путём;
- hide/remove/HA-disabled semantics не меняются.
### #74 — position-only Undo/Redo
- registry relocation не создаёт history command;
- pending relocation отменяет активный drag этого marker;
- position history очищается, чтобы Undo не воскресил принятую старую позицию;
- authoritative layout event другого клиента продолжает очищать history
существующим механизмом.
### #317 — effective room placement
Явное назначение определяется действующим правилом:
- непустой `marker.area` — explicit Area target;
- `marker.area == null` вместе с непустыми `marker.space` и `marker.room_id` —
explicit local-room target;
- иначе direct device/entity marker следует registry Area.
Тот же смысл применяется к визуальному размещению и room-climate membership.
Нельзя вводить вторую несовместимую трактовку `area/space/room_id`.
## 5. Scope
- детерминированное обнаружение authoritative direct registry Area transition;
- немедленное runtime-игнорирование stale saved position во всех View;
- удаление stale layout первым клиентом с write permission;
- placement через существующую общую автосетку target room;
- existing red `new_device_ids` attention и обычный acknowledgement;
- live registry refresh, cold start, reload и multi-client config/layout events;
- безопасный one-time backfill старых configs без provenance;
- device/entity binding, explicit override и composite-group exclusions;
- frontend/backend schema, import/export compatibility, unit/backend/smoke/docs.
## 6. Не входит в задачу
- управление HA Area из House Plan;
- перенос Area самой комнаты или изменение room↔Area binding;
- новый badge, toast, history, notification либо отдельная категория каталога;
- Undo/Redo внешнего registry change;
- автоперенос automatic/composite light groups;
- восстановление старой ручной позиции после переезда;
- изменение `marker.area`, `marker.room_id` или layout при временно
неавторитетном registry snapshot;
- lifecycle устройств без Area либо с Area, не привязанной ровно к одной комнате
плана — это действующий scope #29.
## 7. Единый pure resolver
Вводится provider-neutral pure-модуль (рабочее имя
`src/device-area-relocation.ts`). Он не импортирует Lit, не вызывает HA и не
пишет stores. На вход получает:
- полный список текущих `DevItem`;
- current model всех spaces/rooms;
- layout snapshot;
- persisted Area provenance;
- признак authoritative registry snapshot.
На выходе:
```ts
type MarkerAreaProvenance = {
binding: `device:${string}` | `entity:${string}`;
area: string;
};
type AreaRelocationDecision = {
markerId: string;
binding: MarkerAreaProvenance['binding'];
currentArea: string;
targetSpaceId: string;
targetRoomId: string;
kind: 'baseline' | 'relocate' | 'defer';
reason:
| 'unchanged'
| 'new-without-layout'
| 'backfill-same-room'
| 'backfill-stale-room'
| 'backfill-cross-space'
| 'backfill-ambiguous'
| 'area-changed'
| 'target-unresolved'
| 'registry-unverified';
};
```
Exact названия можно изменить без изменения semantics. Один и тот же resolver
используют interactive card, hosted Static, persistence coordinator и tests.
## 8. Eligibility и источник Area
Eligible:
1. markerless auto device с `bindingKind:'device'`;
2. saved direct `device:` marker без explicit placement;
3. saved direct `entity:` marker без explicit placement, включая standalone
entity и entity с parent device;
4. HA-disabled saved direct marker, если полный authoritative registry всё ещё
содержит exact binding и Area: при реактивации он должен появиться правильно.
Ineligible:
- virtual marker;
- marker с explicit placement по §4;
- markerless auto `entity:` light group;
- composite/fold/group, чья Area выведена не из exact direct binding;
- removed marker;
- orphaned/unverified binding;
- binding без непустой direct registry Area.
Direct entity Area = `entity.area_id || parentDevice.area_id`. Direct device
Area = `device.area_id`. Friendly name, icon, model, current coordinates и id
prefix не являются доказательством принадлежности.
`bindingKind: 'entity'` сам по себе недостаточен: такой же kind сейчас имеют
автоматические light groups. Direct entity marker подтверждается наличием
сохранённого `marker` с exact `binding === entity:${bindingRef}`. Markerless
`lg_*`/composite item исключается независимо от Area участника; prefix служит
только defensive check, не основным discriminator.
Area должна разрешаться **ровно в одну** room текущего model. Дубликат Area у
нескольких комнат считается ambiguous: решение `defer`, position и provenance
не меняются.
## 9. Persisted provenance
Config settings получает optional internal map:
```ts
settings.marker_area_snapshot?: Record<string, {
binding: `device:${string}` | `entity:${string}`;
area: string;
}>;
```
Ключ — runtime marker id: HA device id у auto device либо `marker.id` у saved
marker. Exact binding хранится обязательно. Без него rebind того же marker id к
другому устройству ошибочно выглядел бы как HA Area transition.
Контракт поля:
- это bounded lifecycle metadata, не пользовательская настройка;
- вводится самостоятельный backend limit
`MAX_MARKER_AREA_SNAPSHOT = MAX_KNOWN_DEVICES = 20_000` entries; дополнительно
действует общий `MAX_CONFIG_BYTES = 2 MiB`;
- entries имеют только bounded strings `binding` и `area`;
- запись с другим binding считается новым baseline/backfill, не relocation;
- explicit/ineligible marker очищает собственную старую entry;
- отсутствующий в runtime marker не очищается только из-за active-only roster:
authoritative full registry/marker lifecycle решает, существует ли binding;
- deleted/rebound/nonexistent exact binding очищается действующей lifecycle
hygiene;
- layout v2 не расширяется: provenance принадлежности не дублируется в
координатах.
## 10. Runtime-проекция до persistence
После каждого authoritative registry rebuild resolver вычисляет множество
`relocate`.
- `_livePos()` interactive card и `markerPos()` hosted Static игнорируют saved
layout для этих ids и используют existing default position target room;
- interactive effective attention = persisted `new_device_ids` плюс pending
relocate ids. Это не второй persisted источник, а проекция ещё не
подтверждённого перехода;
- read-only клиент показывает правдивое новое положение, но не вызывает
config/layout writes и не получает дополнительных прав;
- первый клиент с `_canEdit === true` запускает persistence coordinator;
- ordinary HA state ticks и повторный идентичный registry fingerprint не
создают новую работу.
Таким образом медленный backend или отсутствие admin-card не заставляют View
показывать marker в заведомо старой комнате.
## 11. Обработка подтверждённой смены Area
Для entry с тем же binding:
1. `previous.area === currentArea` → `baseline/unchanged`, no-op;
2. current Area не разрешается ровно в одну room → `defer`, snapshot не
продвигается;
3. Area изменилась → `relocate` независимо от сохранённой drag position;
4. отменить active drag marker и очистить position history;
5. удалить saved layout через `houseplan/layout/delete`;
6. после успешного либо уже идемпотентного delete добавить id в
`new_device_ids` и записать provenance `{binding,currentArea}`;
7. отправить одну сериализованную config mutation для batch текущего rebuild.
Порядок fail-safe: layout delete раньше продвижения provenance. Если config write
после delete падает, старый snapshot остаётся на сервере; следующий
authoritative pass повторит idempotent delete и attention write. Нельзя признать
Area обработанной, оставив серверную старую позицию.
Пока persist выполняется, in-flight ids дедуплицируются. Toggle space, hover,
render и второй идентичный registry callback не создают parallel delete/config
loop.
## 12. Initial backfill старых configs
Если entry отсутствует либо binding в ней отличается:
- saved layout отсутствует → записать baseline без attention;
- `saved.s` указывает другое пространство, чем target room → удалить stale
position и добавить attention;
- saved point строго и однозначно лежит внутри target room → сохранить position
и записать baseline;
- saved point строго и однозначно лежит внутри другой room с другой непустой HA
Area → считать существующим stale case, удалить position, автопоставить в
target room и добавить attention;
- source room без HA Area, point вне rooms, на стене/границе нескольких rooms
либо при invalid geometry → сохранить position и записать baseline; историю
без доказательства не угадывать.
Point-in-room использует canonical final room polygons в plan coordinates и
отдельно отсекает boundary. Decor/device bounds и wall body не участвуют.
Backfill выполняется одним bounded pass и одним config write. Повторный reload
видит provenance и не превращает inference в постоянный auto-layout.
## 13. Автосетка и attention
Удаление saved position возвращает marker в тот же `defaultPositions()` path,
который уже используется для новых устройств и cross-space Area placement.
Несколько marker, переехавших одновременно, раскладываются общим
детерминированным pass и не накладываются в центре.
`new_device_ids` переиспользуется без смены UI/формата:
- badge, каталог, фильтрация и acknowledgement остаются как в #29;
- duplicate id не добавляются;
- acknowledgement не возвращает старую position и не меняет provenance;
- external relocation не попадает в history #74;
- explicit hidden marker сохраняет user-hidden состояние; relocation не
превращается в Show;
- HA-disabled marker остаётся service ghost/hidden по действующему контракту,
но после реактивации использует уже новую комнату.
## 14. Multi-client, revisions и races
- Клиенты вычисляют одинаковую runtime-проекцию из общего config/registry.
- Первый writer сериализованно удаляет layout и обновляет config; остальные
принимают обычные revision events.
- Concurrent drag отменяется при local transition. Старый remote drag write не
должен воскресить position после принятого delete: существующие layout
revision/resync и pending delete authority остаются обязательными.
- Если explicit override сохранён до relocation commit, свежий config rebuild
делает marker ineligible и отменяет auto mutation.
- Rebind того же marker id не является Area transition из-за binding в
provenance; применяется backfill нового binding.
- Временный limited/unverified registry не создаёт отрицательного вывода,
cleanup, move или write.
- Ошибка layout delete оставляет provenance старым и состояние retryable без
timer loop; runtime View по-прежнему может показывать безопасную новую
default position.
## 15. UX, touch и accessibility
Новых controls, dialogs и строк нет. Marker визуально появляется в target room
с существующей красной меткой. Focus не переносится, dialog не открывается.
Если Device dialog этого marker открыт в момент authoritative transition, его
не требуется принудительно закрывать или перехватывать focus. При Save dialog
обязан заново разрешить binding по актуальному registry: несвязанные изменения
сохраняются, но stale room draft не может вернуть marker в прежнюю комнату или
создать explicit override. Registry-following placement остаётся authoritative.
View и kiosk на touch обязаны показать то же положение, что desktop View.
Devices editor остаётся desktop-first, но не может отображать старую позицию
после authoritative transition.
## 16. Model, import/export и compatibility
- Добавляется только optional `settings.marker_area_snapshot`.
- Model/Store version не повышается.
- Backend schema валидирует структуру, lengths и общий config budget.
- Config field регистрируется в `scripts/config-field-registry.mjs` и
`docs/CONFIG-COMPATIBILITY.md`.
- Same-source full backup сохраняет snapshot вместе с lifecycle metadata.
- Full import из другого source/HA удаляет `marker_area_snapshot` вместе с
`known_devices/new_device_ids`: чужие HA ids/Areas не являются provenance
нового дома и должны пройти backfill.
- Space-only import не переносит глобальный snapshot из документа.
- Старый frontend игнорирует unknown setting; новый читает отсутствие поля как
backfill, не как пустую историю смен Area.
- Backend schema отклоняет новую запись/import с невалидной map или entry.
Defensive frontend, если такие данные всё же попали из старого/внешнего
источника в обход schema, игнорирует только повреждённую entry и применяет к
её marker безопасный backfill; остальные entries продолжают работать.
## 17. i18n
Новых строк нет. Используются существующие badge, catalog labels и save-error
toast. Если реализация добавляет «Устройство переехало», отдельный toast или
новый badge, это выходит за принятый scope.
## 18. Acceptance criteria
1. **AC1 — same-space relocation.** Direct-registry device с saved layout после
Area A→B в том же space игнорирует и удаляет saved position, попадает в
автосетку room B и получает existing red badge. Доказательство: unit +
production-bundle smoke.
2. **AC2 — cross-space parity.** Тот же outcome действует между spaces; старая
layout entry удаляется, а не остаётся orphan. Доказательство: unit + smoke.
3. **AC3 — immediate/read-only/static truth.** До persistence interactive
read-only View и hosted Static игнорируют stale position; writes выполняет
только writer. Доказательство: pure unit + hosted/full render smoke.
4. **AC4 — explicit wins.** `marker.room_id`/`marker.area` сохраняют room и
saved position при HA Area change. Доказательство: authority matrix.
5. **AC5 — binding scope.** Auto device и direct device/entity markers
переносятся; composite light group и virtual marker — нет. HA-disabled saved
marker корректно появляется после re-enable. Доказательство: unit matrix.
6. **AC6 — safe backfill.** Cross-space и однозначная другая HA room
исправляются; same-room/outside/boundary/area-less/ambiguous сохраняются.
Доказательство: unit + cold-start smoke.
7. **AC7 — attention lifecycle.** Existing `new_device_ids` badge ставится один
раз, отражается каталогом и очищается обычным acknowledgement без нового
state kind. Доказательство: unit + browser smoke.
8. **AC8 — rebind provenance.** Тот же marker id с другим exact binding не
считается Area transition. Доказательство: unit.
9. **AC9 — fail-safe writes.** Snapshot не продвигается при failed layout
delete; failed config write повторяется после следующего authoritative pass;
concurrent revision не воскрешает stale position. Доказательство:
frontend/backend failure tests.
10. **AC10 — no Undo.** Relocation не создаёт command, отменяет active drag и
очищает stale position stack. Доказательство: history unit/source contract.
11. **AC11 — bounded/no-loop.** No-change registry rebuild даёт zero writes;
simultaneous transitions batch config mutation; in-flight work deduped.
Доказательство: counter/performance unit.
12. **AC12 — compatibility.** Backend round-trip, same/cross-source import и
config-field audit выполняют §16. Доказательство: backend tests + registry
audit.
## 19. Обязательные тесты
### Unit
- placement authority: auto device/direct device/direct entity/explicit local
room/explicit Area/composite/virtual/removed/disabled/unverified;
- previous=same/different/missing/binding-changed;
- same-space/cross-space/unresolved/duplicate Area targets;
- backfill point: target/other HA room/area-less/outside/boundary/ambiguous;
- deterministic multi-marker default grid;
- effective pending attention, dedupe/acknowledge;
- no history command, active drag cancellation, no write on identical
authoritative fingerprint;
- mutation/failure ordering and cleanup bounds.
### Backend
- optional structured map validation, string/entry/config limits;
- config round-trip and unknown siblings;
- same-source preserve, cross-source strip, space-only import isolation;
- layout delete idempotence/revision event and conflict path.
### Browser smoke
Новый targeted smoke `demo/smoke_area_relocation.mjs` либо эквивалент обязан
проверить production bundle:
- live Area A→B одного space после drag;
- cross-space;
- standalone entity marker;
- explicit room override и composite exclusion;
- cold-start stale backfill;
- existing red badge/acknowledgement;
- hosted Static и read-only projection;
- no config/layout call при unchanged rebuild.
Golden baseline не меняется: новый визуальный язык не вводится. Полный golden и
performance остаются pre-beta gates.
Performance budget: resolver запускается только на authoritative registry/model
rebuild, имеет линейную сложность по markers + rooms, не вызывается из render и
при unchanged input не создаёт layout/config writes.
## 20. Затронутые поверхности
- новый pure resolver `src/device-area-relocation.ts`;
- registry/device rebuild, effective attention и persistence coordinator в
`src/houseplan-card.ts`;
- shared `markerPos()`/static render в `src/space-geometry.ts` и
`src/space-render.ts`;
- `ServerConfig` types, backend validation и import/export;
- config-field registry, unit/backend/browser fixtures;
- `docs/ARCHITECTURE.md`, `docs/FILTERING.md`,
`docs/CONFIG-COMPATIBILITY.md`, `docs/USER-GUIDE.md`,
`docs/USER-GUIDE.ru.md`, `docs/TESTING.md`, оба changelog.
## 21. Риски и откат
| Риск | Мера |
|---|---|
| Rebind принят за Area change | provenance хранит exact binding |
| Старый manual layout ошибочно принят за stale | backfill только при cross-space либо одной другой HA room |
| Read-only продолжает показывать старую room | shared runtime projection игнорирует stale layout до persistence |
| Config признал transition до layout | delete-first ordering |
| Undo/remote drag воскресил position | cancel/clear history + pending delete/revision authority |
| Composite group прыгает по Area участника | direct-binding eligibility |
| Registry временно пуст | authoritative snapshot guard |
| Несколько clients пишут одно и то же | idempotent delete, serialized config, revision reload, in-flight dedupe |
Откат прекращает вычислять runtime relocations и читать/писать snapshot map,
возвращая saved-layout priority. Optional metadata можно оставить ignored;
destructive rollback и model downgrade не нужны.
## 22. Release-артефакты
Implementation commit имеет `User-Visible: yes` и одновременно обновляет:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- EN/RU user guides — registry Area, explicit House Plan room и red badge;
- architecture/filtering/config-compatibility/testing docs;
- targeted smoke contract и exact local gate evidence в issue.
## 23. Принятые технические предположения
- provenance — bounded structured map в global settings, не layout metadata;
- самостоятельный предел snapshot — 20 000 entries плюс общий wire/config limit
2 MiB;
- exact binding входит в entry и защищает rebind;
- direct entity отличается от composite group по exact persisted marker binding,
а не только по `bindingKind` или префиксу id;
- pending relocation является derived runtime projection, а не вторым store;
- writer batches decisions одного authoritative rebuild;
- layout delete предшествует provenance commit;
- cross-source import удаляет lifecycle provenance;
- old-layout repair использует только строгий однозначный point-in-room;
- точные helper/type names можно изменить на review без изменения semantics.