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

28 KiB
Raw Blame History

ТЗ #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.

На выходе:

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:

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.