mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
v1.49.0: content-fit zoom, swipe animation, wording, and the v1.47.0 review
Owner's batch: - zoom now opens on what is DRAWN (rooms + 5% margin) for spaces with no background image; with one the image is the plan and still fits whole. A small plan on the square canvas no longer opens as a speck. - swiping between spaces, and the kiosk carousel, slide sideways; honours prefers-reduced-motion. - the room settings button reads 'Room settings' and lightens on hover. - 'curation' is filtering everywhere: UI strings, docs, code. Checked the yard while I was there: its drawing sits off-centre because it was drawn that way — before the migration x spanned 0.12..0.54 with 0.12 and 0.46 of margin. The migration added 0.1465 on each side, symmetrically. Content-fit zoom makes it moot anyway. From the v1.47.0 review: - HP-1470-02: the picker let you delete the plan you had just selected — it is not in the stored config yet, so the server rightly called it free, and the save then stored a url with no file. The button is disabled, and since two clients can do this in either order, config/set now verifies every internal plan url against the disk under the write lock and answers . External and legacy urls are not ours to police. - HP-1470-01: growth is bounded at the door rather than by deleting old files — that mistake cost real plans twice. check_quota refuses an upload that would push the store past 256 MB / 200 plans (1 GB / 1000 attachments) or leave less than 512 MB free. The plan list is capped at 60 newest with a total, and thumbnails load lazily. - HP-1470-03: picking a saved plan waited for nothing and stored a fallback ratio when the signature had not arrived — a square plan came out stretched. It waits for the signature, binds the result to the dialog that asked, and the dialog preview is signed too. - report §5: the last lifecycle comments still described age-based collection. Not released yet — the owner asked for a release once the batch is done.
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.
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ CONTENT_URL = "/api/houseplan/content"
|
|||||||
# way to tell which paths were dropped (review R2-2).
|
# way to tell which paths were dropped (review R2-2).
|
||||||
MAX_SIGN_PATHS = 200
|
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
|
# 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
|
# 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
|
# 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
|
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.48.0"
|
VERSION = "1.49.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,9 +20,12 @@ except ImportError: # older HA versions
|
|||||||
KEY_HASS = "hass" # type: ignore[assignment]
|
KEY_HASS = "hass" # type: ignore[assignment]
|
||||||
from homeassistant.core import HomeAssistant
|
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 .auth import may_write
|
||||||
from .plans import TMP_PREFIX, reserve_filename
|
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||||
from .validation import (
|
from .validation import (
|
||||||
FILE_EXTENSIONS,
|
FILE_EXTENSIONS,
|
||||||
MAX_FILE_BYTES,
|
MAX_FILE_BYTES,
|
||||||
@@ -204,6 +207,16 @@ class HouseplanUploadView(HomeAssistantView):
|
|||||||
return web.json_response({"error": "no_file"}, status=400)
|
return web.json_response({"error": "no_file"}, status=400)
|
||||||
|
|
||||||
tmp_path = temps[0]
|
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
|
target_dir = files_root / marker_id
|
||||||
safe_name = filename
|
safe_name = filename
|
||||||
|
|
||||||
|
|||||||
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.48.0"
|
"version": "1.49.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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
|
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -189,6 +189,56 @@ def collect_attachments(
|
|||||||
return removed
|
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:
|
def plan_basename(url: Any) -> str:
|
||||||
"""File name a stored plan_url points at ('' when there is none)."""
|
"""File name a stored plan_url points at ('' when there is none)."""
|
||||||
if not isinstance(url, str) or not url:
|
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
|
* a file the OLD configuration referenced and the new one does not was
|
||||||
authoritative and has been superseded — remove it;
|
authoritative and has been superseded — remove it;
|
||||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
* 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.
|
belong to a transaction that has not committed yet.
|
||||||
|
|
||||||
Never raises: the configuration is already stored by the time this runs, so
|
Never raises: the configuration is already stored by the time this runs, so
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
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 .auth import may_write
|
||||||
from .plans import (
|
from .plans import (
|
||||||
collect_attachments, collect_plans, is_plan_file, plan_basename, plan_refs,
|
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||||
reserve_filename,
|
plan_basename, plan_refs, reserve_filename,
|
||||||
)
|
)
|
||||||
from .store import HouseplanData, get_data, get_entry
|
from .store import HouseplanData, get_data, get_entry
|
||||||
from .validation import (
|
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"])
|
out.sort(key=lambda x: -x["modified"])
|
||||||
return out
|
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(
|
@websocket_api.websocket_command(
|
||||||
@@ -470,6 +477,26 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_internal_plans(plans_dir: Path, config: dict[str, Any]) -> set[str]:
|
||||||
|
"""Plan files a configuration names that are not on disk.
|
||||||
|
|
||||||
|
Only OUR urls are checked — `/api/houseplan/content/plans/_/<name>` and the
|
||||||
|
legacy static path. 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 and not (plans_dir / name).is_file():
|
||||||
|
out.add(name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "houseplan/config/set",
|
vol.Required("type"): "houseplan/config/set",
|
||||||
@@ -519,6 +546,20 @@ 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']})",
|
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||||
)
|
)
|
||||||
return
|
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"]
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "missing_plan",
|
||||||
|
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||||
|
)
|
||||||
|
return
|
||||||
new_rev = current_rev + 1
|
new_rev = current_rev + 1
|
||||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
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
|
# Still holding the lock: the file system is not part of the store's
|
||||||
@@ -589,6 +630,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
|
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||||
# files of a differently named space.
|
# files of a differently named space.
|
||||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
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']}"
|
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||||
path = plans_dir / name
|
path = plans_dir / name
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,16 @@ const res = await page.evaluate(async () => {
|
|||||||
out.listClosed = !c._spaceDialog.pickSaved;
|
out.listClosed = !c._spaceDialog.pickSaved;
|
||||||
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||||
|
|
||||||
// удаление: занятый нельзя, свободный можно
|
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
|
||||||
|
// на несуществующий файл (HP-1470-02)
|
||||||
await c._toggleServerPlans();
|
await c._toggleServerPlans();
|
||||||
await new Promise((r) => setTimeout(r, 60));
|
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(() => {});
|
await c._deleteServerPlan('f2.bbb.png').catch(() => {});
|
||||||
out.usedNotDeleted = !deleted.includes('f2.bbb.png');
|
out.usedNotDeleted = !deleted.includes('f2.bbb.png');
|
||||||
await c._deleteServerPlan('f1.aaa.png');
|
await c._deleteServerPlan('f1.aaa.png');
|
||||||
@@ -78,6 +85,7 @@ checkAll(res, {
|
|||||||
picked: true,
|
picked: true,
|
||||||
listClosed: true,
|
listClosed: true,
|
||||||
saveEnabledAfterPick: true,
|
saveEnabledAfterPick: true,
|
||||||
|
deleteDisabledForPicked: true,
|
||||||
usedNotDeleted: true,
|
usedNotDeleted: true,
|
||||||
freeDeleted: true,
|
freeDeleted: true,
|
||||||
rowGone: true,
|
rowGone: true,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+38
-21
File diff suppressed because one or more lines are too long
+14
-5
@@ -10,7 +10,7 @@ houseplan-card/
|
|||||||
├─ src/ # card sources (TypeScript + Lit 3)
|
├─ src/ # card sources (TypeScript + Lit 3)
|
||||||
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
|
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
|
||||||
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
|
│ ├─ 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/
|
│ └─ data/
|
||||||
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
|
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
|
||||||
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
|
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
|
||||||
@@ -266,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
|
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
|
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
|
write lock, from the pair of configurations that bracket the commit
|
||||||
(`plans.collect_plans`): superseded files go immediately, other unreferenced
|
(`plans.collect_plans`): a file the commit REPLACED goes immediately, and
|
||||||
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
|
nothing else goes at all — see the table above; only a per-dialog staging folder
|
||||||
to a transaction still in flight. The `.` between id and token is load-bearing —
|
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
|
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.
|
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`
|
**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
|
in `src/signing.ts` is the single implementation, used by both cards; the
|
||||||
duplicate inside houseplan-space-card signed correctly and never handed the
|
duplicate inside houseplan-space-card signed correctly and never handed the
|
||||||
@@ -312,7 +321,7 @@ Shared, framework-light modules keep the two views from diverging:
|
|||||||
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
|
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
|
||||||
mirrors the full card's private geometry.
|
mirrors the full card's private geometry.
|
||||||
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
|
- `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
|
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
|
||||||
(the space-card imports `cardStyles`) for visual parity.
|
(the space-card imports `cardStyles`) for visual parity.
|
||||||
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
|
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# 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)
|
## 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 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
|
a plan image keeps its own shape and is centred inside it, so a wide plan gets
|
||||||
|
|||||||
@@ -6,6 +6,42 @@
|
|||||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
> изменением — как и остальная документация (см. 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.48.0 — 2026-07-28 (холст всегда квадратный)
|
||||||
- **У пространства больше нет собственных пропорций.** Область рисования —
|
- **У пространства больше нет собственных пропорций.** Область рисования —
|
||||||
квадрат, а картинка плана сохраняет свою форму и вписывается в него по
|
квадрат, а картинка плана сохраняет свою форму и вписывается в него по
|
||||||
|
|||||||
+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** |
|
| 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** |
|
| 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** |
|
| 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** |
|
| 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** |
|
| 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) |
|
| 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 |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.48.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) |
|
| 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) |
|
| 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 |
|
| 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) |
|
| 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.48.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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
+16
-2
@@ -66,7 +66,7 @@
|
|||||||
than a quarter of the plan outside the viewBox [auto: smoke_decor]
|
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
|
- [ ] 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
|
room card, the tooltip and the temperature fill; fridges/TRVs still do
|
||||||
not; an explicit per-room source still wins [auto: unit devices.test]
|
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
|
- [ ] 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 ★
|
## Devices on the plan ★
|
||||||
|
|
||||||
- [ ] Auto devices appear only in rooms bound to their area [manual]
|
- [ ] 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]
|
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
|
||||||
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [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
|
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
|
||||||
@@ -239,6 +239,20 @@ 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
|
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
|
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
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
|
- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan
|
||||||
looks exactly as before, just with margins where the canvas was extended.
|
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
|
Measure a wall in the plan editor — the length in cm is unchanged. Marker
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.48.0",
|
"version": "1.49.0",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"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.
|
* markers (overrides/virtual). No Lit/DOM — only the hass object.
|
||||||
*/
|
*/
|
||||||
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
|
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;
|
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[] {
|
export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||||
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
|
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
|
||||||
const groupLights = settings.group_lights !== false;
|
const groupLights = settings.group_lights !== false;
|
||||||
@@ -210,7 +210,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
|||||||
if (marker && marker.hidden) continue;
|
if (marker && marker.hidden) continue;
|
||||||
const entIds = entsBy[dev.id] || [];
|
const entIds = entsBy[dev.id] || [];
|
||||||
const dom = domainOfDevice(h, dev, entIds);
|
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 (!showAll) {
|
||||||
if (excluded.has(dom)) continue;
|
if (excluded.has(dom)) continue;
|
||||||
if (dev.model === 'Group') continue;
|
if (dev.model === 'Group') continue;
|
||||||
@@ -391,7 +391,7 @@ export function areaHum(
|
|||||||
const vals: number[] = [];
|
const vals: number[] = [];
|
||||||
for (const dv of devices) {
|
for (const dv of devices) {
|
||||||
if (dv.area !== area) continue;
|
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;
|
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
|
||||||
const h = humFor(hass, dv.entities);
|
const h = humFor(hass, dv.entities);
|
||||||
if (h != null) vals.push(h);
|
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
|
* 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
|
* the visible-icon list, so hiding a thermometer silently removed it from the
|
||||||
* room card (field report, 2026-07-27).
|
* 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.
|
* 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
|
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
||||||
* a device measures.
|
* a device measures.
|
||||||
@@ -451,7 +451,7 @@ export function areaClimateMap(
|
|||||||
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
||||||
// question, 2026-07-27). Three guards, cheapest first:
|
// question, 2026-07-27). Three guards, cheapest first:
|
||||||
if (reg.entity_category) continue; // diagnostic/config readings
|
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/...
|
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||||
let groups = byArea.get(area);
|
let groups = byArea.get(area);
|
||||||
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
||||||
|
|||||||
+91
-36
@@ -33,10 +33,10 @@ import type {
|
|||||||
import './editor';
|
import './editor';
|
||||||
import './space-card';
|
import './space-card';
|
||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { fitInSquare } from './space-geometry';
|
import { fitInSquare, contentBounds } from './space-geometry';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
import { langOf, t, type I18nKey } from './i18n';
|
||||||
|
|
||||||
const CARD_VERSION = '1.48.0';
|
const CARD_VERSION = '1.49.0';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
@@ -164,13 +164,34 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Kiosk auto-carousel: advance to the next space every `cycle` seconds. */
|
/** 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 {
|
private _cycleTick(): void {
|
||||||
if (!this._kiosk || !(Number(this._config?.cycle) > 0)) return;
|
if (!this._kiosk || !(Number(this._config?.cycle) > 0)) return;
|
||||||
if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) {
|
if (Date.now() >= this._cyclePausedUntil && this._model.length > 1 && this._zoom <= 1.001) {
|
||||||
const ids = this._model.map((m) => m.id);
|
const ids = this._model.map((m) => m.id);
|
||||||
const i = ids.indexOf(this._space);
|
const i = ids.indexOf(this._space);
|
||||||
this._space = ids[(i + 1) % ids.length];
|
this._slideTo(ids[(i + 1) % ids.length], 'left');
|
||||||
this._restoreZoom();
|
|
||||||
this._showKioskDots();
|
this._showKioskDots();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,6 +425,7 @@ class HouseplanCard extends LitElement {
|
|||||||
clearTimeout(this._reloadRetry);
|
clearTimeout(this._reloadRetry);
|
||||||
this._signer.dispose();
|
this._signer.dispose();
|
||||||
clearTimeout(this._toastTimer);
|
clearTimeout(this._toastTimer);
|
||||||
|
clearTimeout(this._slideTimer);
|
||||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||||
window.removeEventListener('hashchange', this._onHashChange);
|
window.removeEventListener('hashchange', this._onHashChange);
|
||||||
clearTimeout(this._holdTimer);
|
clearTimeout(this._holdTimer);
|
||||||
@@ -1006,7 +1028,7 @@ class HouseplanCard extends LitElement {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Curation + light groups + overrides + virtual devices. */
|
/** Filtering + light groups + overrides + virtual devices. */
|
||||||
private get _markers(): Marker[] {
|
private get _markers(): Marker[] {
|
||||||
return this._serverCfg?.markers || [];
|
return this._serverCfg?.markers || [];
|
||||||
}
|
}
|
||||||
@@ -1204,10 +1226,23 @@ class HouseplanCard extends LitElement {
|
|||||||
return this.renderRoot.querySelector('.stage') as HTMLElement | null;
|
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). */
|
/** Aspect ratio of the scene (width/height, px). */
|
||||||
private _stageAspect(): number {
|
private _stageAspect(): number {
|
||||||
const s = this._stageEl;
|
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];
|
return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1219,7 +1254,7 @@ class HouseplanCard extends LitElement {
|
|||||||
/** Screen (sx,sy relative to the scene, px) → vb coordinates per the current view. */
|
/** Screen (sx,sy relative to the scene, px) → vb coordinates per the current view. */
|
||||||
private _screenToVb(sx: number, sy: number): number[] {
|
private _screenToVb(sx: number, sy: number): number[] {
|
||||||
const s = this._stageEl;
|
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;
|
const w = s?.clientWidth || 1, h = s?.clientHeight || 1;
|
||||||
return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h];
|
return [v.x + (sx / w) * v.w, v.y + (sy / h) * v.h];
|
||||||
}
|
}
|
||||||
@@ -1239,7 +1274,7 @@ class HouseplanCard extends LitElement {
|
|||||||
|
|
||||||
/** Set the zoom (centered on vb point cx,cy, or on the center of the current view). */
|
/** 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 {
|
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 fit = fitView(vb, this._stageAspect());
|
||||||
const z = Math.min(8, Math.max(1, zoom));
|
const z = Math.min(8, Math.max(1, zoom));
|
||||||
const w = fit.w / z, h = fit.h / z;
|
const w = fit.w / z, h = fit.h / z;
|
||||||
@@ -1262,7 +1297,7 @@ class HouseplanCard extends LitElement {
|
|||||||
private _zoomAt(sx: number, sy: number, newZoom: number): void {
|
private _zoomAt(sx: number, sy: number, newZoom: number): void {
|
||||||
const stage = this._stageEl;
|
const stage = this._stageEl;
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
const vb = this._spaceModel().vb;
|
const vb = this._baseVb();
|
||||||
const fit = fitView(vb, this._stageAspect());
|
const fit = fitView(vb, this._stageAspect());
|
||||||
const z = Math.min(8, Math.max(1, newZoom));
|
const z = Math.min(8, Math.max(1, newZoom));
|
||||||
const w = stage.clientWidth, h = stage.clientHeight;
|
const w = stage.clientWidth, h = stage.clientHeight;
|
||||||
@@ -1290,7 +1325,7 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _resetZoom(): void {
|
private _resetZoom(): void {
|
||||||
const vb = this._spaceModel().vb;
|
const vb = this._baseVb();
|
||||||
this._zoom = 1;
|
this._zoom = 1;
|
||||||
this._view = fitView(vb, this._stageAspect());
|
this._view = fitView(vb, this._stageAspect());
|
||||||
this._saveZoom();
|
this._saveZoom();
|
||||||
@@ -1313,7 +1348,7 @@ class HouseplanCard extends LitElement {
|
|||||||
this._view = null;
|
this._view = null;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!this._stageEl) return;
|
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._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
});
|
});
|
||||||
@@ -1342,7 +1377,7 @@ class HouseplanCard extends LitElement {
|
|||||||
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
|
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
|
||||||
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
|
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
|
||||||
this._pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
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) {
|
if (this._pointers.size === 1) {
|
||||||
this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y };
|
this._panStart = { sx: ev.clientX, sy: ev.clientY, vx: v.x, vy: v.y };
|
||||||
this._suppressClick = false;
|
this._suppressClick = false;
|
||||||
@@ -1388,7 +1423,7 @@ class HouseplanCard extends LitElement {
|
|||||||
if (this._zoom > 1 && this._view) {
|
if (this._zoom > 1 && this._view) {
|
||||||
const stage = this._stageEl!;
|
const stage = this._stageEl!;
|
||||||
const v = this._view;
|
const v = this._view;
|
||||||
const fit = fitView(this._spaceModel().vb, this._stageAspect());
|
const fit = fitView(this._baseVb(), this._stageAspect());
|
||||||
this._view = this._clampView(
|
this._view = this._clampView(
|
||||||
{
|
{
|
||||||
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
|
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
|
||||||
@@ -1418,9 +1453,9 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
const target = swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
|
const target = swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
|
||||||
if (target) {
|
if (target) {
|
||||||
this._space = target;
|
// the plan follows the finger: swiping left brings the next one in
|
||||||
this._selId = null;
|
// from the right, so the current one leaves to the left
|
||||||
this._restoreZoom();
|
this._slideTo(target, dx < 0 ? 'left' : 'right');
|
||||||
this._saveNav();
|
this._saveNav();
|
||||||
this._suppressClick = true;
|
this._suppressClick = true;
|
||||||
setTimeout(() => (this._suppressClick = false), 0);
|
setTimeout(() => (this._suppressClick = false), 0);
|
||||||
@@ -1475,7 +1510,7 @@ class HouseplanCard extends LitElement {
|
|||||||
if (!this._drag || this._drag.id !== d.id) return;
|
if (!this._drag || this._drag.id !== d.id) return;
|
||||||
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
|
const stage = this.renderRoot.querySelector('.stage') as HTMLElement;
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
const vb = this._spaceModel().vb;
|
const vb = this._baseVb();
|
||||||
const rect = stage.getBoundingClientRect();
|
const rect = stage.getBoundingClientRect();
|
||||||
const v = this._viewOr(vb);
|
const v = this._viewOr(vb);
|
||||||
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
|
const dx = ((ev.clientX - this._drag.sx) / rect.width) * v.w;
|
||||||
@@ -3094,22 +3129,41 @@ class HouseplanCard extends LitElement {
|
|||||||
private _useServerPlan(url: string): void {
|
private _useServerPlan(url: string): void {
|
||||||
const d = this._spaceDialog;
|
const d = this._spaceDialog;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
// the aspect ratio is read from the image itself, exactly as on upload
|
// 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();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => res(img.naturalWidth && img.naturalHeight
|
||||||
|
? img.naturalWidth / img.naturalHeight : 0);
|
||||||
|
img.onerror = () => res(0);
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
const cur = this._spaceDialog;
|
const cur = this._spaceDialog;
|
||||||
if (!cur) return;
|
if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
|
||||||
const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1.414;
|
this._spaceDialog = { ...cur, savedAspect: ratio };
|
||||||
this._spaceDialog = {
|
}
|
||||||
...cur, planUrl: url, planFile: null, pickSaved: false,
|
return;
|
||||||
savedAspect: Number.isFinite(ratio) && ratio > 0 ? ratio : 1.414,
|
}
|
||||||
};
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
};
|
if (this._spaceDialog?.planUrl !== url) return; // the user moved on
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _deleteServerPlan(name: string): Promise<void> {
|
private async _deleteServerPlan(name: string): Promise<void> {
|
||||||
@@ -3131,7 +3185,7 @@ class HouseplanCard extends LitElement {
|
|||||||
return html`<div class="savedplans">
|
return html`<div class="savedplans">
|
||||||
${list.map((p) => html`
|
${list.map((p) => html`
|
||||||
<div class="savedplan ${p.url === d.planUrl ? 'cur' : ''}">
|
<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">
|
<div class="savedmeta">
|
||||||
<b>${p.name}</b>
|
<b>${p.name}</b>
|
||||||
<span class="muted">${kb(p.size)}${p.used_by.length
|
<span class="muted">${kb(p.size)}${p.used_by.length
|
||||||
@@ -3140,8 +3194,9 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn ghost" @click=${() => this._useServerPlan(p.url)}
|
<button class="btn ghost" @click=${() => this._useServerPlan(p.url)}
|
||||||
?disabled=${p.url === d.planUrl}>${this._t('btn.use')}</button>
|
?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')}
|
<button class="btn ghost danger"
|
||||||
?disabled=${p.used_by.length > 0}
|
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)}>
|
@click=${() => this._deleteServerPlan(p.name)}>
|
||||||
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
|
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
|
||||||
</button>
|
</button>
|
||||||
@@ -3830,7 +3885,7 @@ class HouseplanCard extends LitElement {
|
|||||||
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
|
@pointermove=${(e: PointerEvent) => this._stagePointerMove(e)}
|
||||||
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
|
@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
|
||||||
@pointercancel=${(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">
|
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
|
||||||
${this._editing ? this._renderMarkupDefs(vb) : nothing}
|
${this._editing ? this._renderMarkupDefs(vb) : nothing}
|
||||||
${this._editing && !this._markup
|
${this._editing && !this._markup
|
||||||
@@ -5220,7 +5275,7 @@ class HouseplanCard extends LitElement {
|
|||||||
${d.planFile
|
${d.planFile
|
||||||
? html`<span class="planname">${d.planFile.name}</span>`
|
? html`<span class="planname">${d.planFile.name}</span>`
|
||||||
: d.planUrl
|
: 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>`}
|
: html`<span class="planname muted">${this._t('space.no_plan')}</span>`}
|
||||||
<label class="btn filebtn">
|
<label class="btn filebtn">
|
||||||
<ha-icon icon="mdi:upload"></ha-icon>${d.planUrl || d.planFile ? this._t('btn.replace') : this._t('btn.upload')}
|
<ha-icon icon="mdi:upload"></ha-icon>${d.planUrl || d.planFile ? this._t('btn.replace') : this._t('btn.upload')}
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@
|
|||||||
"title.zoom_out": "Zoom out",
|
"title.zoom_out": "Zoom out",
|
||||||
"title.zoom_reset": "Reset zoom",
|
"title.zoom_reset": "Reset zoom",
|
||||||
"title.add_device": "Add a device to the plan",
|
"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.markup": "Room markup: grid, lines, outlines",
|
||||||
"title.configure_space": "Configure space",
|
"title.configure_space": "Configure space",
|
||||||
"title.add_space": "Add space",
|
"title.add_space": "Add space",
|
||||||
@@ -323,7 +323,7 @@
|
|||||||
"room.label_scale": "Metrics size",
|
"room.label_scale": "Metrics size",
|
||||||
"preview.room_name": "Living room",
|
"preview.room_name": "Living room",
|
||||||
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
|
"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",
|
"room.unnamed": "Unnamed room",
|
||||||
"marker.is_light": "This device is a light source",
|
"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).",
|
"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_out": "Отдалить",
|
||||||
"title.zoom_reset": "Сбросить масштаб",
|
"title.zoom_reset": "Сбросить масштаб",
|
||||||
"title.add_device": "Добавить устройство на план",
|
"title.add_device": "Добавить устройство на план",
|
||||||
"title.show_all": "Показывать все устройства зоны (без курирования)",
|
"title.show_all": "Показывать все устройства зоны (без фильтрации)",
|
||||||
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
"title.markup": "Разметка комнат: сетка, линии, контуры",
|
||||||
"title.configure_space": "Настроить пространство",
|
"title.configure_space": "Настроить пространство",
|
||||||
"title.add_space": "Добавить пространство",
|
"title.add_space": "Добавить пространство",
|
||||||
@@ -323,7 +323,7 @@
|
|||||||
"room.label_scale": "Размер подписей",
|
"room.label_scale": "Размер подписей",
|
||||||
"preview.room_name": "Гостиная",
|
"preview.room_name": "Гостиная",
|
||||||
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
|
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
|
||||||
"room.settings_short": "Комната",
|
"room.settings_short": "Настройки комнаты",
|
||||||
"room.unnamed": "Комната без имени",
|
"room.unnamed": "Комната без имени",
|
||||||
"marker.is_light": "Это устройство — источник света",
|
"marker.is_light": "Это устройство — источник света",
|
||||||
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без 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
|
* 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).
|
* 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).
|
* 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([
|
export const EXCLUDED_DOMAINS = new Set([
|
||||||
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
|
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
|
||||||
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
|
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
|
||||||
|
|||||||
+36
-1
@@ -57,7 +57,42 @@ export function spaceModels(cfg: ServerConfig | null): 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 } {
|
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
|
||||||
if (r.poly && r.poly.length) {
|
if (r.poly && r.poly.length) {
|
||||||
const xs = r.poly.map((p) => p[0]);
|
const xs = r.poly.map((p) => p[0]);
|
||||||
|
|||||||
+17
-1
@@ -383,6 +383,21 @@ export const cardStyles = css`
|
|||||||
gap: 0.25em;
|
gap: 0.25em;
|
||||||
font-size: calc(1em * var(--rl-name, 1));
|
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 {
|
.rlgearbtn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -402,7 +417,8 @@ export const cardStyles = css`
|
|||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
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; }
|
.rlgearbtn ha-icon { --mdc-icon-size: 14px; display: inline-flex; }
|
||||||
.rlgear {
|
.rlgear {
|
||||||
--mdc-icon-size: 0.9em;
|
--mdc-icon-size: 0.9em;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos, fitInSquare,
|
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos, fitInSquare, contentBounds,
|
||||||
} from '../test-build/space-geometry.js';
|
} from '../test-build/space-geometry.js';
|
||||||
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
@@ -83,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('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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1161,3 +1161,49 @@ async def test_stored_plans_can_be_listed_and_deleted_on_request(
|
|||||||
)
|
)
|
||||||
bad = await client.receive_json()
|
bad = await client.receive_json()
|
||||||
assert not bad["success"] and bad["error"]["code"] == "invalid_name"
|
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
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
monkeypatch.setattr(wsapi, "MAX_PLANS_FILES", 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"
|
||||||
|
|||||||
@@ -780,3 +780,46 @@ def test_migration_runs_once_and_only_when_needed():
|
|||||||
snapshot = repr(cfg)
|
snapshot = repr(cfg)
|
||||||
assert gm.migrate_config(cfg, {}) is False, "already square: nothing to do"
|
assert gm.migrate_config(cfg, {}) is False, "already square: nothing to do"
|
||||||
assert repr(cfg) == snapshot
|
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