Release v1.61.0-beta.8 candidate
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.61.0-beta.7"
|
||||
VERSION = "1.61.0-beta.8"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.61.0-beta.7"
|
||||
"version": "1.61.0-beta.8"
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ const runtime = () => {
|
||||
attributes: { azimuth: 180, elevation: 24 },
|
||||
},
|
||||
};
|
||||
entities['sun.sun'] = {
|
||||
entity_id: 'sun.sun', device_id: null, platform: 'sun', disabled_by: null,
|
||||
};
|
||||
const layout = {};
|
||||
const areas = Object.fromEntries(
|
||||
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 8,
|
||||
"acceptedAt": "2026-08-10T23:57:00.140Z",
|
||||
"sourceFingerprint": "0f963f24ce2ba631daf1931ac9ffd44c7326b1bfee5fca1dac9b3a1678c892f7",
|
||||
"acceptedAt": "2026-08-11T02:39:13.935Z",
|
||||
"sourceFingerprint": "4bb154256a39fbf9a96785f2031970ee852117750efb5f2c2fcb24f53d549cd4",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"geometry-view-dark-fit": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
|
||||
"geometry-view-light-fit": "a425fa6d77081bb35d2fb81e469962402c6a3c86a8113ff957f28548908d0816",
|
||||
"geometry-plan-editor-dark": "86d64305e4da13436aef14fe96b2c9eb5978d1d32a0bba2318005421a2fda161",
|
||||
"geometry-devices-editor-dark": "93417ab7363ca50e65c9d6f0b876f4d5a6a367a8fabdf42306b244e6511693f1",
|
||||
"geometry-decor-editor-dark": "25d341052e30e7acea13c17897e8bd0f1f08235342b949c02afd3db5b9936c64",
|
||||
"tray-wide-selection-en": "c7350c3661f17f46d22b2c977524fde9fba1ebf9d6e10f021482f0d1caeffa8a",
|
||||
"tray-wide-tool-ru": "5472a302ad7e7b70235089164a5c4558183504519d95280e8ff30883e304cb71",
|
||||
"tray-medium-group-en": "5ab850cb6b91f3bc1020c2257970d8a80d175eb7329c6bfd6de7a94386c930ac",
|
||||
"tray-medium-selection-ru": "805c23560bc340582f9b951e910f39245ab62b6cd980d175c8d9e3b7e1ef1519",
|
||||
"tray-narrow-palette-en": "e8dede0d90ebc32e1e294ba3c39621df918aaf7b3183f71aed9b60014eee51db",
|
||||
"tray-narrow-tool-ru": "8889d58aca90daadf0bedd6a7c08afb2604c086a453f9435f32d29607aa49e42",
|
||||
"geometry-diagonal-45-opening-dark": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
|
||||
"openings-thick-wall-dark": "2a502049283df7a38119a3b62a5fbc4bc6d1d1b5aeb1e21337f2436c78017dce",
|
||||
"openings-hidden-view-dark": "eb6f53c7836ccf086a080640394a63b366abe267ea2740912d98f8f163e187fb",
|
||||
"lighting-glow-sun-dark": "567f609ebc409c8477aa0d369bef3578bcadfa17b450e60f6678442ab9b43810",
|
||||
"lighting-fill-light-axis-split-dark": "0e1a90ec64fc8e07e62e1ba684b0345b2caecadaa0e84dcc2f1bc61080d8bc92",
|
||||
"lighting-fill-temp-axis-split-dark": "653deb8ba50763c91665ca9199eed12f4a05ac63e118d294e78c833db867c661",
|
||||
"lighting-fill-lqi-axis-split-dark": "059d129e6ebc8eb645348ebf41560df559494f23d3b3d0e6c2f0269e03de275d",
|
||||
"lighting-temp-glow-dark": "c2dbd5c35f69587291a16ea24da7d8bcc276800ba7f19c88493132e1acc3b35c",
|
||||
"lighting-temp-glow-light": "6f78a661baa9d914358472d1c077ed61d8aaf88a861058afb9dacf2b28e98786",
|
||||
"lighting-custom-glow-dark": "168364a966a0b4d342d38924e0790c7cf5fe6a34798622b7928425c59bb1e737",
|
||||
"lighting-opaque-glow-two-doorways-dark": "9dbeb9ba58e5c30ee2efd7e200353475ed8cb63a3a99620e1f7c862d5448f956",
|
||||
"lighting-custom-glow-light": "d22374e0639578ed80a5703a4acedb20dc0f6dedb2e3512af24ebdabeb97b8c0",
|
||||
"lighting-temp-glow-no-sources-dark": "e35535052212cd519567236a2227393727729654f5670f9b41918c4b23e9127a",
|
||||
"lighting-temp-glow-room-override-dark": "d92fa0f1e4d5ed66aa11651d5807c7e562a215540a45fc2712691b738c475bc9",
|
||||
"lighting-manual-auto-spill-overlap-dark": "2c44b28c86ae0a77c178d982b4e8c47c70ed0df66ccaba23baf89bdddd311fbd",
|
||||
"hover-over-glow-dark": "c24fbcdd2c79037de540d63413592ae49b360d14bc557135d9cf830bfc959fcc",
|
||||
"hover-nested-room-dark": "dbda5b5f82493b14cc6bf36dfe47758f7d8d4af9c8dab0998ff62fb4456ada1f",
|
||||
"large-house-zoom-040-dark": "0a69ba78fc8476c33c0326df697eb9ee1f19799cc7b7ed84b8c95944d3934f03",
|
||||
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
|
||||
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
|
||||
"device-dialog-desktop-en": "177bca2af39fd1bf0c9af58dfa6d17effd8ffdf9f96bcf399a0172e9da58f692",
|
||||
"device-dialog-mobile-ru": "5b66639f71623d218f33df8db99f70edc139b9de60ffe291641ccf10665b78cb",
|
||||
"decor-color-popover-mobile-ru": "eab3c5c76b27050dbdbc1627371a16916ea30473934de48842c6ab94a045f9db"
|
||||
"geometry-view-dark-fit": "0336725040bc4ad986b6e65582904f427d6b5d6aace5c3cf80bf37619a263098",
|
||||
"geometry-view-light-fit": "42ff1b5b96b0d7d4bb80ce48ecbe881f41439214bff55345669960b477496a36",
|
||||
"geometry-plan-editor-dark": "955c066be5990aa54ba202cd9a570cc15c050411e67a7ecd734dbb6bf5bb5fd9",
|
||||
"geometry-devices-editor-dark": "d1a54ede679272ff1b00705a1a7f5af80dac06217df3dff43f7f290eb47240d7",
|
||||
"geometry-decor-editor-dark": "e7ab9bbf0e38cb9d345065e842fc921bf54a9bbbdadb2a63f4c937c79baf7e2f",
|
||||
"tray-wide-selection-en": "5b3603b4ce28ddab570423bde092f30286e7fd5401e22be74a90b08b699371f4",
|
||||
"tray-wide-tool-ru": "c328cfa98ebfb48325cc0e290fa63d9cbaf677bda6e5b3060faa1654ffc444dc",
|
||||
"tray-medium-group-en": "459073ae6e720da99f3096c8eedd8c3f330498ff0b106490f22f0a64d7fe43d0",
|
||||
"tray-medium-selection-ru": "9b5010a1acc6a658c6ecba6ee4aab120d56176b86361635c9c63ba93c642cbb8",
|
||||
"tray-narrow-palette-en": "b8acfda404240a524792215cd34da23b23169323d9e046d5d8c2e2d7f5d05587",
|
||||
"tray-narrow-tool-ru": "9d83ebfee6dfbcd7cd0325c2f21c9e56f821e8878a5de189918236496db5a431",
|
||||
"geometry-diagonal-45-opening-dark": "0336725040bc4ad986b6e65582904f427d6b5d6aace5c3cf80bf37619a263098",
|
||||
"openings-thick-wall-dark": "6b6514d3717c0bde0d58ce4257b6900f361714ca7df9392f26e2f9620321f9aa",
|
||||
"openings-hidden-view-dark": "0f09e43631912047d5b628084bd15bb97517985edd959fbf99130d65642caa5b",
|
||||
"lighting-glow-sun-dark": "5763ef8a9f19f9a4eaa2522cce5c1037707969353eadb68497937750afc11a71",
|
||||
"lighting-fill-light-axis-split-dark": "484e8ba095bbf01f41397b6bfeb04e33340d1e5bbbb272996209d976a69caa0f",
|
||||
"lighting-fill-temp-axis-split-dark": "0484b95691322cea3fbd30879798486b59db2bb02965268ec4747bdf6583c42a",
|
||||
"lighting-fill-lqi-axis-split-dark": "8fbf321d3884381b81b7cabf25e24fceb8c96db1753a483ba6704bbcdb74e785",
|
||||
"lighting-temp-glow-dark": "2807fc0aea018583ae6f57fc3557bd5a61388a8141f0a963e295b78ffab78d5f",
|
||||
"lighting-temp-glow-light": "ab3277fdfe6cf3053a1b88f6c797d35a3e8a30c8835676bf0a77fb4b3bfd01a0",
|
||||
"lighting-custom-glow-dark": "1dd0503afa48be677a25fcb3d1025efeb58ebe9bed02e2d8038017175364ee8e",
|
||||
"lighting-opaque-glow-two-doorways-dark": "aa654b294b207d14c6363a30c0fdbb4bea93dba8aa859da85ddf37383cd64e6b",
|
||||
"lighting-custom-glow-light": "b163c5de2ed38377f2cbd9f0b9197a49672842650a910881d4135555e3280ccc",
|
||||
"lighting-temp-glow-no-sources-dark": "04933dedb465c5ce7dca5286ba236db305f24eba702e02a0fed033920789ca97",
|
||||
"lighting-temp-glow-room-override-dark": "088d9036c4227e6bb1ac13eaab865ae2ecef3ac9de8c8504d18d182c70b9cc98",
|
||||
"lighting-manual-auto-spill-overlap-dark": "47aadb93ac445bda4b90264db1e8dfe2ea79cd0cb78d1ddbe5a22f1965836487",
|
||||
"hover-over-glow-dark": "3c36371ef82deffd0761a12fd2a1c133435af1361f9e0c356d99f23379a56b6e",
|
||||
"hover-nested-room-dark": "b149fe81eecbd7c04fa37e0374187a3c04279d2ab4f1234654bebdea88390c03",
|
||||
"large-house-zoom-040-dark": "0aa8d0f65ea1ca808a7b59e4991b3b8fb6b46d8292b8c736a3f1cd4383572411",
|
||||
"large-house-zoom-250-dark": "ea8722634a09465d0994b6434e2baed388af609c8f6e58898b18ec4cb9e9d6c3",
|
||||
"large-house-warm-remount-dark": "cc54397ac5a0308241b71265033a3a88fe46f3a9ab771a47b5aa0228f2cfbbc7",
|
||||
"device-dialog-desktop-en": "cab047ca85801577c18b001f0ccc2eadcb345b74933ba8f2cd0f5a9b010f5e5f",
|
||||
"device-dialog-mobile-ru": "f6288d59f8eba7af2aa5f9524281de142f6b67c6566de8fb3898b9814ceb761e",
|
||||
"decor-color-popover-mobile-ru": "96c2cd68ffe7ac430f46d0361ebde81b9a3e1896ad2eb72740c53ae534510804"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 296 KiB |
@@ -16,11 +16,21 @@ session.on('Page.screencastFrame', (event) => {
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const card = window.__card;
|
||||
const root = card?.shadowRoot || card?.renderRoot;
|
||||
const host = root?.querySelector('ha-card');
|
||||
return host?.dataset.continuityState === 'steady' && !!host?.dataset.frameFingerprint;
|
||||
});
|
||||
const stage = await page.locator('houseplan-card').evaluate((card) => {
|
||||
const node = (card.shadowRoot || card.renderRoot).querySelector('.stage');
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
});
|
||||
// The reference is an explicitly settled DOM screenshot, never the first
|
||||
// compositor frame emitted by the screencast itself. A degraded first frame
|
||||
// must fail against this baseline rather than widening its own tolerance.
|
||||
const baselinePng = await page.screenshot({ clip: stage });
|
||||
|
||||
await session.send('Page.startScreencast', {
|
||||
format: 'png', everyNthFrame: 1, maxWidth: 820, maxHeight: 760,
|
||||
@@ -39,7 +49,7 @@ try {
|
||||
await session.send('Page.stopScreencast');
|
||||
stopped = true;
|
||||
|
||||
const metrics = await page.evaluate(async ({ encoded, crop }) => {
|
||||
const allMetrics = await page.evaluate(async ({ encoded, crop }) => {
|
||||
const decode = (data) => new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
@@ -84,9 +94,10 @@ try {
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, { encoded: frames, crop: stage });
|
||||
}, { encoded: [baselinePng.toString('base64'), ...frames], crop: stage });
|
||||
|
||||
const baseline = metrics[0] || { mean: 0, variance: 0, darkRatio: 1 };
|
||||
const baseline = allMetrics[0] || { mean: 0, variance: 0, darkRatio: 1 };
|
||||
const metrics = allMetrics.slice(1);
|
||||
const forbidden = metrics.filter((frame) => (
|
||||
frame.variance < Math.max(12, baseline.variance * 0.08)
|
||||
|| frame.mean < Math.max(4, baseline.mean * 0.35)
|
||||
|
||||
@@ -181,6 +181,8 @@ const out = await page.evaluate(async () => {
|
||||
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
|
||||
const clsOfState = (d, states) => {
|
||||
const saved = c.hass;
|
||||
const visibleSnapshot = c._visibleDeviceSnapshot;
|
||||
const candidateSnapshot = c._candidateDeviceSnapshot;
|
||||
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
|
||||
entity_id,
|
||||
{ entity_id, platform: 'demo', disabled_by: null },
|
||||
@@ -190,8 +192,12 @@ const out = await page.evaluate(async () => {
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states },
|
||||
};
|
||||
c._visibleDeviceSnapshot = null;
|
||||
c._candidateDeviceSnapshot = null;
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
c._visibleDeviceSnapshot = visibleSnapshot;
|
||||
c._candidateDeviceSnapshot = candidateSnapshot;
|
||||
return r;
|
||||
};
|
||||
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
|
||||
|
||||
@@ -45,6 +45,8 @@ const out = await page.evaluate(async () => {
|
||||
// =======================================================================
|
||||
const withStates = (states, fn) => {
|
||||
const saved = c.hass;
|
||||
const visibleSnapshot = c._visibleDeviceSnapshot;
|
||||
const candidateSnapshot = c._candidateDeviceSnapshot;
|
||||
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
|
||||
entity_id,
|
||||
{ entity_id, platform: 'demo', disabled_by: null },
|
||||
@@ -54,8 +56,12 @@ const out = await page.evaluate(async () => {
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states },
|
||||
};
|
||||
c._visibleDeviceSnapshot = null;
|
||||
c._candidateDeviceSnapshot = null;
|
||||
const r = fn();
|
||||
c.hass = saved;
|
||||
c._visibleDeviceSnapshot = visibleSnapshot;
|
||||
c._candidateDeviceSnapshot = candidateSnapshot;
|
||||
return r;
|
||||
};
|
||||
const st = (state, attrs = {}) => ({ state, attributes: attrs });
|
||||
|
||||
@@ -55,13 +55,14 @@ out.planIconsHidden = await page.evaluate(() => {
|
||||
await page.evaluate(() => window.__card._setMode('devices'));
|
||||
await page.waitForTimeout(200);
|
||||
out.devices = await st();
|
||||
out.devDragWorks = await page.evaluate(() => {
|
||||
out.devDragWorks = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const d = c._devices.find((x) => x.space === 'f1');
|
||||
const before = { ...c._pos(d) };
|
||||
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 3 }, d);
|
||||
c._pointerMove({ clientX: 100, clientY: 70 }, d);
|
||||
c._pointerUp({}, d);
|
||||
await c.updateComplete;
|
||||
const after = { ...c._pos(d) };
|
||||
return Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
await page.route('**/api/houseplan/content/plans/_/f1.tok.svg?authSig=*', (route) => route.fulfill({
|
||||
await page.route('**/api/houseplan/content/plans/_/*.tok.svg?authSig=*', (route) => route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#789"/></svg>',
|
||||
@@ -14,12 +14,20 @@ const res = await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
const raw = '/api/houseplan/content/plans/_/f1.tok.svg';
|
||||
const baselineRaw = '/api/houseplan/content/plans/_/f1-baseline.tok.svg';
|
||||
|
||||
// подложка на защищённом эндпоинте + управляемый ответ на подпись
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
cfg.spaces = cfg.spaces.map((s) => (s.id === 'f1' ? { ...s, plan_url: raw } : s));
|
||||
const baselineCfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
baselineCfg.spaces = baselineCfg.spaces.map((s) => (
|
||||
s.id === 'f1' ? { ...s, plan_url: baselineRaw } : s
|
||||
));
|
||||
const candidateCfg = JSON.parse(JSON.stringify(baselineCfg));
|
||||
candidateCfg.spaces = candidateCfg.spaces.map((s) => (
|
||||
s.id === 'f1' ? { ...s, plan_url: raw } : s
|
||||
));
|
||||
let servedCfg = baselineCfg;
|
||||
let signCalls = 0;
|
||||
let failFirst = true;
|
||||
let failCandidateOnce = true;
|
||||
const requestedHrefs = [];
|
||||
// A separately mounted test card gets its own HA connection authority. In
|
||||
// production cards sharing one connection deliberately share config/signing
|
||||
@@ -28,11 +36,14 @@ const res = await page.evaluate(async () => {
|
||||
...main.hass,
|
||||
connection: Object.create(main.hass.connection),
|
||||
callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/config/get') return { config: servedCfg, rev: signCalls + 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
signCalls++;
|
||||
if (failFirst && signCalls === 1) throw new Error('ws down');
|
||||
if (failCandidateOnce && m.paths.includes(raw)) {
|
||||
failCandidateOnce = false;
|
||||
throw new Error('ws down');
|
||||
}
|
||||
const urls = {};
|
||||
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
|
||||
return { urls };
|
||||
@@ -62,23 +73,32 @@ const res = await page.evaluate(async () => {
|
||||
};
|
||||
const href = async () => { const im = await stage(); return im ? im.getAttribute('href') : null; };
|
||||
|
||||
// Establish a decoded protected baseline first. A failed replacement must
|
||||
// retain this exact URL, not an unrelated public image from the warm cache.
|
||||
await stage();
|
||||
const baselineHref = await href();
|
||||
servedCfg = candidateCfg;
|
||||
await card._load(true);
|
||||
|
||||
// 1) первая подпись упала → сырой URL в DOM не попадает (иначе 401)
|
||||
await stage();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.hrefAfterFailedSign = await href();
|
||||
out.failedSignKeptProtectedBaseline = baselineHref?.includes('f1-baseline.tok.svg?authSig=SIG1')
|
||||
&& out.hrefAfterFailedSign === baselineHref;
|
||||
|
||||
// 2) сразу повтора нет: после ошибки подпись уходит в backoff (ревью R4-2),
|
||||
// иначе нестабильный сокет получал бы по запросу на каждый рендер
|
||||
for (let i = 0; i < 5; i++) { card.requestUpdate(); await card.updateComplete; }
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.noRetryStorm = signCalls === 1;
|
||||
out.noRetryStorm = signCalls === 2;
|
||||
|
||||
// 3) после выдержки повтор проходит
|
||||
await new Promise((r) => setTimeout(r, 2100));
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.hrefAfterRetry = await href();
|
||||
out.retried = signCalls === 2;
|
||||
out.retried = signCalls === 3;
|
||||
|
||||
// 4) повторный рендер не теряет подпись и не просит её заново
|
||||
const before = signCalls;
|
||||
@@ -104,11 +124,12 @@ const res = await page.evaluate(async () => {
|
||||
checkAll(res, {
|
||||
// #73 keeps the last complete frame until the replacement is signed and
|
||||
// decoded, so a transient signing failure must not blank the background.
|
||||
hrefAfterFailedSign: '/assets/f1.svg',
|
||||
hrefAfterFailedSign: '/api/houseplan/content/plans/_/f1-baseline.tok.svg?authSig=SIG1',
|
||||
failedSignKeptProtectedBaseline: true,
|
||||
noRetryStorm: true,
|
||||
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG3',
|
||||
retried: true,
|
||||
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG3',
|
||||
noExtraSignOnRerender: true,
|
||||
hrefWhenExpired: null,
|
||||
hrefWhenAging: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=AGING',
|
||||
|
||||
@@ -24,6 +24,8 @@ const out = await page.evaluate(async () => {
|
||||
angle: node.querySelector(':scope > ha-icon')?.style.transform || '',
|
||||
});
|
||||
const show = (device, states) => {
|
||||
const visibleSnapshot = card._visibleDeviceSnapshot;
|
||||
const candidateSnapshot = card._candidateDeviceSnapshot;
|
||||
card.hass = {
|
||||
...saved,
|
||||
entities: {
|
||||
@@ -34,8 +36,12 @@ const out = await page.evaluate(async () => {
|
||||
},
|
||||
states: { ...saved.states, ...states },
|
||||
};
|
||||
card._visibleDeviceSnapshot = null;
|
||||
card._candidateDeviceSnapshot = null;
|
||||
const presentation = card._devicePresentation(device);
|
||||
card.hass = saved;
|
||||
card._visibleDeviceSnapshot = visibleSnapshot;
|
||||
card._candidateDeviceSnapshot = candidateSnapshot;
|
||||
return {
|
||||
classes: presentation.classes,
|
||||
icon: presentation.icon,
|
||||
|
||||
@@ -83,7 +83,7 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
// trail off via marker option
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = false;
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
c._cfgEpoch++; c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.trailToggleOff = !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = null;
|
||||
|
||||
@@ -162,7 +162,7 @@ const out = await page.evaluate(async () => {
|
||||
o.integrationPathAlwaysAtRest = (restingIntegrationD.match(/M /g) || []).length === 2;
|
||||
// 'never' kills every line including the previous run
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'never';
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
c._cfgEpoch++; c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.neverHidesAll = !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = null;
|
||||
c._vacSrvTrails = {};
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
// visible across quick/long returns; recovery tokens may advance, pixels may
|
||||
// not disappear or regress to an empty/default frame.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
import { makeVisualMatrixFixture } from './fixtures/visual-matrix.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const fixture = makeVisualMatrixFixture();
|
||||
fixture.states['sun.sun'].attributes.azimuth = 0;
|
||||
fixture.config.spaces[1].plan_url = '/api/houseplan/content/continuity.svg';
|
||||
fixture.config.spaces[1].decor = [
|
||||
{ id: 'continuity-axis', kind: 'line', x1: 0.08, y1: 0.52, x2: 0.92, y2: 0.52,
|
||||
color: '#5d6a73', opacity: 0.45, width_cm: 1, line_style: 'dashed' },
|
||||
];
|
||||
|
||||
const result = await page.evaluate(async (visualFixture) => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
@@ -15,32 +24,74 @@ const result = await page.evaluate(async () => {
|
||||
while (!predicate() && performance.now() - start < timeout) await sleep(20);
|
||||
return predicate();
|
||||
};
|
||||
const originalHass = card.hass;
|
||||
const originalCallWS = originalHass.callWS;
|
||||
const fixtureCallWS = async (message) => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
return { config: visualFixture.config, rev: 73, can_write: true };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') return { layout: visualFixture.layout, rev: 73 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(visualFixture.devices);
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(visualFixture.entities);
|
||||
if (message.type === 'houseplan/content/sign') {
|
||||
return { urls: Object.fromEntries((message.paths || []).map((path) =>
|
||||
[path, '/assets/f1.svg?authSig=continuity'])) };
|
||||
}
|
||||
return originalCallWS.call(originalHass, message);
|
||||
};
|
||||
card._serverCfg = visualFixture.config;
|
||||
card._layout = visualFixture.layout;
|
||||
card._space = 'golden-lighting';
|
||||
card._regSignature = '';
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: visualFixture.states,
|
||||
entities: visualFixture.entities,
|
||||
devices: visualFixture.devices,
|
||||
areas: visualFixture.areas,
|
||||
callWS: fixtureCallWS,
|
||||
};
|
||||
await card.updateComplete;
|
||||
card.requestUpdate();
|
||||
|
||||
await waitUntil(() => !card._booting
|
||||
&& root().querySelector('.stage')
|
||||
&& root().querySelector('ha-card')?.dataset.frameFingerprint);
|
||||
&& root().querySelector('ha-card')?.dataset.frameFingerprint
|
||||
&& root().querySelector('.glow-pools')
|
||||
&& root().querySelector('.sunlayer')
|
||||
&& root().querySelector('image[href*="authSig=continuity"]'));
|
||||
|
||||
const stage = root().querySelector('.stage');
|
||||
const before = {
|
||||
rooms: stage.querySelectorAll('.room').length,
|
||||
viewBox: stage.querySelector('.zoomwrap > svg')?.getAttribute('viewBox'),
|
||||
fingerprint: root().querySelector('ha-card')?.dataset.frameFingerprint || '',
|
||||
snapshot: root().querySelector('ha-card')?.dataset.deviceSnapshotSequence || '',
|
||||
token: Number(root().querySelector('ha-card')?.dataset.continuityToken || 0),
|
||||
};
|
||||
out.hasCompleteBaseline = before.rooms > 0 && !!before.viewBox && !!before.fingerprint;
|
||||
out.hasCompleteBaseline = before.rooms > 0 && !!before.viewBox && !!before.fingerprint
|
||||
&& !!before.snapshot;
|
||||
const fixtureLayers = {
|
||||
wallbody: !!stage.querySelector('.wallbody'),
|
||||
opening: !!stage.querySelector('.opening'),
|
||||
glow: !!stage.querySelector('.glow-pools'),
|
||||
sun: !!stage.querySelector('.sunlayer'),
|
||||
decor: !!stage.querySelector('.decorlayer'),
|
||||
backdrop: !!stage.querySelector('image[href*="authSig=continuity"]'),
|
||||
devices: stage.querySelectorAll('.dev').length > 0,
|
||||
};
|
||||
out.fixtureCoversCriticalLayers = Object.values(fixtureLayers).every(Boolean);
|
||||
out.fixtureLayerDiagnostics = fixtureLayers;
|
||||
|
||||
// A short return is deliberately a direct no-op signal: no token/update.
|
||||
card._pageVisibility({
|
||||
kind: 'visible', token: 1, at: Date.now(), hiddenFor: 1000, long: false,
|
||||
});
|
||||
// Exercise the production Document listener, not the card's private callback.
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
await new Promise(requestAnimationFrame);
|
||||
const afterQuick = root().querySelector('ha-card');
|
||||
out.quickReturnSteady = afterQuick?.dataset.continuityState === 'steady';
|
||||
out.quickReturnKeepsToken = Number(afterQuick?.dataset.continuityToken || 0) === before.token;
|
||||
|
||||
// Long return may revalidate asynchronously. Sample every presented frame.
|
||||
card._pageVisibility({
|
||||
kind: 'visible', token: 2, at: Date.now(), hiddenFor: 20_000, long: true,
|
||||
});
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
|
||||
const samples = [];
|
||||
await new Promise((done) => {
|
||||
let left = 36;
|
||||
@@ -48,12 +99,23 @@ const result = await page.evaluate(async () => {
|
||||
const liveStage = root().querySelector('.stage');
|
||||
const zoom = liveStage?.querySelector('.zoomwrap');
|
||||
const computed = zoom ? getComputedStyle(zoom) : null;
|
||||
const host = root().querySelector('ha-card');
|
||||
const svg = liveStage?.querySelector('.zoomwrap > svg');
|
||||
const stageRect = liveStage?.getBoundingClientRect();
|
||||
const svgRect = svg?.getBoundingClientRect();
|
||||
samples.push({
|
||||
rooms: liveStage?.querySelectorAll('.room').length || 0,
|
||||
viewBox: liveStage?.querySelector('.zoomwrap > svg')?.getAttribute('viewBox') || '',
|
||||
visible: !!computed && computed.display !== 'none' && computed.visibility !== 'hidden',
|
||||
overlay: !!root().querySelector('.recoveryoverlay'),
|
||||
state: root().querySelector('ha-card')?.dataset.continuityState || '',
|
||||
state: host?.dataset.continuityState || '',
|
||||
token: Number(host?.dataset.continuityToken || 0),
|
||||
fingerprint: host?.dataset.frameFingerprint || '',
|
||||
snapshot: host?.dataset.deviceSnapshotSequence || '',
|
||||
href: liveStage?.querySelector('image')?.getAttribute('href') || '',
|
||||
blend: liveStage?.querySelector('.glow-pools')?.getAttribute('data-blend') || '',
|
||||
stageBox: stageRect ? [stageRect.x, stageRect.y, stageRect.width, stageRect.height] : [],
|
||||
svgBox: svgRect ? [svgRect.x, svgRect.y, svgRect.width, svgRect.height] : [],
|
||||
});
|
||||
if (--left > 0) requestAnimationFrame(sample);
|
||||
else done();
|
||||
@@ -66,12 +128,25 @@ const result = await page.evaluate(async () => {
|
||||
out.staleFrameNeedsNoOverlay = samples.every((sample) => !sample.overlay);
|
||||
out.noLegacyResumeClass = !root().querySelector('.stage.hpresume');
|
||||
out.productionAttributesPresent = samples.every((sample) => !!sample.state);
|
||||
out.longResumeAdvancesToken = samples.some((sample) => sample.token > before.token);
|
||||
out.frameSamplerHasRequiredFacts = samples.every((sample) => sample.fingerprint
|
||||
&& sample.snapshot && sample.href.includes('authSig=continuity')
|
||||
&& ['screen', 'normal'].includes(sample.blend)
|
||||
&& sample.stageBox.length === 4 && sample.svgBox.length === 4);
|
||||
|
||||
await waitUntil(() => root().querySelector('ha-card')?.dataset.continuityState === 'steady');
|
||||
out.longResumeEndsSteady = root().querySelector('ha-card')?.dataset.continuityState === 'steady'
|
||||
&& !root().querySelector('.recoveryoverlay');
|
||||
|
||||
const trace = card.houseplanContinuityTrace?.() || [];
|
||||
out.traceIsBoundedAndRedacted = trace.length > 0 && trace.length <= 80
|
||||
&& !JSON.stringify(trace).includes('/api/houseplan/content/');
|
||||
out.productionListenerReachedController = trace.some((event) =>
|
||||
event.event === 'candidate-start' && event.reason === 'pageshow');
|
||||
return out;
|
||||
});
|
||||
}, fixture);
|
||||
|
||||
for (const [name, value] of Object.entries(result)) check(name, value);
|
||||
for (const [name, value] of Object.entries(result)) {
|
||||
if (name !== 'fixtureLayerDiagnostics') check(name, value);
|
||||
}
|
||||
await finish(browser, result);
|
||||
|
||||
@@ -9,6 +9,8 @@ const out = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const cls = (d, states) => {
|
||||
const saved = c.hass;
|
||||
const visibleSnapshot = c._visibleDeviceSnapshot;
|
||||
const candidateSnapshot = c._candidateDeviceSnapshot;
|
||||
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
|
||||
entity_id,
|
||||
{ entity_id, platform: 'demo', disabled_by: null },
|
||||
@@ -18,8 +20,15 @@ const out = await page.evaluate(() => {
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states },
|
||||
};
|
||||
// Synthetic devices are deliberately outside the acquired demo registry.
|
||||
// Exercise the resolver against the injected HA shape, not the previously
|
||||
// committed plan frame used by the real DOM.
|
||||
c._visibleDeviceSnapshot = null;
|
||||
c._candidateDeviceSnapshot = null;
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
c._visibleDeviceSnapshot = visibleSnapshot;
|
||||
c._candidateDeviceSnapshot = candidateSnapshot;
|
||||
return r;
|
||||
};
|
||||
const trv = { id: 't', primary: 'climate.trv', entities: ['climate.trv', 'switch.trv_anti_scaling'], marker: null };
|
||||
|
||||
@@ -206,7 +206,7 @@ out.crossFloorLsIntact = out.crossFloorEditorExit.lsIntactCrossFloor;
|
||||
delete out.crossFloorEditorExit;
|
||||
|
||||
// -- devices outside rooms stretch the default frame ---------------------
|
||||
out.devicesStretchFrame = await page.evaluate(() => {
|
||||
out.devicesStretchFrame = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
@@ -214,6 +214,8 @@ out.devicesStretchFrame = await page.evaluate(() => {
|
||||
const before = c._baseVb();
|
||||
// walk one lamp far outside every room
|
||||
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.99, y: 0.5 } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const after = c._baseVb();
|
||||
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
|
||||
});
|
||||
|
||||
@@ -91,6 +91,8 @@ const DEVICES=Object.fromEntries([
|
||||
dev('d_gate','Gate','GDO-4','garden'),
|
||||
]);
|
||||
const ENTITIES={
|
||||
'sun.sun':{entity_id:'sun.sun',device_id:null,platform:'sun',disabled_by:null},
|
||||
'camera.static_robot_map':{entity_id:'camera.static_robot_map',device_id:null,platform:'demo',disabled_by:null},
|
||||
'light.ceiling':{entity_id:'light.ceiling',device_id:'d_light1',platform:'demo',config_entry_id:'ce_demo',disabled_by:null},
|
||||
'light.floor_lamp':{entity_id:'light.floor_lamp',device_id:'d_lamp',platform:'demo',disabled_by:null},
|
||||
'media_player.tv':{entity_id:'media_player.tv',device_id:'d_tv',platform:'demo',disabled_by:null},
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.61.0-beta.8 — 2026-08-11
|
||||
|
||||
- Plan recovery now has one bounded two-second barrier: a stalled signature or
|
||||
protected-backdrop decode can no longer leave an already complete plan below
|
||||
an opaque layer forever. The card retains its last coherent frame, while a
|
||||
cold failure exposes an explicit retryable error.
|
||||
- Added an immutable per-frame data snapshot. Devices, positions, room fills,
|
||||
Glow, sun, openings, vacuums and HA variables in decorative text now update
|
||||
together instead of mixing old and new state during reconnect.
|
||||
- Connection readiness is treated as recovery only after a confirmed loss;
|
||||
ordinary HA ticks no longer alter the structural frame fingerprint. Forced
|
||||
config refreshes, signed-URL caching and late image load events are now
|
||||
protected from their respective races.
|
||||
- Strengthened deterministic continuity, protected-backdrop and screencast
|
||||
coverage. The browser scenario now exercises the production visibility
|
||||
listener while checking geometry, devices, Glow, sun and decor together.
|
||||
|
||||
## v1.61.0-beta.7 — 2026-08-11
|
||||
|
||||
- Fixed rare but severe Glow geometry failures: a clipping exception can no
|
||||
|
||||
@@ -8,6 +8,23 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.61.0-beta.8 — 2026-08-11
|
||||
|
||||
- Восстановление плана теперь ограничено общим двухсекундным барьером: зависшая
|
||||
подпись или декодирование защищённой подложки больше не могут навсегда
|
||||
оставить уже готовый план под непрозрачным слоем. Карточка сохраняет последний
|
||||
цельный кадр, а при отсутствии такого кадра показывает явную ошибку с повтором.
|
||||
- Добавлен неизменяемый снимок данных одного кадра. Устройства, позиции, заливки
|
||||
комнат, Glow, солнце, проёмы, пылесосы и HA-переменные декоративного текста
|
||||
обновляются согласованно и не смешивают старое и новое состояния при reconnect.
|
||||
- Событие готовности соединения считается восстановлением только после
|
||||
подтверждённой потери связи; обычные HA-tick больше не меняют структурный
|
||||
отпечаток кадра. Устранены гонки принудительного обновления конфигурации,
|
||||
кеша подписанных URL и запоздалых событий загрузки изображений.
|
||||
- Усилены детерминированные проверки непрерывности кадра, защищённых подложек и
|
||||
screencast: сценарий теперь проходит через настоящий listener видимости и
|
||||
одновременно контролирует геометрию, устройства, Glow, солнце и декор.
|
||||
|
||||
## v1.61.0-beta.7 — 2026-08-11
|
||||
|
||||
- Исправлены редкие, но серьёзные сбои геометрии Glow: ошибка clipping больше
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<!-- release: v1.61.0-beta.7 -->
|
||||
<!-- release: v1.61.0-beta.8 -->
|
||||
|
||||
## Основное
|
||||
|
||||
- План сохраняет последний цельный кадр при возврате на вкладку, reconnect и remount, поэтому больше не должен мигать или пересобираться без необходимости ([#73](https://github.com/Matysh/houseplan-card/issues/73)).
|
||||
- Исправлены редкие сбои геометрии Glow, способные засветить область вне дома или исказить тени у стен.
|
||||
- Возврат на вкладку и восстановление соединения больше не могут навсегда скрыть готовый план; все динамические данные обновляются одним согласованным кадром.
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
|
||||
- The plan keeps its last coherent frame through tab resume, reconnect and remount, avoiding unnecessary flashes and rebuilds ([#73](https://github.com/Matysh/houseplan-card/issues/73)).
|
||||
- Fixed rare Glow geometry failures that could light outside the house or distort wall shadows.
|
||||
- Tab resume and connection recovery can no longer permanently cover a complete plan; all dynamic data now updates as one coherent frame.
|
||||
- Small fixes and improvements.
|
||||
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.61.0-beta.7/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.61.0-beta.7/docs/CHANGELOG.md)
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.61.0-beta.8/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.61.0-beta.8/docs/CHANGELOG.md)
|
||||
|
||||
@@ -21,8 +21,8 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.61.0-beta.6** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate with visibility-based light transport, stable room hover and shared responsive dialog chrome |
|
||||
| Current local cycle | After v1.61.0-beta.6, #73 visual continuity is implemented locally alongside the deterministic #79/#80 fixes and the beta.6 review corrections. Full and compact cards share one tokenised controller, keep the last coherent frame through resume/reconnect/remount/resize, adopt config/layout by revision plus content fingerprint, share loaded signed assets and expose a delayed localized recovery fallback only when no complete frame exists. Deterministic unit/frame-sequence coverage is added but not run yet under the owner's local-edit rule. Room hover remains split into a neutral clean-floor wash below Glow/sun/walls and a late outline above walls; the device CSS cascade restores common hover and neutral elevation shadow. Glow now fails dark on clipping errors and sources placed on barriers, normalizes the angular seam, fingerprints complete barrier geometry per space, freezes feather parameters through viewport gestures and has a semantic golden assertion that the receiving room is actually lit. Opening/space footers use the shared responsive action groups. `docs/LIGHT.md` remains canonical. |
|
||||
| Version | **v1.61.0-beta.8** everywhere (manifest, const.py, package.json, CARD_VERSION) — final prerelease candidate for the v1.61.0 stability release |
|
||||
| Current local cycle | v1.61.0-beta.8 completes the #73 review corrections. Full and compact cards use one bounded continuity barrier and immutable render-device snapshot, so a reconnect, delayed protected backdrop or ordinary HA tick cannot mix device, room, Glow, sun, opening, vacuum and live-text data from different frames or leave a complete plan trapped below an opaque recovery layer. Structural fingerprints ignore ordinary state ticks; reconnect semantics require a confirmed connection loss; signed-asset and shared-config races are bounded and retryable. The deterministic continuity smoke now exercises the production visibility listener and a fixture containing thick walls, openings, Glow, sun, decor, protected content and devices. `docs/LIGHT.md` remains canonical. |
|
||||
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.61.0-beta.7",
|
||||
"version": "1.61.0-beta.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.61.0-beta.7",
|
||||
"version": "1.61.0-beta.8",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.61.0-beta.7",
|
||||
"version": "1.61.0-beta.8",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface HpConfigSnapshot {
|
||||
|
||||
let cache: HpConfigSnapshot | null = null;
|
||||
let inflight: Promise<HpConfigSnapshot> | null = null;
|
||||
let fetchGeneration = 0;
|
||||
let inflightGeneration = -1;
|
||||
let subscribed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
@@ -48,12 +50,12 @@ export function cachedSnapshot(): HpConfigSnapshot | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
async function fetchFresh(hass: any, generation: number): Promise<HpConfigSnapshot> {
|
||||
const [cfgResp, layResp] = await Promise.all([
|
||||
hass.callWS({ type: 'houseplan/config/get' }),
|
||||
hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
cache = {
|
||||
const snapshot: HpConfigSnapshot = {
|
||||
config: cfgResp?.config ?? null,
|
||||
rev: cfgResp?.rev ?? 0,
|
||||
configFingerprint: contentFingerprint(cfgResp?.config ?? null),
|
||||
@@ -61,10 +63,15 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
layoutRev: layResp?.rev ?? 0,
|
||||
layoutFingerprint: contentFingerprint(layResp?.layout ?? {}),
|
||||
};
|
||||
// A force request may arrive while this fetch is in flight. Its older
|
||||
// response remains useful to its original caller, but must not repopulate
|
||||
// the shared cache after the newer generation invalidated it.
|
||||
if (generation === fetchGeneration) cache = snapshot;
|
||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||
subscribed = true;
|
||||
const invalidate = () => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
fetchGeneration++;
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
try {
|
||||
@@ -78,16 +85,32 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
subscribed = false;
|
||||
}
|
||||
}
|
||||
return cache;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Get the shared config snapshot (cached, deduped across cards). */
|
||||
export function getConfig(hass: any, force = false): Promise<HpConfigSnapshot> {
|
||||
if (force) cache = null;
|
||||
if (force) {
|
||||
cache = null;
|
||||
fetchGeneration++;
|
||||
if (inflight) {
|
||||
// Do not let `force` silently join the stale request it was meant to
|
||||
// supersede. Wait for that transport slot, then issue a fresh generation.
|
||||
return inflight.catch(() => null).then(() => getConfig(hass, false));
|
||||
}
|
||||
}
|
||||
if (cache) return Promise.resolve(cache);
|
||||
if (inflight) return inflight;
|
||||
inflight = fetchFresh(hass).finally(() => {
|
||||
if (inflight) {
|
||||
if (inflightGeneration !== fetchGeneration) {
|
||||
return inflight.catch(() => null).then(() => getConfig(hass, false));
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
const generation = fetchGeneration;
|
||||
inflightGeneration = generation;
|
||||
inflight = fetchFresh(hass, generation).finally(() => {
|
||||
inflight = null;
|
||||
inflightGeneration = -1;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,9 @@ import {
|
||||
presentationClasses, presentationSourceSignature, resolveDevicePresentation,
|
||||
resolvePresentationSources, type ResolvedDevicePresentation,
|
||||
} from './device-presentation';
|
||||
import {
|
||||
createRenderDeviceSnapshot, presentationSnapshotKey, type RenderDeviceSnapshot,
|
||||
} from './render-device-snapshot';
|
||||
import { deviceFaceStyle, renderDeviceFace } from './device-face';
|
||||
import {
|
||||
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
|
||||
@@ -160,7 +163,7 @@ import {
|
||||
import { renderOpeningTunnelFills } from './render/opening-tunnels';
|
||||
import { safeStoredColor } from './color';
|
||||
|
||||
const CARD_VERSION = '1.61.0-beta.7';
|
||||
const CARD_VERSION = '1.61.0-beta.8';
|
||||
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
|
||||
const DECOR_TEXT_CM_MAX = 2000;
|
||||
const CELL_CM_MIN = 0.1;
|
||||
@@ -1011,6 +1014,8 @@ class HouseplanCard extends LitElement {
|
||||
* First open only; kiosk is 100dvh and never jumps. */
|
||||
private _booting = true;
|
||||
private _bootFading = false; // veil kept one beat for the opacity-out
|
||||
private _bootSettling = false;
|
||||
private _bootSettleRaf = 0;
|
||||
private _bootTimer?: number;
|
||||
private _bootLastH = -1;
|
||||
private _bootStart = 0;
|
||||
@@ -1197,6 +1202,7 @@ class HouseplanCard extends LitElement {
|
||||
private _unsubTrail?: () => void;
|
||||
private _vacJumpOnce = false;
|
||||
private _continuity = this._newContinuityController();
|
||||
private _continuityHistory: import('./visual-continuity').ContinuityTraceEvent[] = [];
|
||||
private _continuityUnsub?: () => void;
|
||||
private _continuityEpoch = 0;
|
||||
private _continuityDataReady = true;
|
||||
@@ -1204,6 +1210,14 @@ class HouseplanCard extends LitElement {
|
||||
private _continuityDisposed = false;
|
||||
private _renderSnapshotAt = Date.now();
|
||||
private _hassSequence = 0;
|
||||
private _visibleDeviceSnapshot: RenderDeviceSnapshot | null = null;
|
||||
private _candidateDeviceSnapshot: RenderDeviceSnapshot | null = null;
|
||||
private _stagedDeviceSnapshotToken = -1;
|
||||
private _capturedSnapshotSequence = -1;
|
||||
private _capturedSnapshotDevices: DevItem[] | null = null;
|
||||
private _capturedSnapshotLayout: Record<string, { x: number; y: number; s?: string; k?: number }> | null = null;
|
||||
private _capturedSnapshotActivity = '';
|
||||
private _capturedSnapshotConfigEpoch = -1;
|
||||
private _lastValidStageSize: [number, number] | null = null;
|
||||
private _pendingRefitSize: [number, number] | null = null;
|
||||
private _refitRaf = 0;
|
||||
@@ -1217,7 +1231,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _pageVisibility = (signal: PageVisibilitySignal): void => {
|
||||
const token = this._continuity.visibility(signal);
|
||||
this._continuity.visibility(signal);
|
||||
if (signal.kind === 'hidden') return;
|
||||
this._vacJumpOnce = true;
|
||||
if (!signal.long) return; // strict quick-return no-op: preserve hover and DOM
|
||||
@@ -1229,10 +1243,8 @@ class HouseplanCard extends LitElement {
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
this._resumeSettling = true;
|
||||
if (token === this._continuity.token) {
|
||||
if (!this._loading) void this._loadFromServer();
|
||||
else this.requestUpdate();
|
||||
}
|
||||
if (!this._loading) void this._loadFromServer();
|
||||
else this.requestUpdate();
|
||||
};
|
||||
private _resumeSettling = false;
|
||||
private _viewportInvalidAt = 0;
|
||||
@@ -1429,6 +1441,8 @@ class HouseplanCard extends LitElement {
|
||||
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
|
||||
if (this._refitRaf) { cancelAnimationFrame(this._refitRaf); this._refitRaf = 0; }
|
||||
if (this._skySnapRaf) { cancelAnimationFrame(this._skySnapRaf); this._skySnapRaf = 0; }
|
||||
if (this._bootSettleRaf) { cancelAnimationFrame(this._bootSettleRaf); this._bootSettleRaf = 0; }
|
||||
this._bootSettling = false;
|
||||
for (const rt of this._activityRt.values()) clearTimeout(rt.timer); // pending activity-window repaints
|
||||
window.removeEventListener('keydown', this._keyHandler);
|
||||
clearInterval(this._cycleTimer);
|
||||
@@ -1519,6 +1533,7 @@ class HouseplanCard extends LitElement {
|
||||
// after the disconnect snapshot (which must still skip unstable geometry),
|
||||
// so a same-element reattach can start fresh instead of keeping the veil.
|
||||
this._resumeSettling = false;
|
||||
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
|
||||
this._continuity.dispose();
|
||||
this._continuityDisposed = true;
|
||||
super.disconnectedCallback();
|
||||
@@ -2223,6 +2238,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._booting && !this._continuity.hasCompleteFrame) return this._continuity.token;
|
||||
this._continuityDataReady = dataReady;
|
||||
this._continuityPaintToken = -1;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
this._resumeSettling = true;
|
||||
return this._continuity.beginCandidate(reason, recoveryReason);
|
||||
}
|
||||
@@ -2233,6 +2249,10 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _continuityAssetsReady(): boolean {
|
||||
// An empty/transient model is not a complete plan frame. Returning true
|
||||
// here used to let the previous DOM be blessed while the replacement
|
||||
// config was still being projected.
|
||||
if (!this._model.length) return false;
|
||||
const space = this._model.length ? this._spaceModel() : null;
|
||||
return !space?.bg?.href || this._signer.isReady(this.hass, space.bg.href);
|
||||
}
|
||||
@@ -2260,7 +2280,6 @@ class HouseplanCard extends LitElement {
|
||||
this._view,
|
||||
size,
|
||||
this._glowScreenBlend ? 'screen' : 'normal',
|
||||
this._hassSequence,
|
||||
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
|
||||
]);
|
||||
}
|
||||
@@ -2269,18 +2288,25 @@ class HouseplanCard extends LitElement {
|
||||
private _settleContinuityFrame(): void {
|
||||
if (this._booting || !this._continuityStageValid()) return;
|
||||
if (!this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
|
||||
if (!this._continuityAssetsReady()) {
|
||||
this._beginContinuityCandidate('asset-wait', true, 'asset');
|
||||
return;
|
||||
// Cold start belongs to the boot veil. Do not replace it with a recovery
|
||||
// error just because a protected backdrop is still signing/decoding.
|
||||
if (this._continuityAssetsReady()) {
|
||||
this._renderSnapshotAt = Date.now();
|
||||
this._continuity.markCompleteFrame(this._visualFrameFingerprint());
|
||||
}
|
||||
this._renderSnapshotAt = Date.now();
|
||||
this._continuity.markCompleteFrame(this._visualFrameFingerprint());
|
||||
return;
|
||||
}
|
||||
if (!this._continuityDataReady || !this._continuityAssetsReady()) return;
|
||||
if (!this._continuityDataReady) return;
|
||||
if (!['holding', 'offline-stale', 'overlay-pending', 'overlay-visible', 'candidate-ready']
|
||||
.includes(this._continuity.state)) return;
|
||||
const token = this._continuity.token;
|
||||
if (this._candidateDeviceSnapshot
|
||||
&& this._candidateDeviceSnapshot !== this._visibleDeviceSnapshot
|
||||
&& this._stagedDeviceSnapshotToken !== token) {
|
||||
this._stagedDeviceSnapshotToken = token;
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (this._continuityPaintToken === token) return;
|
||||
this._continuityPaintToken = token;
|
||||
if (!this._continuity.candidateReady(token)) return;
|
||||
@@ -2291,17 +2317,25 @@ class HouseplanCard extends LitElement {
|
||||
frameFingerprint: () => this._visualFrameFingerprint(),
|
||||
}).then((committed) => {
|
||||
if (!committed || token !== this._continuity.token) {
|
||||
if (token === this._continuity.token) this._continuityPaintToken = -1;
|
||||
if (token === this._continuity.token) {
|
||||
this._continuityPaintToken = -1;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._resumeSettling = false;
|
||||
this._renderSnapshotAt = Date.now();
|
||||
if (this._candidateDeviceSnapshot) this._visibleDeviceSnapshot = this._candidateDeviceSnapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
this._warmSnapshot();
|
||||
});
|
||||
}
|
||||
|
||||
private _onBackdropLoaded(raw: string): void {
|
||||
this._signer.markLoaded(this.hass, raw);
|
||||
private _onBackdropLoaded(raw: string, paintedUrl?: string): void {
|
||||
this._signer.markLoaded(this.hass, raw, paintedUrl);
|
||||
this._continuity.note('asset-ready');
|
||||
this._continuityPaintToken = -1;
|
||||
if (this._continuity.state !== 'steady') this.requestUpdate();
|
||||
@@ -2332,7 +2366,8 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Redacted lifecycle diagnostics used by the deterministic sampler. */
|
||||
public houseplanContinuityTrace(): readonly import('./visual-continuity').ContinuityTraceEvent[] {
|
||||
return this._continuity.trace;
|
||||
return [...this._continuityHistory, ...this._continuity.trace].slice(-80)
|
||||
.map((event) => ({ ...event, ...(event.stage ? { stage: [...event.stage] as [number, number] } : {}) }));
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
@@ -2522,7 +2557,6 @@ class HouseplanCard extends LitElement {
|
||||
// invariant local to that reactive assignment so imports, reconnects and
|
||||
// demo harnesses cannot accidentally reuse an older config object's data.
|
||||
if (changed.has('_serverCfg')) this._cfgEpoch++;
|
||||
this._skyPlan();
|
||||
if (changed.has('hass') && this.hass) {
|
||||
this._hassSequence++;
|
||||
this._renderSnapshotAt = Date.now();
|
||||
@@ -2542,6 +2576,11 @@ class HouseplanCard extends LitElement {
|
||||
if (this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
|
||||
this._continuity.refreshCompleteFrame(this._visualFrameFingerprint());
|
||||
}
|
||||
this._captureRenderDeviceSnapshot();
|
||||
// The sky is part of the same atomic frame as sun rays and devices. Plan
|
||||
// it only after the candidate/visible render snapshot has been refreshed;
|
||||
// doing this first made day/night presentation lag every HA tick by one.
|
||||
this._skyPlan();
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
@@ -2692,6 +2731,7 @@ class HouseplanCard extends LitElement {
|
||||
this._beginContinuityCandidate('structural-response', true);
|
||||
}
|
||||
this._loadOk = true;
|
||||
this._connectionWasLost = false;
|
||||
this._serverStorage = true;
|
||||
// absent can_write = older backend / demo stub → keep null (legacy admin fallback)
|
||||
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
|
||||
@@ -2778,11 +2818,14 @@ class HouseplanCard extends LitElement {
|
||||
// next hass update (WS warm-up)
|
||||
} finally {
|
||||
this._loading = false;
|
||||
// Readiness belongs to the candidate attempt, including a bounded asset
|
||||
// failure. Leaving it false on an early return stranded the controller
|
||||
// before its own two-second barrier could ever start.
|
||||
this._continuityDataReady = true;
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this.requestUpdate();
|
||||
}
|
||||
this._continuityDataReady = true;
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2864,6 +2907,7 @@ class HouseplanCard extends LitElement {
|
||||
* down would never run again. 'ready' fires on every (re)connect: reset
|
||||
* the budget and quietly re-read the config. */
|
||||
private _connHooked: { removeEventListener?: (t: string, cb: () => void) => void } | null = null;
|
||||
private _connectionWasLost = false;
|
||||
/** Shared, page-level full HA registry authority (one fetch/subscription per connection). */
|
||||
private _haRegistryRelease?: () => void;
|
||||
private _haRegistryConnection: any = null;
|
||||
@@ -2910,6 +2954,131 @@ class HouseplanCard extends LitElement {
|
||||
return active;
|
||||
}
|
||||
|
||||
private _captureRenderDeviceSnapshot(): void {
|
||||
if (!this.hass) return;
|
||||
const now = Date.now();
|
||||
const activity = [...this._activityRt.entries()]
|
||||
.map(([id, runtime]) => `${id}:${runtime.gen}:${runtime.flashTs}:`
|
||||
+ `${runtime.flashTs > 0 && now - runtime.flashTs <= ACTIVITY_WINDOW_MS ? 1 : 0}`)
|
||||
.join('|');
|
||||
if (this._capturedSnapshotSequence === this._hassSequence
|
||||
&& this._capturedSnapshotDevices === this._devices
|
||||
&& this._capturedSnapshotLayout === this._layout
|
||||
&& this._capturedSnapshotConfigEpoch === this._cfgEpoch
|
||||
&& this._capturedSnapshotActivity === activity) return;
|
||||
const planHass = this._planHass;
|
||||
const presentations = new Map<string, ResolvedDevicePresentation>();
|
||||
const facts = new Map<string, unknown>();
|
||||
const entityIds = new Set<string>(['sun.sun']);
|
||||
if (this._vacFit?.source) entityIds.add(this._vacFit.source);
|
||||
const deviceIds = new Set<string>();
|
||||
const areaIds = new Set<string>();
|
||||
const addSource = (source: string | null | undefined): void => {
|
||||
if (!source) return;
|
||||
const separator = source.indexOf(':');
|
||||
if (separator < 0) { entityIds.add(source); return; }
|
||||
const kind = source.slice(0, separator), reference = source.slice(separator + 1);
|
||||
if (kind === 'device') deviceIds.add(reference);
|
||||
else if (kind === 'entity') entityIds.add(reference);
|
||||
};
|
||||
for (const space of this._model) for (const room of space.rooms) {
|
||||
if (room.area) areaIds.add(room.area);
|
||||
addSource(room.settings?.temp_source);
|
||||
addSource(room.settings?.hum_source);
|
||||
}
|
||||
for (const rawSpace of this._serverCfg?.spaces || []) for (const opening of rawSpace.openings || []) {
|
||||
if (opening.contact) entityIds.add(opening.contact);
|
||||
if (opening.lock) entityIds.add(opening.lock);
|
||||
}
|
||||
// Inline HA variables on the decorative layer are part of the painted
|
||||
// frame too. Capture both the current token format and the legacy
|
||||
// one-entity fields so a reconnect cannot update a label ahead of the
|
||||
// rooms, devices and light layers around it.
|
||||
for (const rawSpace of this._serverCfg?.spaces || []) {
|
||||
for (const shape of rawSpace.decor || []) {
|
||||
if (shape.kind !== 'text') continue;
|
||||
for (const match of String(shape.text || '').matchAll(/\{([^{}\r\n]+)\}/g)) {
|
||||
const reference = liveTextReference(match[1]);
|
||||
if (reference?.entity) entityIds.add(reference.entity);
|
||||
}
|
||||
if (shape.entity) entityIds.add(shape.entity);
|
||||
// HA's formatter functions are runtime capabilities and must not cross
|
||||
// the immutable render-snapshot boundary. Freeze the already formatted
|
||||
// caption instead, so locale/precision/unit and every surrounding plan
|
||||
// layer belong to one atomic HA frame.
|
||||
facts.set(`decor:${rawSpace.id}:${shape.id}`, liveText(
|
||||
shape.text, shape, planHass,
|
||||
(entityId) => !!planHass.entities?.[entityId] && !!planHass.states?.[entityId]
|
||||
&& !isRemovedPlanEntity(planHass, entityId, removedPlanBindings(this._markers)),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (const device of this._devices) {
|
||||
for (const showLqi of [false, true]) {
|
||||
presentations.set(presentationSnapshotKey(device.id, showLqi), resolveDevicePresentation(
|
||||
planHass, device, {
|
||||
liveStates: this._config?.live_states !== false,
|
||||
showTemperature: this._config?.show_temperature !== false,
|
||||
showSignal: showLqi && this._config?.show_signal !== false,
|
||||
activityRuntime: this._activityRt.get(device.id),
|
||||
sourceDetails: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
if (this._isVacDev(device)) {
|
||||
const source = this._vacSource(device, planHass);
|
||||
const telemetry = source ? readVacTelemetry(planHass?.states?.[source]?.attributes) : null;
|
||||
const runtime = this._vacRt.get(device.id);
|
||||
facts.set(`vacuum:${device.id}`, {
|
||||
source,
|
||||
telemetry,
|
||||
mapId: telemetry ? this._vacMapId(device, telemetry, planHass) : null,
|
||||
runtime: runtime ? {
|
||||
trail: runtime.trail,
|
||||
lastTs: runtime.lastTs,
|
||||
moving: runtime.moving,
|
||||
jump: runtime.jump,
|
||||
} : null,
|
||||
server: this._vacSrvTrails[device.id] || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const snapshot = createRenderDeviceSnapshot({
|
||||
sourceSequence: this._hassSequence, hass: planHass,
|
||||
devices: this._devices, presentations,
|
||||
positions: new Map(this._devices.map((device) => [device.id, this._livePos(device)])),
|
||||
facts,
|
||||
entityIds,
|
||||
deviceIds,
|
||||
areaIds,
|
||||
});
|
||||
this._capturedSnapshotSequence = this._hassSequence;
|
||||
this._capturedSnapshotDevices = this._devices;
|
||||
this._capturedSnapshotLayout = this._layout;
|
||||
this._capturedSnapshotActivity = activity;
|
||||
this._capturedSnapshotConfigEpoch = this._cfgEpoch;
|
||||
if (!this._visibleDeviceSnapshot || this._continuity.state === 'steady') {
|
||||
this._visibleDeviceSnapshot = snapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
} else {
|
||||
this._candidateDeviceSnapshot = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
private get _renderDeviceSnapshot(): RenderDeviceSnapshot | null {
|
||||
return this._stagedDeviceSnapshotToken === this._continuity.token
|
||||
? this._candidateDeviceSnapshot || this._visibleDeviceSnapshot
|
||||
: this._visibleDeviceSnapshot || this._candidateDeviceSnapshot;
|
||||
}
|
||||
|
||||
private get _renderPlanHass(): any {
|
||||
return this._renderDeviceSnapshot?.hass || this._planHass;
|
||||
}
|
||||
|
||||
private get _renderDevices(): readonly DevItem[] {
|
||||
return this._renderDeviceSnapshot?.devices || this._devices;
|
||||
}
|
||||
|
||||
/** Full registry metadata for dialogs/ghost labels; never use for actions. */
|
||||
private get _fullRegistryHass(): any {
|
||||
void this._planHass;
|
||||
@@ -2990,7 +3159,19 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._loadRetryTimer);
|
||||
this._loadRetryTimer = undefined;
|
||||
refreshHaRegistries(this.hass);
|
||||
this._beginContinuityCandidate('connection-ready', false, 'connection');
|
||||
if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush();
|
||||
if (this._cfgWriting) {
|
||||
clearTimeout(this._reloadRetry);
|
||||
this._reloadRetry = window.setTimeout(this._onConnReady, 400);
|
||||
return;
|
||||
}
|
||||
if (!this._connectionWasLost && this._continuity.hasCompleteFrame) {
|
||||
this._beginContinuityCandidate('connection-ready', false, 'plan');
|
||||
} else {
|
||||
// A confirmed loss already owns the connection candidate/token.
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
}
|
||||
if (this._loading) return;
|
||||
// a subscribe lost mid-load leaves _loadOk=true without _unsubCfg — the
|
||||
// full load path repairs both (every subscribe in it is guarded)
|
||||
@@ -3000,6 +3181,7 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
private _onConnLost = (): void => {
|
||||
if (this._booting && !this._continuity.hasCompleteFrame) return;
|
||||
this._connectionWasLost = true;
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
this._continuity.connectionLost();
|
||||
@@ -3258,9 +3440,9 @@ class HouseplanCard extends LitElement {
|
||||
private _roomLqi(area: string | null): number | null {
|
||||
if (!area) return null;
|
||||
const vals: number[] = [];
|
||||
for (const d of this._devices) {
|
||||
for (const d of this._renderDevices) {
|
||||
if (d.area !== area || d.virtual) continue;
|
||||
const l = lqiFor(this.hass, d.entities);
|
||||
const l = lqiFor(this._renderPlanHass, d.entities);
|
||||
if (l != null) vals.push(l);
|
||||
}
|
||||
return averageLqi(vals);
|
||||
@@ -3318,6 +3500,11 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Device position in render units of the current space. */
|
||||
private _pos(d: DevItem): { x: number; y: number } {
|
||||
const captured = this._renderDeviceSnapshot?.positions.get(d.id);
|
||||
return captured ? { x: captured.x, y: captured.y } : this._livePos(d);
|
||||
}
|
||||
|
||||
private _livePos(d: DevItem): { x: number; y: number } {
|
||||
const s = this._spaceModel(d.space);
|
||||
const saved = this._layout[d.id];
|
||||
if (saved) {
|
||||
@@ -3388,14 +3575,18 @@ class HouseplanCard extends LitElement {
|
||||
* functional source.
|
||||
*/
|
||||
private _visualSamples(d: DevItem): EntityVisualSample[] {
|
||||
return resolvePresentationSources(this._planHass, d).samples;
|
||||
return resolvePresentationSources(this._renderPlanHass, d).samples;
|
||||
}
|
||||
|
||||
/** One semantic projection consumed by the plan, preview and static card. */
|
||||
private _devicePresentation(
|
||||
d: DevItem, showLqi = true, designPreview = false,
|
||||
): ResolvedDevicePresentation {
|
||||
return resolveDevicePresentation(this._planHass, d, {
|
||||
const captured = !designPreview
|
||||
? this._renderDeviceSnapshot?.presentations.get(presentationSnapshotKey(d.id, showLqi))
|
||||
: null;
|
||||
if (captured) return captured;
|
||||
return resolveDevicePresentation(this._renderPlanHass, d, {
|
||||
liveStates: this._config?.live_states !== false,
|
||||
showTemperature: this._config?.show_temperature !== false,
|
||||
showSignal: showLqi && this._config?.show_signal !== false,
|
||||
@@ -3426,11 +3617,11 @@ class HouseplanCard extends LitElement {
|
||||
// current_temperature gets the same badge a thermometer has. No valid
|
||||
// reading (missing attribute, unavailable) -> no badge at all.
|
||||
if (d.marker?.use_climate_temp === true) {
|
||||
const t = climateTempFor(this.hass, d.entities);
|
||||
const t = climateTempFor(this._renderPlanHass, d.entities);
|
||||
if (t != null) return t;
|
||||
}
|
||||
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null;
|
||||
return tempFor(this.hass, d.entities);
|
||||
return tempFor(this._renderPlanHass, d.entities);
|
||||
}
|
||||
|
||||
/** Every HA entity owned by a dialog binding. */
|
||||
@@ -3926,9 +4117,22 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _bootSettled(): void {
|
||||
if (!this._booting || this._bootSettling) return;
|
||||
this._bootSettling = true;
|
||||
this._refitView(); // calculate the final geometry while still covered
|
||||
this._bootSettleRaf = requestAnimationFrame(() => {
|
||||
this._bootSettleRaf = 0;
|
||||
this._finishBootSettled();
|
||||
});
|
||||
}
|
||||
|
||||
private _finishBootSettled(): void {
|
||||
if (!this._booting) return;
|
||||
this._refitView(); // reveal in the final geometry, never mid-jump
|
||||
// `_applyView()` is reactive: wait one paint opportunity, refit once more,
|
||||
// and only then remove the veil. This prevents a default-view reveal.
|
||||
this._refitView();
|
||||
this._booting = false;
|
||||
this._bootSettling = false;
|
||||
// DEV-B703-01: the page has settled once — the next instance with the
|
||||
// same config at the same viewport opens warm (no veil, no wait).
|
||||
const settledH = this._stageEl?.clientHeight ?? 0;
|
||||
@@ -3958,12 +4162,11 @@ class HouseplanCard extends LitElement {
|
||||
private _beginResumeSettle(): void {
|
||||
if (this._booting || this._resumeSettling) return;
|
||||
this._viewportInvalidAt = 0;
|
||||
const token = this._beginContinuityCandidate('warm-resume', false);
|
||||
this._beginContinuityCandidate('warm-resume', false);
|
||||
// A card can be detached for the whole visibility event and reattached as
|
||||
// the same already-loaded instance. In that case `willUpdate()` has no
|
||||
// reason to reload, so the warm tombstone must start its own revalidation
|
||||
// instead of immediately blessing the stale frame as current.
|
||||
if (token !== this._continuity.token) return;
|
||||
if (!this._loading) void this._loadFromServer();
|
||||
else this.requestUpdate();
|
||||
}
|
||||
@@ -7619,8 +7822,9 @@ class HouseplanCard extends LitElement {
|
||||
// entity `liveText` gives the stored text back byte-for-byte, so a
|
||||
// plain label is the plain label it always was (docs/LIVE-TEXT.md).
|
||||
const fs = this._decorTextUnits(sh);
|
||||
const lines = decorTextLines(liveText(
|
||||
sh.text, sh, this.hass, (eid) => this._planEntityAvailable(eid),
|
||||
const frozenText = this._renderDeviceSnapshot?.facts.get(`decor:${this._space}:${sh.id}`);
|
||||
const lines = decorTextLines(typeof frozenText === 'string' ? frozenText : liveText(
|
||||
sh.text, sh, this._renderPlanHass, (eid) => this._renderEntityAvailable(eid),
|
||||
));
|
||||
const ax = sh.x * W, ay = sh.y * H;
|
||||
const ang = Number(sh.angle) || 0;
|
||||
@@ -8848,7 +9052,7 @@ class HouseplanCard extends LitElement {
|
||||
mode,
|
||||
mode === 'lqi' && room.area ? this._roomLqi(room.area) : null,
|
||||
mode === 'light'
|
||||
? resolvedLightState(resolvedLightSources(this._planHass, this._devices, room))
|
||||
? resolvedLightState(resolvedLightSources(this._renderPlanHass, this._renderDevices, room))
|
||||
: 'none',
|
||||
mode === 'temp' ? this._roomTemp(room) : null,
|
||||
disp.tempMin,
|
||||
@@ -11042,7 +11246,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** sun.sun, but only when the feature is armed (north_deg set somewhere). */
|
||||
private _sunNow(): { azimuth: number; elevation: number } | null {
|
||||
return this._effNorth() !== null ? sunStateOf(this.hass) : null;
|
||||
return this._effNorth() !== null ? sunStateOf(this._renderPlanHass) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11056,7 +11260,7 @@ class HouseplanCard extends LitElement {
|
||||
// with rays off, or night. Those never fade, they just are not there.
|
||||
if (this._editing || !this._effSunRays()) { this._sunFadeReset(); return empty; }
|
||||
const north = this._effNorth();
|
||||
const sun = north !== null ? sunStateOf(this.hass) : null;
|
||||
const sun = north !== null ? sunStateOf(this._renderPlanHass) : null;
|
||||
if (!sun || sun.elevation <= 0) { this._sunFadeReset(); return empty; }
|
||||
const alpha = rayPeakAlpha();
|
||||
// The ONE thing that fades (owner 2026-08-03): the 3° threshold. Above it
|
||||
@@ -11757,8 +11961,8 @@ class HouseplanCard extends LitElement {
|
||||
const physical = this._physicalBodiesR(space);
|
||||
const { occluders, floor, fingerprint } = this._lightBarriers(space, polys, physical);
|
||||
const resolvedSources = resolvedLightSources(
|
||||
this._planHass,
|
||||
this._devices.filter((d) => d.space === space.id),
|
||||
this._renderPlanHass,
|
||||
this._renderDevices.filter((d) => d.space === space.id),
|
||||
);
|
||||
const sourcesByDevice = new Map<string, typeof resolvedSources>();
|
||||
for (const source of resolvedSources) {
|
||||
@@ -11780,7 +11984,7 @@ class HouseplanCard extends LitElement {
|
||||
r: number;
|
||||
}[] = [];
|
||||
const seenSourceKeys = new Set<string>();
|
||||
for (const d of this._devices) {
|
||||
for (const d of this._renderDevices) {
|
||||
if (d.space !== space.id) continue;
|
||||
const source = selectSpatialGlowSource(sourcesByDevice.get(d.id) || []);
|
||||
if (!source) continue;
|
||||
@@ -11789,7 +11993,7 @@ class HouseplanCard extends LitElement {
|
||||
const key = `${space.id}|${d.id}`;
|
||||
seenSourceKeys.add(key);
|
||||
const visibleGlow = resolveGlowAppearance(
|
||||
this._planHass.states[source.eid], d.marker?.glow_color, colors.glow_light.c,
|
||||
this._renderPlanHass.states[source.eid], d.marker?.glow_color, colors.glow_light.c,
|
||||
);
|
||||
// per-source radius (owner's decision v1.36.2): marker override, else global
|
||||
const ownCm = Number(d.marker?.glow_radius_cm);
|
||||
@@ -12317,7 +12521,8 @@ class HouseplanCard extends LitElement {
|
||||
// (ghosted); everywhere else the flag removes them from sight — but not
|
||||
// from the build, so room LQI still counts them (docs/FILTERING.md)
|
||||
const showGhosts = this._mode === 'devices' && this._showAll;
|
||||
const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
|
||||
const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
|
||||
const deviceSnapshot = this._renderDeviceSnapshot;
|
||||
const disp = this._spaceDisplayForRender();
|
||||
const roomFills = this._resolvedRoomFills(space, disp);
|
||||
const glowBase = this._resolvedGlowBase(space, disp, roomFills);
|
||||
@@ -12347,6 +12552,7 @@ class HouseplanCard extends LitElement {
|
||||
data-continuity-state=${this._continuity.state}
|
||||
data-continuity-token=${this._continuity.token}
|
||||
data-frame-fingerprint=${this._continuity.frameFingerprint || nothing}
|
||||
data-device-snapshot-sequence=${deviceSnapshot?.sourceSequence ?? nothing}
|
||||
data-recovery-reason=${recoveryReason || nothing}
|
||||
data-ha-registry-access=${diagnostics.registry.access}
|
||||
data-ha-disabled-bindings=${diagnostics.bindings.ha_disabled}
|
||||
@@ -12480,7 +12686,7 @@ class HouseplanCard extends LitElement {
|
||||
${space.bg && backdropHref
|
||||
? svg`<image href="${backdropHref}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}"
|
||||
opacity="${this._mode === 'decor' && this._decorTool !== 'backdrop' ? 0.5 : 1}"
|
||||
@load=${() => this._onBackdropLoaded(space.bg!.href)}
|
||||
@load=${() => this._onBackdropLoaded(space.bg!.href, backdropHref)}
|
||||
transform=${space.bg.angle
|
||||
? `rotate(${space.bg.angle} ${space.bg.x + space.bg.w / 2} ${space.bg.y + space.bg.h / 2})`
|
||||
: nothing}
|
||||
@@ -12787,12 +12993,14 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
// ---------------- live robot vacuums (docs/VACUUM.md) ----------------
|
||||
|
||||
private _vacCandidateStatus(entityId: string, candidate: VacSourceCandidate | null): VacSourceStatus {
|
||||
private _vacCandidateStatus(
|
||||
entityId: string, candidate: VacSourceCandidate | null, planHass = this._planHass,
|
||||
): VacSourceStatus {
|
||||
const binding = this._bindingStatus('entity:' + entityId);
|
||||
if (binding.kind === 'ha_disabled') return 'disabled';
|
||||
if (binding.kind === 'orphaned') return 'missing';
|
||||
if (binding.kind === 'unverified') return 'unverified';
|
||||
const state = this.hass?.states?.[entityId];
|
||||
const state = planHass?.states?.[entityId];
|
||||
if (state?.state === 'unavailable') return 'unavailable';
|
||||
return candidate?.hasPosition ? 'ok' : 'unsupported';
|
||||
}
|
||||
@@ -12811,16 +13019,18 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** One sticky, order-independent source resolver for render/dialog/fit. */
|
||||
private _vacSourceResolution(d: DevItem, includeAllCameras = false): VacSourceResolution {
|
||||
private _vacSourceResolution(
|
||||
d: DevItem, includeAllCameras = false, planHass = this._planHass,
|
||||
): VacSourceResolution {
|
||||
const vacuum = d.marker?.vacuum;
|
||||
const pinned = typeof vacuum?.source === 'string' && !!vacuum.source;
|
||||
const sameDevice = new Set(d.entities || []);
|
||||
const ids = new Set<string>(sameDevice);
|
||||
if (pinned) ids.add(vacuum!.source!);
|
||||
const registry = this._haRegistry.entities || {};
|
||||
const registry = planHass?.entities || {};
|
||||
const candidates: VacSourceCandidate[] = [];
|
||||
for (const entityId of ids) {
|
||||
const state = this.hass?.states?.[entityId];
|
||||
const state = planHass?.states?.[entityId];
|
||||
const candidate = parseVacSourceCandidate(entityId, state, registry[entityId]);
|
||||
if (candidate) candidates.push(candidate);
|
||||
else if (pinned && entityId === vacuum!.source) {
|
||||
@@ -12841,15 +13051,15 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
const statuses: Record<string, VacSourceStatus> = {};
|
||||
for (const candidate of candidates) {
|
||||
statuses[candidate.entityId] = this._vacCandidateStatus(candidate.entityId, candidate);
|
||||
statuses[candidate.entityId] = this._vacCandidateStatus(candidate.entityId, candidate, planHass);
|
||||
}
|
||||
return resolveVacSource(vacuum?.source, sameDevice, candidates, statuses);
|
||||
}
|
||||
|
||||
/** The sticky/automatic live-position source entity, or null. */
|
||||
private _vacSource(d: DevItem): string | null {
|
||||
private _vacSource(d: DevItem, planHass = this._planHass): string | null {
|
||||
if (d.marker?.vacuum?.live === false) return null;
|
||||
const resolution = this._vacSourceResolution(d);
|
||||
const resolution = this._vacSourceResolution(d, false, planHass);
|
||||
// A disabled/unavailable/unverified source must never leak stale HA
|
||||
// attributes into plan overlays. `unsupported` remains usable because it
|
||||
// may still provide rooms/path for calibration and trail rendering.
|
||||
@@ -13199,11 +13409,11 @@ class HouseplanCard extends LitElement {
|
||||
* human-readable one on the vacuum entity (selected_map, verified against a
|
||||
* live X50 Master) — without this both floors would share one matrix.
|
||||
*/
|
||||
private _vacMapId(d: DevItem, tele: { mapId: string }): string {
|
||||
private _vacMapId(d: DevItem, tele: { mapId: string }, planHass = this._planHass): string {
|
||||
// HP-1541-01: nullish, not truthy — selected_map: 0 is a real map id and
|
||||
// must equal what trails.py resolve_map_id stores server-side.
|
||||
const ve = this._vacEntity(d);
|
||||
const sel = ve ? this.hass?.states[ve]?.attributes?.selected_map : null;
|
||||
const sel = ve ? planHass?.states?.[ve]?.attributes?.selected_map : null;
|
||||
return vacMapIdWithFallback(tele.mapId, sel);
|
||||
}
|
||||
|
||||
@@ -13399,7 +13609,7 @@ class HouseplanCard extends LitElement {
|
||||
private _renderVacFit(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
|
||||
const f = this._vacFit;
|
||||
if (!f) return nothing;
|
||||
const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes);
|
||||
const tele = readVacTelemetry(this._renderPlanHass?.states?.[f.source]?.attributes);
|
||||
if (!tele) return nothing;
|
||||
const m = fitMatrix(f.p);
|
||||
const rects: TemplateResult[] = [];
|
||||
@@ -13485,20 +13695,21 @@ class HouseplanCard extends LitElement {
|
||||
for (const d of devs) {
|
||||
if (d.hidden || !this._isVacDev(d)) continue;
|
||||
if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') continue;
|
||||
const src = this._vacSource(d);
|
||||
const fact = this._renderDeviceSnapshot?.facts.get(`vacuum:${d.id}`) as any;
|
||||
const src = fact?.source ?? this._vacSource(d, this._renderPlanHass);
|
||||
if (!src) continue;
|
||||
const tele = readVacTelemetry(this.hass?.states[src]?.attributes);
|
||||
const tele = fact?.telemetry ?? readVacTelemetry(this._renderPlanHass?.states[src]?.attributes);
|
||||
if (!tele) continue;
|
||||
const matrix = d.marker?.vacuum?.calibration?.[this._vacMapId(d, tele)] as Affine | undefined;
|
||||
const mapNow = String(fact?.mapId ?? this._vacMapId(d, tele, this._renderPlanHass));
|
||||
const matrix = d.marker?.vacuum?.calibration?.[mapNow] as Affine | undefined;
|
||||
if (!matrix || matrix.length !== 6) continue;
|
||||
const rt = this._vacRt.get(d.id);
|
||||
const rt = fact?.runtime ?? this._vacRt.get(d.id);
|
||||
const moving = rt?.moving ?? false;
|
||||
const tmode = vacTrailMode(d.marker?.vacuum);
|
||||
// owner 2026-07-31: hide when the cleanup is over (default), unless the
|
||||
// mode says always; the previous run only ever shows in 'always'
|
||||
const showCur = tmode === 'always' || (tmode === 'cleaning' && moving);
|
||||
const srv = this._vacSrvTrails[d.id];
|
||||
const mapNow = this._vacMapId(d, tele);
|
||||
const srv = fact?.server ?? this._vacSrvTrails[d.id];
|
||||
const srvCur = srv?.current?.map_id === mapNow && Array.isArray(srv.current.points) ? srv.current : null;
|
||||
const srvPrev = srv?.previous?.map_id === mapNow && Array.isArray(srv.previous.points) ? srv.previous : null;
|
||||
// the PREVIOUS run stays visible even at rest: users compare where the
|
||||
@@ -13622,14 +13833,14 @@ class HouseplanCard extends LitElement {
|
||||
const cmPerUnit = this._cellCm / this._gridPitch;
|
||||
return formatArea(
|
||||
(clean.area * cmPerUnit * cmPerUnit) / 1e4,
|
||||
this.hass?.config?.unit_system?.length === 'mi',
|
||||
this._renderPlanHass?.config?.unit_system?.length === 'mi',
|
||||
);
|
||||
}
|
||||
|
||||
/** Room temperature honouring the tier-3 source override. */
|
||||
private _roomTemp(r: RoomCfg): number | null {
|
||||
const src = r.settings?.temp_source;
|
||||
if (src) return sourceValue(this._planHass, src, 'temp', this._markers);
|
||||
if (src) return sourceValue(this._renderPlanHass, src, 'temp', this._markers);
|
||||
// every sensor of the area, placed on the plan or not (field report)
|
||||
return r.area ? this._climate().get(r.area)?.temp ?? null : null;
|
||||
}
|
||||
@@ -13637,7 +13848,7 @@ class HouseplanCard extends LitElement {
|
||||
/** Room humidity honouring the tier-3 source override. */
|
||||
private _roomHum(r: RoomCfg): number | null {
|
||||
const src = r.settings?.hum_source;
|
||||
if (src) return sourceValue(this._planHass, src, 'hum', this._markers);
|
||||
if (src) return sourceValue(this._renderPlanHass, src, 'hum', this._markers);
|
||||
return r.area ? this._climate().get(r.area)?.hum ?? null : null;
|
||||
}
|
||||
|
||||
@@ -13654,7 +13865,7 @@ class HouseplanCard extends LitElement {
|
||||
// markers are part of the key: ticking "use the device's temperature
|
||||
// sensor" replaces the markers array, so the average recomputes at once
|
||||
const mk = this._serverCfg?.markers;
|
||||
const planHass = this._planHass;
|
||||
const planHass = this._renderPlanHass;
|
||||
const c = this._climateCache;
|
||||
if (c && c.h === planHass && c.r === this._iconRules && c.mk === mk) return c.m;
|
||||
const m = areaClimateMap(planHass, this._iconRules, mk);
|
||||
@@ -13947,7 +14158,7 @@ class HouseplanCard extends LitElement {
|
||||
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
|
||||
}
|
||||
if (disp.labelLight) {
|
||||
const ls = resolvedLightStats(resolvedLightSources(this._planHass, this._devices, r));
|
||||
const ls = resolvedLightStats(resolvedLightSources(this._renderPlanHass, this._renderDevices, r));
|
||||
if (ls) {
|
||||
const txt = ls.on === 0
|
||||
? this._t('roomcard.light_off')
|
||||
@@ -14172,8 +14383,8 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Live state of an opening's contact, 0..1 drawn amount. */
|
||||
private _openingAmt(o: OpeningCfg): number {
|
||||
const st = o.contact && this._planEntityAvailable(o.contact)
|
||||
? this.hass.states[o.contact]?.state
|
||||
const st = o.contact && this._renderEntityAvailable(o.contact)
|
||||
? this._renderPlanHass.states[o.contact]?.state
|
||||
: null;
|
||||
return openingAmount(o.type, st, !!o.invert);
|
||||
}
|
||||
@@ -14185,6 +14396,13 @@ class HouseplanCard extends LitElement {
|
||||
return this._bindingStatus('entity:' + eid).kind === 'active';
|
||||
}
|
||||
|
||||
/** Availability from the same immutable projection as the painted frame. */
|
||||
private _renderEntityAvailable(eid: string | null | undefined): boolean {
|
||||
if (!eid) return false;
|
||||
if (isRemovedPlanEntity(this._renderPlanHass, eid, removedPlanBindings(this._markers))) return false;
|
||||
return !!this._renderPlanHass.entities?.[eid] && !!this._renderPlanHass.states?.[eid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Doors, windows and gates, drawn in plan (SVG) coordinates so they scale and
|
||||
* pan with the plan. Doors/windows follow the easy-floorplan (MIT) symbol
|
||||
@@ -14200,7 +14418,7 @@ class HouseplanCard extends LitElement {
|
||||
return svg`${items.map((o) => {
|
||||
const half = o.rlen / 2;
|
||||
const amt = this._openingAmt(o);
|
||||
const active = amt > 0 && !!o.contact && this._planEntityAvailable(o.contact);
|
||||
const active = amt > 0 && !!o.contact && this._renderEntityAvailable(o.contact);
|
||||
const tone = active ? 'var(--hp-open)' : base;
|
||||
// A normal door/window uses the selected room-side face. A gate's
|
||||
// architectural symbol sits and opens on the opposite (exterior) face;
|
||||
@@ -14315,13 +14533,13 @@ class HouseplanCard extends LitElement {
|
||||
/** Padlock badges for door-like openings with a lock entity. */
|
||||
private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
||||
const items = this._openingsR.filter(
|
||||
(o) => o.type !== 'window' && o.lock && this._planEntityAvailable(o.lock),
|
||||
(o) => o.type !== 'window' && o.lock && this._renderEntityAvailable(o.lock),
|
||||
);
|
||||
if (!items.length) return html``;
|
||||
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
|
||||
const openingWallIndex = this._openingWallIndexFor(this._spaceModel(), openCuts).value;
|
||||
return html`${items.map((o) => {
|
||||
const st = this.hass.states[o.lock!]?.state;
|
||||
const st = this._renderPlanHass.states[o.lock!]?.state;
|
||||
const locked = st === 'locked';
|
||||
const known = locked || ['unlocked', 'open', 'opening', 'unlocking', 'locking'].includes(String(st));
|
||||
// Perpendicular offset from the opening center, away from the swing side.
|
||||
|
||||
@@ -342,8 +342,8 @@
|
||||
"space_card.button": "Open the space plan",
|
||||
"space_card.not_found": "Space “{id}” not found",
|
||||
"space_card.loading": "Loading…",
|
||||
"continuity.restore_plan": "Restoring the plan…",
|
||||
"continuity.restore_connection": "Restoring the connection to devices…",
|
||||
"continuity.restore_plan": "Restoring floor plan…",
|
||||
"continuity.restore_connection": "Restoring device connection…",
|
||||
"continuity.retry": "Try again",
|
||||
"editor.space": "Space",
|
||||
"editor.show_button": "Show button",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { ResolvedDevicePresentation } from './device-presentation';
|
||||
import type { DevItem } from './types';
|
||||
|
||||
/** Immutable render-only projection of one HA tick. */
|
||||
export interface RenderDeviceSnapshot {
|
||||
readonly sourceSequence: number;
|
||||
readonly capturedAt: number;
|
||||
/** Minimal function-free HA-compatible projection used by pure resolvers. */
|
||||
readonly hass: Readonly<{
|
||||
states: Readonly<Record<string, any>>;
|
||||
entities: Readonly<Record<string, any>>;
|
||||
devices: Readonly<Record<string, any>>;
|
||||
config?: any;
|
||||
locale?: any;
|
||||
themes?: any;
|
||||
}>;
|
||||
readonly devices: readonly DevItem[];
|
||||
readonly positions: ReadonlyMap<string, Readonly<{ x: number; y: number }>>;
|
||||
/** Two variants per device: `${id}:1` with LQI and `${id}:0` without it. */
|
||||
readonly presentations: ReadonlyMap<string, ResolvedDevicePresentation>;
|
||||
/** Extra resolved render facts (for example a vacuum puck/trail frame). */
|
||||
readonly facts: ReadonlyMap<string, unknown>;
|
||||
}
|
||||
|
||||
function cloneFact<T>(value: T, stack = new WeakMap<object, unknown>()): T {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
const object = value as unknown as object;
|
||||
const found = stack.get(object);
|
||||
if (found) return found as T;
|
||||
if (Array.isArray(value)) {
|
||||
const out: unknown[] = [];
|
||||
stack.set(object, out);
|
||||
for (const item of value) out.push(cloneFact(item, stack));
|
||||
return Object.freeze(out) as T;
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
stack.set(object, out);
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
// Registry/state facts are data. Functions belong to live HA and never
|
||||
// cross the render-snapshot boundary.
|
||||
if (typeof item !== 'function') out[key] = cloneFact(item, stack);
|
||||
}
|
||||
return Object.freeze(out) as T;
|
||||
}
|
||||
|
||||
/** Runtime-immutable ReadonlyMap facade: Object.freeze(new Map()) still allows set(). */
|
||||
function readonlyMap<K, V>(entries: Iterable<readonly [K, V]>): ReadonlyMap<K, V> {
|
||||
const source = new Map(entries);
|
||||
let view: ReadonlyMap<K, V>;
|
||||
view = Object.freeze({
|
||||
get size() { return source.size; },
|
||||
get: (key: K) => source.get(key),
|
||||
has: (key: K) => source.has(key),
|
||||
entries: () => source.entries(),
|
||||
keys: () => source.keys(),
|
||||
values: () => source.values(),
|
||||
forEach: (callback: (value: V, key: K, map: ReadonlyMap<K, V>) => void, thisArg?: unknown) =>
|
||||
source.forEach((value, key) => callback.call(thisArg, value, key, view)),
|
||||
[Symbol.iterator]: () => source[Symbol.iterator](),
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
export function createRenderDeviceSnapshot(options: {
|
||||
sourceSequence: number;
|
||||
hass: any;
|
||||
devices: readonly DevItem[];
|
||||
presentations: ReadonlyMap<string, ResolvedDevicePresentation>;
|
||||
positions?: ReadonlyMap<string, { x: number; y: number }>;
|
||||
facts?: ReadonlyMap<string, unknown>;
|
||||
entityIds?: Iterable<string>;
|
||||
deviceIds?: Iterable<string>;
|
||||
areaIds?: Iterable<string>;
|
||||
capturedAt?: number;
|
||||
}): RenderDeviceSnapshot {
|
||||
// Copy only the registry/state rows that can contribute to this plan. This
|
||||
// is a render-data projection, not a frozen clone of Home Assistant runtime.
|
||||
const entityIds = new Set(options.entityIds || []);
|
||||
const deviceIds = new Set(options.deviceIds || []);
|
||||
const areaIds = new Set(options.areaIds || []);
|
||||
for (const device of options.devices) {
|
||||
for (const entityId of device.entities || []) entityIds.add(entityId);
|
||||
if (device.primary) entityIds.add(device.primary);
|
||||
for (const entityId of device.controls || []) entityIds.add(entityId);
|
||||
if (device.marker?.vacuum?.source) entityIds.add(device.marker.vacuum.source);
|
||||
if (device.bindingKind === 'device' && device.bindingRef) deviceIds.add(device.bindingRef);
|
||||
if (device.bindingKind === 'entity' && device.bindingRef) entityIds.add(device.bindingRef);
|
||||
}
|
||||
const projectedEntities: Record<string, any> = {};
|
||||
const projectedDevices: Record<string, any> = {};
|
||||
for (const [entityId, registry] of Object.entries<any>(options.hass?.entities || {})) {
|
||||
const device = registry?.device_id ? options.hass?.devices?.[registry.device_id] : null;
|
||||
if (entityIds.has(entityId) || (registry?.device_id && deviceIds.has(registry.device_id))
|
||||
|| areaIds.has(registry?.area_id) || areaIds.has(device?.area_id)) {
|
||||
entityIds.add(entityId);
|
||||
projectedEntities[entityId] = registry;
|
||||
if (registry?.device_id) deviceIds.add(registry.device_id);
|
||||
}
|
||||
}
|
||||
for (const deviceId of deviceIds) {
|
||||
const device = options.hass?.devices?.[deviceId];
|
||||
if (device) projectedDevices[deviceId] = device;
|
||||
}
|
||||
const projectedStates: Record<string, any> = {};
|
||||
for (const entityId of entityIds) {
|
||||
const state = options.hass?.states?.[entityId];
|
||||
if (state) projectedStates[entityId] = state;
|
||||
}
|
||||
const hass = Object.freeze({
|
||||
states: cloneFact(projectedStates),
|
||||
entities: cloneFact(projectedEntities),
|
||||
devices: cloneFact(projectedDevices),
|
||||
config: cloneFact(options.hass?.config),
|
||||
locale: cloneFact(options.hass?.locale),
|
||||
themes: cloneFact(options.hass?.themes),
|
||||
});
|
||||
return Object.freeze({
|
||||
sourceSequence: options.sourceSequence,
|
||||
capturedAt: options.capturedAt ?? Date.now(),
|
||||
hass,
|
||||
devices: cloneFact([...options.devices]),
|
||||
positions: readonlyMap([...(options.positions || [])].map(([id, point]) =>
|
||||
[id, Object.freeze({ x: point.x, y: point.y })] as const)),
|
||||
presentations: readonlyMap([...options.presentations].map(([key, presentation]) =>
|
||||
[key, cloneFact(presentation)] as const)),
|
||||
facts: readonlyMap([...(options.facts || [])].map(([key, fact]) =>
|
||||
[key, cloneFact(fact)] as const)),
|
||||
});
|
||||
}
|
||||
|
||||
export const presentationSnapshotKey = (deviceId: string, showLqi: boolean): string =>
|
||||
`${deviceId}:${showLqi ? 1 : 0}`;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
|
||||
import { PAINT_BARRIER_MAX_MS } from './visual-continuity';
|
||||
|
||||
/** A request older than this is presumed lost, and the url may be asked again. */
|
||||
export const SIGN_INFLIGHT_MS = 15000;
|
||||
@@ -33,7 +34,11 @@ const sharedSignerRuntimes = new WeakMap<object, SharedSignerRuntime>();
|
||||
const SHARED_SIGNED_MAX = 512;
|
||||
|
||||
const signerAuthority = (hass: any, fallback: object): object => {
|
||||
const authority = hass?.connection || hass;
|
||||
// `hass` itself is commonly replaced on every HA state tick. Keying the
|
||||
// shared signer by it discarded the cache continuously in reduced/demo
|
||||
// environments without a Connection object. The transport function is the
|
||||
// next stable authority; a per-signer fallback is the final option.
|
||||
const authority = hass?.connection || hass?.callWS || fallback;
|
||||
return authority && (typeof authority === 'object' || typeof authority === 'function')
|
||||
? authority : fallback;
|
||||
};
|
||||
@@ -301,7 +306,11 @@ export class ContentSigner {
|
||||
const url = entry.url;
|
||||
entry.preload = new Promise<boolean>((resolve) => {
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
const finish = (ready: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const current = shared.signed[path];
|
||||
if (current === entry) delete current.preload;
|
||||
if (ready && current === entry && current.url === url) {
|
||||
@@ -318,6 +327,10 @@ export class ContentSigner {
|
||||
decoded.then(() => finish(true)).catch(() => finish(false));
|
||||
};
|
||||
image.onerror = () => finish(false);
|
||||
// A browser may leave an image request pending forever (sleep, dead
|
||||
// service worker, stalled authenticated endpoint). Release the shared
|
||||
// preload slot so Retry can start a genuinely new request.
|
||||
const timer = setTimeout(() => finish(false), PAINT_BARRIER_MAX_MS);
|
||||
image.src = url;
|
||||
});
|
||||
return entry.preload;
|
||||
@@ -344,8 +357,19 @@ export class ContentSigner {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const bounded = (promise: Promise<boolean>): Promise<boolean> => new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (ready: boolean): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve(ready);
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), PAINT_BARRIER_MAX_MS);
|
||||
promise.then(finish).catch(() => finish(false));
|
||||
});
|
||||
const immediate = attempt();
|
||||
if (immediate) return immediate;
|
||||
if (immediate) return bounded(immediate);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let done = false;
|
||||
let active: Promise<boolean> | null = null;
|
||||
@@ -362,7 +386,7 @@ export class ContentSigner {
|
||||
if (!active) return;
|
||||
active.then(finish).catch(() => finish(false));
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), SIGN_INFLIGHT_MS);
|
||||
const timer = setTimeout(() => finish(false), PAINT_BARRIER_MAX_MS);
|
||||
shared.settlers.add(check);
|
||||
check();
|
||||
});
|
||||
@@ -378,8 +402,18 @@ export class ContentSigner {
|
||||
private trimShared(shared: SharedSignerRuntime): void {
|
||||
const entries = Object.entries(shared.signed);
|
||||
if (entries.length <= SHARED_SIGNED_MAX) return;
|
||||
const protectedPaths = new Set<string>(shared.inFlight.keys());
|
||||
for (const refs of shared.references.values()) for (const path of refs) protectedPaths.add(path);
|
||||
for (const [path, entry] of entries) {
|
||||
if (entry.pending || entry.preload) protectedPaths.add(path);
|
||||
}
|
||||
entries.sort((left, right) => right[1].at - left[1].at);
|
||||
shared.signed = Object.fromEntries(entries.slice(0, SHARED_SIGNED_MAX));
|
||||
const kept = entries.filter(([path]) => protectedPaths.has(path));
|
||||
for (const entry of entries) {
|
||||
if (kept.length >= SHARED_SIGNED_MAX) break;
|
||||
if (!protectedPaths.has(entry[0])) kept.push(entry);
|
||||
}
|
||||
shared.signed = Object.fromEntries(kept);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,11 +438,13 @@ export class ContentSigner {
|
||||
}
|
||||
|
||||
/** Mark a protected image as decoded/paintable; shared warm mounts inherit it. */
|
||||
markLoaded(hass: any, url: string | null | undefined): void {
|
||||
markLoaded(hass: any, url: string | null | undefined, paintedUrl?: string): void {
|
||||
const path = contentUrl(url);
|
||||
if (!path.startsWith('/api/houseplan/content/')) return;
|
||||
const entry = this.bind(hass).signed[path];
|
||||
if (entry && !entry.loaded) {
|
||||
// A late load event from an expired/re-signed URL must not bless the newer
|
||||
// entry that happens to share the same raw content path.
|
||||
if (entry && (!paintedUrl || entry.url === paintedUrl) && !entry.loaded) {
|
||||
entry.loaded = true;
|
||||
for (const listener of [...this.bind(hass).listeners]) listener();
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ import { normalizeDeviceDisplay, referencedContentUrls } from './logic';
|
||||
import { acquireHaRegistries, activeRegistryHass, haRegistrySnapshot } from './ha-binding-status';
|
||||
import { edgeActivity } from './device-visual';
|
||||
import {
|
||||
presentationSourceSignature, resolvePresentationSources,
|
||||
presentationSourceSignature, resolveDevicePresentation, resolvePresentationSources,
|
||||
type PresentationActivityRuntime,
|
||||
} from './device-presentation';
|
||||
import {
|
||||
createRenderDeviceSnapshot, presentationSnapshotKey, type RenderDeviceSnapshot,
|
||||
} from './render-device-snapshot';
|
||||
import type { DevItem } from './types';
|
||||
import {
|
||||
VisualContinuityController,
|
||||
@@ -76,6 +79,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
private _haRegistryRevision = -1;
|
||||
private _devices: DevItem[] = [];
|
||||
private _continuity = this._newContinuityController();
|
||||
private _continuityHistory: import('./visual-continuity').ContinuityTraceEvent[] = [];
|
||||
private _continuityUnsub?: () => void;
|
||||
private _continuityEpoch = 0;
|
||||
private _continuityDataReady = true;
|
||||
@@ -84,11 +88,20 @@ class HouseplanSpaceCard extends LitElement {
|
||||
private _renderSnapshotAt = Date.now();
|
||||
private _hassSequence = 0;
|
||||
private _connHooked: any = null;
|
||||
private _connectionWasLost = false;
|
||||
private _visibleDeviceSnapshot: RenderDeviceSnapshot | null = null;
|
||||
private _candidateDeviceSnapshot: RenderDeviceSnapshot | null = null;
|
||||
private _stagedDeviceSnapshotToken = -1;
|
||||
private _capturedSnapshotSequence = -1;
|
||||
private _capturedSnapshotDevices: DevItem[] | null = null;
|
||||
private _capturedSnapshotActivity = '';
|
||||
private _activityRuntime = new Map<string, StaticActivityRuntime>();
|
||||
private _onHaRegistryUpdate = () => {
|
||||
const revision = haRegistrySnapshot(this.hass).revision;
|
||||
if (revision === this._haRegistryRevision) return;
|
||||
this._haRegistryRevision = revision;
|
||||
this._refreshDevices();
|
||||
this._capturedSnapshotSequence = -1;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
@@ -117,22 +130,30 @@ class HouseplanSpaceCard extends LitElement {
|
||||
}
|
||||
|
||||
private _pageVisibility = (signal: PageVisibilitySignal): void => {
|
||||
const token = this._continuity.visibility(signal);
|
||||
this._continuity.visibility(signal);
|
||||
if (signal.kind === 'hidden' || !signal.long) return;
|
||||
if (Date.now() - this._renderSnapshotAt > 1000) this._continuity.note('device-snapshot-stale');
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
if (token === this._continuity.token) void this._load(true);
|
||||
void this._load(true);
|
||||
};
|
||||
|
||||
private _onConnLost = (): void => {
|
||||
this._connectionWasLost = true;
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
this._continuity.connectionLost();
|
||||
};
|
||||
|
||||
private _onConnReady = (): void => {
|
||||
this._beginContinuityCandidate('connection-ready', false, 'connection');
|
||||
// `ready` also fires for a healthy initial connection. Only a preceding
|
||||
// confirmed loss is allowed to put the card into offline-stale semantics.
|
||||
if (!this._connectionWasLost && this._continuity.hasCompleteFrame) {
|
||||
this._beginContinuityCandidate('connection-ready', false, 'plan');
|
||||
} else {
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
}
|
||||
void this._load(true);
|
||||
};
|
||||
|
||||
@@ -216,6 +237,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._connHooked = null;
|
||||
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
|
||||
this._activityRuntime.clear();
|
||||
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
|
||||
this._continuity.dispose();
|
||||
this._continuityDisposed = true;
|
||||
super.disconnectedCallback();
|
||||
@@ -232,7 +254,10 @@ class HouseplanSpaceCard extends LitElement {
|
||||
if (this.hass && !this._loading && (!this._snap || changed.has('hass'))) {
|
||||
if (!this._snap || !this._loadedOnce) this._load();
|
||||
}
|
||||
this._refreshDevices();
|
||||
if (changed.has('hass') || changed.has('_snap') || changed.has('_config') || !this._devices.length) {
|
||||
this._refreshDevices();
|
||||
}
|
||||
this._captureRenderDeviceSnapshot();
|
||||
if (this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
|
||||
this._continuity.refreshCompleteFrame(this._frameFingerprint());
|
||||
}
|
||||
@@ -313,6 +338,72 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._devices = devices;
|
||||
}
|
||||
|
||||
private _captureRenderDeviceSnapshot(): void {
|
||||
if (!this.hass) return;
|
||||
const activity = [...this._activityRuntime.entries()]
|
||||
.map(([id, runtime]) => `${id}:${runtime.gen}:${runtime.flashTs}`).join('|');
|
||||
if (this._capturedSnapshotSequence === this._hassSequence
|
||||
&& this._capturedSnapshotDevices === this._devices
|
||||
&& this._capturedSnapshotActivity === activity) return;
|
||||
const planHass = activeRegistryHass(this.hass, haRegistrySnapshot(this.hass));
|
||||
const presentations = new Map<string, ReturnType<typeof resolveDevicePresentation>>();
|
||||
const entityIds = new Set<string>(['sun.sun']);
|
||||
const deviceIds = new Set<string>();
|
||||
const areaIds = new Set<string>();
|
||||
const addSource = (source: string | null | undefined): void => {
|
||||
if (!source) return;
|
||||
const separator = source.indexOf(':');
|
||||
if (separator < 0) { entityIds.add(source); return; }
|
||||
const kind = source.slice(0, separator), reference = source.slice(separator + 1);
|
||||
if (kind === 'device') deviceIds.add(reference);
|
||||
else if (kind === 'entity') entityIds.add(reference);
|
||||
};
|
||||
const selectedSpace = spaceModels(this._snap?.config || null)
|
||||
.find((space) => space.id === this._config?.space);
|
||||
for (const room of selectedSpace?.rooms || []) {
|
||||
if (room.area) areaIds.add(room.area);
|
||||
addSource(room.settings?.temp_source);
|
||||
addSource(room.settings?.hum_source);
|
||||
}
|
||||
const rawSpace = this._snap?.config?.spaces?.find((space: any) => space.id === this._config?.space);
|
||||
for (const opening of rawSpace?.openings || []) {
|
||||
if (opening.contact) entityIds.add(opening.contact);
|
||||
if (opening.lock) entityIds.add(opening.lock);
|
||||
}
|
||||
for (const device of this._devices) {
|
||||
for (const showLqi of [false, true]) {
|
||||
presentations.set(presentationSnapshotKey(device.id, showLqi), resolveDevicePresentation(
|
||||
planHass, device, {
|
||||
liveStates: this._config?.live_states !== false,
|
||||
showTemperature: this._config?.show_temperature !== false,
|
||||
showSignal: showLqi,
|
||||
activityRuntime: this._activityRuntime.get(device.id),
|
||||
sourceDetails: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
const snapshot = createRenderDeviceSnapshot({
|
||||
sourceSequence: this._hassSequence, hass: planHass,
|
||||
devices: this._devices, presentations, entityIds, deviceIds, areaIds,
|
||||
});
|
||||
this._capturedSnapshotSequence = this._hassSequence;
|
||||
this._capturedSnapshotDevices = this._devices;
|
||||
this._capturedSnapshotActivity = activity;
|
||||
if (!this._visibleDeviceSnapshot || this._continuity.state === 'steady') {
|
||||
this._visibleDeviceSnapshot = snapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
} else {
|
||||
this._candidateDeviceSnapshot = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
private get _renderDeviceSnapshot(): RenderDeviceSnapshot | null {
|
||||
return this._stagedDeviceSnapshotToken === this._continuity.token
|
||||
? this._candidateDeviceSnapshot || this._visibleDeviceSnapshot
|
||||
: this._visibleDeviceSnapshot || this._candidateDeviceSnapshot;
|
||||
}
|
||||
|
||||
private _beginContinuityCandidate(
|
||||
reason: string,
|
||||
dataReady: boolean,
|
||||
@@ -320,6 +411,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
): number {
|
||||
this._continuityDataReady = dataReady;
|
||||
this._continuityPaintToken = -1;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
return this._continuity.beginCandidate(reason, recoveryReason);
|
||||
}
|
||||
|
||||
@@ -347,7 +439,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
snap?.layoutFingerprint || contentFingerprint(snap?.layout),
|
||||
this._config?.space || '',
|
||||
this._stageWidth,
|
||||
this._hassSequence,
|
||||
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
|
||||
]);
|
||||
}
|
||||
@@ -360,18 +451,24 @@ class HouseplanSpaceCard extends LitElement {
|
||||
private _settleContinuityFrame(): void {
|
||||
if (!this._stageValid()) return;
|
||||
if (!this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
|
||||
if (!this._assetsReady()) {
|
||||
this._beginContinuityCandidate('asset-wait', true, 'asset');
|
||||
return;
|
||||
}
|
||||
if (!this._assetsReady()) this._beginContinuityCandidate('asset-wait', true, 'asset');
|
||||
else {
|
||||
this._renderSnapshotAt = Date.now();
|
||||
this._continuity.markCompleteFrame(this._frameFingerprint());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!this._continuityDataReady || !this._assetsReady()) return;
|
||||
if (!this._continuityDataReady) return;
|
||||
if (!['holding', 'offline-stale', 'overlay-pending', 'overlay-visible', 'candidate-ready']
|
||||
.includes(this._continuity.state)) return;
|
||||
const token = this._continuity.token;
|
||||
if (this._candidateDeviceSnapshot
|
||||
&& this._candidateDeviceSnapshot !== this._visibleDeviceSnapshot
|
||||
&& this._stagedDeviceSnapshotToken !== token) {
|
||||
this._stagedDeviceSnapshotToken = token;
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (this._continuityPaintToken === token) return;
|
||||
this._continuityPaintToken = token;
|
||||
if (!this._continuity.candidateReady(token)) return;
|
||||
@@ -382,15 +479,23 @@ class HouseplanSpaceCard extends LitElement {
|
||||
frameFingerprint: () => this._frameFingerprint(),
|
||||
}).then((committed) => {
|
||||
if (!committed || token !== this._continuity.token) {
|
||||
if (token === this._continuity.token) this._continuityPaintToken = -1;
|
||||
if (token === this._continuity.token) {
|
||||
this._continuityPaintToken = -1;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._renderSnapshotAt = Date.now();
|
||||
if (this._candidateDeviceSnapshot) this._visibleDeviceSnapshot = this._candidateDeviceSnapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
});
|
||||
}
|
||||
|
||||
private _onAssetLoaded = (raw: string): void => {
|
||||
this._signer.markLoaded(this.hass, raw);
|
||||
private _onAssetLoaded = (raw: string, paintedUrl: string): void => {
|
||||
this._signer.markLoaded(this.hass, raw, paintedUrl);
|
||||
this._continuity.note('asset-ready');
|
||||
this._continuityPaintToken = -1;
|
||||
if (this._continuity.state !== 'steady') this.requestUpdate();
|
||||
@@ -449,7 +554,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
}
|
||||
this._loading = true;
|
||||
this._reloadQueued = false;
|
||||
let loaded = false;
|
||||
try {
|
||||
const snap = await getConfig(this.hass, force);
|
||||
const configChanged = !this._snap
|
||||
@@ -480,14 +584,17 @@ class HouseplanSpaceCard extends LitElement {
|
||||
if (configChanged) this._continuity.note('config-candidate', { configRev: snap.rev });
|
||||
if (layoutChanged) this._continuity.note('layout-candidate', { layoutRev: snap.layoutRev });
|
||||
this._loadedOnce = true;
|
||||
loaded = true;
|
||||
this._connectionWasLost = false;
|
||||
this._continuityDataReady = true;
|
||||
this._refreshDevices();
|
||||
} catch {
|
||||
/* keep any localStorage snapshot */
|
||||
} finally {
|
||||
this._loading = false;
|
||||
if (!loaded && !this._continuity.hasCompleteFrame) this._continuityDataReady = true;
|
||||
// A failed signing/decode attempt is still a completed candidate attempt;
|
||||
// the paint barrier decides whether to keep the stale frame or show the
|
||||
// bounded fallback. Never leave the state machine waiting without a timer.
|
||||
this._continuityDataReady = true;
|
||||
this.requestUpdate();
|
||||
if (this._reloadQueued) {
|
||||
const forceAgain = this._forceReloadQueued;
|
||||
@@ -558,7 +665,8 @@ class HouseplanSpaceCard extends LitElement {
|
||||
|
||||
/** Redacted lifecycle diagnostics shared with the full card's sampler. */
|
||||
public houseplanContinuityTrace(): readonly import('./visual-continuity').ContinuityTraceEvent[] {
|
||||
return this._continuity.trace;
|
||||
return [...this._continuityHistory, ...this._continuity.trace].slice(-80)
|
||||
.map((event) => ({ ...event, ...(event.stage ? { stage: [...event.stage] as [number, number] } : {}) }));
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
@@ -569,8 +677,9 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return this._errorCard(t(this._lang, 'space_card.loading'));
|
||||
}
|
||||
const spaceId = this._config.space;
|
||||
const deviceSnapshot = this._renderDeviceSnapshot;
|
||||
const stage = renderSpaceStatic({
|
||||
hass: this.hass,
|
||||
hass: deviceSnapshot?.hass || this.hass,
|
||||
cfg,
|
||||
layout: this._snap?.layout || {},
|
||||
spaceId,
|
||||
@@ -580,12 +689,23 @@ class HouseplanSpaceCard extends LitElement {
|
||||
// resolved at render time: a url baked in earlier would be the unsigned one
|
||||
displayUrl: (raw) => this._signer.display(this.hass, raw),
|
||||
assetLoaded: this._onAssetLoaded,
|
||||
registry: haRegistrySnapshot(this.hass),
|
||||
devices: this._devices,
|
||||
registry: deviceSnapshot
|
||||
? {
|
||||
revision: deviceSnapshot.sourceSequence,
|
||||
authoritative: false,
|
||||
access: 'limited' as const,
|
||||
entities: { ...deviceSnapshot.hass.entities },
|
||||
devices: { ...deviceSnapshot.hass.devices },
|
||||
lastSuccess: deviceSnapshot.capturedAt,
|
||||
}
|
||||
: haRegistrySnapshot(this.hass),
|
||||
devices: [...(deviceSnapshot?.devices || this._devices)],
|
||||
presentations: deviceSnapshot?.presentations,
|
||||
activityRuntime: this._activityRuntime,
|
||||
liveStates: this._config.live_states !== false,
|
||||
showTemperature: this._config.show_temperature !== false,
|
||||
showSignal: this._config.show_signal !== false,
|
||||
inert: this._continuity.overlayBlocksInteraction,
|
||||
});
|
||||
if (!stage) {
|
||||
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
|
||||
@@ -601,6 +721,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
data-continuity-state=${this._continuity.state}
|
||||
data-continuity-token=${this._continuity.token}
|
||||
data-frame-fingerprint=${this._continuity.frameFingerprint || nothing}
|
||||
data-device-snapshot-sequence=${deviceSnapshot?.sourceSequence ?? nothing}
|
||||
data-recovery-reason=${recoveryReason || nothing}>
|
||||
${title ? html`<div class="hp-static-title">${title}</div>` : nothing}
|
||||
<div class="hp-static-body">
|
||||
|
||||
@@ -22,8 +22,9 @@ import type { DevItem, ServerConfig } from './types';
|
||||
import { physicalBodies } from './physical-geometry';
|
||||
import { activeRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
|
||||
import {
|
||||
resolveDevicePresentation, type PresentationActivityRuntime,
|
||||
resolveDevicePresentation, type PresentationActivityRuntime, type ResolvedDevicePresentation,
|
||||
} from './device-presentation';
|
||||
import { presentationSnapshotKey } from './render-device-snapshot';
|
||||
import { deviceFaceStyle, renderDeviceFace } from './device-face';
|
||||
import {
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
|
||||
@@ -46,6 +47,7 @@ export interface StaticRenderOpts {
|
||||
/** Optional roster prepared by the card so its activity runtime sees the same instances. */
|
||||
devices?: DevItem[];
|
||||
activityRuntime?: ReadonlyMap<string, PresentationActivityRuntime>;
|
||||
presentations?: ReadonlyMap<string, ResolvedDevicePresentation>;
|
||||
liveStates?: boolean;
|
||||
showTemperature?: boolean;
|
||||
showSignal?: boolean;
|
||||
@@ -57,7 +59,9 @@ export interface StaticRenderOpts {
|
||||
*/
|
||||
displayUrl?: (raw: string) => string;
|
||||
/** Marks a protected backdrop as loaded/paintable for continuity barriers. */
|
||||
assetLoaded?: (raw: string) => void;
|
||||
assetLoaded?: (raw: string, paintedUrl: string) => void;
|
||||
/** Recovery overlay owns input while an atomic candidate is being painted. */
|
||||
inert?: boolean;
|
||||
}
|
||||
|
||||
export interface StaticDeviceBuildOpts {
|
||||
@@ -235,10 +239,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const p = markerPos(d, o.layout, o.cfg, defPos, space);
|
||||
const left = ((p.x - vb[0]) / vb[2]) * 100;
|
||||
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
||||
const presentation = resolveDevicePresentation(planHass, d, {
|
||||
const showLqi = disp.showLqi ?? (o.showSignal !== false);
|
||||
const presentation = o.presentations?.get(presentationSnapshotKey(d.id, showLqi))
|
||||
|| resolveDevicePresentation(planHass, d, {
|
||||
liveStates: o.liveStates !== false,
|
||||
showTemperature: o.showTemperature !== false,
|
||||
showSignal: disp.showLqi ?? (o.showSignal !== false),
|
||||
showSignal: showLqi,
|
||||
activityRuntime: o.activityRuntime?.get(d.id),
|
||||
sourceDetails: false,
|
||||
});
|
||||
@@ -296,7 +302,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const wallStroke = disp.color || '#607d8b';
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}">
|
||||
<div class="hp-static-stage" ?inert=${!!o.inert} style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${wallUnion ? svg`<defs>
|
||||
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
|
||||
@@ -311,7 +317,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
)}
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}"
|
||||
@load=${() => o.assetLoaded?.(space.bg!.href)}
|
||||
@load=${() => o.assetLoaded?.(space.bg!.href, bgHref)}
|
||||
transform=${space.bg!.angle
|
||||
? `rotate(${space.bg!.angle} ${space.bg!.x + space.bg!.w / 2} ${space.bg!.y + space.bg!.h / 2})`
|
||||
: nothing}
|
||||
|
||||
@@ -325,7 +325,12 @@ export const cardStyles = css`
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-6);
|
||||
box-sizing: border-box;
|
||||
background: var(--ha-card-background, var(--card-background-color, #111));
|
||||
/* The final solid layer guarantees an opaque recovery surface even when
|
||||
a custom HA theme exposes its card colour as rgba(). */
|
||||
background:
|
||||
linear-gradient(var(--ha-card-background, var(--card-background-color, #111)),
|
||||
var(--ha-card-background, var(--card-background-color, #111))),
|
||||
#111;
|
||||
color: var(--primary-text-color, #fff);
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -169,7 +169,12 @@ export class VisualContinuityController {
|
||||
get overlayVisible(): boolean { return this._overlayPhase !== 'none'; }
|
||||
get overlayBlocksInteraction(): boolean { return this.overlayVisible; }
|
||||
get recoveryReason(): RecoveryReason { return this._recoveryReason; }
|
||||
get trace(): readonly ContinuityTraceEvent[] { return this._trace; }
|
||||
get trace(): readonly ContinuityTraceEvent[] {
|
||||
return this._trace.map((event) => ({
|
||||
...event,
|
||||
...(event.stage ? { stage: [...event.stage] as [number, number] } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Redacted card-owned lifecycle markers; never pass ids or urls here. */
|
||||
note(event: string, detail: Partial<ContinuityTraceEvent> = {}): void {
|
||||
@@ -274,13 +279,24 @@ export class VisualContinuityController {
|
||||
*/
|
||||
async commitAfterPaint(token: number, hooks: PaintBarrierHooks): Promise<boolean> {
|
||||
if (this._disposed || token !== this._token) return false;
|
||||
let barrierActive = true;
|
||||
const barrier = (async () => {
|
||||
await hooks.updateComplete();
|
||||
if (token !== this._token || !hooks.stageValid() || !hooks.assetsReady()) return false;
|
||||
await this.nextFrame();
|
||||
if (token !== this._token || !hooks.stageValid() || !hooks.assetsReady()) return false;
|
||||
await this.nextFrame();
|
||||
return token === this._token && hooks.stageValid() && hooks.assetsReady();
|
||||
// Candidate data readiness starts the bounded barrier. Stage dimensions
|
||||
// and protected-image decode may become ready a little later, so keep
|
||||
// sampling them inside the same two-second budget instead of returning
|
||||
// early and leaving an opaque recovery overlay without a running timer.
|
||||
while (barrierActive && !this._disposed && token === this._token) {
|
||||
if (hooks.stageValid() && hooks.assetsReady()) {
|
||||
await this.nextFrame();
|
||||
if (token !== this._token || !hooks.stageValid() || !hooks.assetsReady()) continue;
|
||||
await this.nextFrame();
|
||||
if (token === this._token && hooks.stageValid() && hooks.assetsReady()) return true;
|
||||
} else {
|
||||
await this.nextFrame();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
let timeout = 0;
|
||||
const timed = new Promise<{ ready: false; timedOut: true }>((resolve) => {
|
||||
@@ -292,22 +308,10 @@ export class VisualContinuityController {
|
||||
barrier.then((ready) => ({ ready, timedOut: false as const })),
|
||||
timed,
|
||||
]);
|
||||
barrierActive = false;
|
||||
this.clock.clearTimeout(timeout);
|
||||
if (this._disposed || token !== this._token) return false;
|
||||
if (!outcome.ready && !outcome.timedOut) {
|
||||
// Stage/resource readiness can change between the two paint checks.
|
||||
// This is a recoverable rejection, not the two-second timeout: retain
|
||||
// the token and let the card retry when the missing readiness signal
|
||||
// arrives, without converting a transient image decode into an error.
|
||||
this.record('paint-barrier-rejected');
|
||||
if (this._hasCompleteFrame) {
|
||||
this._state = this._recoveryReason === 'connection' ? 'offline-stale' : 'holding';
|
||||
} else {
|
||||
this._state = this.overlayVisible ? 'overlay-visible' : 'overlay-pending';
|
||||
}
|
||||
this.changed();
|
||||
return false;
|
||||
}
|
||||
if (!outcome.ready && !outcome.timedOut) return false;
|
||||
if (!outcome.ready) {
|
||||
this.record('paint-barrier-timeout');
|
||||
if (this._hasCompleteFrame) {
|
||||
@@ -315,7 +319,10 @@ export class VisualContinuityController {
|
||||
this.clearOverlay();
|
||||
} else {
|
||||
this._state = 'recovery-error';
|
||||
if (this._overlayPhase === 'none') this.showOverlayNow();
|
||||
// A throttled/background tab may reach the terminal timeout while the
|
||||
// entry rAF is still pending. Recovery-error is a terminal fallback,
|
||||
// so never leave it in a transparent/intermediate animation phase.
|
||||
if (this._overlayPhase !== 'opaque') this.showOverlayNow();
|
||||
}
|
||||
this.changed();
|
||||
return false;
|
||||
@@ -346,7 +353,8 @@ export class VisualContinuityController {
|
||||
this._recoveryReason = reason;
|
||||
this._overlayTimer = this.clock.setTimeout(() => {
|
||||
this._overlayTimer = 0;
|
||||
if (this._hasCompleteFrame || this._state !== 'overlay-pending') return;
|
||||
if (this._hasCompleteFrame
|
||||
|| (this._state !== 'overlay-pending' && this._state !== 'candidate-ready')) return;
|
||||
this._state = 'overlay-visible';
|
||||
this._overlayPhase = 'entering';
|
||||
this.record('overlay-enter');
|
||||
@@ -431,7 +439,10 @@ export class VisualContinuityController {
|
||||
export function contentFingerprint(value: unknown): string {
|
||||
let a = 0x811c9dc5;
|
||||
let b = 0x9e3779b9;
|
||||
const seen = new WeakSet<object>();
|
||||
// This is the active recursion stack, not a global "seen" set. A shared but
|
||||
// acyclic child must hash by value at every occurrence; only an ancestor
|
||||
// reference is a cycle.
|
||||
const stack = new WeakSet<object>();
|
||||
const feed = (text: string): void => {
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const code = text.charCodeAt(index);
|
||||
@@ -449,12 +460,13 @@ export function contentFingerprint(value: unknown): string {
|
||||
if (type === 'undefined') { feed('u'); return; }
|
||||
if (type !== 'object') { feed(`${type}:${String(node)}`); return; }
|
||||
const object = node as object;
|
||||
if (seen.has(object)) { feed('[cycle]'); return; }
|
||||
seen.add(object);
|
||||
if (stack.has(object)) { feed('[cycle]'); return; }
|
||||
stack.add(object);
|
||||
if (Array.isArray(node)) {
|
||||
feed('[');
|
||||
for (const item of node) visit(item);
|
||||
feed(']');
|
||||
stack.delete(object);
|
||||
return;
|
||||
}
|
||||
feed('{');
|
||||
@@ -463,6 +475,7 @@ export function contentFingerprint(value: unknown): string {
|
||||
visit((node as Record<string, unknown>)[key]);
|
||||
}
|
||||
feed('}');
|
||||
stack.delete(object);
|
||||
};
|
||||
visit(value);
|
||||
return `${(a >>> 0).toString(16).padStart(8, '0')}${(b >>> 0).toString(16).padStart(8, '0')}`;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
createRenderDeviceSnapshot, presentationSnapshotKey,
|
||||
} from '../test-build/render-device-snapshot.js';
|
||||
|
||||
test('RenderDeviceSnapshot keeps immutable facts and excludes live HA capabilities', () => {
|
||||
const state = { entity_id: 'light.one', state: 'on', attributes: { brightness: 120 } };
|
||||
const device = { id: 'one', space: 'floor', entities: ['light.one'], icon: 'mdi:lightbulb' };
|
||||
const presentation = { icon: 'mdi:lightbulb', valueText: null, visual: { status: 'working' } };
|
||||
const snapshot = createRenderDeviceSnapshot({
|
||||
sourceSequence: 7,
|
||||
hass: {
|
||||
states: { 'light.one': state }, entities: {}, devices: {},
|
||||
connection: { live: true }, callService: () => undefined,
|
||||
},
|
||||
devices: [device],
|
||||
positions: new Map([['one', { x: 12, y: 34 }]]),
|
||||
presentations: new Map([[presentationSnapshotKey('one', true), presentation]]),
|
||||
facts: new Map([['vacuum:one', { moving: true }]]),
|
||||
});
|
||||
|
||||
state.attributes.brightness = 1;
|
||||
device.entities.push('switch.other');
|
||||
presentation.visual.status = 'neutral';
|
||||
|
||||
assert.equal(snapshot.sourceSequence, 7);
|
||||
assert.equal(snapshot.hass.connection, undefined);
|
||||
assert.equal(snapshot.hass.callService, undefined);
|
||||
assert.equal(snapshot.hass.states['light.one'].attributes.brightness, 120);
|
||||
assert.deepEqual(snapshot.devices[0].entities, ['light.one']);
|
||||
assert.deepEqual(snapshot.positions.get('one'), { x: 12, y: 34 });
|
||||
assert.equal(snapshot.presentations.get(presentationSnapshotKey('one', true)).visual.status, 'working');
|
||||
assert.equal(snapshot.facts.get('vacuum:one').moving, true);
|
||||
assert.equal('set' in snapshot.positions, false);
|
||||
assert.equal('set' in snapshot.presentations, false);
|
||||
assert.equal('set' in snapshot.facts, false);
|
||||
});
|
||||
|
||||
const methodBody = (source, name) => {
|
||||
const start = source.indexOf(`private ${name}(`);
|
||||
assert.notEqual(start, -1, `${name} exists`);
|
||||
const tail = source.slice(start + 1);
|
||||
const next = tail.search(/\n (?:private|protected|public)\s/);
|
||||
return source.slice(start, next < 0 ? source.length : start + 1 + next);
|
||||
};
|
||||
|
||||
test('atomic plan render paths do not bypass RenderDeviceSnapshot with this.hass', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
for (const name of [
|
||||
'_roomLqi', '_resolvedRoomFills', '_sunNow', '_renderSunRays', '_renderGlowLayer',
|
||||
'_renderVacuums', '_renderVacFit', '_renderDevice', '_roomTemp', '_roomHum', '_openingAmt',
|
||||
'_renderOpeningLocks', '_renderDecorLayer',
|
||||
]) {
|
||||
assert.doesNotMatch(methodBody(source, name), /this\.hass\b/, name);
|
||||
}
|
||||
});
|
||||
@@ -155,22 +155,24 @@ test('a stale recovery token can never commit', async () => {
|
||||
assert.equal(controller.frameFingerprint, 'old');
|
||||
});
|
||||
|
||||
test('a readiness loss rejects recoverably without masquerading as a timeout', async () => {
|
||||
test('asset readiness is polled inside the bounded barrier and keeps a stale frame on timeout', async () => {
|
||||
const clock = new FakeClock();
|
||||
const controller = new VisualContinuityController(() => undefined, clock);
|
||||
controller.markCompleteFrame('old');
|
||||
const token = controller.beginCandidate('asset-refresh', 'asset');
|
||||
controller.candidateReady(token);
|
||||
const committed = await controller.commitAfterPaint(token, {
|
||||
const committed = controller.commitAfterPaint(token, {
|
||||
updateComplete: async () => undefined,
|
||||
stageValid: () => true,
|
||||
assetsReady: () => false,
|
||||
frameFingerprint: () => 'bad',
|
||||
});
|
||||
assert.equal(committed, false);
|
||||
assert.equal(controller.state, 'holding');
|
||||
await Promise.resolve();
|
||||
clock.advance(PAINT_BARRIER_MAX_MS);
|
||||
assert.equal(await committed, false);
|
||||
assert.equal(controller.state, 'steady');
|
||||
assert.equal(controller.frameFingerprint, 'old');
|
||||
assert.equal(controller.trace.at(-1)?.event, 'paint-barrier-rejected');
|
||||
assert.equal(controller.trace.at(-1)?.event, 'paint-barrier-timeout');
|
||||
});
|
||||
|
||||
test('a real paint timeout without a complete frame becomes a recoverable error', async () => {
|
||||
@@ -195,4 +197,21 @@ test('content fingerprints are stable across object key order and frame parts',
|
||||
assert.equal(contentFingerprint({ b: 2, a: 1 }), contentFingerprint({ a: 1, b: 2 }));
|
||||
assert.notEqual(contentFingerprint({ a: 1 }), contentFingerprint({ a: 2 }));
|
||||
assert.equal(visualFrameFingerprint([1, 'x']), visualFrameFingerprint([1, 'x']));
|
||||
const shared = { value: 1 };
|
||||
assert.equal(
|
||||
contentFingerprint({ a: shared, b: shared }),
|
||||
contentFingerprint({ a: { value: 1 }, b: { value: 1 } }),
|
||||
'a shared acyclic reference is hashed by value, not misclassified as a cycle',
|
||||
);
|
||||
});
|
||||
|
||||
test('trace diagnostics are returned as a defensive copy', () => {
|
||||
const clock = new FakeClock();
|
||||
const controller = new VisualContinuityController(() => undefined, clock);
|
||||
controller.note('sample', { stage: [10, 20] });
|
||||
const trace = controller.trace;
|
||||
trace[0].event = 'mutated';
|
||||
trace[0].stage[0] = 999;
|
||||
assert.equal(controller.trace[0].event, 'sample');
|
||||
assert.deepEqual(controller.trace[0].stage, [10, 20]);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"src/space-geometry.ts",
|
||||
"src/signing.ts",
|
||||
"src/visual-continuity.ts",
|
||||
"src/render-device-snapshot.ts",
|
||||
"src/command-stack.ts",
|
||||
"src/align-grid.ts",
|
||||
"src/plan-optimizer.ts",
|
||||
|
||||