Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
26 KiB
Issue #226 — Entity-marker не дублируется родительским HA-устройством
- Дата: 2026-08-20
- Тип: bug · приоритет P1 · ценность 8/10 · сложность/риск 5/10
- Issue: #226
- Ветка:
issue/226-entity-parent-dedup - Статус ТЗ: на ревью
Канонические документы: docs/SCOPE.md, docs/FILTERING.md,
docs/CONFIG-COMPATIBILITY.md, docs/TOUCH-SUPPORT.md,
docs/USER-GUIDE.ru.md, docs/USER-GUIDE.md.
1. Сценарий и персона
Администратор включает в редакторе устройств показ отдельных сущностей и
размещает entity:X, принадлежащую HA-устройству D. Например, интеграция
Switch as X создаёт light.room поверх физического реле D.
Сейчас House Plan показывает и явно размещённую сущность, и автоматически обнаруженное родительское устройство. Две иконки относятся к одному физическому объекту, могут по-разному выглядеть и реагировать на клик, а свет дважды входит в визуальное представление. Это нарушает J1, J3, J4 и J6.
2. Что человек увидит до и после
До исправления: после размещения entity:X рядом остаётся auto-marker
устройства D. Если у устройства несколько сущностей, auto-marker продолжает
использовать и уже вынесенную X, и остальные сущности.
После исправления: явно размещённая entity:X принадлежит только своему
marker и вычитается из автоматического состава D:
- если у
Dпосле вычитания остаются активные видимые сущности, House Plan показывает ровно один остаточный auto-marker, построенный только из них; - если остаток пуст, auto-marker
Dне показывается; - несколько явно размещённых сущностей одного устройства остаются отдельными markers; auto-marker получает только незанятый остаток;
- явно сохранённые
device:Dиentity:Xне подавляют друг друга: это осознанная конфигурация пользователя, поэтому на плане остаются оба markers.
3. Зафиксированные продуктовые решения
- Частичное владение. Entity-marker забирает из auto-device только свою сущность. Наличие одной entity не подавляет весь родительский marker, пока существует видимый активный остаток.
hidden_byне является глобальным фильтром. Нетронутый auto-device и явно сохранённыйdevice:Dсохраняют действующий функциональный resolver, включая скрытый интеграциейcover.*из #94. Это защищает шторы от выбора служебного switch как основного состояния и действия.- Hidden sibling не удерживает остаток. Сущность с HA
hidden_by(в нормализованном frontend registry —reg.hidden) не считается основанием для остаточного auto-marker. При этом явно сохранённыйentity:Xразрешён и отображается по действующим правилам даже приhidden_by. Следствие принято осознанно: если пользователь вынес видимую вспомогательную entity, а у родителя остался только HA-hidden функциональный sibling, auto-marker родителя исчезает. Чтобы сохранить полное устройство рядом с отдельной entity, пользователь явно размещаетdevice:D— тогда действует решение 4. - Явная конфигурация сильнее автоматической. Сохранённый
device:Dне удаляет явно сохранённые entity-markers того же устройства. В этом случае составdevice:Dостаётся полным, как сейчас. - Tombstone не владеет сущностью.
entity:Xсremoved:trueподавляет только отдельный plan binding. Она не вычитается из живого родительского устройства и не подавляет его auto-marker. - Скрытие marker — сохранённое владение. Живой entity-marker с
marker.hidden:trueпродолжает заниматьX: скрытие не должно возвращать эту сущность внутрь видимого auto-device. В Device editor он остаётся ghost по действующему контракту.
4. Границы задачи
Входит
- единая модель ownership между
entity:Xи родительскимdevice:D; - остаточный состав auto-device во всех потребителях
buildDevices(); - согласованное поведение
seedHiddenBindings(), чтобы seeder не создавал hidden stub для родителя, у которого после вычитания нет пригодного остатка; - регрессии state/icon/action, света/Glow, LQI и редакторского preview;
- unit, browser smoke и mutation guards;
- документация RU/EN и оба changelog.
Не входит
- автоматическое слияние или удаление двух явно сохранённых markers;
- изменение выбора primary entity у обычного полного device-marker;
- глобальное исключение HA
hidden_byиз функционального resolver; - изменение семантики
disabled_by, tombstones, light groups или ручного скрытия; - очистка сохранённых layout-позиций, новый config field, backend API, миграция или настройка в UI.
5. Термины и множества
Для одной проекции buildDevices() вводятся:
placedEntityIds—refвсех живых (removed !== true) markers с валидной привязкойentity:<ref>, включаяmarker.hidden:true;placedDeviceIds—refвсех markersdevice:<ref>по действующему exact-binding контракту, включая tombstone;eligibleDeviceEntities(D)— активные registry entities устройства из текущейactiveRegistryHass();visibleResidual(D)—eligibleDeviceEntities(D)безplacedEntityIdsи без HA-hidden сущностей (reg.hidden === true).
Связь entity → device читается из полного авторитетного/cached registry
snapshot, а не выводится из имени entity или текущего state. Если registry не
даёт device_id, сущность считается самостоятельной и не влияет на устройство.
После следующего авторитетного snapshot проекция пересчитывается без записи
конфига.
6. Алгоритм построения
- Один раз до циклов построить ownership по живым entity-markers. Нельзя
делать вложенный поиск всех markers для каждого устройства: бюджет остаётся
O(markers + entities + devices). - Для каждого auto-discovered
Dсначала сохранить действующие проверки Area, service entry, exactdevice:D, binding status и legacy filtering. - Если существует явно сохранённый
device:D, auto-marker по-прежнему не строится; явные entity-markers обрабатываются независимо на шаге 3 текущегоbuildDevices(). - Для действительно автоматического
Dпередать во все вычисления marker толькоvisibleResidual(D): domain/icon/primary/state/temp/humidity,entities, light/Glow и action не должны видеть вынесеннуюX. - Если
visibleResidual(D)пуст, auto-marker не добавляется. Наличие только hidden siblings не считается остатком. allEntitiesостаточного auto-marker должно описывать тот же остаточный binding, а не возвращать занятуюXчерез side-channel доступности, презентации или диалога. Полный список сохраняется только у явногоdevice:D.- Явные entity-markers строятся существующим exact resolver без изменений;
entity без
device_id(helper/group/template) остаётся самостоятельной. seedHiddenBindings()использует ту же ownership-функцию и остаточный критерий. Он не материализуетdevice:Dstub, если после вычитания размещённых entity и HA-hidden siblings уDничего не осталось.
Ownership/helper должен быть общим для buildDevices() и seeder либо иметь
contract test, доказывающий идентичную семантику. Дублирующиеся реализации
правила запрещены.
7. Состояния, действия и агрегаты
- Entity-marker получает icon/state/value/action только от своей точной
X. - Остаточный auto-marker получает их только от
visibleResidual(D). - Вынесенная light/switch не может второй раз попасть в room light count, light fill или Glow через auto-device. Остальные сущности остатка продолжают работать.
- LQI и availability остаточного marker вычисляются по остаточному составу.
Явный полный
device:Dсохраняет текущую device-wide семантику. - Hidden plan-marker не рисуется и не даёт видимый свет по
docs/FILTERING.md, но продолжает владеть entity, поэтому родитель не возвращает её на план. removed:trueостаётся binding-scoped: после удаления отдельного marker сущность снова доступна полному auto-device.
8. hidden_by и защита #94
Изменять activeRegistryHass(), entitiesByDevice() как глобальный HA-hidden
фильтр или resolvedDeviceStateEntities() для всех устройств запрещено.
Обязательная регрессия: у нетронутой шторы с hidden integration cover.* и
видимым служебным switch.* auto/device-marker сохраняет cover-first
functional state/icon/toggle из #94. Только остаточный auto-marker, возникший
после явного entity-marker, применяет правило «hidden siblings не удерживают
остаток». Поэтому при явном marker на видимый switch.reverse_direction и
единственном остатке в виде hidden cover.curtain автоматическая штора
исчезает; это ожидаемое следствие Q2, а не обход cover-first. Явно сохранённый
device:D по-прежнему показывает полную штору и может сосуществовать с этим
entity-marker.
9. Lifecycle и совместимость
- Схема
ServerConfig, backend validation, storage version и wire protocol не меняются. - Существующие планы исправляются проекцией при следующем render/reload; конфиг не переписывается.
- Лишний auto-marker не имеет собственного marker record. Его старый layout key остаётся инертным и не очищается: удаление могло бы потерять выбранную пользователем позицию при последующем возвращении устройства.
- Старый frontend продолжит показывать старый дубль; downgrade не повреждает данные. Новый frontend восстанавливает исправленную проекцию без миграции.
- Ограниченный или временно неавторитетный registry не даёт права угадывать parent по entity id. Используется последний доступный authoritative cached relation; без неё поведение безопасно возвращается к exact binding и самовосстанавливается после registry refresh.
10. Поверхности
Источник поведения — общий buildDevices(), поэтому контракт обязателен для:
- полного View и kiosk;
- Device editor и его unsaved preview через
deviceFromMarkerDraft(); houseplan-space-card;- room light/fill/Glow, LQI и climate/value consumers набора устройств;
- desktop mouse и touch tap. Геометрия hit-area и жесты не меняются.
i18n-ключи, backend и отдельная mobile-компоновка не требуются.
11. Изменяемые файлы и модули
Ожидаемый минимум:
src/devices.ts— ownership, residual projection,buildDevices()и seeder;test/devices.test.mjs— матрица unit-контрактов;demo/smoke_device_entity_parent_dedup.mjsи package/CI registration, если существующий smoke нельзя расширить без смешения скоупа;scripts/mutation-gate.mjsиtest/mutation-gate.test.mjs— guards;docs/FILTERING.md,docs/USER-GUIDE.md,docs/USER-GUIDE.ru.md,docs/TESTING.md;docs/CHANGELOG.md,docs/CHANGELOG.ru.md;- generated bundles — только штатным
npm run buildв implementation commit.
Список может сузиться по реализации, но новый product/config модуль требует возврата ТЗ на ревью.
12. Матрица обязательных тестов
- Единственная entity
XустройстваD, остатка нет → только markerX. Xразмещена, уDесть видимаяY→ markerXплюс один auto-markerD, причёмD.entities/allEntities/primaryне содержатX.- Размещены
XиY, остатка нет → два entity-markers, autoDотсутствует. - Явные
entity:Xиdevice:D→ оба явных markers;device:Dсохраняет полный состав, третьего auto-marker нет. entity:Xсmarker.hidden:true→ autoDне получаетX; ghost доступен только по действующему editor contract.- Tombstone
entity:X, removed:true→ autoDсуществует и по-прежнему содержитX. - Явная HA-hidden
entity:Xработает как exact marker; hidden siblingYне создаёт пустой/бесполезный остаточный auto-marker. - Нетронутая штора #94 с hidden
cover.*сохраняет cover-first icon/state/ action у полного auto/device marker. - HA-disabled entity-marker с известным
device_idне позволяет занятой сущности вернуться в активный остаток родителя; ghost/lifecycle остаётся прежним. - Helper/group/template без
device_id→ одна exact entity-строка, другие устройства не затронуты. - Auto light group и exact group marker сохраняют текущую дедупликацию.
- Seeder не создаёт parent stub при пустом остатке и остаётся идемпотентным.
- Registry refresh, добавляющий/удаляющий sibling или меняющий hidden status, перестраивает один остаточный marker без config write.
- Граница #94: размещён видимый
entity:switch.reverse_direction, а единственный siblingcover.curtainимеет HA-hidden status → остаётся только entity-marker, auto-marker шторы отсутствует; добавление явногоdevice:Dвозвращает полную cover-first штору рядом с entity-marker. - Switch as X browser fixture: отдельная лампа и остаток (если он есть)
дают ожидаемое число DOM markers; click entity-marker вызывает точную
entity action, а light/Glow считают
Xодин раз.
13. Acceptance criteria
- AC1 — нет полного дубля. Размещённая
entity:Xисключается из состава auto-deviceD; при пустом остаткеDотсутствует. Доказательство: unit cases 1/3 и mutation guard основного residual predicate. - AC2 — частичный остаток. При наличии
Yостаётся ровно один auto-marker, все его state/icon/action/availability поля построены безX. Доказательство: unit case 2 с проверкой результата и primary/action. - AC3 — явная асимметрия. Entity tombstone не вычитает
X, а явныеdevice:D + entity:Xсосуществуют. Доказательство: unit cases 4/6. - AC4 — hidden-контракты. Marker hidden, HA hidden и HA disabled следуют
решениям §§3, 7 и 8; штора #94 не регрессирует. Доказательство: unit cases
5/7/8/9/14, включая явную проверку hidden-only остатка и восстановления
полного cover-first marker через сохранённый
device:D. - AC5 — standalone и групповые bindings. Entity без parent и light group не меняют поведение. Доказательство: unit cases 10/11 и существующие device/group tests.
- AC6 — seeder parity. Seeder использует ту же ownership semantics и не создаёт новый скрытый parent stub для пустого остатка. Доказательство: unit case 12 и mutation guard seeder predicate.
- AC7 — все renderers и действия. Full View, kiosk/touch, Device preview и static card получают одну проекцию; Switch as X рисуется и действует без двойного light/Glow contribution. Доказательство: browser smoke case 15, shared projection unit и code review.
- AC8 — динамический registry. Изменение sibling/hidden metadata пересчитывает остаток без записи конфига и без исключения/ошибки. Доказательство: registry mutation unit/smoke case 13.
- AC9 — совместимость. Нет schema/backend/i18n migration, layout не очищается, unknown config siblings не затрагиваются. Доказательство: diff review, config round-trip regressions, typecheck и build.
- AC10 — release artifacts. Оба changelog и RU/EN user/filter/testing docs описывают ownership; generated bundles идентичны. Доказательство: docs check, bundle hash check и review diff.
14. Mutation guards
Минимум два мутанта в scripts/mutation-gate.mjs:
| id | Поломка | Guard |
|---|---|---|
entity-marker-kept-in-parent-device |
не вычитать placedEntityIds из residual D |
AC1/AC2 unit |
entity-marker-parent-seeded |
вернуть seeder к exact device:D claimed без residual ownership |
AC6 unit |
Unit отдельно обязан падать, если tombstone ошибочно начать считать живым
ownership, или если явный device:D начать обрезать по entity-markers.
15. Проверки реализации и ревью
Implementation loop:
npm run typecheck
npm test
npm run build
Перед бетой по действующему процессу:
- targeted Switch as X browser smoke на desktop и touch/kiosk viewport;
npm run golden:verifyдля проверки отсутствия непредусмотренной визуальной дельты; новый golden не обязателен, потому что геометрия marker не меняется;- performance gate: синтетический большой registry не должен получить
markers × devicesобход; - штатные smoke/performance/security и проверка SHA-256 трёх bundles.
Автор не принимает новые golden baselines самостоятельно.
16. Release-артефакты
docs/CHANGELOG.mdиdocs/CHANGELOG.ru.mdв том же пользовательском implementation commit (User-Visible: yes);docs/USER-GUIDE.mdиdocs/USER-GUIDE.ru.md: выбор Entity и судьба родительского auto-marker;docs/FILTERING.md: разница live entity-marker и binding tombstone, а также ограниченныйhidden_byresidual contract;docs/TESTING.md: автоматические доказательства и mutation ids;- generated
dist, demo и integration bundles после build, с одинаковым hash; - screenshots manifest/PNG меняются только если штатный capture действительно затронут. Само исправление не требует нового эталонного изображения.
17. Производительность, безопасность и touch
- Временная и пространственная сложность ownership — линейная; запрещён поиск markers внутри device/entity loops.
- Новых HA service calls, прав, внешних URL, HTML или пользовательского ввода нет; security surface не меняется.
- Touch: View и kiosk release-blocking. Количество markers и точный tap target
должны совпадать с desktop; drag/editor остаётся best effort по текущему
docs/TOUCH-SUPPORT.md.
18. Откат и риски
Откат — один implementation commit #226 вместе с тестами, документацией, changelog и generated bundles. Данные не мигрируют, поэтому отдельного rollback данных нет; старые инертные layout keys сохраняются.
Риски:
- Частичный auto-device может случайно получить
XчерезallEntities, primary или агрегацию, хотяentitiesуже обрезан. - Глобальный hidden filter способен повторно сломать шторы #94.
- Seeder может материализовать скрытый explicit device и превратить автоматический дубль в постоянную конфигурацию.
- Неправильная трактовка tombstone может удалить полезную entity из parent.
- Вложенный поиск ownership ухудшит cold render на больших registry.
Каждый риск закрыт соответствующим AC и тестом выше.
19. Принятые предположения
- «Видимая entity» в остатке означает HA registry entity без
reg.hidden, а не видимость plan-marker. - Живой hidden plan-marker остаётся пользовательским ownership;
removed:true— нет. - При явной паре
device:D + entity:Xвозможен осознанный повтор состояния и света; автоматическая дедупликация явной конфигурации вне скоупа. - Инертный layout key не является пользовательски видимым объектом и не требует очистки.
- Дополнительных настроек, предупреждений и переводов не требуется.