mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
@@ -0,0 +1,149 @@
|
|||||||
|
"""Lossless, allow-listed canonicalisation of persisted geometry.
|
||||||
|
|
||||||
|
The frontend mirrors this module in src/coordinate-canonicalization.ts.
|
||||||
|
Keep the precision, scalar formula and field allow-list in lockstep; a shared
|
||||||
|
fixture is exercised by both runtimes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
COORDINATE_DECIMALS = 9
|
||||||
|
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_number(value: Any) -> Any:
|
||||||
|
"""Return one stable IEEE-754 representation for an allow-listed number."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return value
|
||||||
|
number = float(value)
|
||||||
|
if not math.isfinite(number):
|
||||||
|
return value
|
||||||
|
sign = -1.0 if math.copysign(1.0, number) < 0 else 1.0
|
||||||
|
result = sign * (
|
||||||
|
math.floor(abs(number) * COORDINATE_FACTOR + 0.5)
|
||||||
|
/ COORDINATE_FACTOR
|
||||||
|
)
|
||||||
|
if result == 0:
|
||||||
|
return 0.0
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _record(value: Any) -> dict[str, Any] | None:
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _records(value: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [item for item in value if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||||
|
for name in names:
|
||||||
|
if name in record:
|
||||||
|
record[name] = canonicalize_number(record[name])
|
||||||
|
|
||||||
|
|
||||||
|
def _point(value: Any) -> None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return
|
||||||
|
for index in range(min(2, len(value))):
|
||||||
|
value[index] = canonicalize_number(value[index])
|
||||||
|
|
||||||
|
|
||||||
|
def _points(value: Any) -> None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return
|
||||||
|
for point in value:
|
||||||
|
_point(point)
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_position(position: Any) -> Any:
|
||||||
|
"""Canonicalise x/y in one layout record without touching its metadata."""
|
||||||
|
result = copy.deepcopy(position)
|
||||||
|
record = _record(result)
|
||||||
|
if record is not None:
|
||||||
|
_fields(record, ("x", "y"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_layout_geometry(layout: Any) -> Any:
|
||||||
|
"""Canonicalise x/y in every layout record, preserving future fields."""
|
||||||
|
result = copy.deepcopy(layout)
|
||||||
|
record = _record(result)
|
||||||
|
if record is None:
|
||||||
|
return result
|
||||||
|
for position in record.values():
|
||||||
|
item = _record(position)
|
||||||
|
if item is not None:
|
||||||
|
_fields(item, ("x", "y"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_config_geometry(config: Any) -> Any:
|
||||||
|
"""Canonicalise only the named persisted geometry fields."""
|
||||||
|
result = copy.deepcopy(config)
|
||||||
|
root = _record(result)
|
||||||
|
if root is None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for space in _records(root.get("spaces")):
|
||||||
|
_fields(
|
||||||
|
space,
|
||||||
|
(
|
||||||
|
"plan_x",
|
||||||
|
"plan_y",
|
||||||
|
"plan_scale",
|
||||||
|
"plan_scale_x",
|
||||||
|
"plan_scale_y",
|
||||||
|
"plan_angle",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for room in _records(space.get("rooms")):
|
||||||
|
_fields(room, ("x", "y", "w", "h"))
|
||||||
|
_points(room.get("poly"))
|
||||||
|
|
||||||
|
for wall in _records(space.get("walls")):
|
||||||
|
_point(wall.get("a"))
|
||||||
|
_point(wall.get("b"))
|
||||||
|
|
||||||
|
for opening in _records(space.get("openings")):
|
||||||
|
_fields(opening, ("x", "y", "angle", "length"))
|
||||||
|
host = _record(opening.get("host"))
|
||||||
|
if host is not None:
|
||||||
|
_fields(host, ("t",))
|
||||||
|
|
||||||
|
for decor in _records(space.get("decor")):
|
||||||
|
kind = decor.get("kind")
|
||||||
|
if kind == "line":
|
||||||
|
_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||||
|
elif kind in ("rect", "ellipse", "furniture"):
|
||||||
|
_fields(decor, ("x", "y", "w", "h", "angle"))
|
||||||
|
elif kind == "text":
|
||||||
|
_fields(decor, ("x", "y", "scale", "angle"))
|
||||||
|
|
||||||
|
for draft in _records(space.get("room_drafts")):
|
||||||
|
_points(draft.get("points"))
|
||||||
|
|
||||||
|
for partition in _records(space.get("partitions")):
|
||||||
|
_point(partition.get("a"))
|
||||||
|
_point(partition.get("b"))
|
||||||
|
|
||||||
|
for column in _records(space.get("wall_columns")):
|
||||||
|
_point(column.get("center"))
|
||||||
|
if column.get("shape") == "square":
|
||||||
|
_fields(column, ("angle",))
|
||||||
|
|
||||||
|
for span in _records(space.get("open_spans")):
|
||||||
|
_point(span.get("a"))
|
||||||
|
_point(span.get("b"))
|
||||||
|
|
||||||
|
for marker in _records(root.get("markers")):
|
||||||
|
_fields(marker, ("angle",))
|
||||||
|
|
||||||
|
return result
|
||||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,10 @@ from .const import (
|
|||||||
STORAGE_VERSION,
|
STORAGE_VERSION,
|
||||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||||
)
|
)
|
||||||
|
from .coordinate_canonicalization import (
|
||||||
|
canonicalize_config_geometry,
|
||||||
|
canonicalize_layout_geometry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -157,7 +161,7 @@ def layout_store_payload(
|
|||||||
}
|
}
|
||||||
if metadata:
|
if metadata:
|
||||||
out.update(metadata)
|
out.update(metadata)
|
||||||
out["layout"] = layout
|
out["layout"] = canonicalize_layout_geometry(layout)
|
||||||
out["rev"] = rev
|
out["rev"] = rev
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -205,7 +209,8 @@ async def async_save_config_state(
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
previous_rev = 0
|
previous_rev = 0
|
||||||
|
|
||||||
payload = {"config": config, "rev": rev}
|
canonical_config = canonicalize_config_geometry(config)
|
||||||
|
payload = {"config": canonical_config, "rev": rev}
|
||||||
await runtime.config_store.async_save(payload)
|
await runtime.config_store.async_save(payload)
|
||||||
|
|
||||||
# The config is already durable at this point. Reconciliation remains a
|
# The config is already durable at this point. Reconciliation remains a
|
||||||
@@ -216,7 +221,7 @@ async def async_save_config_state(
|
|||||||
try:
|
try:
|
||||||
await async_reconcile_virtual_lights(
|
await async_reconcile_virtual_lights(
|
||||||
runtime.virtual_light_store,
|
runtime.virtual_light_store,
|
||||||
config,
|
canonical_config,
|
||||||
rev,
|
rev,
|
||||||
previous_config_rev=previous_rev,
|
previous_config_rev=previous_rev,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import re
|
|||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from custom_components.houseplan.coordinate_canonicalization import (
|
||||||
|
canonicalize_config_geometry,
|
||||||
|
canonicalize_layout_geometry,
|
||||||
|
canonicalize_position,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------- limits and extension sets ----------
|
# ---------- limits and extension sets ----------
|
||||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||||
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
||||||
@@ -522,11 +528,18 @@ _URL = vol.All(str, vol.Length(max=MAX_URL))
|
|||||||
CANVAS_LIMIT = 5000.0
|
CANVAS_LIMIT = 5000.0
|
||||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||||
|
|
||||||
POS_SCHEMA = vol.Schema(
|
POS_SCHEMA = vol.All(
|
||||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
vol.Schema(
|
||||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||||
|
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||||
|
),
|
||||||
|
canonicalize_position,
|
||||||
|
)
|
||||||
|
LAYOUT_SCHEMA = vol.All(
|
||||||
|
vol.Schema({str: POS_SCHEMA}),
|
||||||
|
vol.Length(max=MAX_LAYOUT),
|
||||||
|
canonicalize_layout_geometry,
|
||||||
)
|
)
|
||||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
|
||||||
|
|
||||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||||
@@ -1124,38 +1137,41 @@ MARKER_SCHEMA = vol.Schema(
|
|||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.All(
|
||||||
{
|
vol.Schema(
|
||||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
{
|
||||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||||
vol.Optional("settings", default=dict): vol.Schema(
|
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||||
{
|
vol.Optional("settings", default=dict): vol.Schema(
|
||||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
{
|
||||||
# background around the plan, all spaces (a space may override)
|
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||||
vol.Optional("bg_color"): _COLOR,
|
# background around the plan, all spaces (a space may override)
|
||||||
# sun on the plan (docs/SUN.md): global defaults
|
vol.Optional("bg_color"): _COLOR,
|
||||||
vol.Optional("north_deg"): _north_deg,
|
# sun on the plan (docs/SUN.md): global defaults
|
||||||
vol.Optional("bg_mode"): _BG_MODE,
|
vol.Optional("north_deg"): _north_deg,
|
||||||
vol.Optional("sun_rays"): bool,
|
vol.Optional("bg_mode"): _BG_MODE,
|
||||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
vol.Optional("sun_rays"): bool,
|
||||||
# legacy field so an existing stored config can still load; the
|
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||||
# frontend ignores it and removes it on the next settings save.
|
# legacy field so an existing stored config can still load; the
|
||||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
# frontend ignores it and removes it on the next settings save.
|
||||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||||
vol.Optional("fill_colors"): vol.Schema(
|
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||||
{
|
vol.Optional("fill_colors"): vol.Schema(
|
||||||
str: vol.Schema(
|
{
|
||||||
{
|
str: vol.Schema(
|
||||||
vol.Required("c"): _COLOR,
|
{
|
||||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
vol.Required("c"): _COLOR,
|
||||||
}
|
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||||
)
|
}
|
||||||
}
|
)
|
||||||
),
|
}
|
||||||
},
|
),
|
||||||
extra=vol.ALLOW_EXTRA,
|
},
|
||||||
),
|
extra=vol.ALLOW_EXTRA,
|
||||||
},
|
),
|
||||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||||
|
),
|
||||||
|
canonicalize_config_geometry,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ from .const import (
|
|||||||
MAX_SIGN_PATHS,
|
MAX_SIGN_PATHS,
|
||||||
PLANS_DIR, PLANS_URL,
|
PLANS_DIR, PLANS_URL,
|
||||||
)
|
)
|
||||||
|
from .coordinate_canonicalization import (
|
||||||
|
canonicalize_config_geometry,
|
||||||
|
canonicalize_layout_geometry,
|
||||||
|
)
|
||||||
from .auth import may_write
|
from .auth import may_write
|
||||||
from .import_export import (
|
from .import_export import (
|
||||||
ImportFailure,
|
ImportFailure,
|
||||||
@@ -426,8 +430,12 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
if kind == "full":
|
if kind == "full":
|
||||||
backup = {
|
backup = {
|
||||||
"kind": "import",
|
"kind": "import",
|
||||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
"config": canonicalize_config_geometry(
|
||||||
"layout": layout_data.get("layout") or {},
|
config_data.get("config") or DEFAULT_CONFIG
|
||||||
|
),
|
||||||
|
"layout": canonicalize_layout_geometry(
|
||||||
|
layout_data.get("layout") or {}
|
||||||
|
),
|
||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"after_config_rev": new_config_rev,
|
"after_config_rev": new_config_rev,
|
||||||
"after_layout_rev": new_layout_rev,
|
"after_layout_rev": new_layout_rev,
|
||||||
@@ -444,16 +452,20 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
final_metadata[_OPTIMIZE_BACKUP] = backup
|
final_metadata[_OPTIMIZE_BACKUP] = backup
|
||||||
pending = {
|
pending = {
|
||||||
"kind": "import",
|
"kind": "import",
|
||||||
"config": target_config,
|
"config": canonicalize_config_geometry(target_config),
|
||||||
"layout": target_layout,
|
"layout": canonicalize_layout_geometry(target_layout),
|
||||||
"config_rev": new_config_rev,
|
"config_rev": new_config_rev,
|
||||||
"layout_rev": new_layout_rev,
|
"layout_rev": new_layout_rev,
|
||||||
"final_metadata": final_metadata,
|
"final_metadata": final_metadata,
|
||||||
}
|
}
|
||||||
rollback = {
|
rollback = {
|
||||||
"kind": "import_rollback",
|
"kind": "import_rollback",
|
||||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
"config": canonicalize_config_geometry(
|
||||||
"layout": layout_data.get("layout") or {},
|
config_data.get("config") or DEFAULT_CONFIG
|
||||||
|
),
|
||||||
|
"layout": canonicalize_layout_geometry(
|
||||||
|
layout_data.get("layout") or {}
|
||||||
|
),
|
||||||
"config_rev": config_rev,
|
"config_rev": config_rev,
|
||||||
"layout_rev": layout_rev,
|
"layout_rev": layout_rev,
|
||||||
"final_metadata": original_metadata,
|
"final_metadata": original_metadata,
|
||||||
@@ -566,6 +578,9 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
|
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
|
||||||
|
if layout == data.get("layout", {}):
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "rev": current_rev})
|
||||||
|
return
|
||||||
new_rev = current_rev + 1
|
new_rev = current_rev + 1
|
||||||
await async_save_layout_state(
|
await async_save_layout_state(
|
||||||
rt, data, layout, new_rev,
|
rt, data, layout, new_rev,
|
||||||
@@ -627,7 +642,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
return
|
return
|
||||||
data = await rt.store.async_load() or {}
|
data = await rt.store.async_load() or {}
|
||||||
layout = data.get("layout", {})
|
layout = data.get("layout", {})
|
||||||
layout[msg["device_id"]] = msg["pos"]
|
if layout.get(msg["device_id"]) == msg["pos"]:
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
|
||||||
|
return
|
||||||
|
layout = {**layout, msg["device_id"]: msg["pos"]}
|
||||||
# keep the revision: a point-wise write used to drop it, which made the
|
# keep the revision: a point-wise write used to drop it, which made the
|
||||||
# optimistic locking on layout/set meaningless — every drag reset the
|
# optimistic locking on layout/set meaningless — every drag reset the
|
||||||
# counter to 0 (HP-1454-08)
|
# counter to 0 (HP-1454-08)
|
||||||
@@ -735,7 +753,10 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
|||||||
rt, data, new_layout, new_rev,
|
rt, data, new_layout, new_rev,
|
||||||
metadata={
|
metadata={
|
||||||
**_optimizer_backup_after_layout_maintenance(data, new_rev),
|
**_optimizer_backup_after_layout_maintenance(data, new_rev),
|
||||||
"repair_backup": {"space": space_id, "positions": touched},
|
"repair_backup": {
|
||||||
|
"space": space_id,
|
||||||
|
"positions": canonicalize_layout_geometry(touched),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
remove=("repair_backup",),
|
remove=("repair_backup",),
|
||||||
)
|
)
|
||||||
@@ -1281,6 +1302,17 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if msg["config"] == data.get("config"):
|
||||||
|
# A semantic no-op still has to reconcile Repairs with external
|
||||||
|
# file-system changes. It must not create a revision, event, or
|
||||||
|
# discard the optimizer snapshot merely to refresh diagnostics.
|
||||||
|
entry = get_entry(hass)
|
||||||
|
if entry is not None:
|
||||||
|
from .repairs import async_check_plan_files
|
||||||
|
|
||||||
|
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "rev": int(current_rev)})
|
||||||
|
return
|
||||||
new_rev = current_rev + 1
|
new_rev = current_rev + 1
|
||||||
await async_save_config_state(
|
await async_save_config_state(
|
||||||
rt,
|
rt,
|
||||||
@@ -1397,15 +1429,19 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
new_layout_rev = layout_rev + 1
|
new_layout_rev = layout_rev + 1
|
||||||
backup = {
|
backup = {
|
||||||
"kind": "optimize",
|
"kind": "optimize",
|
||||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
"config": canonicalize_config_geometry(
|
||||||
"layout": layout_data.get("layout", {}),
|
config_data.get("config") or DEFAULT_CONFIG
|
||||||
|
),
|
||||||
|
"layout": canonicalize_layout_geometry(
|
||||||
|
layout_data.get("layout", {})
|
||||||
|
),
|
||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"after_config_rev": new_config_rev,
|
"after_config_rev": new_config_rev,
|
||||||
"after_layout_rev": new_layout_rev,
|
"after_layout_rev": new_layout_rev,
|
||||||
}
|
}
|
||||||
pending = {
|
pending = {
|
||||||
"config": msg["config"],
|
"config": canonicalize_config_geometry(msg["config"]),
|
||||||
"layout": msg["layout"],
|
"layout": canonicalize_layout_geometry(msg["layout"]),
|
||||||
"config_rev": new_config_rev,
|
"config_rev": new_config_rev,
|
||||||
"layout_rev": new_layout_rev,
|
"layout_rev": new_layout_rev,
|
||||||
"clear_backup": False,
|
"clear_backup": False,
|
||||||
@@ -1475,8 +1511,12 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
|||||||
|
|
||||||
backup = layout_data[_OPTIMIZE_BACKUP]
|
backup = layout_data[_OPTIMIZE_BACKUP]
|
||||||
restored_kind = str(backup.get("kind") or "optimize")
|
restored_kind = str(backup.get("kind") or "optimize")
|
||||||
restored_config = backup.get("config") or DEFAULT_CONFIG
|
restored_config = canonicalize_config_geometry(
|
||||||
restored_layout = backup.get("layout") or {}
|
backup.get("config") or DEFAULT_CONFIG
|
||||||
|
)
|
||||||
|
restored_layout = canonicalize_layout_geometry(
|
||||||
|
backup.get("layout") or {}
|
||||||
|
)
|
||||||
new_config_rev = config_rev + 1
|
new_config_rev = config_rev + 1
|
||||||
new_layout_rev = layout_rev + 1
|
new_layout_rev = layout_rev + 1
|
||||||
pending = {
|
pending = {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+45
-45
File diff suppressed because one or more lines are too long
@@ -45,6 +45,29 @@ centimetres, screen-fixed strokes/handles, plan-relative icon sizes and the
|
|||||||
grid pitch must not receive that factor again. Full, static and hidden
|
grid pitch must not receive that factor again. Full, static and hidden
|
||||||
isometric renderers share this classification.
|
isometric renderers share this classification.
|
||||||
|
|
||||||
|
### Persisted coordinate canonicalisation
|
||||||
|
|
||||||
|
Every current config/layout write removes IEEE-754 representation tails from
|
||||||
|
persisted geometry by rounding an explicit allow-list to nine decimal places.
|
||||||
|
This is not grid snapping: an off-grid or diagonal coordinate stays where the
|
||||||
|
editor put it, with a maximum normalized change of `5e-10`. The contract is
|
||||||
|
mirrored in Python and TypeScript and normalizes negative zero.
|
||||||
|
|
||||||
|
The allow-list covers room outlines/extents, exact wall endpoints, openings
|
||||||
|
(including angle, length and hosted `t`), decor transforms, drafts,
|
||||||
|
partitions, columns, open spans, backdrop transforms, marker angle and layout
|
||||||
|
`x/y`. It deliberately excludes `cell_cm`, `plan_aspect`, `view_box`,
|
||||||
|
physical centimetre fields, colours/opacities/live values, presentation scales
|
||||||
|
and vacuum affine calibration. Unknown/future numeric fields round-trip
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
Schema validation is the public door; the common config/layout storage helpers
|
||||||
|
repeat the same idempotent operation for internal import, maintenance and
|
||||||
|
startup-recovery writers. A canonical read/write echo is a no-op: optimistic
|
||||||
|
locking is still checked, but the revision, update event and maintenance Undo
|
||||||
|
snapshot do not move. Existing stores are not rewritten on read; Optimize Plans
|
||||||
|
remains the explicit bulk-cleanup path.
|
||||||
|
|
||||||
## Model
|
## Model
|
||||||
|
|
||||||
| Concept | Before | Now |
|
| Concept | Before | Now |
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Every config and device-layout write now canonicalizes persisted geometry to
|
||||||
|
nine decimal places without snapping it to the grid. This removes invisible
|
||||||
|
floating-point tails before they can break shared walls, room unions or Glow;
|
||||||
|
import, maintenance recovery and server Undo use the same invariant. Repeating
|
||||||
|
an unchanged canonical Save no longer creates a revision or consumes the
|
||||||
|
one-deep Undo snapshot. Existing noisy plans are cleaned on their next write,
|
||||||
|
or immediately through Optimize Plans
|
||||||
|
([#224](https://github.com/Matysh/houseplan-card/issues/224)).
|
||||||
|
|
||||||
- Grid precision no longer changes the appearance of the same physical plan.
|
- Grid precision no longer changes the appearance of the same physical plan.
|
||||||
Room and wall outlines, openings and their hit areas, Plan hints, the static
|
Room and wall outlines, openings and their hit areas, Plan hints, the static
|
||||||
card and hidden isometric geometry now retain the `cell_cm: 5` visual size at
|
card and hidden isometric geometry now retain the `cell_cm: 5` visual size at
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
|
|
||||||
## Не выпущено
|
## Не выпущено
|
||||||
|
|
||||||
|
- Каждая запись конфигурации и раскладки устройств теперь приводит геометрию к
|
||||||
|
девяти десятичным знакам, не привязывая её к сетке. Невидимые
|
||||||
|
floating-point-хвосты удаляются до того, как смогут сломать общие стены,
|
||||||
|
объединение комнат или Glow; тот же инвариант действует для импорта,
|
||||||
|
восстановления служебных операций и серверной отмены. Повторное сохранение
|
||||||
|
уже канонических данных больше не создаёт ревизию и не съедает одношаговую
|
||||||
|
отмену. Старый план очищается при следующей записи либо сразу через
|
||||||
|
«Оптимизировать планы»
|
||||||
|
([#224](https://github.com/Matysh/houseplan-card/issues/224)).
|
||||||
|
|
||||||
- Точность сетки больше не меняет внешний вид одного и того же физического
|
- Точность сетки больше не меняет внешний вид одного и того же физического
|
||||||
плана. Контуры комнат и стен, проёмы и их зоны попадания, подсказки редактора,
|
плана. Контуры комнат и стен, проёмы и их зоны попадания, подсказки редактора,
|
||||||
статическая карточка и скрытая изометрия сохраняют эталонный вид
|
статическая карточка и скрытая изометрия сохраняют эталонный вид
|
||||||
|
|||||||
@@ -48,6 +48,27 @@ Unknown future fields remain outside this report and continue to follow the
|
|||||||
backend's forward-compatibility policy. Absence from the report is therefore
|
backend's forward-compatibility policy. Absence from the report is therefore
|
||||||
not permission to delete a field.
|
not permission to delete a field.
|
||||||
|
|
||||||
|
## Canonical geometry on write (#224)
|
||||||
|
|
||||||
|
Config and layout schemas canonicalize only named geometry numbers to nine
|
||||||
|
decimal places. The common storage helpers repeat the same idempotent contract
|
||||||
|
for internal writers, while the frontend adopts the exact candidate it sends.
|
||||||
|
This prevents ULP noise without changing the schema, JSON number type,
|
||||||
|
model/store version or any user-visible placement.
|
||||||
|
|
||||||
|
The operation is lossless at the product scale and intentionally narrow.
|
||||||
|
`view_box`, `cell_cm`, `plan_aspect`, physical centimetre values,
|
||||||
|
presentation settings, colours, opacity/brightness/temperature, vacuum
|
||||||
|
calibration and unknown/future numeric fields retain their exact input values.
|
||||||
|
No recursive “round every number” migration is allowed.
|
||||||
|
|
||||||
|
Existing stores remain byte-for-byte untouched on read. Their geometry becomes
|
||||||
|
canonical on the next config/layout write; Optimize Plans is the immediate bulk
|
||||||
|
path. Optimize/Import/repair Undo restores the previous semantic geometry and
|
||||||
|
unknown fields in canonical representation, not the invisible noisy IEEE-754
|
||||||
|
tail. A repeated canonical Save with the current revision is a no-op and does
|
||||||
|
not invalidate the one-deep maintenance backup.
|
||||||
|
|
||||||
## Open-passage opening type (#157)
|
## Open-passage opening type (#157)
|
||||||
|
|
||||||
`space.openings[].type` additionally accepts the literal `passage`. Its
|
`space.openings[].type` additionally accepts the literal `passage`. Its
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
|||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.66.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion of the published v1.66.0-beta.1 product line |
|
| Version | **v1.66.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion of the published v1.66.0-beta.1 product line |
|
||||||
| Current local cycle | Post-v1.66.0 Unreleased development includes reliable Plan drawing (#228): exact Shift rays, ambiguous-node blocking, active axis/node chrome, room creation from an existing face with an explicit ≤2 cm repair, and room deletion with Keep/Delete walls consequences. Entity/parent marker deduplication (#226) and exact coordinate maintenance (#223) are also included. |
|
| Current local cycle | Post-v1.66.0 Unreleased development includes reliable Plan drawing (#228): exact Shift rays, ambiguous-node blocking, active axis/node chrome, room creation from an existing face with an explicit ≤2 cm repair, and room deletion with Keep/Delete walls consequences. Entity/parent marker deduplication (#226), explicit coordinate maintenance (#223) and canonical geometry on every write (#224) are also included. |
|
||||||
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
|
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
|
||||||
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||||
|
|||||||
+14
-1
@@ -2090,11 +2090,24 @@ require hands on real hardware — they remain for the human pass.
|
|||||||
`moved: 0` and a positive cleaned-coordinate count. Preview distinguishes
|
`moved: 0` and a positive cleaned-coordinate count. Preview distinguishes
|
||||||
updated spaces from removed coordinate noise; Cancel writes nothing;
|
updated spaces from removed coordinate noise; Cancel writes nothing;
|
||||||
Apply stores exact grid nodes in one transaction; the next run is a no-op
|
Apply stores exact grid nodes in one transaction; the next run is a no-op
|
||||||
and server Undo restores the original noisy bits. A rejected hosted
|
and server Undo restores the original geometry in canonical
|
||||||
|
representation. A rejected hosted
|
||||||
partition contributes nothing to the counter
|
partition contributes nothing to the counter
|
||||||
[unit: align-grid + plan-optimizer + i18n; auto:
|
[unit: align-grid + plan-optimizer + i18n; auto:
|
||||||
smoke_optimize_coordinate_canonicalization; mutation:
|
smoke_optimize_coordinate_canonicalization; mutation:
|
||||||
`snapn-returns-input-near-node`].
|
`snapn-returns-input-near-node`].
|
||||||
|
- [ ] **Every write prevents new ULP coordinate noise (#224)**: config/layout
|
||||||
|
schema, import, direct storage writers, startup recovery and maintenance
|
||||||
|
Undo produce the same nine-decimal allow-listed geometry as the frontend.
|
||||||
|
A first noisy write creates one canonical revision; a repeated canonical
|
||||||
|
write creates no store write/event/revision and preserves the maintenance
|
||||||
|
backup. `view_box`, physical/presentation values, colours and vacuum
|
||||||
|
calibration remain exact; the six-room #218 union and Glow clip stay
|
||||||
|
non-empty [unit: coordinate-canonicalization + physical-geometry;
|
||||||
|
backend: test_coordinate_canonicalization + test_ha_websocket +
|
||||||
|
test_ha_import_export; mutations: `schema-quantization-removed`,
|
||||||
|
`frontend-writes-raw-coords`, `quantization-hits-allowlist`,
|
||||||
|
`import-path-bypasses-schema`; pre-release: golden verify].
|
||||||
- [ ] Optimizer migration safety: legacy decor width/text size is clamped to
|
- [ ] Optimizer migration safety: legacy decor width/text size is clamped to
|
||||||
the backend schema, `fill: true` receives explicit fill style, invalid
|
the backend schema, `fill: true` receives explicit fill style, invalid
|
||||||
legacy `plan_scale` is preserved for repair, an already canonical plan is
|
legacy `plan_scale` is preserved for repair, an already canonical plan is
|
||||||
|
|||||||
+12
-1
@@ -1332,6 +1332,17 @@ show_signal: true
|
|||||||
|
|
||||||
## 19. Обслуживание планов
|
## 19. Обслуживание планов
|
||||||
|
|
||||||
|
Обычное сохранение конфигурации или позиции устройства автоматически убирает
|
||||||
|
из координат невидимый вычислительный шум. Это **не привязка к сетке**:
|
||||||
|
диагональные стены, свободный декор и другие допустимые положения остаются на
|
||||||
|
своих местах. Импорт и серверная отмена используют то же правило. Повторное
|
||||||
|
сохранение уже неизменного плана не создаёт новую ревизию и не отменяет
|
||||||
|
доступность последней служебной отмены.
|
||||||
|
|
||||||
|
Существующий план не переписывается при простом открытии. Его геометрия
|
||||||
|
очистится при следующем изменении; если очистить всё нужно сразу, используйте
|
||||||
|
явную оптимизацию ниже.
|
||||||
|
|
||||||
Кнопка **Общие настройки → Оптимизировать планы** запускает явное обслуживание. Сначала показывается точный предпросмотр; данные не меняются до подтверждения.
|
Кнопка **Общие настройки → Оптимизировать планы** запускает явное обслуживание. Сначала показывается точный предпросмотр; данные не меняются до подтверждения.
|
||||||
|
|
||||||
### Что делает оптимизация
|
### Что делает оптимизация
|
||||||
@@ -1365,7 +1376,7 @@ show_signal: true
|
|||||||
|
|
||||||
### Риск и отмена
|
### Риск и отмена
|
||||||
|
|
||||||
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Новый edit делает резервную копию оптимизации устаревшей.
|
Старые и импортированные объекты между узлами будут привязаны к сетке. Новые редакторы такие координаты не создают. После оптимизации доступна одна серверная отмена, но только до следующего изменения плана. Отмена возвращает прежнюю геометрию и неизвестные поля в чистом числовом представлении, не восстанавливая невидимый floating-point шум. Новый edit делает резервную копию оптимизации устаревшей.
|
||||||
|
|
||||||
Обычное открытие и редактирование плана не удаляет даже очень короткие точные
|
Обычное открытие и редактирование плана не удаляет даже очень короткие точные
|
||||||
границы толщины. Описанное схлопывание выполняется только после явного
|
границы толщины. Описанное схлопывание выполняется только после явного
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ if (!pattern) {
|
|||||||
console.error('usage: node scripts/backend-test-guard.mjs <pytest-k-pattern>');
|
console.error('usage: node scripts/backend-test-guard.mjs <pytest-k-pattern>');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
const testFile = process.argv[3] || 'tests_backend/test_ha_import_export.py';
|
||||||
|
|
||||||
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
|
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
|
||||||
const result = spawnSync(python, [
|
const result = spawnSync(python, [
|
||||||
'-m', 'pytest', 'tests_backend/test_ha_import_export.py', '-q', '-k', pattern,
|
'-m', 'pytest', testFile, '-q', '-k', pattern,
|
||||||
], { stdio: 'inherit' });
|
], { stdio: 'inherit' });
|
||||||
process.exit(result.status ?? 2);
|
process.exit(result.status ?? 2);
|
||||||
|
|||||||
@@ -178,6 +178,57 @@ export const MUTANTS = [
|
|||||||
replace: ' const qx = x;\n const qy = y;',
|
replace: ' const qx = x;\n const qy = y;',
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'schema-quantization-removed',
|
||||||
|
guard: 'node scripts/backend-test-guard.mjs backend_schemas_apply '
|
||||||
|
+ 'tests_backend/test_coordinate_canonicalization.py',
|
||||||
|
because: 'backend schemas are the public write door for cards, imports and manual clients; '
|
||||||
|
+ 'removing their canonicalisation would let the same ULP noise enter every future writer',
|
||||||
|
patches: [{
|
||||||
|
file: 'custom_components/houseplan/validation.py',
|
||||||
|
find: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n'
|
||||||
|
+ ' ),\n canonicalize_config_geometry,\n)',
|
||||||
|
replace: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n'
|
||||||
|
+ ' ),\n lambda value: value,\n)',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'frontend-writes-raw-coords',
|
||||||
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
+ '&& node --test --test-name-pattern="frontend write paths" '
|
||||||
|
+ 'test/coordinate-canonicalization.test.mjs',
|
||||||
|
because: 'a server-only fix leaves the open card rendering its noisy mutable config until '
|
||||||
|
+ 'reload, so the current session can still reproduce the geometry failure after Save',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/houseplan-card.ts',
|
||||||
|
find: ' const candidate = canonicalizeConfigGeometry(this._serverCfg);',
|
||||||
|
replace: ' const candidate = this._serverCfg;',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quantization-hits-allowlist',
|
||||||
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
+ '&& node --test --test-name-pattern="nine-decimal fixture contract" '
|
||||||
|
+ 'test/coordinate-canonicalization.test.mjs',
|
||||||
|
because: 'presentation and calibration values are deliberately outside geometry; widening '
|
||||||
|
+ 'the allow-list silently changes user data that has no ULP topology problem',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/coordinate-canonicalization.ts',
|
||||||
|
find: " for (const marker of records(root.markers)) fields(marker, ['angle']);",
|
||||||
|
replace: " for (const marker of records(root.markers)) fields(marker, ['angle', 'size']);",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'import-path-bypasses-schema',
|
||||||
|
guard: 'node scripts/backend-test-guard.mjs import_document_canonicalizes_external_coordinates',
|
||||||
|
because: 'an externally assembled backup is an independent source of noisy geometry and '
|
||||||
|
+ 'must enter preview and storage through the same canonical schema as the live card',
|
||||||
|
patches: [{
|
||||||
|
file: 'custom_components/houseplan/import_export.py',
|
||||||
|
find: ' config = CONFIG_SCHEMA(_json_copy(payload.get("config")))',
|
||||||
|
replace: ' config = _json_copy(payload.get("config"))',
|
||||||
|
}],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'union-failure-kills-space',
|
id: 'union-failure-kills-space',
|
||||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Stable, allow-listed persisted geometry.
|
||||||
|
*
|
||||||
|
* Mirrored by custom_components/houseplan/coordinate_canonicalization.py.
|
||||||
|
* Keep the precision, scalar formula and field allow-list in lockstep.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const COORDINATE_DECIMALS = 9;
|
||||||
|
export const COORDINATE_FACTOR = 10 ** COORDINATE_DECIMALS;
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, any>;
|
||||||
|
|
||||||
|
function cloneJson<T>(value: T): T {
|
||||||
|
if (Array.isArray(value)) return value.map((item) => cloneJson(item)) as T;
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
const out: JsonRecord = {};
|
||||||
|
for (const [key, item] of Object.entries(value as JsonRecord)) out[key] = cloneJson(item);
|
||||||
|
return out as T;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeNumber(value: unknown): unknown {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return value;
|
||||||
|
const sign = value < 0 || Object.is(value, -0) ? -1 : 1;
|
||||||
|
const result = sign
|
||||||
|
* (Math.floor(Math.abs(value) * COORDINATE_FACTOR + 0.5) / COORDINATE_FACTOR);
|
||||||
|
return result === 0 ? 0 : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): JsonRecord | null {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as JsonRecord : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function records(value: unknown): JsonRecord[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((item): item is JsonRecord => record(item) !== null)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fields(item: JsonRecord, names: readonly string[]): void {
|
||||||
|
for (const name of names) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(item, name)) {
|
||||||
|
item[name] = canonicalizeNumber(item[name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function point(value: unknown): void {
|
||||||
|
if (!Array.isArray(value)) return;
|
||||||
|
for (let index = 0; index < Math.min(2, value.length); index++) {
|
||||||
|
value[index] = canonicalizeNumber(value[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function points(value: unknown): void {
|
||||||
|
if (!Array.isArray(value)) return;
|
||||||
|
for (const item of value) point(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizePosition<T>(position: T): T {
|
||||||
|
const result = cloneJson(position);
|
||||||
|
const item = record(result);
|
||||||
|
if (item) fields(item, ['x', 'y']);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeLayoutGeometry<T>(layout: T): T {
|
||||||
|
const result = cloneJson(layout);
|
||||||
|
const root = record(result);
|
||||||
|
if (!root) return result;
|
||||||
|
for (const value of Object.values(root)) {
|
||||||
|
const item = record(value);
|
||||||
|
if (item) fields(item, ['x', 'y']);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeConfigGeometry<T>(config: T): T {
|
||||||
|
const result = cloneJson(config);
|
||||||
|
const root = record(result);
|
||||||
|
if (!root) return result;
|
||||||
|
|
||||||
|
for (const space of records(root.spaces)) {
|
||||||
|
fields(space, [
|
||||||
|
'plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle',
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const room of records(space.rooms)) {
|
||||||
|
fields(room, ['x', 'y', 'w', 'h']);
|
||||||
|
points(room.poly);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const wall of records(space.walls)) {
|
||||||
|
point(wall.a);
|
||||||
|
point(wall.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const opening of records(space.openings)) {
|
||||||
|
fields(opening, ['x', 'y', 'angle', 'length']);
|
||||||
|
const host = record(opening.host);
|
||||||
|
if (host) fields(host, ['t']);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const decor of records(space.decor)) {
|
||||||
|
if (decor.kind === 'line') fields(decor, ['x1', 'y1', 'x2', 'y2']);
|
||||||
|
else if (decor.kind === 'rect' || decor.kind === 'ellipse' || decor.kind === 'furniture') {
|
||||||
|
fields(decor, ['x', 'y', 'w', 'h', 'angle']);
|
||||||
|
} else if (decor.kind === 'text') {
|
||||||
|
fields(decor, ['x', 'y', 'scale', 'angle']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const draft of records(space.room_drafts)) points(draft.points);
|
||||||
|
|
||||||
|
for (const partition of records(space.partitions)) {
|
||||||
|
point(partition.a);
|
||||||
|
point(partition.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const column of records(space.wall_columns)) {
|
||||||
|
point(column.center);
|
||||||
|
if (column.shape === 'square') fields(column, ['angle']);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const span of records(space.open_spans)) {
|
||||||
|
point(span.a);
|
||||||
|
point(span.b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const marker of records(root.markers)) fields(marker, ['angle']);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+14
-2
@@ -187,6 +187,11 @@ import {
|
|||||||
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
|
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
|
||||||
} from './space-geometry';
|
} from './space-geometry';
|
||||||
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
|
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
|
||||||
|
import {
|
||||||
|
canonicalizeConfigGeometry,
|
||||||
|
canonicalizeLayoutGeometry,
|
||||||
|
canonicalizePosition,
|
||||||
|
} from './coordinate-canonicalization';
|
||||||
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
|
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
|
||||||
import { CommandStack } from './command-stack';
|
import { CommandStack } from './command-stack';
|
||||||
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
|
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
|
||||||
@@ -4416,8 +4421,9 @@ class HouseplanCard extends LitElement {
|
|||||||
const ids = [...this._dirtyPos];
|
const ids = [...this._dirtyPos];
|
||||||
this._dirtyPos.clear();
|
this._dirtyPos.clear();
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const pos = this._layout[id];
|
const pos = canonicalizePosition(this._layout[id]);
|
||||||
if (!pos) continue;
|
if (!pos) continue;
|
||||||
|
this._layout = { ...this._layout, [id]: pos };
|
||||||
// in flight until the server answers: a layout reload triggered in the
|
// in flight until the server answers: a layout reload triggered in the
|
||||||
// meantime must keep this position, not the one the server still has
|
// meantime must keep this position, not the one the server still has
|
||||||
this._sentPos.set(id, pos);
|
this._sentPos.set(id, pos);
|
||||||
@@ -4429,6 +4435,7 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
this._cacheSnapshot();
|
this._cacheSnapshot();
|
||||||
} else {
|
} else {
|
||||||
|
this._layout = canonicalizeLayoutGeometry(this._layout);
|
||||||
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
|
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
|
||||||
}
|
}
|
||||||
}, 600);
|
}, 600);
|
||||||
@@ -6945,8 +6952,13 @@ class HouseplanCard extends LitElement {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
if (!this._serverCfg) return;
|
if (!this._serverCfg) return;
|
||||||
this._dropLegacySegments();
|
this._dropLegacySegments();
|
||||||
|
const candidate = canonicalizeConfigGeometry(this._serverCfg);
|
||||||
|
const candidateFingerprint = contentFingerprint(candidate);
|
||||||
|
if (candidateFingerprint !== contentFingerprint(this._serverCfg)) this._cfgEpoch++;
|
||||||
|
this._serverCfg = candidate;
|
||||||
|
this._cfgContentFingerprint = candidateFingerprint;
|
||||||
const r = await this.hass.callWS({
|
const r = await this.hass.callWS({
|
||||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
type: 'houseplan/config/set', config: candidate, expected_rev: this._cfgRev,
|
||||||
});
|
});
|
||||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
COORDINATE_DECIMALS,
|
||||||
|
canonicalizeConfigGeometry,
|
||||||
|
canonicalizeLayoutGeometry,
|
||||||
|
canonicalizeNumber,
|
||||||
|
canonicalizePosition,
|
||||||
|
} from '../test-build/coordinate-canonicalization.js';
|
||||||
|
|
||||||
|
const fixture = JSON.parse(readFileSync(
|
||||||
|
new URL('./fixtures/coordinate-canonicalization.json', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
));
|
||||||
|
|
||||||
|
test('frontend and backend share the nine-decimal fixture contract (#224)', () => {
|
||||||
|
assert.equal(COORDINATE_DECIMALS, fixture.decimals);
|
||||||
|
const configBefore = structuredClone(fixture.configInput);
|
||||||
|
const layoutBefore = structuredClone(fixture.layoutInput);
|
||||||
|
|
||||||
|
const config = canonicalizeConfigGeometry(fixture.configInput);
|
||||||
|
const layout = canonicalizeLayoutGeometry(fixture.layoutInput);
|
||||||
|
|
||||||
|
assert.deepEqual(config, fixture.configExpected);
|
||||||
|
assert.deepEqual(layout, fixture.layoutExpected);
|
||||||
|
assert.deepEqual(fixture.configInput, configBefore, 'config input is immutable');
|
||||||
|
assert.deepEqual(fixture.layoutInput, layoutBefore, 'layout input is immutable');
|
||||||
|
assert.deepEqual(canonicalizeConfigGeometry(config), config, 'config is idempotent');
|
||||||
|
assert.deepEqual(canonicalizeLayoutGeometry(layout), layout, 'layout is idempotent');
|
||||||
|
assert.equal(Object.is(layout['rl:poly'].x, -0), false, 'negative zero becomes positive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scalar canonicalization is symmetric and never snaps off-grid geometry (#224)', () => {
|
||||||
|
assert.equal(canonicalizeNumber(1.2345678905), 1.234567891);
|
||||||
|
assert.equal(canonicalizeNumber(-1.2345678905), -1.234567891);
|
||||||
|
assert.equal(canonicalizeNumber(0.20833333333333334), 0.208333333);
|
||||||
|
assert.ok(Math.abs(canonicalizeNumber(0.20833333333333334) - 0.20833333333333334) <= 5e-10);
|
||||||
|
assert.equal(canonicalizeNumber(Number.NaN), Number.NaN);
|
||||||
|
assert.equal(canonicalizeNumber(Number.POSITIVE_INFINITY), Number.POSITIVE_INFINITY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one position changes only x/y and preserves future metadata (#224)', () => {
|
||||||
|
const input = {
|
||||||
|
s: 'floor', x: 0.1000000006, y: -0.0000000004,
|
||||||
|
k: 1.0000000004, nested: { numeric: 0.1234567896 },
|
||||||
|
};
|
||||||
|
assert.deepEqual(canonicalizePosition(input), {
|
||||||
|
s: 'floor', x: 0.100000001, y: 0,
|
||||||
|
k: 1.0000000004, nested: { numeric: 0.1234567896 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontend write paths adopt canonical candidates before persistence (#224)', () => {
|
||||||
|
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);[\s\S]*config: candidate/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/const pos = canonicalizePosition\(this\._layout\[id\]\);[\s\S]*device_id: id, pos/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/this\._layout = canonicalizeLayoutGeometry\(this\._layout\);[\s\S]*localStorage\.setItem/,
|
||||||
|
);
|
||||||
|
});
|
||||||
+435
@@ -0,0 +1,435 @@
|
|||||||
|
{
|
||||||
|
"decimals": 9,
|
||||||
|
"configInput": {
|
||||||
|
"spaces": [
|
||||||
|
{
|
||||||
|
"id": "floor",
|
||||||
|
"title": "Floor",
|
||||||
|
"cell_cm": 5.0000000004,
|
||||||
|
"plan_aspect": 1.5000000004,
|
||||||
|
"plan_x": 0.1234567894,
|
||||||
|
"plan_y": -0.1234567896,
|
||||||
|
"plan_scale": 1.0000000001,
|
||||||
|
"plan_scale_x": 1.2345678906,
|
||||||
|
"plan_scale_y": 0.9876543216,
|
||||||
|
"plan_angle": 44.1234567896,
|
||||||
|
"view_box": [0.0000000004, -0.0000000004, 1.0000000004, 1.0000000004],
|
||||||
|
"settings": {
|
||||||
|
"room_opacity": 0.5000000004,
|
||||||
|
"temp_min": 18.1234567896,
|
||||||
|
"temp_max": 28.1234567896,
|
||||||
|
"card_font_scale": 1.0000000004
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "poly",
|
||||||
|
"name": "Poly",
|
||||||
|
"area": null,
|
||||||
|
"poly": [
|
||||||
|
[0.0000000004, -0.0000000004],
|
||||||
|
[0.3000000004, 0.0000000004],
|
||||||
|
[0.0000000004, 0.3000000004]
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"name_scale": 1.0000000004,
|
||||||
|
"label_scale": 1.2000000004
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rect",
|
||||||
|
"name": "Rect",
|
||||||
|
"area": null,
|
||||||
|
"x": 0.4000000004,
|
||||||
|
"y": 0.4000000006,
|
||||||
|
"w": 0.2000000004,
|
||||||
|
"h": 0.2000000006
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{
|
||||||
|
"key": "wall",
|
||||||
|
"cm": 15.0000000004,
|
||||||
|
"a": [0.0000000004, 0.3000000004],
|
||||||
|
"b": [0.3000000006, 0.3000000006]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"openings": [
|
||||||
|
{
|
||||||
|
"id": "opening",
|
||||||
|
"type": "door",
|
||||||
|
"x": 0.5000000004,
|
||||||
|
"y": 0.0000000004,
|
||||||
|
"angle": -0.0000000004,
|
||||||
|
"length": 0.2000000006,
|
||||||
|
"host": {
|
||||||
|
"kind": "partition",
|
||||||
|
"id": "partition",
|
||||||
|
"t": 0.5000000004
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decor": [
|
||||||
|
{
|
||||||
|
"id": "line",
|
||||||
|
"kind": "line",
|
||||||
|
"x1": 0.1000000004,
|
||||||
|
"y1": 0.1000000006,
|
||||||
|
"x2": 0.2000000004,
|
||||||
|
"y2": 0.2000000006,
|
||||||
|
"width_cm": 2.0000000004,
|
||||||
|
"opacity": 0.7000000004
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rect-decor",
|
||||||
|
"kind": "rect",
|
||||||
|
"x": 0.1000000004,
|
||||||
|
"y": 0.2000000006,
|
||||||
|
"w": 0.3000000004,
|
||||||
|
"h": 0.4000000006,
|
||||||
|
"angle": 12.1234567896,
|
||||||
|
"fill_opacity": 0.6000000004
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ellipse",
|
||||||
|
"kind": "ellipse",
|
||||||
|
"x": -0.1000000004,
|
||||||
|
"y": -0.2000000006,
|
||||||
|
"w": 0.3000000006,
|
||||||
|
"h": 0.4000000004,
|
||||||
|
"angle": -12.1234567896
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "text",
|
||||||
|
"kind": "text",
|
||||||
|
"x": 0.7000000004,
|
||||||
|
"y": 0.8000000006,
|
||||||
|
"text": "Text",
|
||||||
|
"scale": 1.2345678906,
|
||||||
|
"angle": 2.1234567896,
|
||||||
|
"size_cm": 25.0000000004
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chair",
|
||||||
|
"kind": "furniture",
|
||||||
|
"symbol": "chair",
|
||||||
|
"x": 0.6000000004,
|
||||||
|
"y": 0.6000000006,
|
||||||
|
"w": 0.1000000004,
|
||||||
|
"h": 0.1000000006,
|
||||||
|
"angle": 90.1234567896
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"room_drafts": [
|
||||||
|
{
|
||||||
|
"id": "draft",
|
||||||
|
"points": [
|
||||||
|
[0.1000000004, 0.9000000006],
|
||||||
|
[0.2000000006, 0.9000000004],
|
||||||
|
[0.3000000004, 0.9000000006]
|
||||||
|
],
|
||||||
|
"segments": [
|
||||||
|
{"cm": 10.0000000004},
|
||||||
|
{"cm": 20.0000000004}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"partitions": [
|
||||||
|
{
|
||||||
|
"id": "partition",
|
||||||
|
"a": [0.0000000004, 0.0000000004],
|
||||||
|
"b": [1.0000000004, 0.0000000004],
|
||||||
|
"cm": 12.0000000004
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wall_columns": [
|
||||||
|
{
|
||||||
|
"id": "column",
|
||||||
|
"shape": "square",
|
||||||
|
"center": [0.7500000004, 0.7500000006],
|
||||||
|
"cm": 30.0000000004,
|
||||||
|
"angle": 45.1234567896
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"open_spans": [
|
||||||
|
{
|
||||||
|
"a": [0.3000000004, 0.3000000006],
|
||||||
|
"b": [0.6000000006, 0.3000000004]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"future": {
|
||||||
|
"numeric": 0.1234567896
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markers": [
|
||||||
|
{
|
||||||
|
"id": "virtual",
|
||||||
|
"binding": "virtual",
|
||||||
|
"angle": -30.1234567896,
|
||||||
|
"size": 1.1000000004,
|
||||||
|
"ripple_size": 1.5000000004,
|
||||||
|
"glow_radius_cm": 120.0000000004,
|
||||||
|
"glow_color": {
|
||||||
|
"c": "#112233",
|
||||||
|
"bri": 0.5000000004
|
||||||
|
},
|
||||||
|
"vacuum": {
|
||||||
|
"calibration": {
|
||||||
|
"map": [
|
||||||
|
-0.0017921234567,
|
||||||
|
0.0000000004,
|
||||||
|
1.0000000004,
|
||||||
|
0.0017921234567,
|
||||||
|
-0.0000000004,
|
||||||
|
2.0000000004
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"glow_radius_cm": 150.0000000004,
|
||||||
|
"fill_colors": {
|
||||||
|
"on": {
|
||||||
|
"c": "#abcdef",
|
||||||
|
"a": 0.5000000004
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"future_numeric": 0.9876543216
|
||||||
|
},
|
||||||
|
"future_root": {
|
||||||
|
"numeric": 0.1234567896
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configExpected": {
|
||||||
|
"spaces": [
|
||||||
|
{
|
||||||
|
"id": "floor",
|
||||||
|
"title": "Floor",
|
||||||
|
"cell_cm": 5.0000000004,
|
||||||
|
"plan_aspect": 1.5000000004,
|
||||||
|
"plan_x": 0.123456789,
|
||||||
|
"plan_y": -0.12345679,
|
||||||
|
"plan_scale": 1,
|
||||||
|
"plan_scale_x": 1.234567891,
|
||||||
|
"plan_scale_y": 0.987654322,
|
||||||
|
"plan_angle": 44.12345679,
|
||||||
|
"view_box": [0.0000000004, -0.0000000004, 1.0000000004, 1.0000000004],
|
||||||
|
"settings": {
|
||||||
|
"room_opacity": 0.5000000004,
|
||||||
|
"temp_min": 18.1234567896,
|
||||||
|
"temp_max": 28.1234567896,
|
||||||
|
"card_font_scale": 1.0000000004
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "poly",
|
||||||
|
"name": "Poly",
|
||||||
|
"area": null,
|
||||||
|
"poly": [
|
||||||
|
[0, 0],
|
||||||
|
[0.3, 0],
|
||||||
|
[0, 0.3]
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"name_scale": 1.0000000004,
|
||||||
|
"label_scale": 1.2000000004
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rect",
|
||||||
|
"name": "Rect",
|
||||||
|
"area": null,
|
||||||
|
"x": 0.4,
|
||||||
|
"y": 0.400000001,
|
||||||
|
"w": 0.2,
|
||||||
|
"h": 0.200000001
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{
|
||||||
|
"key": "wall",
|
||||||
|
"cm": 15.0000000004,
|
||||||
|
"a": [0, 0.3],
|
||||||
|
"b": [0.300000001, 0.300000001]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"openings": [
|
||||||
|
{
|
||||||
|
"id": "opening",
|
||||||
|
"type": "door",
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0,
|
||||||
|
"angle": 0,
|
||||||
|
"length": 0.200000001,
|
||||||
|
"host": {
|
||||||
|
"kind": "partition",
|
||||||
|
"id": "partition",
|
||||||
|
"t": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decor": [
|
||||||
|
{
|
||||||
|
"id": "line",
|
||||||
|
"kind": "line",
|
||||||
|
"x1": 0.1,
|
||||||
|
"y1": 0.100000001,
|
||||||
|
"x2": 0.2,
|
||||||
|
"y2": 0.200000001,
|
||||||
|
"width_cm": 2.0000000004,
|
||||||
|
"opacity": 0.7000000004
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rect-decor",
|
||||||
|
"kind": "rect",
|
||||||
|
"x": 0.1,
|
||||||
|
"y": 0.200000001,
|
||||||
|
"w": 0.3,
|
||||||
|
"h": 0.400000001,
|
||||||
|
"angle": 12.12345679,
|
||||||
|
"fill_opacity": 0.6000000004
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ellipse",
|
||||||
|
"kind": "ellipse",
|
||||||
|
"x": -0.1,
|
||||||
|
"y": -0.200000001,
|
||||||
|
"w": 0.300000001,
|
||||||
|
"h": 0.4,
|
||||||
|
"angle": -12.12345679
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "text",
|
||||||
|
"kind": "text",
|
||||||
|
"x": 0.7,
|
||||||
|
"y": 0.800000001,
|
||||||
|
"text": "Text",
|
||||||
|
"scale": 1.234567891,
|
||||||
|
"angle": 2.12345679,
|
||||||
|
"size_cm": 25.0000000004
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chair",
|
||||||
|
"kind": "furniture",
|
||||||
|
"symbol": "chair",
|
||||||
|
"x": 0.6,
|
||||||
|
"y": 0.600000001,
|
||||||
|
"w": 0.1,
|
||||||
|
"h": 0.100000001,
|
||||||
|
"angle": 90.12345679
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"room_drafts": [
|
||||||
|
{
|
||||||
|
"id": "draft",
|
||||||
|
"points": [
|
||||||
|
[0.1, 0.900000001],
|
||||||
|
[0.200000001, 0.9],
|
||||||
|
[0.3, 0.900000001]
|
||||||
|
],
|
||||||
|
"segments": [
|
||||||
|
{"cm": 10.0000000004},
|
||||||
|
{"cm": 20.0000000004}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"partitions": [
|
||||||
|
{
|
||||||
|
"id": "partition",
|
||||||
|
"a": [0, 0],
|
||||||
|
"b": [1, 0],
|
||||||
|
"cm": 12.0000000004
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wall_columns": [
|
||||||
|
{
|
||||||
|
"id": "column",
|
||||||
|
"shape": "square",
|
||||||
|
"center": [0.75, 0.750000001],
|
||||||
|
"cm": 30.0000000004,
|
||||||
|
"angle": 45.12345679
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"open_spans": [
|
||||||
|
{
|
||||||
|
"a": [0.3, 0.300000001],
|
||||||
|
"b": [0.600000001, 0.3]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"future": {
|
||||||
|
"numeric": 0.1234567896
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markers": [
|
||||||
|
{
|
||||||
|
"id": "virtual",
|
||||||
|
"binding": "virtual",
|
||||||
|
"angle": -30.12345679,
|
||||||
|
"size": 1.1000000004,
|
||||||
|
"ripple_size": 1.5000000004,
|
||||||
|
"glow_radius_cm": 120.0000000004,
|
||||||
|
"glow_color": {
|
||||||
|
"c": "#112233",
|
||||||
|
"bri": 0.5000000004
|
||||||
|
},
|
||||||
|
"vacuum": {
|
||||||
|
"calibration": {
|
||||||
|
"map": [
|
||||||
|
-0.0017921234567,
|
||||||
|
0.0000000004,
|
||||||
|
1.0000000004,
|
||||||
|
0.0017921234567,
|
||||||
|
-0.0000000004,
|
||||||
|
2.0000000004
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"glow_radius_cm": 150.0000000004,
|
||||||
|
"fill_colors": {
|
||||||
|
"on": {
|
||||||
|
"c": "#abcdef",
|
||||||
|
"a": 0.5000000004
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"future_numeric": 0.9876543216
|
||||||
|
},
|
||||||
|
"future_root": {
|
||||||
|
"numeric": 0.1234567896
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layoutInput": {
|
||||||
|
"virtual": {
|
||||||
|
"s": "floor",
|
||||||
|
"x": 0.1234567894,
|
||||||
|
"y": -0.1234567896,
|
||||||
|
"k": 1.2000000004,
|
||||||
|
"future_numeric": 0.9876543216
|
||||||
|
},
|
||||||
|
"rl:poly": {
|
||||||
|
"s": "floor",
|
||||||
|
"x": -0.0000000004,
|
||||||
|
"y": 0.7500000006,
|
||||||
|
"k": 1.5000000004
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layoutExpected": {
|
||||||
|
"virtual": {
|
||||||
|
"s": "floor",
|
||||||
|
"x": 0.123456789,
|
||||||
|
"y": -0.12345679,
|
||||||
|
"k": 1.2000000004,
|
||||||
|
"future_numeric": 0.9876543216
|
||||||
|
},
|
||||||
|
"rl:poly": {
|
||||||
|
"s": "floor",
|
||||||
|
"x": 0,
|
||||||
|
"y": 0.750000001,
|
||||||
|
"k": 1.5000000004
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
polygonSegments, splitAtIntersections, visibilityPolygon,
|
polygonSegments, splitAtIntersections, visibilityPolygon,
|
||||||
} from '../test-build/light-visibility.js';
|
} from '../test-build/light-visibility.js';
|
||||||
|
import { canonicalizeConfigGeometry } from '../test-build/coordinate-canonicalization.js';
|
||||||
|
|
||||||
const closeTo = (got, want, tol = 1e-6) =>
|
const closeTo = (got, want, tol = 1e-6) =>
|
||||||
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
|
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
|
||||||
@@ -162,6 +163,28 @@ test('six-room ULP topology keeps a complete visible floor and is permutation-st
|
|||||||
closeTo(geometryArea(direct), 0.31835083680549986, 1e-9);
|
closeTo(geometryArea(direct), 0.31835083680549986, 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('write canonicalization cleans the six-room #218 topology before union', () => {
|
||||||
|
const config = {
|
||||||
|
spaces: [{
|
||||||
|
rooms: noisySixRoomFloor.map((poly, index) => ({
|
||||||
|
id: `r${index}`, name: `Room ${index}`, area: null, poly,
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
markers: [],
|
||||||
|
settings: {},
|
||||||
|
};
|
||||||
|
const clean = canonicalizeConfigGeometry(config);
|
||||||
|
const outlines = clean.spaces[0].rooms.map((room) => room.poly);
|
||||||
|
|
||||||
|
assert.equal(outlines[0][0][0], outlines[0][2][0]);
|
||||||
|
assert.equal(outlines[2][2][1], outlines[3][0][1]);
|
||||||
|
assert.ok(unionBodies(outlines), 'canonical persisted rooms have a valid union');
|
||||||
|
const fan = [[-0.1, -0.1], [1.1, -0.1], [1.1, 1.1], [-0.1, 1.1]];
|
||||||
|
assert.ok(intersectionPaths([fan], outlines).length > 0, 'Glow clip remains non-empty');
|
||||||
|
assert.deepEqual(config.spaces[0].rooms.map((room) => room.poly), noisySixRoomFloor,
|
||||||
|
'write canonicalization never mutates the editor candidate');
|
||||||
|
});
|
||||||
|
|
||||||
test('one malformed room is diagnosed and cannot erase healthy lit floor', () => {
|
test('one malformed room is diagnosed and cannot erase healthy lit floor', () => {
|
||||||
const fan = [[-1, -1], [6, -1], [6, 3], [-1, 3]];
|
const fan = [[-1, -1], [6, -1], [6, 3], [-1, 3]];
|
||||||
const healthy = [[3, 0], [5, 0], [5, 2], [3, 2]];
|
const healthy = [[3, 0], [5, 0], [5, 2], [3, 2]];
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Shared coordinate canonicalisation contract (#224)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from custom_components.houseplan import virtual_lights
|
||||||
|
from custom_components.houseplan.coordinate_canonicalization import (
|
||||||
|
COORDINATE_DECIMALS,
|
||||||
|
canonicalize_config_geometry,
|
||||||
|
canonicalize_layout_geometry,
|
||||||
|
canonicalize_number,
|
||||||
|
)
|
||||||
|
from custom_components.houseplan.store import (
|
||||||
|
async_save_config_state,
|
||||||
|
async_save_layout_state,
|
||||||
|
)
|
||||||
|
from custom_components.houseplan.validation import (
|
||||||
|
CONFIG_SCHEMA,
|
||||||
|
LAYOUT_SCHEMA,
|
||||||
|
POS_SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURE = (
|
||||||
|
Path(__file__).parents[1]
|
||||||
|
/ "test"
|
||||||
|
/ "fixtures"
|
||||||
|
/ "coordinate-canonicalization.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture() -> dict:
|
||||||
|
return json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_and_frontend_share_the_nine_decimal_fixture_contract() -> None:
|
||||||
|
fixture = _fixture()
|
||||||
|
config_input = fixture["configInput"]
|
||||||
|
layout_input = fixture["layoutInput"]
|
||||||
|
config_before = copy.deepcopy(config_input)
|
||||||
|
layout_before = copy.deepcopy(layout_input)
|
||||||
|
|
||||||
|
assert COORDINATE_DECIMALS == fixture["decimals"]
|
||||||
|
config = canonicalize_config_geometry(config_input)
|
||||||
|
layout = canonicalize_layout_geometry(layout_input)
|
||||||
|
|
||||||
|
assert config == fixture["configExpected"]
|
||||||
|
assert layout == fixture["layoutExpected"]
|
||||||
|
assert config_input == config_before
|
||||||
|
assert layout_input == layout_before
|
||||||
|
assert canonicalize_config_geometry(config) == config
|
||||||
|
assert canonicalize_layout_geometry(layout) == layout
|
||||||
|
assert math.copysign(1.0, layout["rl:poly"]["x"]) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_scalar_contract_is_symmetric_and_keeps_off_grid_geometry() -> None:
|
||||||
|
assert canonicalize_number(1.2345678905) == 1.234567891
|
||||||
|
assert canonicalize_number(-1.2345678905) == -1.234567891
|
||||||
|
value = 0.20833333333333334
|
||||||
|
assert canonicalize_number(value) == 0.208333333
|
||||||
|
assert abs(canonicalize_number(value) - value) <= 5e-10
|
||||||
|
assert math.isnan(canonicalize_number(float("nan")))
|
||||||
|
assert canonicalize_number(float("inf")) == float("inf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_schemas_apply_the_same_allowlist() -> None:
|
||||||
|
fixture = _fixture()
|
||||||
|
assert CONFIG_SCHEMA(fixture["configInput"]) == fixture["configExpected"]
|
||||||
|
assert LAYOUT_SCHEMA(fixture["layoutInput"]) == fixture["layoutExpected"]
|
||||||
|
assert POS_SCHEMA(fixture["layoutInput"]["virtual"]) == fixture["layoutExpected"]["virtual"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_storage_helpers_are_the_final_canonical_barrier(monkeypatch) -> None:
|
||||||
|
fixture = _fixture()
|
||||||
|
reconcile = AsyncMock()
|
||||||
|
monkeypatch.setattr(virtual_lights, "async_reconcile_virtual_lights", reconcile)
|
||||||
|
|
||||||
|
config_store = AsyncMock()
|
||||||
|
config_store.async_load.return_value = {"rev": 0}
|
||||||
|
config_runtime = SimpleNamespace(
|
||||||
|
config_store=config_store,
|
||||||
|
virtual_light_store=object(),
|
||||||
|
)
|
||||||
|
payload = await async_save_config_state(
|
||||||
|
config_runtime, fixture["configInput"], 1, previous_rev=0
|
||||||
|
)
|
||||||
|
assert payload["config"] == fixture["configExpected"]
|
||||||
|
assert config_store.async_save.await_args.args[0] == payload
|
||||||
|
assert reconcile.await_args.args[1] == fixture["configExpected"]
|
||||||
|
|
||||||
|
layout_store = AsyncMock()
|
||||||
|
layout_runtime = SimpleNamespace(store=layout_store)
|
||||||
|
layout_payload = await async_save_layout_state(
|
||||||
|
layout_runtime, {}, fixture["layoutInput"], 1
|
||||||
|
)
|
||||||
|
assert layout_payload["layout"] == fixture["layoutExpected"]
|
||||||
|
assert layout_store.async_save.await_args.args[0] == layout_payload
|
||||||
@@ -88,6 +88,18 @@ def _document(tmp_path: Path, kind: str = "full") -> dict:
|
|||||||
return document
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_document_canonicalizes_external_coordinates(tmp_path: Path) -> None:
|
||||||
|
document = _document(tmp_path)
|
||||||
|
room = document["payload"]["config"]["spaces"][0]["rooms"][0]
|
||||||
|
room["poly"][0][0] = 0.1234567896
|
||||||
|
document["payload"]["layout"]["lamp"]["x"] = -0.1234567896
|
||||||
|
|
||||||
|
parsed = parse_document(json.dumps(document).encode("utf-8"))
|
||||||
|
|
||||||
|
assert parsed["payload"]["config"]["spaces"][0]["rooms"][0]["poly"][0][0] == 0.12345679
|
||||||
|
assert parsed["payload"]["layout"]["lamp"]["x"] == -0.12345679
|
||||||
|
|
||||||
|
|
||||||
# --- issue #225: an attachment url carries a cache-buster ---------------------
|
# --- issue #225: an attachment url carries a cache-buster ---------------------
|
||||||
#
|
#
|
||||||
# Legacy references look like "/houseplan_files/files/m1/doc.pdf?v=1783170649".
|
# Legacy references look like "/houseplan_files/files/m1/doc.pdf?v=1783170649".
|
||||||
|
|||||||
@@ -172,6 +172,159 @@ async def test_config_rev_conflict(hass: HomeAssistant, hass_ws_client: WebSocke
|
|||||||
assert resp["result"]["rev"] == 1
|
assert resp["result"]["rev"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_canonical_rewrites_are_noops_without_events_or_undo_loss(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
"""#224: a read/write echo must not manufacture another edit."""
|
||||||
|
from custom_components.houseplan.store import get_data
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
config_events = []
|
||||||
|
layout_events = []
|
||||||
|
hass.bus.async_listen(
|
||||||
|
"houseplan_config_updated", lambda event: config_events.append(event.data)
|
||||||
|
)
|
||||||
|
hass.bus.async_listen(
|
||||||
|
"houseplan_layout_updated", lambda event: layout_events.append(event.data)
|
||||||
|
)
|
||||||
|
|
||||||
|
noisy_config = {
|
||||||
|
"spaces": [{
|
||||||
|
"id": "floor",
|
||||||
|
"title": "Floor",
|
||||||
|
"view_box": [0, 0, 1, 1],
|
||||||
|
"rooms": [{
|
||||||
|
"id": "room",
|
||||||
|
"name": "Room",
|
||||||
|
"poly": [[0.1234567896, 0], [0.5, 0], [0.5, 0.5]],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
"markers": [],
|
||||||
|
"settings": {},
|
||||||
|
}
|
||||||
|
await client.send_json_auto_id({
|
||||||
|
"type": "houseplan/config/set", "config": noisy_config, "expected_rev": 0,
|
||||||
|
})
|
||||||
|
first = await client.receive_json()
|
||||||
|
assert first["success"] and first["result"]["rev"] == 1
|
||||||
|
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||||
|
canonical_config = (await client.receive_json())["result"]["config"]
|
||||||
|
assert canonical_config["spaces"][0]["rooms"][0]["poly"][0][0] == 0.12345679
|
||||||
|
|
||||||
|
runtime = get_data(hass)
|
||||||
|
assert runtime is not None
|
||||||
|
layout_data = await runtime.store.async_load() or {}
|
||||||
|
layout_data["optimize_backup"] = {"sentinel": True}
|
||||||
|
await runtime.store.async_save(layout_data)
|
||||||
|
|
||||||
|
await client.send_json_auto_id({
|
||||||
|
"type": "houseplan/config/set", "config": canonical_config, "expected_rev": 1,
|
||||||
|
})
|
||||||
|
noop = await client.receive_json()
|
||||||
|
assert noop["success"] and noop["result"]["rev"] == 1
|
||||||
|
assert len(config_events) == 1
|
||||||
|
assert "optimize_backup" in (await runtime.store.async_load())
|
||||||
|
|
||||||
|
# CAS remains authoritative even when the body is otherwise identical.
|
||||||
|
await client.send_json_auto_id({
|
||||||
|
"type": "houseplan/config/set", "config": canonical_config, "expected_rev": 0,
|
||||||
|
})
|
||||||
|
stale = await client.receive_json()
|
||||||
|
assert not stale["success"] and stale["error"]["code"] == "conflict"
|
||||||
|
|
||||||
|
noisy_layout = {
|
||||||
|
"lamp": {"s": "floor", "x": 0.1234567896, "y": -0.1234567896}
|
||||||
|
}
|
||||||
|
await client.send_json_auto_id({
|
||||||
|
"type": "houseplan/layout/set", "layout": noisy_layout, "expected_rev": 0,
|
||||||
|
})
|
||||||
|
first_layout = await client.receive_json()
|
||||||
|
assert first_layout["success"] and first_layout["result"]["rev"] == 1
|
||||||
|
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||||
|
canonical_layout = (await client.receive_json())["result"]["layout"]
|
||||||
|
assert canonical_layout["lamp"]["x"] == 0.12345679
|
||||||
|
|
||||||
|
layout_data = await runtime.store.async_load() or {}
|
||||||
|
layout_data["optimize_backup"] = {"sentinel": True}
|
||||||
|
await runtime.store.async_save(layout_data)
|
||||||
|
await client.send_json_auto_id({
|
||||||
|
"type": "houseplan/layout/set",
|
||||||
|
"layout": canonical_layout,
|
||||||
|
"expected_rev": 1,
|
||||||
|
})
|
||||||
|
layout_noop = await client.receive_json()
|
||||||
|
assert layout_noop["success"] and layout_noop["result"]["rev"] == 1
|
||||||
|
|
||||||
|
await client.send_json_auto_id({
|
||||||
|
"type": "houseplan/layout/update",
|
||||||
|
"device_id": "lamp",
|
||||||
|
"pos": canonical_layout["lamp"],
|
||||||
|
})
|
||||||
|
point_noop = await client.receive_json()
|
||||||
|
assert point_noop["success"] and point_noop["result"]["rev"] == 1
|
||||||
|
assert len(layout_events) == 1
|
||||||
|
assert "optimize_backup" in (await runtime.store.async_load())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_optimize_undo_restores_geometry_but_not_legacy_noisy_bits(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
"""#224 supersedes #223's invisible exact-bit Undo promise."""
|
||||||
|
from custom_components.houseplan.store import get_data
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
runtime = get_data(hass)
|
||||||
|
assert runtime is not None
|
||||||
|
noisy_config = {
|
||||||
|
"spaces": [{
|
||||||
|
"id": "floor",
|
||||||
|
"title": "Floor",
|
||||||
|
"view_box": [0, 0, 1, 1],
|
||||||
|
"rooms": [{
|
||||||
|
"id": "room",
|
||||||
|
"name": "Room",
|
||||||
|
"poly": [[0.1234567896, 0], [0.5, 0], [0.5, 0.5]],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
"markers": [],
|
||||||
|
"settings": {},
|
||||||
|
}
|
||||||
|
noisy_layout = {
|
||||||
|
"lamp": {"s": "floor", "x": -0.1234567896, "y": 0.5}
|
||||||
|
}
|
||||||
|
# Seed the pre-#224 store directly: public writers can no longer create it.
|
||||||
|
await runtime.config_store.async_save({"config": noisy_config, "rev": 1})
|
||||||
|
await runtime.store.async_save({"layout": noisy_layout, "rev": 1})
|
||||||
|
|
||||||
|
await client.send_json_auto_id({
|
||||||
|
"type": "houseplan/plan/optimize",
|
||||||
|
"config": noisy_config,
|
||||||
|
"layout": noisy_layout,
|
||||||
|
"expected_config_rev": 1,
|
||||||
|
"expected_layout_rev": 1,
|
||||||
|
})
|
||||||
|
optimized = await client.receive_json()
|
||||||
|
assert optimized["success"]
|
||||||
|
stored_layout = await runtime.store.async_load()
|
||||||
|
backup = stored_layout["optimize_backup"]
|
||||||
|
assert backup["config"]["spaces"][0]["rooms"][0]["poly"][0][0] == 0.12345679
|
||||||
|
assert backup["layout"]["lamp"]["x"] == -0.12345679
|
||||||
|
|
||||||
|
await client.send_json_auto_id({
|
||||||
|
"type": "houseplan/plan/optimize_undo",
|
||||||
|
"expected_config_rev": 2,
|
||||||
|
"expected_layout_rev": 2,
|
||||||
|
})
|
||||||
|
undone = await client.receive_json()
|
||||||
|
assert undone["success"] and undone["result"]["can_undo"] is False
|
||||||
|
restored_config = (await runtime.config_store.async_load())["config"]
|
||||||
|
restored_layout = (await runtime.store.async_load())["layout"]
|
||||||
|
assert restored_config["spaces"][0]["rooms"][0]["poly"][0][0] == 0.12345679
|
||||||
|
assert restored_layout["lamp"]["x"] == -0.12345679
|
||||||
|
|
||||||
|
|
||||||
async def test_config_set_validates_new_marker_light_links(
|
async def test_config_set_validates_new_marker_light_links(
|
||||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",
|
"src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",
|
||||||
"src/render-device-snapshot.ts",
|
"src/render-device-snapshot.ts",
|
||||||
"src/command-stack.ts",
|
"src/command-stack.ts",
|
||||||
|
"src/coordinate-canonicalization.ts",
|
||||||
"src/align-grid.ts",
|
"src/align-grid.ts",
|
||||||
"src/plan-optimizer.ts",
|
"src/plan-optimizer.ts",
|
||||||
"src/furniture.ts",
|
"src/furniture.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user