fix: roll back rejected marker writes atomically

Issue: #442
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-03 21:42:57 +00:00
committed by claude[bot]
parent 215d59823b
commit 3a88f3ad6c
36 changed files with 1138 additions and 462 deletions
@@ -1,125 +1,125 @@
{ {
"schema": 1, "schema": 1,
"fingerprint": "5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5", "fingerprint": "4e8e80e2c125257ca4ae9222e54a57b88f14757c520801bf6c28ebb62c8281fa",
"entry": "houseplan-card.js", "entry": "houseplan-card.js",
"initialViewFiles": [ "initialViewFiles": [
"houseplan-assets/houseplan-card-CfQ49v8a.js", "houseplan-assets/houseplan-card-CheE6i9L.js",
"houseplan-card.js" "houseplan-card.js"
], ],
"initialViewGzipBytes": 293046, "initialViewGzipBytes": 293148,
"lazyFiles": [ "lazyFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/de-D6tfUEEi.js", "houseplan-assets/de-CtdCgGNT.js",
"houseplan-assets/editor-veGv06fc.js", "houseplan-assets/editor-a0TiMYLK.js",
"houseplan-assets/fr-B9jqd_9T.js", "houseplan-assets/fr-C_YoglnG.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js", "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js" "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
], ],
"lazyGzipBytes": 221355, "lazyGzipBytes": 222077,
"lazyEditorFiles": [ "lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/editor-veGv06fc.js", "houseplan-assets/editor-a0TiMYLK.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js" "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js"
], ],
"lazyEditorGzipBytes": 165335, "lazyEditorGzipBytes": 166046,
"lazyOnboardingFiles": [ "lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js" "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
], ],
"lazyOnboardingGzipBytes": 14233, "lazyOnboardingGzipBytes": 14231,
"lazyLocaleFiles": [ "lazyLocaleFiles": [
"houseplan-assets/de-D6tfUEEi.js", "houseplan-assets/de-CtdCgGNT.js",
"houseplan-assets/fr-B9jqd_9T.js" "houseplan-assets/fr-C_YoglnG.js"
], ],
"lazyLocaleGzipBytes": 49066, "lazyLocaleGzipBytes": 49078,
"files": [ "files": [
{ {
"path": "houseplan-assets/backdrop-pick-DsJZC1kQ.js", "path": "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"sha256": "f163ec3c045e02b53c31d00b7b18b87394b1d485d01963878d390db5280e9d3c", "sha256": "bf2fc78b96e1f99e347924dd5eee54b3406bb6a78639301fd79b4d56e1efd251",
"rawBytes": 21418, "rawBytes": 21418,
"gzipBytes": 7279, "gzipBytes": 7278,
"isEntry": false, "isEntry": false,
"imports": [ "imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/de-D6tfUEEi.js", "path": "houseplan-assets/de-CtdCgGNT.js",
"sha256": "8bbf9c291b9973c77de053a1677d19846cc61e44232988739af1e31412812a1e", "sha256": "6a52b552d343d8f969997904d7d3b8146d699ef87fa5d3eed506f3cec6a42a3a",
"rawBytes": 82140, "rawBytes": 82140,
"gzipBytes": 24802, "gzipBytes": 24808,
"isEntry": false, "isEntry": false,
"imports": [], "imports": [],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/editor-veGv06fc.js", "path": "houseplan-assets/editor-a0TiMYLK.js",
"sha256": "40fcfabb1c6b1df59264c1a0da9d74cb67cfd2230e731f0e2c20e9fdaa21c01e", "sha256": "00980a861d57c8c31544ea828308fd7746380633134d29f4dddeb7d32e418669",
"rawBytes": 3826, "rawBytes": 3826,
"gzipBytes": 1580, "gzipBytes": 1581,
"isEntry": false, "isEntry": false,
"imports": [ "imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/fr-B9jqd_9T.js", "path": "houseplan-assets/fr-C_YoglnG.js",
"sha256": "4ec01fd1b61ce5f0a796edb86bcbe4f87dcfba1b37c6f12afca535590ce36b9d", "sha256": "61fd6f6b50ae250f8d95e6b893d4973354927dd0920044e67ea09bfbbafbfa8f",
"rawBytes": 84267, "rawBytes": 84267,
"gzipBytes": 24264, "gzipBytes": 24270,
"isEntry": false, "isEntry": false,
"imports": [], "imports": [],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/houseplan-card-CfQ49v8a.js", "path": "houseplan-assets/houseplan-card-CheE6i9L.js",
"sha256": "46dbdf5dc1881b44d03804f95f9406242f6f491f534466acdfcb1f478618b61d", "sha256": "f3069dacc6e4c6ae46bec78b957748f21053d573f7c80c327787ec8a68e3d6a9",
"rawBytes": 1040074, "rawBytes": 1040585,
"gzipBytes": 292251, "gzipBytes": 292354,
"isEntry": false, "isEntry": false,
"imports": [], "imports": [],
"dynamicImports": [ "dynamicImports": [
"houseplan-assets/de-D6tfUEEi.js", "houseplan-assets/de-CtdCgGNT.js",
"houseplan-assets/editor-veGv06fc.js", "houseplan-assets/editor-a0TiMYLK.js",
"houseplan-assets/fr-B9jqd_9T.js", "houseplan-assets/fr-C_YoglnG.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js", "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js" "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
] ]
}, },
{ {
"path": "houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js", "path": "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
"sha256": "3eabc904f3eec1ad453ad7746433e3232caa38ef0797adbf7833318ee22cb946", "sha256": "7cc4ee224f6efbd9d16dd8ff1dd00a87b8d566dec444272265f02dea0ebd95a3",
"rawBytes": 595676, "rawBytes": 597836,
"gzipBytes": 156476, "gzipBytes": 157187,
"isEntry": false, "isEntry": false,
"imports": [ "imports": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js", "path": "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js",
"sha256": "d3645c23d4dd4dc4b1dd93452f047b3936e5878f90688a14d5d8693da424a0f7", "sha256": "4f2c09b2688fbc967913c8f1166d3f9515a1dc82c9debe82be4bffae707f28e4",
"rawBytes": 28088, "rawBytes": 28088,
"gzipBytes": 6954, "gzipBytes": 6953,
"isEntry": false, "isEntry": false,
"imports": [ "imports": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-card.js", "path": "houseplan-card.js",
"sha256": "5a6cb0840f0836d5c09b7de0313c57b74c0bb1d5df585067062229b553e89bab", "sha256": "821870c8e6cba7a01d630b5a2a412f5d76d4afc639c3eba926b13b41f27508c2",
"rawBytes": 1183, "rawBytes": 1183,
"gzipBytes": 795, "gzipBytes": 794,
"isEntry": true, "isEntry": true,
"imports": [ "imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
} }
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-CfQ49v8a.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4e8e80e2c125257ca4ae9222e54a57b88f14757c520801bf6c28ebb62c8281fa";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-CheE6i9L.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass} .hass=${this.hass}
.data=${this._formData} .data=${this._formData}
.schema=${e} .schema=${e}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";try{await import("./houseplan-assets/houseplan-card-CfQ49v8a.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)} globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4e8e80e2c125257ca4ae9222e54a57b88f14757c520801bf6c28ebb62c8281fa";try{await import("./houseplan-assets/houseplan-card-CheE6i9L.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+92
View File
@@ -0,0 +1,92 @@
// #442: marker Save is an immutable config transaction. A rejected semantic
// write restores the accepted View while keeping the dialog draft for Retry;
// failures after config acceptance never roll the accepted marker back.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const result = {};
await new Promise((resolve) => setTimeout(resolve, 650));
c._saveConfigDebounced?.flush?.();
await c._writeChain;
await c._areaRelocationWrite;
c._setMode('devices');
await c.updateComplete;
const device = c._devices[0];
const markerId = device.id;
const acceptedName = c._serverCfg.markers.find((item) => item.id === markerId)?.name ?? null;
let acceptedFingerprint = c._cfgContentFingerprint;
c._openMarkerDialog(device);
await c.updateComplete;
const draftName = 'Rejected marker draft #442';
c._markerDialog = { ...c._markerDialog, name: draftName };
const realWS = c.hass.callWS;
let rejectConfig = false;
let configWrites = 0;
let configGate = null;
let rejectLayout = false;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/set') {
configWrites += 1;
const rejectThis = rejectConfig;
if (configGate) await configGate.promise;
if (rejectThis) throw new Error('semantic marker reject');
}
if (message.type === 'houseplan/layout/update' && rejectLayout)
throw new Error('layout unavailable');
return realWS(message);
} };
await c.updateComplete;
// Queue the rejected marker attempt behind an older accepted write. Its
// rollback guard must adopt the revision actually used after the queue wait.
let releasePrior;
configGate = { promise: new Promise((resolve) => { releasePrior = resolve; }) };
const priorWrite = c._writeConfig();
while (configWrites === 0) await new Promise((resolve) => setTimeout(resolve, 0));
acceptedFingerprint = c._cfgContentFingerprint;
rejectConfig = true;
const rejectedWrite = c._saveMarker();
releasePrior();
await priorWrite;
configGate = null;
await rejectedWrite;
await c.updateComplete;
const rejectedMarker = c._serverCfg.markers.find((item) => item.id === markerId);
result.rejectedConfigRestored = (rejectedMarker?.name ?? null) === acceptedName;
result.rejectedViewRestored = c._devices.find((item) => item.id === markerId)?.name === device.name;
result.acceptedFingerprintRestored = c._cfgContentFingerprint === acceptedFingerprint;
result.dialogDraftPreserved = c._markerDialog?.name === draftName && c._markerDialog.busy === false;
result.rejectHasNoSuccess = c._toast !== c._t('toast.marker_saved');
result.oneRejectedWrite = configWrites === 2;
rejectConfig = false;
await c._saveMarker();
await c.updateComplete;
result.retryAccepted = c._serverCfg.markers.find((item) => item.id === markerId)?.name === draftName;
result.retryClosedAndToasted = c._markerDialog === null && c._toast === c._t('toast.marker_saved');
result.retryWroteOnce = configWrites === 3;
// A new virtual marker has a layout side effect after config/set. Reject
// that second phase: the already accepted config must remain authoritative.
c._openMarkerDialog();
const virtualName = 'Accepted before layout failure #442';
c._markerDialog = { ...c._markerDialog, name: virtualName, binding: 'virtual' };
rejectLayout = true;
await c._saveMarker();
await c.updateComplete;
result.sideEffectFailureKeepsAcceptedConfig = c._serverCfg.markers
.some((item) => item.name === virtualName);
result.sideEffectFailureHasNoSuccess = c._toast !== c._t('toast.marker_saved');
result.sideEffectFailureKeepsDraft = c._markerDialog?.name === virtualName
&& c._markerDialog.busy === false;
c.hass = { ...c.hass, callWS: realWS };
return result;
});
checkAll(out);
await finish(browser, out);
+1 -1
View File
@@ -262,7 +262,7 @@ const out = await page.evaluate(async () => {
await c.updateComplete; await c.updateComplete;
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6; o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
// save -> matrix stored for m2, panel closed, puck appears while cleaning // save -> matrix stored for m2, panel closed, puck appears while cleaning
c._vacFitSave(); await c.updateComplete; await c._vacFitSave(); await c.updateComplete;
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2; const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite); o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit'); o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
+90 -15
View File
@@ -56,14 +56,23 @@ const out = await page.evaluate(async () => {
// spy on config writes: a success toast is only honest after one of these // spy on config writes: a success toast is only honest after one of these
const writes = []; const writes = [];
const realWS = c.hass.callWS; const realWS = c.hass.callWS;
c.hass.callWS = async (m) => { if (m.type === 'houseplan/config/set') writes.push(m); return realWS(m); }; let configGate = null;
let rejectConfig = false;
c.hass.callWS = async (m) => {
if (m.type === 'houseplan/config/set') {
writes.push(m);
if (configGate) await configGate.promise;
if (rejectConfig) throw new Error('calibration rejected');
}
return realWS(m);
};
const dev = c._devices.find((x) => x.id === 'd_mower'); const dev = c._devices.find((x) => x.id === 'd_mower');
o.devFound = !!dev; o.devFound = !!dev;
o.freshNoMarker = (c._serverCfg.markers || []).length === 0 && !dev.marker; o.freshNoMarker = (c._serverCfg.markers || []).length === 0 && !dev.marker;
// ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ---- // ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ----
c._vacAutoCalibrate(dev); await c._vacAutoCalibrate(dev);
await c.updateComplete; await c.updateComplete;
const m1 = (c._serverCfg.markers || []).find((x) => x.id === 'd_mower'); const m1 = (c._serverCfg.markers || []).find((x) => x.id === 'd_mower');
o.markerMaterialised = !!m1 && m1.binding === 'device:d_mower'; o.markerMaterialised = !!m1 && m1.binding === 'device:d_mower';
@@ -79,25 +88,91 @@ const out = await page.evaluate(async () => {
o.configWritePersisted = writes.length >= 1 o.configWritePersisted = writes.length >= 1
&& !!writes[writes.length - 1].config.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0']; && !!writes[writes.length - 1].config.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'];
// #442: a low-residual write stays busy until config/set answers. Rejecting
// it restores the accepted matrix and leaves the Device editor usable.
const acceptedBeforeReject = [...cal0];
for (const room of Object.values(robotRooms)) { room.x0 += 200; room.x1 += 200; }
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
let releaseGate;
configGate = { promise: new Promise((resolve) => { releaseGate = resolve; }) };
rejectConfig = true;
c._toast = null;
const beforeDeferredWrites = writes.length;
const rejectedAuto = c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
while (writes.length === beforeDeferredWrites) await new Promise((resolve) => setTimeout(resolve, 0));
await c.updateComplete;
const autoButton = [...sr().querySelectorAll('.vacbtns button')]
.find((button) => button.textContent.includes(c._t('vac.autocal')));
o.autoWaitsBusyDisabled = c._markerDialog?.busy === true && autoButton?.disabled === true;
o.autoNoEarlySuccess = c._toast === null;
await c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
o.autoDoubleClickSuppressed = writes.length === beforeDeferredWrites + 1;
releaseGate();
await rejectedAuto; await c.updateComplete;
o.autoRejectRestoresAccepted = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) === JSON.stringify(acceptedBeforeReject);
o.autoRejectKeepsDialog = c._markerDialog?.busy === false;
o.autoRejectHasNoSuccess = !String(c._toast || '').startsWith('Done: bound via');
configGate = null;
rejectConfig = false;
await c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
await c.updateComplete;
o.autoRetryAccepted = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) !== JSON.stringify(acceptedBeforeReject)
&& String(c._toast || '').startsWith('Done: bound via');
// High residual is a proposal, never an implicit write. Cover Cancel, // High residual is a proposal, never an implicit write. Cover Cancel,
// explicit Apply and manual-fit Apply from the same deterministic fixture. // explicit Apply and manual-fit Apply from the same deterministic fixture.
const lowMatrix = [...cal0]; const lowMatrix = [
...c._serverCfg.markers.find((x) => x.id === 'd_mower').vacuum.calibration['0'],
];
robotRooms[4].x0 = 20600; robotRooms[4].x0 = 20600;
robotRooms[4].x1 = 21400; robotRooms[4].x1 = 21400;
c._vacAutoCalibrate(dev); await c.updateComplete; await c._vacAutoCalibrate(dev); await c.updateComplete;
o.highResidualDialog = !!c._vacCalConfirm; o.highResidualDialog = !!c._vacCalConfirm;
o.highResidualNoImplicitSave = JSON.stringify(m1.vacuum.calibration['0']) === JSON.stringify(lowMatrix); o.highResidualNoImplicitSave = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) === JSON.stringify(lowMatrix);
c._vacCalConfirm = null; await c.updateComplete; c._vacCalConfirm = null; await c.updateComplete;
o.highResidualCancelUntouched = JSON.stringify(m1.vacuum.calibration['0']) === JSON.stringify(lowMatrix); o.highResidualCancelUntouched = JSON.stringify(
c._vacAutoCalibrate(dev); await c.updateComplete; c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
c._vacApplyCalibrationProposal(false); await c.updateComplete; ) === JSON.stringify(lowMatrix);
const appliedProposal = [...m1.vacuum.calibration['0']]; await c._vacAutoCalibrate(dev); await c.updateComplete;
const rejectedProposalDraft = JSON.stringify(c._vacCalConfirm);
rejectConfig = true;
await c._vacApplyCalibrationProposal(false); await c.updateComplete;
o.highResidualRejectKeepsProposal = !!c._vacCalConfirm
&& c._vacCalConfirm.busy === false
&& JSON.stringify({ ...c._vacCalConfirm, busy: undefined })
=== JSON.stringify({ ...JSON.parse(rejectedProposalDraft), busy: undefined });
o.highResidualRejectRestoresMatrix = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) === JSON.stringify(lowMatrix);
rejectConfig = false;
await c._vacApplyCalibrationProposal(false); await c.updateComplete;
const appliedProposal = [
...c._serverCfg.markers.find((x) => x.id === 'd_mower').vacuum.calibration['0'],
];
o.highResidualApplySaved = JSON.stringify(appliedProposal) !== JSON.stringify(lowMatrix); o.highResidualApplySaved = JSON.stringify(appliedProposal) !== JSON.stringify(lowMatrix);
c._vacAutoCalibrate(dev); await c.updateComplete; await c._vacAutoCalibrate(dev); await c.updateComplete;
c._vacApplyCalibrationProposal(true); await c.updateComplete; await c._vacApplyCalibrationProposal(true); await c.updateComplete;
o.highResidualManualOpened = !!c._vacFit && c._vacFit.markerId === 'd_mower'; o.highResidualManualOpened = !!c._vacFit && c._vacFit.markerId === 'd_mower';
c._vacFitSave(); await c.updateComplete; const manualDraft = JSON.stringify(c._vacFit);
o.highResidualManualApplied = !c._vacFit && Array.isArray(m1.vacuum.calibration['0']) rejectConfig = true;
await c._vacFitSave(); await c.updateComplete;
o.manualRejectKeepsExactDraft = JSON.stringify({ ...c._vacFit, busy: undefined })
=== JSON.stringify({ ...JSON.parse(manualDraft), busy: undefined });
o.manualRejectRestoresMatrix = JSON.stringify(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
) === JSON.stringify(appliedProposal);
rejectConfig = false;
await c._vacFitSave(); await c.updateComplete;
o.highResidualManualApplied = !c._vacFit && Array.isArray(
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
)
&& c._toast === c._t('vac.cal_done'); && c._toast === c._t('vac.cal_done');
robotRooms[4].x0 = 1700; robotRooms[4].x0 = 1700;
robotRooms[4].x1 = 2500; robotRooms[4].x1 = 2500;
@@ -108,7 +183,7 @@ const out = await page.evaluate(async () => {
const dev2 = c._devices.find((x) => x.id === 'd_mower'); const dev2 = c._devices.find((x) => x.id === 'd_mower');
o.fitFreshNoMarker = !dev2.marker; o.fitFreshNoMarker = !dev2.marker;
c._vacStartFit(dev2); await c.updateComplete; c._vacStartFit(dev2); await c.updateComplete;
c._vacFitSave(); await c.updateComplete; await c._vacFitSave(); await c.updateComplete;
const m2 = (c._serverCfg.markers || []).find((x) => x.id === 'd_mower'); const m2 = (c._serverCfg.markers || []).find((x) => x.id === 'd_mower');
const calFit = m2?.vacuum?.calibration?.['0']; const calFit = m2?.vacuum?.calibration?.['0'];
o.fitMaterialises = !!m2 && Array.isArray(calFit) && calFit.length === 6; o.fitMaterialises = !!m2 && Array.isArray(calFit) && calFit.length === 6;
@@ -167,7 +242,7 @@ const out = await page.evaluate(async () => {
c.hass = { ...c.hass, states: { ...c.hass.states, c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } }; 'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } };
await c.updateComplete; await c.updateComplete;
c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower')); await c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
o.noRoomsToastReworded = c._toast === c._t('vac.autocal_no_rooms') && !/point|точк/i.test(c._toast); o.noRoomsToastReworded = c._toast === c._t('vac.autocal_no_rooms') && !/point|точк/i.test(c._toast);
c.hass.callWS = realWS; c.hass.callWS = realWS;
+1 -1
View File
@@ -94,7 +94,7 @@ const out = await page.evaluate(async () => {
routeId: 'vr2', space: 'garden', matrix: M2, rooms: 3, error: '80 см', routeId: 'vr2', space: 'garden', matrix: M2, rooms: 3, error: '80 см',
}; };
await c.updateComplete; await c.updateComplete;
c._vacApplyCalibrationProposal(true); await c._vacApplyCalibrationProposal(true);
await c.updateComplete; await new Promise((r) => setTimeout(r, 80)); await c.updateComplete; await new Promise((r) => setTimeout(r, 80));
o.manualFitSwitchedToRouteSpace = c._space === 'garden'; o.manualFitSwitchedToRouteSpace = c._space === 'garden';
o.manualFitKeepsRoute = c._vacFit?.routeId === 'vr2'; o.manualFitKeepsRoute = c._vacFit?.routeId === 'vr2';
+57 -57
View File
@@ -1,125 +1,125 @@
{ {
"schema": 1, "schema": 1,
"fingerprint": "5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5", "fingerprint": "4e8e80e2c125257ca4ae9222e54a57b88f14757c520801bf6c28ebb62c8281fa",
"entry": "houseplan-card.js", "entry": "houseplan-card.js",
"initialViewFiles": [ "initialViewFiles": [
"houseplan-assets/houseplan-card-CfQ49v8a.js", "houseplan-assets/houseplan-card-CheE6i9L.js",
"houseplan-card.js" "houseplan-card.js"
], ],
"initialViewGzipBytes": 293046, "initialViewGzipBytes": 293148,
"lazyFiles": [ "lazyFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/de-D6tfUEEi.js", "houseplan-assets/de-CtdCgGNT.js",
"houseplan-assets/editor-veGv06fc.js", "houseplan-assets/editor-a0TiMYLK.js",
"houseplan-assets/fr-B9jqd_9T.js", "houseplan-assets/fr-C_YoglnG.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js", "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js" "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
], ],
"lazyGzipBytes": 221355, "lazyGzipBytes": 222077,
"lazyEditorFiles": [ "lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/editor-veGv06fc.js", "houseplan-assets/editor-a0TiMYLK.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js" "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js"
], ],
"lazyEditorGzipBytes": 165335, "lazyEditorGzipBytes": 166046,
"lazyOnboardingFiles": [ "lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js" "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
], ],
"lazyOnboardingGzipBytes": 14233, "lazyOnboardingGzipBytes": 14231,
"lazyLocaleFiles": [ "lazyLocaleFiles": [
"houseplan-assets/de-D6tfUEEi.js", "houseplan-assets/de-CtdCgGNT.js",
"houseplan-assets/fr-B9jqd_9T.js" "houseplan-assets/fr-C_YoglnG.js"
], ],
"lazyLocaleGzipBytes": 49066, "lazyLocaleGzipBytes": 49078,
"files": [ "files": [
{ {
"path": "houseplan-assets/backdrop-pick-DsJZC1kQ.js", "path": "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"sha256": "f163ec3c045e02b53c31d00b7b18b87394b1d485d01963878d390db5280e9d3c", "sha256": "bf2fc78b96e1f99e347924dd5eee54b3406bb6a78639301fd79b4d56e1efd251",
"rawBytes": 21418, "rawBytes": 21418,
"gzipBytes": 7279, "gzipBytes": 7278,
"isEntry": false, "isEntry": false,
"imports": [ "imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/de-D6tfUEEi.js", "path": "houseplan-assets/de-CtdCgGNT.js",
"sha256": "8bbf9c291b9973c77de053a1677d19846cc61e44232988739af1e31412812a1e", "sha256": "6a52b552d343d8f969997904d7d3b8146d699ef87fa5d3eed506f3cec6a42a3a",
"rawBytes": 82140, "rawBytes": 82140,
"gzipBytes": 24802, "gzipBytes": 24808,
"isEntry": false, "isEntry": false,
"imports": [], "imports": [],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/editor-veGv06fc.js", "path": "houseplan-assets/editor-a0TiMYLK.js",
"sha256": "40fcfabb1c6b1df59264c1a0da9d74cb67cfd2230e731f0e2c20e9fdaa21c01e", "sha256": "00980a861d57c8c31544ea828308fd7746380633134d29f4dddeb7d32e418669",
"rawBytes": 3826, "rawBytes": 3826,
"gzipBytes": 1580, "gzipBytes": 1581,
"isEntry": false, "isEntry": false,
"imports": [ "imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/fr-B9jqd_9T.js", "path": "houseplan-assets/fr-C_YoglnG.js",
"sha256": "4ec01fd1b61ce5f0a796edb86bcbe4f87dcfba1b37c6f12afca535590ce36b9d", "sha256": "61fd6f6b50ae250f8d95e6b893d4973354927dd0920044e67ea09bfbbafbfa8f",
"rawBytes": 84267, "rawBytes": 84267,
"gzipBytes": 24264, "gzipBytes": 24270,
"isEntry": false, "isEntry": false,
"imports": [], "imports": [],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/houseplan-card-CfQ49v8a.js", "path": "houseplan-assets/houseplan-card-CheE6i9L.js",
"sha256": "46dbdf5dc1881b44d03804f95f9406242f6f491f534466acdfcb1f478618b61d", "sha256": "f3069dacc6e4c6ae46bec78b957748f21053d573f7c80c327787ec8a68e3d6a9",
"rawBytes": 1040074, "rawBytes": 1040585,
"gzipBytes": 292251, "gzipBytes": 292354,
"isEntry": false, "isEntry": false,
"imports": [], "imports": [],
"dynamicImports": [ "dynamicImports": [
"houseplan-assets/de-D6tfUEEi.js", "houseplan-assets/de-CtdCgGNT.js",
"houseplan-assets/editor-veGv06fc.js", "houseplan-assets/editor-a0TiMYLK.js",
"houseplan-assets/fr-B9jqd_9T.js", "houseplan-assets/fr-C_YoglnG.js",
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js", "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js" "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
] ]
}, },
{ {
"path": "houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js", "path": "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
"sha256": "3eabc904f3eec1ad453ad7746433e3232caa38ef0797adbf7833318ee22cb946", "sha256": "7cc4ee224f6efbd9d16dd8ff1dd00a87b8d566dec444272265f02dea0ebd95a3",
"rawBytes": 595676, "rawBytes": 597836,
"gzipBytes": 156476, "gzipBytes": 157187,
"isEntry": false, "isEntry": false,
"imports": [ "imports": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js", "path": "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js",
"sha256": "d3645c23d4dd4dc4b1dd93452f047b3936e5878f90688a14d5d8693da424a0f7", "sha256": "4f2c09b2688fbc967913c8f1166d3f9515a1dc82c9debe82be4bffae707f28e4",
"rawBytes": 28088, "rawBytes": 28088,
"gzipBytes": 6954, "gzipBytes": 6953,
"isEntry": false, "isEntry": false,
"imports": [ "imports": [
"houseplan-assets/backdrop-pick-DsJZC1kQ.js", "houseplan-assets/backdrop-pick-D2rfOCFq.js",
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
}, },
{ {
"path": "houseplan-card.js", "path": "houseplan-card.js",
"sha256": "5a6cb0840f0836d5c09b7de0313c57b74c0bb1d5df585067062229b553e89bab", "sha256": "821870c8e6cba7a01d630b5a2a412f5d76d4afc639c3eba926b13b41f27508c2",
"rawBytes": 1183, "rawBytes": 1183,
"gzipBytes": 795, "gzipBytes": 794,
"isEntry": true, "isEntry": true,
"imports": [ "imports": [
"houseplan-assets/houseplan-card-CfQ49v8a.js" "houseplan-assets/houseplan-card-CheE6i9L.js"
], ],
"dynamicImports": [] "dynamicImports": []
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-CfQ49v8a.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4e8e80e2c125257ca4ae9222e54a57b88f14757c520801bf6c28ebb62c8281fa";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-CheE6i9L.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass} .hass=${this.hass}
.data=${this._formData} .data=${this._formData}
.schema=${e} .schema=${e}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";try{await import("./houseplan-assets/houseplan-card-CfQ49v8a.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)} globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="4e8e80e2c125257ca4ae9222e54a57b88f14757c520801bf6c28ebb62c8281fa";try{await import("./houseplan-assets/houseplan-card-CheE6i9L.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+5
View File
@@ -2,6 +2,11 @@
## Unreleased ## Unreleased
- Rejected device-marker and robot-calibration saves now restore the last
server-confirmed plan without losing the open editor, automatic proposal or
manual-fit draft; success is shown only after Home Assistant accepts the
write, and Retry no longer requires recalibration
([#442](https://github.com/Matysh/houseplan-card/issues/442)).
- Removing a robot vacuum's last map route now keeps the route list explicitly - Removing a robot vacuum's last map route now keeps the route list explicitly
empty instead of reviving legacy calibration; routes to deleted spaces are empty instead of reviving legacy calibration; routes to deleted spaces are
grouped clearly in the editor, and the live layer avoids rescanning every grouped clearly in the editor, and the live layer avoids rescanning every
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено ## Не выпущено
- Если Home Assistant отклоняет сохранение устройства или калибровки робота,
план теперь возвращается к последнему подтверждённому состоянию, но сохраняет
открытый редактор, автоматическое предложение или ручную подгонку для повтора;
сообщение об успехе появляется только после принятой записи
([#442](https://github.com/Matysh/houseplan-card/issues/442)).
- После удаления последнего маршрута карты робота список теперь остаётся явно - После удаления последнего маршрута карты робота список теперь остаётся явно
пустым и не возвращает старую калибровку; маршруты в удалённые пространства пустым и не возвращает старую калибровку; маршруты в удалённые пространства
собраны в понятную группу, а живой слой больше не перебирает повторно все собраны в понятную группу, а живой слой больше не перебирает повторно все
+16
View File
@@ -556,6 +556,22 @@ full/space transfer preserve, remap or report/drop `derived_marker_state.ref`
through the same reference seam as controls and value badges. Older clients through the same reference seam as controls and value badges. Older clients
ignore the field and may erase it if they reconstruct the marker. ignore the field and may erase it if they reconstruct the marker.
## Atomic marker writes (#442)
The Device editor builds a separate complete config candidate and treats a
successful `houseplan/config/set` response as the durable boundary. If semantic
validation, transport or schema validation rejects that request, the card
restores the preceding server-confirmed config and content fingerprint, rebuilds
the visible marker set, and keeps the independent dialog draft available for
Retry. A revision loaded after a conflict or newer local content always wins;
an older rejection cannot overwrite either one.
Layout placement, obsolete-layout cleanup and copied-file cleanup happen only
after config acceptance. Their failure can report an error and keep the dialog
open, but cannot roll back a marker already accepted by the server. This is a
frontend transaction contract only: marker schema, validators, revision wire
format and downgrade behaviour are unchanged.
## Persistent manual virtual-light state ## Persistent manual virtual-light state
The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a
+9
View File
@@ -126,6 +126,15 @@ At `> 40 cm` nothing is saved until the user explicitly chooses **Apply**.
**Fit manually** opens the proposal in the fit overlay; **Cancel** leaves the **Fit manually** opens the proposal in the fit overlay; **Cancel** leaves the
saved configuration byte-for-byte unchanged. saved configuration byte-for-byte unchanged.
Calibration Save is complete only after Home Assistant accepts the config
write. While it is pending, automatic-calibration controls, a high-residual
proposal or the manual-fit overlay stay busy and cannot submit, close or move a
second draft. On rejection the last accepted matrix is restored immediately:
the Device editor remains open, a high-residual proposal keeps its exact matrix,
and manual fit keeps its exact transform for Retry. A rejected first-use
calibration also removes the unaccepted synthetic marker. Success closes the
relevant proposal/overlay and appears only after server acceptance.
Map ID uses one nullish chain and deliberately ignores volatile values such as Map ID uses one nullish chain and deliberately ignores volatile values such as
`vacuum_json_id`: `vacuum_json_id`:
+81 -6
View File
@@ -450,9 +450,9 @@ const MUTANT_DEFINITIONS = [
+ 'against — opening it on the dock floor fits the robot to the wrong plan (#162, AC8, ' + 'against — opening it on the dock floor fits the robot to the wrong plan (#162, AC8, '
+ 'the stateful half the pure calibrationTarget mutant cannot reach)', + 'the stateful half the pure calibrationTarget mutant cannot reach)',
patches: [{ patches: [{
file: 'src/houseplan-editor-runtime.ts', file: 'src/vacuum-calibration-write.ts',
find: ' const space = proposal.space || dev.space;', find: ' const space = proposal.space || device.space;',
replace: ' const space = dev.space;', replace: ' const space = device.space;',
}], }],
}, },
{ {
@@ -715,12 +715,12 @@ const MUTANT_DEFINITIONS = [
because: 'saving one child must preserve the device tombstone; dropping all tombstones ' because: 'saving one child must preserve the device tombstone; dropping all tombstones '
+ 'resurrects the automatic parent and the siblings the person deliberately removed (#262)', + 'resurrects the automatic parent and the siblings the person deliberately removed (#262)',
patches: [{ patches: [{
file: 'src/houseplan-card.ts', file: 'src/houseplan-editor-runtime.ts',
find: ` cfg.markers = cfg.markers.filter( find: ` candidate.markers = candidate.markers.filter(
(m) => m.id !== id && m.id !== oldId (m) => m.id !== id && m.id !== oldId
&& (marker.binding === 'virtual' || m.binding !== marker.binding), && (marker.binding === 'virtual' || m.binding !== marker.binding),
);`, );`,
replace: ` cfg.markers = cfg.markers.filter( replace: ` candidate.markers = candidate.markers.filter(
(m) => m.id !== id && m.id !== oldId (m) => m.id !== id && m.id !== oldId
&& (marker.binding === 'virtual' || m.binding !== marker.binding) && (marker.binding === 'virtual' || m.binding !== marker.binding)
&& (!marker.binding.startsWith('entity:') || m.removed !== true), && (!marker.binding.startsWith('entity:') || m.removed !== true),
@@ -5367,6 +5367,81 @@ const MUTANT_DEFINITIONS = [
replace: 'const page = await (await browser.newContext()).newPage();', replace: 'const page = await (await browser.newContext()).newPage();',
}], }],
}, },
{
id: 'marker-reject-keeps-optimistic-candidate',
guard: 'node demo/smoke_marker_write_rollback.mjs',
because: 'a rejected semantic marker write must restore accepted config and View while '
+ 'leaving the independent Device editor draft available for Retry (#442 AC1)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: ' rollbackOptimistic(this.host, attempt, contentFingerprint);\n'
+ " this.host._regSignature = '';",
replace: " this.host._regSignature = '';",
}],
},
{
id: 'marker-rollback-keeps-enqueue-time-revision',
guard: 'node demo/smoke_marker_write_rollback.mjs',
because: 'a marker save queued behind an accepted write must guard rollback with the revision '
+ 'its own request used, while still yielding to genuinely newer content (#442 AC2)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: ' if (attempt && candidateFingerprint === attempt.attemptedFingerprint) attempt.revision = this.host._cfgRev;',
replace: '',
}],
},
{
id: 'accepted-marker-rolled-back-by-layout-failure',
guard: 'node demo/smoke_marker_write_rollback.mjs',
because: 'config acceptance is the durable marker boundary: a later layout failure must not '
+ 'restore the marker that the server already accepted (#442 AC3)',
patches: [{
file: 'src/houseplan-editor-runtime.ts',
find: ' configAccepted = true;\n if (newPos) {',
replace: ' if (newPos) {',
}],
},
{
id: 'vacuum-reject-keeps-optimistic-matrix',
guard: 'node --test test/vacuum-calibration-write.test.mjs',
because: 'a rejected calibration, including first use, must not leave its matrix or synthetic '
+ 'marker in the local accepted config (#442 AC5–AC7)',
patches: [{
file: 'src/vacuum-calibration-write.ts',
find: ' rollbackOptimistic(host, attempt, contentFingerprint);\n rebuild(host);',
replace: ' rebuild(host);',
}],
},
{
id: 'vacuum-auto-reports-success-before-acceptance',
guard: 'node --test test/vacuum-calibration-write.test.mjs',
because: 'automatic calibration must stay busy and suppress both duplicate writes and success '
+ 'until config/set has actually resolved (#442 AC4)',
patches: [{
file: 'src/vacuum-calibration-write.ts',
find: ' const saved = await saveVacuumMatrix(\n'
+ ' runtime, request.markerId, request.source, request.mapId, request.matrix, request.routeId,\n'
+ ' );',
replace: ' const saved = true;\n'
+ ' void saveVacuumMatrix(\n'
+ ' runtime, request.markerId, request.source, request.mapId, request.matrix, request.routeId,\n'
+ ' );',
}],
},
{
id: 'optimistic-rollback-skips-same-root-fingerprint',
guard: 'node --test test/serialized-write-queue.test.mjs',
because: 'newer in-place content can retain the attempted object identity; rollback must still '
+ 'compare content and must never erase that newer owner (#442 AC2)',
patches: [{
file: 'src/serialized-write-queue.ts',
find: ' if (!current || host._cfgRev !== attempt.revision\n'
+ ' || fingerprint(current) !== attempt.attemptedFingerprint) return false;',
replace: ' if (!current || host._cfgRev !== attempt.revision\n'
+ ' || (current !== attempt.attempted\n'
+ ' && fingerprint(current) !== attempt.attemptedFingerprint)) return false;',
}],
},
]; ];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+32 -22
View File
@@ -246,6 +246,7 @@ import {
formatLatticeShiftCm, formatLatticeShiftCm,
} from './coordinate-canonicalization'; } from './coordinate-canonicalization';
import { enqueueSerializedWrite } from './serialized-write-queue'; import { enqueueSerializedWrite } from './serialized-write-queue';
import type { CalibrationProposal, VacuumFit } from './vacuum-calibration-write';
import { hasTranslation, langOf, t, type I18nKey } from './i18n'; import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { LANGUAGE_RUNTIME, subscribeLanguageLoadFailures } from './i18n/registry'; import { LANGUAGE_RUNTIME, subscribeLanguageLoadFailures } from './i18n/registry';
import { import {
@@ -2503,18 +2504,13 @@ export class HouseplanCard extends LitElement {
}; };
private _resumeSettling = false; private _resumeSettling = false;
private _viewportInvalidAt = 0; private _viewportInvalidAt = 0;
private _vacFit: { markerId: string; source: string; mapId: string; routeId?: string; p: FitParams; private _vacFit: VacuumFit | null = null;
drag: null | { kind: 'move' | 'scale'; sx: number; sy: number; p0: FitParams;
fx: number; fy: number } } | null = null;
/** Marker whose lazy «All cameras» candidate section is expanded. */ /** Marker whose lazy «All cameras» candidate section is expanded. */
private _vacAllCamerasFor: string | null = null; private _vacAllCamerasFor: string | null = null;
/** One snapshot per currently open global-camera section; rebuilt on reopen. */ /** One snapshot per currently open global-camera section; rebuilt on reopen. */
private _vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[] } | null = null; private _vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[] } | null = null;
/** Proposed high-residual auto-calibration. Config remains untouched until Apply. */ /** Proposed high-residual auto-calibration. Config remains untouched until Apply. */
private _vacCalConfirm: { private _vacCalConfirm: CalibrationProposal | null = null;
markerId: string; source: string; mapId: string; routeId?: string; space?: string; matrix: Affine;
rooms: number; error: string;
} | null = null;
private _kioskDots = false; private _kioskDots = false;
private _kioskDotsTimer?: number; private _kioskDotsTimer?: number;
private _kioskHoldTimer?: number; private _kioskHoldTimer?: number;
@@ -2943,6 +2939,7 @@ export class HouseplanCard extends LitElement {
private _onKey(e: KeyboardEvent): void { private _onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && this._vacFit) { if (e.key === 'Escape' && this._vacFit) {
if (this._vacFit.busy) return;
this._vacFit = null; this._vacFit = null;
this._showToast(this._t('vac.cal_cancelled')); this._showToast(this._t('vac.cal_cancelled'));
e.stopPropagation(); e.stopPropagation();
@@ -2951,7 +2948,10 @@ export class HouseplanCard extends LitElement {
if (e.key === 'Escape') { if (e.key === 'Escape') {
// close the topmost open dialog; info popups first, then editors // close the topmost open dialog; info popups first, then editors
if (this._tapConfirm) { this._tapConfirm = null; return; } if (this._tapConfirm) { this._tapConfirm = null; return; }
if (this._vacCalConfirm) { this._vacCalConfirm = null; return; } if (this._vacCalConfirm) {
if (!this._vacCalConfirm.busy) this._vacCalConfirm = null;
return;
}
if (this._decorEraseConfirm) { this._decorEraseConfirm = null; return; } if (this._decorEraseConfirm) { this._decorEraseConfirm = null; return; }
if (this._openingInfo) { this._openingInfo = null; return; } if (this._openingInfo) { this._openingInfo = null; return; }
if (this._infoCard) { this._closeInfoCard(); return; } if (this._infoCard) { this._closeInfoCard(); return; }
@@ -11882,15 +11882,19 @@ export class HouseplanCard extends LitElement {
: nothing} : nothing}
${this._vacCalConfirm ? this._editorRuntime ? html`<hp-dialog .hass=${this.hass} ${this._vacCalConfirm ? this._editorRuntime ? html`<hp-dialog .hass=${this.hass}
.title=${this._t('vac.residual_title')} icon="mdi:map-marker-alert-outline" .title=${this._t('vac.residual_title')} icon="mdi:map-marker-alert-outline"
dismiss-on-scrim @hp-close=${() => (this._vacCalConfirm = null)}> dismiss-on-scrim aria-busy=${String(!!this._vacCalConfirm.busy)}
@hp-close=${() => { if (!this._vacCalConfirm?.busy) this._vacCalConfirm = null; }}>
<div class="body"> <div class="body">
<p>${this._t('vac.residual_message', { error: this._vacCalConfirm.error })}</p> <p>${this._t('vac.residual_message', { error: this._vacCalConfirm.error })}</p>
</div> </div>
<div class="row" slot="footer"> <div class="row" slot="footer">
<button class="btn ghost" @click=${() => (this._vacCalConfirm = null)}>${this._t('btn.cancel')}</button> <button class="btn ghost" ?disabled=${this._vacCalConfirm.busy}
@click=${() => (this._vacCalConfirm = null)}>${this._t('btn.cancel')}</button>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn ghost" @click=${() => this._vacApplyCalibrationProposal(true)}>${this._t('vac.fit')}</button> <button class="btn ghost" ?disabled=${this._vacCalConfirm.busy}
<button class="btn on" @click=${() => this._vacApplyCalibrationProposal(false)}> @click=${() => this._vacApplyCalibrationProposal(true)}>${this._t('vac.fit')}</button>
<button class="btn on" ?disabled=${this._vacCalConfirm.busy}
@click=${() => this._vacApplyCalibrationProposal(false)}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('vac.apply_proposal')} <ha-icon icon="mdi:check"></ha-icon>${this._t('vac.apply_proposal')}
</button> </button>
</div> </div>
@@ -11924,12 +11928,16 @@ export class HouseplanCard extends LitElement {
</div>` </div>`
: nothing} : nothing}
${this._kioskDialog ? this._renderKioskDialog() : nothing} ${this._kioskDialog ? this._renderKioskDialog() : nothing}
${this._vacFit ? html`<div class="vaccalbar"> ${this._vacFit ? html`<div class="vaccalbar" aria-busy=${String(!!this._vacFit.busy)}>
<span>${this._t('vac.fit_hint')}</span> <span>${this._t('vac.fit_hint')}</span>
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ rot: ((this._vacFit!.p.rot + 90) % 360) as any })}>${this._t('vac.fit_rotate')}</button> <button class="btn ghostbtn" ?disabled=${this._vacFit.busy}
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ mir: !this._vacFit!.p.mir })}>${this._t('vac.fit_mirror')}</button> @click=${() => this._vacFitTurn({ rot: ((this._vacFit!.p.rot + 90) % 360) as any })}>${this._t('vac.fit_rotate')}</button>
<button class="btn" @click=${() => this._vacFitSave()}>${this._t('btn.save')}</button> <button class="btn ghostbtn" ?disabled=${this._vacFit.busy}
<button class="btn ghostbtn" @click=${() => { this._vacFit = null; }}>${this._t('btn.cancel')}</button> @click=${() => this._vacFitTurn({ mir: !this._vacFit!.p.mir })}>${this._t('vac.fit_mirror')}</button>
<button class="btn" ?disabled=${this._vacFit.busy}
@click=${() => this._vacFitSave()}>${this._t('btn.save')}</button>
<button class="btn ghostbtn" ?disabled=${this._vacFit.busy}
@click=${() => { this._vacFit = null; }}>${this._t('btn.cancel')}</button>
</div>` : nothing} </div>` : nothing}
${this._tapConfirm ${this._tapConfirm
? html`<hp-dialog .hass=${this.hass} ? html`<hp-dialog .hass=${this.hass}
@@ -12260,8 +12268,10 @@ export class HouseplanCard extends LitElement {
/** Persist a solved matrix into marker.vacuum.calibration[mapId]. /** Persist a solved matrix into marker.vacuum.calibration[mapId].
* Returns whether the write actually landed — callers must not toast * Returns whether the write actually landed — callers must not toast
* success otherwise (HP-1540-01). */ * success otherwise (HP-1540-01). */
private _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine): boolean { private _vacSaveMatrix(
return this._editorRuntimeOrThrow()._vacSaveMatrix(markerId, source, mapId, matrix); markerId: string, source: string, mapId: string, matrix: Affine, routeId = '',
): Promise<boolean> {
return this._editorRuntimeOrThrow()._vacSaveMatrix(markerId, source, mapId, matrix, routeId);
} }
/** The exact plan-room set accepted by auto-calibration and diagnostics. */ /** The exact plan-room set accepted by auto-calibration and diagnostics. */
@@ -12272,11 +12282,11 @@ export class HouseplanCard extends LitElement {
} }
/** «Настроить автоматически»: robot rooms ↔ plan rooms by name. */ /** «Настроить автоматически»: robot rooms ↔ plan rooms by name. */
private _vacAutoCalibrate(d: DevItem): void { private _vacAutoCalibrate(d: DevItem): Promise<void> {
return this._editorRuntimeOrThrow()._vacAutoCalibrate(d); return this._editorRuntimeOrThrow()._vacAutoCalibrate(d);
} }
private _vacApplyCalibrationProposal(manual: boolean): void { private _vacApplyCalibrationProposal(manual: boolean): Promise<void> {
return this._editorRuntimeOrThrow()._vacApplyCalibrationProposal(manual); return this._editorRuntimeOrThrow()._vacApplyCalibrationProposal(manual);
} }
@@ -12285,7 +12295,7 @@ export class HouseplanCard extends LitElement {
return this._editorRuntimeOrThrow()._vacStartFit(d); return this._editorRuntimeOrThrow()._vacStartFit(d);
} }
private _vacFitSave(): void { private _vacFitSave(): Promise<void> {
return this._editorRuntimeOrThrow()._vacFitSave(); return this._editorRuntimeOrThrow()._vacFitSave();
} }
+72 -70
View File
@@ -9,7 +9,7 @@
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit'; import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
import { guard } from 'lit/directives/guard.js'; import { guard } from 'lit/directives/guard.js';
import { renderVacuumMapsSection } from './editors/vacuum-maps-section'; import { renderVacuumMapsSection } from './editors/vacuum-maps-section';
import { calibrationTarget, planVacuumFit, writeVacuumMatrix } from './vacuum-route-edit'; import { calibrationTarget, planVacuumFit } from './vacuum-route-edit';
import { repeat } from 'lit/directives/repeat.js'; import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog'; import './hp-dialog';
import type { HpDialog } from './hp-dialog'; import type { HpDialog } from './hp-dialog';
@@ -118,7 +118,7 @@ import {
parseVacSourceCandidate, resolveVacSource, resolveCurrentVacPath, trimVacPathTarget, areaCentroid, parseVacSourceCandidate, resolveVacSource, resolveCurrentVacPath, trimVacPathTarget, areaCentroid,
vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM, vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM,
VAC_TELEPORT_GAP_MS, VAC_STALE_MS, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom, FitParams, fitMatrix, initialFit, reanchorFit, VacRoom,
Pt as VacPt, type VacPath, type VacSourceCandidate, Pt as VacPt, type VacPath, type VacSourceCandidate,
type VacSourceResolution, type VacSourceStatus, type VacSourceResolution, type VacSourceStatus,
} from './vacuum'; } from './vacuum';
@@ -245,6 +245,8 @@ import {
formatLatticeShiftCm, formatLatticeShiftCm,
} from './coordinate-canonicalization'; } from './coordinate-canonicalization';
import { enqueueSerializedWrite, optimisticAttempt, rollbackOptimistic, type OptimisticAttempt } from './serialized-write-queue'; import { enqueueSerializedWrite, optimisticAttempt, rollbackOptimistic, type OptimisticAttempt } from './serialized-write-queue';
import { applyCalibrationProposal, saveAutomaticCalibration, saveManualCalibration, saveVacuumMatrix,
type CalibrationProposal, type VacuumFit } from './vacuum-calibration-write';
import { hasTranslation, langOf, t, type I18nKey } from './i18n'; import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { supportT, type SupportI18nKey } from './i18n/support'; import { supportT, type SupportI18nKey } from './i18n/support';
import { import {
@@ -1147,11 +1149,11 @@ export interface HouseplanEditorHostPort {
_undoPoint: () => void; _undoPoint: () => void;
_vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[]; } | null; _vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[]; } | null;
_vacAllCamerasFor: string | null; _vacAllCamerasFor: string | null;
_vacCalConfirm: { markerId: string; source: string; mapId: string; routeId?: string; space?: string; matrix: Affine; rooms: number; error: string; } | null; _vacCalConfirm: CalibrationProposal | null;
_vacEnsureMarker: (d: DevItem) => Marker | null; _vacEnsureMarker: (d: DevItem) => Marker | null;
_vacEntity: (d: DevItem) => string | null; _vacEntity: (d: DevItem) => string | null;
_vacMapId: (d: DevItem, tele: { mapId: string }, planHass?: any) => string; _vacMapId: (d: DevItem, tele: { mapId: string }, planHass?: any) => string;
_vacFit: { markerId: string; source: string; mapId: string; routeId?: string; p: FitParams; drag: null | { kind: "move" | "scale"; sx: number; sy: number; p0: FitParams; fx: number; fy: number; }; } | null; _vacFit: VacuumFit | null;
_vacOpenAllCameras: (d: DevItem) => void; _vacOpenAllCameras: (d: DevItem) => void;
_vacRt: Map<string, { trail: VacPt[]; lastKey: string; lastTs: number; moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | null; }>; _vacRt: Map<string, { trail: VacPt[]; lastKey: string; lastTs: number; moving: boolean; jump: boolean; endedTs: number; lastPos: VacPt | null; }>;
_vacSource: (d: DevItem, planHass?: any) => string | null; _vacSource: (d: DevItem, planHass?: any) => string | null;
@@ -1736,13 +1738,13 @@ public _samePt(a: readonly number[], b: readonly number[]): boolean {
return samePoint(a, b); return samePoint(a, b);
} }
public _dropLegacySegments(): void { public _dropLegacySegments(config = this.host._serverCfg): void {
// «Ripple only» was removed from the UI: keep old configs readable, then // «Ripple only» was removed from the UI: keep old configs readable, then
// materialise the recognisable icon+activity presentation on any write. // materialise the recognisable icon+activity presentation on any write.
for (const marker of this.host._serverCfg?.markers || []) { for (const marker of config?.markers || []) {
if (marker.display === 'ripple') marker.display = 'icon_ripple'; if (marker.display === 'ripple') marker.display = 'icon_ripple';
} }
for (const sp of this.host._serverCfg?.spaces || []) { for (const sp of config?.spaces || []) {
delete (sp as any).segments; delete (sp as any).segments;
const physicalIds = new Set<string>(); const physicalIds = new Set<string>();
const validId = (id: any): id is string => typeof id === 'string' const validId = (id: any): id is string => typeof id === 'string'
@@ -1828,13 +1830,16 @@ public async _reloadRejectedPhysicalWrite(): Promise<void> {
await this.host._reloadConfigOnly(true); await this.host._reloadConfigOnly(true);
} }
public _writeConfig(): Promise<void> { public _prepareConfigCandidate(config: ServerConfig): ServerConfig { this._dropLegacySegments(config); return canonicalizeConfigGeometry(config); }
public _writeConfig(attempt: OptimisticAttempt<ServerConfig> | null = null): Promise<void> {
this.host._writesPending++; this.host._writesPending++;
this.host._writeChain = enqueueSerializedWrite(this.host._writeChain, async () => { this.host._writeChain = enqueueSerializedWrite(this.host._writeChain, async () => {
if (!this.host._serverCfg) return; if (!this.host._serverCfg) return;
this._dropLegacySegments(); const candidate = this._prepareConfigCandidate(this.host._serverCfg);
const candidate = canonicalizeConfigGeometry(this.host._serverCfg);
const candidateFingerprint = contentFingerprint(candidate); const candidateFingerprint = contentFingerprint(candidate);
// Bind a queued optimistic save to the revision its request will actually use.
if (attempt && candidateFingerprint === attempt.attemptedFingerprint) attempt.revision = this.host._cfgRev;
const strictEntries = [...this.host._pendingPhysicalWrites.entries()]; const strictEntries = [...this.host._pendingPhysicalWrites.entries()];
for (const [spaceId, accepted] of strictEntries) { for (const [spaceId, accepted] of strictEntries) {
const candidateSpace = candidate.spaces.find((space) => space.id === spaceId); const candidateSpace = candidate.spaces.find((space) => space.id === spaceId);
@@ -8157,7 +8162,10 @@ public async _saveMarker(): Promise<void> {
} }
const cfg = this.host._serverCfg; const cfg = this.host._serverCfg;
if (!cfg) return; if (!cfg) return;
const markers = cfg.markers || []; const baseRevision = this.host._cfgRev;
const baseContent = contentFingerprint(cfg);
let candidate = JSON.parse(JSON.stringify(cfg)) as ServerConfig;
const markers = candidate.markers || [];
const id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36)); const id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
const oldId = dlg.devId; const oldId = dlg.devId;
const prevDev = oldId ? this.host._devices.find((x) => x.id === oldId) : null; const prevDev = oldId ? this.host._devices.find((x) => x.id === oldId) : null;
@@ -8186,6 +8194,8 @@ public async _saveMarker(): Promise<void> {
const targetSpaceId = targetSpaceModel.id; const targetSpaceId = targetSpaceModel.id;
if (dlg.binding === 'virtual' && !space) space = targetSpaceId; if (dlg.binding === 'virtual' && !space) space = targetSpaceId;
this.host._markerDialog = { ...dlg, busy: true }; this.host._markerDialog = { ...dlg, busy: true };
let attempt: OptimisticAttempt<ServerConfig> | null = null;
let configAccepted = false;
try { try {
const replacedRemovedIds = dlg.binding === 'virtual' const replacedRemovedIds = dlg.binding === 'virtual'
? [] ? []
@@ -8267,8 +8277,9 @@ public async _saveMarker(): Promise<void> {
} }
// Rebinding changes source identity. Rewrite every marker:* edge in the // Rebinding changes source identity. Rewrite every marker:* edge in the
// same config transaction before replacing the marker itself. // same config transaction before replacing the marker itself.
cfg.markers = markers; candidate.markers = markers;
if (oldId && oldId !== id) cfg.markers = rewriteMarkerControlReferences(cfg.markers, oldId, id); if (oldId && oldId !== id)
candidate.markers = rewriteMarkerControlReferences(candidate.markers, oldId, id);
if (oldId && oldId !== id && marker.value_badge?.source?.kind === 'derived_marker_state' if (oldId && oldId !== id && marker.value_badge?.source?.kind === 'derived_marker_state'
&& marker.value_badge.source.ref === `marker:${oldId}`) { && marker.value_badge.source.ref === `marker:${oldId}`) {
marker.value_badge.source = { kind: 'derived_marker_state', ref: `marker:${id}` }; marker.value_badge.source = { kind: 'derived_marker_state', ref: `marker:${id}` };
@@ -8278,19 +8289,19 @@ public async _saveMarker(): Promise<void> {
marker.value_source = { kind: 'derived_marker_state', ref: `marker:${id}` }; marker.value_source = { kind: 'derived_marker_state', ref: `marker:${id}` };
} }
// remove the previous marker (by the old id and by the new id) // remove the previous marker (by the old id and by the new id)
cfg.markers = cfg.markers.filter( candidate.markers = candidate.markers.filter(
(m) => m.id !== id && m.id !== oldId (m) => m.id !== id && m.id !== oldId
&& (marker.binding === 'virtual' || m.binding !== marker.binding), && (marker.binding === 'virtual' || m.binding !== marker.binding),
); );
cfg.markers.push(marker); candidate.markers.push(marker);
const obsoleteAreaSnapshotIds = new Set(replacedRemovedIds); const obsoleteAreaSnapshotIds = new Set(replacedRemovedIds);
if (oldId && oldId !== id) obsoleteAreaSnapshotIds.add(oldId); if (oldId && oldId !== id) obsoleteAreaSnapshotIds.add(oldId);
obsoleteAreaSnapshotIds.delete(id); obsoleteAreaSnapshotIds.delete(id);
if (obsoleteAreaSnapshotIds.size && cfg.settings?.marker_area_snapshot) { if (obsoleteAreaSnapshotIds.size && candidate.settings?.marker_area_snapshot) {
cfg.settings = { candidate.settings = {
...cfg.settings, ...candidate.settings,
marker_area_snapshot: removeMarkerAreaSnapshots( marker_area_snapshot: removeMarkerAreaSnapshots(
cfg.settings.marker_area_snapshot, obsoleteAreaSnapshotIds, candidate.settings.marker_area_snapshot, obsoleteAreaSnapshotIds,
), ),
}; };
} }
@@ -8313,7 +8324,6 @@ public async _saveMarker(): Promise<void> {
// stays in place; pin it under the (possibly new) id // stays in place; pin it under the (possibly new) id
if (id !== oldId || !this.host._layout[id] || roomChanged) { if (id !== oldId || !this.host._layout[id] || roomChanged) {
newPos = { s: prevPos.s, x: prevPos.x, y: prevPos.y }; newPos = { s: prevPos.s, x: prevPos.x, y: prevPos.y };
this.host._layout = { ...this.host._layout, [id]: newPos };
} }
} else if (replacingRemoved || !this.host._layout[id] || roomChanged) { } else if (replacingRemoved || !this.host._layout[id] || roomChanged) {
let cx = targetSpaceModel.vb[0] + targetSpaceModel.vb[2] / 2; let cx = targetSpaceModel.vb[0] + targetSpaceModel.vb[2] / 2;
@@ -8325,9 +8335,22 @@ public async _saveMarker(): Promise<void> {
: undefined; : undefined;
if (room) [cx, cy] = this.host._roomCenter(room); if (room) [cx, cy] = this.host._roomCenter(room);
newPos = this.host._normPos(targetSpaceId, cx, cy); newPos = this.host._normPos(targetSpaceId, cx, cy);
this.host._layout = { ...this.host._layout, [id]: newPos };
} }
await this._saveConfigNow(); // File copy is async: never install a candidate built from a replaced root.
if (this.host._serverCfg !== cfg || this.host._cfgRev !== baseRevision || contentFingerprint(cfg) !== baseContent) {
if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false };
this.host._showToast(this.host._t('toast.conflict'));
return;
}
candidate = this._prepareConfigCandidate(candidate);
attempt = optimisticAttempt(cfg, candidate, this.host._cfgContentFingerprint, this.host._cfgRev, contentFingerprint);
this.host._serverCfg = candidate;
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this.host.requestUpdate();
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.cancel();
await this._saveConfigNow(attempt);
configAccepted = true;
if (newPos) { if (newPos) {
const pos = canonicalizePosition(newPos); const pos = canonicalizePosition(newPos);
this.host._layout = { ...this.host._layout, [id]: pos }; this.host._layout = { ...this.host._layout, [id]: pos };
@@ -8361,6 +8384,12 @@ public async _saveMarker(): Promise<void> {
// in flight — spreading null yields a truthy husk and the renderer // in flight — spreading null yields a truthy husk and the renderer
// then crashes, blanking the whole card. The toast below is the // then crashes, blanking the whole card. The toast below is the
// only remaining signal, so it must still fire. // only remaining signal, so it must still fire.
if (!configAccepted && attempt) {
rollbackOptimistic(this.host, attempt, contentFingerprint);
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this.host.requestUpdate();
}
if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false }; if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false };
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) })); this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) }));
} }
@@ -8931,12 +8960,12 @@ public async _deleteSpace(): Promise<void> {
} }
} }
public async _saveConfigNow(): Promise<void> { public async _saveConfigNow(attempt: OptimisticAttempt<ServerConfig> | null = null): Promise<void> {
this.host._cfgEpoch++; this.host._cfgEpoch++;
try { try {
// same queue as the debounced writer: a dialog saving while a background // same queue as the debounced writer: a dialog saving while a background
// write is still out must not race it into a self-inflicted conflict // write is still out must not race it into a self-inflicted conflict
await this._writeConfig(); await this._writeConfig(attempt);
} catch (e: any) { } catch (e: any) {
if (e?.physicalGeometryRolledBack) await this._reloadRejectedPhysicalWrite(); if (e?.physicalGeometryRolledBack) await this._reloadRejectedPhysicalWrite();
else if (e?.code === 'conflict') await this.host._reloadConfigOnly(); else if (e?.code === 'conflict') await this.host._reloadConfigOnly();
@@ -10952,9 +10981,9 @@ public _renderVacSection(dlg: any): TemplateResult | typeof nothing {
</div> </div>
</details> </details>
<div class="vacbtns"> <div class="vacbtns">
${tierA ? html`<button class="btn" @click=${() => this._vacAutoCalibrate(dev)}>${this.host._t('vac.autocal')}</button>` : nothing} ${tierA ? html`<button class="btn" ?disabled=${this.host._markerDialog?.busy} @click=${() => this._vacAutoCalibrate(dev)}>${this.host._t('vac.autocal')}</button>` : nothing}
${canUseSource ${canUseSource
? html`<button class="btn ghostbtn" @click=${() => this._vacStartFit(dev)}>${this.host._t('vac.fit')}</button>` ? html`<button class="btn ghostbtn" ?disabled=${this.host._markerDialog?.busy} @click=${() => this._vacStartFit(dev)}>${this.host._t('vac.fit')}</button>`
: nothing} : nothing}
<a class="btn ghostbtn" href="https://github.com/Matysh/houseplan-card/blob/main/docs/VACUUM.md" <a class="btn ghostbtn" href="https://github.com/Matysh/houseplan-card/blob/main/docs/VACUUM.md"
target="_blank" rel="noopener">${this.host._t('vac.documentation')}</a> target="_blank" rel="noopener">${this.host._t('vac.documentation')}</a>
@@ -10981,20 +11010,10 @@ public _vacMapId(d: DevItem, tele: { mapId: string }, planHass = this.host._plan
return this.host._vacMapId(d, tele, planHass); return this.host._vacMapId(d, tele, planHass);
} }
public _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine, routeId = ''): boolean { public _vacSaveMatrix(
// HP-1540-01: a first-use vacuum has no marker yet — materialise it markerId: string, source: string, mapId: string, matrix: Affine, routeId = '',
const dev = this.host._devices.find((x) => x.id === markerId); ): Promise<boolean> {
const m = dev ? this.host._vacEnsureMarker(dev) return saveVacuumMatrix(this, markerId, source, mapId, matrix, routeId);
: this.host._serverCfg?.markers?.find((x: Marker) => x.id === markerId);
if (!m) return false;
// #162: with explicit routes the matrix belongs to the route, not to a
// marker-wide dictionary that cannot tell two floors apart.
m.vacuum = writeVacuumMatrix(m.vacuum || {}, { source, mapId, routeId, matrix });
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this._saveConfig();
this.host.requestUpdate();
return true;
} }
public _vacPlanRoomAnchors(spaceId: string | null | undefined): Array<{ public _vacPlanRoomAnchors(spaceId: string | null | undefined): Array<{
@@ -11012,7 +11031,8 @@ public _vacPlanRoomAnchors(spaceId: string | null | undefined): Array<{
.filter(Boolean) as Array<{ name: string; cx: number; cy: number }>; .filter(Boolean) as Array<{ name: string; cx: number; cy: number }>;
} }
public _vacAutoCalibrate(d: DevItem): void { public async _vacAutoCalibrate(d: DevItem): Promise<void> {
if (this.host._markerDialog?.busy) return;
const src = this.host._vacSource(d); const src = this.host._vacSource(d);
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null; const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
if (!src || !tele || tele.rooms.length < 3) { if (!src || !tele || tele.rooms.length < 3) {
@@ -11038,32 +11058,18 @@ public _vacAutoCalibrate(d: DevItem): void {
}; };
return; return;
} }
if (!this._vacSaveMatrix(d.id, src, mapId, res.matrix, target.routeId)) return; await saveAutomaticCalibration(this, {
this.host._showToast(subst(this.host._t('vac.autocal_done'), { rooms: String(res.matched.length) })); markerId: d.id, source: src, mapId, routeId: target.routeId, space: target.space,
matrix: res.matrix, rooms: res.matched.length,
});
} }
public _vacApplyCalibrationProposal(manual: boolean): void { public _vacApplyCalibrationProposal(manual: boolean): Promise<void> {
const proposal = this.host._vacCalConfirm; return applyCalibrationProposal(this, manual);
if (!proposal) return;
this.host._vacCalConfirm = null;
if (manual) {
const dev = this.host._devices.find((candidate) => candidate.id === proposal.markerId);
const fit = fitFromMatrix(proposal.matrix);
if (!dev || !fit) return;
this.host._markerDialog = null;
// #162: матрица решена против пространства МАРШРУТА, а не дока.
const space = proposal.space || dev.space;
if (space !== this.host._space && !this.host._commitSpace(space)) return;
this.host._vacFit = { markerId: proposal.markerId, source: proposal.source,
routeId: proposal.routeId, mapId: proposal.mapId, p: fit, drag: null };
return;
}
if (this._vacSaveMatrix(
proposal.markerId, proposal.source, proposal.mapId, proposal.matrix, proposal.routeId,
)) this.host._showToast(subst(this.host._t('vac.autocal_done'), { rooms: String(proposal.rooms) }));
} }
public _vacStartFit(d: DevItem, routeId = ''): void { public _vacStartFit(d: DevItem, routeId = ''): void {
if (this.host._markerDialog?.busy) return;
const src = this.host._vacSource(d); const src = this.host._vacSource(d);
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null; const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
if (!src || !tele) { if (!src || !tele) {
@@ -11081,18 +11087,13 @@ public _vacStartFit(d: DevItem, routeId = ''): void {
this.host._vacFit = { markerId: d.id, source: src, mapId, routeId: plan.routeId, p: plan.params, drag: null }; this.host._vacFit = { markerId: d.id, source: src, mapId, routeId: plan.routeId, p: plan.params, drag: null };
} }
public _vacFitSave(): void { public _vacFitSave(): Promise<void> {
const f = this.host._vacFit; return saveManualCalibration(this, fitMatrix);
if (!f) return;
// HP-1540-01: no success toast for a save that did not happen
const ok = this._vacSaveMatrix(f.markerId, f.source, f.mapId, fitMatrix(f.p), f.routeId);
this.host._vacFit = null;
if (ok) this.host._showToast(this.host._t('vac.cal_done'));
} }
public _vacFitTurn(patch: Partial<FitParams>): void { public _vacFitTurn(patch: Partial<FitParams>): void {
const f = this.host._vacFit; const f = this.host._vacFit;
if (!f) return; if (!f || f.busy) return;
const tele = readVacTelemetry(this.host.hass?.states[f.source]?.attributes); const tele = readVacTelemetry(this.host.hass?.states[f.source]?.attributes);
const c = this._vacGhostCentre(tele?.rooms || []); const c = this._vacGhostCentre(tele?.rooms || []);
const next = { ...f.p, ...patch } as FitParams; const next = { ...f.p, ...patch } as FitParams;
@@ -11119,6 +11120,7 @@ public _vacFitPointer(ev: PointerEvent, view: { x: number; y: number; w: number;
const f = this.host._vacFit; const f = this.host._vacFit;
if (!f) return; if (!f) return;
ev.stopPropagation(); ev.stopPropagation();
if (f.busy) return;
if (ev.type === 'pointerdown') { if (ev.type === 'pointerdown') {
const t = ev.target as HTMLElement; const t = ev.target as HTMLElement;
const corner = t.getAttribute?.('data-corner'); const corner = t.getAttribute?.('data-corner');
+1 -2
View File
@@ -51,8 +51,7 @@ export function rollbackOptimistic<T>(
): boolean { ): boolean {
const current = host._serverCfg; const current = host._serverCfg;
if (!current || host._cfgRev !== attempt.revision if (!current || host._cfgRev !== attempt.revision
|| (current !== attempt.attempted || fingerprint(current) !== attempt.attemptedFingerprint) return false;
&& fingerprint(current) !== attempt.attemptedFingerprint)) return false;
host._serverCfg = attempt.previous; host._serverCfg = attempt.previous;
host._cfgContentFingerprint = attempt.previousFingerprint; host._cfgContentFingerprint = attempt.previousFingerprint;
host.requestUpdate(); host.requestUpdate();
+208
View File
@@ -0,0 +1,208 @@
/** Atomic persistence and retry state for vacuum calibration (#442). */
import type { I18nKey } from './i18n';
import type { DevItem, Marker, ServerConfig } from './types';
import { fitFromMatrix, type Affine, type FitParams } from './vacuum';
import { writeVacuumMatrix } from './vacuum-route-edit';
import {
optimisticAttempt, rollbackOptimistic, type OptimisticAttempt,
} from './serialized-write-queue';
import { contentFingerprint } from './visual-continuity';
export type CalibrationProposal = {
markerId: string;
source: string;
mapId: string;
routeId?: string;
space?: string;
matrix: Affine;
rooms: number;
error: string;
busy?: boolean;
};
export type VacuumFit = {
markerId: string;
source: string;
mapId: string;
routeId?: string;
p: FitParams;
busy?: boolean;
drag: null | {
kind: 'move' | 'scale';
sx: number;
sy: number;
p0: FitParams;
fx: number;
fy: number;
};
};
export interface VacuumCalibrationWriteHost {
_serverCfg: ServerConfig | null;
_devices: DevItem[];
_cfgContentFingerprint: string;
_cfgRev: number;
_saveConfigDebounced: { pending: () => boolean; cancel: () => void };
_regSignature: string;
_markerDialog: any;
_vacCalConfirm: CalibrationProposal | null;
_vacFit: VacuumFit | null;
_space: string;
_commitSpace: (spaceId: string) => boolean;
_maybeRebuildDevices: () => void;
_showToast: (message: string) => void;
_t: (key: I18nKey, vars?: Record<string, string | number>) => string;
_errText: (error: unknown) => string;
requestUpdate: () => unknown;
}
export interface VacuumCalibrationWriteRuntime {
host: VacuumCalibrationWriteHost;
_prepareConfigCandidate: (config: ServerConfig) => ServerConfig;
_saveConfigNow: (attempt?: OptimisticAttempt<ServerConfig>) => Promise<void>;
}
const automaticWrites = new WeakSet<VacuumCalibrationWriteRuntime>();
const rebuild = (host: VacuumCalibrationWriteHost): void => {
host._regSignature = '';
host._maybeRebuildDevices();
host.requestUpdate();
};
/** Persist one exact route matrix without ever mutating the accepted config. */
export async function saveVacuumMatrix(
runtime: VacuumCalibrationWriteRuntime,
markerId: string,
source: string,
mapId: string,
matrix: Affine,
routeId = '',
): Promise<boolean> {
const host = runtime.host;
const previous = host._serverCfg;
if (!previous) return false;
let candidate = JSON.parse(JSON.stringify(previous)) as ServerConfig;
candidate.markers = candidate.markers || [];
let marker = candidate.markers.find((item) => item.id === markerId);
if (!marker) {
const device = host._devices.find((item) => item.id === markerId);
if (!device || (device.bindingKind !== 'device' && device.bindingKind !== 'entity')
|| !device.bindingRef) return false;
marker = {
id: device.id,
binding: `${device.bindingKind}:${device.bindingRef}`,
space: device.space || null,
area: device.area || null,
hidden: device.hidden ? true : false,
} as Marker;
candidate.markers.push(marker);
}
marker.vacuum = writeVacuumMatrix(marker.vacuum || {}, {
source, mapId, routeId, matrix,
});
candidate = runtime._prepareConfigCandidate(candidate);
const attempt = optimisticAttempt(
previous, candidate, host._cfgContentFingerprint, host._cfgRev, contentFingerprint,
);
host._serverCfg = candidate;
rebuild(host);
if (host._saveConfigDebounced.pending()) host._saveConfigDebounced.cancel();
try {
await runtime._saveConfigNow(attempt);
return true;
} catch (error) {
rollbackOptimistic(host, attempt, contentFingerprint);
rebuild(host);
host._showToast(host._t('toast.cfg_save_failed', { err: host._errText(error) }));
return false;
}
}
/** Low-residual auto calibration: keep its source dialog and suppress doubles. */
export async function saveAutomaticCalibration(
runtime: VacuumCalibrationWriteRuntime,
request: Omit<CalibrationProposal, 'error' | 'busy'>,
): Promise<void> {
if (automaticWrites.has(runtime)) return;
automaticWrites.add(runtime);
const host = runtime.host;
const dialog = host._markerDialog;
const busyDialog = dialog ? { ...dialog, busy: true } : null;
if (busyDialog) {
host._markerDialog = busyDialog;
host.requestUpdate();
}
try {
const saved = await saveVacuumMatrix(
runtime, request.markerId, request.source, request.mapId, request.matrix, request.routeId,
);
if (saved) host._showToast(host._t('vac.autocal_done', { rooms: String(request.rooms) }));
} finally {
automaticWrites.delete(runtime);
if (busyDialog && host._markerDialog === busyDialog) {
host._markerDialog = { ...busyDialog, busy: false };
host.requestUpdate();
}
}
}
/** Apply a high-residual proposal, or transfer it unchanged into manual fit. */
export async function applyCalibrationProposal(
runtime: VacuumCalibrationWriteRuntime,
manual: boolean,
): Promise<void> {
const host = runtime.host;
const proposal = host._vacCalConfirm;
if (!proposal || proposal.busy) return;
if (manual) {
const device = host._devices.find((item) => item.id === proposal.markerId);
const fit = fitFromMatrix(proposal.matrix);
if (!device || !fit) return;
const space = proposal.space || device.space;
if (space !== host._space && !host._commitSpace(space)) return;
host._vacCalConfirm = null;
host._markerDialog = null;
host._vacFit = {
markerId: proposal.markerId,
source: proposal.source,
routeId: proposal.routeId,
mapId: proposal.mapId,
p: fit,
drag: null,
};
return;
}
const busyProposal = { ...proposal, busy: true };
host._vacCalConfirm = busyProposal;
host.requestUpdate();
const saved = await saveVacuumMatrix(
runtime, proposal.markerId, proposal.source, proposal.mapId, proposal.matrix, proposal.routeId,
);
if (host._vacCalConfirm === busyProposal) {
host._vacCalConfirm = saved ? null : { ...proposal, busy: false };
host.requestUpdate();
}
if (saved) host._showToast(host._t('vac.autocal_done', { rooms: String(proposal.rooms) }));
}
/** Save manual-fit parameters, retaining the exact overlay draft on rejection. */
export async function saveManualCalibration(
runtime: VacuumCalibrationWriteRuntime,
matrixOf: (params: FitParams) => Affine,
): Promise<void> {
const host = runtime.host;
const fit = host._vacFit;
if (!fit || fit.busy) return;
const busyFit = { ...fit, busy: true, drag: null };
host._vacFit = busyFit;
host.requestUpdate();
const saved = await saveVacuumMatrix(
runtime, fit.markerId, fit.source, fit.mapId, matrixOf(fit.p), fit.routeId,
);
if (host._vacFit === busyFit) {
host._vacFit = saved ? null : { ...fit, busy: false, drag: null };
host.requestUpdate();
}
if (saved) host._showToast(host._t('vac.cal_done'));
}
+1 -1
View File
@@ -183,7 +183,7 @@ test('frontend write paths adopt canonical candidates before persistence (#224)'
assert.match(source, /enqueueSerializedWrite\(this\._writeChain, async \(\) =>/); assert.match(source, /enqueueSerializedWrite\(this\._writeChain, async \(\) =>/);
assert.match( assert.match(
source, source,
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);/, /const candidate = this\._prepareConfigCandidate\(this\._serverCfg\);/,
); );
assert.match( assert.match(
eagerCardSource, eagerCardSource,
+1 -1
View File
@@ -49,7 +49,7 @@ test('stable space ids use exact lookup and abort before side effects', () => {
const guardAt = saveMarker.indexOf('if (!targetSpaceModel) return;'); const guardAt = saveMarker.indexOf('if (!targetSpaceModel) return;');
const busyAt = saveMarker.indexOf('busy: true'); const busyAt = saveMarker.indexOf('busy: true');
const migrateAt = saveMarker.indexOf("type: 'houseplan/files/migrate'"); const migrateAt = saveMarker.indexOf("type: 'houseplan/files/migrate'");
const configMutationAt = saveMarker.indexOf('cfg.markers = markers'); const configMutationAt = saveMarker.indexOf('candidate.markers = markers');
assert.ok(exactAt >= 0 && exactAt < guardAt && guardAt < busyAt assert.ok(exactAt >= 0 && exactAt < guardAt && guardAt < busyAt
&& busyAt < migrateAt && migrateAt < configMutationAt); && busyAt < migrateAt && migrateAt < configMutationAt);
+1 -1
View File
@@ -169,6 +169,6 @@ test('marker delete/re-add and opening save remain separate config transactions'
const saveMarker = methodBody(source, '_saveMarker'); const saveMarker = methodBody(source, '_saveMarker');
assert.match(saveOpening, /sp\.openings/); assert.match(saveOpening, /sp\.openings/);
assert.doesNotMatch(saveOpening, /cfg\.markers|this\._markers/); assert.doesNotMatch(saveOpening, /cfg\.markers|this\._markers/);
assert.match(saveMarker, /cfg\.markers/); assert.match(saveMarker, /candidate\.markers/);
assert.doesNotMatch(saveMarker, /\.openings/); assert.doesNotMatch(saveMarker, /\.openings/);
}); });
+17
View File
@@ -100,3 +100,20 @@ test('a conflict reload or newer mutation wins over a rejected candidate (#439)'
assert.equal(host._serverCfg.settings.value, 'newer-local-edit'); assert.equal(host._serverCfg.settings.value, 'newer-local-edit');
assert.equal(updates, 0); assert.equal(updates, 0);
}); });
test('newer in-place content on the attempted root also wins (#442)', () => {
const fingerprint = (value) => JSON.stringify(value);
const previous = { settings: { value: 'server-before' } };
const attempted = { settings: { value: 'draft' } };
const attempt = optimisticAttempt(previous, attempted, 'before', 3, fingerprint);
const host = {
_serverCfg: attempted,
_cfgRev: 3,
_cfgContentFingerprint: fingerprint(attempted),
requestUpdate: () => assert.fail('newer content must not be rolled back'),
};
attempted.settings.newer = true;
assert.equal(rollbackOptimistic(host, attempt, fingerprint), false);
assert.equal(host._serverCfg, attempted);
});
+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyCalibrationProposal,
saveAutomaticCalibration,
saveManualCalibration,
saveVacuumMatrix,
} from '../test-build/vacuum-calibration-write.js';
const fingerprint = (value) => JSON.stringify(value);
function deferred() {
let resolve;
let reject;
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
function fixture({ marker = true, save } = {}) {
const accepted = {
spaces: [{ id: 'ground', title: 'Ground', rooms: [] }],
markers: marker ? [{
id: 'vac', binding: 'entity:vacuum.demo', space: 'ground',
vacuum: {
calibration: { old: [1, 0, 10, 0, 1, 20] },
map_routes: [{
id: 'upstairs', source: 'camera.map', map_id: 'new', space: 'ground',
calibration: [1, 0, 10, 0, 1, 20],
}],
},
}] : [],
};
const toasts = [];
let rebuilds = 0;
const host = {
_serverCfg: accepted,
_devices: [{
id: 'vac', bindingKind: 'entity', bindingRef: 'vacuum.demo',
space: 'ground', area: null, hidden: false,
}],
_cfgContentFingerprint: fingerprint(accepted),
_cfgRev: 7,
_saveConfigDebounced: { pending: () => true, cancel: () => {} },
_regSignature: 'accepted',
_markerDialog: { busy: false, name: 'Vacuum draft' },
_vacCalConfirm: null,
_vacFit: null,
_space: 'ground',
_commitSpace: () => true,
_maybeRebuildDevices: () => { rebuilds += 1; },
_showToast: (message) => { toasts.push(message); },
_t: (key, vars) => `${key}${vars ? `:${JSON.stringify(vars)}` : ''}`,
_errText: (error) => String(error?.message || error),
requestUpdate: () => {},
};
const runtime = {
host,
_prepareConfigCandidate: (config) => config,
_saveConfigNow: save || (async () => {
host._cfgContentFingerprint = fingerprint(host._serverCfg);
}),
};
return { accepted, host, runtime, toasts, rebuilds: () => rebuilds };
}
test('rejected matrix restores the accepted marker and a retry can persist (#442)', async () => {
let reject = true;
const f = fixture({ save: async () => {
if (reject) throw new Error('semantic reject');
f.host._cfgContentFingerprint = fingerprint(f.host._serverCfg);
} });
assert.equal(await saveVacuumMatrix(
f.runtime, 'vac', 'camera.map', 'new', [2, 0, 30, 0, 2, 40], 'upstairs',
), false);
assert.deepEqual(f.host._serverCfg, f.accepted);
assert.notEqual(f.host._serverCfg, f.accepted, 'rollback uses an isolated accepted snapshot');
assert.match(f.toasts.at(-1), /^toast\.cfg_save_failed/);
reject = false;
assert.equal(await saveVacuumMatrix(
f.runtime, 'vac', 'camera.map', 'new', [2, 0, 30, 0, 2, 40], 'upstairs',
), true);
const route = f.host._serverCfg.markers[0].vacuum.map_routes
.find((item) => item.id === 'upstairs');
assert.deepEqual(route.calibration, [2, 0, 30, 0, 2, 40]);
});
test('rejected first-use calibration leaves no synthetic marker (#442)', async () => {
const f = fixture({ marker: false, save: async () => { throw new Error('offline'); } });
assert.equal(await saveVacuumMatrix(
f.runtime, 'vac', 'camera.map', 'default', [1, 0, 0, 0, 1, 0],
), false);
assert.deepEqual(f.host._serverCfg.markers, []);
});
test('automatic calibration stays busy and has no early success (#442)', async () => {
const gate = deferred();
const f = fixture({ save: () => gate.promise });
const pending = saveAutomaticCalibration(f.runtime, {
markerId: 'vac', source: 'camera.map', mapId: 'default', space: 'ground',
matrix: [1, 0, 0, 0, 1, 0], rooms: 3,
});
assert.equal(f.host._markerDialog.busy, true);
assert.equal(f.toasts.some((toast) => toast.startsWith('vac.autocal_done')), false);
const duplicate = saveAutomaticCalibration(f.runtime, {
markerId: 'vac', source: 'camera.map', mapId: 'default', space: 'ground',
matrix: [1, 0, 0, 0, 1, 0], rooms: 3,
});
await duplicate;
gate.resolve();
await pending;
assert.equal(f.host._markerDialog.busy, false);
assert.equal(f.toasts.filter((toast) => toast.startsWith('vac.autocal_done')).length, 1);
});
test('rejected proposal and manual fit preserve their exact retry drafts (#442)', async () => {
let reject = true;
const f = fixture({ save: async () => {
if (reject) throw new Error('invalid calibration');
} });
const matrix = [1, 0, 11, 0, 1, 22];
const proposal = {
markerId: 'vac', source: 'camera.map', mapId: 'default', routeId: 'ground',
space: 'ground', matrix, rooms: 4, error: '42 cm',
};
f.host._vacCalConfirm = proposal;
await applyCalibrationProposal(f.runtime, false);
assert.deepEqual(f.host._vacCalConfirm, { ...proposal, busy: false });
const fit = {
markerId: 'vac', source: 'camera.map', mapId: 'default', routeId: 'ground',
p: { s: 1.25, rot: 90, mir: true, ox: 12, oy: 34 }, drag: null,
};
f.host._vacCalConfirm = null;
f.host._vacFit = fit;
await saveManualCalibration(f.runtime, () => matrix);
assert.deepEqual(f.host._vacFit, { ...fit, busy: false, drag: null });
reject = false;
await saveManualCalibration(f.runtime, () => matrix);
assert.equal(f.host._vacFit, null);
assert.equal(f.toasts.at(-1), 'vac.cal_done');
});