docs: specify opening references after marker deletion

Issue: #104
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-13 13:11:57 +03:00
parent df5be154ee
commit c585f0268d
@@ -0,0 +1,370 @@
# Issue #104 — HA-привязка проёма после удаления маркера
- **Issue:** https://github.com/Matysh/houseplan-card/issues/104
- **Статус:** первая редакция ТЗ; требуется независимое ревью
- **Тип / приоритет:** bug / P2
- **Оценка:** пользовательская ценность 8/10; сложность и риск 5/10
- **Область:** настройки проёма в Plan, состояние проёма и замка в View/киоске,
политика доступности HA-привязок, документация и тесты
- **Модель данных:** без изменений и миграции
- **Связано:** #98, `docs/FILTERING.md`, `docs/USER-GUIDE.ru.md`,
`docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`
## 1. Продуктовый контекст
**Персона:** пользователь Home Assistant, который убирает с плана лишний
самостоятельный маркер датчика или замка, но продолжает использовать ту же
сущность как часть двери, окна или ворот.
**Поверхности и момент:** Plan → свойства проёма при выборе датчика/замка; затем
View или киоск, когда House Plan показывает фактическое состояние проёма и
пользователь открывает карточку его замка.
**До → после, без технических терминов:** сейчас после удаления отдельного
значка датчик исчезает из настроек, а дверь перестаёт показывать состояние;
после исправления датчик остаётся частью двери, хотя отдельного значка на плане
по-прежнему нет.
Это поддерживает основные jobs продукта:
- **J2:** с одного взгляда понимать, что открыто и что не заперто;
- **J6:** сохранять правдивый план при изменении состава устройств и маркеров;
- **J3:** выполнять действие с замком только через уже существующий явный и
защищённый control карточки проёма.
## 2. Проблема и подтверждённая причина
Удаление маркера сохраняет минимальный `marker.removed = true` tombstone для
`entity:*` либо `device:*`. Это правильно запрещает повторное автоматическое
появление маркера и исключает его из агрегатов и живых источников плана.
Однако тот же фильтр сейчас применяется к отдельным полям проёма:
- `_contactCandidates()` и `_lockCandidates()` используют
`_planEntityAvailable()`;
- `_openingAmt()`, `_renderOpenings()` и `_renderOpeningLocks()` используют
`_renderEntityAvailable()`;
- карточка проёма и `_lockAction()` снова используют
`_planEntityAvailable()`.
Обе проверки вызывают `isRemovedPlanEntity()`. Поэтому tombstone, который
описывает отсутствие самостоятельного маркера, ошибочно выключает явно
сохранённые `opening.contact` и `opening.lock`.
При этом immutable render snapshot уже захватывает `contact` и `lock` каждого
проёма. Дефект находится в политике доступности, а не в формате конфигурации или
доставке HA state в кадр.
## 3. Скоуп
В задачу входят:
1. отдельная политика доступности явной HA-ссылки проёма;
2. списки контактных датчиков и замков в свойствах проёма;
3. анимация/тон двери, окна или ворот по контактному датчику;
4. бейдж замка, карточка проёма и единственная разрешённая кнопка
lock/unlock;
5. случаи entity-tombstone и device-tombstone;
6. сохранение текущей семантики HA-disabled, orphaned, limited registry и
`unavailable` state;
7. unit-тесты, целевой browser smoke и актуализация пользовательского контракта.
## 4. Не входит в задачу
- восстановление, повторное создание или перемещение удалённого маркера;
- изменение `removed` tombstone, списка **Добавить** или повторного добавления;
- ослабление фильтрации LQI, температуры, влажности, света, Glow, live text,
controls, vacuum и других plan-level contributions;
- новый picker, поиск, группировка, предупреждения или подписи в диалоге;
- новые состояния, цвета, иконки, анимации или геометрия проёмов;
- изменение правил lock/unlock, confirmation или разрешение действия по тапу
на маркер/сам проём;
- изменение `houseplan-space-card`: статическая карточка сейчас проёмы не
рисует;
- backend, schema version, import/export и миграция сохранённых данных.
## 5. Нормативная модель доступности
Нужно различать три понятия.
### 5.1. Plan contribution
Самостоятельный маркер и производные плана доступны только при выполнении
нынешнего контракта `_planEntityAvailable()` / `_renderEntityAvailable()`.
Entity- или device-tombstone продолжает их выключать.
### 5.2. Explicit opening reference
`opening.contact` и `opening.lock` — точные ссылки на HA entity, а не ссылка на
маркер. Для них доступность определяется HA binding status точного
`entity:<entity_id>` и **не зависит** от tombstone маркера.
Ссылка доступна, только если `resolveHaBindingStatus(...).kind === 'active'`:
| HA-ситуация | Кандидат | Сохранённая связь и runtime |
|---|---:|---:|
| Активная entity, маркер не удалён | да | работает |
| Активная entity, удалён `entity:*` marker | да | работает |
| Активная entity, удалён parent `device:*` marker | да | работает |
| Активная registry entity со state `unavailable`/`unknown` | да | сохраняется; показывается существующее неизвестное состояние |
| Точный live YAML entity без registry row | да | работает по действующему HA binding contract |
| Entity или parent device имеет `disabled_by` | нет | конфиг не стирается, runtime не действует |
| Authoritative registry подтверждает missing entity/parent | нет | конфиг не стирается, runtime не действует |
| Limited registry, есть точный live state | да | работает как подтверждённая active ссылка |
| Limited registry, остался authoritative cached disabled | нет | runtime не действует |
| Limited registry без положительного свидетельства (`unverified`) | нет | конфиг не стирается, runtime не действует |
`unavailable` здесь означает буквальное HA state существующей активной entity,
а не `disabled_by`. Для двери/окна и карточки используется уже существующее
представление unknown; outage не должен изображать ложное движение.
### 5.3. Render-frame availability
Отрисовка проёма читает только immutable active-registry projection текущего
видимого кадра. Эта projection уже исключает HA-disabled и authoritative
orphaned entries, но не применяет marker tombstone к явным ссылкам проёма.
Render-проверка должна требовать frozen state точной entity. Registry-less live
entity допустима, поскольку `activeRegistryHass()` уже считает точный live state
положительным свидетельством и сохраняет его в projection.
## 6. Контракт поведения
### 6.1. Выбор в Plan
1. Контактный список по-прежнему содержит подходящие `binary_sensor` и только
door/window/opening/garage-like `cover`; текущая сортировка и friendly name
не меняются.
2. Список замков по-прежнему содержит только `lock.*` и сортируется по friendly
name.
3. Активная подходящая entity присутствует в списке независимо от tombstone
собственного маркера или parent device marker.
4. Выбор записывает только `opening.contact` либо `opening.lock`. Tombstone,
markers, layout и другие поля не меняются.
5. Один contact/lock можно выбрать у нескольких проёмов; изменения одного
проёма не меняют остальные.
### 6.2. Уже сохранённая связь
Если contact/lock был выбран до удаления маркера, удаление маркера не меняет
поле проёма и не выключает его. Если связь выбрана после удаления, результат
тот же.
Повторное добавление самостоятельного маркера позже не создаёт вторую связь,
не переписывает проём и не меняет его состояние: marker и opening reference —
два независимых потребителя одной HA entity.
### 6.3. View и киоск
- contact управляет существующей анимацией и active tone только своего проёма;
- `unavailable`, `unknown` и отсутствие frozen state используют существующий
unknown/default визуальный контракт, не ложное открытие/закрытие;
- lock показывает существующий locked/unlocked/unknown badge и строку в
карточке проёма;
- явная кнопка карточки может вызвать lock/unlock только после повторной live
проверки explicit opening reference;
- unlock по-прежнему требует confirmation, lock — нет;
- сам проём, маркер устройства и обычные controls не получают права управлять
замком.
### 6.4. Удалённый маркер
Удалённый маркер остаётся удалённым и доступным для повторного добавления по
нынешнему контракту. Он не рисуется, не участвует в агрегатах, live text,
controls, Glow или других plan-level contributions. Исключение относится только
к точным полям `opening.contact` и `opening.lock`.
## 7. Архитектурный контракт реализации
Конкретные имена приватных методов могут отличаться, но граница должна быть
явной и тестируемой.
1. Ввести pure policy/helper для exact opening entity, который использует
`resolveHaBindingStatus()` и не принимает marker tombstones.
2. В full card разделить:
- live availability для picker и service action;
- render availability для frozen active projection.
3. Перевести на новую политику только:
- `_contactCandidates()`;
- `_lockCandidates()`;
- `_openingAmt()` и active tone `_renderOpenings()`;
- `_renderOpeningLocks()`;
- `_renderOpeningInfoCard()`;
- `_lockAction()`.
4. Не менять `_planEntityAvailable()` и `_renderEntityAvailable()` и не
расширять список их plan-level consumers.
5. Не читать live `this.hass.states` в SVG-отрисовке вместо
`_renderPlanHass`: atomic frame / reconnect continuity остаётся обязательной.
6. Candidate path не должен инициировать registry fetch на каждую entity;
используется уже существующий shared snapshot и resolver.
Предполагаемые файлы реализации:
- `src/houseplan-card.ts`;
- `src/ha-binding-status.ts` либо небольшой отдельный pure module для policy;
- unit-тест policy и матрицы статусов;
- `demo/smoke_opening_binding.mjs` либо эквивалентный узкий smoke;
- документы и два changelog из раздела 12.
## 8. Модель данных, compatibility и миграция
Формат не меняется:
```ts
interface OpeningCfg {
contact?: string | null;
lock?: string | null;
}
```
- существующие exact entity IDs читаются без преобразования;
- tombstones не удаляются и не меняют форму;
- открытие/сохранение диалога без пользовательского изменения не должно
очистить временно недоступную сохранённую ссылку;
- schema version, backend validation и import/export не меняются;
- прямой и обратной миграции нет.
## 9. UX, i18n и accessibility
Новых элементов интерфейса, текстов и i18n-ключей нет. Сохраняются текущие
labels, сортировка, keyboard/native select semantics, dialog focus и a11y names.
На touch новый жест не вводится. View и киоск восстанавливают существующую
индикацию. Единственное действие замка остаётся крупной подписанной кнопкой
внутри открытой карточки; confirmation для unlock обязательно. Редактор Plan
остаётся desktop-first по `docs/TOUCH-SUPPORT.md`.
## 10. Критерии приёмки
- **AC1 (`unit` + `smoke`):** активный подходящий contact и активный lock
присутствуют в picker после удаления их exact `entity:*` marker.
- **AC2 (`unit` + `smoke`):** те же entity присутствуют и работают после
удаления `device:*` marker их parent device.
- **AC3 (`smoke`):** contact/lock, выбранные до удаления маркера, продолжают
управлять анимацией, badge и карточкой проёма; выбранные после удаления дают
тот же результат.
- **AC4 (`unit` + `smoke`):** выбор contact/lock не снимает tombstone, не создаёт
marker/layout и не возвращает entity в LQI, климат, свет, Glow, live text или
controls.
- **AC5 (`smoke`):** повторное добавление marker не дублирует и не изменяет
`opening.contact` / `opening.lock`.
- **AC6 (`unit` + `smoke`):** одна entity может обслуживать несколько проёмов;
изменение/очистка одного поля не меняет остальные.
- **AC7 (`unit` + `smoke`):** entity-disabled, parent-device-disabled,
authoritative entity-missing и parent-missing не появляются как новые
кандидаты и не выполняют runtime/action; сохранённые строки не стираются.
- **AC8 (`unit` + `smoke`):** active registry entity со state `unavailable` или
`unknown` остаётся выбранной и показывает существующий unknown state без
ложного движения и service call.
- **AC9 (`unit` + `smoke`):** live YAML entity и limited-registry entity с
точным live state доступны; `unverified` и cached-disabled — недоступны.
- **AC10 (`smoke` + ревью кода):** lock/unlock возможен только из карточки
проёма; unlock требует confirmation; stale/disabled/orphaned lock не вызывает
service.
- **AC11 (`unit` + ревью кода):** `_planEntityAvailable()` и все потребители
plan-level tombstone сохраняют прежнее поведение.
- **AC12 (`unit` + `build`):** typecheck, полный unit suite и production build
зелёные; три bundle snapshot побайтно совпадают.
- **AC13 (ревью кода):** пользовательская документация и RU/EN changelog точно
описывают exception для explicit opening references и не обещают
восстановления marker.
## 11. План автотестов
### 11.1. Unit
Pure matrix должна покрыть:
1. active exact entity при отсутствии tombstone-контекста;
2. entity-disabled, parent-disabled, entity-missing и parent-missing;
3. `unavailable`/`unknown` как active binding с неизвестным state;
4. registry-less live YAML entity;
5. limited live, limited unverified и cached disabled;
6. render projection: exact frozen state принимается без требования marker;
7. regression: `isRemovedPlanEntity()` по-прежнему подавляет entity- и
device-tombstone у обычных plan consumers.
### 11.2. Browser smoke
Один узкий сценарий на full card:
1. создать два проёма с общим contact и lock;
2. удалить exact entity marker, затем parent device marker;
3. проверить options диалога, leaf/amount, active tone, padlock и info card;
4. проверить выбор после удаления и отсутствие восстановленного marker;
5. повторно добавить marker и убедиться, что opening fields не меняются;
6. последовательно подать `on/off`, `locked/unlocked`, `unavailable`;
7. подать disabled/orphaned registry rows и проверить отрицательные случаи;
8. проверить единственный service call, confirmation unlock и отсутствие call
у недоступного lock.
По текущему правилу владельца smoke добавляется при реализации, но запускается
перед бетой; в обычном цикле реализации выполняются только typecheck, unit и
build.
### 11.3. Golden и performance
Golden не нужен: стиль, геометрия и новый визуальный state не вводятся. Отдельный
performance benchmark не нужен; beta проходит общий performance gate. Code
review проверяет отсутствие per-entity registry fetch и повторного сканирования
markers в render hot path.
## 12. Документация и release-артефакты
В том же user-visible commit обновить:
- `docs/CHANGELOG.ru.md`;
- `docs/CHANGELOG.md`;
- `docs/USER-GUIDE.ru.md` — настройки проёма и точное исключение из раздела об
удалённых маркерах;
- `docs/FILTERING.md` — plan contributions остаются выключенными, explicit
opening references живут по HA binding status;
- `docs/ARCHITECTURE.md` — разделение marker availability и exact opening
reference availability;
- `docs/STATUS.md` — уточнить shipped-контракт true plan deletion после
фактической реализации.
Скриншоты и новые golden baselines не требуются. Отдельного security-артефакта
нет; lock safety доказывается targeted smoke и независимым code review. Issue
проходит beta/CI gate до стабильного релиза.
## 13. Производительность, безопасность и touch
- **Производительность:** новый helper O(1) поверх существующего binding-status
resolver/snapshot; новых websocket запросов, subscriptions и render layers
нет. Удаление marker scan из opening path не должно ухудшить budget.
- **Безопасность:** расширение намеренно возвращает сохранённому opening lock
доступ к уже существующей явной кнопке. Live status перепроверяется перед
service; unlock confirmation и запрет plan tap обязательны.
- **Touch:** View/киоск release-blocking; smoke проверяет, что badge открывает
карточку, а сам проём остаётся inert. Plan picker — desktop-first.
## 14. Риски и снижение
| Риск | Вероятность / ущерб | Снижение |
|---|---|---|
| Случайно оживут live text/Glow/controls удалённого marker | средняя / высокий | новый узкий helper; старые plan helpers не менять; regression unit |
| Disabled или orphaned entity станет доступной | средняя / высокий | единый `resolveHaBindingStatus`; отрицательная unit/smoke matrix |
| Lock service пройдёт после registry change | низкая / критический | live re-check непосредственно перед confirmation/service |
| Render прочитает новый state поверх старого кадра | низкая / высокий | render только из immutable active projection |
| YAML entity ошибочно потребует registry row | средняя / средний | state-positive render policy и unit fixture |
| Диалог визуально очистит временно недоступную сохранённую ссылку | средняя / средний | lossless saved-value smoke; не мутировать до явного выбора |
| Повторное добавление marker перепишет проём | низкая / средний | независимые поля и re-add smoke |
## 15. Откат
Откат — revert единого behavior commit. Данные не мигрируются, поэтому старые
`opening.contact`, `opening.lock` и marker tombstones остаются валидными. После
отката вернётся прежняя ошибочная фильтрация, но конфигурация не потребует
восстановления. Feature flag и обратная миграция не нужны.
## 16. Принятые предположения — можно изменить без пересмотра продукта
1. Новые подписи/предупреждения в picker не нужны: пользователь выбирает ту же
HA entity, а независимость от marker объясняется документацией.
2. Буквальный state `unavailable`/`unknown` считается существующей active
ссылкой и использует нынешнее unknown-представление; полное отсутствие live
state не изображается как известное состояние.
3. Новый узкий smoke предпочтительнее расширения геометрических opening smoke;
имя файла может измениться при сохранении того же покрытия.
4. Блокирующих продуктовых вопросов перед ревью ТЗ нет.