From a1b8861eff0c502e686e1039f689bfabba80a0c8 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Mon, 17 Aug 2026 13:16:04 +0300 Subject: [PATCH] fix: preserve plan-only room label scale Issue: #167 User-Visible: yes --- .github/workflows/mutation-gate.yml | 7 ++++- custom_components/houseplan/import_export.py | 27 +++++++++++++------- docs/CHANGELOG.md | 4 +-- docs/CHANGELOG.ru.md | 5 ++-- docs/USER-GUIDE.md | 5 ++-- docs/USER-GUIDE.ru.md | 4 +-- docs/specs/167-plan-only-export.md | 17 ++++++------ scripts/backend-test-guard.mjs | 3 ++- tests_backend/test_ha_import_export.py | 14 +++++++--- 9 files changed, 55 insertions(+), 31 deletions(-) diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index b6a304e1..55d77621 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -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' @@ -33,7 +38,7 @@ jobs: 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 diff --git a/custom_components/houseplan/import_export.py b/custom_components/houseplan/import_export.py index a5408051..ace3bf41 100644 --- a/custom_components/houseplan/import_export.py +++ b/custom_components/houseplan/import_export.py @@ -9,6 +9,7 @@ from __future__ import annotations import copy import hashlib import json +import math import re import secrets import time @@ -276,15 +277,23 @@ def _plan_only_room_label_layout( ) -> 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 - } + 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: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 33b33bf4..ab58f929 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,8 +3,8 @@ ## 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 + 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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index dfce9f3c..1fb6113f 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -9,8 +9,9 @@ ## Unreleased - Экспорт текущего пространства теперь может создать JSON-шаблон **«Только - планировка»** с комнатами, стенами, проёмами, декором, подложкой и позициями - подписей комнат, но без устройств и структурных привязок Home Assistant. + планировка»** с комнатами, стенами, проёмами, декором, подложкой, позициями и + масштабом подписей комнат, но без устройств и структурных привязок Home + Assistant. Предпросмотр помечает такой файл до добавления новым пространством ([#167](https://github.com/Matysh/houseplan-card/issues/167)). - Составная техника, например стиральная машина, теперь использует явный diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 7c225ea7..374f2156 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -509,8 +509,9 @@ 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, +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. diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 3d5972a3..fa94c8ba 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1159,8 +1159,8 @@ show_signal: true глобальные настройки не меняются и отмена не создаётся. Для текущего пространства можно включить **Только планировка**. Такой файл -сохраняет комнаты, стены, проёмы, декор, трансформации подложки и вручную -расставленные подписи комнат, но удаляет реальные и виртуальные маркеры, +сохраняет комнаты, стены, проёмы, декор, трансформации подложки, позиции и +выбранный масштаб подписей комнат, но удаляет реальные и виртуальные маркеры, позиции устройств, Area, источники температуры/влажности и контакты/замки проёмов. Живые значения в текстовых подписях заменяются на `—`, а окружающий статический текст остаётся. Предпросмотр явно помечает файл как планировку и diff --git a/docs/specs/167-plan-only-export.md b/docs/specs/167-plan-only-export.md index b4857f26..360607b8 100644 --- a/docs/specs/167-plan-only-export.md +++ b/docs/specs/167-plan-only-export.md @@ -162,7 +162,7 @@ device layout и известных структурных HA-привязок. "payload": { "config": { "spaces": ["…"], "markers": [] }, "layout": { - "rl_room-kitchen": { "x": 0.42, "y": 0.31, "s": "floor-1" } + "rl_room-kitchen": { "x": 0.42, "y": 0.31, "s": "floor-1", "k": 1.4 } } }, "placement_manifest": [ @@ -215,7 +215,7 @@ device layout и известных структурных HA-привязок. | Источник | Результат plan-only | |---|---| | `config.markers` | `[]`; marker config целиком отсутствует | -| `payload.layout` | только `rl_` для комнаты экспортируемого пространства; marker, `v_*`, `lg_*`, auto-device и неизвестные позиции удаляются | +| `payload.layout` | только `rl_` для комнаты экспортируемого пространства: обязательные `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 | @@ -288,7 +288,8 @@ presentation, user content или HA binding. Это fail-closed защита о - ровно одно пространство; - `markers == []`; - каждый layout key строго равен `rl_` существующей комнаты - экспортируемого пространства, а `pos.s` равен 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; @@ -363,8 +364,8 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн 6. Названия, статический текст, filenames и external URLs сохраняются; UX не обещает полную анонимизацию и не добавляет отдельного предупреждения. 7. Импорт plan-only файла на чистый целевой instance создаёт новое пространство - с той же планировкой, нулём устройств/HA-привязок, remap-нутыми позициями - подписей комнат и unbound rooms. + с той же планировкой, нулём устройств/HA-привязок, remap-нутыми позициями и + масштабом подписей комнат и unbound rooms. 8. Preview и revalidate явно сохраняют `plan_only: true`, показывают нулевые binding counts и не предлагают duplicate policy. 9. File с true, но с маркером, не-room-label layout, несогласованным placement @@ -397,7 +398,7 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн - reject `plan_only=true` для full; - reject non-boolean plan_only; - reject forged plan-only files по одному для marker, чужого/невалидного - room-label layout/placement, room area, + 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 по действующему контракту; @@ -494,8 +495,8 @@ Push ветки выполняется после задачи; issue не за 1. `plan_only` — optional additive metadata внутри существующего export version, а не новый kind или новая версия формата. -2. Безопасные ручные позиции подписей комнат `rl_` сохраняются и - remap-ятся; весь остальной layout удаляется. +2. Безопасные ручные позиции и конечный масштаб `k` подписей комнат + `rl_` сохраняются и remap-ятся; весь остальной layout удаляется. 3. Реальные и виртуальные markers удаляются одинаково. 4. Геометрический `flip_h|flip_v` сохраняется, contact-specific `invert` удаляется вместе с binding. diff --git a/scripts/backend-test-guard.mjs b/scripts/backend-test-guard.mjs index d3331c92..b6af1f91 100644 --- a/scripts/backend-test-guard.mjs +++ b/scripts/backend-test-guard.mjs @@ -7,7 +7,8 @@ if (!pattern) { process.exit(2); } -const result = spawnSync(process.env.PYTHON || 'python', [ +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); diff --git a/tests_backend/test_ha_import_export.py b/tests_backend/test_ha_import_export.py index b2971d49..d4701b9f 100644 --- a/tests_backend/test_ha_import_export.py +++ b/tests_backend/test_ha_import_export.py @@ -340,7 +340,10 @@ def _plan_only_source() -> tuple[dict[str, Any], dict[str, Any]]: "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_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 @@ -358,7 +361,7 @@ def test_plan_only_export_projects_geometry_and_round_trips_room_labels(tmp_path 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"}, + "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", @@ -416,7 +419,7 @@ def test_plan_only_export_projects_geometry_and_round_trips_room_labels(tmp_path assert imported_room.get("area") is None assert merged_layout == { "rl_" + imported_room["id"]: { - "x": 0.45, "y": 0.55, "s": details["space_id"], + "x": 0.45, "y": 0.55, "s": details["space_id"], "k": 1.4, }, } assert parsed["transfer"]["plan_only"] is True @@ -447,7 +450,8 @@ def test_ordinary_space_export_is_unchanged_when_plan_only_is_false(tmp_path: Pa "mutation", [ "marker", "layout", "area", "temperature", "opening", "legacy_decor", - "live_token", "unknown", "placement", "marker_content", "invalid_content", + "live_token", "unknown", "placement", "layout_scale", + "marker_content", "invalid_content", ], ) def test_parser_rejects_forged_plan_only_privacy_claim( @@ -487,6 +491,8 @@ def test_parser_rejects_forged_plan_only_privacy_claim( 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":