mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
## Unreleased
|
||||
|
||||
- Экспорт текущего пространства теперь может создать JSON-шаблон **«Только
|
||||
планировка»** с комнатами, стенами, проёмами, декором, подложкой и позициями
|
||||
подписей комнат, но без устройств и структурных привязок Home Assistant.
|
||||
планировка»** с комнатами, стенами, проёмами, декором, подложкой, позициями и
|
||||
масштабом подписей комнат, но без устройств и структурных привязок Home
|
||||
Assistant.
|
||||
Предпросмотр помечает такой файл до добавления новым пространством
|
||||
([#167](https://github.com/Matysh/houseplan-card/issues/167)).
|
||||
- Составная техника, например стиральная машина, теперь использует явный
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
@@ -1159,8 +1159,8 @@ show_signal: true
|
||||
глобальные настройки не меняются и отмена не создаётся.
|
||||
|
||||
Для текущего пространства можно включить **Только планировка**. Такой файл
|
||||
сохраняет комнаты, стены, проёмы, декор, трансформации подложки и вручную
|
||||
расставленные подписи комнат, но удаляет реальные и виртуальные маркеры,
|
||||
сохраняет комнаты, стены, проёмы, декор, трансформации подложки, позиции и
|
||||
выбранный масштаб подписей комнат, но удаляет реальные и виртуальные маркеры,
|
||||
позиции устройств, Area, источники температуры/влажности и контакты/замки
|
||||
проёмов. Живые значения в текстовых подписях заменяются на `—`, а окружающий
|
||||
статический текст остаётся. Предпросмотр явно помечает файл как планировку и
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user