Files
houseplan-card/docs/specs/050-config-export-import.md
T
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

753 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #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|delete` пишут по чёрному списку (`websocket_api.py:195, 259, 657`), `optimize` — через `layout_meta` (`:906`), а `geometry/repair` — по **белому** списку `{"layout","rev"[,"repair_backup"]}` (`:314`, `:346`) и молча стирает `optimize_backup`/`optimize_pending`/`geom_pending`. Новый intent импорта умер бы там же | Нормативно: единственный helper записи layout-стора, все существующие вызовы переводятся на него в рамках этой задачи (§7). Существующая потеря `optimize_backup` в `geometry/repair` выделена в отдельный баг #87 |
| 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:
- **«Вся конфигурация»** — выбран по умолчанию;
- **«Текущее пространство: <title>»** — выключено, если пространства нет.
Под вариантами явно написано:
- файл содержит реальные HA ids, имена, ссылки и координаты (в отличие от
HA-диагностики, которая часть полей вырезает — `diagnostics.py:12`);
- локальные изображения и вложения перечислены, но не вложены в JSON;
- trails и текущее состояние устройств не экспортируются.
Кнопка **«Скачать JSON»** запрашивает свежий backend snapshot и только после
ответа создаёт `Blob`, временный object URL и download. URL обязательно
revoke-ится. Экспорт ничего не записывает и не создаёт undo point.
Имена файлов:
- `houseplan-full-YYYY-MM-DD_HH-mm-ss.json`;
- `houseplan-space-<safe-title>-YYYY-MM-DD_HH-mm-ss.json`.
### 4.3 Импорт и предпросмотр
Кнопка «Импортировать…» использует скрытый `input[type=file]` с
`accept="application/json,.json"`. Выбор файла **не изменяет данные**. Файл
отправляется в backend preview endpoint, после чего открывается диалог с:
- именем и размером файла;
- видом экспорта, временем, версиями card/integration/model;
- признаком «эта HA instance» / «другая HA instance»;
- количеством пространств, комнат, стен, проёмов, декора, markers и layout;
- отдельной строкой «позиции без пространства (legacy): N» (R7);
- active / disabled / missing / virtual HA bindings;
- локальными assets: доступны, отсутствуют или непереносимы;
- всеми блокирующими ошибками и предупреждениями.
Неизвестный формат, неподдерживаемая будущая версия, невалидная schema или
превышение лимита дают read-only error dialog без кнопки применения.
### 4.4 Полный импорт
Preview показывает `current → incoming` минимум для spaces, rooms, markers и
layout и отдельный danger block:
> Текущая конфигурация и позиции будут полностью заменены. Локальные файлы на
> диске не удаляются. Отменить импорт можно только до следующего изменения.
Если локальные refs надо отсоединить, появляется обязательный checkbox
**«Импортировать без N локальных файлов»** со списком затронутых объектов.
Без него Apply недоступен — и это не вежливость, а условие записи (R3).
Footer построен на существующем контракте `dialog-action-footer` /
`dialog-action-danger` / `dialog-action-commit` (`styles.ts:2754-2776`):
«Отмена» и последней в DOM danger-кнопка **«Заменить конфигурацию»**. Initial
focus всегда на «Отмена»; Enter сам по себе не подтверждает замену.
Escape/close/scrim = cancel и ноль записей.
После успеха:
- закрываются открытые редакторские диалоги и жесты;
- очищаются локальные состояния: `_geometryHistory.clear()` (как в
`_runAlignToGrid`, `houseplan-card.ts:11597`), `_dirtyPos`/`_sentPos`,
`_defPos`, кэш `ContentSigner`, `_renderDeviceSnapshot`;
- выбирается прежнее пространство, если его id осталось, иначе первое;
- показывается toast с итоговыми counts;
- в том же разделе настроек появляется **«Отменить последний импорт»** —
та же кнопка, что и у оптимизации, с текстом по `undo_kind` (R5).
Undo доступно только пока не было ни одного последующего config/layout write:
любой обычный `config/set` вызывает `_discard_optimizer_snapshot`
(`websocket_api.py:804`). Новый импорт или Optimize заменяет предыдущий
одноразовый plan-wide снимок.
### 4.5 Импорт пространства
Preview показывает итоговое название (`Гараж`, `Гараж (2)`, …), counts,
bindings и предупреждение, что глобальные цвета, icon rules, Glow defaults и
прочие общие настройки берутся с target instance.
Для binding, уже размещённого/настроенного на target, доступна одна общая
политика:
1. **«Пропустить повторяющиеся устройства»** — безопасное значение по
умолчанию;
2. **«Добавить как статичные виртуальные обозначения»** — сохраняет позицию,
имя, явную иконку, размер и угол, но удаляет управление, vacuum, controls,
light/glow role и HA actions. Такой marker не влияет на данные плана.
Изменение политики пересчитывает preview до применения (через `revalidate`, без
повторного выбора файла). Footer: **«Отмена»**, **«Добавить пространство»**.
Это не destructive replacement, поэтому danger style не используется.
После успеха новое пространство выбирается автоматически. Оно может быть
удалено обычным существующим действием; отдельное Undo импорта пространства в
v1 не добавляется.
### 4.6 Responsive и accessibility
Оба сценария используют `hp-dialog`: focus trap, initial/restore focus, Escape
через существующий реестр поверхностей (`hp-dialog.ts:345`), scrollable body и
общий responsive footer. На узком экране нет горизонтального scroll; длинные
title/file names переносятся или сокращаются с полным `title`. Статус
чтения/preview/apply объявляется через `aria-live`.
Импорт/экспорт — безопасная dialog-based maintenance operation, поэтому она
должна работать на desktop и touch. Точные редакторские операции по-прежнему
остаются desktop-first согласно `docs/TOUCH-SUPPORT.md`.
## 5. Формат export v1
Один UTF-8 JSON без BOM:
```json
{
"format": "houseplan-export",
"export_version": 1,
"kind": "full|space",
"created_at": "2026-08-11T12:34:56Z",
"source_fingerprint": "sha256:…",
"card_version": "1.61.0",
"integration_version": "1.61.0",
"model_version": 6,
"payload": {
"config": {},
"layout": {}
},
"placement_manifest": [],
"content_manifest": []
}
```
`source_fingerprint` — SHA-256 от `houseplan-export-v1|` + HA `instance_id`
(`homeassistant.helpers.instance_id.async_get`, новая зависимость — R13);
исходный instance id в файл не пишется. Значение нужно только для безопасного
отличия собственной storage от чужой и не используется как секрет.
`model_version` повторяет фактически сохранённую версию модели; **отсутствие
поля в конфиге означает `0`** и ошибкой не является (R1). Поле живёт только в
envelope и удаляется из копии `payload.config`: это исключает два источника
истины и молчаливое понижение future-модели при экспорте. Изменения envelope
требуют нового `export_version`. Импорт с `export_version > 1` или
`model_version > PLAN_MODEL_VERSION` отклоняется без downgrade.
В объём задачи входит:
- завести `PLAN_MODEL_VERSION = 6` в `custom_components/houseplan/const.py`;
- parity-тест: значение в `const.py` равно `PLAN_MODEL_VERSION` из
`src/plan-optimizer.ts:27` (сканер исходника, как это уже сделано для
`MAX_SIGN_PATHS` — `logic.ts:1829`).
### 5.1 Full payload
- `payload.config` — полный текущий config после backend schema normalization,
кроме envelope-поля `model_version`;
- `payload.layout` — результат `_live_layout(config, stored_layout)`
(`websocket_api.py:114`), без позиций удалённых маркеров и сиротских `v_*`;
функция выносится из ws-модуля в общий helper и **переиспользуется**, а не
копируется;
- `placement_manifest` может быть пустым: full import не remap-ит ids.
Служебные ключи layout-стора (`rev`, `geom_pending`, `optimize_pending`,
`optimize_backup`, `repair_backup`) в экспорт не попадают.
### 5.2 Space payload
`payload.config` содержит только:
```json
{ "spaces": [/* ровно одно */], "markers": [/* только владельцы space */] }
```
Глобальный `settings` не входит и при импорте не меняется. Пространство входит
целиком: rooms, walls, drafts, partitions, columns, open spans, openings,
decor, backdrop transforms (`plan_x/plan_y/plan_scale*/plan_angle`) и
собственные `settings`.
В space export входят live layout records с `pos.s == space.id` (включая поле
`k` — сохранённый масштаб карточки комнаты, 0.5..3, `houseplan-card.ts:14065`)
и marker без `removed`, если выполняется хотя бы одно условие:
- его id владеет одной из этих layout positions;
- `marker.space == space.id`;
- `marker.room_id` указывает на room экспортируемого пространства.
Записи layout **без поля `s`** — legacy-координаты в render-units
(`houseplan-card.ts:3514-3518`) — не принадлежат пространству, в space export не
входят и в full export переносятся буквально; их количество показано в preview
(R7).
Global removed tombstones, `known_devices`, `new_device_ids` и прочие global
settings в space export не попадают.
`placement_manifest` классифицирует каждую layout key:
```json
{
"layout_id": "…",
"owner": "marker|room_label|light_group|auto_device",
"binding": "device:…|entity:…|virtual|null",
"owner_id": "…",
"label": "…",
"icon": "…"
}
```
Это позволяет не потерять layout-only auto-discovered device (ключ = HA
`device_id`, `devices.ts:688`) и группу света (`lg_<entity_id>`,
`devices.ts:712`): на другой instance они превращаются в явный orphan marker, а
не исчезают из импорта.
### 5.3 Content manifest
Manifest содержит только ссылки, без bytes, и адресует владельца по **id**, а не
по индексу в массиве (R15):
```json
{
"kind": "plan|attachment|decor_asset",
"owner": "space|marker",
"owner_id": "loft",
"field": "plan_url",
"url": "/api/houseplan/content/plans/_/loft.ab12cd34.svg",
"storage": "internal|external",
"exists_at_export": true
}
```
Текущие обязательные owners: `space.plan_url` и `marker.pdfs[].url`
(источник истины по составу — `logic.ts:1851 referencedContentUrls`);
`decor_asset` подключается после появления соответствующего поля из #51. Signed
URL, auth token и runtime-resolved URL никогда не экспортируются: в конфиге и
так лежит сырой путь, подпись живёт только в рантайме (`signing.ts`).
Правила импорта:
| Ссылка | Результат |
|---|---|
| external schema-valid URL | сохраняется без сетевого запроса |
| internal, тот же fingerprint, файл существует | сохраняется |
| internal, тот же fingerprint, файл исчез | preview требует отсоединения |
| internal, другой fingerprint | считается непереносимой даже при совпадении имени |
Отсоединение означает `plan_url = null` либо удаление соответствующего PDF ref.
Проверка существования — та же функция, что защищает обычную запись:
`_missing_internal_plans` (`websocket_api.py:714`). Без отсоединения apply
физически не может завершиться: он вернул бы `missing_plan` (R3).
Ни preview, ни apply не удаляют файлы с диска и не выполняют внешние HTTP GET.
## 6. Backend API
### 6.1 Export
```text
houseplan/export/create
{kind: full|space, space_id?, card_version}
→ {document, filename}
```
Backend под `write_lock` (`store.py:45` — он общий для обоих сторов) одним
чтением получает config и layout, строит live snapshot, manifest и metadata.
Lock удерживается только на чтение/копирование, не на frontend download.
Направление «сервер → клиент» размерных ограничений WS не создаёт: конфиг
ограничен `MAX_CONFIG_BYTES = 2 MiB` (`validation.py:114`).
### 6.2 Import preview и revalidate
Недоверенный JSON не парсится в карточке, и WS для загрузки не используется:
входящее направление ограничено и уже занято `config/set`. Frontend отправляет
файл как raw body в новый authenticated view — по образцу
`HouseplanUploadView` (`http_api.py:115`), регистрация в `async_setup`
(`__init__.py:35-38`), `requires_auth = True`, `may_write` внутри:
```text
POST /api/houseplan/import/preview?duplicate_policy=skip|virtual
→ {token, preview, expected_config_rev, expected_layout_rev, expires_at}
```
Endpoint streaming-читает не больше `MAX_EXPORT_BYTES = 8 MiB` (новая константа,
проверка по мере чтения, ответ `413`, как в `http_api.py:185-189`), строго
разбирает JSON, валидирует envelope, `CONFIG_SCHEMA`, `LAYOUT_SCHEMA`, content
policy и итоговые collection/byte caps после merge. Никакие HA services или
store writes во время preview не выполняются.
```text
houseplan/import/revalidate
{token, duplicate_policy?}
→ {token, preview, expected_config_rev, expected_layout_rev, expires_at}
```
Пересчитывает preview на актуальных revision из уже проверенного candidate —
это ответ на смену политики дубликатов и на конфликт, вызванный собственной
фоновой записью позиций (`layout/update` без `expected_rev`, debounce 600 мс —
R16). Повторно выбирать файл пользователь не должен.
Preview token:
- криптографически случайный, одноразовый на apply, TTL 10 минут;
- связан с user id, digest файла, выбранной duplicate policy и обеими rev;
- хранит уже проверенный candidate только в памяти;
- максимум три token на пользователя и одновременно максимум три token на весь
runtime (parsed JSON занимает больше wire bytes); старейший вытесняется;
- не переживает restart и никогда не пишется в лог целиком.
### 6.3 Apply и Undo
```text
houseplan/import/apply
{token, expected_config_rev, expected_layout_rev,
confirm_missing_content: bool}
→ {ok, kind, config_rev, layout_rev, counts, can_undo}
```
Undo отдельной команды не получает: используется существующая
`houseplan/plan/optimize_undo` (`websocket_api.py:941`) — тот же слот
`optimize_backup`, та же проверка `_optimizer_backup_is_current`, те же
`expected_config_rev`/`expected_layout_rev`. К снапшоту добавляется поле
`kind: "optimize" | "import"`; `config/get` и `layout/get` рядом с существующим
`can_optimize_undo` отдают `undo_kind`, и подпись кнопки выбирается по нему
(R5). Имена ключей стора не меняются — миграции не требуется, setup-recovery
(`__init__.py:156-193`) продолжает работать как есть.
Apply повторно проверяет `may_write`, owner/TTL/single-use token и обе текущие
revision. Любой mismatch возвращает `conflict`/`preview_expired`; клиент
вызывает `revalidate`, а не присылает candidate повторно. Full import создаёт
backup; space import — нет.
## 7. Согласованная запись двух Store
Буквальная atomic transaction между `.storage/houseplan.config` и
`.storage/houseplan.layout` невозможна. Механизм уже существует дважды
(`geom_pending` — `__init__.py:112-154`, `optimize_pending` —
`websocket_api.py:900-925`); третий не заводится. Обе половины импорта проходят
через **тот же** paired commit:
1. Под `write_lock` повторно загрузить stores и сравнить revisions.
2. Сохранить в layout store intent (`optimize_pending` + `kind: "import"`) с
target config/layout и before snapshot.
3. Сохранить target config.
4. Сохранить target layout и удалить pending intent; для full import оставить
one-deep backup.
5. Только после обеих durable writes отправить `houseplan_config_updated` и
`houseplan_layout_updated` и success response.
При сбое любой записи handler один раз повторно сходится к target, учитывая
fail-after-write. Если это не удалось, он сначала заменяет durable intent на
`kind: import_rollback`, затем сходится к before-pair. Поэтому error response
никогда не оставляет intent, который при restart сам закончит импорт; при
аварии уже во время rollback setup-time recovery идемпотентно восстанавливает
before-pair до регистрации API. Events никогда не посылаются для
полуприменённой пары.
Нормативно (R4): запись layout-стора выполняется **единственным** helper-ом,
который сохраняет все служебные ключи и заменяет только `layout` и `rev`. Все
существующие вызовы (`websocket_api.py:195, 259, 314, 346, 657, 913, 985`)
переводятся на него в рамках этой задачи; белый список в `geometry/repair`
(`:314`, `:346`) сегодня стирает `optimize_backup`/`geom_pending` — это баг #87,
и он же — готовый мутант для §12.
Space import использует тот же paired commit без пользовательского Undo: это
гарантирует, что новый space и его positions не разъедутся при сбое.
Файлы (R2). Путь импорта **не вызывает** `collect_plans` и
`collect_attachments`: для них полная замена конфига выглядит как «старый
референс исчез» и они удалили бы картинки планов предыдущей конфигурации,
обесценив Undo. Суточный sweep (`__init__.py:210-244`) безопасен: он передаёт
один и тот же конфиг с обеих сторон, а непривязанные plan-файлы сохраняются по
второму правилу `collect_plans` (`plans.py:288-296`). Отсоединённые файлы
пользователь удаляет существующим менеджером планов.
После успешного full import выполняются:
- `_refresh_trail_recorder(hass)` и удаление прогонов, чьих маркеров больше нет
(`houseplan.trails` ключуется `marker_id` из конфига — `trails.py:135-141`,
собственного GC у книги нет, R12);
- `async_check_plan_files` — пересчёт repair-issue `broken_plan_<space_id>`
(`repairs.py:18`).
## 8. Правила полного импорта
- Incoming config/layout полностью заменяют current config/live layout.
- Revisions, optimizer/import pending/backup и runtime metadata из файла
игнорируются: новый target получает собственные revisions.
- Current normalized data roundtrip сохраняет config/layout эквивалентно,
включая поля, проходящие через `extra=vol.ALLOW_EXTRA`.
- Known lifecycle fields с `vol.Remove` (`aspect` — `validation.py:520`,
`segments` — `:595`) и допустимые coercions показываются в preview как
normalization; это не скрытая Optimize operation.
- `settings.known_devices` и `settings.new_device_ids` импортируются только при
совпадении `source_fingerprint`; при импорте с чужой instance они
отбрасываются, иначе все устройства target станут «новыми»
(`houseplan-card.ts:3407-3412`, R6). Это отдельная строка preview.
- Missing, disabled, removed, virtual markers и vacuum calibration сохраняются.
- Missing/disabled HA bindings не блокируют импорт и после него обрабатываются
существующим binding lifecycle.
- HA area ids комнат сохраняются буквально; preview отдельно показывает
отсутствующие target areas, автоматического matching по названию нет.
- Full import не выполняет entity actions и не меняет HA registry.
## 9. Правила импорта пространства
### 9.1 Новое пространство, ids и ссылки
- Import всегда создаёт новый safe space id (`SPACE_ID_RE`, `validation.py:17`).
- Title сравнивается после trim + Unicode normalization + casefold; конфликт
получает ` (2)`, ` (3)` и далее.
- Все space-owned ids всегда получают новые ids, даже если сейчас не
конфликтуют: room, room draft, partition, column, opening и decor.
- Геометрические wall keys и `open_spans` не являются object ids и
пересчитываются/сохраняются по действующему wall contract, а не получают
случайный suffix.
- Одна immutable remap map переписывает все внутренние ссылки.
| Поле | Действие |
|---|---|
| `room.id`, `opening.id`, `decor.id`, `partition.id`, `wall_column.id`, `room_draft.id`, `marker.id`, `space.id` | новый id |
| `room.open_to[]` | remap на новые room id |
| `marker.room_id`, `marker.space` | remap |
| `marker.vacuum.segment_map` (значения — room id; поле объявлено в `types.ts:110`, потребителя пока нет) | remap, чтобы не оставить мину |
| layout keys `<marker_id>`, `rl_<room_id>`, `v_*` | новые ключи по той же map |
| `pos.s` | новый space id; `k` сохраняется |
| `marker.binding`, `marker.tap_target`, `marker.vacuum.source` | **буквально**, guessed matching запрещён |
| `marker.controls[]` — значения `light.*`/`switch.*` | **буквально** |
| `marker.controls[]` — значения `marker:<id>` (вводятся задачей #84) | remap по той же map, что и `marker.id`; ссылка на маркер вне импортируемого пространства **удаляется**, а не остаётся битой, и считается в preview отдельной строкой |
| `room.area`, `room.settings.temp_source`, `room.settings.hum_source` | **буквально** |
| `opening.contact`, `opening.lock`, decor `entity` | **буквально** |
| `marker.vacuum.calibration` (ключи — map id робота, `vacuum.ts:80`) | **буквально** |
| `space.plan_url`, `marker.pdfs[].url` | по правилам §5.3 |
| layout keys вида HA `device_id` и `lg_<entity_id>` | **буквально**: это идентификаторы HA, а не наши |
- Повторный импорт того же файла создаёт ещё одно независимое пространство и
не меняет результат первого импорта.
### 9.2 HA bindings
- `device:`/`entity:` ids сохраняются буквально; guessed matching запрещён.
- Room `area` ids сохраняются буквально; отсутствующий area не блокирует
геометрию комнаты и явно отмечается в preview.
- Отсутствующий target binding импортируется как явный orphan marker.
- Disabled binding импортируется, но остаётся скрытым по существующему правилу
disabled devices.
- `controls` и другие entity refs сохраняются; runtime отдельно фильтрует
недоступные targets.
- Binding конфликтует, если target уже имеет live, hidden, removed tombstone
или layout-only placement с тем же binding. Ничего не реактивируется молча.
- Для конфликта действует выбранная в preview политика `skip` или безопасная
virtual visual copy из §4.5.
### 9.3 Итоговые limits
До выдачи apply token валидируется **merged target**, а не только файл:
| Лимит | Значение | Источник |
|---|---|---|
| `MAX_CONFIG_BYTES` | 2 MiB | `validation.py:114` |
| `MAX_SPACES` | 50 | `validation.py:79-108` |
| `MAX_ROOMS` | 400 | там же |
| `MAX_MARKERS` | 2000 | там же |
| `MAX_LAYOUT` | 5000 | там же |
| `MAX_DECOR` / `MAX_OPENINGS` / `MAX_WALLS` | 1000 / 500 / 500 | там же |
| `MAX_EXPORT_BYTES` | 8 MiB | новая, `const.py` |
Плюс все inner collection caps. При превышении preview read-only и ничего не
записывается.
## 10. Безопасность и приватность
- UI visibility не считается authorization; каждый новый endpoint вызывает
`may_write` (`auth.py:16`) и `_runtime`. Исключение `content/sign`
(`websocket_api.py:530`) сделано ради read-only зрителей и образцом не
является (R17).
- JSON содержит персональные HA ids/имена/ссылки; это прямо указано до export.
В отличие от `diagnostics.py`, здесь ничего не редактируется намеренно.
- Strict parser отклоняет NaN/Infinity, duplicate keys, BOM после первого
символа, trailing data и рекурсивные `__proto__`/`prototype`/`constructor`.
- Path traversal, неизвестный internal content namespace и неканонический
internal URL дают invalid content error; канон — `CONTENT_URL` +
`/plans/_/<name>` и `/files/<marker_id>/<name>` (`const.py:13`,
`websocket_api.py:1075`, `http_api.py:248`).
- Preview не возвращает payload обратно в DOM: только bounded summary и token.
- Raw document, entity ids, titles, coordinates и links не логируются; допустимы
digest, bytes, kind, counts и stable error code.
- Preview/apply не вызывают HA services и не загружают external URLs.
- Cancel, validation error, conflict, expired token и disconnect дают ноль
store writes.
- Backend export создаёт обычный JSON response; никакая внешняя telemetry не
используется.
## 11. Stable error codes
Минимальный набор:
- `unauthorized`, `not_ready`, `too_large`;
- `invalid_json`, `invalid_format`, `unsupported_export_version`;
- `future_model`, `invalid_config`, `invalid_layout`, `invalid_content`;
- `space_not_found`, `capacity_exceeded`;
- `preview_expired`, `preview_owner_mismatch`, `conflict`;
- `content_confirmation_required`, `missing_plan`, `missing_content`,
`commit_failed`, `no_backup`.
Preview обязан заранее поймать недостающий plan и предложить отсоединение
(§5.3). Но файл может исчезнуть после preview, поэтому apply повторяет проверку
под write-lock и в этой гонке возвращает стабильный `missing_plan`. Та же
проверка применяется к локальным PDF-вложениям и возвращает
`missing_content`, если файл исчез после preview. Frontend локализует code;
backend message остаётся диагностикой, но не является единственным
пользовательским текстом.
## 12. Проверки
### Backend (`tests_backend/`, HA-тесты в файлах `test_ha_*.py`)
- export во время конкурентной записи даёт одну согласованную пару revisions;
- current normalized full roundtrip сохраняет config + live layout + unknown
supported fields;
- strict parser и каждый envelope/schema/size/content error не пишут stores;
- preview token: owner, TTL, cap, policy, single-use, conflict; `revalidate`
выдаёт свежие rev и не требует повторной загрузки файла;
- full paired commit: success, failure каждой write, crash recovery, events;
- **apply не удаляет файлы**: план предыдущей конфигурации остаётся на диске
после полной замены (прямая проверка R2);
- **apply с недостающим внутренним планом** без отсоединения отклоняется, с
отсоединением проходит (R3);
- Undo работает один раз, отдаёт `undo_kind: "import"` и становится stale после
любого следующего edit; Optimize и импорт делят один слот (R5);
- запись layout-стора любым существующим хендлером сохраняет служебные ключи
(регрессия на `geometry/repair`, R4);
- space paired commit не оставляет config без layout и наоборот;
- remap всех ids/references из таблицы §9.1, повторный импорт,
title/layout/marker collisions;
- layout-only device, `lg_*`, legacy-запись без `s`, orphan, disabled, hidden,
removed, virtual и duplicate binding с обеими policies;
- vacuum calibration/segment map сохраняются, trails отсутствуют, сироты
трек-книги удаляются после full import (R12);
- `known_devices`/`new_device_ids` отбрасываются при чужом fingerprint и
сохраняются при своём (R6);
- same-instance internal asset сохраняется; missing/cross-instance требует
detach confirmation; external URL не fetch-ится;
- merged collection/byte caps проверяются до token;
- parity-тест `PLAN_MODEL_VERSION` между `const.py` и `plan-optimizer.ts` (R1).
### Frontend (`test/*.test.mjs`, `demo/smoke_*.mjs`)
- раздел виден только writer; две кнопки имеют правильные accessible names;
- обновлён эталон `demo/smoke_general_settings.mjs` (`rows`, массив групп) —
без этого смок красный (R10);
- паритет RU/EN для всех `backup.*` (`test/i18n.test.mjs`);
- full/space export формируют корректный download и revoke object URL;
- import file picker не парсит payload и не пишет state до preview/apply;
- invalid/too-large/future files показывают локализованную read-only ошибку;
- full preview: counts, missing-content checkbox, focus, cancel, danger apply;
- space preview: suffix, target-global warning, смена duplicate policy идёт
через `revalidate`;
- success reload очищает stale dialogs/caches и выбирает корректное space;
- cancel/Escape/scrim/double click/disconnect не применяют token дважды;
- desktop + narrow touch: footer не обрезан, horizontal scroll отсутствует;
- RU/EN golden для export dialog, full destructive preview и space preview:
требуется новая ветка в `demo/golden/harness.mjs` (сейчас поддержаны только
`device` и `decor-color`) и bump `GOLDEN_MATRIX_VERSION` (R11).
### Mutation/negative gate (контракт #85)
Задача не считается выполненной, пока не показано, что тесты краснеют при:
1. возврате `collect_plans`/`collect_attachments` на путь apply — тест «план
предыдущей конфигурации остался на диске» обязан упасть;
2. пропуске detach-проверки — apply обязан упасть на `missing_plan`;
3. записи layout-стора белым списком (как в `geometry/repair`) — снапшот Undo
обязан пропасть и тест обязан это заметить;
4. приёме payload от клиента вместо preview token;
5. переиспользовании исходных ids при space import;
6. отправке event между двумя Store writes;
7. удалении секции из «Общих настроек» — смок обязан покраснеть на эталоне.
## 13. Acceptance criteria
1. Writer скачивает один versioned JSON для всей модели или текущего space.
2. Full export является согласованным snapshot config + live layout.
3. Full import ничего не меняет до preview и явной danger-кнопки, затем заменяет
оба stores согласованно и предоставляет one-deep Undo — **тот же самый**, что
у оптимизации, одной кнопкой.
4. Полная замена конфигурации не удаляет ни одного файла с диска.
5. Space import всегда добавляет независимое пространство, remap-ит все
внутренние ids/references из таблицы §9.1 и не перезаписывает существующие
данные.
6. Missing/disabled bindings, duplicates и layout-only devices имеют
предсказуемое поведение из §9; guessed matching отсутствует.
7. Локальные assets другой instance не подменяются одноимёнными target files;
их отсоединение всегда видно и отдельно подтверждено.
8. Invalid, future, oversize, stale, unauthorized и interrupted операции не
оставляют наблюдаемую несогласованную пару config/layout.
9. Trails, runtime state, revisions, signed tokens и undo metadata в export не
попадают; import не вызывает HA services и внешнюю сеть.
10. UI доступен с клавиатуры, не обрезается на узком экране и локализован RU/EN.
11. Документация честно называет JSON backup моделью без media bytes.
## 14. Release artifacts
В том же release cycle обязательны:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #50;
- новый раздел backup/transfer в `docs/USER-GUIDE.ru.md`;
- обновление `docs/ARCHITECTURE.md`: новый HTTP view, paired commit, token,
manifests, единый helper записи layout-стора;
- `docs/STATUS.md` после реализации;
- RU/EN goldens из §12, обновлённый `demo/smoke_general_settings.mjs` и
backend/frontend test inventory;
- короткий release body: пользовательская ценность, без перечисления protocol.