Files
houseplan-card/docs/specs/457-zigbee-route-arrows.md
T
Matysh 565acb6046 Fix Zigbee topology layer order
Issue: #464
User-Visible: yes
2026-09-06 00:48:54 +03:00

38 KiB
Raw Blame History

ТЗ #457 — направление Zigbee-связей к координатору

  • Issue: https://github.com/Matysh/houseplan-card/issues/457
  • Базовый контракт топологии: ТЗ #54
  • Приоритет: P2
  • Тип: feature
  • Сложность: 4/5
  • Трек: полный — новый визуальный контракт, производный граф, i18n и browser smoke
  • Продуктовые решения владельца: 2026-09-05

1. Сценарий

Персона — администратор дома из docs/SCOPE.md, работающий в полной карточке House Plan на компьютере с мышью. В общих настройках уже включено отображение Zigbee-связей и явно загружен snapshot ZHA или Zigbee2MQTT.

Администратор наводит курсор на проблемное устройство и хочет не только увидеть его радиососедей, но и понять, через какое следующее устройство путь ведёт к координатору и какие устройства, наоборот, используют наведённый router как следующий шаг.

2. Что человек увидит до и после

До: при наведении House Plan показывает одинаковые линии ко всем доступным соседям; направление к координатору по ним определить нельзя.

После: те же линии и LQI-цвета остаются, но на рёбрах выбранного дерева маршрутов появляются стрелки к координатору. У обычного устройства одна исходящая стрелка, у router также могут быть входящие стрелки от детей, а резервные соседские связи остаются без стрелок. Если следующий узел нельзя показать на текущем плане, короткая стрелка ведёт в поясняющий bubble рядом с наведённым устройством.

3. Проблема и текущее состояние

ZigbeeTopologyLink уже хранит направленные наблюдения aToB и bToA, но ZigbeeHoverLine несёт только neighborMarkerId и lqi. resolveMappedTopologyHover() классифицирует локальные, удалённые и неотрисованные endpoints, а hp-zigbee-topology-overlay рисует обычные SVG line. Поэтому существующая картинка отвечает на вопрос «кого видит это устройство», но не отвечает на вопрос «куда отсюда двигаться к координатору».

Нельзя ставить стрелку непосредственно по relationship каждого neighbour record. Снимки могут быть неполными или взаимно противоречивыми: два узла могут одновременно назвать друг друга родителем, sibling не задаёт направление, а устаревшие записи способны образовать цикл. Требование владельца сильнее отдельного provider-наблюдения: двигаясь по стрелкам, пользователь должен приходить к координатору. Это свойство должно обеспечиваться построением производного дерева.

Дополнительный дефект нормализации: Zigbee2MQTT передаёт relationship числом, а текущий observation() принимает только строку. В результате полезное предпочтение родителя теряется, хотя сам граф и LQI сохраняются.

4. Скоуп

В #457 входят:

  • детерминированное дерево аплинков отдельно для каждой topology instance;
  • построение дерева по полному нормализованному графу, включая узлы без marker;
  • стрелки только на рёбрах дерева, всегда в направлении к координатору;
  • сохранение всех существующих прямых соседских линий, halo и LQI-семантики;
  • parent bubble со стрелкой, если следующий узел находится в другом пространстве или не имеет drawable marker;
  • нормализация числовых relationship Zigbee2MQTT;
  • локализованные тексты для отсутствующего устройства и координатора;
  • пояснение семантики в руководстве пользователя;
  • unit-проверка дерева и инварианта, расширение topology hover smoke;
  • сохранение lazy boundary и существующих performance budgets.

5. Не-скоуп

В #457 не входят:

  • фактическая трассировка каждого Zigbee-пакета или гарантия реального текущего маршрута между router-узлами;
  • постоянный полный mesh-граф, режим «показать все связи» или легенда;
  • изменение способа и частоты получения ZHA/Zigbee2MQTT snapshots;
  • автоматическое сканирование, история маршрутов и анимация пакетов;
  • новая визуализация асимметрии LQI;
  • изменение цветов, толщины или состава существующих соседских линий;
  • отдельный topology-жест для touch/pen, keyboard focus, kiosk, houseplan-space-card или редакторов;
  • автоматическое размещение координатора либо других отсутствующих устройств;
  • переход в другое пространство по bubble;
  • новые настройки или изменение сохранённого конфига.

