37 KiB
ТЗ #29 — единый каталог и жизненный цикл устройств
- Issue: https://github.com/Matysh/houseplan-card/issues/29
- Приоритет: P1
- Тип: feature
- Трек: обычный
- Связано: #44 (настройки discovery), #109 (поиск каналов устройства), #126 (смена HA area), #262 (возврат exact entity после удаления device)
1. Сценарий
Персона: администратор дома из docs/SCOPE.md.
Поверхность: полный houseplan-card, desktop-first редактор устройств.
Момент: пользователь добавил, скрыл или удалил устройства, Home Assistant обнаружил новые или деактивировал существующие, и теперь нужно понять, какие объекты представлены на плане и что с каждым из них можно сделать.
2. Что человек увидит до и после
До: в панели отдельно находятся «Добавить» и «Скрытые и деактивированные»; они позволяют выполнить операцию, но не дают единой картины и не объясняют, почему конкретного устройства нет на плане.
После: одна кнопка «Устройства» открывает каталог всех относящихся к плану HA-привязок с поиском, понятной категорией, причиной текущего состояния и доступным следующим действием; автоматически появившееся устройство уже считается находящимся на плане и отдельно помечается как новое.
3. Проблема
Текущая реализация уже поддерживает несколько устойчивых состояний:
- автоматически отображаемое устройство без сохранённого
marker; - явный видимый marker;
- пользовательски или автоматически скрытый
hidden:truemarker; - временно неактивную HA-привязку (
ha_disabled,orphaned,unverified); - удалённую exact binding с
removed:true, которую можно добавить заново; - отдельную
entity:X, возвращённую из-под tombstone родителяdevice:D.
Эти состояния обслуживаются разными UI-путями и не образуют объяснимого каталога. Пользователь вынужден помнить разницу между скрытием, удалением, деактивацией в HA, фильтрацией и автоматическим появлением. В результате одинаковое отсутствие значка визуально выглядит как несколько разных причин, а поиск уже размещённого устройства возможен только глазами на плане.
Старое ТЗ от 9 августа дополнительно считало любую binding без marker «новой». Это расходится с текущим zero-config поведением: автообнаруженное устройство в привязанной HA area уже рисуется без marker.
4. Цель и метрики результата
Создать одну read-only при открытии проекцию lifecycle, которая использует существующие данные и действия, а не вводит второй источник истины.
Результат считается достигнутым, если:
- для каждой включённой в каталог exact binding существует ровно одна базовая категория;
- каталог отличает пользовательское намерение от временного статуса HA;
- открытие, поиск, смена вкладки и просмотр причины не меняют config, layout и их revisions;
- все существующие операции — найти, настроить, добавить, показать и добавить заново — доступны из одной поверхности;
- текущая автоматическая раскладка, tombstones, exact entity ownership и фильтрация не меняют семантику.
5. Скоуп
Входит:
- единый каталог в редакторе устройств;
- pure resolver строк каталога;
- категории, статусы, причины, счётчики и поиск;
- действия над одной строкой;
- возврат из существующего диалога устройства обратно в каталог;
- локальное выделение найденного marker на плане;
- desktop, узкая responsive-компоновка без горизонтального скролла, keyboard navigation;
- RU/EN i18n, unit/smoke/golden, пользовательская и каноническая документация.
6. Не входит
- изменение правил автоматического discovery, группировки света и списка исключённых интеграций — это #44;
- управление HA registry: активация, деактивация, переименование или смена area;
- автоматический перенос marker при смене HA area — это #126;
- исправление отсутствующих каналов конкретного multi-channel устройства — #109;
- bulk hide/show/delete/add;
- история устройств, графики и статистика;
- добавление виртуальных markers в lifecycle-каталог: они не имеют HA binding и остаются редактируемыми кликом на плане;
- гарантированная полнофункциональность редактора на touch по
docs/TOUCH-SUPPORT.md.
7. Термины и две независимые оси
7.1 Exact binding
Ключ строки — точная привязка device:<device_id> либо
entity:<entity_id>. device:D и entity:X, принадлежащая D, являются разными
ключами и могут осознанно сосуществовать. Семейство HA device не является
единицей дедупликации.
7.2 Базовая категория — пользовательское намерение
| Категория | Условие | Смысл |
|---|---|---|
on_plan — «На плане» |
Binding представлена runtime-устройством на плане либо живым marker с hidden !== true |
Объект уже принадлежит плану. Отсутствие сохранённого marker не делает автоустройство «не добавленным» |
available — «Доступны» |
Активная допустимая binding не представлена runtime-устройством, живым marker или tombstone | Её можно добавить впервые |
hidden — «Скрытые» |
Живой marker имеет hidden:true |
Объект сохранён, но пользовательское намерение — не показывать его |
readd — «Доступны снова» |
Exact binding имеет removed:true и её существование сейчас подтверждено |
Объект был удалён с плана и может быть создан заново |
Приоритет категорий для одного exact key:
removed → hidden → on_plan → available.
Живой exact marker перекрывает tombstone той же exact binding. Tombstone
родителя device:D не перекрывает живую entity:X, и наоборот, согласно #262.
7.3 Операционный статус — состояние Home Assistant
Статус накладывается на категорию и не переносит строку между вкладками:
| Статус | Источник | Поведение |
|---|---|---|
active |
HaBindingStatus.kind === 'active' либо положительное доказательство для нового кандидата |
Доступны обычные действия категории |
ha_disabled |
Авторитетный registry подтверждает деактивацию | Строка помечена «Отключено в Home Assistant»; показать, добавить и выполнить Find нельзя |
orphaned |
Авторитетный registry подтверждает отсутствие сохранённой binding | Строка сохраняет свою базовую категорию, получает предупреждение и безопасные действия Edit/Delete |
unverified |
Registry недоступен или ограничен | Нельзя делать отрицательный вывод; строка не объявляется удалённой или деактивированной |
Пример: сохранённый видимый marker, который позже деактивировали в HA, остаётся
в «На плане», но строка сообщает, что сейчас он временно не показывается.
hidden:true + HA-disabled остаётся в «Скрытые» и показывает обе причины.
8. Состав каталога
Pure resolver строит детерминированный snapshot из:
- runtime
_devices; config.markers;settings.new_device_ids;- авторитетного или ограниченного HA registry snapshot;
- live states и текущей проекции доступности;
- действующих product filters и synthetic light groups;
- связей HA area → пространство/комната.
Кандидаты собираются из того же источника и по тем же eligibility-правилам, что текущий Add picker. Нельзя поддерживать отдельную, постепенно расходящуюся копию правил.
Виртуальные markers исключаются. Tombstone без положительного доказательства,
что exact binding снова существует, не показывается как «Доступно снова».
Limited registry никогда не превращает неизвестность в ha_disabled,
orphaned или readd.
8.1 Entity-уровень
- Уже размещённые
entity:*markers всегда присутствуют в каталоге. - В категории «Доступны» individual child entities показываются после включения фильтра «Показывать сущности», как в текущем Add dialog.
- Поиск выполняется по полному индексу и может найти exact entity независимо от её позиции после первых 200 элементов; hard cap до фильтрации запрещён.
- Явные
device:Dиentity:Xне скрывают друг друга из каталога. Residual automatic parent продолжает строиться по текущему контрактуbuildDevices.
8.2 Признак «Новое»
settings.new_device_ids остаётся единственным серверным источником этого
признака.
- Автоматически появившееся и уже видимое устройство находится в «На плане» и получает badge/фильтр «Новое».
- Открытие каталога и действие Find не подтверждают новизну.
- Открытие существующего диалога настройки через Edit подтверждает новизну по текущему контракту.
- Первично отфильтрованный скрытый кандидат не получает badge, как и сегодня.
9. Причины и объяснения
Строка может иметь одну основную lifecycle-причину и дополнительные статусы. В UI используются локализованные сообщения, не внутренние regex или raw id.
Stable reason enum:
visible_auto— найдено автоматически и уже находится на плане;visible_explicit— добавлено или настроено явно;manual_hidden— скрыто пользователем;automatic_hidden— скрыто автоматически, точная историческая причина не доказуема;service_entry;excluded_integration;excluded_domain;grouped_light/represented_by_parent;removed;no_bound_room— доступно, но HA area не связана с комнатой плана;ha_disabled_device,ha_disabled_entity,ha_disabled_all_entities;orphaned_device,orphaned_entity;registry_unavailable.
duplicate_name_area удаляется из старого ТЗ: совпадающие имена сейчас
нумеруются, а не скрываются.
Если точную причину старого seed-marker нельзя доказать по текущему registry и правилам, показывается честное «Скрыто автоматически». Новое поле с исторической причиной в config не добавляется.
Raw binding и диагностические детали допускаются в раскрываемой строке «Технические сведения» и копируются отдельно; они не заменяют пользовательское объяснение.
10. UX
10.1 Панель редактора
- Кнопки «Добавить» и «Скрытые и деактивированные» заменяются одной кнопкой «Устройства» с иконкой списка/устройств.
- «Правила иконок» остаётся отдельной кнопкой.
- Существующий локальный режим показа скрытых и HA-disabled markers призраками
на плане сохраняется, включая их реальную позицию, выбор, настройку и
перетаскивание. Его переключатель переносится с основной панели внутрь
каталога; это не persisted-настройка и не меняет
marker.hidden. - После закрытия каталога выбранный локальный режим действует до выхода из редактора устройств, как сегодня. При повторном входе в редактор он выключен; кнопка «Устройства» показывает активное состояние, чтобы режим не был скрыт от пользователя.
10.2 Диалог
Используется wide hp-dialog с заголовком «Устройства на плане».
Верхняя область:
- поле поиска с initial focus;
- действие «Добавить виртуальное устройство»;
- вкладки/фильтры со счётчиками:
- На плане;
- Доступны;
- Скрытые;
- Доступны снова;
- дополнительный фильтр «Новые» для «На плане»;
- «Показывать сущности» для «Доступны»;
- локальный switch «Показывать скрытые на плане». Он доступен во всех вкладках, включает существующий ghost-render скрытых и HA-disabled markers и ничего не сохраняет в config/layout сам по себе.
Строка содержит:
- текущую иконку;
- пользовательское имя;
- тип «Устройство»/«Сущность»;
- пространство и комнату, если известны;
- интеграцию/модель, если известны;
- badge «Новое» и operational status;
- одну короткую причину;
- primary action и меню дополнительных действий.
Поиск регистронезависим и работает по имени, модели, интеграции, названию
пространства/комнаты, entity_id и exact binding. Сначала фильтруется полный
snapshot, затем применяется progressive rendering. Первые 100 строк выводятся
сразу, остальные — кнопкой «Показать ещё» порциями по 100; счётчики всегда
относятся к полному результату.
Пустое состояние каждой вкладки объясняет, что сюда попадает и какое действие может изменить результат.
10.3 Действия строк
| Категория/статус | Primary | Дополнительные действия |
|---|---|---|
| На плане, реально отображается | Найти на плане | Настроить, Скрыть |
| На плане, временно не отображается из-за HA status | Настроить | Найти призрак при включённом ghost-режиме; Открыть в HA; Удалить через существующий dialog |
| Скрытые, active | Показать | Найти призрак при включённом ghost-режиме; Настроить |
| Скрытые, HA-disabled | Настроить | Найти призрак при включённом ghost-режиме; Открыть в HA; «Показать» disabled с объяснением |
| Доступны | Добавить | Скрыть из списка |
| Доступны снова | Добавить заново | — |
Hide/Show — обратимые одиночные действия без подтверждения, как текущий флаг скрытия. Delete остаётся только в существующем диалоге и сохраняет текущее подтверждение. Add/Re-add не сохраняют объект немедленно: они открывают существующий device dialog с preselected exact binding; Cancel ничего не пишет.
«Скрыть из списка» материализует обычный hidden:true exact marker и переносит
строку в «Скрытые». Это не tombstone и всегда обратимо.
10.4 Find и возврат из настройки
Find:
- доступен для обычного marker, если тот реально отрисовывается, и для hidden/HA-disabled marker, если включён локальный ghost-режим; в противном случае действие disabled и объясняет, что сначала надо включить «Показывать скрытые на плане»;
- переключает пространство при необходимости;
- закрывает каталог;
- перемещает viewport так, чтобы marker оказался внутри безопасной центральной области, не меняя zoom без необходимости;
- выделяет marker существующим selection style и дополнительным конечным акцентом не дольше 1,5 секунды;
- не открывает карточку, не выполняет tap action и не подтверждает badge «Новое».
Add/Edit/Show через существующий device dialog временно заменяют каталог. Cancel или Save возвращают пользователя в тот же tab/search/filter и к той же логической строке; snapshot пересчитывается. После удаления открывается обновлённая категория «Доступны снова», если binding подтверждена HA.
Registry/config refresh при открытом каталоге обновляет строки и счётчики, но не сбрасывает tab/search/filter. Открытый device dialog не закрывается из-за refresh; результат применяется после возврата.
11. Модель данных, сохранение и конкуренция
Новых persisted-полей и версии модели нет.
- Категория, статус, reason и состояние UI вычисляются в памяти.
- Tab, search, entity/new filters, progressive limit, scroll anchor и
returnToInbox— локальное состояние текущего экземпляра карточки и не переживают reload. - Hide/Show используют существующий
marker.hidden. - Delete/Re-add используют существующий
marker.removedи текущую очистку layout/files/trails. - New badge использует
settings.new_device_idsбез смены формата. - Любая mutation проходит существующий config revision/expected-rev путь. Конфликт не создаёт дубликат: карточка перечитывает snapshot, сохраняет tab/search и показывает локализованный toast.
- Открытие каталога, Find, поиск, смена вкладки, раскрытие причины и Show more не
вызывают
_saveConfig, layout update или acknowledgement.
Существующий filter_seeded остаётся compatibility-механизмом. Если старый
config материализуется при входе редактирующего клиента, это отдельная
идемпотентная миграция текущего продукта; каталог не инициирует и не маскирует
её как пользовательское действие.
12. Архитектурный контракт
Новый pure-модуль (рабочее имя src/device-inbox.ts) владеет:
DeviceInboxRow, category/status/reason enums;- сборкой и дедупликацией exact bindings;
- capability flags действий;
- поисковым индексом и стабильной сортировкой.
Он не импортирует Lit, не пишет config и не выполняет service calls.
Eligibility новых bindings обязана использовать общий helper текущего Add
picker. В ходе реализации общая логика извлекается из
houseplan-card.ts::_bindingCandidates; две независимые реализации запрещены.
Стабильная сортировка внутри вкладки:
new;- blocking operational status;
- localized display name;
- exact binding как tie-breaker.
Runtime complexity: O(devices + entities + markers) на snapshot плюс O(rows) на поиск. Нельзя для каждой строки повторно обходить все registry entities. Результат memoize по config epoch, registry revision, device roster signature, new ids и UI entity filter.
13. Доступность и responsive
hp-dialogсохраняет focus trap и restore focus.- Search получает initial focus; Escape закрывает dialog.
- Tabs имеют
role=tablist, выбранная вкладка —aria-selected; стрелки переключают вкладки. - Строка и каждое действие достижимы Tab; icon-only действия имеют aria-label.
- Изменение результатов поиска объявляется ненавязчивым
aria-live=polite. - На узкой ширине метаданные переносятся под имя, действия не создают горизонтальный скролл.
- View/киоск не меняются. Редактор на touch остаётся best effort; каталог не должен ломать pinch/pan View и не добавляет gestures на сам план.
14. i18n
Новые ключи создаются одновременно в src/i18n/en.json и
src/i18n/ru.json для:
- кнопки и заголовка каталога;
- четырёх вкладок, счётчиков и empty states;
- search, Show entities, New, Show more;
- Add virtual, Find, Edit, Hide, Show, Hide from list, Add, Re-add;
- всех reason/status из §9;
- unavailable action hints, refresh/conflict toast и accessibility labels.
Существующий marker.hide_tip в обоих языках обновляется: вместо удаляемой
кнопки «Скрытые и деактивированные» он направляет в каталог
«Устройства». Перед реализацией выполняется поиск остальных ссылок на старые
названия кнопок; пользовательские строки не должны вести к отсутствующему UI.
Имена integration/model/binding не переводятся. Формулировки интерфейса синхронизируются с RU/EN user guide.
15. Критерии приёмки
AC1 — единая точка входа (golden + smoke)
В Device editor вместо двух кнопок Add/Show hidden отображается одна кнопка «Устройства»; она открывает каталог с четырьмя категориями, поиском и счётчиками. Локальный ghost-toggle доступен внутри каталога, а его активность видна на кнопке «Устройства». «Правила иконок» остаётся доступной.
AC2 — детерминированная классификация (unit)
Матрица покрывает auto no-marker, explicit visible, manual/automatic hidden, HA-disabled, orphaned, unverified, removed active, removed missing, device-parent tombstone + live entity child, synthetic light group и candidate без bound room. Каждая включённая exact binding находится ровно в одной базовой категории.
AC3 — принятое правило auto/new (unit + smoke)
Автообнаруженное уже видимое устройство без marker находится в «На плане» и
получает «Новое», если его runtime id есть в new_device_ids. Открытие каталога
и Find badge не снимают; Edit снимает по текущему контракту.
AC4 — lifecycle и HA status независимы (unit)
hidden:true + ha_disabled остаётся в «Скрытые» с двумя объяснениями;
hidden:false + ha_disabled остаётся в «На плане»: Show недоступен, а Find
доступен только для призрака при включённом ghost-режиме.
Limited registry не создаёт ложный disabled/orphaned/readd.
AC5 — exact binding и повторное добавление (unit + smoke)
device:D tombstone и живой entity:X образуют две корректные строки.
Re-add exact binding заменяет только её tombstone, не создаёт дубликат, не
возвращает старую позицию/файлы/trail и сохраняет соседний parent/child lifecycle.
AC6 — действия используют существующие транзакции (smoke)
Find, Edit, Hide, Show, Add, Hide from list и Re-add дают результат §10.3. Cancel из Add/Re-add не меняет данные; Delete остаётся подтверждаемым действием существующего dialog.
Переключатель ghost-режима внутри каталога воспроизводит текущий локальный показ
hidden и HA-disabled markers: после закрытия каталога их можно найти, выбрать,
настроить и перетащить без изменения marker.hidden; выход из редактора
сбрасывает режим.
AC7 — read-only открытие (unit + smoke)
Snapshot config, layout и обе revisions до/после открытия, поиска, tab switch, reason expand, Show more и Find идентичны. Ни один из этих путей не вызывает config/layout websocket write.
AC8 — возврат и live refresh (smoke)
После Edit/Save/Cancel каталог возвращается с прежними tab/search/filter и логическим scroll anchor. Registry/config refresh пересчитывает строку без закрытия открытого device dialog и без дубликатов.
AC9 — поиск и большие реестры (unit + smoke)
Поиск находит строку по каждому полю §10.2, включая entity за пределом первых 200 исходных записей. Progressive rendering не меняет total counts и не приводит к вложенному полному обходу registry на каждую строку.
AC10 — accessibility/responsive (smoke + golden + review)
Диалог работает с клавиатуры, восстанавливает focus, не имеет горизонтального скролла на narrow fixture и корректен в RU/EN и light/dark. Touch View и kiosk не получают новых интерактивных слоёв.
AC11 — compatibility (unit + existing regressions)
Текущие тесты devices, ha-binding-status, device-presentation,
binding_picker и hidden_flag сохраняют смысл. Старые markers/settings читаются
без миграции, а каталог не меняет auto-placement, filtering, light aggregation,
LQI/climate или opening references.
16. План автотестов
test/device-inbox.test.mjs— pure resolver, reason/capability/search/sort, large registry и exact-binding matrix;test/devices.test.mjs,test/ha-binding-status.test.mjs— соседние регрессии lifecycle;demo/smoke_device_inbox.mjs— реальный dialog, read-only assertions, row actions, return state, late registry refresh;- адаптация
demo/smoke_binding_picker.mjsиdemo/smoke_hidden_flag.mjsбез потери существующих проверок:smoke_hidden_flagвключает ghost-режим через каталог и по-прежнему проверяет позицию, клик/настройку и drag скрытого marker; - golden: desktop RU/EN, light/dark; narrow RU с длинными причинами;
node scripts/smoke-select.mjs --base origin/dev --head HEADопределяет дополнительный набор перед код-ревью.
Минимальные локальные гейты реализации:
npx tsc --noEmit
npm test
npm run bundle:sync
node scripts/check-docs.mjs
node scripts/smoke-select.mjs --base origin/dev --head HEAD
node demo/smoke_device_inbox.mjs
node demo/smoke_binding_picker.mjs
node demo/smoke_hidden_flag.mjs
npm run golden:verify
17. Производительность
- полный индекс может включать тысячи entity, но DOM получает не более 100 строк за одну порцию;
- поиск и counts считаются по полному memoized snapshot;
- registry refresh инвалидирует snapshot один раз на revision;
- новый код не добавляет рендеров или observers в View/киоск;
- performance benchmark обязателен только если
smoke-selectлибо ревью выявят затронутый бюджет; synthetic unit на большой registry остаётся обязательным.
18. Риски
| Риск | Защита |
|---|---|
| Auto no-marker ошибочно показан как не добавленный | Effective runtime presence важнее наличия marker; AC3 |
| Device/entity parent-child дедуплицируются слишком широко | Exact binding key и fixture #262; AC5 |
| Limited registry создаёт ложные причины | Только positive evidence; AC4 |
| Catalog и Add picker расходятся | Один общий eligibility helper |
| Открытие UI материализует данные | Явный no-write contract и websocket assertions; AC7 |
| Историческая причина скрытия выдумывается | Generic automatic_hidden fallback |
| Большой registry блокирует dialog | O(N) snapshot + progressive rendering |
| Возврат из nested flow теряет контекст | Локальный returnToInbox snapshot; AC8 |
| Перенос ghost-toggle делает активный режим незаметным после закрытия каталога | Активное состояние кнопки «Устройства» и сброс при выходе из редактора; AC6 |
| #29 поглощает redesign фильтров | #44 явно вне скоупа |
19. Откат
Изменение не мигрирует config и не создаёт новых persisted-полей. Откат
возвращает две прежние кнопки и Add picker; markers, tombstones, hidden flags,
layout и attachments остаются совместимыми. Созданный через «Скрыть из списка»
hidden:true marker уже поддерживается старым UI и может быть показан обычным
способом.
20. Release-артефакты
docs/CHANGELOG.mdиdocs/CHANGELOG.ru.mdсо ссылкой на #29;- раздел Device editor в
docs/USER-GUIDE.mdиdocs/USER-GUIDE.ru.md; docs/FILTERING.md— каталог как единая presentation surface, без изменения lifecycle semantics;docs/ARCHITECTURE.md— pure resolver и shared eligibility helper;- актуальные golden baselines и публичный screenshot редактора устройств, если документационный capture затронут;
- backend, security и model migration release notes: не требуются.
21. Принятые технические предположения — ревьюер может менять свободно
- Pure resolver живёт в
src/device-inbox.ts, а не внутри Lit-компонента. - Existing Add eligibility извлекается в общий helper; имя модуля не является продуктовым контрактом.
- Tab/search/filter/scroll state хранится только в экземпляре карточки.
- Progressive page = 100 строк; конкретное число можно скорректировать по golden/performance без изменения пользовательской семантики.
- Точная историческая причина seed не сохраняется; при отсутствии доказательства
используется
automatic_hidden. - #44 расширит тот же dialog advanced-настройками позже, но #29 не ждёт #44 и не меняет discovery settings.