Merge issue #167 into dev

Issue: #167
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-17 13:25:31 +03:00
27 changed files with 1346 additions and 73 deletions
+15 -3
View File
@@ -13,6 +13,11 @@ name: Mutation gate
on:
workflow_dispatch:
inputs:
ref:
description: Git ref whose mutation guards must be proved
required: false
default: dev
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
@@ -27,13 +32,13 @@ concurrency:
jobs:
mutants:
runs-on: ubuntu-latest
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
# гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
with:
ref: dev
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
fetch-depth: 0
- uses: actions/setup-node@v7
@@ -43,6 +48,13 @@ jobs:
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Установить backend test dependencies
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
File diff suppressed because one or more lines are too long
+249 -31
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import copy
import hashlib
import json
import math
import re
import secrets
import time
@@ -53,6 +54,34 @@ from .validation import (
FORMAT = "houseplan-export"
_PROTO_KEYS = {"__proto__", "prototype", "constructor"}
_SAFE_FILE = re.compile(r"[^A-Za-z0-9._-]+")
_LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
_LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_PLAN_ONLY_DASH = "—"
_SPACE_PLAN_FIELDS = (
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
"plan_scale", "plan_scale_x", "plan_scale_y", "plan_angle", "view_box",
)
_SPACE_DISPLAY_FIELDS = (
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
"fill_mode", "custom_fill", "glow_enabled", "temp_min", "temp_max",
"show_lqi", "hide_decor", "hide_openings", "label_temp", "label_hum",
"label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode",
"sun_rays",
)
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")
_ROOM_DISPLAY_FIELDS = (
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
)
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
_DECOR_KIND_FIELDS = {
"line": ("x1", "y1", "x2", "y2", "line_style"),
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
}
class ImportFailure(Exception):
@@ -139,6 +168,134 @@ def live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any
}
def _pick_fields(source: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
"""Copy only fields explicitly classified as portable plan data."""
return {key: _json_copy(source[key]) for key in fields if key in source}
def _is_live_text_reference(raw: str) -> bool:
"""Mirror ``liveTextReference`` without evaluating Home Assistant state."""
ref = raw.strip()
if not ref:
return False
entity = ref
attribute = ""
colon = ref.find(":")
if colon >= 0:
entity = ref[:colon].strip()
attribute = ref[colon + 1:].strip()
else:
parts = ref.split(".")
if len(parts) > 2:
entity = ".".join(parts[:2])
attribute = ".".join(parts[2:])
if _LIVE_TEXT_ENTITY.fullmatch(entity) is None:
return False
if colon >= 0 and not attribute:
return False
return not attribute or _LIVE_TEXT_ATTRIBUTE.fullmatch(attribute) is not None
def _plan_only_text(value: str) -> str:
"""Freeze every recognized live reference while preserving authored copy."""
replaced = _LIVE_TEXT_TOKEN.sub(
lambda match: _PLAN_ONLY_DASH
if _is_live_text_reference(match.group(1)) else match.group(0),
value,
)
return replaced.replace("{}", _PLAN_ONLY_DASH)
def _project_plan_only_room(room: dict[str, Any]) -> dict[str, Any]:
projected = _pick_fields(room, _ROOM_PLAN_FIELDS)
if "settings" in room:
settings = room.get("settings")
projected["settings"] = (
_pick_fields(settings, _ROOM_DISPLAY_FIELDS)
if isinstance(settings, dict) else None
)
return projected
def _project_plan_only_decor(shape: dict[str, Any]) -> dict[str, Any]:
kind = str(shape.get("kind", ""))
projected = _pick_fields(
shape, _DECOR_COMMON_FIELDS + _DECOR_KIND_FIELDS.get(kind, ()),
)
if kind == "text" and isinstance(projected.get("text"), str):
projected["text"] = _plan_only_text(projected["text"])
return projected
def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
"""Build the fail-closed geometry/presentation projection for #167."""
projected = _pick_fields(space, _SPACE_PLAN_FIELDS)
if "settings" in space:
projected["settings"] = _pick_fields(
space.get("settings") or {}, _SPACE_DISPLAY_FIELDS,
)
projected["rooms"] = [
_project_plan_only_room(room) for room in space.get("rooms") or []
]
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
("walls", ("key", "cm", "a", "b")),
("room_drafts", ("id", "points", "segments")),
("partitions", ("id", "a", "b", "cm")),
("wall_columns", ("id", "shape", "center", "cm", "angle")),
("open_spans", ("a", "b")),
)
for name, fields in collections:
if name not in space:
continue
values = []
for item in space.get(name) or []:
selected = _pick_fields(item, fields)
if name == "room_drafts" and "segments" in selected:
selected["segments"] = [
_pick_fields(segment, ("cm",))
for segment in selected.get("segments") or []
]
values.append(selected)
projected[name] = values
if "openings" in space:
projected["openings"] = [
_pick_fields(
opening,
("id", "type", "x", "y", "angle", "length", "flip_h", "flip_v"),
)
for opening in space.get("openings") or []
]
if "decor" in space:
projected["decor"] = [
_project_plan_only_decor(shape) for shape in space.get("decor") or []
]
return projected
def _plan_only_room_label_layout(
layout: dict[str, Any], space: dict[str, Any],
) -> dict[str, Any]:
space_id = str(space.get("id", ""))
room_ids = {str(room.get("id", "")) for room in space.get("rooms") or []}
projected: dict[str, Any] = {}
for key, pos in layout.items():
if not (
isinstance(key, str)
and key.startswith("rl_")
and key[3:] in room_ids
and isinstance(pos, dict)
and str(pos.get("s", "")) == space_id
):
continue
value = _pick_fields(pos, ("x", "y", "s"))
scale = pos.get("k")
if isinstance(scale, (int, float)) and not isinstance(scale, bool) \
and math.isfinite(scale) and 0.5 <= scale <= 3:
value["k"] = scale
projected[key] = value
return projected
def _marker_owned(marker: dict[str, Any], space: dict[str, Any], layout: dict[str, Any]) -> bool:
marker_id = str(marker.get("id", ""))
room_ids = {str(room.get("id")) for room in space.get("rooms") or []}
@@ -254,9 +411,12 @@ def create_export(
*,
kind: str,
space_id: str | None,
plan_only: bool = False,
card_version: str,
config_root: Path,
) -> tuple[dict[str, Any], str]:
if not isinstance(plan_only, bool) or plan_only and kind != "space":
raise ImportFailure("invalid_format", "Plan-only export requires one space")
raw_config = _json_copy(
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
)
@@ -283,36 +443,41 @@ def create_export(
key: pos for key, pos in layout.items()
if isinstance(pos, dict) and str(pos.get("s")) == str(space_id)
}
selected_markers = [
m for m in config.get("markers") or []
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
]
selected_ids = {str(marker.get("id")) for marker in selected_markers}
for marker in selected_markers:
controls = marker.get("controls")
if isinstance(controls, list):
kept = []
for ref in controls:
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
dropped_marker_links += 1
continue
kept.append(ref)
marker["controls"] = kept or None
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
ref = source.get("ref") if isinstance(source, dict) \
and source.get("kind") == "derived_marker_state" else None
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
config = {
"spaces": [_json_copy(space)],
"markers": _json_copy(selected_markers),
}
layout = selected_layout
if plan_only:
projected_space = _project_plan_only_space(space)
config = {"spaces": [projected_space], "markers": []}
layout = _plan_only_room_label_layout(selected_layout, projected_space)
else:
selected_markers = [
m for m in config.get("markers") or []
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
]
selected_ids = {str(marker.get("id")) for marker in selected_markers}
for marker in selected_markers:
controls = marker.get("controls")
if isinstance(controls, list):
kept = []
for ref in controls:
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
dropped_marker_links += 1
continue
kept.append(ref)
marker["controls"] = kept or None
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
ref = source.get("ref") if isinstance(source, dict) \
and source.get("kind") == "derived_marker_state" else None
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
config = {
"spaces": [_json_copy(space)],
"markers": _json_copy(selected_markers),
}
layout = selected_layout
elif kind != "full":
raise ImportFailure("invalid_format", "Unknown export kind")
document = {
@@ -327,7 +492,10 @@ def create_export(
"payload": {"config": config, "layout": layout},
"placement_manifest": placement_manifest(config, layout),
"content_manifest": content_manifest(config, config_root),
"transfer": {"dropped_marker_links": dropped_marker_links},
"transfer": {
"dropped_marker_links": dropped_marker_links,
**({"plan_only": True} if plan_only else {}),
},
}
if len(json.dumps(
document, ensure_ascii=False, separators=(",", ":"), allow_nan=False
@@ -416,10 +584,13 @@ def parse_document(raw: bytes) -> dict[str, Any]:
if document["kind"] == "space" and placement_ids != set(layout):
raise ImportFailure("invalid_format", "Placement manifest does not match layout")
dropped_marker_links = _transfer_dropped_marker_links(document)
plan_only = _transfer_plan_only(document)
document["transfer"] = {
**(document.get("transfer") or {}),
"dropped_marker_links": dropped_marker_links,
}
if plan_only:
_validate_plan_only_document(document, config, layout, placement)
if len(json.dumps(
config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
).encode("utf-8")) > MAX_CONFIG_BYTES:
@@ -490,6 +661,51 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
return value
def _transfer_plan_only(document: dict[str, Any]) -> bool:
"""Read strict additive plan-only metadata without widening old exports."""
transfer = document.get("transfer")
if transfer is None:
return False
if not isinstance(transfer, dict):
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
value = transfer.get("plan_only", False)
if not isinstance(value, bool):
raise ImportFailure("invalid_format", "Plan-only metadata must be a boolean")
if value and document.get("kind") != "space":
raise ImportFailure("invalid_format", "Plan-only metadata requires one space")
return value
def _validate_plan_only_document(
document: dict[str, Any],
config: dict[str, Any],
layout: dict[str, Any],
placement: list[Any],
) -> None:
"""Reject forged plan-only flags unless every privacy invariant is true."""
spaces = config.get("spaces") or []
if len(spaces) != 1 or config.get("markers") != []:
raise ImportFailure("invalid_format", "Plan-only export has invalid owners")
expected_config = CONFIG_SCHEMA({
"spaces": [_project_plan_only_space(spaces[0])],
"markers": [],
})
if config != expected_config:
raise ImportFailure("invalid_format", "Plan-only export contains private fields")
expected_layout = _plan_only_room_label_layout(layout, spaces[0])
if layout != expected_layout:
raise ImportFailure("invalid_format", "Plan-only export contains device layout")
expected_placement = placement_manifest(config, layout)
if placement != expected_placement:
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
content = document.get("content_manifest")
if not isinstance(content, list) or any(
not isinstance(item, dict) or item.get("owner") != "space"
for item in content
):
raise ImportFailure("invalid_format", "Plan-only export contains private content")
def _drop_invalid_import_marker_links(
config: dict[str, Any], *, clean_ids: set[str] | None = None,
) -> int:
@@ -964,6 +1180,7 @@ def create_preview(
runtime.import_previews[token] = candidate
preview = {
"kind": document["kind"],
"plan_only": _transfer_plan_only(document),
"created_at": document.get("created_at"),
"card_version": document.get("card_version"),
"integration_version": document.get("integration_version"),
@@ -1043,6 +1260,7 @@ def revalidate_candidate(
return {
"preview": {
"kind": document["kind"],
"plan_only": _transfer_plan_only(document),
"counts": _counts(incoming["config"], incoming["layout"]),
"current_counts": _counts(current_config, current_layout),
"source": "same" if candidate.get("same_source") else "foreign",
@@ -263,6 +263,7 @@ async def _commit_import_pair(
vol.Required("type"): "houseplan/export/create",
vol.Required("kind"): vol.In(["full", "space"]),
vol.Optional("space_id"): str,
vol.Optional("plan_only", default=False): bool,
vol.Optional("card_version", default=""): str,
}
)
@@ -287,6 +288,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
layout_data,
kind=msg["kind"],
space_id=msg.get("space_id"),
plan_only=msg.get("plan_only", False),
card_version=msg.get("card_version", ""),
config_root=Path(hass.config.path("")),
)
+11 -3
View File
@@ -561,6 +561,13 @@ export async function prepareGoldenScenario(page, scenario) {
await dialog?.updateComplete;
dialog?.renderRoot?.querySelector('.close')?.focus();
}
} else if (scenario.dialog === 'backup-export-plan-only') {
card._openBackupExport();
card._backupExportDialog = {
...card._backupExportDialog, kind: 'space', planOnly: true,
};
card.requestUpdate();
await card.updateComplete;
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
const full = scenario.dialog === 'backup-full';
card._backupImportDialog = {
@@ -568,10 +575,11 @@ export async function prepareGoldenScenario(page, scenario) {
size: 12345,
token: 'golden-token',
preview: {
kind: full ? 'full' : 'space', source: full ? 'foreign' : 'same',
kind: full ? 'full' : 'space', plan_only: !full,
source: full ? 'foreign' : 'same',
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
counts: { spaces: 1, rooms: 4, markers: 12, layout: 15 },
duplicates: full ? 0 : 2,
counts: { spaces: 1, rooms: 4, markers: full ? 12 : 0, layout: full ? 15 : 4 },
duplicates: 0,
confirmation_required: full,
content: full
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
+4 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 23;
export const GOLDEN_MATRIX_VERSION = 24;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -248,6 +248,9 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-full', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, ...page },
{ id: 'backup-plan-only-export-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-export-plan-only', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, ...page },
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-space', language: 'ru', theme: 'light',
viewport: { width: 390, height: 820 }, ...page },
+64 -1
View File
@@ -19,6 +19,40 @@ const result = await page.evaluate(async () => {
await card.updateComplete;
const exportRadios = root().querySelectorAll('input[name="backup-kind"]').length;
const exportWarning = !!root().querySelector('.backupwarn');
const planOnlyHiddenForFull = !root().querySelector('.backupplanonly');
root().querySelector('input[name="backup-kind"][value="space"]')?.click();
await card.updateComplete;
const planOnly = root().querySelector('.backupplanonly input[type="checkbox"]');
planOnly?.click();
await card.updateComplete;
const planOnlySpace = {
visible: !!planOnly,
checked: card._backupExportDialog?.planOnly === true,
labelled: root().querySelector('.backupplanonly')?.textContent
.includes(card._t('backup.plan_only')) === true,
};
root().querySelector('input[name="backup-kind"][value="full"]')?.click();
await card.updateComplete;
const planOnlyResetForFull = card._backupExportDialog?.planOnly === false
&& !root().querySelector('.backupplanonly');
let sentExport = null;
card.hass = {
...card.hass,
callWS: async (message) => {
sentExport = message;
return { document: { smoke: true }, filename: 'plan-only-smoke.json' };
},
};
card._backupExportDialog = {
kind: 'space', planOnly: true, busy: false, error: '',
};
await card._runBackupExport();
const planOnlyRequest = {
kind: sentExport?.kind,
planOnly: sentExport?.plan_only,
space: sentExport?.space_id,
};
card._backupExportDialog = null;
card._backupImportDialog = {
@@ -40,7 +74,31 @@ const result = await page.evaluate(async () => {
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
<= root().querySelector('hp-dialog').clientWidth,
};
return { group, actions, keyboardImport, exportRadios, exportWarning, importSafe };
card._backupImportDialog = {
filename: 'houseplan-space-plan-only.json', size: 2048, token: 'plan-only',
preview: {
kind: 'space', plan_only: true, source: 'same',
created_at: '2026-08-17T00:00:00Z', space_title: 'Ground (2)',
counts: { spaces: 1, rooms: 2, markers: 0, layout: 2 },
bindings: { device: 0, entity: 0, virtual: 0, active: 0, disabled: 0, missing: 0 },
duplicates: 0, confirmation_required: false, content: [],
},
expectedConfigRev: 1, expectedLayoutRev: 2, duplicatePolicy: 'skip',
confirmMissing: false, busy: false, error: '',
};
await card.updateComplete;
const planOnlyPreview = {
visible: root().querySelector('.backupplanonlystatus')?.textContent
=== card._t('backup.plan_only_preview'),
noDuplicatePolicy: !root().querySelector('.backupchoices'),
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
<= root().querySelector('hp-dialog').clientWidth,
};
return {
group, actions, keyboardImport, exportRadios, exportWarning,
planOnlyHiddenForFull, planOnlySpace, planOnlyResetForFull, planOnlyRequest,
importSafe, planOnlyPreview,
};
});
checkAll(result, {
@@ -49,6 +107,11 @@ checkAll(result, {
keyboardImport: true,
exportRadios: 2,
exportWarning: true,
planOnlyHiddenForFull: true,
planOnlySpace: { visible: true, checked: true, labelled: true },
planOnlyResetForFull: true,
planOnlyRequest: { kind: 'space', planOnly: true, space: 'f1' },
importSafe: { danger: true, disabledUntilConfirmed: true, noHorizontalOverflow: true },
planOnlyPreview: { visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true },
});
await finish(browser, result);
File diff suppressed because one or more lines are too long
+16 -5
View File
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -539,7 +539,7 @@ spans are clipped per atomic body.
| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move |
| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection |
| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for `<image>`/`<a>` fetches |
| `houseplan/export/create` | `kind`, `space_id?`, `card_version` | consistent versioned JSON document + safe filename |
| `houseplan/export/create` | `kind`, `space_id?`, `plan_only?`, `card_version` | consistent versioned JSON document + safe filename; plan-only is valid only for one space |
| `houseplan/import/revalidate` | preview `token`, `duplicate_policy?` | refreshed bounded preview and current expected revisions |
| `houseplan/import/apply` | token, both expected revisions, content confirmation | crash-resumable paired config/layout commit; full import gets one-deep undo |
@@ -570,6 +570,14 @@ versions, and retains the parsed candidate only in memory for ten minutes. Its
opaque token is bound to the HA user, normalized-candidate digest and the exact
config/layout revisions. Parsed candidates are capped globally as well as per
user.
Plan-only export is a server-owned, fail-closed projection rather than a
client-side scrub. It removes every marker and all device layout, preserves
only canonical room-label placements, and copies one space through explicit
geometry/presentation allowlists. The parser recomputes that projection and
its placement manifest before showing a plan-only preview, so manually adding
a private field while keeping `transfer.plan_only: true` is rejected.
The browser never parses imported configuration. Full import and maintenance
share the `optimize_pending` crash-recovery intent and the one-deep backup slot;
the backup carries `kind: optimize|import`, while every layout-store writer
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Current-space export can now create a **Plan only** JSON template with rooms,
walls, openings, decor, backdrop, room-label positions and scale, while
removing devices and structural Home Assistant bindings. Import preview identifies
the template before adding it as a new space
([#167](https://github.com/Matysh/houseplan-card/issues/167)).
- Composite appliances such as washing machines now use an explicit Home
Assistant Status/Run state/Job state to show the yellow working marker during
an active cycle. Power-on alone remains neutral, Power-off still fades stale
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Экспорт текущего пространства теперь может создать JSON-шаблон **«Только
планировка»** с комнатами, стенами, проёмами, декором, подложкой, позициями и
масштабом подписей комнат, но без устройств и структурных привязок Home
Assistant.
Предпросмотр помечает такой файл до добавления новым пространством
([#167](https://github.com/Matysh/houseplan-card/issues/167)).
- Составная техника, например стиральная машина, теперь использует явный
Status/Run state/Job state Home Assistant и получает жёлтую подложку во время
активного цикла. Одного Power=`on` по-прежнему недостаточно, Power=`off`
+17
View File
@@ -69,6 +69,23 @@ that fallback:
Import preview and apply therefore operate on the same normalized candidate.
Explicit `static` and `daynight` survive same-instance and foreign transfer.
## Additive plan-only space transfer (#167)
`houseplan/export/create` accepts `plan_only: true` only for a one-space
export. The resulting version-1 envelope adds `transfer.plan_only: true`,
contains no markers and retains only canonical `rl_<room_id>` room-label
layout. Normal full/space exports never write `plan_only: false`, so their
existing document shape and lossless compatibility remain unchanged; an
absent field still means an ordinary export.
Plan-only data is a fail-closed allowlist projection of supported geometry,
presentation and content references. Known Area, temperature/humidity,
opening and decor bindings are removed and recognized live-text references are
frozen as `—`. Import rejects a true flag on a full export, non-boolean values,
or any document whose projected config, layout, placement or content owner no
longer satisfies that privacy contract. There is no persisted config/layout
migration: the new field exists only in the portable envelope.
## Legacy device tap action
The historical marker token `tap_action: cover` remains accepted indefinitely.
+14
View File
@@ -507,6 +507,20 @@ assigns new internal IDs and adds the copy without replacing global settings.
Internal uploaded files are not embedded in JSON; an import to another HA
instance must explicitly detach those links.
For **Current space**, enable **Plan only** to transfer the architectural
template without devices or Home Assistant bindings. It keeps rooms, walls,
openings, decor, backdrop transforms and manually positioned room labels at
their chosen scale, but removes real and virtual markers, device positions,
Area assignments,
temperature/humidity sources and opening contacts/locks. Live values in text
labels become `—`; surrounding static text stays intact. The import preview
marks this file as plan-only and adds it through the normal space-import flow.
Plan-only is not full anonymisation: space and room names, static text, file
names, exact coordinates and external URLs remain in the JSON. Internal plan
files are still referenced rather than embedded and may need to be detached on
another Home Assistant instance.
### Storage locations
| Data | Location |
+13
View File
@@ -1158,6 +1158,14 @@ show_signal: true
импорте оно получает новые внутренние ID и добавляется рядом с существующими;
глобальные настройки не меняются и отмена не создаётся.
Для текущего пространства можно включить **Только планировка**. Такой файл
сохраняет комнаты, стены, проёмы, декор, трансформации подложки, позиции и
выбранный масштаб подписей комнат, но удаляет реальные и виртуальные маркеры,
позиции устройств, Area, источники температуры/влажности и контакты/замки
проёмов. Живые значения в текстовых подписях заменяются на `—`, а окружающий
статический текст остаётся. Предпросмотр явно помечает файл как планировку и
добавляет её обычным импортом пространства.
Файл сначала загружается в безопасный серверный предпросмотр: House Plan
показывает тип, версии, количество объектов, источник и состояние ссылок, но
ничего не записывает до подтверждения. Если привязка к устройству HA уже есть,
@@ -1169,6 +1177,11 @@ JSON хранит названия, идентификаторы HA и точн
переносе на другой экземпляр такие ссылки нужно явно согласиться отвязать.
Текущие состояния устройств и маршруты пылесосов в экспорт не входят.
Режим «Только планировка» не является полной анонимизацией: в файле остаются
названия пространства и комнат, статический текст, имена файлов, точные
координаты и внешние URL. Структурные привязки Home Assistant при этом не
переносятся.
### Где лежат данные
| Данные | Хранение |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 286 KiB

+12 -12
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceFingerprint": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "2f4770869f04c8d7f1e8ff33af1e8bd79f45ad6d97be6d5e6135b3a82fec9e4c"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "47d8fc2ac1c14cb699e990ca9c00d89b9ac57f6cc76b40420ceb26b4d7f50df4"
},
"device-editor": {
@@ -68,8 +68,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b"
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
"sourceSha256": "7a788419e5d1a6e4b944a5d436d2cffd00795c3237791689b1401b73925353f8",
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
}
}
+508
View File
@@ -0,0 +1,508 @@
# Issue #167 — экспорт «только планировка»
Дата: 2026-08-17
Тип: `feature` · приоритет: `P1` · пользовательская ценность: 7/10 ·
сложность: 5/10 · риск: 6/10
Issue: [#167](https://github.com/Matysh/houseplan-card/issues/167)
Ветка: `issue/167-plan-only-export`
Зависимость: [#50](https://github.com/Matysh/houseplan-card/issues/50) — выполнена
и выпущена в stable v1.62.0.
Канонические документы: [SCOPE](../SCOPE.md),
[CONFIG-COMPATIBILITY](../CONFIG-COMPATIBILITY.md),
[TOUCH-SUPPORT](../TOUCH-SUPPORT.md), [USER-GUIDE](../USER-GUIDE.md),
[USER-GUIDE.ru](../USER-GUIDE.ru.md),
[ТЗ #50](050-config-export-import.md).
## 1. Сценарий и персона
Владелец уже нарисовал этаж и хочет:
- перенести его геометрию в другой Home Assistant, где устройства и Area имеют
другие идентификаторы;
- передать чистый шаблон планировки другому пользователю;
- сохранить архитектурную заготовку без раскрытия HA-привязок.
В General settings он открывает действующий экспорт, выбирает «Current space»
и включает «Plan only». Полученный JSON импортируется существующим потоком как
новое пространство: комнаты, стены, проёмы, декор и фон остаются, а устройства
и автоматические привязки на новом экземпляре настраиваются заново.
Это сценарии J4/J6 из `docs/SCOPE.md`: первоначальная настройка и дальнейшее
обслуживание House Plan. Нового поведения обычного View задача не вводит.
## 2. Что человек увидит до и после
**До:** экспорт текущего пространства всегда содержит его маркеры, device layout и
HA-привязки. Для чистого переноса пользователь должен вручную редактировать
JSON, рискуя повредить структуру или случайно оставить идентификаторы.
**После:** рядом с выбором текущего пространства доступен выключенный по
умолчанию флажок «Plan only». В этом режиме файл сохраняет переносимую
планировку и вручную расставленные подписи комнат, но не содержит маркеров,
device layout и известных структурных HA-привязок. Preview импорта явно
сообщает, что файл содержит только
планировку; импорт добавляет новое несвязанное пространство существующим
безопасным механизмом #50.
Обычный full export и обычный export current space работают как раньше.
## 3. Проблема и подтверждённая причина
1. `houseplan/export/create` принимает только `kind`, `space_id` и версию
карточки; отдельного намерения «только планировка» нет.
2. `create_export()` для `kind == "space"` намеренно выбирает маркеры этого
пространства и соответствующий live layout.
3. HA-привязки находятся не только в маркерах. Они есть в `room.area`,
`room.settings.temp_source|hum_source`, `opening.contact|lock` и live-text
декора; поэтому одного удаления массива `markers` недостаточно.
4. Современный live text хранит ссылку прямо в `decor.text` токеном вида
`{sensor.kitchen}`; legacy-конфиги дополнительно могут содержать поля
`entity`, `attr`, `unit` и placeholder `{}`.
5. Действующий импорт пространства уже умеет remap внутренних id, добавить
новое пространство без замены существующего, отсоединить недоступный
content и показать preview. Новый импорт-процесс не требуется.
## 4. Scope
В задачу входят:
1. опция «Plan only» только для экспорта текущего пространства;
2. schema-aware проекция переносимой геометрии и визуальных настроек;
3. полное удаление реальных и виртуальных маркеров, marker/auto-device/light-
group layout и структурных HA-привязок при сохранении безопасных позиций
подписей комнат `rl_<room_id>`;
4. статическая нейтрализация live-text токенов по решению владельца;
5. аддитивный признак `transfer.plan_only: true` в JSON;
6. строгая проверка plan-only инварианта при чтении файла;
7. существующий preview/apply пространства с явным plan-only статусом;
8. одинаковый контракт в RU/EN;
9. unit, backend, smoke, golden и executable mutation coverage;
10. пользовательская документация и оба changelog.
## 5. Non-scope
В задачу не входят:
- полноценная анонимизация пользовательского содержимого;
- удаление или замена названий пространства и комнат, статического текста,
имён файлов, внешних URL и иных пользовательских строк;
- встраивание backdrop или attachment bytes в JSON — действует content-
контракт #50;
- экспорт нескольких выбранных пространств;
- новый формат файла, отдельный import endpoint или replace существующего
пространства;
- сопоставление Area, устройств и сущностей при импорте;
- перенос marker icon, actions, vacuum paths, runtime states, histories,
trails, known/new-device bookkeeping;
- сохранение виртуальных маркеров вроде пользовательских заметок «Котёл»:
`binding: virtual` не делает marker частью архитектурной геометрии, поэтому
он удаляется вместе со всеми остальными маркерами;
- создание PDF/изображения чистого плана — это сценарий #53;
- дополнительное privacy-предупреждение специально для plan-only;
- изменение редакторов, View, kiosk или touch-жестов;
- миграция сохранённого server config либо layout store.
## 6. Пользовательский и UX-контракт
### 6.1. Диалог экспорта
В существующем диалоге:
1. Full backup и Current space остаются взаимоисключающими radio options.
2. Флажок «Plan only» показывается и доступен только при выбранном Current
space и наличии текущего пространства.
3. При каждом открытии диалога флажок выключен.
4. Переключение на Full backup сбрасывает флажок; возврат к Current space не
включает его автоматически.
5. На сервер отправляется `plan_only: true` только при Current space +
включённом флажке. При всех остальных состояниях поле отсутствует или false.
6. Действующий `backup.privacy_warning` сохраняется без изменений. Новое
предупреждение, требующее отдельного подтверждения, не добавляется.
7. Label и короткий нейтральный hint должны объяснять результат, но не обещать
анонимизацию: «Сохранить комнаты, стены, проёмы и декор без устройств и
привязок Home Assistant».
Флажок следует существующей keyboard/focus семантике `ha-checkbox`, имеет
доступную подпись и не уменьшает действующие touch targets.
### 6.2. Preview импорта
Для plan-only файла preview:
- явно показывает информационную строку «Файл содержит только планировку»;
- показывает `markers = 0`, device/entity/virtual bindings = 0, а `layout`
считает только сохранённые позиции подписей комнат;
- не показывает duplicate policy, поскольку дубликатов устройств нет;
- не показывает missing Area, поскольку `room.area` очищен;
- показывает обычные counts комнат, стен, проёмов, декора и content;
- сохраняет действующие final-name, content detach и confirmation правила #50;
- после revalidate продолжает показывать plan-only статус.
Кнопка применения остаётся «Add space». Импорт никогда не заменяет текущее
пространство и не вводит отдельного Undo.
## 7. Контракт экспортируемой модели
### 7.1. Envelope и совместимость формата
Файл остаётся обычным envelope #50:
```json
{
"kind": "space",
"transfer": {
"plan_only": true,
"dropped_marker_links": 0
},
"payload": {
"config": { "spaces": ["…"], "markers": [] },
"layout": {
"rl_room-kitchen": { "x": 0.42, "y": 0.31, "s": "floor-1", "k": 1.4 }
}
},
"placement_manifest": [
{
"layout_id": "rl_room-kitchen",
"space_id": "floor-1",
"owner": "room_label",
"owner_id": "room-kitchen",
"binding": null,
"label": null,
"icon": null
}
],
"content_manifest": ["…"]
}
```
- `export_version` и `model_version` не повышаются только из-за этой опции.
- `transfer.plan_only` допускается только как strict boolean и только при
`kind == "space"`.
- Поле присутствует только при true. У обычного space export документ при
фиксированных входе и времени остаётся семантически и структурно идентичен
прежнему, без `plan_only: false`.
- `plan_only: true` у `kind == "full"` отклоняется как `invalid_format`.
- Старые файлы без поля читаются как обычный экспорт.
### 7.2. Что сохраняется
Экспорт строит новую проекцию из текущего известного portable-plan allowlist,
а не копирует произвольные объекты с последующим чёрным списком. Сохраняются:
- одно пространство: внутренний id, title и известные собственные визуальные
настройки;
- rooms: внутренние id, title, polygon/geometry, толщина/вид стен и известные
визуальные room settings;
- walls, drafts, partitions, columns, open spans и иные поддерживаемые
геометрические примитивы пространства;
- openings/open boundaries: id, тип, геометрия, ориентация и flip-поля;
- decor/backdrop: тип, геометрия, transform, style, статический текст и
переносимые content references;
- `plan_url` и backdrop transforms по действующему content manifest #50.
Внутренние House Plan id сохраняются только внутри файла и затем remap-ятся
существующим `build_space_merge()`. Они не являются HA-привязками.
### 7.3. Что удаляется или нейтрализуется
Обязательная проекция:
| Источник | Результат plan-only |
|---|---|
| `config.markers` | `[]`; marker config целиком отсутствует |
| `payload.layout` | только `rl_<room_id>` для комнаты экспортируемого пространства: обязательные `x/y/s` и опциональный конечный масштаб карточки `k` в диапазоне `0.5..3`; marker, `v_*`, `lg_*`, auto-device, неизвестные поля и невалидный `k` удаляются |
| `placement_manifest` | только canonical `room_label` entries, точно соответствующие сохранённым `rl_*` ключам |
| marker attachment/content entries | отсутствуют |
| `room.area` | отсутствует или canonical unbound value |
| `room.settings.temp_source` | отсутствует |
| `room.settings.hum_source` | отсутствует |
| `opening.contact` / `opening.lock` | отсутствуют |
| contact-specific `opening.invert` | отсутствует как часть binding behavior |
| decor legacy `entity` / `attr` / `unit` | отсутствуют |
| valid live tokens и legacy `{}` в `decor.text` | заменены на `—` |
| `known_devices` / `new_device_ids` | не переносятся |
Реальные и виртуальные markers удаляются одинаково: `binding: virtual`, имя или
статичная иконка не переводят marker в архитектурный decor.
`flip_h`, `flip_v` и другие геометрические параметры проёма не являются
HA-binding behavior и сохраняются.
### 7.4. Live text
Используется тот же синтаксический контракт live-text, что во фронтенде, без
подстановки runtime state:
- каждый валидный HA live token `{sensor.kitchen}` заменяется одним символом
`—`;
- legacy placeholder `{}` также заменяется на `—`;
- окружающий пользовательский текст, whitespace и форматирование сохраняются;
- malformed braces, которые parser не признаёт live token, остаются обычным
статическим текстом;
- legacy `entity`, `attr`, `unit` удаляются независимо от наличия placeholder.
Пример: `Температура {sensor.kitchen} °C` → `Температура — °C`.
Это принятое владельцем решение Q1. Текущее значение сущности не читается и
не записывается: экспорт остаётся deterministic относительно server config.
### 7.5. Граница privacy-обещания
Режим гарантирует отсутствие HA-specific identifier/binding в известных
структурных позициях модели и распознанных live tokens. Он не сканирует и не
анонимизирует произвольный пользовательский текст. Поэтому сохраняются названия
пространства/комнат, статические decor labels, filenames и внешние URL, даже
если пользователь сам написал в них строку, похожую на entity id.
Это принятое владельцем решение Q2. Дополнительное UI-предупреждение не
добавляется.
Неизвестные поля внутри экспортируемых model objects не копируются автоматически:
новое переносимое поле сначала должно быть классифицировано как geometry,
presentation, user content или HA binding. Это fail-closed защита от утечки
нового binding-поля в будущей версии.
## 8. Контракт API, парсинга и импорта
### 8.1. Export endpoint
`houseplan/export/create` получает optional strict boolean `plan_only`.
- `plan_only == true` требует `kind == "space"` и валидный `space_id`.
- Право доступа, readiness, limits, source fingerprint, signing/content и
download contract остаются от #50.
- Проекция строится на backend; frontend не получает полный config для
самостоятельной очистки.
- Экспорт не читает HA runtime states и не выполняет network requests.
### 8.2. Проверка входящего файла
`parse_document()` не доверяет одному флагу. Для
`kind == "space" && transfer.plan_only == true` он дополнительно проверяет:
- ровно одно пространство;
- `markers == []`;
- каждый layout key строго равен `rl_<room_id>` существующей комнаты
экспортируемого пространства, `pos.s` равен id этого пространства, а запись
содержит только `x/y/s` и опциональный конечный `k` в диапазоне `0.5..3`;
- каждый placement entry canonical: `owner == "room_label"`, `owner_id`
совпадает с room id, `binding|label|icon == null`, и set записей точно
совпадает с layout;
- отсутствие marker-owned content;
- отсутствие Area/temp/hum/opening/decor legacy bindings;
- отсутствие валидных live-text токенов и legacy `{}`;
- согласованность обычного content manifest.
Нарушение возвращает существующий стабильный `invalid_format`; файл не
попадает в preview/apply. Это предотвращает ложную маркировку вручную
отредактированного файла как «только планировка».
### 8.3. Preview, revalidate и apply
- `create_preview()` возвращает `plan_only: true` для валидного файла.
- Кандидат и `revalidate_candidate()` сохраняют это значение.
- Existing space merge remap-ит внутренние id, добавляет suffix к конфликтному
title и не меняет global settings.
- Content availability/detach повторно проверяется перед apply под действующим
lock по контракту #50.
- Apply не добавляет маркеры; существующий remap переносит только room-label
layout на новые room/space ids, а комнаты остаются unbound.
- Events, revision conflict, token ownership/expiry и capacity limits не
меняются.
## 9. Модель данных, миграция и compatibility
Server config, layout store и localStorage не получают новых полей. Опция
существует только в краткоживущем состоянии export dialog и в export envelope.
Прямая миграция не нужна: новая версия читает прежние full/space файлы без
изменений. Обратная совместимость best-effort: старая версия, поддерживающая
тот же `export_version` и игнорирующая additive transfer metadata, увидит
структурно валидный обычный space export с нулём маркеров. При этом именно
новая версия обязана проверить усиленный plan-only инвариант.
Ordinary full и space exports, обычный preview/apply и existing import Undo не
меняются.
## 10. i18n, accessibility и touch
Нужны синхронные RU/EN keys минимум для:
- label «Plan only»;
- короткого hint без обещания анонимизации;
- informational preview line.
Новых error keys и дополнительного privacy warning нет; invalid document
использует `backup.error.invalid_format`.
Диалог остаётся keyboard-operable: label связан с checkbox, visible focus и
screen-reader name обеспечиваются действующим компонентом. Preview status
доступен как обычный текст, не только цветом.
Touch View и kiosk не затронуты. General settings/editor остаётся desktop-
first по `TOUCH-SUPPORT`, но диалог не должен переполнять узкий viewport и
действующие touch targets не уменьшаются.
## 11. Acceptance criteria и доказательства
1. При Current space пользователь видит выключенный «Plan only»; при Full
backup опции нет, а request не содержит true.
2. Plan-only export содержит одно пространство, `markers: []`, только валидные
`rl_<room_id>` layout/room-label placement entries и
`transfer.plan_only: true`.
3. Геометрия rooms/walls/drafts/partitions/columns/openings/open spans,
decor/backdrop и переносимые визуальные настройки сохраняются по allowlist.
4. Area, temperature/humidity source, opening contact/lock/invert, marker data,
known/new bookkeeping и legacy decor binding fields отсутствуют.
5. Все валидные inline live tokens и legacy `{}` заменены на `—` с сохранением
окружающего текста; runtime value в файл не попадает.
6. Названия, статический текст, filenames и external URLs сохраняются; UX не
обещает полную анонимизацию и не добавляет отдельного предупреждения.
7. Импорт plan-only файла на чистый целевой instance создаёт новое пространство
с той же планировкой, нулём устройств/HA-привязок, remap-нутыми позициями и
масштабом подписей комнат и unbound rooms.
8. Preview и revalidate явно сохраняют `plan_only: true`, показывают нулевые
binding counts и не предлагают duplicate policy.
9. File с true, но с маркером, не-room-label layout, несогласованным placement
или известной HA-привязкой отклоняется как `invalid_format` до preview.
10. Обычные full/space export и import проходят неизменённые regression
fixtures; normal space document не получает `plan_only: false`.
11. RU/EN тексты синхронны, checkbox доступен с клавиатуры и диалог проходит
narrow-viewport smoke.
12. Typecheck, unit и build зелёные; targeted backend import/export tests
зелёные в Linux CI.
13. Targeted golden подтверждает export dialog и plan-only preview; diff
просмотрен человеком и не имеет непреднамеренных изменений.
14. Все обязательные executable mutants из §12.3 действительно делают
соответствующий guard красным.
15. Оба changelog и обе пользовательские инструкции обновлены в том же
пользовательском коммите.
## 12. План автотестов
### 12.1. Backend unit/integration
Расширить `tests_backend/test_ha_import_export.py`:
- export normal space с фиксированным временем — прежний fixture без нового
поля;
- plan-only projection полного representative space со всеми типами geometry,
real/virtual marker layout, safe room-label layout, room/opening bindings,
modern и legacy live text;
- preserve static names/text/URLs/content owner и drop marker attachments;
- reject `plan_only=true` для full;
- reject non-boolean plan_only;
- reject forged plan-only files по одному для marker, чужого/невалидного
room-label layout/placement/scale, room area,
temp/hum, opening refs, legacy decor refs и inline token;
- preview/revalidate/apply happy path на same и foreign instance;
- missing internal backdrop + detach confirmation по действующему контракту;
- capacity, revision conflict, expired/foreign token regressions;
- ordinary full/space fixtures без изменений.
Полный HA harness канонически выполняется в Linux CI: Windows-путь блокируется
зависимостью `fcntl` и не является локальным release gate.
### 12.2. Frontend unit, smoke и golden
- unit: export dialog state по умолчанию, reset при Full, request payload;
- smoke: checkbox видим только для Current space, keyboard change и narrow
viewport;
- import smoke: `plan_only` line есть, duplicate controls отсутствуют;
- RU/EN i18n parity;
- добавить/обновить deterministic golden scenarios для export dialog с
включённой опцией и import preview; после слитого #166 поднять жёстко
проверяемый `GOLDEN_MATRIX_VERSION` с 23 до 24;
- review actual/expected/diff до принятия baseline.
В implementation loop выполняются только действующие быстрые gates:
`typecheck`, `unit`, `build`. Golden и smoke — перед бетой по runbook.
### 12.3. Executable mutation gate
Mutation harness обязан временно внести каждую поломку, запустить названный
guard, получить non-zero и восстановить файл:
1. оставить один inline live token либо `room.area` в проекции — backend
plan-only privacy test падает;
2. проигнорировать `plan_only` и вернуть marker либо не-room-label layout —
projection/roundtrip test падает;
3. прогнать normal space export через lossy projection или записать
`plan_only: false` — fixed ordinary-export regression падает;
4. не перенести `plan_only` через preview/revalidate или не показать строку —
backend preview test либо frontend smoke падает;
5. отключить строгую проверку forged plan-only документа — negative parser
test падает.
Gate считается доказанным только если лог содержит имя каждого mutant,
ожидаемый guard и зафиксированный non-zero exit; простой список будущих
мутантов acceptance criterion не выполняет.
## 13. Риски и меры
| Риск | Мера |
|---|---|
| Новый HA-binding field утечёт в файл | allowlist projection + fail-closed parser + mutation gate |
| Очистка затронет обычный export | отдельная true-ветка и fixed regression обычного файла |
| Live text потеряет полезную подпись | заменять только token, сохранять окружающий static text |
| Пользователь сочтёт файл полностью анонимным | нейтральный hint точно говорит «без устройств и HA-привязок», документация перечисляет сохраняемые данные |
| Backdrop не откроется на другом instance | действующие content preview/detach правила #50, без ложного обещания embed |
| Frontend и backend расходятся в понимании token | единые fixtures grammar и forged-file tests |
| Новый checkbox ломает узкий диалог | narrow-viewport smoke + golden review |
Производительность: проекция и проверка линейны по размеру одного пространства,
не выполняются в render loop и не меняют View performance budget.
Security: доступ остаётся только у `may_write`; backend не обращается к
entity states или сети. Режим уменьшает объём структурных HA-данных, но не
является средством анонимизации пользовательского текста.
## 14. Rollback
Откат — удалить UI option и обработку true на export endpoint. Сохранённые
server config/layout не менялись, поэтому миграция назад не нужна. Уже созданный
plan-only файл остаётся структурно обычным space export с нулём маркеров и
может быть импортирован по базовому контракту #50; additive metadata безопасно
игнорируется совместимой версией.
Если реализация не может доказать отсутствие известных HA-привязок, режим не
выпускается частично: обычный экспорт #50 остаётся доступен.
## 15. Release-артефакты
Задача пользовательская (`User-Visible: yes`). В том же продуктовом коммите
обязательны:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- раздел экспорта/импорта в `docs/USER-GUIDE.md` и
`docs/USER-GUIDE.ru.md`, включая точную privacy-границу;
- при необходимости `docs/CONFIG-COMPATIBILITY.md` и архитектурное описание
additive `transfer.plan_only`;
- deterministic golden actual/expected/diff и обновлённая golden matrix;
- smoke/mutation logs согласно принятому тестовому контракту;
- перед бетой — golden, smoke и performance gates по runbook;
- terminal commit trailers `Issue: #167` и `User-Visible: yes`.
Push ветки выполняется после задачи; issue не закрывается до пакетного выпуска
беты. Перевод в `S4-spec-review` в рамках этого шага не выполняется.
## 16. Принятые предположения
1. `plan_only` — optional additive metadata внутри существующего export
version, а не новый kind или новая версия формата.
2. Безопасные ручные позиции и конечный масштаб `k` подписей комнат
`rl_<room_id>` сохраняются и remap-ятся; весь остальной layout удаляется.
3. Реальные и виртуальные markers удаляются одинаково.
4. Геометрический `flip_h|flip_v` сохраняется, contact-specific `invert`
удаляется вместе с binding.
5. Неизвестные поля в plan-only проекцию автоматически не попадают; обычный
export остаётся lossless.
6. Privacy invariant относится к структурным HA-полям и валидным live tokens,
но не к произвольным пользовательским строкам.
7. Новый informational label/hint допустим; отдельное предупреждение или новое
подтверждение по решению владельца запрещено.
+2 -1
View File
@@ -1,6 +1,6 @@
# Спецификации задач P1 и P2
Актуально на 2026-08-16.
Актуально на 2026-08-17.
GitHub Issues и GitHub Projects (v2) остаются единственным каноническим backlog проекта. Этот каталог содержит развёрнутые ТЗ: каждое ТЗ ссылается на issue, а issue — на соответствующий файл. Статус, приоритет и факт завершения меняются только в GitHub.
@@ -51,6 +51,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#156](https://github.com/Matysh/houseplan-card/issues/156) Регрессии Full Performance перед v1.64.0 stable | [156-full-performance-regressions.md](156-full-performance-regressions.md) |
| [#164](https://github.com/Matysh/houseplan-card/issues/164) Активный цикл стиральной машины должен быть жёлтым | [164-washer-active-cycle.md](164-washer-active-cycle.md) |
| [#166](https://github.com/Matysh/houseplan-card/issues/166) Солнечные лучи зеркально учитывают направление севера | [166-sun-north-rotation.md](166-sun-north-rotation.md) |
| [#167](https://github.com/Matysh/houseplan-card/issues/167) Экспорт «только планировка» | [167-plan-only-export.md](167-plan-only-export.md) |
## P2
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
const pattern = process.argv[2];
if (!pattern) {
console.error('usage: node scripts/backend-test-guard.mjs <pytest-k-pattern>');
process.exit(2);
}
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
const result = spawnSync(python, [
'-m', 'pytest', 'tests_backend/test_ha_import_export.py', '-q', '-k', pattern,
], { stdio: 'inherit' });
process.exit(result.status ?? 2);
+66
View File
@@ -165,6 +165,72 @@ export const MUTANTS = [
replace: ' glowEnabled: false, allLightsOff: true, northDeg: 0,',
}],
},
{
id: 'plan-only-room-area-restored',
guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects',
because: 'возврат room.area нарушает основное обещание чистого шаблона; '
+ 'projection/roundtrip тест обязан увидеть HA Area даже при нулевых markers',
patches: [{
file: 'custom_components/houseplan/import_export.py',
find: '_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")',
replace: '_ROOM_PLAN_FIELDS = ("id", "name", "area", "open_to", "x", "y", "w", "h", "poly")',
}],
},
{
id: 'plan-only-projector-bypassed',
guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects',
because: 'игнорирование request-флага возвращает markers и device layout; '
+ 'полный representative export обязан доказать, что true включает lossy projector',
patches: [{
file: 'custom_components/houseplan/import_export.py',
find: ' if plan_only:\n projected_space = _project_plan_only_space(space)',
replace: ' if False and plan_only:\n projected_space = _project_plan_only_space(space)',
}],
},
{
id: 'ordinary-export-plan-only-false-emitted',
guard: 'node scripts/backend-test-guard.mjs ordinary_space_export_is_unchanged',
because: 'аддитивное поле не должно менять обычный space export даже значением false; '
+ 'fixed regression обязан сохранить прежнюю структуру документа',
patches: [{
file: 'custom_components/houseplan/import_export.py',
find: '**({"plan_only": True} if plan_only else {}),',
replace: '**({"plan_only": plan_only}),',
}],
},
{
id: 'plan-only-revalidate-flag-dropped',
guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects',
because: 'revalidate не имеет права превращать plan-only preview в обычный space preview; '
+ 'roundtrip test проверяет сохранение флага после обновления revisions',
patches: [{
file: 'custom_components/houseplan/import_export.py',
find: ' "plan_only": _transfer_plan_only(document),\n "counts":',
replace: ' "plan_only": False,\n "counts":',
}],
},
{
id: 'plan-only-forged-file-trusted',
guard: 'node scripts/backend-test-guard.mjs forged_plan_only_privacy_claim',
because: 'флаг файла не является доказательством приватности; negative parser matrix '
+ 'обязана покраснеть, если schema-aware проверка больше не вызывается',
patches: [{
file: 'custom_components/houseplan/import_export.py',
find: ' if plan_only:\n _validate_plan_only_document(document, config, layout, placement)',
replace: ' if False and plan_only:\n _validate_plan_only_document(document, config, layout, placement)',
}],
},
{
id: 'plan-only-preview-label-hidden',
guard: 'node demo/smoke_backup_transfer.mjs',
because: 'валидный plan-only файл без явной строки в preview выглядит обычной копией; '
+ 'browser smoke обязан проверять пользовательский статус, а не только backend-флаг',
patches: [{
file: 'src/houseplan-card.ts',
find: "${p.plan_only ? html`<span class=\"backupplanonlystatus\">${this._t('backup.plan_only_preview')}</span>` : nothing}",
replace: "${false ? html`<span class=\"backupplanonlystatus\">${this._t('backup.plan_only_preview')}</span>` : nothing}",
}],
},
];
// --- механика ---------------------------------------------------------------
+13 -3
View File
@@ -1396,7 +1396,7 @@ class HouseplanCard extends LitElement {
busy: boolean;
} | null = null;
private _backupExportDialog: {
kind: 'full' | 'space'; busy: boolean; error: string;
kind: 'full' | 'space'; planOnly: boolean; busy: boolean; error: string;
} | null = null;
private _backupImportDialog: {
filename: string; size: number; token: string; preview: any;
@@ -13088,7 +13088,7 @@ class HouseplanCard extends LitElement {
private _openBackupExport = (): void => {
this._settingsDialog = null;
this._backupExportDialog = { kind: 'full', busy: false, error: '' };
this._backupExportDialog = { kind: 'full', planOnly: false, busy: false, error: '' };
};
private async _runBackupExport(): Promise<void> {
@@ -13100,6 +13100,7 @@ class HouseplanCard extends LitElement {
type: 'houseplan/export/create',
kind: d.kind,
space_id: d.kind === 'space' ? this._space : undefined,
...(d.kind === 'space' && d.planOnly ? { plan_only: true } : {}),
card_version: CARD_VERSION,
});
const blob = new Blob([JSON.stringify(response.document, null, 2) + '\n'], {
@@ -13285,7 +13286,8 @@ class HouseplanCard extends LitElement {
<div class="body backupbody">
<div class="rhint">${this._t('backup.export_hint')}</div>
<label class="srcrow"><input type="radio" name="backup-kind" value="full"
.checked=${d.kind === 'full'} @change=${() => (this._backupExportDialog = { ...d, kind: 'full' })} />
.checked=${d.kind === 'full'}
@change=${() => (this._backupExportDialog = { ...d, kind: 'full', planOnly: false })} />
<span>${this._t('backup.full')}</span></label>
<label class="srcrow"><input type="radio" name="backup-kind" value="space"
.checked=${d.kind === 'space'} ?disabled=${!currentSpace}
@@ -13293,6 +13295,13 @@ class HouseplanCard extends LitElement {
<span>${currentSpace
? this._t('backup.current_space_title', { title: currentSpace.title || currentSpace.id })
: this._t('backup.no_current_space')}</span></label>
${d.kind === 'space' && currentSpace ? html`<label class="srcrow backupplanonly">
<input type="checkbox" .checked=${d.planOnly}
@change=${(event: Event) => (this._backupExportDialog = {
...d, planOnly: (event.target as HTMLInputElement).checked,
})} />
<span><b>${this._t('backup.plan_only')}</b><small>${this._t('backup.plan_only_hint')}</small></span>
</label>` : nothing}
<div class="backupwarn">${this._t('backup.privacy_warning')}</div>
${d.error ? html`<div class="backuperror" role="alert">${d.error}</div>` : nothing}
</div>
@@ -13320,6 +13329,7 @@ class HouseplanCard extends LitElement {
${p ? html`
<div class="backupsummary">
<b>${this._t(p.kind === 'full' ? 'backup.full' : 'backup.current_space')}</b>
${p.plan_only ? html`<span class="backupplanonlystatus">${this._t('backup.plan_only_preview')}</span>` : nothing}
<span>${this._t(p.source === 'same' ? 'backup.same_source' : 'backup.foreign_source')}</span>
<span>${this._t('backup.created', { value: p.created_at || '—' })}</span>
<span>${this._t('backup.versions', {
+3
View File
@@ -825,6 +825,9 @@
"backup.current_space": "Current space",
"backup.current_space_title": "Current space: {title}",
"backup.no_current_space": "No current space",
"backup.plan_only": "Plan only",
"backup.plan_only_hint": "Keep rooms, walls, openings, decor and room-label positions without devices or Home Assistant bindings.",
"backup.plan_only_preview": "This file contains the plan only",
"backup.privacy_warning": "The archive keeps names, Home Assistant identifiers and exact coordinates. Internal plans and attachments are referenced, not embedded; runtime states and vacuum trails are not included.",
"backup.download": "Download JSON",
"backup.export_done": "Backup downloaded",
+3
View File
@@ -825,6 +825,9 @@
"backup.current_space": "Текущее пространство",
"backup.current_space_title": "Текущее пространство: {title}",
"backup.no_current_space": "Нет текущего пространства",
"backup.plan_only": "Только планировка",
"backup.plan_only_hint": "Сохранить комнаты, стены, проёмы, декор и позиции подписей комнат без устройств и привязок Home Assistant.",
"backup.plan_only_preview": "Файл содержит только планировку",
"backup.privacy_warning": "Архив сохраняет названия, идентификаторы Home Assistant и точные координаты. Внутренние планы и вложения указываются ссылками, но не вкладываются; текущие состояния и маршруты пылесосов не включаются.",
"backup.download": "Скачать JSON",
"backup.export_done": "Резервная копия скачана",
+4
View File
@@ -2867,6 +2867,10 @@ export const cardStyles = css`
.backupupload > .btn { width: 100%; justify-content: center; }
.backupupload input { display: none; }
.backupbody { min-width: 0; }
.backupplanonly { margin-inline-start: var(--sp-4) !important; align-items: flex-start !important; }
.backupplanonly > span:first-of-type { display: grid; gap: 2px; white-space: normal; }
.backupplanonly small { color: var(--secondary-text-color); line-height: 1.35; }
.backupplanonlystatus { color: var(--hp-accent) !important; font-weight: 700; }
.backupfile, .backupsummary, .backupcontent {
display: flex;
flex-direction: column;
+2 -2
View File
@@ -85,7 +85,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'fill-light', 'fill-temp', 'fill-lqi', 'lighting', 'hover', 'zoom-040', 'zoom-250',
'warm-remount', 'dialog-mobile', 'color-popover', 'tray-wide', 'tray-medium', 'sun-window',
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'opening-placement',
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
'backup-full', 'backup-space', 'backup-plan-only', 'value-badge-positions', 'isometric-geometry',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions',
@@ -193,7 +193,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 23);
assert.equal(GOLDEN_MATRIX_VERSION, 24);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
+262
View File
@@ -270,6 +270,257 @@ def test_full_export_has_versioned_envelope_and_live_layout(tmp_path: Path) -> N
assert filename.startswith("houseplan-full-") and filename.endswith(".json")
def _plan_only_source() -> tuple[dict[str, Any], dict[str, Any]]:
config = _config()
space = config["spaces"][0]
space.update({
"cell_cm": 5,
"plan_url": "https://example.invalid/floor.svg",
"plan_x": 0.1,
"plan_y": -0.2,
"plan_scale_x": 1.2,
"plan_scale_y": 0.8,
"plan_angle": 15,
"settings": {
"show_names": True, "north_deg": 90, "sun_rays": True,
"future_binding": "sensor.secret",
},
"future_space": {"entity": "sensor.secret"},
"openings": [{
"id": "window", "type": "window", "x": 0.5, "y": 0,
"angle": 0, "length": 0.2, "contact": "binary_sensor.window",
"lock": "lock.window", "invert": True, "flip_h": True,
"future_opening": "sensor.secret",
}],
"walls": [{"key": "wall-1", "cm": 20, "future_wall": "sensor.secret"}],
"room_drafts": [{
"id": "draft", "points": [[0, 0], [0.2, 0]],
"segments": [{"cm": 10, "future_segment": "sensor.secret"}],
}],
"partitions": [{"id": "partition", "a": [0, 0.5], "b": [1, 0.5], "cm": 12}],
"wall_columns": [{"id": "column", "shape": "circle", "center": [0.2, 0.2], "cm": 30}],
"open_spans": [{"a": [0.4, 0], "b": [0.6, 0], "future_span": "sensor.secret"}],
"decor": [
{
"id": "modern", "kind": "text", "x": 0.2, "y": 0.2,
"text": "Temp {sensor.kitchen} / {climate.hall.current_temperature} °C",
"future_decor": "sensor.secret",
},
{
"id": "legacy", "kind": "text", "x": 0.3, "y": 0.3,
"text": "Tank {} / {}", "entity": "sensor.tank",
"attr": "level", "unit": "%",
},
{
"id": "static", "kind": "text", "x": 0.4, "y": 0.4,
"text": "Literal {not a reference} and sensor.user_text",
},
{
"id": "furniture", "kind": "furniture", "symbol": "sofa",
"x": 0.5, "y": 0.5, "w": 0.2, "h": 0.1, "angle": 10,
},
],
})
room = space["rooms"][0]
room.update({
"area": "living-area",
"settings": {
"fill_mode": "custom", "custom_fill": {"c": "#123456", "a": 0.4},
"temp_source": "sensor.room_temp", "hum_source": "sensor.room_humidity",
"future_room_binding": "sensor.secret",
},
"future_room": "sensor.secret",
})
config["markers"].append({
"id": "note", "binding": "virtual", "space": "ground",
"room_id": "living", "name": "Boiler", "icon": "mdi:fire",
})
layout = {
"lamp": {"x": 0.4, "y": 0.5, "s": "ground"},
"note": {"x": 0.2, "y": 0.3, "s": "ground"},
"lg_light.group": {"x": 0.1, "y": 0.1, "s": "ground"},
"auto-device": {"x": 0.8, "y": 0.8, "s": "ground"},
"rl_living": {
"x": 0.45, "y": 0.55, "s": "ground", "k": 1.4,
"future": "drop",
},
"rl_other": {"x": 0.1, "y": 0.1, "s": "other"},
}
return config, layout
def test_plan_only_export_projects_geometry_and_round_trips_room_labels(tmp_path: Path) -> None:
config, layout = _plan_only_source()
runtime = SimpleNamespace(instance_id="instance-a", import_previews={})
document, _ = create_export(
runtime, {"config": config}, {"layout": layout}, kind="space",
space_id="ground", plan_only=True, card_version="review", config_root=tmp_path,
)
payload = document["payload"]
exported = payload["config"]["spaces"][0]
assert document["transfer"] == {"dropped_marker_links": 0, "plan_only": True}
assert payload["config"]["markers"] == []
assert payload["layout"] == {
"rl_living": {"x": 0.45, "y": 0.55, "s": "ground", "k": 1.4},
}
assert document["placement_manifest"] == [{
"layout_id": "rl_living", "space_id": "ground", "owner": "room_label",
"owner_id": "living", "binding": None, "label": None, "icon": None,
}]
assert document["content_manifest"][0]["url"] == "https://example.invalid/floor.svg"
assert all(item.get("owner") != "marker" for item in document["content_manifest"])
assert "future_space" not in exported
assert exported["settings"] == {
"show_names": True, "north_deg": 90, "sun_rays": True, "bg_mode": "static",
}
room = exported["rooms"][0]
assert "area" not in room and "future_room" not in room
assert room["settings"] == {
"fill_mode": "custom", "custom_fill": {"c": "#123456", "a": 0.4},
}
opening = exported["openings"][0]
assert opening["flip_h"] is True
assert not {"contact", "lock", "invert", "future_opening"} & set(opening)
by_id = {item["id"]: item for item in exported["decor"]}
assert by_id["modern"]["text"] == "Temp — / — °C"
assert by_id["legacy"]["text"] == "Tank — / —"
assert not {"entity", "attr", "unit"} & set(by_id["legacy"])
assert by_id["static"]["text"] == "Literal {not a reference} and sensor.user_text"
assert by_id["furniture"]["symbol"] == "sofa"
parsed = parse_document(json.dumps(document).encode())
preview = create_preview(
runtime, json.dumps(document).encode(), owner_id="alice", duplicate_policy="skip",
current_config_data={"config": {"spaces": [], "markers": []}, "rev": 0},
current_layout_data={"layout": {}, "rev": 0}, config_root=tmp_path,
registry_snapshot={"areas": set()},
)
assert preview["preview"]["plan_only"] is True
assert preview["preview"]["counts"]["markers"] == 0
assert preview["preview"]["counts"]["layout"] == 1
assert preview["preview"]["duplicates"] == 0
assert preview["preview"]["missing_areas"] == []
assert preview["preview"]["bindings"] == {
"device": 0, "entity": 0, "virtual": 0,
"active": 0, "disabled": 0, "missing": 0,
}
candidate = get_candidate(runtime, preview["token"], "alice")
refreshed = revalidate_candidate(
candidate, {"config": {"spaces": [], "markers": []}, "rev": 2},
{"layout": {}, "rev": 3}, duplicate_policy="skip", config_root=tmp_path,
)
assert refreshed["preview"]["plan_only"] is True
merged, merged_layout, details = prepare_apply(
candidate, {"spaces": [], "markers": []}, {}, confirm_missing_content=False,
)
assert merged["markers"] == []
imported_room = merged["spaces"][0]["rooms"][0]
assert imported_room.get("area") is None
assert merged_layout == {
"rl_" + imported_room["id"]: {
"x": 0.45, "y": 0.55, "s": details["space_id"], "k": 1.4,
},
}
assert parsed["transfer"]["plan_only"] is True
def test_ordinary_space_export_is_unchanged_when_plan_only_is_false(tmp_path: Path) -> None:
config, layout = _plan_only_source()
runtime = SimpleNamespace(instance_id="instance-a")
implicit, _implicit_name = create_export(
runtime, {"config": config}, {"layout": layout}, kind="space",
space_id="ground", card_version="review", config_root=tmp_path,
)
explicit, _explicit_name = create_export(
runtime, {"config": config}, {"layout": layout}, kind="space",
space_id="ground", plan_only=False, card_version="review", config_root=tmp_path,
)
for value in (implicit, explicit):
value.pop("created_at")
assert implicit == explicit
assert "plan_only" not in implicit["transfer"]
assert implicit["payload"]["config"]["markers"]
assert set(implicit["payload"]["layout"]) == {
key for key, pos in layout.items() if pos.get("s") == "ground"
}
@pytest.mark.parametrize(
"mutation",
[
"marker", "layout", "area", "temperature", "opening", "legacy_decor",
"live_token", "unknown", "placement", "layout_scale",
"marker_content", "invalid_content",
],
)
def test_parser_rejects_forged_plan_only_privacy_claim(
tmp_path: Path, mutation: str,
) -> None:
config, layout = _plan_only_source()
document, _ = create_export(
SimpleNamespace(instance_id="instance-a"), {"config": config}, {"layout": layout},
kind="space", space_id="ground", plan_only=True,
card_version="review", config_root=tmp_path,
)
space = document["payload"]["config"]["spaces"][0]
if mutation == "marker":
document["payload"]["config"]["markers"].append({
"id": "forged", "binding": "virtual", "name": "Forged",
})
elif mutation == "layout":
document["payload"]["layout"]["sensor.secret"] = {
"x": 0.1, "y": 0.2, "s": "ground",
}
document["placement_manifest"].append({
"layout_id": "sensor.secret", "space_id": "ground",
"owner": "auto_device", "owner_id": "sensor.secret",
"binding": "device:sensor.secret", "label": None, "icon": None,
})
elif mutation == "area":
space["rooms"][0]["area"] = "secret-area"
elif mutation == "temperature":
space["rooms"][0].setdefault("settings", {})["temp_source"] = "sensor.secret"
elif mutation == "opening":
space["openings"][0]["contact"] = "binary_sensor.secret"
elif mutation == "legacy_decor":
space["decor"][0]["entity"] = "sensor.secret"
elif mutation == "live_token":
space["decor"][0]["text"] = "Leaked {sensor.secret}"
elif mutation == "unknown":
space["future_binding"] = "sensor.secret"
elif mutation == "placement":
document["placement_manifest"][0]["owner"] = "auto_device"
elif mutation == "layout_scale":
document["payload"]["layout"]["rl_living"]["k"] = "sensor.secret"
elif mutation == "marker_content":
document["content_manifest"].append({"owner": "marker"})
elif mutation == "invalid_content":
document["content_manifest"].append("sensor.secret")
with pytest.raises(ImportFailure) as invalid:
parse_document(json.dumps(document).encode())
assert invalid.value.code == "invalid_format"
@pytest.mark.parametrize("value", [1, "true", None, {}, []])
def test_parser_requires_strict_plan_only_boolean(tmp_path: Path, value: Any) -> None:
document = _document(tmp_path, "space")
document["transfer"]["plan_only"] = value
with pytest.raises(ImportFailure) as invalid:
parse_document(json.dumps(document).encode())
assert invalid.value.code == "invalid_format"
def test_plan_only_cannot_be_requested_for_full_export(tmp_path: Path) -> None:
with pytest.raises(ImportFailure) as invalid:
create_export(
SimpleNamespace(instance_id="instance-a"), {"config": _config()}, {"layout": {}},
kind="full", space_id=None, plan_only=True,
card_version="review", config_root=tmp_path,
)
assert invalid.value.code == "invalid_format"
def test_full_export_import_round_trip_restores_model_version(tmp_path: Path) -> None:
"""The portable envelope version must return to the persisted config."""
document = _document(tmp_path)
@@ -830,6 +1081,17 @@ async def test_export_and_revalidate_ws_endpoints_use_server_owned_state(
assert document["model_version"] == PLAN_MODEL_VERSION
assert "model_version" not in document["payload"]["config"]
plan_exported = _Connection()
await wsapi.ws_export_create.__wrapped__(hass, plan_exported, {
"id": 44, "type": "houseplan/export/create", "kind": "space",
"space_id": "ground", "plan_only": True, "card_version": "review",
})
assert plan_exported.error is None and plan_exported.result
plan_document = plan_exported.result["document"]
assert plan_document["transfer"]["plan_only"] is True
assert plan_document["payload"]["config"]["markers"] == []
assert plan_document["payload"]["layout"] == {}
refreshed = _Connection()
await wsapi.ws_import_revalidate.__wrapped__(hass, refreshed, {
"id": 41, "type": "houseplan/import/revalidate",