6. Контракт производного дерева

6.1. Изоляция provider-сетей

Дерево строится отдельно для каждого ZigbeeTopology, то есть для пары provider + instanceId. Узлы и рёбра разных ZHA/Zigbee2MQTT instances никогда не объединяются в один маршрут.

Ровно один узел с role: coordinator становится корнем. Если координатора нет или нормализованный instance содержит несколько координаторов, дерево для этого instance не строится: соседские линии продолжают работать как сейчас, но стрелок и parent bubble нет. Неоднозначность не исправляется выбором по имени, порядку или IEEE.

6.2. Дистанция и выбор родителя

От координатора выполняется обход в ширину по ненаправленному набору нормализованных links. Для каждого достижимого узла фиксируется минимальное число рёбер до координатора.

Родитель узла с дистанцией d > 0 выбирается только среди его соседей с дистанцией d - 1. Это ограничение обязательно: оно по построению исключает циклы и гарантирует продвижение к корню на каждом шаге.

Если допустимых родителей несколько, применяется стабильный порядок:

  1. сначала сосед, которого направленное наблюдение от дочернего узла к соседу называет parent;
  2. затем большее известное LQI того же направления; известное значение выше отсутствующего;
  3. при полном равенстве — лексикографически меньший node.key.

Обратное LQI не подменяет отсутствующее прямое наблюдение и не усредняется с ним. relationship, указывающий на соседа той же или большей дистанции, игнорируется как устаревшее предпочтение, потому что нарушил бы инвариант.

Недостижимые от координатора компоненты не получают parent. Их прямые связи видны как сейчас, без стрелок; отсутствие стрелки и есть согласованный сигнал, что путь по snapshot неизвестен.

6.3. Нормализация relationship

Строковые значения нормализуются без учёта регистра, пробелов, дефисов и подчёркиваний. Числа Zigbee2MQTT преобразуются в канонические значения:

Значение Каноническая семантика
0 parent
1 child
2 sibling
3 none
4 previous_child

Неизвестное число или строка не отбрасывает link и LQI, а означает отсутствие предпочтения. Только parent влияет на tie-break выбора родителя; остальные значения сохраняются для диагностики, но не задают стрелку напрямую.

6.4. Инварианты

Для каждого построенного дерева должны выполняться свойства:

  1. у координатора нет parent;
  2. у каждого другого включённого узла ровно один parent;
  3. дистанция parent всегда на единицу меньше дистанции ребёнка;
  4. циклов нет;
  5. повторный переход по parent за конечное число шагов достигает координатора;
  6. одинаковый snapshot всегда даёт одинаковое дерево независимо от порядка nodes и links во входном массиве.

7. Контракт hover

7.1. Локальные endpoints

Hover по-прежнему берёт только incident links наведённого node.

  • если сосед выбран parent наведённого node, стрелка идёт от наведённого marker к соседу;
  • если наведённый node выбран parent соседа, стрелка идёт от marker соседа к наведённому marker;
  • если link не входит в дерево, линия остаётся без стрелки;
  • координатор не имеет исходящей стрелки, но может принимать входящие;
  • цвет, пунктир и LQI линии по-прежнему берутся из наблюдения от наведённого node к соседу, независимо от направления стрелки;
  • существующий halo каждого drawable локального соседа сохраняется.

Если одна и та же физическая пара неожиданно пришла из двух независимых instances, результаты instances не смешиваются в одно дерево. Совпадающие отрисованные линии могут быть дедуплицированы по marker endpoints; при противоречащих направлениях наконечник fail-closed не показывается.

7.2. Parent в другом пространстве

Если parent имеет drawable marker, но находится в другом пространстве, линия к его неизвестной экранной позиции не рисуется. Возле наведённого marker появляется один короткий parent bubble с названием пространства, и стрелка от marker направлена к bubble.

Название берётся из текущего server config и передаётся overlay как явная spaceId -> title проекция. Пустой или отсутствующий title не заменяется raw space id: используется локализованный безопасный fallback «другое пространство».

