v1.46.0: full external audit of v1.45.4 — HP-1454-01 … -10

HP-1454-01 (high, release blocker): an uploaded SVG plan opened directly is a
top-level document of Home Assistant's own origin, so a <script> inside it
reaches the session's localStorage and API. Uploading needs write access, which
by default every authenticated user has. SVG responses now carry a sandbox CSP;
only SVG, because a CSP on a PDF can break the browser's viewer and a raster
image has nothing to disable. Verified in Chromium both ways: the script runs
without the header and does not with it.

HP-1454-02: attachment uploads wrote straight to <marker>/<filename>, outside
the config transaction — a cancelled dialog or a rejected save left the stored
url serving new bytes, and every new icon shared one 'new' folder, so two of
them attaching manual.pdf pointed at one file. Uploads take a free name, a new
icon gets a per-dialog staging folder promoted on an accepted save, and
config/set collects superseded and aged-orphan attachments like it does plans.

HP-1454-03: the debounce spaced out the starts of a write, not the writes. A
save slower than 500 ms let the next edit go out with the same expected_rev;
the server accepted the first, rejected the second, and the conflict handler
reloaded over the local copy. Writes are chained now — one in flight, each with
the revision the previous returned.

HP-1454-04: _openPairsCache keyed on room ids and links only, so an aspect
change or a dragged vertex left open boundaries and their glow cuts at old
coordinates. It keys on the rendered model object now — the same invalidation
the model cache already has, not a second strategy. The fingerprint also gained
an O(1) geometry roll-up per room.

HP-1454-05: outer collections were capped, inner ones were not. Limits for
poly points, open_to, controls, pdfs, text and url lengths, plus a total
serialized size cap; legacy  is dropped server-side.

HP-1454-06: upload streams to a temp file and downloads use FileResponse, so a
50 MB manual no longer costs ~100 MB of RSS per transfer.

HP-1454-07: spaceModels() dropped room.settings, so the static card ignored the
per-room fill override. HP-1454-08: layout had no revision on point-wise writes
and no event, leaving static cards stale forever; it now keeps a revision,
returns it and fires houseplan_layout_updated. HP-1454-09: repair cleanup only
walked existing spaces, so a deleted space kept its warning. HP-1454-10:
serialize-javascript pinned past two advisories.

