Files
houseplan-card/docs/specs/104-opening-ha-reference-after-marker-delete.md
T
2026-08-13 13:11:57 +03:00

450 lines
31 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.
# Issue #104 — HA-привязка проёма после удаления маркера
- **Issue:** https://github.com/Matysh/houseplan-card/issues/104
- **Статус:** редакция r2 после ревью r1; решения владельца зафиксированы,
готово к повторному независимому ревью
- **Тип / приоритет:** bug / P2
- **Оценка:** пользовательская ценность 8/10; сложность и риск 5/10
- **Область:** настройки проёма в Plan, состояние проёма и замка в View/киоске,
политика доступности HA-привязок, документация и тесты
- **Модель данных:** без изменений и миграции
- **Связано:** #98, #117, `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`.
В full card метод `_captureRenderDeviceSnapshot()` явно добавляет `contact` и
`lock` каждого проёма в общий `entityIds`, после чего generic
`createRenderDeviceSnapshot()` замораживает запрошенную HA-проекцию. Сам модуль
`render-device-snapshot.ts` ничего не знает о проёмах. Поэтому основной дефект
на registry-backed пути находится в политике доступности, а не в формате
конфигурации или списке entity IDs, переданном snapshot builder.
### 2.1. Конфликт с действующим письменным контрактом
`docs/FILTERING.md` сейчас объединяет три разных вида сохранённых ссылок одним
правилом: ссылки в проёмах, live text и `marker.controls[]` сохраняются, но
становятся неактивными после удаления соответствующего marker; повторное
добавление binding оживляет их.
#104 намеренно отменяет эту норму как минимум для проёмов. Это не простое
исправление расхождения кода и документа: это частичная замена записанного
продуктового решения. Новая формулировка должна описать все три случая, даже
если меняется только один.
**Принятая владельцем новая формулировка:**
- exact `opening.contact` и `opening.lock` живут по HA binding status и не
выключаются tombstone самостоятельного marker;
- live text и `marker.controls[]` по-прежнему сохраняются, но не действуют,
пока tombstone существует; повторное добавление binding оживляет их;
- HA-disabled и authoritative orphaned entity не действует ни в одном из трёх
случаев; конфигурация при этом не стирается.
Новый принцип не распространяется на live text или `controls[]`: это явное
решение scope #104, а не случайный пробел реализации.
## 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, vacuum и
других plan-level contributions;
- изменение tombstone-семантики live text и `marker.controls[]` — решением
владельца они сохраняют нынешнее отключение до повторного добавления binding;
- новый 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 | да | picker уже принимает; render-расхождение вынесено в #117 и не меняется в #104 |
| 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 к явным ссылкам проёма.
Сегодня `_renderEntityAvailable()` требует одновременно registry entity row и
frozen state. `activeRegistryHass()` сохраняет state registry-less live entity,
но не синтезирует для неё строку в `entities`; поэтому picker такую entity уже
принимает, а render — нет. Это отдельное текущее расхождение, а не сохранённое
поведение #104.
**Решение владельца:** не расширять #104. Новая opening render-проверка
игнорирует marker tombstone, но сохраняет требование frozen active registry row
и state. Исправление registry-less render вынесено в #117.
### 5.4. Решения владельца
Владелец принял все defaults 13.08.2026; каноническая запись:
https://github.com/Matysh/houseplan-card/issues/104#issuecomment-5278421151
- **Q1:** новый принцип действует только для openings; live text и
`controls[]` не меняются;
- **Q2:** CR-1 в `docs/SCOPE.md` не меняется, потому что поверхность актуации
остаётся той же;
- **Q3:** registry-less render исключён из #104 и отслеживается в #117.
## 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 только своего проёма;
- то же значение amount участвует в существующем hit-test проёма через
`_openingAt()`; смена availability не должна разъединить видимую и кликабельную
геометрию;
- `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 с сохранением требования
active registry row + state; registry-less расхождение относится к #117.
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 (`unit` + `smoke`):** pure policy даёт одинаковый результат независимо
от того, была exact opening reference сохранена до или после marker
tombstone; browser smoke подтверждает анимацию, badge и карточку проёма.
- **AC4 (`unit` + `smoke`):** выбор contact/lock не снимает tombstone, не создаёт
marker/layout и не возвращает entity в LQI, климат, свет, Glow, live text или
controls.
- **AC5 (`unit` + `smoke`):** операции удаления/повторного добавления marker в
pure config fixture не дублируют и не изменяют `opening.contact` /
`opening.lock`; browser smoke подтверждает интеграцию диалога.
- **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`):** limited-registry entity с точным live state
доступна; `unverified` и cached-disabled — недоступны. Существующий picker
продолжает принимать live YAML entity, а её render-расхождение в #104 не
меняется и отслеживается в #117.
- **AC10 (`unit` + source-contract + `smoke` + ревью кода):** lock/unlock
возможен только из карточки проёма; availability guard стоит до
confirmation/service, `callService('lock', …)` не появляется на другой
plan-поверхности, unlock требует confirmation, а stale/disabled/orphaned lock
не вызывает service. Unit и source-contract выполняются до code review;
browser smoke повторяет пользовательский путь перед бетой.
- **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 остаётся active в exact binding resolver
picker; render requirement не меняется и отслеживается в #117;
5. limited live, limited unverified и cached disabled;
6. render projection: exact frozen registry row + state принимаются без
требования marker, но state-only registry-less путь не расширяется;
7. regression: `isRemovedPlanEntity()` по-прежнему подавляет entity- и
device-tombstone у обычных plan consumers;
8. pure config fixture: marker delete/re-add не меняет opening fields и не
связывает несколько проёмов друг с другом;
9. lock action policy: active проходит, disabled/orphaned/unverified не
достигают service intent.
### 11.2. Source-contract для lock safety
До code review отдельный Node test читает `src/houseplan-card.ts` через уже
применяемый в suite приём `methodBody()` и доказывает:
1. `_lockAction()` вызывает exact opening availability guard до confirmation и
`callService`;
2. unlock confirmation остаётся внутри санкционированного метода;
3. `callService('lock', ...)` не появляется в другом plan interaction path;
4. сам opening hit и device marker не получают lock actuation.
### 11.3. 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.4. 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 после
фактической реализации.
`docs/SCOPE.md` и lock invariant CR-1 проверены обязательно. Решением владельца
текст не меняется: санкционированная поверхность остаётся той же единственной
кнопкой карточки проёма, меняется лишь availability exact lock.
Скриншоты и новые 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. Решения Q1–Q3 зафиксированы владельцем в §5.4 и не относятся к изменяемым
предположениям этого раздела.