Parent, представленный отдельным bubble, исключается из старого агрегата +{n} в других пространствах, чтобы одна связь не считалась дважды. Остальные drawable remote соседи, не являющиеся parent наведённого node, продолжают участвовать в агрегате по контракту #54.

7.3. Parent без drawable marker

Если parent присутствует в полном графе, но не сопоставлен с drawable marker, показывается bubble со стрелкой:

Роль parent Текст bubble
coordinator «координатора нет на плане»
любая другая «устройства нет на плане»

Одинаковый текст «устройства нет на плане» применяется к отсутствующему, hidden/removed, ambiguous, HA-disabled, unmatched и отмеченному provider как недоступный parent. Эти технические причины не раскрываются в View.

Bubble создаётся только для parent наведённого устройства. Неразмещённые дети, для которых наведённый router является parent, не получают ни bubble, ни подпись. Остальные omitted endpoints остаются невидимыми, как в #54.

7.4. Нет известного пути

Если дерево не построено либо наведённый node не достижим от координатора, parent bubble не показывается и маршрут не выдумывается. Существующие прямые соседские линии, remote count и lifecycle hover продолжают работать.

8. UX и визуальная геометрия

Заменено #464: актуальный порядок active topology относительно room labels, посторонних markers и endpoints, а также casing unknown-LQI линии задаёт ТЗ #464. Геометрия и семантика route arrows/bubbles из этого раздела сохраняются.

  • Стрелка — заполненный треугольный наконечник цвета своей линии; новый цвет или legend не вводятся.
  • Геометрия наконечника рассчитывается в экранных пикселях, а не в процентах растянутого SVG. Изменение aspect ratio контейнера не сплющивает и не поворачивает стрелку неверно.
  • Наконечник локальной связи располагается перед halo целевого marker и не уходит под marker/halo. Линия остаётся визуально соединённой с endpoint.
  • Для входящей стрелки целью является halo наведённого marker; для исходящей — halo соседнего marker.
  • Bubble имеет screen-space отступ от наведённого marker, не перекрывает его hit area, остаётся pointer-events:none и не участвует в fit/bounds.
  • Короткая линия к bubble оканчивается у границы bubble, а наконечник направлен в bubble, не в произвольную точку плана.
  • При недостатке места bubble может сместиться на противоположную сторону marker, оставаясь внутри видимой области overlay; текст не обрезается сырой идентификационной строкой.
  • Arrow/bubble находятся в существующем topology layer: над архитектурой и decor, под device markers и tooltip.
  • Light/dark themes сохраняют существующую контрастность bubble. В forced-colors линия, наконечник и граница bubble используют системные Highlight / CanvasText / Canvas.
  • Hover cleanup, pointer ownership и действия устройств не меняются. Весь новый слой pointer-transparent.

9. Режимы, touch и доступность

Функция остаётся mouse-hover диагностикой полной карточки в View для администратора.

  • Touch/pen не получают стрелок, bubble или нового жеста; их событие немедленно очищает активный mouse hover по контракту #54.
  • Keyboard focus не имитирует hover.
  • В Plan, Devices, Background, kiosk и houseplan-space-card topology overlay не создаётся.
  • Новые элементы имеют aria-hidden и не образуют интерактивных tab stops, потому что дублируют временную визуальную диагностику без нового действия.
  • Существующие tap, long press, click, tooltip, pan/pinch и fit не меняются.

Это сознательно не добавляет touch-путь к административной radio-диагностике и не ослабляет touch-first контракт обычного View: сама функция остаётся выключенной по умолчанию и недоступна non-admin.

10. Модель данных, миграция и совместимость

Сохранённая конфигурация и topology provider payload не меняются. Новые данные существуют только в памяти:

type ZigbeeRouteParent = {
  parentNodeKey: string;
  distance: number;
};

type ZigbeeRouteDirection = 'toward-neighbor' | 'toward-origin' | 'none';

type ZigbeeParentTarget =
  | { kind: 'local'; markerId: string }
  | { kind: 'remote-space'; spaceId: string }
  | { kind: 'unplaced-device' }
  | { kind: 'unplaced-coordinator' };

