58 KiB
ТЗ #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 есть две операции:
- Полная конфигурация — экспорт
config + live layout; импорт полностью заменяет текущую модель после подробного предпросмотра. - Одно пространство — экспорт текущего пространства с принадлежащими ему объектами и позициями; импорт всегда добавляет новое пространство и ничего существующего не заменяет.
Это резервная копия модели, а не архив всего медиаконтента. Локальные планы и вложения перечисляются в 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:
- «Вся конфигурация» — выбран по умолчанию;
- «Текущее пространство: