From 71ac4a663926e8f7d1cbb9a2163c212a39b257ea Mon Sep 17 00:00:00 2001 From: Matysh Date: Wed, 26 Aug 2026 00:28:29 +0300 Subject: [PATCH] fix: enforce wall model barrier in backend optimize Issue: #282 User-Visible: yes --- custom_components/houseplan/websocket_api.py | 27 +++++++++++++++++++- docs/CHANGELOG.md | 5 ++-- docs/CHANGELOG.ru.md | 4 ++- tests_backend/test_ha_websocket.py | 9 +++++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index d77eb33f..8851279f 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -60,6 +60,11 @@ from .virtual_lights import ( ) from .registry_snapshot import import_registry_snapshot from .projection import project_config, project_layout +from .wall_segment_model import ( + WALL_SEGMENT_MODEL_VERSION, + WallSegmentMigrationError, + commit_wall_segment_model, +) from .validation import ( CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES, PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError, @@ -1633,7 +1638,26 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) # as config/set; otherwise a crafted client can persist a new cycle. try: validate_wall_model_transition(msg["config"], config_data.get("config")) - validated_config = CONFIG_SCHEMA(msg["config"]) + try: + submitted_model = int(msg["config"].get("model_version", 0) or 0) + except (TypeError, ValueError): + submitted_model = WALL_SEGMENT_MODEL_VERSION + # Optimize is an explicit structural writer and therefore the + # server-side v7 -> v8 barrier as well. Current v8 candidates are + # validated verbatim: the backend must never invent lineage for a + # graph already authored by a current client. + candidate_config = CONFIG_SCHEMA(msg["config"]) + if submitted_model < WALL_SEGMENT_MODEL_VERSION: + candidate_config, _ = commit_wall_segment_model(candidate_config) + validated_config = CONFIG_SCHEMA(candidate_config) + migrated_size = len(json.dumps(validated_config, separators=(",", ":"))) + if migrated_size > MAX_CONFIG_BYTES: + connection.send_error( + msg["id"], "too_large", + f"Configuration is {migrated_size // 1024} KB, " + f"the limit is {MAX_CONFIG_BYTES // 1024} KB", + ) + return msg["config"].clear() msg["config"].update(validated_config) validate_marker_controls(msg["config"], config_data.get("config")) @@ -1647,6 +1671,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) except ( MarkerControlError, OpeningPassageError, PartitionOpeningHostError, PartitionOpeningJambMarginError, WallModelClientOutdatedError, + WallSegmentMigrationError, ) as err: connection.send_error(msg["id"], err.code, str(err)) return diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9d309739..46683302 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,8 +18,9 @@ passage ownership attached to stable wall-segment IDs. Existing plans upgrade atomically on their first structural edit or through **Optimize plans**; Resize, Undo/Redo and repeated edits preserve the same identities without - accumulating off-grid coordinates. Ambiguous legacy geometry is left - unchanged with a recovery prompt. No Plan editor controls or + accumulating off-grid coordinates. The backend Optimize transaction also + enforces this upgrade when invoked directly. Ambiguous legacy geometry is + left unchanged with a recovery prompt. No Plan editor controls or zero-thickness-wall behaviour changed ([#282](https://github.com/Matysh/houseplan-card/issues/282)). - The active wall chain keeps its axis and node markers visible on already diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 1dba5108..899c1601 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -22,7 +22,9 @@ сегментов и принадлежность дверей, окон, ворот и проходов через стабильные ID сегментов стен. Существующий план обновляется атомарно при первом структурном изменении или через **«Оптимизировать планы»**; Resize, отмена/повтор и - последующие правки сохраняют те же ID без накопления координат вне сетки. + последующие правки сохраняют те же ID без накопления координат вне сетки; + серверная транзакция Optimize также гарантирует это обновление при прямом + вызове. Неоднозначная старая геометрия остаётся без изменений с подсказкой по восстановлению. Кнопки редактора плана и поведение стен нулевой толщины не менялись diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index a6bfe4cc..63cf83d9 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -568,8 +568,13 @@ async def test_optimize_accepts_proved_rehost_and_undo_restores_host( optimized = await client.receive_json() assert optimized["success"] stored = (await runtime.config_store.async_load())["config"] - assert stored["spaces"][0].get("partitions") in (None, []) - assert "host" not in stored["spaces"][0]["openings"][0] + stored_space = stored["spaces"][0] + assert stored_space.get("partitions") in (None, []) + wall_ids = {segment["id"] for segment in stored_space["wall_segments"]} + assert wall_ids + for opening in stored_space["openings"]: + assert opening["host"]["kind"] == "wall" + assert opening["host"]["id"] in wall_ids await client.send_json_auto_id({ "type": "houseplan/plan/optimize_undo",