Имена типов допускается уточнить без изменения поведения. Route projection вычисляется рядом с mapTopologies и мемоизируется на тот же snapshot + devices

  • registry lifecycle; она не пересчитывается на каждый render или HA state tick.

ZigbeeHoverLine расширяется направлением стрелки, а ZigbeeHoverResolution — максимум одним parent target для каждого обработанного instance. Raw IEEE, provider payload и внутренний node key не передаются в DOM, тексты или console.

Миграции нет. Старый config читается и сохраняется без изменений, downgrade игнорирует отсутствие runtime-only полей. Compatibility-поля не добавляются.

11. i18n

В src/i18n/topology/{en,ru,de,fr}.json добавляются одинаковые ключи:

  • route_device_not_on_plan;
  • route_coordinator_not_on_plan;
  • route_other_space — безопасный fallback, если title пространства пуст.

Название пространства остаётся пользовательским title и не переводится. Новые строки не включают IEEE, device id, space id или provider error text. Parity и dead-key проверки обязательны для четырёх тематических словарей.

12. Производительность и lazy boundary

  • Полный обход ограничен действующими лимитами: не более 1000 nodes и 6000 links на instance.
  • Построение расстояний и parent map имеет сложность O(V + E) и выполняется один раз на изменение topology/mapping, а не на hover.
  • Hover остаётся incident-only; он не запускает повторный BFS.
  • benchmark:zigbee-topology расширяется построением дерева и сохраняет текущие потолки normalize 80 ms, map 160 ms, first hover 180 ms, 20 repeated hovers 120 ms на 500-node fixture. Поднимать бюджеты в рамках задачи нельзя без отдельного измеренного решения.
  • Topology model/overlay/i18n остаются в существующем lazy chunk. Выключенная опция не загружает их и не выполняет route work.
  • Initial View graph остаётся в действующем bundle budget и ceiling.

13. Безопасность и privacy

Новых backend/API/service-call поверхностей нет. Стрелки используют только уже загруженный memory-only snapshot и не инициируют scan или fetch. Права остаются admin-only на существующем bridge.

IEEE, raw topology, внутренние ids и relationship не попадают в saved config, export, backup, diagnostics, support report, localStorage, DOM-текст или console. Пользовательский title пространства проходит существующий Lit text binding, не вставляется через unsafeHTML и не становится CSS.

14. Критерии приёмки и доказательства

AC1. Дерево всегда ведёт к координатору

Для каждого корректного instance ровно с одним координатором parent map ациклична, уменьшает дистанцию на каждом шаге и приводит каждый достижимый узел к координатору. Порядок входных arrays результат не меняет.

Доказательство: pure unit на цепочке, ромбе, циклическом mesh, disconnected component и перестановках nodes/links.

AC2. Parent выбирается детерминированно

Среди соседей предыдущего BFS-уровня соблюдается порядок: direct relationship=parent, затем direct LQI, затем stable node key. Противоречивый relationship не создаёт цикл и не уводит на тот же/следующий уровень.

Доказательство: table-driven unit, включая one-way observations и ties.

AC3. Z2M relationship сохраняется

Числа 0…4 реальной Z2M network-map fixture нормализуются в заявленные значения; неизвестное значение сохраняет link/LQI и не становится parent preference.

Доказательство: unit на обезличенной fixture и snake/camel-case payloads.

AC4. Локальные стрелки имеют правильное направление

При hover parent-связь наведённого устройства показывает стрелку от него к соседу; child-связь — от соседа к наведённому; non-tree link остаётся без наконечника. У координатора нет исходящей стрелки.

Доказательство: resolver unit + smoke_zigbee_topology_hover.mjs с DOM-атрибутом направления и pixel-geometry assertions.

AC5. Соседские линии не регрессируют

Количество существующих drawable incident links, их hovered-side LQI-цвет, unknown-пунктир и halo не меняются из-за route tree. Линия без известного пути остаётся обычной линией.

Доказательство: существующие unit/smoke assertions #54 плюс regression cases.

После #464 состав, LQI и геометрия линий по-прежнему не меняются, но unknown-LQI пунктир получает 1 CSS px внешний casing и новый порядок слоя.

