fix: preserve plan-only room label scale

Issue: #167
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-17 13:16:04 +03:00
parent 2e47473619
commit a1b8861eff
9 changed files with 55 additions and 31 deletions
+6 -1
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'
@@ -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
+18 -9
View File
@@ -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:
+2 -2
View File
@@ -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
+3 -2
View File
@@ -9,8 +9,9 @@
## Unreleased
- Экспорт текущего пространства теперь может создать JSON-шаблон **«Только
планировка»** с комнатами, стенами, проёмами, декором, подложкой и позициями
подписей комнат, но без устройств и структурных привязок Home Assistant.
планировка»** с комнатами, стенами, проёмами, декором, подложкой, позициями и
масштабом подписей комнат, но без устройств и структурных привязок Home
Assistant.
Предпросмотр помечает такой файл до добавления новым пространством
([#167](https://github.com/Matysh/houseplan-card/issues/167)).
- Составная техника, например стиральная машина, теперь использует явный
+3 -2
View File
@@ -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.
+2 -2
View File
@@ -1159,8 +1159,8 @@ show_signal: true
глобальные настройки не меняются и отмена не создаётся.
Для текущего пространства можно включить **Только планировка**. Такой файл
сохраняет комнаты, стены, проёмы, декор, трансформации подложки и вручную
расставленные подписи комнат, но удаляет реальные и виртуальные маркеры,
сохраняет комнаты, стены, проёмы, декор, трансформации подложки, позиции и
выбранный масштаб подписей комнат, но удаляет реальные и виртуальные маркеры,
позиции устройств, Area, источники температуры/влажности и контакты/замки
проёмов. Живые значения в текстовых подписях заменяются на `—`, а окружающий
статический текст остаётся. Предпросмотр явно помечает файл как планировку и
+9 -8
View File
@@ -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_<room_id>` для комнаты экспортируемого пространства; marker, `v_*`, `lg_*`, auto-device и неизвестные позиции удаляются |
| `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 |
@@ -288,7 +288,8 @@ presentation, user content или HA binding. Это fail-closed защита о
- ровно одно пространство;
- `markers == []`;
- каждый layout key строго равен `rl_<room_id>` существующей комнаты
экспортируемого пространства, а `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_<room_id>` сохраняются и
remap-ятся; весь остальной layout удаляется.
2. Безопасные ручные позиции и конечный масштаб `k` подписей комнат
`rl_<room_id>` сохраняются и remap-ятся; весь остальной layout удаляется.
3. Реальные и виртуальные markers удаляются одинаково.
4. Геометрический `flip_h|flip_v` сохраняется, contact-specific `invert`
удаляется вместе с binding.
+2 -1
View File
@@ -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);
+10 -4
View File
@@ -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":