mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
v1.49.0
This commit is contained in:
@@ -281,7 +281,7 @@ Services → House Plan**.
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by curation (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by filtering (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
|
||||
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .geometry_migration import migrate_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
@@ -99,6 +100,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
module_url, module_url,
|
||||
)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
# square and a plan is centred inside it. Nothing about the drawing changes
|
||||
# — the box is padded and the numbers re-expressed against it.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config")
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
layout = lay_stored.get("layout") or {}
|
||||
if cfg and migrate_config(cfg, layout):
|
||||
rev = int(stored.get("rev", 0)) + 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
await data.store.async_save(
|
||||
{"layout": layout, "rev": int(lay_stored.get("rev", 0)) + 1}
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(cfg.get("spaces") or [])
|
||||
)
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
|
||||
@@ -17,6 +17,20 @@ CONTENT_URL = "/api/houseplan/content"
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
|
||||
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
|
||||
# stopped at the door instead. These bound the whole store, not one request: by
|
||||
# default any authenticated user may upload, and a per-request cap of 8/50 MB
|
||||
# says nothing about how many requests there are (HP-1470-01).
|
||||
MAX_PLANS_BYTES = 256 * 1024 * 1024
|
||||
MAX_PLANS_FILES = 200
|
||||
# How many the picker asks for at once — newest first.
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# An uploaded plan that no accepted configuration references is collected only
|
||||
# once it is this old. Age is a race guard, not a policy: a plan uploaded
|
||||
# seconds ago may belong to another client's transaction that has not written
|
||||
@@ -31,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.47.0"
|
||||
VERSION = "1.49.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
|
||||
"""One-time migration to a square canvas — pure, so it can be tested alone.
|
||||
|
||||
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
|
||||
it: x by the width, y by the HEIGHT. Making every canvas square without touching
|
||||
the numbers would stretch every plan vertically.
|
||||
|
||||
Nothing about the drawing changes here. The canvas is padded to a square —
|
||||
top and bottom for a wide plan, left and right for a tall one — and the
|
||||
coordinates are re-expressed against that larger box. In render units it is a
|
||||
uniform scale plus an offset, so angles, room proportions and relative positions
|
||||
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
|
||||
tall plan the width grew, so the same wall would otherwise measure less.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def transform_for(aspect: float) -> tuple[float, float, float, float]:
|
||||
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
|
||||
|
||||
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
|
||||
axis's factor; both are the same uniform scale in RENDER units, which is
|
||||
why angles are preserved.
|
||||
"""
|
||||
a = float(aspect)
|
||||
if not a or a <= 0:
|
||||
a = 1.0
|
||||
k = min(1.0, a) # how much the old box shrinks inside the square
|
||||
kx = k # x was normalised by the width
|
||||
ky = k / a # y was normalised by the height (= width / aspect)
|
||||
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
|
||||
|
||||
|
||||
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
|
||||
if isinstance(p, (list, tuple)) and len(p) >= 2:
|
||||
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
|
||||
return p
|
||||
|
||||
|
||||
def migrate_space(space: dict[str, Any]) -> bool:
|
||||
"""Rewrite one space in place. Returns True when anything was changed."""
|
||||
if "aspect" not in space:
|
||||
return False
|
||||
try:
|
||||
aspect = float(space.get("aspect") or 1)
|
||||
except (TypeError, ValueError):
|
||||
aspect = 1.0
|
||||
dx, dy, kx, ky = transform_for(aspect)
|
||||
space.pop("aspect", None)
|
||||
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("x") is not None:
|
||||
room["x"] = dx + float(room["x"]) * kx
|
||||
if room.get("y") is not None:
|
||||
room["y"] = dy + float(room["y"]) * ky
|
||||
if room.get("w") is not None:
|
||||
room["w"] = float(room["w"]) * kx
|
||||
if room.get("h") is not None:
|
||||
room["h"] = float(room["h"]) * ky
|
||||
if room.get("poly"):
|
||||
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
|
||||
|
||||
for op in space.get("openings") or []:
|
||||
op["x"] = dx + float(op.get("x", 0)) * kx
|
||||
op["y"] = dy + float(op.get("y", 0)) * ky
|
||||
# a length is measured along the wall, and the render scale is uniform
|
||||
if op.get("length") is not None:
|
||||
op["length"] = float(op["length"]) * kx
|
||||
|
||||
for shape in space.get("decor") or []:
|
||||
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
|
||||
if shape.get(a) is not None:
|
||||
shape[a] = dx + float(shape[a]) * fx
|
||||
if shape.get(b) is not None:
|
||||
shape[b] = dy + float(shape[b]) * fy
|
||||
if shape.get("w") is not None:
|
||||
shape["w"] = float(shape["w"]) * kx
|
||||
if shape.get("h") is not None:
|
||||
shape["h"] = float(shape["h"]) * ky
|
||||
|
||||
# The viewport becomes the whole square rather than the transformed old
|
||||
# rectangle. It is what the grid is drawn over and what "fit to screen"
|
||||
# fits, so keeping the old box would leave the new margins outside the
|
||||
# canvas — no dots, nothing to draw on — which is exactly the room this
|
||||
# change was meant to give.
|
||||
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
|
||||
# canvas, so a wall now covers fewer cells; without this every measurement
|
||||
# in the plan would silently shrink.
|
||||
if kx != 1:
|
||||
try:
|
||||
cell = float(space.get("cell_cm") or 5)
|
||||
except (TypeError, ValueError):
|
||||
cell = 5.0
|
||||
space["cell_cm"] = round(cell / kx, 4)
|
||||
|
||||
# The image keeps its own proportions and is centred; the space no longer
|
||||
# has any of its own.
|
||||
if space.get("plan_url") and not space.get("plan_aspect"):
|
||||
space["plan_aspect"] = round(aspect, 6)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
|
||||
"""Migrate every space, and the marker positions that belong to them."""
|
||||
spaces = config.get("spaces") or []
|
||||
factors = {}
|
||||
for space in spaces:
|
||||
if "aspect" in space:
|
||||
factors[str(space.get("id"))] = transform_for(space.get("aspect") or 1)
|
||||
if not factors:
|
||||
return False
|
||||
|
||||
for space in spaces:
|
||||
migrate_space(space)
|
||||
|
||||
for pos in (layout or {}).values():
|
||||
if not isinstance(pos, dict):
|
||||
continue
|
||||
f = factors.get(str(pos.get("s")))
|
||||
if not f:
|
||||
continue
|
||||
dx, dy, kx, ky = f
|
||||
if pos.get("x") is not None:
|
||||
pos["x"] = dx + float(pos["x"]) * kx
|
||||
if pos.get("y") is not None:
|
||||
pos["y"] = dy + float(pos["y"]) * ky
|
||||
return True
|
||||
@@ -20,9 +20,12 @@ except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import TMP_PREFIX, reserve_filename
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -204,6 +207,16 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
tmp_path = temps[0]
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT,
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
|
||||
except OSError:
|
||||
pass
|
||||
target_dir = files_root / marker_id
|
||||
safe_name = filename
|
||||
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.47.0"
|
||||
"version": "1.49.0"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S
|
||||
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -189,6 +189,56 @@ def collect_attachments(
|
||||
return removed
|
||||
|
||||
|
||||
class QuotaError(Exception):
|
||||
"""A store limit would be exceeded. Carries what to tell the user."""
|
||||
|
||||
def __init__(self, reason: str, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def dir_usage(path: Path) -> tuple[int, int]:
|
||||
"""(bytes, files) below `path`, ignoring what we cannot read."""
|
||||
total = count = 0
|
||||
if not path.is_dir():
|
||||
return 0, 0
|
||||
for item in path.rglob("*"):
|
||||
try:
|
||||
if item.is_file():
|
||||
total += item.stat().st_size
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
return total, count
|
||||
|
||||
|
||||
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit.
|
||||
|
||||
Deliberately not an age rule. Files are never removed for getting old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: at the moment somebody asks to store something new.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
used, count = dir_usage(path)
|
||||
if count + 1 > max_files:
|
||||
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
|
||||
if used + incoming > max_bytes:
|
||||
raise QuotaError(
|
||||
"quota_exceeded",
|
||||
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
|
||||
f"{max_bytes // 1024 // 1024} MB",
|
||||
)
|
||||
try:
|
||||
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
|
||||
except OSError:
|
||||
return
|
||||
if free - incoming < MIN_FREE_BYTES:
|
||||
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
if not isinstance(url, str) or not url:
|
||||
@@ -240,7 +290,7 @@ def collect_plans(
|
||||
* a file the OLD configuration referenced and the new one does not was
|
||||
authoritative and has been superseded — remove it;
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
|
||||
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
|
||||
@@ -188,7 +188,15 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("title"): str,
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
# `aspect` is gone; the background image keeps its own proportions and
|
||||
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
|
||||
# send the old field — it is dropped rather than trusted, because the
|
||||
# coordinates it comes with were normalised against a different box.
|
||||
vol.Remove("aspect"): object,
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
|
||||
@@ -17,12 +17,14 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import (
|
||||
collect_attachments, collect_plans, is_plan_file, plan_basename, plan_refs,
|
||||
reserve_filename,
|
||||
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
@@ -263,7 +265,12 @@ async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
out.sort(key=lambda x: -x["modified"])
|
||||
return out
|
||||
|
||||
connection.send_result(msg["id"], {"plans": await hass.async_add_executor_job(_scan)})
|
||||
plans = await hass.async_add_executor_job(_scan)
|
||||
# newest first and capped: a folder with thousands of files would otherwise
|
||||
# become one huge message, one huge list and a signing request per row
|
||||
connection.send_result(
|
||||
msg["id"], {"plans": plans[:MAX_PLANS_LISTED], "total": len(plans)}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -470,6 +477,46 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
|
||||
|
||||
|
||||
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
|
||||
"""Plan file names a configuration names through OUR urls.
|
||||
|
||||
Only `/api/houseplan/content/plans/_/<name>` and the legacy static path
|
||||
count. Anything else belongs to the user and may point wherever they like.
|
||||
"""
|
||||
out: set[str] = set()
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
url = space.get("plan_url")
|
||||
if not isinstance(url, str) or not url:
|
||||
continue
|
||||
if not (url.startswith(CONTENT_URL + "/plans/") or url.startswith(PLANS_URL + "/")):
|
||||
continue
|
||||
name = plan_basename(url)
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def _missing_internal_plans(
|
||||
plans_dir: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
|
||||
) -> set[str]:
|
||||
"""Newly named plan files that are not on disk.
|
||||
|
||||
Guards the pick-then-save window: another client may delete a plan between
|
||||
the moment this one chose it and the moment it saves, which would otherwise
|
||||
store a url with nothing behind it (HP-1470-02).
|
||||
|
||||
A name the stored configuration already carries is deliberately let through.
|
||||
It is already broken — repairs says so — and refusing the write would lock
|
||||
the owner out of every other edit, including the one that detaches it.
|
||||
"""
|
||||
known = _internal_plan_names(previous or {})
|
||||
return {
|
||||
name
|
||||
for name in _internal_plan_names(config)
|
||||
if name not in known and not (plans_dir / name).is_file()
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -519,6 +566,23 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
# plan and then delete it from the same dialog, and two clients can do
|
||||
# the same thing in either order — the lock serialises them but says
|
||||
# nothing about whether the file survived (HP-1470-02). External and
|
||||
# legacy urls are not ours to check and are left alone.
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
msg["config"],
|
||||
data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
connection.send_error(
|
||||
msg["id"], "missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
@@ -589,6 +653,13 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
# files of a differently named space.
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES
|
||||
)
|
||||
except QuotaError as err:
|
||||
connection.send_error(msg["id"], err.reason, err.detail)
|
||||
return
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||
path = plans_dir / name
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const res = await page.evaluate(async () => {
|
||||
const a = devs[0], b = devs[1];
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / (1000 / (c._curSpaceCfg.aspect || 1)) } };
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
const W = 1000, H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const W = 1000, H = 1000; // square canvas
|
||||
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.decorGuide = guides() >= 1;
|
||||
|
||||
@@ -11,7 +11,7 @@ const res = await page.evaluate(async () => {
|
||||
document.body.appendChild(c1);
|
||||
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
await new Promise(r=>setTimeout(r,150));
|
||||
@@ -31,7 +31,7 @@ const res = await page.evaluate(async () => {
|
||||
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
|
||||
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
|
||||
callWS: async (m) => m.type==='houseplan/config/get'
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
|
||||
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, view_box:[0,0,1,1],
|
||||
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
|
||||
: { layout:{} },
|
||||
connection:{ subscribeEvents: async()=>()=>{} } };
|
||||
|
||||
+3
-4
@@ -44,7 +44,7 @@ const res = await page.evaluate(async () => {
|
||||
const c2 = c._roomCenter(r2);
|
||||
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
// общая стена вертикальная — дверь ставим на неё
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
const doorPt = (() => {
|
||||
let best = null, bd = 1e9;
|
||||
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
|
||||
@@ -57,9 +57,8 @@ const res = await page.evaluate(async () => {
|
||||
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
const c1 = c._roomCenter(r1);
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||||
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
|
||||
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
@@ -73,7 +72,7 @@ const res = await page.evaluate(async () => {
|
||||
const minX = Math.min(...poly1.map((p) => p[0]));
|
||||
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) };
|
||||
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
|
||||
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
|
||||
|
||||
@@ -38,8 +38,7 @@ const res = await page.evaluate(async () => {
|
||||
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
|
||||
const center = c._roomCenter(room);
|
||||
const vpos = c._layout[vid];
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
|
||||
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * 1000 - center[1]) < 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -9,7 +9,7 @@ const restore = () => page.evaluate((s) => {
|
||||
}, snap);
|
||||
// norm→render helper mirrors what _markupClick passes to handlers
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
return [nx * 1000, ny * 1000]; // the canvas is square (v1.48.0)
|
||||
}, [nx, ny]);
|
||||
const S = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
c._openWallClick([550, 0.25 * H]);
|
||||
await c.updateComplete;
|
||||
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
|
||||
@@ -53,8 +53,7 @@ const res = await page.evaluate(async () => {
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
|
||||
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
|
||||
const aspect = c._curSpaceCfg.aspect || 1;
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
|
||||
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / 1000 } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
|
||||
|
||||
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
const H = 1000 / (c._curSpaceCfg.aspect || 1);
|
||||
const H = 1000; // square canvas
|
||||
// 1) без наведения: курсор default, превью нет
|
||||
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
|
||||
@@ -39,7 +39,7 @@ const res = await page.evaluate(async () => {
|
||||
const sentF1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
||||
const liveF1 = (c._serverCfg?.spaces || []).find((s) => s.id === 'f1');
|
||||
out.sentPlanUrl = sentF1?.plan_url;
|
||||
out.sentAspect = sentF1?.aspect;
|
||||
out.sentPlanAspect = sentF1?.plan_aspect; // the IMAGE's ratio; the canvas is square
|
||||
out.sentTitle = sentF1?.title;
|
||||
out.livePlanUrl = liveF1?.plan_url;
|
||||
out.dialogClosed = c._spaceDialog === null;
|
||||
@@ -52,19 +52,19 @@ const res = await page.evaluate(async () => {
|
||||
const attic = (c.__sent?.spaces || []).find((s) => s.title === 'Attic');
|
||||
out.atticSaved = !!attic;
|
||||
out.atticHasPlan = !!attic && typeof attic.plan_url === 'string' && attic.plan_url.includes('/content/plans/');
|
||||
out.atticAspect = attic?.aspect;
|
||||
out.atticPlanAspect = attic?.plan_aspect;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.8 и сверено с кодом
|
||||
checkAll(res, {
|
||||
reloadHappened: true,
|
||||
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
sentAspect: 1.6,
|
||||
sentPlanAspect: 1.6,
|
||||
sentTitle: 'Ground',
|
||||
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
dialogClosed: true,
|
||||
atticSaved: true,
|
||||
atticHasPlan: true,
|
||||
atticAspect: 0.8,
|
||||
atticPlanAspect: 0.8,
|
||||
});
|
||||
await finish(browser);
|
||||
|
||||
@@ -56,9 +56,16 @@ const res = await page.evaluate(async () => {
|
||||
out.listClosed = !c._spaceDialog.pickSaved;
|
||||
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
|
||||
// удаление: занятый нельзя, свободный можно
|
||||
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
|
||||
// на несуществующий файл (HP-1470-02)
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await c.updateComplete;
|
||||
const rows2 = [...sr().querySelectorAll('.savedplan')];
|
||||
const picked = rows2.find((r) => r.textContent.includes('f1.aaa.png'));
|
||||
out.deleteDisabledForPicked = !!picked?.querySelector('.btn.danger[disabled]');
|
||||
c._spaceDialog = { ...c._spaceDialog, planUrl: null };
|
||||
await c.updateComplete;
|
||||
await c._deleteServerPlan('f2.bbb.png').catch(() => {});
|
||||
out.usedNotDeleted = !deleted.includes('f2.bbb.png');
|
||||
await c._deleteServerPlan('f1.aaa.png');
|
||||
@@ -78,6 +85,7 @@ checkAll(res, {
|
||||
picked: true,
|
||||
listClosed: true,
|
||||
saveEnabledAfterPick: true,
|
||||
deleteDisabledForPicked: true,
|
||||
usedNotDeleted: true,
|
||||
freeDeleted: true,
|
||||
rowGone: true,
|
||||
|
||||
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
|
||||
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
|
||||
out.atticAspect = attic?.aspect;
|
||||
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
|
||||
out.atticSettings = attic?.settings;
|
||||
out.atticNoPlan = attic ? attic.plan_url === null : null;
|
||||
return out;
|
||||
@@ -55,7 +55,7 @@ checkAll(res, {
|
||||
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
|
||||
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
|
||||
"lqiFills": 0,
|
||||
"atticAspect": 1,
|
||||
"atticSquare": true,
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
|
||||
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
|
||||
return [nx * 1000, ny * 1000]; // square canvas (v1.48.0)
|
||||
}, [nx, ny]);
|
||||
const out = {};
|
||||
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
|
||||
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
|
||||
// living room (r1) has walls at y=0.14 which are NOT grid nodes; click near the wall
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.142)); // near top wall (off grid)
|
||||
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
|
||||
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
|
||||
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+7
-7
@@ -51,17 +51,17 @@ customElements.define('ha-card',HaCard);
|
||||
<script type="module">
|
||||
const CFG = {
|
||||
spaces: [
|
||||
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', aspect:1.25,
|
||||
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', plan_aspect:1.25,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[
|
||||
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.05],[0.55,0.05],[0.55,0.6],[0.04,0.6]]},
|
||||
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.05],[0.96,0.05],[0.96,0.45],[0.55,0.45]]},
|
||||
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.45],[0.96,0.45],[0.96,0.95],[0.55,0.95]]},
|
||||
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.6],[0.55,0.6],[0.55,0.95],[0.04,0.95]]}
|
||||
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]]},
|
||||
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.14],[0.96,0.14],[0.96,0.46],[0.55,0.46]]},
|
||||
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.46],[0.96,0.46],[0.96,0.86],[0.55,0.86]]},
|
||||
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.58],[0.55,0.58],[0.55,0.86],[0.04,0.86]]}
|
||||
], segments:[] },
|
||||
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', aspect:1.4286,
|
||||
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', plan_aspect:1.4286,
|
||||
view_box:[0,0,1,1],
|
||||
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.04],[0.97,0.04],[0.97,0.96],[0.03,0.96]]} ], segments:[] }
|
||||
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.178],[0.97,0.178],[0.97,0.822],[0.03,0.822]]} ], segments:[] }
|
||||
],
|
||||
markers: [],
|
||||
settings: {}
|
||||
|
||||
Vendored
+52
-40
File diff suppressed because one or more lines are too long
+25
-5
@@ -10,7 +10,7 @@ houseplan-card/
|
||||
├─ src/ # card sources (TypeScript + Lit 3)
|
||||
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
|
||||
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
|
||||
│ ├─ rules.ts # icon rules (iconFor), curation, groups, domain priority
|
||||
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, domain priority
|
||||
│ └─ data/
|
||||
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
|
||||
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
|
||||
@@ -182,6 +182,17 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
|
||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||
|
||||
**The canvas is square, the image is not** (v1.48.0). A space used to carry an
|
||||
`aspect`, and coordinates were normalised against it — x by the width, y by the
|
||||
height. That made every geometric question depend on a per-space number for no
|
||||
benefit. Now the render space is `NORM_W × NORM_W` and a plan image is fitted
|
||||
inside it by its own ratio (`fitInSquare`, shared by both renderers), which is
|
||||
stored as `plan_aspect` so the layout does not jump before the file loads.
|
||||
Upgrading runs `geometry_migration.migrate_config` once: it pads the old box out
|
||||
to a square and re-expresses every coordinate against it — a uniform scale plus
|
||||
an offset in render units, so angles and proportions are exact — and scales
|
||||
`cell_cm` for tall plans, since the grid pitch is a fraction of the width.
|
||||
|
||||
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
|
||||
thing here that a browser will happily treat as a *document* rather than an
|
||||
image, and it would be a document of Home Assistant's own origin. Inside the
|
||||
@@ -255,12 +266,21 @@ removes nothing. Deciding what may then go is *not* a client's call — a cleanu
|
||||
request cannot be ordered against another client's commit, and a delayed one
|
||||
deletes a plan that was just saved. So `config/set` collects itself, inside its
|
||||
write lock, from the pair of configurations that bracket the commit
|
||||
(`plans.collect_plans`): superseded files go immediately, other unreferenced
|
||||
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
|
||||
to a transaction still in flight. The `.` between id and token is load-bearing —
|
||||
(`plans.collect_plans`): a file the commit REPLACED goes immediately, and
|
||||
nothing else goes at all — see the table above; only a per-dialog staging folder
|
||||
ages out. Growth is bounded at the door instead, by `plans.check_quota` on every
|
||||
upload (store size, file count, free disk), because a limit that deletes is how
|
||||
plans were lost twice. The `.` between id and token is load-bearing —
|
||||
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
|
||||
with the files of a space whose name merely starts the same way.
|
||||
|
||||
**An internal plan url must exist when it is stored** (HP-1470-02). The picker
|
||||
can attach a plan and then delete it, and two clients can do the same in either
|
||||
order — the write lock orders the requests but says nothing about whether the
|
||||
file survived. `config/set` therefore checks every `/api/houseplan/content/plans/`
|
||||
url against the disk before saving, and refuses with `missing_plan`. External and
|
||||
legacy urls are the user's own and are never second-guessed.
|
||||
|
||||
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
|
||||
in `src/signing.ts` is the single implementation, used by both cards; the
|
||||
duplicate inside houseplan-space-card signed correctly and never handed the
|
||||
@@ -301,7 +321,7 @@ Shared, framework-light modules keep the two views from diverging:
|
||||
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
|
||||
mirrors the full card's private geometry.
|
||||
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
|
||||
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
|
||||
borders/names + device markers (via `buildDevices`, same filtering) with NO handlers,
|
||||
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
|
||||
(the space-card imports `cardStyles`) for visual parity.
|
||||
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## v1.49.0 — 2026-07-28
|
||||
|
||||
**The canvas is square** (see v1.48.0, released together with this one).
|
||||
|
||||
- **Zoom opens on what is drawn, not on the whole canvas.** A space without a
|
||||
background image now fits its rooms with a 5% margin, so a small plan on a big
|
||||
canvas fills the screen instead of sitting in the middle of it as a speck.
|
||||
With a background image nothing changes: the image is the plan, and cropping
|
||||
to the rooms would hide the parts nobody has outlined yet.
|
||||
- **Switching spaces by swipe, or on the kiosk carousel, slides.** The plan
|
||||
leaves the way the finger went and the next one arrives from the other side.
|
||||
Respects "reduce motion".
|
||||
- The room settings button says "Room settings" rather than just "Room", and
|
||||
lightens slightly under the cursor.
|
||||
- "Curation" is called filtering everywhere — the interface, the documentation
|
||||
and the code.
|
||||
|
||||
**From the v1.47.0 review**
|
||||
|
||||
- **A plan you have just picked can no longer be deleted from the same dialog
|
||||
(HP-1470-02).** It was not saved yet, so the server correctly considered it
|
||||
free — and the save then stored a url with no file behind it. The button is
|
||||
disabled now, and, because two clients can do the same in either order, the
|
||||
server checks every internal plan url against the disk before storing a
|
||||
configuration and refuses one that is missing. Urls that are not ours are left
|
||||
alone.
|
||||
- **Uploads are bounded (HP-1470-01).** Nothing is deleted for being old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: an upload is refused if the store would pass 256 MB or 200 plans
|
||||
(1 GB / 1000 for attachments), or if the disk would drop below 512 MB free.
|
||||
The plan list is capped at the 60 newest and its thumbnails load lazily.
|
||||
- **Picking a saved plan reads its real proportions (HP-1470-03).** The card
|
||||
waited for nothing and, when the signature for the protected url had not
|
||||
arrived yet, saved a fallback ratio — a square plan came out stretched. It now
|
||||
waits for the signature, ties the result to the dialog that asked, and the
|
||||
preview in the dialog is signed like everything else.
|
||||
|
||||
## v1.48.0 — 2026-07-28 (the canvas is always square)
|
||||
- **A space no longer has proportions of its own.** The drawing area is a square;
|
||||
a plan image keeps its own shape and is centred inside it, so a wide plan gets
|
||||
margins above and below and a tall one gets them at the sides. There is
|
||||
nothing left to choose — the canvas orientation setting for hand-drawn spaces
|
||||
is gone with it.
|
||||
- **Existing plans are migrated once, on upgrade.** Nothing about a drawing
|
||||
changes: the box is padded out to a square and every coordinate is
|
||||
re-expressed against it — rooms, doors and windows, decor, marker positions
|
||||
and the saved viewport. Angles, room proportions and relative positions are
|
||||
preserved exactly. For a tall plan the scale in centimetres per grid cell is
|
||||
adjusted along with it, because the grid is tied to the width; without that a
|
||||
wall would silently measure less than it does.
|
||||
|
||||
## v1.47.0 — 2026-07-28 (pick a plan you already uploaded)
|
||||
- **The space dialog can now show the plans stored on the server.** Detaching a
|
||||
plan keeps the image on disk — that has been the rule since v1.46.4, but until
|
||||
|
||||
@@ -6,6 +6,56 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.49.0 — 2026-07-28
|
||||
|
||||
**Холст стал квадратным** (см. v1.48.0, выпущена вместе с этой).
|
||||
|
||||
- **Масштаб открывается по нарисованному, а не по всему холсту.** Пространство
|
||||
без подложки теперь вписывается по границам своих комнат с полями 5%: маленький
|
||||
план на большом холсте заполняет экран, а не сидит посередине точкой. С
|
||||
подложкой ничего не меняется — картинка и есть план, и обрезать её по комнатам
|
||||
значило бы спрятать то, что ещё не обведено.
|
||||
- **Переключение пространств свайпом и в киоске стало с анимацией.** План
|
||||
уезжает туда, куда пошёл палец, следующий приходит с другой стороны.
|
||||
Уважает системную настройку «уменьшить движение».
|
||||
- Кнопка настроек комнаты подписана «Настройки комнаты», а не просто «Комната»,
|
||||
и слегка светлеет под курсором.
|
||||
- Слово «курирование» заменено на «фильтрацию» — в интерфейсе, документации и
|
||||
коде.
|
||||
|
||||
**По ревью v1.47.0**
|
||||
|
||||
- **Только что выбранный план больше нельзя удалить из того же диалога
|
||||
(HP-1470-02).** Он ещё не сохранён, поэтому сервер справедливо считал его
|
||||
свободным — а сохранение потом записывало ссылку, за которой нет файла.
|
||||
Кнопка заблокирована, и, поскольку два клиента могут сделать это в любом
|
||||
порядке, сервер перед записью конфигурации сверяет каждую внутреннюю ссылку с
|
||||
диском и отказывает, если файла нет. Чужие ссылки не трогаются.
|
||||
- **Загрузки ограничены (HP-1470-01).** По возрасту не удаляется ничего — это
|
||||
дважды стоило настоящих планов, — поэтому предел стоит там, где решение и так
|
||||
принимается: загрузка отклоняется, если хранилище перевалит за 256 МБ или 200
|
||||
планов (1 ГБ и 1000 для вложений) либо если на диске останется меньше 512 МБ.
|
||||
Список планов отдаётся по 60 самых свежих, миниатюры грузятся лениво.
|
||||
- **Выбор сохранённого плана читает его настоящие пропорции (HP-1470-03).**
|
||||
Карточка ничего не ждала и, если подпись для защищённой ссылки ещё не пришла,
|
||||
записывала пропорции «по умолчанию» — квадратный план получался растянутым.
|
||||
Теперь она дожидается подписи, привязывает результат к тому диалогу, который
|
||||
спрашивал, а превью в диалоге подписывается, как и всё остальное.
|
||||
|
||||
## v1.48.0 — 2026-07-28 (холст всегда квадратный)
|
||||
- **У пространства больше нет собственных пропорций.** Область рисования —
|
||||
квадрат, а картинка плана сохраняет свою форму и вписывается в него по
|
||||
центру: у широкого плана появляются поля сверху и снизу, у вытянутого — по
|
||||
бокам. Выбирать нечего, поэтому настройка ориентации холста для пространств
|
||||
без картинки убрана.
|
||||
- **Существующие планы переносятся один раз, при обновлении.** В самом рисунке
|
||||
ничего не меняется: коробка дополняется до квадрата, и все координаты
|
||||
пересчитываются относительно неё — комнаты, двери и окна, декор, позиции
|
||||
значков и сохранённая область просмотра. Углы, пропорции комнат и взаимное
|
||||
расположение сохраняются точно. Для вытянутых планов заодно пересчитывается
|
||||
масштаб в сантиметрах на клетку: сетка привязана к ширине, и без этого стены
|
||||
молча стали бы короче.
|
||||
|
||||
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
|
||||
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
|
||||
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ Editors are admin-only tools and must never leak interactions into View
|
||||
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
|
||||
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
|
||||
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action | **Closed** |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, filtered auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
|
||||
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
|
||||
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.47.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.49.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (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) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.47.0** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.49.0** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||
|
||||
+25
-2
@@ -66,7 +66,7 @@
|
||||
than a quarter of the plan outside the viewBox [auto: smoke_decor]
|
||||
|
||||
- [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT
|
||||
placed on the plan (hidden by curation or by the user) still feeds the
|
||||
placed on the plan (hidden by filtering or by the user) still feeds the
|
||||
room card, the tooltip and the temperature fill; fridges/TRVs still do
|
||||
not; an explicit per-room source still wins [auto: unit devices.test]
|
||||
- [ ] Room tooltip wording (v1.44.5): hovering a room shows its name (plus
|
||||
@@ -195,7 +195,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
## Devices on the plan ★
|
||||
|
||||
- [ ] Auto devices appear only in rooms bound to their area [manual]
|
||||
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
|
||||
- [ ] Filtering hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
|
||||
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
|
||||
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual]
|
||||
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
|
||||
@@ -239,6 +239,29 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
on the plan after a reload. Same for each tap action and each fill mode
|
||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||
- [ ] Zoom opens on the content (v1.49.0): a space with no background and one
|
||||
small room opens with that room filling the screen, with a small margin.
|
||||
With a background it still fits the whole image
|
||||
[auto: unit: contentBounds]
|
||||
- [ ] Deleting a picked plan is refused (v1.49.0, HP-1470-02): pick a saved
|
||||
plan, reopen the list — its delete button is disabled. Ask the server to
|
||||
store a plan url whose file is gone: `missing_plan`, and the revision does
|
||||
not move [auto: smoke_saved_plans + backend
|
||||
test_config_set_refuses_a_plan_that_no_longer_exists]
|
||||
- [ ] Uploads are bounded (v1.49.0, HP-1470-01): past the store quota an upload
|
||||
is refused with a clear error and the disk does not grow; the plan list
|
||||
returns the newest 60 with a total
|
||||
[auto: unit: test_check_quota_counts_the_whole_store_not_one_request,
|
||||
backend test_uploads_are_bounded_by_a_store_quota]
|
||||
- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan
|
||||
looks exactly as before, just with margins where the canvas was extended.
|
||||
Measure a wall in the plan editor — the length in cm is unchanged. Marker
|
||||
positions, doors, decor and the saved zoom are all where they were
|
||||
[auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours,
|
||||
test_migration_preserves_real_lengths_and_shapes]
|
||||
- [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with
|
||||
empty bands above and below, a tall one with bands at the sides, and it is
|
||||
never stretched [auto: unit: fitInSquare + smoke_space_settings]
|
||||
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
|
||||
PAGE, open space settings → "Already uploaded" → the image is listed with
|
||||
its size and no "in use" note → attach it → it renders. The one a space
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.47.0",
|
||||
"version": "1.49.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+7
-7
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Building the device list from HA registries: curation, light groups,
|
||||
* Building the device list from HA registries: filtering, light groups,
|
||||
* markers (overrides/virtual). No Lit/DOM — only the hass object.
|
||||
*/
|
||||
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
|
||||
@@ -184,7 +184,7 @@ function applyMarker(item: DevItem, m: Marker): void {
|
||||
item.tapAction = m.tap_action ?? null;
|
||||
}
|
||||
|
||||
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
|
||||
/** Filtering + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
|
||||
export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
|
||||
const groupLights = settings.group_lights !== false;
|
||||
@@ -210,7 +210,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
if (marker && marker.hidden) continue;
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const dom = domainOfDevice(h, dev, entIds);
|
||||
// curation (can be turned off with the “show all” toggle)
|
||||
// filtering (can be turned off with the “show all” toggle)
|
||||
if (!showAll) {
|
||||
if (excluded.has(dom)) continue;
|
||||
if (dev.model === 'Group') continue;
|
||||
@@ -391,7 +391,7 @@ export function areaHum(
|
||||
const vals: number[] = [];
|
||||
for (const dv of devices) {
|
||||
if (dv.area !== area) continue;
|
||||
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
// same filtering idea as areaTemp: climate sensors only, not fridges/plugs
|
||||
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
|
||||
const h = humFor(hass, dv.entities);
|
||||
if (h != null) vals.push(h);
|
||||
@@ -416,11 +416,11 @@ const NON_AIR_RE = new RegExp(
|
||||
|
||||
/**
|
||||
* Room climate from EVERY sensor of the area — including devices that are not
|
||||
* placed on the plan (hidden by curation or by the user). The old helpers read
|
||||
* placed on the plan (hidden by filtering or by the user). The old helpers read
|
||||
* the visible-icon list, so hiding a thermometer silently removed it from the
|
||||
* room card (field report, 2026-07-27).
|
||||
*
|
||||
* Curation is kept: only devices the card itself recognises as thermometers /
|
||||
* Filtering is kept: only devices the card itself recognises as thermometers /
|
||||
* air monitors count, so fridges, TRVs and chip-temperature plugs stay out.
|
||||
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
||||
* a device measures.
|
||||
@@ -451,7 +451,7 @@ export function areaClimateMap(
|
||||
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
||||
// question, 2026-07-27). Three guards, cheapest first:
|
||||
if (reg.entity_category) continue; // diagnostic/config readings
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations
|
||||
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||
let groups = byArea.get(area);
|
||||
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
||||
|
||||
+117
-75
@@ -33,15 +33,16 @@ import type {
|
||||
import './editor';
|
||||
import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { fitInSquare, contentBounds } from './space-geometry';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.47.0';
|
||||
const CARD_VERSION = '1.49.0';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were)
|
||||
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
|
||||
const NORM_W = 1000; // width of the render space for normalized configs
|
||||
const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
|
||||
|
||||
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
|
||||
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'openwall' | 'delroom';
|
||||
@@ -163,13 +164,34 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Kiosk auto-carousel: advance to the next space every `cycle` seconds. */
|
||||
/**
|
||||
* Which way the plan should fly out when the space changes. Empty means no
|
||||
* animation — a direct pick from the tabs should not slide anywhere.
|
||||
*/
|
||||
private _slide: '' | 'left' | 'right' = '';
|
||||
private _slideTimer?: number;
|
||||
|
||||
/** Change the space with the usual sideways transition. */
|
||||
private _slideTo(id: string, dir: 'left' | 'right'): void {
|
||||
if (id === this._space) return;
|
||||
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
||||
this._space = id;
|
||||
this._selId = null;
|
||||
this._restoreZoom();
|
||||
if (reduce) return;
|
||||
this._slide = dir;
|
||||
clearTimeout(this._slideTimer);
|
||||
// long enough to be read as motion, short enough not to be in the way
|
||||
this._slideTimer = window.setTimeout(() => { this._slide = ''; this.requestUpdate(); }, 260);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _cycleTick(): void {
|
||||
if (!this._kiosk || !(Number(this._config?.cycle) > 0)) return;
|
||||
if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) {
|
||||
const ids = this._model.map((m) => m.id);
|
||||
const i = ids.indexOf(this._space);
|
||||
this._space = ids[(i + 1) % ids.length];
|
||||
this._restoreZoom();
|
||||
this._slideTo(ids[(i + 1) % ids.length], 'left');
|
||||
this._showKioskDots();
|
||||
}
|
||||
}
|
||||
@@ -287,7 +309,6 @@ class HouseplanCard extends LitElement {
|
||||
savedBusy?: boolean;
|
||||
savedAspect?: number;
|
||||
source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms
|
||||
orientation: 'landscape' | 'portrait' | 'square';
|
||||
showBorders: boolean;
|
||||
showNames: boolean;
|
||||
roomColor: string;
|
||||
@@ -404,6 +425,7 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._reloadRetry);
|
||||
this._signer.dispose();
|
||||
clearTimeout(this._toastTimer);
|
||||
clearTimeout(this._slideTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
clearTimeout(this._holdTimer);
|
||||
@@ -569,7 +591,7 @@ class HouseplanCard extends LitElement {
|
||||
return !!(this._serverCfg && this._serverCfg.spaces.length);
|
||||
}
|
||||
|
||||
/** Spaces in render units (NORM_W × NORM_W/aspect). */
|
||||
/** Spaces in render units (NORM_W × NORM_W — the canvas is square). */
|
||||
/** Bumped by every config mutation — the model/geometry cache key (audit L1). */
|
||||
private _cfgEpoch = 0;
|
||||
private _modelCache: { key: string; model: SpaceModel[] } | null = null;
|
||||
@@ -579,7 +601,7 @@ class HouseplanCard extends LitElement {
|
||||
const sp = this._serverCfg?.spaces || [];
|
||||
let s = sp.length + ':';
|
||||
for (const x of sp as any[]) {
|
||||
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||
s += (x.id || '') + ',' + (x.plan_aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
|
||||
for (const r of x.rooms || []) {
|
||||
// O(1) geometry roll-up per room: the count alone said nothing about
|
||||
@@ -610,7 +632,7 @@ class HouseplanCard extends LitElement {
|
||||
private _buildModel(): SpaceModel[] {
|
||||
if (!this._serverCfg) return [];
|
||||
return this._serverCfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
const H = NORM_W; // the canvas is always square (v1.48.0)
|
||||
const scale = (r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -631,7 +653,7 @@ class HouseplanCard extends LitElement {
|
||||
// so a signed url baked in here would freeze BEFORE the signature
|
||||
// arrives and the plan would never load (bug found 2026-07-27).
|
||||
// _display() is called at render time instead.
|
||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
bg: s.plan_url ? { href: s.plan_url, ...fitInSquare(s.plan_aspect, NORM_W) } : null,
|
||||
rooms: s.rooms.map(scale),
|
||||
};
|
||||
});
|
||||
@@ -1006,7 +1028,7 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Curation + light groups + overrides + virtual devices. */
|
||||
/** Filtering + light groups + overrides + virtual devices. */
|
||||
private get _markers(): Marker[] {
|
||||
return this._serverCfg?.markers || [];
|
||||
}
|
||||
@@ -1071,8 +1093,7 @@ class HouseplanCard extends LitElement {
|
||||
if (saved) {
|
||||
if (this._norm) {
|
||||
if (saved.s === d.space) {
|
||||
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
} else if (saved.s === undefined) {
|
||||
return { x: saved.x, y: saved.y };
|
||||
@@ -1089,11 +1110,11 @@ class HouseplanCard extends LitElement {
|
||||
const g = this._gridPitch;
|
||||
const gx = Math.round(x / g) * g;
|
||||
const gy = Math.round(y / g) * g;
|
||||
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === d.space)?.aspect || 1;
|
||||
|
||||
const prevK = (this._layout[d.id] as any)?.k;
|
||||
this._layout = {
|
||||
...this._layout,
|
||||
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / (NORM_W / aspect), ...(prevK ? { k: prevK } : {}) },
|
||||
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / NORM_W, ...(prevK ? { k: prevK } : {}) },
|
||||
};
|
||||
} else {
|
||||
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
|
||||
@@ -1205,10 +1226,23 @@ class HouseplanCard extends LitElement {
|
||||
return this.renderRoot.querySelector('.stage') as HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rectangle "fit to screen" fits. With a background image that is the
|
||||
* whole canvas — the image IS the plan. Without one it is what has been
|
||||
* drawn, plus a small margin, so a single room on a big canvas fills the
|
||||
* screen instead of sitting in it as a speck.
|
||||
*/
|
||||
private _baseVb(): number[] {
|
||||
const m = this._spaceModel();
|
||||
if (m.bg) return m.vb;
|
||||
const b = contentBounds(m);
|
||||
return b ? [b.x, b.y, b.w, b.h] : m.vb;
|
||||
}
|
||||
|
||||
/** Aspect ratio of the scene (width/height, px). */
|
||||
private _stageAspect(): number {
|
||||
const s = this._stageEl;
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3];
|
||||
}
|
||||
|
||||
@@ -1220,7 +1254,7 @@ class HouseplanCard extends LitElement {
|
||||
/** Screen (sx,sy relative to the scene, px) → vb coordinates per the current view. */
|
||||
private _screenToVb(sx: number, sy: number): number[] {
|
||||
const s = this._stageEl;
|
||||
const v = this._viewOr(this._spaceModel().vb);
|
||||
const v = this._viewOr(this._baseVb());
|
||||
const w = s?.clientWidth || 1, h = s?.clientHeight || 1;
|
||||
return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h];
|
||||
}
|
||||
@@ -1240,7 +1274,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Set the zoom (centered on vb point cx,cy, or on the center of the current view). */
|
||||
private _applyView(zoom: number, cx?: number, cy?: number): void {
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
const z = Math.min(8, Math.max(1, zoom));
|
||||
const w = fit.w / z, h = fit.h / z;
|
||||
@@ -1263,7 +1297,7 @@ class HouseplanCard extends LitElement {
|
||||
private _zoomAt(sx: number, sy: number, newZoom: number): void {
|
||||
const stage = this._stageEl;
|
||||
if (!stage) return;
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
const z = Math.min(8, Math.max(1, newZoom));
|
||||
const w = stage.clientWidth, h = stage.clientHeight;
|
||||
@@ -1291,7 +1325,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _resetZoom(): void {
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
this._zoom = 1;
|
||||
this._view = fitView(vb, this._stageAspect());
|
||||
this._saveZoom();
|
||||
@@ -1314,7 +1348,7 @@ class HouseplanCard extends LitElement {
|
||||
this._view = null;
|
||||
requestAnimationFrame(() => {
|
||||
if (!this._stageEl) return;
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
this.requestUpdate();
|
||||
});
|
||||
@@ -1343,7 +1377,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
|
||||
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
|
||||
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||
const v = this._viewOr(this._spaceModel().vb);
|
||||
const v = this._viewOr(this._baseVb());
|
||||
if (this._pointers.size === 1) {
|
||||
this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y };
|
||||
this._suppressClick = false;
|
||||
@@ -1389,7 +1423,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._zoom > 1 && this._view) {
|
||||
const stage = this._stageEl!;
|
||||
const v = this._view;
|
||||
const fit = fitView(this._spaceModel().vb, this._stageAspect());
|
||||
const fit = fitView(this._baseVb(), this._stageAspect());
|
||||
this._view = this._clampView(
|
||||
{
|
||||
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
|
||||
@@ -1419,9 +1453,9 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
const target = swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
|
||||
if (target) {
|
||||
this._space = target;
|
||||
this._selId = null;
|
||||
this._restoreZoom();
|
||||
// the plan follows the finger: swiping left brings the next one in
|
||||
// from the right, so the current one leaves to the left
|
||||
this._slideTo(target, dx < 0 ? 'left' : 'right');
|
||||
this._saveNav();
|
||||
this._suppressClick = true;
|
||||
setTimeout(() => (this._suppressClick = false), 0);
|
||||
@@ -1476,7 +1510,7 @@ class HouseplanCard extends LitElement {
|
||||
if (!this._drag || this._drag.id !== d.id) return;
|
||||
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
|
||||
if (!stage) return;
|
||||
const vb = this._spaceModel().vb;
|
||||
const vb = this._baseVb();
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const v = this._viewOr(vb);
|
||||
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
|
||||
@@ -1567,7 +1601,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private get _spaceH(): number {
|
||||
const sp = this._curSpaceCfg;
|
||||
return sp ? NORM_W / sp.aspect : NORM_W;
|
||||
return NORM_W; // square canvas
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1827,7 +1861,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private get _decorH(): number {
|
||||
return NORM_W / (this._curSpaceCfg?.aspect || 1);
|
||||
return NORM_W;
|
||||
}
|
||||
|
||||
/** Begin a decor gesture. Returns true when the event is consumed (no pan). */
|
||||
@@ -2111,7 +2145,7 @@ class HouseplanCard extends LitElement {
|
||||
//
|
||||
// The key is the SPACE MODEL OBJECT ITSELF (HP-1454-04). It used to be a
|
||||
// string of room ids and open_to links, which said nothing about geometry:
|
||||
// change the space's aspect, or drag a vertex, and the shared segments were
|
||||
// change the plan, or drag a vertex, and the shared segments were
|
||||
// recomputed for the outlines but the open boundaries — and the glow cuts
|
||||
// that follow them — kept their old coordinates until a full reload.
|
||||
// `_model` is already rebuilt whenever the epoch or the config fingerprint
|
||||
@@ -2540,8 +2574,7 @@ class HouseplanCard extends LitElement {
|
||||
// so that icons do not get reshuffled when the order in the HA registry changes.
|
||||
let added = 0;
|
||||
if (boundArea) {
|
||||
const aspect = this._serverCfg?.spaces.find((x: any) => x.id === this._space)?.aspect || 1;
|
||||
const H2 = NORM_W / aspect;
|
||||
const H2 = NORM_W;
|
||||
const next = { ...this._layout };
|
||||
for (const d of this._devices) {
|
||||
if (d.area !== boundArea || d.space !== this._space) continue;
|
||||
@@ -2996,8 +3029,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _normPos(space: string, x: number, y: number): { s: string; x: number; y: number } {
|
||||
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === space)?.aspect || 1;
|
||||
return { s: space, x: x / NORM_W, y: y / (NORM_W / aspect) };
|
||||
return { s: space, x: x / NORM_W, y: y / NORM_W };
|
||||
}
|
||||
|
||||
// ================= SPACE MANAGEMENT =================
|
||||
@@ -3013,7 +3045,7 @@ class HouseplanCard extends LitElement {
|
||||
const disp = spaceDisplayOf(sp);
|
||||
this._spaceDialog = {
|
||||
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
|
||||
source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape',
|
||||
source: sp.plan_url ? 'file' : 'draw',
|
||||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||||
tempMin: disp.tempMin, tempMax: disp.tempMax,
|
||||
@@ -3027,7 +3059,7 @@ class HouseplanCard extends LitElement {
|
||||
} else {
|
||||
this._spaceDialog = {
|
||||
mode, title: '', planUrl: null, planFile: null,
|
||||
source: 'file', orientation: 'landscape',
|
||||
source: 'file',
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
@@ -3097,22 +3129,41 @@ class HouseplanCard extends LitElement {
|
||||
private _useServerPlan(url: string): void {
|
||||
const d = this._spaceDialog;
|
||||
if (!d) return;
|
||||
// the aspect ratio is read from the image itself, exactly as on upload
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cur = this._spaceDialog;
|
||||
if (!cur) return;
|
||||
const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1.414;
|
||||
this._spaceDialog = {
|
||||
...cur, planUrl: url, planFile: null, pickSaved: false,
|
||||
savedAspect: Number.isFinite(ratio) && ratio > 0 ? ratio : 1.414,
|
||||
};
|
||||
};
|
||||
img.onerror = () => {
|
||||
const cur = this._spaceDialog;
|
||||
if (cur) this._spaceDialog = { ...cur, planUrl: url, planFile: null, pickSaved: false, savedAspect: 1.414 };
|
||||
};
|
||||
img.src = this._display(url);
|
||||
// Attach immediately — the click should not wait for anything.
|
||||
this._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false };
|
||||
void this._readPlanAspect(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a stored plan's proportions from the image itself.
|
||||
*
|
||||
* The content endpoint needs a signature, and `_display()` deliberately
|
||||
* returns nothing until one arrives. Loading too early therefore failed and
|
||||
* an earlier version treated that as "unknown ratio" and saved a fallback of
|
||||
* 1.414 — a square plan came out stretched (HP-1470-03). So wait for the
|
||||
* signature, and bind the result to THIS dialog and THIS url, or a late
|
||||
* answer would reshape whatever the user opened next.
|
||||
*/
|
||||
private async _readPlanAspect(url: string): Promise<void> {
|
||||
for (let i = 0; i < 40; i++) { // ~6 s, then give up quietly
|
||||
const src = this._display(url);
|
||||
if (src) {
|
||||
const ratio = await new Promise<number>((res) => {
|
||||
const img = new Image();
|
||||
img.onload = () => res(img.naturalWidth && img.naturalHeight
|
||||
? img.naturalWidth / img.naturalHeight : 0);
|
||||
img.onerror = () => res(0);
|
||||
img.src = src;
|
||||
});
|
||||
const cur = this._spaceDialog;
|
||||
if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
|
||||
this._spaceDialog = { ...cur, savedAspect: ratio };
|
||||
}
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
if (this._spaceDialog?.planUrl !== url) return; // the user moved on
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteServerPlan(name: string): Promise<void> {
|
||||
@@ -3134,7 +3185,7 @@ class HouseplanCard extends LitElement {
|
||||
return html`<div class="savedplans">
|
||||
${list.map((p) => html`
|
||||
<div class="savedplan ${p.url === d.planUrl ? 'cur' : ''}">
|
||||
<img src=${this._display(p.url)} alt="" />
|
||||
<img src=${this._display(p.url)} alt="" loading="lazy" decoding="async" />
|
||||
<div class="savedmeta">
|
||||
<b>${p.name}</b>
|
||||
<span class="muted">${kb(p.size)}${p.used_by.length
|
||||
@@ -3143,8 +3194,9 @@ class HouseplanCard extends LitElement {
|
||||
</div>
|
||||
<button class="btn ghost" @click=${() => this._useServerPlan(p.url)}
|
||||
?disabled=${p.url === d.planUrl}>${this._t('btn.use')}</button>
|
||||
<button class="btn ghost danger" title=${p.used_by.length ? this._t('space.in_use') : this._t('btn.delete')}
|
||||
?disabled=${p.used_by.length > 0}
|
||||
<button class="btn ghost danger"
|
||||
title=${p.used_by.length || p.url === d.planUrl ? this._t('space.in_use') : this._t('btn.delete')}
|
||||
?disabled=${p.used_by.length > 0 || p.url === d.planUrl}
|
||||
@click=${() => this._deleteServerPlan(p.name)}>
|
||||
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
|
||||
</button>
|
||||
@@ -3162,7 +3214,6 @@ class HouseplanCard extends LitElement {
|
||||
const wasFirst = d.mode === 'create' && (this._serverCfg?.spaces.length || 0) === 0;
|
||||
this._spaceDialog = { ...d, busy: true };
|
||||
try {
|
||||
const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414;
|
||||
const spaceId = d.mode === 'create' ? 's' + Date.now().toString(36) : d.spaceId!;
|
||||
|
||||
/* Upload BEFORE touching the config, and never hold a reference to a
|
||||
@@ -3188,7 +3239,7 @@ class HouseplanCard extends LitElement {
|
||||
id: spaceId,
|
||||
title: d.title.trim(),
|
||||
plan_url: null,
|
||||
aspect: d.source === 'draw' ? drawAspect : 1.414,
|
||||
|
||||
view_box: [0, 0, 1, 1],
|
||||
rooms: [],
|
||||
};
|
||||
@@ -3200,15 +3251,16 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
if (uploaded) {
|
||||
sp.plan_url = uploaded.url;
|
||||
sp.aspect = uploaded.aspect;
|
||||
// the image's own proportions, so it can be centred before it loads
|
||||
sp.plan_aspect = uploaded.aspect;
|
||||
} else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) {
|
||||
// picked from the server list: no upload, just a reference
|
||||
sp.plan_url = d.planUrl;
|
||||
if (d.savedAspect) sp.aspect = d.savedAspect;
|
||||
if (d.savedAspect) sp.plan_aspect = d.savedAspect;
|
||||
}
|
||||
// switching an existing space to "draw" detaches its background image
|
||||
// (the uploaded file stays on disk; only the reference is cleared)
|
||||
if (d.source === 'draw') sp.plan_url = null;
|
||||
if (d.source === 'draw') { sp.plan_url = null; sp.plan_aspect = null; }
|
||||
// per-space display settings; hand-drawn spaces get borders+names on by default
|
||||
const draw = d.source === 'draw';
|
||||
sp.settings = {
|
||||
@@ -3320,7 +3372,7 @@ class HouseplanCard extends LitElement {
|
||||
if (title === undefined) return;
|
||||
this._spaceDialog = {
|
||||
mode: 'create', title, planUrl: null, planFile: null,
|
||||
source: 'file', orientation: 'landscape',
|
||||
source: 'file',
|
||||
showBorders: false, showNames: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
|
||||
@@ -3833,7 +3885,7 @@ class HouseplanCard extends LitElement {
|
||||
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
|
||||
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
|
||||
@pointercancel=${(e: PointerEvent) => this._stagePointerUp(e)}>
|
||||
<div class="zoomwrap">
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}">
|
||||
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
|
||||
${this._editing ? this._renderMarkupDefs(vb) : nothing}
|
||||
${this._editing && !this._markup
|
||||
@@ -4195,8 +4247,7 @@ class HouseplanCard extends LitElement {
|
||||
private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
|
||||
const saved = this._layout['rl_' + (r.id || '')];
|
||||
if (saved && saved.s === spaceId) {
|
||||
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
const c = this._roomCenter(r);
|
||||
return { x: c[0], y: c[1] };
|
||||
@@ -4272,10 +4323,10 @@ class HouseplanCard extends LitElement {
|
||||
const room = sp.rooms.find((x) => x.id === roomId);
|
||||
if (!room) return;
|
||||
const p = this._labelPos(room, rs.space);
|
||||
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === rs.space)?.aspect || 1;
|
||||
|
||||
this._layout = {
|
||||
...this._layout,
|
||||
[rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / (NORM_W / aspect), k },
|
||||
[rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / NORM_W, k },
|
||||
};
|
||||
} else {
|
||||
this._layout = { ...this._layout, [rs.id]: { ...rec, k } };
|
||||
@@ -5224,7 +5275,7 @@ class HouseplanCard extends LitElement {
|
||||
${d.planFile
|
||||
? html`<span class="planname">${d.planFile.name}</span>`
|
||||
: d.planUrl
|
||||
? html`<img class="planprev" src=${d.planUrl} alt=${this._t('space.plan_alt')} />`
|
||||
? html`<img class="planprev" src=${this._display(d.planUrl)} alt=${this._t('space.plan_alt')} />`
|
||||
: html`<span class="planname muted">${this._t('space.no_plan')}</span>`}
|
||||
<label class="btn filebtn">
|
||||
<ha-icon icon="mdi:upload"></ha-icon>${d.planUrl || d.planFile ? this._t('btn.replace') : this._t('btn.upload')}
|
||||
@@ -5243,15 +5294,6 @@ class HouseplanCard extends LitElement {
|
||||
@change=${() => (this._spaceDialog = { ...d, source: 'draw' })} />
|
||||
<span>${this._t('space.source_draw')}</span>
|
||||
</label>
|
||||
${d.source === 'draw' && d.mode === 'create'
|
||||
? html`<label>${this._t('space.orientation')}</label>
|
||||
<select class="areasel"
|
||||
@change=${(e: Event) => (this._spaceDialog = { ...d, orientation: (e.target as HTMLSelectElement).value as any })}>
|
||||
${[['landscape', 'orient.landscape'], ['portrait', 'orient.portrait'], ['square', 'orient.square']].map(
|
||||
([v, k]) => html`<option value=${v} ?selected=${d.orientation === v}>${this._t(k as any)}</option>`,
|
||||
)}
|
||||
</select>`
|
||||
: nothing}
|
||||
|
||||
<label>${this._t('space.scale_label')}</label>
|
||||
<div class="colorrow">
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@
|
||||
"title.zoom_out": "Zoom out",
|
||||
"title.zoom_reset": "Reset zoom",
|
||||
"title.add_device": "Add a device to the plan",
|
||||
"title.show_all": "Show all area devices (no curation)",
|
||||
"title.show_all": "Show all area devices (no filtering)",
|
||||
"title.markup": "Room markup: grid, lines, outlines",
|
||||
"title.configure_space": "Configure space",
|
||||
"title.add_space": "Add space",
|
||||
@@ -323,7 +323,7 @@
|
||||
"room.label_scale": "Metrics size",
|
||||
"preview.room_name": "Living room",
|
||||
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
|
||||
"room.settings_short": "Room",
|
||||
"room.settings_short": "Room settings",
|
||||
"room.unnamed": "Unnamed room",
|
||||
"marker.is_light": "This device is a light source",
|
||||
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@
|
||||
"title.zoom_out": "Отдалить",
|
||||
"title.zoom_reset": "Сбросить масштаб",
|
||||
"title.add_device": "Добавить устройство на план",
|
||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
||||
"title.show_all": "Показывать все устройства зоны (без фильтрации)",
|
||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||
"title.configure_space": "Настроить пространство",
|
||||
"title.add_space": "Добавить пространство",
|
||||
@@ -323,7 +323,7 @@
|
||||
"room.label_scale": "Размер подписей",
|
||||
"preview.room_name": "Гостиная",
|
||||
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
|
||||
"room.settings_short": "Комната",
|
||||
"room.settings_short": "Настройки комнаты",
|
||||
"room.unnamed": "Комната без имени",
|
||||
"marker.is_light": "Это устройство — источник света",
|
||||
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Device curation and icon rules.
|
||||
* Device filtering and icon rules.
|
||||
*
|
||||
* Icon rules are DATA, not code: the built-in defaults below can be overridden
|
||||
* per instance via `config.settings.icon_rules` (edited in the card UI).
|
||||
@@ -8,7 +8,7 @@
|
||||
* skipped silently at compile time (and flagged in the rules editor).
|
||||
*/
|
||||
|
||||
/** Integration domains whose devices are hidden by default (curation). */
|
||||
/** Integration domains whose devices are hidden by default (filtering). */
|
||||
export const EXCLUDED_DOMAINS = new Set([
|
||||
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
|
||||
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
|
||||
|
||||
+58
-9
@@ -6,16 +6,32 @@
|
||||
import { declump, contentUrl } from './logic';
|
||||
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
|
||||
|
||||
export const NORM_W = 1000; // width of the render space for normalized configs
|
||||
export const NORM_W = 1000; // side of the render space — the canvas is square
|
||||
|
||||
/**
|
||||
* Where a plan image sits inside the square canvas (v1.48.0).
|
||||
*
|
||||
* The canvas has no proportions of its own any more; the image keeps its own
|
||||
* and is centred, so a wide plan gets margins above and below and a tall one
|
||||
* gets them at the sides. `ratio` is the image's width/height; without it we
|
||||
* assume square, which is only ever a brief guess before the file loads.
|
||||
*/
|
||||
export function fitInSquare(ratio: number | null | undefined, side: number) {
|
||||
const r = Number(ratio);
|
||||
const a = Number.isFinite(r) && r > 0 ? r : 1;
|
||||
const w = a >= 1 ? side : side * a;
|
||||
const h = a >= 1 ? side / a : side;
|
||||
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
|
||||
}
|
||||
|
||||
export type Pt = { x: number; y: number };
|
||||
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
|
||||
|
||||
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
|
||||
/** Build render-space models (NORM_W × NORM_W) from a server config. */
|
||||
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
if (!cfg || !Array.isArray(cfg.spaces)) return [];
|
||||
return cfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
const H = NORM_W; // square canvas
|
||||
const scale = (r: any): RoomCfg => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -35,13 +51,48 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
|
||||
rooms: (s.rooms || []).map(scale),
|
||||
} as SpaceModel;
|
||||
});
|
||||
}
|
||||
|
||||
/** Bounding rectangle of a room (rect or polygon) in render units. */
|
||||
/**
|
||||
* What the plan actually occupies, padded by `pad` of the larger side.
|
||||
*
|
||||
* The canvas is a square big enough for any house, so a small hand-drawn plan
|
||||
* used to open as a speck in the middle of it. Zooming to the CONTENT instead
|
||||
* of the canvas is what people expect — but only when there is no background
|
||||
* image: with one, the image is the plan, and cropping to the rooms would hide
|
||||
* the parts of it nobody has outlined yet.
|
||||
*
|
||||
* Returns null when there is nothing drawn, so the caller keeps the full canvas.
|
||||
*/
|
||||
export function contentBounds(
|
||||
space: SpaceModel, pad = 0.05,
|
||||
): { x: number; y: number; w: number; h: number } | null {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const add = (x: number, y: number) => {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
};
|
||||
for (const r of space.rooms || []) {
|
||||
if (r.poly) for (const p of r.poly) add(p[0], p[1]);
|
||||
else if (r.x != null && r.y != null) {
|
||||
add(r.x, r.y);
|
||||
add(r.x + (r.w || 0), r.y + (r.h || 0));
|
||||
}
|
||||
}
|
||||
if (minX > maxX || minY > maxY) return null;
|
||||
const m = Math.max(maxX - minX, maxY - minY) * pad;
|
||||
const x = minX - m, y = minY - m;
|
||||
return { x, y, w: (maxX - minX) + m * 2, h: (maxY - minY) + m * 2 };
|
||||
}
|
||||
|
||||
/** Bounding rectangle of a room (rect or polygon) in render units. *//** Bounding rectangle of a room (rect or polygon) in render units. */
|
||||
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
|
||||
if (r.poly && r.poly.length) {
|
||||
const xs = r.poly.map((p) => p[0]);
|
||||
@@ -91,8 +142,7 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
|
||||
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
|
||||
const saved = layout[d.id];
|
||||
if (saved && saved.s === d.space) {
|
||||
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
if (defPos[d.id]) return defPos[d.id];
|
||||
const vb = model.vb;
|
||||
@@ -103,8 +153,7 @@ export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos:
|
||||
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
|
||||
const saved = layout['rl_' + (r.id || '')];
|
||||
if (saved && saved.s === spaceId) {
|
||||
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
|
||||
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
|
||||
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
|
||||
}
|
||||
const c = roomCenter(r);
|
||||
return { x: c[0], y: c[1] };
|
||||
|
||||
+17
-1
@@ -383,6 +383,21 @@ export const cardStyles = css`
|
||||
gap: 0.25em;
|
||||
font-size: calc(1em * var(--rl-name, 1));
|
||||
}
|
||||
/* Switching spaces by swipe or on the kiosk carousel: the plan flies out
|
||||
the way the finger went and the next one arrives from the other side. */
|
||||
@keyframes hp-slide-left {
|
||||
0% { transform: translateX(22%); opacity: 0; }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes hp-slide-right {
|
||||
0% { transform: translateX(-22%); opacity: 0; }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.zoomwrap.slide-left { animation: hp-slide-left 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
.zoomwrap.slide-right { animation: hp-slide-right 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.zoomwrap.slide-left, .zoomwrap.slide-right { animation: none; }
|
||||
}
|
||||
.rlgearbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -402,7 +417,8 @@ export const cardStyles = css`
|
||||
opacity: 0.92;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.rlgearbtn:hover { opacity: 1; }
|
||||
.rlgearbtn { transition: opacity 0.15s, filter 0.15s; }
|
||||
.rlgearbtn:hover { opacity: 1; filter: brightness(1.18); }
|
||||
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
|
||||
.rlgear {
|
||||
--mdc-icon-size: 0.9em;
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos,
|
||||
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos, fitInSquare, contentBounds,
|
||||
} from '../test-build/space-geometry.js';
|
||||
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'f1', title: '1st', aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
|
||||
id: 'f1', title: '1st', plan_aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'a1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }],
|
||||
}, {
|
||||
id: 'yard', title: 'Yard', aspect: 1, view_box: [0, 0, 1, 1], rooms: [],
|
||||
id: 'yard', title: 'Yard', view_box: [0, 0, 1, 1], rooms: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
|
||||
test('spaceModels: scales vb/rooms by NORM_W and H=NORM_W/aspect; bg only with plan_url', () => {
|
||||
test('spaceModels: the canvas is square; the image is centred by its own ratio', () => {
|
||||
const m = spaceModels(cfg);
|
||||
assert.equal(m.length, 2);
|
||||
const f1 = m[0];
|
||||
assert.deepEqual(f1.vb, [0, 0, 1000, 500]); // aspect 2 → H 500
|
||||
assert.deepEqual(f1.vb, [0, 0, 1000, 1000]);
|
||||
assert.equal(f1.bg.href, '/plans/f1.svg');
|
||||
assert.deepEqual(f1.rooms[0].poly, [[100, 50], [500, 50], [500, 250], [100, 250]]);
|
||||
// a plan twice as wide as it is tall: full width, half height, margins above
|
||||
// and below — the canvas has no proportions of its own any more (v1.48.0)
|
||||
assert.deepEqual(f1.bg, { href: '/plans/f1.svg', x: 0, y: 250, w: 1000, h: 500 });
|
||||
assert.deepEqual(f1.rooms[0].poly, [[100, 100], [500, 100], [500, 500], [100, 500]]);
|
||||
assert.equal(m[1].bg, null); // no plan_url
|
||||
assert.equal(spaceModels(null).length, 0);
|
||||
});
|
||||
|
||||
test('fitInSquare: wide gets top/bottom margins, tall gets side margins', () => {
|
||||
assert.deepEqual(fitInSquare(2, 1000), { x: 0, y: 250, w: 1000, h: 500 });
|
||||
assert.deepEqual(fitInSquare(0.5, 1000), { x: 250, y: 0, w: 500, h: 1000 });
|
||||
assert.deepEqual(fitInSquare(1, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
|
||||
// unknown ratio (image not loaded yet, old config): assume square
|
||||
for (const bad of [null, undefined, 0, -3, NaN, 'x']) {
|
||||
assert.deepEqual(fitInSquare(bad, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('roomBounds + roomCenter for a polygon', () => {
|
||||
const r = spaceModels(cfg)[0].rooms[0];
|
||||
assert.deepEqual(roomBounds(r), { x: 100, y: 50, w: 400, h: 200 });
|
||||
assert.deepEqual(roomCenter(r), [300, 150]);
|
||||
assert.deepEqual(roomBounds(r), { x: 100, y: 100, w: 400, h: 400 });
|
||||
assert.deepEqual(roomCenter(r), [300, 300]);
|
||||
});
|
||||
|
||||
test('markerPos: saved layout → default grid → space centre', () => {
|
||||
@@ -37,7 +50,7 @@ test('markerPos: saved layout → default grid → space centre', () => {
|
||||
// saved layout (normalized) → render units
|
||||
assert.deepEqual(
|
||||
markerPos(dev, { d1: { s: 'f1', x: 0.2, y: 0.3 } }, cfg, {}, model),
|
||||
{ x: 200, y: 150 }, // 0.2*1000, 0.3*(1000/2)
|
||||
{ x: 200, y: 300 }, // 0.2*1000, 0.3*1000
|
||||
);
|
||||
// default grid position (inside the room)
|
||||
const defPos = defaultPositions([dev], model, 2.5);
|
||||
@@ -46,14 +59,14 @@ test('markerPos: saved layout → default grid → space centre', () => {
|
||||
const b = roomBounds(model.rooms[0]);
|
||||
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
|
||||
// no layout, no defPos → space centre
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 250 });
|
||||
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 500 });
|
||||
});
|
||||
|
||||
test('labelPos: saved rl_<id> → render units; else room centre', () => {
|
||||
const model = spaceModels(cfg)[0];
|
||||
const r = model.rooms[0];
|
||||
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 200 });
|
||||
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 150 }); // room centre
|
||||
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 400 });
|
||||
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 300 }); // room centre
|
||||
});
|
||||
|
||||
test('defaultPositions: several devices in one room are spread (declumped, distinct)', () => {
|
||||
@@ -70,3 +83,26 @@ test('defaultPositions: several devices in one room are spread (declumped, disti
|
||||
});
|
||||
|
||||
test('NORM_W is 1000', () => assert.equal(NORM_W, 1000));
|
||||
|
||||
test('contentBounds: fits what is drawn, with a 5% margin', () => {
|
||||
const one = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
|
||||
}], markers: [] })[0];
|
||||
// 200x200 render units in the middle of a 1000x1000 canvas, +5% of 200 each side
|
||||
assert.deepEqual(contentBounds(one), { x: 390, y: 390, w: 220, h: 220 });
|
||||
|
||||
// rectangles count too, and the margin follows the LARGER side
|
||||
const rect = spaceModels({ spaces: [{
|
||||
id: 's', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r', x: 0.1, y: 0.4, w: 0.6, h: 0.1 }],
|
||||
}], markers: [] })[0];
|
||||
const b = contentBounds(rect);
|
||||
assert.equal(Math.round(b.w), 660); // 600 + 2 * 5% of 600
|
||||
assert.equal(Math.round(b.h), 160); // 100 + the same absolute margin
|
||||
assert.equal(Math.round(b.x), 70);
|
||||
|
||||
// nothing drawn → the caller keeps the whole canvas
|
||||
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
|
||||
assert.equal(contentBounds(empty), null);
|
||||
});
|
||||
|
||||
@@ -784,8 +784,22 @@ async def test_repair_issue_goes_when_its_space_does(
|
||||
client = await hass_ws_client(hass)
|
||||
registry = ir.async_get(hass)
|
||||
|
||||
gone = "/api/houseplan/content/plans/_/nosuchfile.png"
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": gone}]), 0))["result"]["rev"]
|
||||
# A reference can only go bad AFTER it is stored — config/set refuses a new
|
||||
# one that is already broken (HP-1470-02). So: attach a real plan, then let
|
||||
# the file disappear the way it does in life, from outside Home Assistant.
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
|
||||
url, name = await _upload(client, "r7", b"PLAN")
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), 0))["result"]["rev"]
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None, "the file is there"
|
||||
|
||||
await hass.async_add_executor_job(
|
||||
os.remove, os.path.join(hass.config.path(PLANS_DIR), name)
|
||||
)
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": url}]), rev))["result"]["rev"]
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is not None
|
||||
|
||||
@@ -940,6 +954,9 @@ async def test_startup_sweep_collects_what_no_commit_will(
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
files, _plans, p = _paths(hass)
|
||||
# the plan it names has to exist: config/set refuses a NEW broken
|
||||
# reference (HP-1470-02). The orphans still arrive after the save.
|
||||
await _seed_aged(hass, [p["kept_plan"]])
|
||||
assert (await _save(client, await _referenced_config(), 0))["success"]
|
||||
|
||||
await _seed_aged(hass, list(p.values()))
|
||||
@@ -967,6 +984,9 @@ async def test_periodic_sweep_collects_too(
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
_files, _plans, p = _paths(hass)
|
||||
# the plan it names has to exist: config/set refuses a NEW broken
|
||||
# reference (HP-1470-02). The orphans still arrive after the save.
|
||||
await _seed_aged(hass, [p["kept_plan"]])
|
||||
assert (await _save(client, await _referenced_config(), 0))["success"]
|
||||
|
||||
await _seed_aged(hass, list(p.values()))
|
||||
@@ -994,6 +1014,9 @@ async def test_sweep_and_a_config_write_do_not_race(
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
_files, plans, p = _paths(hass)
|
||||
# the plan it names has to exist: config/set refuses a NEW broken
|
||||
# reference (HP-1470-02). The orphans still arrive after the save.
|
||||
await _seed_aged(hass, [p["kept_plan"]])
|
||||
rev = (await _save(client, await _referenced_config(), 0))["result"]["rev"]
|
||||
|
||||
# an aged, currently unreferenced plan that the commit below adopts
|
||||
@@ -1161,3 +1184,59 @@ async def test_stored_plans_can_be_listed_and_deleted_on_request(
|
||||
)
|
||||
bad = await client.receive_json()
|
||||
assert not bad["success"] and bad["error"]["code"] == "invalid_name"
|
||||
|
||||
|
||||
async def test_config_set_refuses_a_plan_that_no_longer_exists(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1470-02: a stored internal plan url must name a file that exists.
|
||||
|
||||
The card can pick a plan and delete it from the same dialog, and two clients
|
||||
can do the same in either order. The lock serialises them; it says nothing
|
||||
about whether the file survived, so the check has to be here.
|
||||
"""
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
url, name = await _upload(client, "x1", b"PLAN", ext="png")
|
||||
await client.send_json_auto_id({"type": "houseplan/plans/delete", "name": name})
|
||||
assert (await client.receive_json())["result"]["removed"] is True
|
||||
|
||||
resp = await _save(client, await _cfg([{"id": "x1", "plan_url": url}]), 0)
|
||||
assert not resp["success"] and resp["error"]["code"] == "missing_plan"
|
||||
|
||||
# an external or legacy url is the user's business, not ours to verify
|
||||
assert (await _save(client, await _cfg([{"id": "x1", "plan_url": "/local/mine.png"}]), 0))["success"]
|
||||
|
||||
|
||||
async def test_uploads_are_bounded_by_a_store_quota(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1470-01: nothing is deleted for being old, so growth stops at the door."""
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.houseplan.const import PLANS_DIR
|
||||
from custom_components.houseplan.plans import dir_usage
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
# every test in this module shares one config directory, so the plans folder
|
||||
# is not empty here — budget two more from whatever is already stored
|
||||
stored, _b = await hass.async_add_executor_job(
|
||||
lambda: dir_usage(Path(hass.config.path(PLANS_DIR)))[::-1]
|
||||
)
|
||||
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", stored + 2)
|
||||
|
||||
await _upload(client, "q1", b"one")
|
||||
await _upload(client, "q1", b"two")
|
||||
|
||||
import base64
|
||||
|
||||
await client.send_json_auto_id({
|
||||
"type": "houseplan/plan/set", "space_id": "q1", "ext": "png",
|
||||
"data": base64.b64encode(b"three").decode(),
|
||||
})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "too_many_files"
|
||||
|
||||
@@ -90,11 +90,19 @@ def test_room_schema_poly_or_rect():
|
||||
v.ROOM_SCHEMA({"id": "r4", "name": "D", "poly": [[0, 0], [1, 1]]})
|
||||
|
||||
|
||||
def test_space_schema_aspect_range():
|
||||
ok = {"id": "f1", "title": "1", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
v.SPACE_SCHEMA(ok)
|
||||
def test_space_schema_drops_the_old_aspect_and_bounds_the_image_ratio():
|
||||
"""v1.48.0: the canvas is square; only the IMAGE keeps proportions.
|
||||
|
||||
A stale tab may still send `aspect`. It is dropped rather than trusted —
|
||||
the coordinates it arrives with were normalised against a different box, so
|
||||
honouring the field would not make them right anyway.
|
||||
"""
|
||||
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
|
||||
assert "aspect" not in v.SPACE_SCHEMA({**ok, "aspect": 1.4})
|
||||
v.SPACE_SCHEMA({**ok, "plan_aspect": 1.4})
|
||||
v.SPACE_SCHEMA({**ok, "plan_aspect": None})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA({**ok, "aspect": 0})
|
||||
v.SPACE_SCHEMA({**ok, "plan_aspect": 0})
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
|
||||
|
||||
@@ -666,3 +674,152 @@ def test_only_a_staging_folder_ages_out(tmp_path):
|
||||
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------- square canvas migration (v1.48.0) ----------
|
||||
|
||||
|
||||
gm = _load_pure("geometry_migration")
|
||||
|
||||
|
||||
def _sq(space, layout=None):
|
||||
cfg = {"spaces": [space]}
|
||||
gm.migrate_config(cfg, layout if layout is not None else {})
|
||||
return cfg["spaces"][0]
|
||||
|
||||
|
||||
def test_the_viewport_becomes_the_whole_square():
|
||||
"""The grid is drawn over the view box, and the fit fits it.
|
||||
|
||||
Transforming the old rectangle instead would leave the new margins outside
|
||||
the canvas — no grid there and nothing to draw on — which is the room the
|
||||
square canvas was meant to add.
|
||||
"""
|
||||
sp = _sq({"id": "f1", "aspect": 0.5, "view_box": [0.1, 0.2, 0.5, 0.5], "rooms": []})
|
||||
assert sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def test_a_wide_plan_gains_margins_above_and_below():
|
||||
sp = _sq({
|
||||
"id": "f1", "aspect": 2.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r", "x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}],
|
||||
})
|
||||
r = sp["rooms"][0]
|
||||
assert (r["x"], r["w"]) == (0.0, 1.0), "the width is untouched"
|
||||
assert r["y"] == 0.25 and r["h"] == 0.5, "half the height, centred"
|
||||
assert sp["cell_cm"] == 5, "the grid is tied to the width, which did not change"
|
||||
assert "aspect" not in sp
|
||||
|
||||
|
||||
def test_a_tall_plan_gains_margins_on_the_sides_and_rescales_the_grid():
|
||||
sp = _sq({
|
||||
"id": "f1", "aspect": 0.5, "cell_cm": 5, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r", "poly": [[0, 0], [1, 0], [1, 1], [0, 1]]}],
|
||||
})
|
||||
poly = sp["rooms"][0]["poly"]
|
||||
assert [round(c, 6) for c in poly[0]] == [0.25, 0.0]
|
||||
assert [round(c, 6) for c in poly[2]] == [0.75, 1.0], "half the width, centred"
|
||||
assert sp["cell_cm"] == 10, "the canvas got twice as wide, so a cell is twice the cm"
|
||||
|
||||
|
||||
def test_a_square_plan_is_left_alone():
|
||||
before = {
|
||||
"id": "f1", "aspect": 1.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
|
||||
"rooms": [{"id": "r", "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4}],
|
||||
}
|
||||
sp = _sq({**before, "rooms": [dict(before["rooms"][0])]})
|
||||
assert sp["rooms"][0] == before["rooms"][0]
|
||||
assert sp["cell_cm"] == 5 and sp["view_box"] == [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def test_migration_preserves_real_lengths_and_shapes():
|
||||
"""A wall keeps its length in centimetres, and a square stays square."""
|
||||
GRID = 1000.0
|
||||
|
||||
def wall_cm(space, p, q):
|
||||
# render units per normalised unit is the canvas width, always 1000
|
||||
dx = (q[0] - p[0]) * GRID
|
||||
dy = (q[1] - p[1]) * GRID
|
||||
pitch = GRID / 40 # whatever the grid is, the same constant both sides
|
||||
return ((dx * dx + dy * dy) ** 0.5 / pitch) * float(space["cell_cm"])
|
||||
|
||||
for aspect in (2.0, 0.5, 0.8155784250916674, 1.4142):
|
||||
# a square room, 0.2 x 0.2 of the OLD box, i.e. 200 x 200/aspect render
|
||||
old = {"id": "f", "aspect": aspect, "cell_cm": 5,
|
||||
"rooms": [{"id": "r", "poly": [[0.2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]]}]}
|
||||
before_w = 0.2 * GRID
|
||||
before_h = 0.2 * GRID / aspect
|
||||
before_cm_w = (before_w / (GRID / 40)) * 5
|
||||
sp = _sq(old)
|
||||
poly = sp["rooms"][0]["poly"]
|
||||
after_w = (poly[1][0] - poly[0][0]) * GRID
|
||||
after_h = (poly[2][1] - poly[1][1]) * GRID
|
||||
assert abs(after_w / after_h - before_w / before_h) < 1e-9, "shape preserved"
|
||||
# cell_cm is stored rounded — a user reads it — so allow 0.01 cm on a
|
||||
# 40 cm wall rather than pretending the scale is infinitely precise
|
||||
assert abs(wall_cm(sp, poly[0], poly[1]) - before_cm_w) < 1e-2, "length in cm preserved"
|
||||
|
||||
|
||||
def test_migration_moves_marker_positions_of_that_space_only():
|
||||
layout = {
|
||||
"a": {"s": "f1", "x": 0.5, "y": 0.5},
|
||||
"b": {"s": "other", "x": 0.5, "y": 0.5},
|
||||
"c": "not a dict",
|
||||
}
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []},
|
||||
{"id": "other", "rooms": []}]}
|
||||
assert gm.migrate_config(cfg, layout) is True
|
||||
assert layout["a"] == {"s": "f1", "x": 0.5, "y": 0.5}, "x untouched for a wide plan"
|
||||
assert layout["a"]["y"] == 0.5
|
||||
assert layout["b"] == {"s": "other", "x": 0.5, "y": 0.5}, "another space is not touched"
|
||||
|
||||
|
||||
def test_migration_runs_once_and_only_when_needed():
|
||||
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": [], "cell_cm": 5}]}
|
||||
assert gm.migrate_config(cfg, {}) is True
|
||||
snapshot = repr(cfg)
|
||||
assert gm.migrate_config(cfg, {}) is False, "already square: nothing to do"
|
||||
assert repr(cfg) == snapshot
|
||||
|
||||
|
||||
# ---------- store-wide limits (HP-1470-01) ----------
|
||||
|
||||
|
||||
def test_check_quota_counts_the_whole_store_not_one_request(tmp_path):
|
||||
"""Per-request caps say nothing about how many requests there are."""
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
for i in range(3):
|
||||
(d / f"p{i}.png").write_bytes(b"x" * 1000)
|
||||
|
||||
plans.check_quota(d, 1000, max_bytes=10_000, max_files=10) # fits
|
||||
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 8000, max_bytes=10_000, max_files=10)
|
||||
assert e.value.reason == "quota_exceeded" and "MB" in e.value.detail
|
||||
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 1, max_bytes=10_000, max_files=3)
|
||||
assert e.value.reason == "too_many_files"
|
||||
|
||||
|
||||
def test_dir_usage_walks_subfolders_and_ignores_the_unreadable(tmp_path):
|
||||
d = tmp_path / "files"
|
||||
(d / "m1").mkdir(parents=True)
|
||||
(d / "m1" / "a.pdf").write_bytes(b"x" * 10)
|
||||
(d / "b.pdf").write_bytes(b"x" * 5)
|
||||
assert plans.dir_usage(d) == (15, 2)
|
||||
assert plans.dir_usage(tmp_path / "nope") == (0, 0)
|
||||
|
||||
|
||||
def test_check_quota_refuses_when_the_disk_is_nearly_full(tmp_path, monkeypatch):
|
||||
import shutil
|
||||
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
monkeypatch.setattr(
|
||||
shutil, "disk_usage", lambda _p: type("U", (), {"free": const.MIN_FREE_BYTES // 2})()
|
||||
)
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 1, max_bytes=10 ** 12, max_files=10 ** 6)
|
||||
assert e.value.reason == "low_disk_space"
|
||||
|
||||
Reference in New Issue
Block a user