AC6. Remote parent заканчивается названием пространства

Parent в другом пространстве получает короткую стрелку к bubble с title пространства; не создаёт линию к выдуманной позиции и не дублируется в remote_count. Остальные remote соседи продолжают учитываться агрегатом.

Доказательство: resolver unit + browser smoke с двумя пространствами.

AC7. Unplaced parent объяснён без утечки деталей

Неразмещённый coordinator показывает «координатора нет на плане», другой parent — «устройства нет на плане». Hidden, ambiguous, unmatched, HA-disabled и provider-unavailable parent используют тот же безопасный device-текст. Неразмещённые дети bubble не создают.

Доказательство: classification unit + browser smoke + DOM privacy assertions.

AC8. Неоднозначный или неизвестный корень fail-closed

При нуле/нескольких координаторах и для disconnected component стрелки и parent bubble не выдумываются, а обычные incident lines продолжают работать.

Доказательство: pure unit + smoke fallback case.

AC9. Arrow geometry остаётся экранной

На широком и высоком контейнере, при min/default/max zoom наконечник сохраняет размер и угол, располагается перед target halo; bubble line заканчивается у границы bubble. Layer остаётся pointer-transparent.

Доказательство: pure pixel-geometry unit + browser smoke на двух aspect ratios и трёх zoom; computed styles для pointer ownership.

AC10. Themes, forced colors и lifecycle сохранены

Light/dark/forced-colors читаемы; pointerleave, mouse→touch/pen, mode/space change, setting off и disconnect очищают стрелки и bubble вместе с линиями.

Доказательство: browser theme/modality/lifecycle smoke и CSS audit.

AC11. Производительность и lazy boundary не ухудшены

Route tree строится один раз на snapshot/mapping, benchmark проходит без увеличения бюджетов, disabled topology не грузит lazy chunks, bundle budget и initial View ceiling зелёные.

Доказательство: benchmark, call-count unit, browser resource spy, npm run bundle:budget.

AC12. Документация и локализация полны

Четыре topology-словаря имеют одинаковые ключи, руководства RU/EN объясняют семантику стрелки, approximation и случаи без стрелки; changelog RU/EN содержит пользовательское изменение со ссылкой на #457.

Доказательство: i18n tests, node scripts/check-docs.mjs, review diff.

