Files
houseplan-card/docs/specs/050-config-export-import.md
Matysh 9e74051652 Release v1.62.0-beta.8 candidate
Issue: #75
Issue: #76
Issue: #95
User-Visible: yes
2026-08-12 19:18:54 +03:00

58 KiB
Raw Permalink Blame History

ТЗ #50 — экспорт и импорт конфигурации House Plan

  • Issue: https://github.com/Matysh/houseplan-card/issues/50
  • Приоритет: P1, feature
  • Статус ТЗ: реализовано локально (ревизия 3; проверки запланированы на следующий пре-релиз по правилу владельца)
  • Связано: #33 (жизненный цикл схемы конфига), #51 (custom decor images), #85 (тесты должны уметь падать)
  • Security scope: только пользователь, которому разрешена запись политикой may_write (custom_components/houseplan/auth.py:16); файл намеренно не анонимизируется

0. Что изменено ревью 2026-08-11

Ревизия 1 писалась «по памяти о модели». Ревью сверило каждое утверждение с кодом v1.61.0; ниже — только то, что не сошлось, и принятое решение.

# Находка Решение
R1 PLAN_MODEL_VERSION на бэкенде не существует. ТЗ обещало «обязательный parity-test frontend/backend» и отказ при model_version > PLAN_MODEL_VERSION, но константа есть только во фронте (src/plan-optimizer.ts:27), в custom_components/** её нет ни одной. Сверять нечего Ввести PLAN_MODEL_VERSION = 6 в const.py и parity-тест — это входит в объём задачи (§5). Плюс: оптимизатор пишет поле только при modelFrom < PLAN_MODEL_VERSION && meaningfulChanged (plan-optimizer.ts:382-384), поэтому у большинства реальных конфигов поля нет — отсутствие трактуется как 0, а не как ошибка
R2 Полный импорт по обычному пути записи стёр бы картинки планов. ws_config_set после коммита зовёт collect_plans/collect_attachments (websocket_api.py:817-819), правило которых — «файл, на который ссылалась СТАРАЯ конфигурация и не ссылается новая, удалить» (plans.py:273-299). Полная замена конфига — ровно этот переход для всех планов сразу Apply не вызывает collect-функции (§7). Это то, что делает one-deep Undo настоящим. Суточный sweep безопасен: он передаёт один и тот же конфиг с обеих сторон (__init__.py:225-235), а непривязанные планы по второму правилу collect_plans сохраняются
R3 Импорт с недостающим планом был бы отклонён существующим инвариантом. _missing_internal_plans (websocket_api.py:714-732) блокирует запись кодом missing_plan для каждого нового внутреннего plan-url, которого нет на диске Отсоединение файлов — не вежливость UX, а обязательное условие записи. Preview выполняет ту же самую функцию и показывает её результат до применения (§5.3, §6.2)
R4 Три разных способа сохранять layout-стор, служебные ключи теряются. `layout/set update
R5 Два независимых Undo в одном UI. One-deep снапшот уже есть: optimize_backup/optimize_pending + флаг can_optimize_undo (websocket_api.py:40-58, houseplan-card.ts:539, кнопка :12332). ТЗ добавляло второй флаг can_import_undo и, значит, вторую кнопку на ту же ячейку Один слот, одна кнопка. К снапшоту добавляется поле kind: "optimize" | "import", ответы config/get/layout/get отдают can_optimize_undo + новый undo_kind; подпись кнопки зависит от kind (§6.3). Имена ключей стора не меняются — миграция не нужна, существующий recovery продолжает работать
R6 known_devices/new_device_ids — bookkeeping инстанса, а не модель. После импорта из чужого HA diffNewDevices объявит новыми все устройства цели (houseplan-card.ts:3407-3412) При source_fingerprint != target эти два поля не импортируются и это показано в preview; при совпадении отпечатков импортируются буквально (§8)
R7 Layout-записи без s (legacy render-units). houseplan-card.ts:3514-3518: отсутствие s = старые координаты, не привязанные к пространству Space export их не видит по определению (правило pos.s == space.id), full export переносит буквально. Preview показывает отдельной строкой «позиции без пространства: N» (§5.2)
R8 Список ссылок для remap был неполон — названы только open_to, room_id, room-label ключи и pos.s §9.1 получает полную таблицу полей модели: marker.controls[], marker.tap_target, marker.vacuum.segment_map, room.settings.temp_source|hum_source, opening.contact|lock, decor entity, layout-ключи lg_<entity_id> и v_* — с явным разделением «переименовать» / «сохранить буквально»
R9 Префикс import.* в i18n уже занят мастером «создать пространства из этажей HA» (6 ключей: import.title/hint/start/manual/progress/done) Все новые ключи — backup.*, секция настроек — gs.backup_group/gs.backup_hint (§4.1)
R10 Новая секция в «Общих настройках» валит смок с зашитым эталоном — demo/smoke_general_settings.mjs:53-56 фиксирует rows: 15 и массив из девяти групп Обновление эталона входит в объём задачи; оно же — естественный мутант по #85: если секция не отрендерилась, смок обязан покраснеть (§12)
R11 Golden для нового диалога требует ветки в harness. demo/golden/harness.mjs умеет открывать ровно два диалога (scenario.dialog === 'device' и 'decor-color'), плюс GOLDEN_MATRIX_VERSION (сейчас 10) надо поднять §12 явно включает работу по harness и bump версии матрицы
R12 Треки пылесосов ключуются marker_id из конфига и живут в отдельном сторе houseplan.trails без rev (trails.py:104, 135-141); сборщика сирот нет После полного импорта recorder не только refresh-ится, но и удаляет прогоны, чьих маркеров больше нет (§7). Для Undo это безопасно: треки не входят в snapshot и в §3 записаны как принятая потеря
R13 source_fingerprint требует HA instance_id, которого проект ещё не касался (homeassistant.helpers.instance_id — 0 вхождений в бэкенде) Явная новая зависимость: получить в async_setup_entry, положить в runtime data, солить домен-сепаратором (§5)
R14 MAX_EXPORT_BYTES не существует, а реальные лимиты конкретны: MAX_CONFIG_BYTES = 2 MiB (validation.py:114), MAX_LAYOUT = 5000, MAX_SPACES = 50, MAX_MARKERS = 2000 (validation.py:79-108); у HTTP-вьюх client_max_size не задан MAX_EXPORT_BYTES = 8 * 1024 * 1024, проверяется потоково до разбора JSON, ровно как в HouseplanUploadView (http_api.py:185-189) (§6.2, §9.3)
R15 owner_path в content manifest был JSON-pointer с индексами (/payload/config/spaces/0/plan_url) — ломается от любой пересортировки Ссылки по id: {"owner":"space","id":…,"field":"plan_url"} и {"owner":"marker","id":…,"url":…} (§5.3)
R16 Конфликт при apply был бы массовым из-за собственной вкладки. Позиции пишутся debounce-ом 600 мс через layout/update, у которого нет expected_rev (websocket_api.py:210-262), — любое перетаскивание в фоне двигает layout rev, и пользователю пришлось бы заново выбирать файл Добавлен houseplan/import/revalidate {token}: сервер уже держит проверенный candidate, пересчитывает preview и выдаёт свежие expected_*_rev, не требуя повторной загрузки файла (§6.2)
R18 controls перестанет быть целиком «буквальным» полем — ревью ТЗ #84 (2026-08-11) вводит внутренние ссылки marker:<id> внутри того же массива. Скопированные буквально, они после remap id указывали бы в пустоту §9.1 делит controls по префиксу значения: entity-ссылки буквально, marker:-ссылки ремапятся; ссылка за пределы импортируемого пространства удаляется и показывается в preview. Внести до коммита реализации, иначе понадобится миграция
R17 Единственная ws-команда без may_write — houseplan/content/sign (websocket_api.py:530), и это сделано намеренно: read-only зритель обязан видеть фон плана, а подпись выдаётся только на собственный namespace и привязана к refresh token вызывающего Не образец для новых endpoint-ов: export, preview, revalidate, apply и undo проверяют _runtime + may_write без исключений. Отдельный баг не заводится

0.1 Правки после code review локальной реализации

Ревизия 3 уточняет не продуктовый UX, а проверяемые инварианты реализации:

  • envelope сохраняет фактический model_version хранимого плана, включая 0 и future-значение; в payload.config это поле не дублируется и экспорт никогда не штампует текущую версию поверх данных;
  • apply повторно проверяет внутренние plan-файлы под тем же write_lock, что и revisions; исчезновение файла между preview и apply возвращает стабильный missing_plan;
  • при сбое paired commit handler один раз докатывает target, затем заменяет intent на rollback и восстанавливает before-pair. После ответа об ошибке restart не имеет права самостоятельно закончить импорт;
  • token проверяет digest нормализованного candidate при каждом обращении; одновременно в памяти хранится не более трёх parsed preview суммарно, а не по три на каждого writer;
  • HA-набор переименован в test_ha_import_export.py и расширен endpoint- проверками detach, remap, metadata, write failure, recovery и порядка events.

1. Цель

Дать владельцу переносимую резервную копию модели House Plan и безопасный способ перенести одно пространство между собственными Home Assistant.

В v1 есть две операции:

  1. Полная конфигурация — экспорт config + live layout; импорт полностью заменяет текущую модель после подробного предпросмотра.
  2. Одно пространство — экспорт текущего пространства с принадлежащими ему объектами и позициями; импорт всегда добавляет новое пространство и ничего существующего не заменяет.

Это резервная копия модели, а не архив всего медиаконтента. Локальные планы и вложения перечисляются в manifest, но их bytes в JSON не входят.

2. Зафиксированные решения

Ревизия 1 сняла следующие неоднозначности:

  • config и layout экспортируются одним согласованным снимком под общим write_lock, а не двумя независимыми frontend-запросами;
  • вместо невозможной буквальной транзакции двух HA Store используется crash-resumable paired commit с intent до первой записи;
  • полный импорт получает одно безопасное Undo до следующего изменения плана;
  • импортируемый файл разбирается строго на backend, до попадания недоверенного объекта во frontend runtime;
  • локальный URL с другой HA instance никогда не связывается с одноимённым случайным файлом на target;
  • правила владения layout-записями, remap внутренних id, повторного импорта и дубликатов HA binding определены явно;
  • future model не понижается молча; неизвестные поля поддерживаемой версии сохраняются благодаря существующему extra=vol.ALLOW_EXTRA (validation.py:709);
  • определён конечный UX, разрешения, error codes, recovery и release gate.

Ревизия 2 добавила к ним: переиспользование существующего слота Undo вместо второго (R5), запрет collect-функций на пути импорта (R2), единый helper записи layout-стора (R4), полную таблицу remap-ссылок (R8) и revalidate вместо повторного выбора файла (R16).

3. Не входит в v1

  • ZIP с локальными plan/PDF/image bytes;
  • облачная синхронизация или расписание резервных копий;
  • анонимизация entity/device ids, имён, ссылок или координат;
  • экспорт vacuum trails (houseplan.trails), runtime caches, подписанных URL, undo/command stacks;
  • merge полного backup в существующую модель;
  • импорт пространства поверх существующего пространства;
  • автоматический подбор «похожей» сущности на другой HA instance;
  • перенос dashboard/card YAML и настроек самого Home Assistant.

Portable ZIP с assets является отдельным следующим этапом. Будущие custom decor images из #51 подключаются к тому же content manifest, но не расширяют scope этой версии.

4. Пользовательский интерфейс

4.1 Точка входа

В «Общие настройки» (houseplan-card.ts:12236) перед разделом «Обслуживание планов» (gs.grid_group, :12325) появляется раздел «Резервная копия и перенос» — gs.backup_group + gs.backup_hint — с двумя кнопками в одном div.colorrow.gsrow, по образцу кнопки «Оптимизировать планы»:

  • «Экспортировать…» (mdi:download, backup.export_open);
  • «Импортировать…» (mdi:upload, backup.import_open).

Отдельные четыре кнопки для full/space не добавляются: вид импорта определяется из файла автоматически. В настройках конкретного пространства в v1 нет дублирующей кнопки.

Все новые i18n-ключи живут под префиксом backup.* — import.* занят мастером этажей (R9). Оба словаря пополняются в одном коммите: test/i18n.test.mjs:9 падает при расхождении.

Раздел виден только при _canEdit (houseplan-card.ts:652, зеркало серверного can_write). Backend независимо повторяет may_write для export, preview, revalidate, apply и undo.

4.2 Экспорт

Диалог «Экспорт House Plan» содержит radio:

  • «Вся конфигурация» — выбран по умолчанию;
  • «Текущее пространство: