docs: specify area-less room device binding

Issue: #170
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-18 09:28:18 +03:00
parent c9a00b2a37
commit 260a994f5f
2 changed files with 258 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
# Issue #170 — HA-устройство не привязывается к комнате без HA-зоны
- Дата: 2026-08-18
- Тип: bug · приоритет P1 · ценность 8/10 · сложность/риск 4/10
- Issue: [#170](https://github.com/Matysh/houseplan-card/issues/170)
- Ветка: `issue/170-room-without-area`
Канонические документы: `docs/SCOPE.md`, `docs/ARCHITECTURE.md`,
`docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`,
`docs/USER-GUIDE.ru.md`, `docs/USER-GUIDE.md`.
## 1. Сценарий и персона
Администратор House Plan создаёт внутри пространства комнату без HA Area —
например, гардеробную или кладовую. В редакторе устройств он назначает этой
комнате уже существующее HA-устройство или отдельную HA-сущность и сохраняет
маркер.
Это штатный сценарий J4/J6: модель плана может быть точнее модели Areas в Home
Assistant, а её сопровождение не должно требовать создания искусственных зон.
Корректная пространственная принадлежность также нужна J1: устройство должно
отображаться там, куда его явно поместил пользователь.
## 2. Что человек увидит до и после
**До исправления:** выбор комнаты без HA-зоны сохраняется, но устройство после
перестроения данных остаётся в пространстве своей HA Area либо исчезает из
ожидаемого пространства. При повторном открытии редактора выбранная комната не
восстанавливается корректно.
**После исправления:** явный выбор комнаты без HA-зоны имеет приоритет над HA
Area устройства. Маркер сразу отображается в выбранном пространстве, а
редактор при следующем открытии показывает ту же комнату. Уже сохранённые
затронутые маркеры восстанавливаются автоматически, без повторного сохранения.
Внешний вид поля «Комната», маркера и плана не меняется.
## 3. Проблема и подтверждённая причина
Сохранение уже записывает корректный контракт:
- `marker.space` — пространство выбранной комнаты;
- `marker.room_id` — id выбранной комнаты;
- `marker.area: null` — у комнаты нет HA Area.
Ошибка возникает при чтении в общем `buildDevices()`. В ветках `device:*` и
`entity:*` effective area вычисляется через `marker.area || registryArea`.
Значимый `null` ошибочно принимается за отсутствие ручного выбора, после чего
HA Area снова определяет и `DevItem.area`, и `DevItem.space`.
Подтверждённое воспроизведение на `origin/dev`: маркер сохранился как
`{space: "garden", area: null, room_id: "<room>"}`, но runtime-проекция стала
`{space: "f1", area: "living_room"}`. Повторное открытие редактора построило
несуществующую пару `f1#@<room>`. Имеющийся `smoke_subarea` не заметил ошибку,
поскольку проверяет только `binding: virtual`.
## 4. Scope
- исправить общую runtime-проекцию ручной комнаты без HA Area;
- одинаково поддержать `device:*` и `entity:*`, включая entity с Area у
родительского HA-устройства;
- корректно отразить назначение в полном плане, `houseplan-space-card` и preview
редактора через существующий общий `buildDevices()`;
- восстановить точное выбранное значение при повторном открытии редактора;
- сохранить действующие правила позиции маркера;
- добавить unit- и browser-smoke-регрессии;
- описать пользовательское исправление в обоих changelog.
## 5. Non-scope
- новый UI, новый способ создания комнат или изменение терминологии;
- автоматическое создание/изменение HA Floors и Areas;
- изменение схемы `markers[]`, версия конфигурации или миграция хранилища;
- новая обработка удалённого/несуществующего `room_id`;
- изменение автоматического размещения устройств без ручной комнаты;
- изменение поведения виртуальных маркеров и комнат с HA Area;
- новые room aggregates, источники температуры/влажности, LQI или правила Glow;
- изменение визуала, touch-, accessibility- или performance-контракта.
## 6. Контракт поведения
### 6.1. Ручная комната без HA Area
Для явного HA-маркера `room_id` является признаком точного назначения в
комнату. Если `room_id` — непустая строка и сохранённый `marker.area` равен
`null`, runtime обязан получить:
- `DevItem.space` из сохранённого `marker.space`;
- `DevItem.area === ""` как существующее runtime-представление отсутствующей
HA Area;
- неизменённый `marker.room_id`.
Registry Area устройства, сущности или её родительского устройства не должна
переопределять эти значения. Правило одинаково для `device:*` и `entity:*`.
### 6.2. Остальные способы размещения
Если точного назначения в комнату без HA Area нет, сохраняется текущая
семантика:
1. явная Area маркера определяет Area и пространство;
2. для старого/metadata-only HA-маркера без явной комнаты применяется Area из
entity/device registry;
3. если Area не разрешается, применяется существующий fallback пространства;
4. `virtual` продолжает использовать свою текущую ветку;
5. удалённые, hidden, disabled, orphaned и unverified binding сохраняют
действующие правила.
Исправление не должно превращать любое `area: null` в ручную комнату: точное
правило активируется только при непустом `room_id`.
### 6.3. Редактор, preview и позиция
- Unsaved preview использует тот же production resolver и показывает маркер в
выбранном пространстве без наследования registry Area.
- После сохранения и повторного открытия поле «Комната» восстанавливает точное
значение `<space>#@<room_id>`.
- При смене комнаты внутри того же пространства существующая позиция маркера
не меняется.
- При переносе в другое пространство действует существующее центрирование по
целевой комнате.
Новый отдельный resolver для полного или статического плана не допускается.
### 6.4. Пространственные потребители
Полный и статический планы получают одинаковые `space`, `area` и `room_id` из
`buildDevices()`. Пустая effective area не должна возвращать устройство в
агрегаты исходной HA Area. Существующий room-id-aware light/Glow resolver может
использовать точную комнату; остальные агрегаты продолжают действовать по
своим текущим Area/source-контрактам.
## 7. Данные, миграция и совместимость
Persisted-формат не меняется. Каноническая запись комнаты без HA Area остаётся
`space: <id>`, `area: null`, `room_id: <id>`; runtime `area: ""` в `DevItem` не
записывается обратно в конфигурацию.
Миграция не нужна: корректные значения уже находятся в существующих
конфигурациях и должны начать читаться правильно сразу после обновления.
Неизвестные sibling-поля маркера сохраняются обычным путём. Старые frontend
после downgrade снова могут проявить исходный дефект, но данные при этом не
требуют новой downgrade-конверсии.
Никаких изменений backend validation, WebSocket API, export/import envelope
или `docs/CONFIG-COMPATIBILITY.md` не требуется.
## 8. i18n, accessibility и touch
Новых строк и переводов нет. Существующие label, focus order, keyboard и touch
targets редактора не меняются. `docs/TOUCH-SUPPORT.md` остаётся без правок.
## 9. Acceptance criteria и доказательства
| AC | Критерий | Обязательное доказательство |
|---|---|---|
| AC1 | `device:*` с registry Area и сохранёнными `space`, `area:null`, `room_id` строится в сохранённом пространстве с `DevItem.area === ""`. | Targeted unit `buildDevices`. |
| AC2 | То же правило работает для `entity:*`, даже если Area приходит от entity registry или родительского device. | Targeted unit с обоими registry-path. |
| AC3 | Старый HA-маркер без `room_id`, ручная комната с HA Area, автоустройство и `virtual` сохраняют текущее размещение. | Негативные/regression unit cases. |
| AC4 | Уже сохранённый маркер комнаты без HA Area корректно читается без resave или migration. | Unit-fixture существующей persisted-записи. |
| AC5 | Сохранение реального HA-binding в комнату без Area переносит runtime-маркер в целевое пространство, а повторное открытие редактора восстанавливает точный выбор. | Расширенный `demo/smoke_subarea.mjs`. |
| AC6 | Перенос между пространствами использует существующее центрирование, а выбор другой комнаты в том же пространстве не двигает расставленный маркер. | Browser-smoke с обеими ветками позиции. |
| AC7 | Полный и статический планы используют единую исправленную проекцию без второго resolver. | Unit общего `buildDevices` и code review shared call sites. |
| AC8 | Изменение проходит рабочие implementation-gates. | `npm run typecheck`, `npm run test:unit`, `npm run build`; targeted browser smoke до code review. |
## 10. План автотестов
### 10.1. Unit
В `test/devices.test.mjs` добавить минимальные fixtures для:
- `device:*`: registry Area конфликтует с ручной area-less комнатой;
- `entity:*`: конфликт с Area самой entity и с Area parent device;
- сохранённой до исправления записи без миграции;
- маркера без `room_id`, который обязан продолжить registry fallback;
- комнаты с HA Area, автоустройства и virtual marker.
Проверять одновременно `space`, `area` и сохранение `marker.room_id`, чтобы
частичное исправление не прошло незамеченным.
### 10.2. Browser smoke
Расширить `demo/smoke_subarea.mjs`, не удаляя существующий virtual-сценарий:
1. открыть реальный HA device/entity, исходно принадлежащий Area другого
пространства;
2. выбрать комнату без Area и сохранить;
3. проверить persisted `space/area/room_id` и runtime `space/area`;
4. повторно открыть редактор и проверить точное значение выбора;
5. доказать центрирование при межпространственном переносе;
6. доказать сохранение позиции при смене комнаты внутри пространства.
Golden/screenshot не нужен: пиксельный контракт не меняется. Полный smoke,
golden и performance остаются общими пред-бета gates по runbook.
### 10.3. Executable mutation gate
Тесты обязаны падать хотя бы при следующих намеренных мутациях:
- возврат `marker.area || registryArea` в device-ветке;
- исправление только `device:*`, без `entity:*`;
- применение area-less-семантики ко всем `area:null`, без проверки `room_id`;
- построение reopened room через registry-derived space;
- центрирование уже расставленного маркера при смене комнаты в том же
пространстве.
## 11. Риски и меры
| Риск | Мера |
|---|---|
| Сломан registry fallback старых явных маркеров без ручной комнаты. | Отдельный negative unit без `room_id`. |
| Исправлена device-, но не entity-ветка. | Симметричные AC1/AC2 и mutation gate. |
| Пространство исправлено, но registry Area продолжает влиять на room aggregates. | В AC1/AC2 обязательно проверять и `area === ""`. |
| Полный и статический планы расходятся. | Исправлять общий `buildDevices`, не добавлять renderer-specific resolver. |
| Исправление случайно меняет позицию существующего маркера. | Две browser-проверки same-space/cross-space. |
| Начинается неоговорённая очистка stale `room_id`. | Stale/deleted room явно оставлен в non-scope. |
Performance-риск пренебрежимо мал: добавляется только константная проверка
полей на явный marker. Новых DOM-узлов, таймеров, подписок, сетевых запросов и
persisted writes нет. Security/privacy boundary не меняется.
## 12. Rollback
Исправление можно откатить одним frontend-коммитом: persisted-схема и backend
не меняются. Откат возвращает исходный баг чтения, но не требует восстановления
данных. Перед откатом оставить новые тесты как описание известного контракта
либо явно откатить их вместе с поведением; частичный откат resolver без тестов
не допускается.
## 13. Release-артефакты
- пользовательские записи в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том
же implementation-коммите (`User-Visible: yes`);
- `docs/USER-GUIDE.ru.md` и `docs/USER-GUIDE.md` уже описывают требуемое
поведение, поэтому смысловая правка не нужна; при реализации проверить, что
формулировки не устарели;
- обновлённые unit и `demo/smoke_subarea.mjs`;
- синхронные build-артефакты по действующему D-контракту;
- screenshots/golden не требуются;
- отдельные performance/security artifacts не требуются;
- перед бетой выполняются общие golden, smoke и performance gates; Linux CI
остаётся каноном полного HA harness.
## 14. Принятые предположения
1. Непустой `room_id` вместе с `area:null` однозначно означает намеренное
назначение в комнату без HA Area; это уже записывает текущий редактор.
2. `marker.space` у такой сохранённой записи валиден. Если он отсутствует или
указывает на удалённое пространство, применяется текущий безопасный fallback
без новой диагностики или автоматической очистки.
3. Текущая политика позиции — сохранить координаты внутри того же пространства
и центрировать только при смене пространства — является продуктовым
контрактом и не пересматривается этой задачей.
4. Исправление должно восстанавливать уже сохранённые записи только чтением;
фоновая миграция и принудительный resave не допускаются.
5. Вопросов владельцу нет: ожидаемое поведение уже закреплено в issue и
пользовательском руководстве.
+1
View File
@@ -52,6 +52,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#164](https://github.com/Matysh/houseplan-card/issues/164) Активный цикл стиральной машины должен быть жёлтым | [164-washer-active-cycle.md](164-washer-active-cycle.md) |
| [#166](https://github.com/Matysh/houseplan-card/issues/166) Солнечные лучи зеркально учитывают направление севера | [166-sun-north-rotation.md](166-sun-north-rotation.md) |
| [#167](https://github.com/Matysh/houseplan-card/issues/167) Экспорт «только планировка» | [167-plan-only-export.md](167-plan-only-export.md) |
| [#170](https://github.com/Matysh/houseplan-card/issues/170) HA-устройство не привязывается к комнате без HA-зоны | [170-room-without-area.md](170-room-without-area.md) |
## P2