fix: harden beta 2 audit paths

Issue: #440
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-03 16:50:54 +03:00
parent 0f513ac88b
commit 53847e3997
36 changed files with 652 additions and 397 deletions
+10 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import logging
import stat
import threading
from collections import OrderedDict
from dataclasses import dataclass
@@ -15,6 +16,7 @@ _LOGGER = logging.getLogger(__name__)
ASSET_INTEGRITY_CACHE_ENTRIES = 256
ASSET_HASH_CHUNK_BYTES = 64 * 1024
ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS = 30.0
_HASS_DATA_KEY = "asset_integrity_verifier"
@@ -40,11 +42,13 @@ class _Flight:
def _signature(path: Path) -> FileSignature:
stat = path.stat()
current = path.stat()
if not stat.S_ISREG(current.st_mode):
raise OSError("asset is not a regular file")
return FileSignature(
size=stat.st_size,
mtime_ns=stat.st_mtime_ns,
ctime_ns=stat.st_ctime_ns,
size=current.st_size,
mtime_ns=current.st_mtime_ns,
ctime_ns=current.st_ctime_ns,
)
@@ -105,7 +109,8 @@ class AssetIntegrityVerifier:
assert flight is not None
if not owner:
flight.event.wait()
if not flight.event.wait(ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS):
return False
return flight.digest == expected_digest
digest: str | None = None
+25 -13
View File
@@ -373,25 +373,37 @@ def asset_meta_path(root: Path, asset_id: str) -> Path:
return root / f"{asset_id}.json"
def _physical_asset_inventory(root: Path) -> list[tuple[Path, int]]:
"""Observe regular promoted blobs once, tolerating concurrent removals."""
try:
if not root.is_dir():
return []
entries = list(root.iterdir())
except OSError:
return []
inventory: list[tuple[Path, int]] = []
for path in entries:
if path.suffix not in ASSET_EXTENSIONS or not ASSET_ID_RE.fullmatch(path.stem):
continue
try:
current = path.stat(follow_symlinks=False)
except OSError:
continue
if stat.S_ISREG(current.st_mode):
inventory.append((path, current.st_size))
return sorted(inventory, key=lambda item: item[0].name)
def physical_asset_blobs(root: Path) -> list[Path]:
"""Return exact promoted blob files, independently from their sidecars."""
if not root.is_dir():
return []
blobs: list[Path] = []
for path in root.iterdir():
if (
path.suffix in ASSET_EXTENSIONS
and ASSET_ID_RE.fullmatch(path.stem)
and stat.S_ISREG(path.stat(follow_symlinks=False).st_mode)
):
blobs.append(path)
return sorted(blobs, key=lambda path: path.name)
return [path for path, _size in _physical_asset_inventory(root)]
def physical_asset_usage(root: Path) -> tuple[int, int]:
"""Return promoted blob count and actual bytes used for quota checks."""
blobs = physical_asset_blobs(root)
return len(blobs), sum(path.stat().st_size for path in blobs)
inventory = _physical_asset_inventory(root)
return len(inventory), sum(size for _path, size in inventory)
def _read_catalog_row(root: Path, path: Path) -> dict[str, Any] | None:
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "a1c6b7fa80c55b69f45f0b0975bcde7eb67d53a02f75f5b7c61d66f220e78ed9",
"fingerprint": "060ec710f4ade532364b8887066629a45636ac2a8e16af549ec8f323c449b8a9",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-D9kogu9G.js",
"houseplan-assets/houseplan-card-Cq0kQMae.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291175,
"initialViewGzipBytes": 291190,
"lazyFiles": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/de-B-zT3Bet.js",
"houseplan-assets/editor-DJZS-SMT.js",
"houseplan-assets/fr-DhlZhAWs.js",
"houseplan-assets/houseplan-editor-runtime-S0bYVW5I.js",
"houseplan-assets/houseplan-onboarding-runtime-DJriHHug.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/de-1N1nfNI0.js",
"houseplan-assets/editor-yDIoOZQr.js",
"houseplan-assets/fr-CY0KFbzM.js",
"houseplan-assets/houseplan-editor-runtime-Dv0DngiN.js",
"houseplan-assets/houseplan-onboarding-runtime-goBKaVug.js"
],
"lazyGzipBytes": 215559,
"lazyGzipBytes": 215576,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/editor-DJZS-SMT.js",
"houseplan-assets/houseplan-editor-runtime-S0bYVW5I.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/editor-yDIoOZQr.js",
"houseplan-assets/houseplan-editor-runtime-Dv0DngiN.js"
],
"lazyEditorGzipBytes": 159876,
"lazyEditorGzipBytes": 159881,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/houseplan-onboarding-runtime-DJriHHug.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/houseplan-onboarding-runtime-goBKaVug.js"
],
"lazyOnboardingGzipBytes": 14133,
"lazyOnboardingGzipBytes": 14134,
"lazyLocaleFiles": [
"houseplan-assets/de-B-zT3Bet.js",
"houseplan-assets/fr-DhlZhAWs.js"
"houseplan-assets/de-1N1nfNI0.js",
"houseplan-assets/fr-CY0KFbzM.js"
],
"lazyLocaleGzipBytes": 48727,
"lazyLocaleGzipBytes": 48739,
"files": [
{
"path": "houseplan-assets/backdrop-pick-BEQcDs80.js",
"sha256": "b556cef2823ea5e4f7c142ee5f4cee8dbbed71311dd101291faf30a0d38d6805",
"path": "houseplan-assets/backdrop-pick--dMAHFcA.js",
"sha256": "982ddc9c1b3ecbfb0dfb63703a0bf35f4a1c294b4cb612e1734f848e328db03d",
"rawBytes": 20996,
"gzipBytes": 7177,
"gzipBytes": 7178,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-B-zT3Bet.js",
"sha256": "2ee98742a517ac754434e5627f958a20def03cd1c3907dd7ec3e394104c850a1",
"path": "houseplan-assets/de-1N1nfNI0.js",
"sha256": "5ebb658cfb7e05c3f1e074127e8fbead0580121b3f6dc8c390075ef5550f85c4",
"rawBytes": 81632,
"gzipBytes": 24635,
"gzipBytes": 24641,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DJZS-SMT.js",
"sha256": "3cda88a32916d4a3256cb0f76570d72f58308ad9ee6a28c9496d5c9e2c613aa4",
"path": "houseplan-assets/editor-yDIoOZQr.js",
"sha256": "f70e69874730180ecdf67a7ee9003f5c3564432e2c370d235025bcda17696054",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1582,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DhlZhAWs.js",
"sha256": "f37aecbd6a5aaa1fbc5c3be0854464b949cbdcefd453906b268c46d76760772d",
"path": "houseplan-assets/fr-CY0KFbzM.js",
"sha256": "f356e5b756d5d1746567c1e687c709799af7f842d7de8107d5efdb9107716321",
"rawBytes": 83754,
"gzipBytes": 24092,
"gzipBytes": 24098,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-D9kogu9G.js",
"sha256": "65622583b77e3ff11a34ff2c2ceb65748cb1f607412794141375841fcea818bb",
"rawBytes": 1033646,
"gzipBytes": 290380,
"path": "houseplan-assets/houseplan-card-Cq0kQMae.js",
"sha256": "d3064f61d80066e24759cac73e23afc0f4c624d813b29c0f9ff30d12d7fea4a4",
"rawBytes": 1033685,
"gzipBytes": 290393,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-B-zT3Bet.js",
"houseplan-assets/editor-DJZS-SMT.js",
"houseplan-assets/fr-DhlZhAWs.js",
"houseplan-assets/houseplan-editor-runtime-S0bYVW5I.js",
"houseplan-assets/houseplan-onboarding-runtime-DJriHHug.js"
"houseplan-assets/de-1N1nfNI0.js",
"houseplan-assets/editor-yDIoOZQr.js",
"houseplan-assets/fr-CY0KFbzM.js",
"houseplan-assets/houseplan-editor-runtime-Dv0DngiN.js",
"houseplan-assets/houseplan-onboarding-runtime-goBKaVug.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-S0bYVW5I.js",
"sha256": "c1935afb94bfe01cc7ee1302492c6c9a76faac8bd60ddc1db98eded52c7d9d65",
"path": "houseplan-assets/houseplan-editor-runtime-Dv0DngiN.js",
"sha256": "098fd4c1331b45578ac8d07a0eb351b1202a5cc3d16cefba88ed17c5e1bb6a4c",
"rawBytes": 576162,
"gzipBytes": 151119,
"gzipBytes": 151121,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DJriHHug.js",
"sha256": "20b122261b8ce7484806fbaaf17aafb5f59c1864648b1a31fa226dc861d7cbd2",
"path": "houseplan-assets/houseplan-onboarding-runtime-goBKaVug.js",
"sha256": "1b3cbd71e89904dc93378c95df0e0bc230787f9a4d1b2bfbd5d09874f93fce8b",
"rawBytes": 28088,
"gzipBytes": 6956,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "ac14b26a8d45a1747833be382ab3b0b816332156a59ed7125fa3eba598cd940d",
"sha256": "2d498cb03fe036c36702907335bcf53ee43ba2e58a5e445ee7de73b745365757",
"rawBytes": 1183,
"gzipBytes": 795,
"gzipBytes": 797,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a1c6b7fa80c55b69f45f0b0975bcde7eb67d53a02f75f5b7c61d66f220e78ed9";import{b as e,l as o,t,di as s,A as a,dj as i,dk as l,dl as n,E as r,c}from"./houseplan-card-D9kogu9G.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="060ec710f4ade532364b8887066629a45636ac2a8e16af549ec8f323c449b8a9";import{b as e,l as o,t,di as s,A as a,dj as i,dk as l,dl as n,E as r,c}from"./houseplan-card-Cq0kQMae.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a1c6b7fa80c55b69f45f0b0975bcde7eb67d53a02f75f5b7c61d66f220e78ed9";try{await import("./houseplan-assets/houseplan-card-D9kogu9G.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="060ec710f4ade532364b8887066629a45636ac2a8e16af549ec8f323c449b8a9";try{await import("./houseplan-assets/houseplan-card-Cq0kQMae.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+2 -1
View File
@@ -341,7 +341,8 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
async with runtime.upload_lock:
row, reused = await hass.async_add_executor_job(_store)
except DecorAssetError as err:
return web.json_response({"error": err.code, "message": str(err)}, status=507)
status = 507 if err.code == "capacity_exceeded" else 400
return web.json_response({"error": err.code, "message": str(err)}, status=status)
except OSError as err:
_LOGGER.warning("House Plan decor asset upload: store failed: %s", err)
return web.json_response({"error": "io_error"}, status=500)
+2 -2
View File
@@ -54,12 +54,12 @@ const warmGate = await page.evaluate(async () => {
const openedWhileReady = !!root().querySelector('hp-confirm');
card._config = { ...card._config, language: 'de' };
card.requestUpdate();
for (let attempt = 0; attempt < 50 && card._dangerConfirmLocaleGate !== 'warm'; attempt++) {
for (let attempt = 0; attempt < 50 && !card.inert; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
await settle();
}
const entered = card._dangerConfirmLocaleGate === 'warm' && card.inert;
const entered = card.inert && card.getAttribute('aria-busy') === 'true';
const cancelledOnTransition = await Promise.race([
openDecision,
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
+20 -2
View File
@@ -5,9 +5,10 @@ const result = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const room = () => root().querySelector('.room');
const mouse = (type, x, y) => new PointerEvent(type, {
pointerType: 'mouse', bubbles: true, composed: true, clientX: x, clientY: y,
const pointer = (type, pointerType, x, y) => new PointerEvent(type, {
pointerType, bubbles: true, composed: true, clientX: x, clientY: y,
});
const mouse = (type, x, y) => pointer(type, 'mouse', x, y);
const out = {};
card._openSettingsDialog();
@@ -109,6 +110,18 @@ const result = await page.evaluate(async () => {
out.roomHighlightSurvives = card._hoverRoom !== null
&& !!root().querySelector('.room-hover-fill-layer,.room-hover-outline-layer');
room().dispatchEvent(pointer('pointermove', 'pen', 190, 190));
await card.updateComplete;
out.disabledPenMoveUpdatesModality = card._pointerModality.modality === 'pen';
out.disabledPenMoveClearsMouseHover = card._hoverRoom === null && card._tip === null;
room().dispatchEvent(mouse('pointerenter', 195, 195));
room().dispatchEvent(mouse('pointermove', 195, 195));
room().dispatchEvent(pointer('pointermove', 'touch', 195, 195));
await card.updateComplete;
out.disabledTouchMoveUpdatesModality = card._pointerModality.modality === 'touch';
out.disabledTouchMoveClearsMouseHover = card._hoverRoom === null && card._tip === null;
out.disabledNonMouseMovesStillSkipArea = roomAreaCalls === 0;
root().querySelector('.dev').dispatchEvent(mouse('pointermove', 200, 200));
await card.updateComplete;
out.deviceTipSurvives = card._tip?.room === false && !!root().querySelector('.tip');
@@ -144,6 +157,11 @@ checkAll(result, {
disabledRoomTip: true,
disabledSkipsArea: true,
roomHighlightSurvives: true,
disabledPenMoveUpdatesModality: true,
disabledPenMoveClearsMouseHover: true,
disabledTouchMoveUpdatesModality: true,
disabledTouchMoveClearsMouseHover: true,
disabledNonMouseMovesStillSkipArea: true,
deviceTipSurvives: true,
reopenShowsOff: true,
trueStoredAsAbsent: true,
@@ -53,6 +53,7 @@ const out = await page.evaluate(async () => {
await card._load(true);
await settleLoad();
const exactCapabilityResolves = resolveCalls === 1 && card._decorAssets.has(assetId);
const capabilityOnlyUpgradeAdopted = card._snap.decorAssetsApi === 1;
await card._load(true);
await settleLoad();
const sameEpochUsesCache = resolveCalls === 1;
@@ -67,6 +68,7 @@ const out = await page.evaluate(async () => {
return {
oldBackendSkippedResolve,
exactCapabilityResolves,
capabilityOnlyUpgradeAdopted,
sameEpochUsesCache,
downgradeRevokesWithoutResolve,
};
+55 -55
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "a1c6b7fa80c55b69f45f0b0975bcde7eb67d53a02f75f5b7c61d66f220e78ed9",
"fingerprint": "060ec710f4ade532364b8887066629a45636ac2a8e16af549ec8f323c449b8a9",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-D9kogu9G.js",
"houseplan-assets/houseplan-card-Cq0kQMae.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291175,
"initialViewGzipBytes": 291190,
"lazyFiles": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/de-B-zT3Bet.js",
"houseplan-assets/editor-DJZS-SMT.js",
"houseplan-assets/fr-DhlZhAWs.js",
"houseplan-assets/houseplan-editor-runtime-S0bYVW5I.js",
"houseplan-assets/houseplan-onboarding-runtime-DJriHHug.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/de-1N1nfNI0.js",
"houseplan-assets/editor-yDIoOZQr.js",
"houseplan-assets/fr-CY0KFbzM.js",
"houseplan-assets/houseplan-editor-runtime-Dv0DngiN.js",
"houseplan-assets/houseplan-onboarding-runtime-goBKaVug.js"
],
"lazyGzipBytes": 215559,
"lazyGzipBytes": 215576,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/editor-DJZS-SMT.js",
"houseplan-assets/houseplan-editor-runtime-S0bYVW5I.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/editor-yDIoOZQr.js",
"houseplan-assets/houseplan-editor-runtime-Dv0DngiN.js"
],
"lazyEditorGzipBytes": 159876,
"lazyEditorGzipBytes": 159881,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/houseplan-onboarding-runtime-DJriHHug.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/houseplan-onboarding-runtime-goBKaVug.js"
],
"lazyOnboardingGzipBytes": 14133,
"lazyOnboardingGzipBytes": 14134,
"lazyLocaleFiles": [
"houseplan-assets/de-B-zT3Bet.js",
"houseplan-assets/fr-DhlZhAWs.js"
"houseplan-assets/de-1N1nfNI0.js",
"houseplan-assets/fr-CY0KFbzM.js"
],
"lazyLocaleGzipBytes": 48727,
"lazyLocaleGzipBytes": 48739,
"files": [
{
"path": "houseplan-assets/backdrop-pick-BEQcDs80.js",
"sha256": "b556cef2823ea5e4f7c142ee5f4cee8dbbed71311dd101291faf30a0d38d6805",
"path": "houseplan-assets/backdrop-pick--dMAHFcA.js",
"sha256": "982ddc9c1b3ecbfb0dfb63703a0bf35f4a1c294b4cb612e1734f848e328db03d",
"rawBytes": 20996,
"gzipBytes": 7177,
"gzipBytes": 7178,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-B-zT3Bet.js",
"sha256": "2ee98742a517ac754434e5627f958a20def03cd1c3907dd7ec3e394104c850a1",
"path": "houseplan-assets/de-1N1nfNI0.js",
"sha256": "5ebb658cfb7e05c3f1e074127e8fbead0580121b3f6dc8c390075ef5550f85c4",
"rawBytes": 81632,
"gzipBytes": 24635,
"gzipBytes": 24641,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DJZS-SMT.js",
"sha256": "3cda88a32916d4a3256cb0f76570d72f58308ad9ee6a28c9496d5c9e2c613aa4",
"path": "houseplan-assets/editor-yDIoOZQr.js",
"sha256": "f70e69874730180ecdf67a7ee9003f5c3564432e2c370d235025bcda17696054",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1582,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DhlZhAWs.js",
"sha256": "f37aecbd6a5aaa1fbc5c3be0854464b949cbdcefd453906b268c46d76760772d",
"path": "houseplan-assets/fr-CY0KFbzM.js",
"sha256": "f356e5b756d5d1746567c1e687c709799af7f842d7de8107d5efdb9107716321",
"rawBytes": 83754,
"gzipBytes": 24092,
"gzipBytes": 24098,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-D9kogu9G.js",
"sha256": "65622583b77e3ff11a34ff2c2ceb65748cb1f607412794141375841fcea818bb",
"rawBytes": 1033646,
"gzipBytes": 290380,
"path": "houseplan-assets/houseplan-card-Cq0kQMae.js",
"sha256": "d3064f61d80066e24759cac73e23afc0f4c624d813b29c0f9ff30d12d7fea4a4",
"rawBytes": 1033685,
"gzipBytes": 290393,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-B-zT3Bet.js",
"houseplan-assets/editor-DJZS-SMT.js",
"houseplan-assets/fr-DhlZhAWs.js",
"houseplan-assets/houseplan-editor-runtime-S0bYVW5I.js",
"houseplan-assets/houseplan-onboarding-runtime-DJriHHug.js"
"houseplan-assets/de-1N1nfNI0.js",
"houseplan-assets/editor-yDIoOZQr.js",
"houseplan-assets/fr-CY0KFbzM.js",
"houseplan-assets/houseplan-editor-runtime-Dv0DngiN.js",
"houseplan-assets/houseplan-onboarding-runtime-goBKaVug.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-S0bYVW5I.js",
"sha256": "c1935afb94bfe01cc7ee1302492c6c9a76faac8bd60ddc1db98eded52c7d9d65",
"path": "houseplan-assets/houseplan-editor-runtime-Dv0DngiN.js",
"sha256": "098fd4c1331b45578ac8d07a0eb351b1202a5cc3d16cefba88ed17c5e1bb6a4c",
"rawBytes": 576162,
"gzipBytes": 151119,
"gzipBytes": 151121,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DJriHHug.js",
"sha256": "20b122261b8ce7484806fbaaf17aafb5f59c1864648b1a31fa226dc861d7cbd2",
"path": "houseplan-assets/houseplan-onboarding-runtime-goBKaVug.js",
"sha256": "1b3cbd71e89904dc93378c95df0e0bc230787f9a4d1b2bfbd5d09874f93fce8b",
"rawBytes": 28088,
"gzipBytes": 6956,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BEQcDs80.js",
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/backdrop-pick--dMAHFcA.js",
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "ac14b26a8d45a1747833be382ab3b0b816332156a59ed7125fa3eba598cd940d",
"sha256": "2d498cb03fe036c36702907335bcf53ee43ba2e58a5e445ee7de73b745365757",
"rawBytes": 1183,
"gzipBytes": 795,
"gzipBytes": 797,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-D9kogu9G.js"
"houseplan-assets/houseplan-card-Cq0kQMae.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a1c6b7fa80c55b69f45f0b0975bcde7eb67d53a02f75f5b7c61d66f220e78ed9";import{b as e,l as o,t,di as s,A as a,dj as i,dk as l,dl as n,E as r,c}from"./houseplan-card-D9kogu9G.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="060ec710f4ade532364b8887066629a45636ac2a8e16af549ec8f323c449b8a9";import{b as e,l as o,t,di as s,A as a,dj as i,dk as l,dl as n,E as r,c}from"./houseplan-card-Cq0kQMae.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a1c6b7fa80c55b69f45f0b0975bcde7eb67d53a02f75f5b7c61d66f220e78ed9";try{await import("./houseplan-assets/houseplan-card-D9kogu9G.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="060ec710f4ade532364b8887066629a45636ac2a8e16af549ec8f323c449b8a9";try{await import("./houseplan-assets/houseplan-card-Cq0kQMae.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+9 -7
View File
@@ -391,17 +391,19 @@ keeps its authenticated/signed exact-URL contract.
Resolve and HTTP GET share one HA-instance memory-only integrity verifier. It
streams SHA-256 in bounded chunks and caches at most 256 actual digests by
canonical path plus size/mtime/ctime signature. Per-file-version single-flight
deduplicates concurrent reads without serialising different files; a second
`stat` prevents a digest for bytes changed mid-read from entering the cache.
Missing, changed and corrupt files fail dark. The shared `ContentSigner` batches
signatures for `<image>` elements. Catalog deletion rechecks references across
every space under the config write lock. Missing or corrupt assets are never
painted in View.
deduplicates concurrent reads without serialising different files; followers
have a bounded wait. Both signatures require a regular file, and the second
`stat` prevents a digest for bytes changed or replaced mid-read from entering
the cache. Missing, changed, non-regular and corrupt files fail dark. The shared
`ContentSigner` batches signatures for `<image>` elements. Catalog deletion
rechecks references across every space under the config write lock. Missing or
corrupt assets are never painted in View.
Physical inventory is separate from the strict catalog projection. Quota
counts every regular `<sha256><allowed-extension>` blob by actual file size,
including blobs with absent or malformed sidecars; a sidecar without a blob
does not count. Re-uploading exact bytes repairs a digest-proven orphan before
does not count. Entries which disappear or change type during the scan are
skipped without aborting the remaining inventory. Re-uploading exact bytes repairs a digest-proven orphan before
new-file quota checks and reports `reused:false`, while an already valid row
reports `reused:true`. Explicit delete removes only the exact hash sidecar and
exact allow-listed blob names under the reference/upload locks—never a prefix,
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Custom background assets now reject special filesystem entries without
blocking Home Assistant, tolerate files disappearing during quota scans and
report corrupt images separately from exhausted storage; pen/touch movement
also clears stale room hover when the room tooltip is disabled
([#440](https://github.com/Matysh/houseplan-card/issues/440)).
- If Home Assistant rejects a General settings save, the plan now returns to
the last server-confirmed state instead of displaying an unsaved local draft;
the entered values remain in the open dialog for another attempt
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Пользовательские изображения подложки теперь отклоняют специальные файловые
объекты без зависания Home Assistant, переживают исчезновение файлов во время
подсчёта лимита и отличают повреждённое изображение от переполненного
хранилища; движение пером или пальцем также снимает зависшее наведение комнаты
при выключенной подсказке
([#440](https://github.com/Matysh/houseplan-card/issues/440)).
- Если Home Assistant отклоняет сохранение общих настроек, план снова показывает
последнее подтверждённое сервером состояние вместо несохранённого локального
черновика; введённые значения остаются в открытом диалоге для повторной
+8
View File
@@ -37,6 +37,14 @@
мутантам с браузерным гвардом; компиляция тестов в worktree стартует с
тёплого `test-build/` основного дерева.
Чистые Python-контракты канонизации, которым не нужен Home Assistant, находятся
в `tests_backend/test_coordinate_canonicalization_pure.py`. Они обязаны реально
исполняться в локальном `pytest tests_backend`, а не исчезать за module-level
`importorskip`; schema/store/virtual-light проверки остаются в HA-зависимом
`test_coordinate_canonicalization.py`. Поведение static-card capability
доказывается unit/smoke-счётчиками и состоянием runtime, а не regex по исходнику
(#440).
## Vacuum trail smoothing (#209)
- [ ] Pure `smoothVacPath` tests prove exact endpoints, separate subpaths,
+11 -11
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceFingerprint": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"captureScriptSha256": "cadb8e1bcab9f1dcdd7d75b3b90ddcbaaeb2b8c2a098f575a21f39ff70f5c59c",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "80a70361dc18dd0461568df332062e6482c633af5d280954f8b675701418a76d"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "4106cc28847047505f46921ff95765d8abdf5b382d9d17d4c5e4ad129dd8f6be"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "617b51b3648498787b5039980c9f3eceb75ba56ed63a1a20e616bc05bc304362"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "1dab6cc3b9d1bf7d8c40f0e5137f8c688683c9b7eabc5167da99d41ecfdd5b79"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "94ef50762753c0ac6ddc84d2521c4232a3f9ecd89843810d2df61c517592bed7"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "7a601769de38aa19c2e280f6ff4cf3695b854b1d1c6d1f8de4e6b5e8f7eb1559"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "0939875f8631694f4de0ef4fd01032010ede8c7d49fb7c0a857612d4b3afff93"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "054170fd9ef45762b602b4d5c9c3b9ea9724858be61af137970c246f485c13bb"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "2ae4a58853d98e10d12456b2078ec2f6a0b597722c8310d7b016abd2bc42561e"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b352add35375f89faf34bb618d758fd2e8e11964dc850c2a3c4035afc676258d",
"sourceSha256": "3d5b7e47cc37fa1c507f7f6f3be0c594bea9c4c448d32e2b6095d4ae5b1c24a4",
"imageSha256": "cb37f7eefd936f98ef44969b21dfe8e40ee27d1f558abe8d53c0223f6488b8a6"
}
},
+89 -17
View File
@@ -1049,11 +1049,11 @@ const MUTANT_DEFINITIONS = [
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (this._dangerConfirm && (\n'
+ ' this._dangerConfirmMissingSpace() || this._dangerConfirmLocaleGate === \'warm\'\n'
+ ' this._dangerConfirmMissingSpace() || this._syncDangerConfirmLocaleGate() === \'warm\'\n'
+ ' )) {\n'
+ ' this._cancelDangerConfirm();\n'
+ ' }',
replace: ' if (this._dangerConfirm && this._dangerConfirmLocaleGate === \'warm\') {\n'
replace: ' if (this._dangerConfirm && this._syncDangerConfirmLocaleGate() === \'warm\') {\n'
+ ' this._cancelDangerConfirm();\n'
+ ' }',
}],
@@ -1065,8 +1065,8 @@ const MUTANT_DEFINITIONS = [
+ 'confirmation has no rendered decision source and hangs (#417 AC2)',
patches: [{
file: 'src/houseplan-card.ts',
find: " if (this._dangerConfirmLocaleGate === 'warm') return Promise.resolve(false);",
replace: " void this._dangerConfirmLocaleGate;",
find: " if (this._syncDangerConfirmLocaleGate() === 'warm') return Promise.resolve(false);",
replace: " void this._syncDangerConfirmLocaleGate();",
}],
},
{
@@ -1076,17 +1076,20 @@ const MUTANT_DEFINITIONS = [
+ 'last painted branch can admit an unrenderable request or reject a renderable one (#434 AC7)',
patches: [{
file: 'src/houseplan-card.ts',
find: " /** Current language branch; never cached from a previous render. */\n"
+ ' private get _dangerConfirmLocaleGate(): LanguageRenderGate {\n'
find: " /** Synchronize host/runtime language state and return the current branch. */\n"
+ ' private _syncDangerConfirmLocaleGate(): LanguageRenderGate {\n'
+ " if (!this._config || !this.hass) return 'ready';\n"
+ ' return languageRenderGate(\n'
+ ' this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),\n'
+ ' );\n'
+ ' }',
replace: " private _dangerConfirmLocaleGate: LanguageRenderGate = 'ready';",
replace: " private _dangerConfirmLocaleGate: LanguageRenderGate = 'ready';\n"
+ ' private _syncDangerConfirmLocaleGate(): LanguageRenderGate {\n'
+ ' return this._dangerConfirmLocaleGate;\n'
+ ' }',
}, {
file: 'src/houseplan-card.ts',
find: ' const localeGate = this._dangerConfirmLocaleGate;\n'
find: ' const localeGate = this._syncDangerConfirmLocaleGate();\n'
+ " if (localeGate === 'cold') return languageLoadingTemplate();",
replace: ' const localeGate = languageRenderGate(\n'
+ ' this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),\n'
@@ -1103,7 +1106,7 @@ const MUTANT_DEFINITIONS = [
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (this._dangerConfirm && (\n'
+ ' this._dangerConfirmMissingSpace() || this._dangerConfirmLocaleGate === \'warm\'\n'
+ ' this._dangerConfirmMissingSpace() || this._syncDangerConfirmLocaleGate() === \'warm\'\n'
+ ' )) {\n'
+ ' this._cancelDangerConfirm();\n'
+ ' }',
@@ -1112,6 +1115,28 @@ const MUTANT_DEFINITIONS = [
+ ' }',
}],
},
{
id: 'space-card-decor-capability-downgrade-does-not-clear-assets',
guard: 'node demo/smoke_space_card_decor_capability.mjs',
because: 'the static card must fail closed after a capability-only backend downgrade; '
+ 'source-shape assertions cannot prove that the stale image map is actually cleared (#440)',
patches: [{
file: 'src/space-card.ts',
find: ' if (snap.decorAssetsApi !== DECOR_ASSETS_API_VERSION) {',
replace: ' if (false) {',
}],
},
{
id: 'space-card-decor-capability-change-not-adopted',
guard: 'node demo/smoke_space_card_decor_capability.mjs',
because: 'an exact capability-only upgrade must become authoritative even when config, '
+ 'layout and virtual-light content are unchanged (#440)',
patches: [{
file: 'src/space-card.ts',
find: ' if (configChanged || layoutChanged || virtualLightsChanged || decorAssetsCapabilityChanged) {',
replace: ' if (configChanged || layoutChanged || virtualLightsChanged) {',
}],
},
{
id: 'furniture-edge-handles-steal-the-corner',
guard: 'node demo/smoke_furniture_polish.mjs',
@@ -2401,7 +2426,7 @@ const MUTANT_DEFINITIONS = [
{
id: 'image-box-python-canonicalization-omitted',
guard: 'node scripts/backend-test-guard.mjs decor_box_catalog_matches_shared_contract '
+ 'tests_backend/test_coordinate_canonicalization.py',
+ 'tests_backend/test_coordinate_canonicalization_pure.py',
because: 'the Python schema and storage boundary must mirror the complete frontend box '
+ 'catalog instead of accepting image floating-point tails from older clients (#431 AC4)',
patches: [{
@@ -2443,7 +2468,7 @@ const MUTANT_DEFINITIONS = [
{
id: 'python-lattice-round-truncates',
guard: 'node scripts/backend-test-guard.mjs all_4801_lattice_nodes '
+ 'tests_backend/test_coordinate_canonicalization.py',
+ 'tests_backend/test_coordinate_canonicalization_pure.py',
because: 'the backend must use the JavaScript Math.round tie direction and nearest-node '
+ 'semantics; truncation would make old-client writes diverge from the card (#291)',
patches: [{
@@ -4812,12 +4837,8 @@ const MUTANT_DEFINITIONS = [
+ 'promoted blobs invisible, while sidecars without blobs consume no blob quota (#434 AC1)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' for path in root.iterdir():\n'
+ ' if (\n'
+ ' path.suffix in ASSET_EXTENSIONS',
replace: ' for path in root.glob("*.json"):\n'
+ ' if (\n'
+ ' path.suffix in ASSET_EXTENSIONS',
find: ' entries = list(root.iterdir())\n',
replace: ' entries = list(root.glob("*.json"))\n',
}],
},
{
@@ -4997,6 +5018,57 @@ const MUTANT_DEFINITIONS = [
replace: ' stable = True\n',
}],
},
{
id: 'asset-integrity-non-regular-file-admitted',
guard: 'node scripts/backend-test-guard.mjs '
+ 'integrity_verifier_rejects_non_regular_files_before_hashing '
+ 'tests_backend/test_decor_assets.py',
because: 'a FIFO or device with a valid asset filename can block an HA executor forever; '
+ 'only regular files may reach the streaming hasher (#440 AC1)',
patches: [{
file: 'custom_components/houseplan/asset_integrity.py',
find: ' if not stat.S_ISREG(current.st_mode):\n'
+ ' raise OSError("asset is not a regular file")\n',
replace: '',
}],
},
{
id: 'asset-integrity-follower-waits-forever',
guard: 'node scripts/backend-test-guard.mjs integrity_follower_has_a_bounded_wait '
+ 'tests_backend/test_decor_assets.py',
because: 'a follower must fail dark after a bounded wait even if an injected or wedged '
+ 'owner never signals its single-flight event (#440 AC1)',
patches: [{
file: 'custom_components/houseplan/asset_integrity.py',
find: ' if not flight.event.wait(ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS):\n',
replace: ' if not flight.event.wait():\n',
}],
},
{
id: 'room-tooltip-off-skips-pointer-modality',
guard: 'node demo/smoke_room_tooltip_toggle.mjs',
because: 'pen/touch pointermove inside an already hovered room must clear mouse-only hover '
+ 'even when the room information tooltip is disabled (#440 AC2)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._notePointer(e);\n'
+ ' if (!showRoomTooltipOf(this._settings)) {',
replace: ' if (!showRoomTooltipOf(this._settings)) {',
}],
},
{
id: 'decor-upload-invalid-image-reported-as-capacity',
guard: 'node scripts/backend-test-guard.mjs '
+ 'decor_asset_upload_deduplicates_and_rejects_mime_spoofing '
+ 'tests_backend/test_ha_websocket.py',
because: 'a corrupt orphan is a client/integrity rejection, not exhausted storage; only '
+ 'capacity_exceeded may produce HTTP 507 (#440 AC4)',
patches: [{
file: 'custom_components/houseplan/http_api.py',
find: ' status = 507 if err.code == "capacity_exceeded" else 400\n',
replace: ' status = 507\n',
}],
},
{
id: 'initial-view-ceiling-unplugged',
guard: 'node --test test/bundle-assets.test.mjs',
+6 -5
View File
@@ -2157,8 +2157,8 @@ export class HouseplanCard extends LitElement {
} | { kind: 'run'; text: string; exec: () => void } | null = null;
/** One eager confirmation owner shared by View, onboarding and lazy editors. */
private _dangerConfirm: HpConfirmState | null = null;
/** Current language branch; never cached from a previous render. */
private get _dangerConfirmLocaleGate(): LanguageRenderGate {
/** Synchronize host/runtime language state and return the current branch. */
private _syncDangerConfirmLocaleGate(): LanguageRenderGate {
if (!this._config || !this.hass) return 'ready';
return languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
@@ -2185,7 +2185,7 @@ export class HouseplanCard extends LitElement {
// resolve. Keep the guards aligned with `_renderBody`'s two unrenderable
// branches instead of registering a controller request with no DOM owner.
if (!this._config || !this.hass) return Promise.resolve(false);
if (this._dangerConfirmLocaleGate === 'warm') return Promise.resolve(false);
if (this._syncDangerConfirmLocaleGate() === 'warm') return Promise.resolve(false);
if (this._dangerConfirmMissingSpace()) return Promise.resolve(false);
return this._dangerConfirmController.confirm(request);
};
@@ -4190,7 +4190,7 @@ export class HouseplanCard extends LitElement {
// #417: losing the only renderable space while a decision is pending must
// settle it before render() clears hp-confirm with `nothing`.
if (this._dangerConfirm && (
this._dangerConfirmMissingSpace() || this._dangerConfirmLocaleGate === 'warm'
this._dangerConfirmMissingSpace() || this._syncDangerConfirmLocaleGate() === 'warm'
)) {
this._cancelDangerConfirm();
}
@@ -11233,7 +11233,7 @@ export class HouseplanCard extends LitElement {
private _renderBody(): TemplateResult | typeof nothing | typeof noChange {
if (!this._config || !this.hass) return nothing;
const localeGate = this._dangerConfirmLocaleGate;
const localeGate = this._syncDangerConfirmLocaleGate();
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
const onboardingRuntimeRequested = !!this._importDialog
@@ -11588,6 +11588,7 @@ export class HouseplanCard extends LitElement {
let areaText: string | null | undefined;
const tip = (e: PointerEvent) => {
if (this._mode !== 'view') return;
this._notePointer(e);
if (!showRoomTooltipOf(this._settings)) {
if (this._tip?.room) this._tip = null;
return;
-14
View File
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const card = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
const configStore = readFileSync(new URL('../src/config-store.ts', import.meta.url), 'utf8');
// #376(а): YAML `title:` with no value parses as null. The owner decided null
// and '' mean the same thing — no header AND the compact frame. The behaviour
@@ -24,16 +23,3 @@ test('#376(е) light_pools gates are symmetric: anything but true disposes', ()
assert.ok(!/if \(!this\._config\.light_pools\) \{\s*\n\s*disposeGlowRuntime\(/.test(card),
'the old truthy dispose gate must not return');
});
test('#434 static card fails closed without exact decor capability', () => {
assert.match(configStore, /decorAssetsApi: cfgResp\?\.decor_assets_api === 1 \? 1 : null/,
'every fresh config/get must normalize and revoke the runtime capability');
assert.match(configStore, /decorAssetsApi: null,/,
'localStorage must never seed an unverified runtime capability');
assert.match(card, /snap\.decorAssetsApi !== DECOR_ASSETS_API_VERSION[\s\S]*?this\._decorAssets = new Map\(\)/,
'old/downgraded backends clear the projection and make no resolve call');
assert.match(card, /decorAssetIds\(snap\.config\), snap\.rev/,
'the static resolver cache must be scoped by the authoritative config revision');
assert.match(card, /configChanged \|\| layoutChanged \|\| virtualLightsChanged \|\| decorAssetsCapabilityChanged/,
'a capability-only downgrade must be adopted even when config content is identical');
});
@@ -1,9 +1,7 @@
"""Shared coordinate canonicalisation contract (#224)."""
from __future__ import annotations
import copy
import json
import math
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -31,14 +29,8 @@ pytest.importorskip("homeassistant", reason="модуль тянет HA чере
from custom_components.houseplan import virtual_lights
from custom_components.houseplan.coordinate_canonicalization import (
COORDINATE_DECIMALS,
DECOR_BOX_KINDS,
LATTICE_GRID_N,
LATTICE_NOISE_STEPS,
canonicalize_config_geometry,
canonicalize_lattice_coordinate,
canonicalize_layout_geometry,
canonicalize_number,
)
from custom_components.houseplan.store import (
async_save_config_state,
@@ -69,80 +61,6 @@ def _fixture() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def test_python_and_frontend_share_the_scalar_lattice_fixture_contract() -> None:
fixture = _fixture()
config_input = fixture["configInput"]
layout_input = fixture["layoutInput"]
config_before = copy.deepcopy(config_input)
layout_before = copy.deepcopy(layout_input)
assert COORDINATE_DECIMALS == fixture["decimals"]
config = canonicalize_config_geometry(config_input)
layout = canonicalize_layout_geometry(layout_input)
assert config == fixture["configExpected"]
assert layout == fixture["layoutExpected"]
assert config_input == config_before
assert layout_input == layout_before
assert canonicalize_config_geometry(config) == config
assert canonicalize_layout_geometry(layout) == layout
assert math.copysign(1.0, layout["rl:poly"]["x"]) == 1.0
def test_decor_box_catalog_matches_shared_contract() -> None:
fixture = _fixture()
assert list(DECOR_BOX_KINDS) == fixture["boxKinds"]
input_decor = fixture["configInput"]["spaces"][0]["decor"]
input_boxes = [item for item in input_decor if item["kind"] in DECOR_BOX_KINDS]
assert sorted(item["kind"] for item in input_boxes) == sorted(fixture["boxKinds"])
result = canonicalize_config_geometry(fixture["configInput"])
output_by_id = {item["id"]: item for item in result["spaces"][0]["decor"]}
expected_by_id = {
item["id"]: item for item in fixture["configExpected"]["spaces"][0]["decor"]
}
for source in input_boxes:
actual = output_by_id[source["id"]]
expected = expected_by_id[source["id"]]
for field in ("x", "y", "w", "h", "angle"):
assert actual[field] == expected[field]
assert actual[field] != source[field]
source_image = next(item for item in input_boxes if item["kind"] == "image")
image = output_by_id["image"]
for field in ("asset_id", "opacity", "flip_h", "flip_v", "future"):
assert image[field] == source_image[field]
def test_scalar_contract_is_symmetric_and_keeps_off_grid_geometry() -> None:
assert canonicalize_number(1.2345678905) == 1.234567891
assert canonicalize_number(-1.2345678905) == -1.234567891
value = 0.20833333333333334
assert canonicalize_number(value) == 0.208333333
assert abs(canonicalize_number(value) - value) <= 5e-10
assert math.isnan(canonicalize_number(float("nan")))
assert canonicalize_number(float("inf")) == float("inf")
def test_all_4801_lattice_nodes_and_nine_decimal_forms_share_exact_bits() -> None:
assert LATTICE_GRID_N == 240
assert LATTICE_NOISE_STEPS == 1e-4
for index in range(-2400, 2401):
node = index / LATTICE_GRID_N
nine_decimal = float(f"{node:.9f}")
assert canonicalize_lattice_coordinate(node) == node
assert canonicalize_lattice_coordinate(nine_decimal) == node
assert canonicalize_lattice_coordinate(
canonicalize_lattice_coordinate(nine_decimal)
) == node
assert math.copysign(1.0, canonicalize_lattice_coordinate(-0.0)) == 1.0
assert canonicalize_lattice_coordinate(0.06) == 0.06
assert canonicalize_lattice_coordinate(0.2875) == 0.2875
assert canonicalize_lattice_coordinate((1 + 0.999e-4) / 240) == 1 / 240
assert canonicalize_lattice_coordinate((1 + 1.001e-4) / 240) == 0.004167084
def test_backend_schemas_apply_the_same_allowlist() -> None:
fixture = _fixture()
assert CONFIG_SCHEMA(fixture["configInput"]) == fixture["configExpected"]
@@ -0,0 +1,103 @@
"""HA-independent coordinate canonicalisation contracts (#224/#431/#440)."""
from __future__ import annotations
import copy
import json
import math
from pathlib import Path
from custom_components.houseplan.coordinate_canonicalization import (
COORDINATE_DECIMALS,
DECOR_BOX_KINDS,
LATTICE_GRID_N,
LATTICE_NOISE_STEPS,
canonicalize_config_geometry,
canonicalize_lattice_coordinate,
canonicalize_layout_geometry,
canonicalize_number,
)
FIXTURE = (
Path(__file__).parents[1]
/ "test"
/ "fixtures"
/ "coordinate-canonicalization.json"
)
def _fixture() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def test_python_and_frontend_share_the_scalar_lattice_fixture_contract() -> None:
fixture = _fixture()
config_input = fixture["configInput"]
layout_input = fixture["layoutInput"]
config_before = copy.deepcopy(config_input)
layout_before = copy.deepcopy(layout_input)
assert COORDINATE_DECIMALS == fixture["decimals"]
config = canonicalize_config_geometry(config_input)
layout = canonicalize_layout_geometry(layout_input)
assert config == fixture["configExpected"]
assert layout == fixture["layoutExpected"]
assert config_input == config_before
assert layout_input == layout_before
assert canonicalize_config_geometry(config) == config
assert canonicalize_layout_geometry(layout) == layout
assert math.copysign(1.0, layout["rl:poly"]["x"]) == 1.0
def test_decor_box_catalog_matches_shared_contract() -> None:
fixture = _fixture()
assert list(DECOR_BOX_KINDS) == fixture["boxKinds"]
input_decor = fixture["configInput"]["spaces"][0]["decor"]
input_boxes = [item for item in input_decor if item["kind"] in DECOR_BOX_KINDS]
assert sorted(item["kind"] for item in input_boxes) == sorted(fixture["boxKinds"])
result = canonicalize_config_geometry(fixture["configInput"])
output_by_id = {item["id"]: item for item in result["spaces"][0]["decor"]}
expected_by_id = {
item["id"]: item for item in fixture["configExpected"]["spaces"][0]["decor"]
}
for source in input_boxes:
actual = output_by_id[source["id"]]
expected = expected_by_id[source["id"]]
for field in ("x", "y", "w", "h", "angle"):
assert actual[field] == expected[field]
assert actual[field] != source[field]
source_image = next(item for item in input_boxes if item["kind"] == "image")
image = output_by_id["image"]
for field in ("asset_id", "opacity", "flip_h", "flip_v", "future"):
assert image[field] == source_image[field]
def test_scalar_contract_is_symmetric_and_keeps_off_grid_geometry() -> None:
assert canonicalize_number(1.2345678905) == 1.234567891
assert canonicalize_number(-1.2345678905) == -1.234567891
value = 0.20833333333333334
assert canonicalize_number(value) == 0.208333333
assert abs(canonicalize_number(value) - value) <= 5e-10
assert math.isnan(canonicalize_number(float("nan")))
assert canonicalize_number(float("inf")) == float("inf")
def test_all_4801_lattice_nodes_and_nine_decimal_forms_share_exact_bits() -> None:
assert LATTICE_GRID_N == 240
assert LATTICE_NOISE_STEPS == 1e-4
for index in range(-2400, 2401):
node = index / LATTICE_GRID_N
nine_decimal = float(f"{node:.9f}")
assert canonicalize_lattice_coordinate(node) == node
assert canonicalize_lattice_coordinate(nine_decimal) == node
assert canonicalize_lattice_coordinate(
canonicalize_lattice_coordinate(nine_decimal)
) == node
assert math.copysign(1.0, canonicalize_lattice_coordinate(-0.0)) == 1.0
assert canonicalize_lattice_coordinate(0.06) == 0.06
assert canonicalize_lattice_coordinate(0.2875) == 0.2875
assert canonicalize_lattice_coordinate((1 + 0.999e-4) / 240) == 1 / 240
assert canonicalize_lattice_coordinate((1 + 1.001e-4) / 240) == 0.004167084
+100
View File
@@ -5,6 +5,7 @@ import base64
import hashlib
import importlib
import json
import os
import struct
import threading
import zlib
@@ -15,6 +16,7 @@ import pytest
from custom_components.houseplan.asset_integrity import (
ASSET_INTEGRITY_CACHE_ENTRIES,
ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS,
AssetIntegrityVerifier,
)
from custom_components.houseplan.const import MAX_DECOR_ASSET_BYTES
@@ -358,6 +360,40 @@ def test_physical_inventory_counts_exact_promoted_blobs_not_sidecars(tmp_path) -
assert physical_asset_usage(tmp_path) == (2, 9)
def test_physical_inventory_skips_entries_lost_during_stat(
tmp_path, monkeypatch,
) -> None:
lost = tmp_path / f"{'4' * 64}.png"
survivor = tmp_path / f"{'5' * 64}.webp"
lost.write_bytes(b"gone")
survivor.write_bytes(b"survives")
original_stat = Path.stat
def racing_stat(path: Path, *, follow_symlinks=True):
if path == lost and not follow_symlinks:
raise FileNotFoundError("removed after iterdir")
return original_stat(path, follow_symlinks=follow_symlinks)
monkeypatch.setattr(Path, "stat", racing_stat)
assert physical_asset_blobs(tmp_path) == [survivor]
assert physical_asset_usage(tmp_path) == (1, len(b"survives"))
def test_physical_inventory_treats_disappearing_root_as_empty(
tmp_path, monkeypatch,
) -> None:
original_iterdir = Path.iterdir
def racing_iterdir(path: Path):
if path == tmp_path:
raise FileNotFoundError("root removed before scan")
return original_iterdir(path)
monkeypatch.setattr(Path, "iterdir", racing_iterdir)
assert physical_asset_blobs(tmp_path) == []
assert physical_asset_usage(tmp_path) == (0, 0)
def test_catalog_empty_directory_and_metadata_path(tmp_path) -> None:
missing = tmp_path / "missing"
aid = "c" * 64
@@ -444,6 +480,34 @@ def test_integrity_cache_invalidates_changed_signature_and_rejects_mid_read_chan
assert not unstable._cache, "an unstable digest must not become a cache hit"
def test_integrity_verifier_rejects_non_regular_files_before_hashing(tmp_path) -> None:
calls = 0
def forbidden(_candidate: Path) -> str:
nonlocal calls
calls += 1
raise AssertionError("non-regular files must never reach the hasher")
verifier = AssetIntegrityVerifier(hasher=forbidden)
assert not verifier.verify(tmp_path, "0" * 64)
assert calls == 0
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="FIFO is POSIX-only")
def test_integrity_verifier_rejects_fifo_without_opening_it(tmp_path) -> None:
fifo = tmp_path / "asset.png"
os.mkfifo(fifo)
called = False
def forbidden(_candidate: Path) -> str:
nonlocal called
called = True
raise AssertionError("opening a FIFO could block the HA executor forever")
assert not AssetIntegrityVerifier(hasher=forbidden).verify(fifo, "0" * 64)
assert not called
def test_integrity_cache_single_flights_same_path_and_releases_after_error(tmp_path) -> None:
path = tmp_path / "asset.bin"
payload = b"concurrent"
@@ -493,6 +557,42 @@ def test_integrity_cache_single_flights_same_path_and_releases_after_error(tmp_p
assert attempts == 2 and not recovered._inflight
def test_integrity_follower_has_a_bounded_wait(tmp_path) -> None:
path = tmp_path / "asset.bin"
payload = b"slow-owner"
path.write_bytes(payload)
expected = hashlib.sha256(payload).hexdigest()
owner_started = threading.Event()
release_owner = threading.Event()
events = []
class TimedEvent:
def __init__(self) -> None:
self.timeout = None
events.append(self)
def set(self) -> None:
pass
def wait(self, timeout=None) -> bool:
self.timeout = timeout
return False
def slow(candidate: Path) -> str:
owner_started.set()
assert release_owner.wait(2)
return hashlib.sha256(candidate.read_bytes()).hexdigest()
verifier = AssetIntegrityVerifier(hasher=slow, event_factory=TimedEvent)
with ThreadPoolExecutor(max_workers=1) as pool:
owner = pool.submit(verifier.verify, path, expected)
assert owner_started.wait(1)
assert not verifier.verify(path, expected)
assert events[0].timeout == ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS
release_owner.set()
assert owner.result(timeout=2)
def test_integrity_checks_for_different_paths_do_not_share_a_hash_lock(tmp_path) -> None:
first_path = tmp_path / "first.bin"
second_path = tmp_path / "second.bin"
+18 -2
View File
@@ -2152,17 +2152,33 @@ async def test_decor_asset_upload_deduplicates_and_rejects_mime_spoofing(
assert repaired_body["reused"] is False
assert json.loads((root / f"{aid}.json").read_text(encoding="utf-8"))["asset_id"] == aid
# Only quota exhaustion is a storage-capacity response.
(root / f"{aid}.json").unlink()
(root / f"{aid}.png").unlink()
monkeypatch.setattr(hp_http, "MAX_DECOR_ASSETS_COUNT", 0)
full = await view.post(_Request("image/png"))
assert full.status == 507
assert json.loads(full.text)["error"] == "capacity_exceeded"
# A matching filename never licenses overwriting bytes whose digest does
# not match the content-addressed id.
(root / f"{aid}.json").unlink()
blob = root / f"{aid}.png"
blob.write_bytes(b"corrupt")
rejected = await view.post(_Request("image/png"))
assert rejected.status == 507
assert rejected.status == 400
assert json.loads(rejected.text)["error"] == "invalid_image"
assert blob.read_bytes() == b"corrupt"
assert not (root / f"{aid}.json").exists()
blob.unlink()
monkeypatch.setattr(
hp_http, "physical_asset_usage",
lambda _root: (_ for _ in ()).throw(OSError("inventory failed")),
)
failed = await view.post(_Request("image/png"))
assert failed.status == 500
assert json.loads(failed.text)["error"] == "io_error"
spoofed = await view.post(_Request("image/jpeg"))
assert spoofed.status == 400
assert json.loads(spoofed.text)["error"] == "invalid_format"