# ТЗ #44 — Явные фильтры и группировка устройств - Issue: https://github.com/Matysh/houseplan-card/issues/44 - Приоритет: P2, tech-debt; полный трек (discovery/UI/settings/i18n/tests — решение аналитики 2026-08-15) - Ревизия: 4 (2026-08-30) — по SPEC-REVIEW-44-r2 (M1 + Low); policy v1 принята владельцем, конкретизация под инбокс v1.69 - Связано: device inbox #29, field registry/манифест #33 (паспорта `allow-extra` обоих ключей уже выданы), персист-паттерн #377 ## Сценарий Хозяин плана открывает «Устройства» и на вкладке «Доступны» видит новый раскрываемый блок «Фильтры обнаружения». Там два честных элемента: тумблер «Объединять светильники комнаты в группу» (включён по умолчанию — как продукт и вёл себя всегда) и список «Исключённые интеграции» с рекомендованным набором, поиском по реально присутствующим в HA интеграциям и кнопкой «Вернуть рекомендуемые». Изменение показывает счётчики «появится / скроется / сгруппируется» до сохранения; Сохранить пишет настройки один раз. ## Что человек увидит до и после **До**: группировка света и исключение интеграций происходят молча; причина `excluded_integration` в инбоксе уже существует (#29), но безлична («Интеграция исключена фильтрами»). **После**: обе настройки видны и управляемы; текст причины называет интеграцию по имени («Исключена интеграция {integration}»); ничего не меняется само — только по Сохранить. ## Проблема Оба ключа — «третий вариант», запрещённый issue: влияют на результат, не видны, без migration policy. Факты кода: `group_lights` читается как `!== false` (default TRUE, `devices.ts:1033/1098`); `exclude_integrations` при наличии ЦЕЛИКОМ подменяет продуктовый `EXCLUDED_DOMAINS` (13 доменов, `rules.ts:12`; потребители `houseplan-card.ts:3924`, `space-render.ts:245`, discovery `devices.ts:1491`). ## Решение (policy v1, принята) Оба ключа — поддерживаемые advanced-настройки. Storage-имена и семантика НЕ меняются (обратная совместимость): `group_lights` отсутствие = true; `exclude_integrations` отсутствие = продуктовый список, наличие = полная замена. UI прячет эту механику за честными представлениями. ## Скоуп ### 1. Блок «Фильтры обнаружения» в инбоксе - Раскрываемая секция внизу вкладки «Доступны» диалога «Устройства» (editor-runtime; холодный View не задет — весь UI в runtime, класс #357). - **Тумблер группировки**: отражает `settings.group_lights` (unset→вкл). Выключение НЕ удаляет существующие маркеры (в т.ч. group-маркеры) — меняет только будущих кандидатов discovery. - **Исключённые интеграции**: чипы текущего действующего набора (ключ или продуктовый список); поиск-добавление по интеграциям, реально присутствующим в registry HA (platform-ы entity registry); удаление чипа; кнопка «Вернуть рекомендуемые» (= удалить ключ из settings). Если действующий набор равен продуктовому — ключ в settings отсутствует (паттерн «дефолт = отсутствие ключа», #377). - **Превью до сохранения**: счётчики «появится N / скроется M / группировка изменит K» — пересчёт кандидатов discovery на черновых значениях (чистая функция buildDevices уже принимает settings/excluded через ctx — используется она же, без дублирования логики). Список затронутых биндингов раскрывается (имена, без лишних id). - **Транзакция**: Изменения живут в черновике диалога; Сохранить пишет settings ОДИН раз штатным сериализованным путём (expected_rev #340); Отмена — no-op. Undo — штатный именованный снапшот конфига (паттерн существующих правок настроек). ### 2. Причина с именем интеграции (H1 r1: факт исправлен) Причина `excluded_integration` УЖЕ существует и рендерится на вкладке «Доступны» (#29, `houseplan-editor-runtime.ts:7637`, `device_inbox.reason_excluded_integration`) — но текст обобщённый. Скоуп этой задачи: тексту добавляется плейсхолдер `{integration}` с фактическим platform-именем кандидата; вкладка НЕ меняется (перенос категории — отдельный продуктовый вопрос, здесь не открывается). Значение reason и его механика не трогаются. ### 3. Registry/доки - `config-field-registry.mjs`: статусы обоих ключей `decision-required` → `current`, `ui: 'device inbox → Discovery filters'`, `enforcedBy` — новый смок; migration-строки — «none, supported setting». - USER-GUIDE(.ru): подраздел «Фильтры обнаружения» в теме инбокса. ## Не-скоуп - Изменение семантики replacement→additive у `exclude_integrations` (совместимость; additive-режим — отдельное решение, если понадобится). - Пер-пространственные фильтры; фильтры по доменам сущностей. - Изменение самого продуктового списка `EXCLUDED_DOMAINS`. - Материализация/`show_all` (закрыто ранее, #33 registry). ## Контракт поведения 1. Конфиг без ключей → поведение байт-в-байт сегодняшнее (default-путь не меняется ни в одном потребителе). 1a. (H2 r1) `roomClimateMap` (`devices.ts:1491`) — единственное место в `src/**`, хардкодящее `EXCLUDED_DOMAINS` мимо настройки, — переводится на тот же резолвер действующего набора исключений, что и discovery: климат комнаты следует за настройкой пользователя, а не за старым жёстким списком (иначе UI создаёт новый «третий вариант»). Явный climate-opt-in (галочка «этот датчик меряет воздух комнаты») по-прежнему сильнее исключения — существующая ветка `optClimate` не меняется. 2. Explicit-маркер (живой или tombstone) НИКОГДА не исчезает из-за смены фильтров — фильтры влияют только на автоматических кандидатов и seed. 3. Оба значения читаются из одного источника (`_settings`) всеми потребителями — новых копий состояния нет. 4. `Вернуть рекомендуемые` удаляет ключ (не пишет копию списка). 5. Никакой записи до явного Сохранить; двойная вкладка — штатный conflict. ## UX / i18n Новые ключи (en/ru/de/fr, все четыре — паритет-гейт): заголовок секции, label+hint тумблера, label списка, placeholder поиска, кнопка reset, счётчики превью (3), причина `device_inbox.reason_excluded_integration` (с плейсхолдером {integration}). Порядка 9 ключей. ## Модель данных и миграция Нет изменений хранения: ключи те же, семантика та же. Бэкенд-схему не трогаем: ключи остаются `allow-extra` (паспорта #33). Причина: перенос в строгую схему — отдельное решение с рисками отклонения старых конфигов; issue требует видимости и policy, не пере-схемизации. ## Критерии приёмки - **AC1** (смок): тумблер группировки выключить+Сохранить → в конфиге `group_lights: false`, повторное открытие показывает выкл, discovery- кандидаты содержат отдельные лампы вместо группы; обратное включение → ключ УДАЛЯЕТСЯ из settings (default = отсутствие). - **AC2** (смок): добавление интеграции в исключения → превью-счётчик «скроется» ненулевой; Сохранить → ключ в settings = полный действующий список; «Вернуть рекомендуемые» + Сохранить → ключа нет. - **AC3** (смок): у явного маркера с binding исключённой интеграции ничего не меняется (живой остаётся на плане, tombstone не трогается). - **AC4** (смок): кандидат исключённой интеграции виден на «Доступны» с причиной, называющей интеграцию по имени («Исключена интеграция demo_x»); вкладка и значение reason прежние (регресс-ветка). - **AC4b** (юнит, H2): `roomClimateMap` с настроенным `exclude_integrations` фильтрует по НЕМУ (интеграция вне списка даёт климат; продуктовая, вручную включённая в список — не даёт); без ключа — байт-в-байт сегодняшний результат; явный climate-opt-in побеждает исключение (регресс-ветка). - **AC5** (юнит): резолвер действующего набора: unset→продуктовый список, []→пусто (валидная «ничего не исключать»), список→список; тумблер: unset→true. - **AC6** (юнит): превью-диффер чист: те же ctx-входы, что у boевого buildDevices (контракт-проверка, что превью не дублирует логику фильтра). - **AC7**: отсутствие ключей → discovery-выдача байт-в-байт с сегодняшней (регресс-юнит на фикстуре). - **AC8**: полный гейт; i18n 4/4; бюджет: UI в editor-runtime (lazy), initial ≈ без изменений. ## План автотестов - Юниты: резолверы (AC5), превью-контракт (AC6), регресс дефолта (AC7), климат-фильтр (AC4b) — test/devices.test.mjs + test/device-inbox.test.mjs. - Смок `demo/smoke_discovery_filters.mjs` (новый): AC1–AC4 на демо-стенде с моковым config/set (паттерн #377-смока). - Мутанты: м1 — превью-счётчики от копии логики (подмена вызова общего билдера константой) → красный AC6/смок; м2 — «Вернуть рекомендуемые» пишет копию списка вместо удаления ключа → красный AC2. ## Риски - Пересчёт превью на большом registry — переиспользуется кэшируемый buildDevices; расчёт по явному действию (открытие секции/изменение), не на каждый тик. - Текст причины обогащается плейсхолдером на уже существующей вкладке «Доступны» — новых классов записей не появляется; смок проверяет отсутствие дублей. - Гонка вкладок — штатный conflict #340. - Перевод roomClimateMap на резолвер (H2) меняет климат ТОЛЬКО при заданном пользователем ключе — default-путь передаёт тот же продуктовый список (AC4b регресс-ветка + существующие климат-юниты #317 не слабеют). ## Откат `git revert`: UI и причина исчезают, ключи продолжают работать как до задачи (скрыто); записанные пользователями значения остаются валидными — семантика не менялась. Потери данных нет. **DoR-примечания:** миграция/compatibility — нет (семантика и имена ключей неизменны); touch — стандартные элементы диалога (чипы/тумблер), новых жестов нет; производительность — превью по явному действию. ## Release-артефакты - CHANGELOG + CHANGELOG.ru: user-visible запись (настройки фильтров стали видимыми и объяснимыми), ссылка #44. - USER-GUIDE(.ru): подраздел; ARCHITECTURE.md: абзац про policy «нет скрытых discovery-ключей» со ссылкой на registry. ## Принятые предположения - Размещение — вкладка «Доступны» (фильтры определяют её содержимое); если ревью ТЗ предпочтёт отдельную вкладку/шестерёнку — правится без смены механики. - `[]` (пустой список) — валидное значение «ничего не исключать», отличное от отсутствия ключа; так уже работает код (`list ? new Set(list) : EXCLUDED_DOMAINS`). - Причина остаётся на «Доступны», где уже живёт (#29); ни переноса категории, ни новой вкладки.