15. План автотестов

  1. Расширить test/zigbee-topology.test.mjs pure suites для normalised relationship, BFS distances, parent tie-breaks, invariant/path traversal, input-order stability и zero/multiple coordinator fail-closed.
  2. Добавить hover projection cases: local parent/child/peer, remote parent с title, unplaced device/coordinator, hidden/ambiguous parent, unplaced child, disconnected node и remote-count deduplication.
  3. Вынести расчёт screen-space наконечника/обрезания линии в pure helper и проверить горизонталь, вертикаль, диагональ, короткую линию, разные marker размеры и границу bubble.
  4. Расширить demo/smoke_zigbee_topology_hover.mjs: direction attributes, наконечники, parent bubble, две формы контейнера, три zoom, light/dark, forced-colors emulation, pointer transparency и существующий cleanup.
  5. Расширить demo/benchmark_zigbee_topology.mjs route-build measurement в существующем mapMs либо отдельной метрикой внутри тех же суммарных потолков.
  6. Добавить mutation witnesses для удаления BFS-level guard, инверсии стрелки и повторного включения parent в remote count.
  7. Перед S7 выполнить npm run typecheck, npm test, npm run build, npm run bundle:sync, npm run bundle:budget, node scripts/check-docs.mjs, node scripts/smoke-select.mjs --base origin/dev --head HEAD, выбранные topology/device-action smokes, npm run benchmark:zigbee-topology и node scripts/no-new-any.mjs --base origin/dev --head HEAD.
  8. Golden baselines не добавляются: topology отсутствует в текущей golden matrix и появляется только после реального hover. npm run golden:verify должен подтвердить отсутствие изменений существующих эталонов. Поскольку меняется src/**, docs screenshots и их fingerprint обновляются штатным capture workflow по общему процессу.

16. Затронутые файлы и модули

Ожидаемые продуктовые поверхности:

  • src/zigbee-topology.ts — relationship normalization, route parent map, hover direction и parent-target classification;
  • src/hp-zigbee-topology-overlay.ts — screen-space arrowheads, bubble и forced-colors;
  • src/zigbee-topology-overlay-bridge.ts, src/houseplan-card.ts — проекция названий пространств без загрузки topology в initial graph;
  • src/i18n/topology/{en,ru,de,fr}.json — три новых строки.

Доказательные и документальные поверхности:

  • test/zigbee-topology.test.mjs и при необходимости отдельный pure geometry test;
  • demo/smoke_zigbee_topology_hover.mjs;
  • demo/benchmark_zigbee_topology.mjs;
  • scripts/mutation-gate.mjs и smoke-selection registry при необходимости;
  • docs/USER-GUIDE.md, docs/USER-GUIDE.ru.md;
  • docs/CHANGELOG.md, docs/CHANGELOG.ru.md;
  • manifest-driven dist/** и integration frontend после bundle:sync;
  • docs screenshot fingerprint/artifacts по обязательному check-docs flow.

Backend Python не меняется. Если реализация потребует новый House Plan API или provider fetch, это расширение скоупа и возврат в S3-spec.

17. Риски и меры

Риск Мера
Provider relationship противоречит графу Parent только на предыдущем BFS-уровне; relationship лишь tie-break
Стрелка сплющивается preserveAspectRatio=none Вся arrow geometry считается в screen pixels
Наконечник скрывается marker/halo Обрезать линию по фактическому target radius и проверять geometry smoke
Неразмещённый router искажает путь BFS использует полный graph, mapping применяется только при проекции
Стрелки прыгают между hover Stable node-key tie-break и permutation unit
Несколько provider instances смешиваются Parent map scoped by topology instance
Parent считается дважды как remote Явное исключение parent из remote_count и regression test
Текст раскрывает внутренний id Только space title или локализованный fallback, DOM privacy test
Hover начинает делать дорогой BFS Memo на snapshot/mapping, benchmark и call-count witness
Lazy-код протекает в initial View Bundle graph/budget gate и disabled resource spy

18. Откат

Пользовательский немедленный откат — выключить существующую опцию «Показывать связи Zigbee при наведении на устройство»: lazy overlay исчезает целиком и не выполняет route work.

Кодовый откат удаляет runtime-only parent projection, arrow/bubble rendering и три i18n-ключа, возвращая обычные линии #54. Сохранённый config и provider data не мигрируются, поэтому обратного преобразования данных нет.

19. Release-артефакты

  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md: одна пользовательская запись со ссылкой на #457 без публикации внутренних ключей/алгоритма;
  • docs/USER-GUIDE.md и docs/USER-GUIDE.ru.md: стрелка означает следующий шаг производного пути к координатору, а не фактический маршрут пакета; описать parent bubble и честное отсутствие стрелки;
  • golden: новых baselines нет; golden:verify подтверждает отсутствие регрессий существующей matrix;
  • docs screenshots: обновить штатным reviewed capture после изменения src/**;
  • performance: зелёные benchmark:zigbee-topology и bundle:budget;
  • security/privacy: DOM/serialization audit без новых backend surfaces;
  • generated bundles: синхронизировать только через npm run bundle:sync.

20. Принятые предположения

Ниже технические решения, не меняющие утверждённый владельцем пользовательский контракт; ревьюер может уточнить их без нового продуктового вопроса.

  1. Только сосед предыдущего BFS-уровня может стать parent; это формализует обязательный инвариант и безопаснее прямого доверия relationship.
  2. Если instance содержит не ровно одного coordinator, стрелки fail-closed не показываются, а существующие линии сохраняются.
  3. Parent в другом пространстве исключается из общего remote count, остальные remote-соседи остаются в нём.
  4. Пустой title пространства не заменяется raw id, а получает локализованный fallback.
  5. Новых golden baselines нет, потому что текущая матрица не активирует mouse-hover topology; screen-space геометрию защищает целевой browser smoke.
  6. Размер и точная форма наконечника определяются общими визуальными токенами overlay при условии соблюдения AC9, без новой пользовательской настройки.