Tests: smoke_svg_sandbox (proves both directions), smoke_config_writer and
smoke_render_parity (both verified failing against a v1.45.4 build), six pure
tests for attachment collection and inner limits, four HA-harness tests for the
CSP, non-overwriting uploads, the size cap and layout revisions.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Matysh
2026-07-28 16:06:21 +03:00
parent 96d387ff1d
commit 260615a63f
27 changed files with 1130 additions and 228 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
PLAN_ORPHAN_TTL_S = 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.45.4"
VERSION = "1.46.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+88 -30
View File
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
from __future__ import annotations
import logging
import os
import tempfile
from pathlib import Path
from aiohttp import web
@@ -20,6 +22,7 @@ from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
from .auth import may_write
from .plans import unique_filename
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -73,17 +76,35 @@ class HouseplanContentView(HomeAssistantView):
if not str(path).startswith(str(base)):
return web.Response(status=404)
def _read() -> bytes | None:
return path.read_bytes() if path.is_file() else None
blob = await hass.async_add_executor_job(_read)
if blob is None:
if not await hass.async_add_executor_job(path.is_file):
return web.Response(status=404)
return web.Response(
body=blob,
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
headers={"Cache-Control": "private, max-age=3600"},
)
suffix = path.suffix.lower()
headers = {
"Cache-Control": "private, max-age=3600",
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
}
if suffix == ".svg":
# An uploaded SVG is user content served from Home Assistant's own
# origin. Inside the card it is referenced by <image>, where scripts
# never run — but the same url opened as a top-level document is a
# live document of this origin, and a <script> in it reaches the
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
# needs write access, which by default every authenticated user has,
# and the signed url is easy to hand to an admin).
#
# `sandbox` with no allow-* tokens drops the document into an opaque
# origin: no scripts, no same-origin access, no forms. The explicit
# directives below are belt and braces for older engines. Only SVG
# gets this — a CSP on a PDF response can break the browser's built-in
# viewer, and a raster image cannot execute anything in the first place.
headers["Content-Security-Policy"] = (
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
)
# FileResponse streams from disk: a 50 MB manual used to be read whole
# into memory and copied into the response body, so a couple of parallel
# downloads could push a small Home Assistant host into swap (HP-1454-06).
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
class HouseplanUploadView(HomeAssistantView):
@@ -100,8 +121,27 @@ class HouseplanUploadView(HomeAssistantView):
marker_id = "misc"
filename: str | None = None
blob: bytes | None = None
tmp_path: Path | None = None
too_large = False
bad_ext = False
files_root = Path(hass.config.path(FILES_DIR))
def _open_tmp() -> Path:
files_root.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix=".upload-", dir=str(files_root))
os.close(fd)
return Path(name)
def _append(target: Path, data: bytes) -> None:
with open(target, "ab") as fh:
fh.write(data)
def _discard(target: Path) -> None:
try:
target.unlink()
except OSError:
pass
try:
reader = await request.multipart()
async for part in reader:
@@ -109,44 +149,62 @@ class HouseplanUploadView(HomeAssistantView):
marker_id = sanitize_marker_id(await part.text())
elif part.name == "file":
filename = part.filename or "file"
# read in chunks, aborting at the limit, instead of loading the whole file into memory
chunks: list[bytes] = []
if file_ext(filename) not in FILE_EXTENSIONS:
bad_ext = True
break
# Stream to a temporary file instead of collecting the whole
# upload in memory and copying it again into one buffer: a
# 50 MB manual used to cost ~100 MB of RSS mid-request, and
# a few of those at once is real pressure on a small Home
# Assistant host (HP-1454-06).
tmp_path = await hass.async_add_executor_job(_open_tmp)
size = 0
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
too_large = True
break
chunks.append(chunk)
await hass.async_add_executor_job(_append, tmp_path, chunk)
if too_large:
break
blob = b"".join(chunks)
except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
if tmp_path is not None:
await hass.async_add_executor_job(_discard, tmp_path)
return web.json_response({"error": "bad_request"}, status=400)
if too_large:
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
)
if blob is None or not filename:
return web.json_response({"error": "no_file"}, status=400)
ext = file_ext(filename)
if ext not in FILE_EXTENSIONS:
if bad_ext:
if tmp_path is not None:
await hass.async_add_executor_job(_discard, tmp_path)
return web.json_response(
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
)
if too_large:
await hass.async_add_executor_job(_discard, tmp_path)
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
)
if tmp_path is None or not filename:
return web.json_response({"error": "no_file"}, status=400)
safe_name = sanitize_filename(filename)
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
path = target_dir / safe_name
target_dir = files_root / marker_id
def _write() -> int:
def _promote() -> str:
"""Move the finished upload in under a name that is free.
Never overwrite: the previous bytes may be referenced by the stored
configuration, and this upload is not part of that transaction — a
cancelled dialog or a rejected save would leave the old url pointing
at the new content (HP-1454-02). An unreferenced upload is collected
later by config/set, once it is old enough.
"""
target_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob)
return int(path.stat().st_mtime)
name = unique_filename(target_dir, safe_name)
os.replace(tmp_path, target_dir / name)
return name
mtime = await hass.async_add_executor_job(_write)
name = await hass.async_add_executor_job(_promote)
return web.json_response(
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
)
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.45.4"
"version": "1.46.0"
}
+97 -5
View File
@@ -1,9 +1,10 @@
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
The file system is not part of the configuration store's transaction, so who
may delete a plan file, and when, is a correctness question rather than a
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
because it is the part that has to be reasoned about and tested.
may write or delete a plan or an attachment, and when, is a correctness
question rather than housekeeping. It lives here, apart from the WebSocket and
HTTP plumbing, precisely because it is the part that has to be reasoned about
and tested.
"""
from __future__ import annotations
@@ -13,11 +14,102 @@ from pathlib import Path
from typing import Any
from .const import PLAN_ORPHAN_TTL_S
from .validation import PLAN_EXTENSIONS
from .validation import PLAN_EXTENSIONS, sanitize_filename
_LOGGER = logging.getLogger(__name__)
def unique_filename(directory: Path, name: str) -> str:
"""A name inside `directory` that is not taken, deriving from `name`.
Uploads never overwrite. The bytes already under a name may be referenced by
the stored configuration, and an upload is not part of that transaction: a
cancelled dialog or a rejected save would otherwise leave a live url serving
someone else's file (HP-1454-02).
"""
safe = sanitize_filename(name)
if not (directory / safe).exists():
return safe
stem, dot, suffix = safe.rpartition(".")
if not dot:
stem, suffix = safe, ""
i = 2
while True:
candidate = f"{stem} ({i}){'.' + suffix if suffix else ''}"
if not (directory / candidate).exists():
return candidate
i += 1
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
""""<marker>/<file>" for every attachment a configuration references."""
out: set[str] = set()
for m in (cfg or {}).get("markers") or []:
for pdf in m.get("pdfs") or []:
url = pdf.get("url") if isinstance(pdf, dict) else None
if not isinstance(url, str) or "/files/" not in url:
continue
rel = url.split("?", 1)[0].split("/files/", 1)[1]
if rel.count("/") == 1:
out.add(rel)
return out
def collect_attachments(
files_dir: Path,
old_cfg: dict[str, Any] | None,
new_cfg: dict[str, Any],
now: float | None = None,
) -> int:
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
A file the old revision referenced and the new one does not was superseded
by this commit and goes. Anything else unreferenced is an upload that was
never saved — a cancelled dialog, a rejected write — and waits out
PLAN_ORPHAN_TTL_S first, because a fresh one may belong to a dialog the user
still has open. Never raises: it runs behind a durable write.
"""
new_refs = attachment_refs(new_cfg)
old_refs = attachment_refs(old_cfg)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
removed = 0
try:
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
except OSError as err:
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
return 0
for folder in folders:
try:
items = sorted(p for p in folder.iterdir() if p.is_file())
except OSError:
continue
for item in items:
rel = f"{folder.name}/{item.name}"
if rel in new_refs:
continue
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if rel not in old_refs and not stale:
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
try:
next(folder.iterdir())
except StopIteration:
try:
folder.rmdir()
except OSError:
pass
except OSError:
pass
return removed
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:
+14 -5
View File
@@ -51,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
translation_key="broken_plan",
translation_placeholders={"space": space_id, "file": fname},
)
# clear stale issues for spaces that are fine again (or gone)
for sp in spaces:
sid = sp.get("id", "?")
if sid not in broken:
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
# issues for spaces that still exist, so deleting or renaming a space with a
# missing plan left its warning in Repairs forever, with nothing left to fix
# it (HP-1454-09). Enumerate what we actually published instead.
registry = ir.async_get(hass)
stale = [
issue_id
for (domain, issue_id) in list(registry.issues)
if domain == DOMAIN
and issue_id.startswith("broken_plan_")
and issue_id[len("broken_plan_") :] not in broken
]
for issue_id in stale:
ir.async_delete_issue(hass, DOMAIN, issue_id)
+41 -17
View File
@@ -66,6 +66,26 @@ MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_LAYOUT = 5000
# Inner limits (HP-1454-05). The outer collections were capped, the collections
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
# list passed validation, then made the card build gigantic SVG attributes and
# walk them on every render. Any authenticated writer could store one, and with
# `admin_only` off that is every user. These are product limits, not guesses: a
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
# one switch.
MAX_POLY_POINTS = 500
MAX_OPEN_TO = 50
MAX_CONTROLS = 200
MAX_PDFS = 50
MAX_KNOWN_DEVICES = 20000
MAX_TEXT = 500 # names, models, ids
MAX_DESCRIPTION = 4000
MAX_URL = 2000
MAX_CONFIG_BYTES = 12 * 1024 * 1024
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _finite, vol.Required("y"): _finite},
@@ -85,10 +105,10 @@ def _require_geometry(room: dict) -> dict:
ROOM_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None),
vol.Optional("open_to"): [str],
vol.Required("id"): _TEXT,
vol.Required("name"): _TEXT,
vol.Optional("area"): _TEXT_OR_NONE,
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
vol.Optional("settings"): vol.Any(
None,
vol.Schema(
@@ -106,7 +126,7 @@ ROOM_SCHEMA = vol.All(
vol.Optional("y"): _finite,
vol.Optional("w"): _finite,
vol.Optional("h"): _finite,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
},
extra=vol.ALLOW_EXTRA,
),
@@ -185,7 +205,10 @@ SPACE_SCHEMA = vol.Schema(
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
# independent existence. Still accepted so a stale browser tab cannot fail a save;
# the card strips the field on every write.
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
# list is not a limit, it is a hope. `Remove` returns the key stripped.
vol.Remove("segments"): object,
},
extra=vol.ALLOW_EXTRA,
)
@@ -197,13 +220,13 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
vol.Optional("name"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("model"): vol.Any(str, None),
vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None),
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
vol.Optional("link"): vol.Any(None, _URL),
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("controls"): vol.Any([str], None),
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
@@ -214,9 +237,10 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
vol.Optional("pdfs"): [
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
],
vol.Optional("pdfs"): vol.All(
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
vol.Length(max=MAX_PDFS),
),
},
extra=vol.ALLOW_EXTRA,
)
@@ -227,8 +251,8 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
vol.Optional("known_devices"): [str],
vol.Optional("new_device_ids"): [str],
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
+40 -14
View File
@@ -5,6 +5,7 @@ import logging
import base64
import binascii
import json
import secrets
from pathlib import Path
from typing import Any
@@ -16,13 +17,13 @@ from homeassistant.core import HomeAssistant, callback
from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
)
from .auth import may_write
from .plans import collect_plans
from .plans import collect_attachments, collect_plans
from .store import HouseplanData, get_data, get_entry
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
)
@@ -74,7 +75,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {"layout": data.get("layout", {})})
connection.send_result(
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
)
@websocket_api.websocket_command(
@@ -107,8 +110,10 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
)
return
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
new_rev = current_rev + 1
await rt.store.async_save({"layout": msg["layout"], "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@websocket_api.websocket_command(
@@ -131,8 +136,13 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
# keep the revision: a point-wise write used to drop it, which made the
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@websocket_api.websocket_command(
@@ -297,13 +307,17 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
new_rev: int | None = None
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
if msg["device_id"] in layout:
del layout[msg["device_id"]]
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({"layout": layout, "rev": new_rev})
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
# ---------------- space configuration ----------------
@@ -343,6 +357,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
# Per-field limits bound each list; this bounds their product (HP-1454-05).
# Everything below the caps can still add up to something no dashboard can
# render, and the store writes it to disk on every save.
size = len(json.dumps(msg["config"], separators=(",", ":")))
if size > MAX_CONFIG_BYTES:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
current_rev = data.get("rev", 0)
@@ -369,12 +393,14 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# failure here must not withhold the event and the success response,
# or the client retries an edit the server has already accepted and
# gets a conflict for its trouble (R4-1).
def _collect() -> None:
collect_plans(Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"])
collect_attachments(Path(hass.config.path(FILES_DIR)), data.get("config"), msg["config"])
try:
await hass.async_add_executor_job(
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
)
await hass.async_add_executor_job(_collect)
except Exception: # noqa: BLE001 — see above: the commit stands regardless
_LOGGER.exception("House Plan: collecting superseded plan files failed")
_LOGGER.exception("House Plan: collecting superseded files failed")
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)