feat: add plan-only space export

Issue: #167
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-17 12:33:52 +03:00
parent bf2937c1ed
commit 7f397a6875
24 changed files with 859 additions and 84 deletions
+9 -2
View File
@@ -27,8 +27,8 @@ concurrency:
jobs:
mutants:
runs-on: ubuntu-latest
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
# гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
@@ -43,6 +43,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
+240 -31
View File
@@ -53,6 +53,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 +167,126 @@ 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 []}
return {
key: _pick_fields(pos, ("x", "y", "s"))
for key, pos in layout.items()
if isinstance(key, str)
and key.startswith("rl_")
and key[3:] in room_ids
and isinstance(pos, dict)
and str(pos.get("s", "")) == space_id
}
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 +402,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 +434,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 +483,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 +575,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 +652,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 +1171,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 +1251,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 and room-label positions, 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
+5
View File
@@ -8,6 +8,11 @@
## 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.
+13
View File
@@ -507,6 +507,19 @@ 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, 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 при этом не
переносятся.
### Где лежат данные
| Данные | Хранение |
+61 -25
View File
@@ -37,14 +37,15 @@ Issue: [#167](https://github.com/Matysh/houseplan-card/issues/167)
## 2. Что человек увидит до и после
**До:** экспорт текущего пространства всегда содержит его маркеры, layout и
**До:** экспорт текущего пространства всегда содержит его маркеры, device layout и
HA-привязки. Для чистого переноса пользователь должен вручную редактировать
JSON, рискуя повредить структуру или случайно оставить идентификаторы.
**После:** рядом с выбором текущего пространства доступен выключенный по
умолчанию флажок «Plan only». В этом режиме файл сохраняет переносимую
планировку, но не содержит маркеров, layout и известных структурных
HA-привязок. Preview импорта явно сообщает, что файл содержит только
планировку и вручную расставленные подписи комнат, но не содержит маркеров,
device layout и известных структурных HA-привязок. Preview импорта явно
сообщает, что файл содержит только
планировку; импорт добавляет новое несвязанное пространство существующим
безопасным механизмом #50.
@@ -72,7 +73,9 @@ HA-привязок. Preview импорта явно сообщает, что ф
1. опция «Plan only» только для экспорта текущего пространства;
2. schema-aware проекция переносимой геометрии и визуальных настроек;
3. полное удаление маркеров, marker layout и структурных HA-привязок;
3. полное удаление реальных и виртуальных маркеров, marker/auto-device/light-
group layout и структурных HA-привязок при сохранении безопасных позиций
подписей комнат `rl_<room_id>`;
4. статическая нейтрализация live-text токенов по решению владельца;
5. аддитивный признак `transfer.plan_only: true` в JSON;
6. строгая проверка plan-only инварианта при чтении файла;
@@ -96,6 +99,9 @@ HA-привязок. Preview импорта явно сообщает, что ф
- сопоставление 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-жестов;
@@ -129,7 +135,8 @@ HA-привязок. Preview импорта явно сообщает, что ф
Для plan-only файла preview:
- явно показывает информационную строку «Файл содержит только планировку»;
- показывает `markers = 0`, `layout = 0`, device/entity/virtual bindings = 0;
- показывает `markers = 0`, device/entity/virtual bindings = 0, а `layout`
считает только сохранённые позиции подписей комнат;
- не показывает duplicate policy, поскольку дубликатов устройств нет;
- не показывает missing Area, поскольку `room.area` очищен;
- показывает обычные counts комнат, стен, проёмов, декора и content;
@@ -154,9 +161,21 @@ HA-привязок. Preview импорта явно сообщает, что ф
},
"payload": {
"config": { "spaces": ["…"], "markers": [] },
"layout": {}
"layout": {
"rl_room-kitchen": { "x": 0.42, "y": 0.31, "s": "floor-1" }
}
},
"placement_manifest": [],
"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": ["…"]
}
```
@@ -196,8 +215,8 @@ HA-привязок. Preview импорта явно сообщает, что ф
| Источник | Результат plan-only |
|---|---|
| `config.markers` | `[]`; marker config целиком отсутствует |
| `payload.layout` | `{}`; удаляются все позиции, включая room labels |
| `placement_manifest` | `[]` |
| `payload.layout` | только `rl_<room_id>` для комнаты экспортируемого пространства; marker, `v_*`, `lg_*`, auto-device и неизвестные позиции удаляются |
| `placement_manifest` | только canonical `room_label` entries, точно соответствующие сохранённым `rl_*` ключам |
| marker attachment/content entries | отсутствуют |
| `room.area` | отсутствует или canonical unbound value |
| `room.settings.temp_source` | отсутствует |
@@ -208,6 +227,9 @@ HA-привязок. Preview импорта явно сообщает, что ф
| valid live tokens и legacy `{}` в `decor.text` | заменены на `—` |
| `known_devices` / `new_device_ids` | не переносятся |
Реальные и виртуальные markers удаляются одинаково: `binding: virtual`, имя или
статичная иконка не переводят marker в архитектурный decor.
`flip_h`, `flip_v` и другие геометрические параметры проёма не являются
HA-binding behavior и сохраняются.
@@ -264,7 +286,12 @@ presentation, user content или HA binding. Это fail-closed защита о
`kind == "space" && transfer.plan_only == true` он дополнительно проверяет:
- ровно одно пространство;
- `markers == []`, `layout == {}`, `placement_manifest == []`;
- `markers == []`;
- каждый layout key строго равен `rl_<room_id>` существующей комнаты
экспортируемого пространства, а `pos.s` равен id этого пространства;
- каждый 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 `{}`;
@@ -282,7 +309,8 @@ presentation, user content или HA binding. Это fail-closed защита о
title и не меняет global settings.
- Content availability/detach повторно проверяется перед apply под действующим
lock по контракту #50.
- Apply не добавляет маркеры и layout, а комнаты остаются unbound.
- Apply не добавляет маркеры; существующий remap переносит только room-label
layout на новые room/space ids, а комнаты остаются unbound.
- Events, revision conflict, token ownership/expiry и capacity limits не
меняются.
@@ -323,8 +351,9 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн
1. При Current space пользователь видит выключенный «Plan only»; при Full
backup опции нет, а request не содержит true.
2. Plan-only export содержит одно пространство, `markers: []`, `layout: {}`,
empty placement manifest и `transfer.plan_only: 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,
@@ -334,11 +363,12 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн
6. Названия, статический текст, filenames и external URLs сохраняются; UX не
обещает полную анонимизацию и не добавляет отдельного предупреждения.
7. Импорт plan-only файла на чистый целевой instance создаёт новое пространство
с той же планировкой, нулём устройств/позиций/привязок и unbound rooms.
с той же планировкой, нулём устройств/HA-привязок, remap-нутыми позициями
подписей комнат и unbound rooms.
8. Preview и revalidate явно сохраняют `plan_only: true`, показывают нулевые
binding counts и не предлагают duplicate policy.
9. File с true, но с маркером, layout или известной HA-привязкой отклоняется
как `invalid_format` до preview.
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 доступен с клавиатуры и диалог проходит
@@ -361,11 +391,13 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн
- export normal space с фиксированным временем — прежний fixture без нового
поля;
- plan-only projection полного representative space со всеми типами geometry,
marker/layout, room/opening bindings, modern и legacy live text;
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, layout, room area,
- reject forged plan-only files по одному для marker, чужого/невалидного
room-label layout/placement, room area,
temp/hum, opening refs, legacy decor refs и inline token;
- preview/revalidate/apply happy path на same и foreign instance;
- missing internal backdrop + detach confirmation по действующему контракту;
@@ -383,7 +415,8 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн
- import smoke: `plan_only` line есть, duplicate controls отсутствуют;
- RU/EN i18n parity;
- добавить/обновить deterministic golden scenarios для export dialog с
включённой опцией и import preview, поднять версию golden matrix;
включённой опцией и import preview; после слитого #166 поднять жёстко
проверяемый `GOLDEN_MATRIX_VERSION` с 23 до 24;
- review actual/expected/diff до принятия baseline.
В implementation loop выполняются только действующие быстрые gates:
@@ -396,8 +429,8 @@ guard, получить non-zero и восстановить файл:
1. оставить один inline live token либо `room.area` в проекции — backend
plan-only privacy test падает;
2. проигнорировать `plan_only` и вернуть marker/layout — projection/roundtrip
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 или не показать строку —
@@ -461,11 +494,14 @@ Push ветки выполняется после задачи; issue не за
1. `plan_only` — optional additive metadata внутри существующего export
version, а не новый kind или новая версия формата.
2. Геометрический `flip_h|flip_v` сохраняется, contact-specific `invert`
2. Безопасные ручные позиции подписей комнат `rl_<room_id>` сохраняются и
remap-ятся; весь остальной layout удаляется.
3. Реальные и виртуальные markers удаляются одинаково.
4. Геометрический `flip_h|flip_v` сохраняется, contact-specific `invert`
удаляется вместе с binding.
3. Неизвестные поля в plan-only проекцию автоматически не попадают; обычный
5. Неизвестные поля в plan-only проекцию автоматически не попадают; обычный
export остаётся lossless.
4. Privacy invariant относится к структурным HA-полям и валидным live tokens,
6. Privacy invariant относится к структурным HA-полям и валидным live tokens,
но не к произвольным пользовательским строкам.
5. Новый informational label/hint допустим; отдельное предупреждение или новое
7. Новый informational label/hint допустим; отдельное предупреждение или новое
подтверждение по решению владельца запрещено.
+13
View File
@@ -0,0 +1,13 @@
#!/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 result = spawnSync(process.env.PYTHON || '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');
+254
View File
@@ -270,6 +270,249 @@ 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", "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"},
}
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"],
},
}
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"]) == set(layout)
@pytest.mark.parametrize(
"mutation",
[
"marker", "layout", "area", "temperature", "opening", "legacy_decor",
"live_token", "unknown", "placement", "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 == "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 +1073,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",