mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
fix: roll back rejected marker writes atomically
Issue: #442 User-Visible: yes
This commit is contained in:
committed by
claude[bot]
parent
215d59823b
commit
3a88f3ad6c
@@ -1,125 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5",
|
||||
"fingerprint": "4e8e80e2c125257ca4ae9222e54a57b88f14757c520801bf6c28ebb62c8281fa",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js",
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 293046,
|
||||
"initialViewGzipBytes": 293148,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/de-D6tfUEEi.js",
|
||||
"houseplan-assets/editor-veGv06fc.js",
|
||||
"houseplan-assets/fr-B9jqd_9T.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/de-CtdCgGNT.js",
|
||||
"houseplan-assets/editor-a0TiMYLK.js",
|
||||
"houseplan-assets/fr-C_YoglnG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
|
||||
],
|
||||
"lazyGzipBytes": 221355,
|
||||
"lazyGzipBytes": 222077,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/editor-veGv06fc.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/editor-a0TiMYLK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 165335,
|
||||
"lazyEditorGzipBytes": 166046,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14233,
|
||||
"lazyOnboardingGzipBytes": 14231,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-D6tfUEEi.js",
|
||||
"houseplan-assets/fr-B9jqd_9T.js"
|
||||
"houseplan-assets/de-CtdCgGNT.js",
|
||||
"houseplan-assets/fr-C_YoglnG.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49066,
|
||||
"lazyLocaleGzipBytes": 49078,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"sha256": "f163ec3c045e02b53c31d00b7b18b87394b1d485d01963878d390db5280e9d3c",
|
||||
"path": "houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"sha256": "bf2fc78b96e1f99e347924dd5eee54b3406bb6a78639301fd79b4d56e1efd251",
|
||||
"rawBytes": 21418,
|
||||
"gzipBytes": 7279,
|
||||
"gzipBytes": 7278,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-D6tfUEEi.js",
|
||||
"sha256": "8bbf9c291b9973c77de053a1677d19846cc61e44232988739af1e31412812a1e",
|
||||
"path": "houseplan-assets/de-CtdCgGNT.js",
|
||||
"sha256": "6a52b552d343d8f969997904d7d3b8146d699ef87fa5d3eed506f3cec6a42a3a",
|
||||
"rawBytes": 82140,
|
||||
"gzipBytes": 24802,
|
||||
"gzipBytes": 24808,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-veGv06fc.js",
|
||||
"sha256": "40fcfabb1c6b1df59264c1a0da9d74cb67cfd2230e731f0e2c20e9fdaa21c01e",
|
||||
"path": "houseplan-assets/editor-a0TiMYLK.js",
|
||||
"sha256": "00980a861d57c8c31544ea828308fd7746380633134d29f4dddeb7d32e418669",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1580,
|
||||
"gzipBytes": 1581,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-B9jqd_9T.js",
|
||||
"sha256": "4ec01fd1b61ce5f0a796edb86bcbe4f87dcfba1b37c6f12afca535590ce36b9d",
|
||||
"path": "houseplan-assets/fr-C_YoglnG.js",
|
||||
"sha256": "61fd6f6b50ae250f8d95e6b893d4973354927dd0920044e67ea09bfbbafbfa8f",
|
||||
"rawBytes": 84267,
|
||||
"gzipBytes": 24264,
|
||||
"gzipBytes": 24270,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CfQ49v8a.js",
|
||||
"sha256": "46dbdf5dc1881b44d03804f95f9406242f6f491f534466acdfcb1f478618b61d",
|
||||
"rawBytes": 1040074,
|
||||
"gzipBytes": 292251,
|
||||
"path": "houseplan-assets/houseplan-card-CheE6i9L.js",
|
||||
"sha256": "f3069dacc6e4c6ae46bec78b957748f21053d573f7c80c327787ec8a68e3d6a9",
|
||||
"rawBytes": 1040585,
|
||||
"gzipBytes": 292354,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-D6tfUEEi.js",
|
||||
"houseplan-assets/editor-veGv06fc.js",
|
||||
"houseplan-assets/fr-B9jqd_9T.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
|
||||
"houseplan-assets/de-CtdCgGNT.js",
|
||||
"houseplan-assets/editor-a0TiMYLK.js",
|
||||
"houseplan-assets/fr-C_YoglnG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
|
||||
"sha256": "3eabc904f3eec1ad453ad7746433e3232caa38ef0797adbf7833318ee22cb946",
|
||||
"rawBytes": 595676,
|
||||
"gzipBytes": 156476,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
|
||||
"sha256": "7cc4ee224f6efbd9d16dd8ff1dd00a87b8d566dec444272265f02dea0ebd95a3",
|
||||
"rawBytes": 597836,
|
||||
"gzipBytes": 157187,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js",
|
||||
"sha256": "d3645c23d4dd4dc4b1dd93452f047b3936e5878f90688a14d5d8693da424a0f7",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js",
|
||||
"sha256": "4f2c09b2688fbc967913c8f1166d3f9515a1dc82c9debe82be4bffae707f28e4",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6954,
|
||||
"gzipBytes": 6953,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "5a6cb0840f0836d5c09b7de0313c57b74c0bb1d5df585067062229b553e89bab",
|
||||
"sha256": "821870c8e6cba7a01d630b5a2a412f5d76d4afc639c3eba926b13b41f27508c2",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 795,
|
||||
"gzipBytes": 794,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
+7
-7
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+22
-14
File diff suppressed because one or more lines are too long
+86
-86
File diff suppressed because one or more lines are too long
+3
-3
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)}
|
||||
|
||||
@@ -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);
|
||||
@@ -262,7 +262,7 @@ const out = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
|
||||
// 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;
|
||||
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
|
||||
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
|
||||
|
||||
@@ -56,14 +56,23 @@ const out = await page.evaluate(async () => {
|
||||
// spy on config writes: a success toast is only honest after one of these
|
||||
const writes = [];
|
||||
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');
|
||||
o.devFound = !!dev;
|
||||
o.freshNoMarker = (c._serverCfg.markers || []).length === 0 && !dev.marker;
|
||||
|
||||
// ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ----
|
||||
c._vacAutoCalibrate(dev);
|
||||
await c._vacAutoCalibrate(dev);
|
||||
await c.updateComplete;
|
||||
const m1 = (c._serverCfg.markers || []).find((x) => x.id === '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
|
||||
&& !!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,
|
||||
// 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].x1 = 21400;
|
||||
c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
await c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
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;
|
||||
o.highResidualCancelUntouched = JSON.stringify(m1.vacuum.calibration['0']) === JSON.stringify(lowMatrix);
|
||||
c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
c._vacApplyCalibrationProposal(false); await c.updateComplete;
|
||||
const appliedProposal = [...m1.vacuum.calibration['0']];
|
||||
o.highResidualCancelUntouched = JSON.stringify(
|
||||
c._serverCfg.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'],
|
||||
) === JSON.stringify(lowMatrix);
|
||||
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);
|
||||
c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
c._vacApplyCalibrationProposal(true); await c.updateComplete;
|
||||
await c._vacAutoCalibrate(dev); await c.updateComplete;
|
||||
await c._vacApplyCalibrationProposal(true); await c.updateComplete;
|
||||
o.highResidualManualOpened = !!c._vacFit && c._vacFit.markerId === 'd_mower';
|
||||
c._vacFitSave(); await c.updateComplete;
|
||||
o.highResidualManualApplied = !c._vacFit && Array.isArray(m1.vacuum.calibration['0'])
|
||||
const manualDraft = JSON.stringify(c._vacFit);
|
||||
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');
|
||||
robotRooms[4].x0 = 1700;
|
||||
robotRooms[4].x1 = 2500;
|
||||
@@ -108,7 +183,7 @@ const out = await page.evaluate(async () => {
|
||||
const dev2 = c._devices.find((x) => x.id === 'd_mower');
|
||||
o.fitFreshNoMarker = !dev2.marker;
|
||||
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 calFit = m2?.vacuum?.calibration?.['0'];
|
||||
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,
|
||||
'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } };
|
||||
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);
|
||||
|
||||
c.hass.callWS = realWS;
|
||||
|
||||
@@ -94,7 +94,7 @@ const out = await page.evaluate(async () => {
|
||||
routeId: 'vr2', space: 'garden', matrix: M2, rooms: 3, error: '80 см',
|
||||
};
|
||||
await c.updateComplete;
|
||||
c._vacApplyCalibrationProposal(true);
|
||||
await c._vacApplyCalibrationProposal(true);
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 80));
|
||||
o.manualFitSwitchedToRouteSpace = c._space === 'garden';
|
||||
o.manualFitKeepsRoute = c._vacFit?.routeId === 'vr2';
|
||||
|
||||
Vendored
+57
-57
@@ -1,125 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5",
|
||||
"fingerprint": "4e8e80e2c125257ca4ae9222e54a57b88f14757c520801bf6c28ebb62c8281fa",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js",
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 293046,
|
||||
"initialViewGzipBytes": 293148,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/de-D6tfUEEi.js",
|
||||
"houseplan-assets/editor-veGv06fc.js",
|
||||
"houseplan-assets/fr-B9jqd_9T.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/de-CtdCgGNT.js",
|
||||
"houseplan-assets/editor-a0TiMYLK.js",
|
||||
"houseplan-assets/fr-C_YoglnG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
|
||||
],
|
||||
"lazyGzipBytes": 221355,
|
||||
"lazyGzipBytes": 222077,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/editor-veGv06fc.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/editor-a0TiMYLK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 165335,
|
||||
"lazyEditorGzipBytes": 166046,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14233,
|
||||
"lazyOnboardingGzipBytes": 14231,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-D6tfUEEi.js",
|
||||
"houseplan-assets/fr-B9jqd_9T.js"
|
||||
"houseplan-assets/de-CtdCgGNT.js",
|
||||
"houseplan-assets/fr-C_YoglnG.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49066,
|
||||
"lazyLocaleGzipBytes": 49078,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"sha256": "f163ec3c045e02b53c31d00b7b18b87394b1d485d01963878d390db5280e9d3c",
|
||||
"path": "houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"sha256": "bf2fc78b96e1f99e347924dd5eee54b3406bb6a78639301fd79b4d56e1efd251",
|
||||
"rawBytes": 21418,
|
||||
"gzipBytes": 7279,
|
||||
"gzipBytes": 7278,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-D6tfUEEi.js",
|
||||
"sha256": "8bbf9c291b9973c77de053a1677d19846cc61e44232988739af1e31412812a1e",
|
||||
"path": "houseplan-assets/de-CtdCgGNT.js",
|
||||
"sha256": "6a52b552d343d8f969997904d7d3b8146d699ef87fa5d3eed506f3cec6a42a3a",
|
||||
"rawBytes": 82140,
|
||||
"gzipBytes": 24802,
|
||||
"gzipBytes": 24808,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-veGv06fc.js",
|
||||
"sha256": "40fcfabb1c6b1df59264c1a0da9d74cb67cfd2230e731f0e2c20e9fdaa21c01e",
|
||||
"path": "houseplan-assets/editor-a0TiMYLK.js",
|
||||
"sha256": "00980a861d57c8c31544ea828308fd7746380633134d29f4dddeb7d32e418669",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1580,
|
||||
"gzipBytes": 1581,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-B9jqd_9T.js",
|
||||
"sha256": "4ec01fd1b61ce5f0a796edb86bcbe4f87dcfba1b37c6f12afca535590ce36b9d",
|
||||
"path": "houseplan-assets/fr-C_YoglnG.js",
|
||||
"sha256": "61fd6f6b50ae250f8d95e6b893d4973354927dd0920044e67ea09bfbbafbfa8f",
|
||||
"rawBytes": 84267,
|
||||
"gzipBytes": 24264,
|
||||
"gzipBytes": 24270,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CfQ49v8a.js",
|
||||
"sha256": "46dbdf5dc1881b44d03804f95f9406242f6f491f534466acdfcb1f478618b61d",
|
||||
"rawBytes": 1040074,
|
||||
"gzipBytes": 292251,
|
||||
"path": "houseplan-assets/houseplan-card-CheE6i9L.js",
|
||||
"sha256": "f3069dacc6e4c6ae46bec78b957748f21053d573f7c80c327787ec8a68e3d6a9",
|
||||
"rawBytes": 1040585,
|
||||
"gzipBytes": 292354,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-D6tfUEEi.js",
|
||||
"houseplan-assets/editor-veGv06fc.js",
|
||||
"houseplan-assets/fr-B9jqd_9T.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
|
||||
"houseplan-assets/de-CtdCgGNT.js",
|
||||
"houseplan-assets/editor-a0TiMYLK.js",
|
||||
"houseplan-assets/fr-C_YoglnG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
|
||||
"sha256": "3eabc904f3eec1ad453ad7746433e3232caa38ef0797adbf7833318ee22cb946",
|
||||
"rawBytes": 595676,
|
||||
"gzipBytes": 156476,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-BFZoQ3uF.js",
|
||||
"sha256": "7cc4ee224f6efbd9d16dd8ff1dd00a87b8d566dec444272265f02dea0ebd95a3",
|
||||
"rawBytes": 597836,
|
||||
"gzipBytes": 157187,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js",
|
||||
"sha256": "d3645c23d4dd4dc4b1dd93452f047b3936e5878f90688a14d5d8693da424a0f7",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-z8tmcLTE.js",
|
||||
"sha256": "4f2c09b2688fbc967913c8f1166d3f9515a1dc82c9debe82be4bffae707f28e4",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6954,
|
||||
"gzipBytes": 6953,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/backdrop-pick-D2rfOCFq.js",
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "5a6cb0840f0836d5c09b7de0313c57b74c0bb1d5df585067062229b553e89bab",
|
||||
"sha256": "821870c8e6cba7a01d630b5a2a412f5d76d4afc639c3eba926b13b41f27508c2",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 795,
|
||||
"gzipBytes": 794,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-CheE6i9L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
Vendored
+7
-7
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+22
-14
File diff suppressed because one or more lines are too long
+86
-86
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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)}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## 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
|
||||
empty instead of reviving legacy calibration; routes to deleted spaces are
|
||||
grouped clearly in the editor, and the live layer avoids rescanning every
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Если Home Assistant отклоняет сохранение устройства или калибровки робота,
|
||||
план теперь возвращается к последнему подтверждённому состоянию, но сохраняет
|
||||
открытый редактор, автоматическое предложение или ручную подгонку для повтора;
|
||||
сообщение об успехе появляется только после принятой записи
|
||||
([#442](https://github.com/Matysh/houseplan-card/issues/442)).
|
||||
- После удаления последнего маршрута карты робота список теперь остаётся явно
|
||||
пустым и не возвращает старую калибровку; маршруты в удалённые пространства
|
||||
собраны в понятную группу, а живой слой больше не перебирает повторно все
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`vacuum_json_id`:
|
||||
|
||||
|
||||
@@ -450,9 +450,9 @@ const MUTANT_DEFINITIONS = [
|
||||
+ '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)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: ' const space = proposal.space || dev.space;',
|
||||
replace: ' const space = dev.space;',
|
||||
file: 'src/vacuum-calibration-write.ts',
|
||||
find: ' const space = proposal.space || device.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 '
|
||||
+ 'resurrects the automatic parent and the siblings the person deliberately removed (#262)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ` cfg.markers = cfg.markers.filter(
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
find: ` candidate.markers = candidate.markers.filter(
|
||||
(m) => m.id !== id && m.id !== oldId
|
||||
&& (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
|
||||
&& (marker.binding === 'virtual' || m.binding !== marker.binding)
|
||||
&& (!marker.binding.startsWith('entity:') || m.removed !== true),
|
||||
@@ -5367,6 +5367,81 @@ const MUTANT_DEFINITIONS = [
|
||||
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');
|
||||
|
||||
+32
-22
@@ -246,6 +246,7 @@ import {
|
||||
formatLatticeShiftCm,
|
||||
} from './coordinate-canonicalization';
|
||||
import { enqueueSerializedWrite } from './serialized-write-queue';
|
||||
import type { CalibrationProposal, VacuumFit } from './vacuum-calibration-write';
|
||||
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
|
||||
import { LANGUAGE_RUNTIME, subscribeLanguageLoadFailures } from './i18n/registry';
|
||||
import {
|
||||
@@ -2503,18 +2504,13 @@ export class HouseplanCard extends LitElement {
|
||||
};
|
||||
private _resumeSettling = false;
|
||||
private _viewportInvalidAt = 0;
|
||||
private _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 = null;
|
||||
private _vacFit: VacuumFit | null = null;
|
||||
/** Marker whose lazy «All cameras» candidate section is expanded. */
|
||||
private _vacAllCamerasFor: string | null = null;
|
||||
/** One snapshot per currently open global-camera section; rebuilt on reopen. */
|
||||
private _vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[] } | null = null;
|
||||
/** Proposed high-residual auto-calibration. Config remains untouched until Apply. */
|
||||
private _vacCalConfirm: {
|
||||
markerId: string; source: string; mapId: string; routeId?: string; space?: string; matrix: Affine;
|
||||
rooms: number; error: string;
|
||||
} | null = null;
|
||||
private _vacCalConfirm: CalibrationProposal | null = null;
|
||||
private _kioskDots = false;
|
||||
private _kioskDotsTimer?: number;
|
||||
private _kioskHoldTimer?: number;
|
||||
@@ -2943,6 +2939,7 @@ export class HouseplanCard extends LitElement {
|
||||
|
||||
private _onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && this._vacFit) {
|
||||
if (this._vacFit.busy) return;
|
||||
this._vacFit = null;
|
||||
this._showToast(this._t('vac.cal_cancelled'));
|
||||
e.stopPropagation();
|
||||
@@ -2951,7 +2948,10 @@ export class HouseplanCard extends LitElement {
|
||||
if (e.key === 'Escape') {
|
||||
// close the topmost open dialog; info popups first, then editors
|
||||
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._openingInfo) { this._openingInfo = null; return; }
|
||||
if (this._infoCard) { this._closeInfoCard(); return; }
|
||||
@@ -11882,15 +11882,19 @@ export class HouseplanCard extends LitElement {
|
||||
: nothing}
|
||||
${this._vacCalConfirm ? this._editorRuntime ? html`<hp-dialog .hass=${this.hass}
|
||||
.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">
|
||||
<p>${this._t('vac.residual_message', { error: this._vacCalConfirm.error })}</p>
|
||||
</div>
|
||||
<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>
|
||||
<button class="btn ghost" @click=${() => this._vacApplyCalibrationProposal(true)}>${this._t('vac.fit')}</button>
|
||||
<button class="btn on" @click=${() => this._vacApplyCalibrationProposal(false)}>
|
||||
<button class="btn ghost" ?disabled=${this._vacCalConfirm.busy}
|
||||
@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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -11924,12 +11928,16 @@ export class HouseplanCard extends LitElement {
|
||||
</div>`
|
||||
: 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>
|
||||
<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" @click=${() => this._vacFitTurn({ mir: !this._vacFit!.p.mir })}>${this._t('vac.fit_mirror')}</button>
|
||||
<button class="btn" @click=${() => this._vacFitSave()}>${this._t('btn.save')}</button>
|
||||
<button class="btn ghostbtn" @click=${() => { this._vacFit = null; }}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn ghostbtn" ?disabled=${this._vacFit.busy}
|
||||
@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}
|
||||
@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}
|
||||
${this._tapConfirm
|
||||
? html`<hp-dialog .hass=${this.hass}
|
||||
@@ -12260,8 +12268,10 @@ export class HouseplanCard extends LitElement {
|
||||
/** Persist a solved matrix into marker.vacuum.calibration[mapId].
|
||||
* Returns whether the write actually landed — callers must not toast
|
||||
* success otherwise (HP-1540-01). */
|
||||
private _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine): boolean {
|
||||
return this._editorRuntimeOrThrow()._vacSaveMatrix(markerId, source, mapId, matrix);
|
||||
private _vacSaveMatrix(
|
||||
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. */
|
||||
@@ -12272,11 +12282,11 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** «Настроить автоматически»: robot rooms ↔ plan rooms by name. */
|
||||
private _vacAutoCalibrate(d: DevItem): void {
|
||||
private _vacAutoCalibrate(d: DevItem): Promise<void> {
|
||||
return this._editorRuntimeOrThrow()._vacAutoCalibrate(d);
|
||||
}
|
||||
|
||||
private _vacApplyCalibrationProposal(manual: boolean): void {
|
||||
private _vacApplyCalibrationProposal(manual: boolean): Promise<void> {
|
||||
return this._editorRuntimeOrThrow()._vacApplyCalibrationProposal(manual);
|
||||
}
|
||||
|
||||
@@ -12285,7 +12295,7 @@ export class HouseplanCard extends LitElement {
|
||||
return this._editorRuntimeOrThrow()._vacStartFit(d);
|
||||
}
|
||||
|
||||
private _vacFitSave(): void {
|
||||
private _vacFitSave(): Promise<void> {
|
||||
return this._editorRuntimeOrThrow()._vacFitSave();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
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 './hp-dialog';
|
||||
import type { HpDialog } from './hp-dialog';
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
parseVacSourceCandidate, resolveVacSource, resolveCurrentVacPath, trimVacPathTarget, areaCentroid,
|
||||
vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM,
|
||||
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,
|
||||
type VacSourceResolution, type VacSourceStatus,
|
||||
} from './vacuum';
|
||||
@@ -245,6 +245,8 @@ import {
|
||||
formatLatticeShiftCm,
|
||||
} from './coordinate-canonicalization';
|
||||
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 { supportT, type SupportI18nKey } from './i18n/support';
|
||||
import {
|
||||
@@ -1147,11 +1149,11 @@ export interface HouseplanEditorHostPort {
|
||||
_undoPoint: () => void;
|
||||
_vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[]; } | 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;
|
||||
_vacEntity: (d: DevItem) => string | null;
|
||||
_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;
|
||||
_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;
|
||||
@@ -1736,13 +1738,13 @@ public _samePt(a: readonly number[], b: readonly number[]): boolean {
|
||||
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
|
||||
// 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';
|
||||
}
|
||||
for (const sp of this.host._serverCfg?.spaces || []) {
|
||||
for (const sp of config?.spaces || []) {
|
||||
delete (sp as any).segments;
|
||||
const physicalIds = new Set<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);
|
||||
}
|
||||
|
||||
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._writeChain = enqueueSerializedWrite(this.host._writeChain, async () => {
|
||||
if (!this.host._serverCfg) return;
|
||||
this._dropLegacySegments();
|
||||
const candidate = canonicalizeConfigGeometry(this.host._serverCfg);
|
||||
const candidate = this._prepareConfigCandidate(this.host._serverCfg);
|
||||
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()];
|
||||
for (const [spaceId, accepted] of strictEntries) {
|
||||
const candidateSpace = candidate.spaces.find((space) => space.id === spaceId);
|
||||
@@ -8157,7 +8162,10 @@ public async _saveMarker(): Promise<void> {
|
||||
}
|
||||
const cfg = this.host._serverCfg;
|
||||
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 oldId = dlg.devId;
|
||||
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;
|
||||
if (dlg.binding === 'virtual' && !space) space = targetSpaceId;
|
||||
this.host._markerDialog = { ...dlg, busy: true };
|
||||
let attempt: OptimisticAttempt<ServerConfig> | null = null;
|
||||
let configAccepted = false;
|
||||
try {
|
||||
const replacedRemovedIds = dlg.binding === 'virtual'
|
||||
? []
|
||||
@@ -8267,8 +8277,9 @@ public async _saveMarker(): Promise<void> {
|
||||
}
|
||||
// Rebinding changes source identity. Rewrite every marker:* edge in the
|
||||
// same config transaction before replacing the marker itself.
|
||||
cfg.markers = markers;
|
||||
if (oldId && oldId !== id) cfg.markers = rewriteMarkerControlReferences(cfg.markers, oldId, id);
|
||||
candidate.markers = markers;
|
||||
if (oldId && oldId !== id)
|
||||
candidate.markers = rewriteMarkerControlReferences(candidate.markers, oldId, id);
|
||||
if (oldId && oldId !== id && marker.value_badge?.source?.kind === 'derived_marker_state'
|
||||
&& marker.value_badge.source.ref === `marker:${oldId}`) {
|
||||
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}` };
|
||||
}
|
||||
// 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
|
||||
&& (marker.binding === 'virtual' || m.binding !== marker.binding),
|
||||
);
|
||||
cfg.markers.push(marker);
|
||||
candidate.markers.push(marker);
|
||||
const obsoleteAreaSnapshotIds = new Set(replacedRemovedIds);
|
||||
if (oldId && oldId !== id) obsoleteAreaSnapshotIds.add(oldId);
|
||||
obsoleteAreaSnapshotIds.delete(id);
|
||||
if (obsoleteAreaSnapshotIds.size && cfg.settings?.marker_area_snapshot) {
|
||||
cfg.settings = {
|
||||
...cfg.settings,
|
||||
if (obsoleteAreaSnapshotIds.size && candidate.settings?.marker_area_snapshot) {
|
||||
candidate.settings = {
|
||||
...candidate.settings,
|
||||
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
|
||||
if (id !== oldId || !this.host._layout[id] || roomChanged) {
|
||||
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) {
|
||||
let cx = targetSpaceModel.vb[0] + targetSpaceModel.vb[2] / 2;
|
||||
@@ -8325,9 +8335,22 @@ public async _saveMarker(): Promise<void> {
|
||||
: undefined;
|
||||
if (room) [cx, cy] = this.host._roomCenter(room);
|
||||
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) {
|
||||
const pos = canonicalizePosition(newPos);
|
||||
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
|
||||
// then crashes, blanking the whole card. The toast below is the
|
||||
// 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 };
|
||||
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++;
|
||||
try {
|
||||
// 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
|
||||
await this._writeConfig();
|
||||
await this._writeConfig(attempt);
|
||||
} catch (e: any) {
|
||||
if (e?.physicalGeometryRolledBack) await this._reloadRejectedPhysicalWrite();
|
||||
else if (e?.code === 'conflict') await this.host._reloadConfigOnly();
|
||||
@@ -10952,9 +10981,9 @@ public _renderVacSection(dlg: any): TemplateResult | typeof nothing {
|
||||
</div>
|
||||
</details>
|
||||
<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
|
||||
? 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}
|
||||
<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>
|
||||
@@ -10981,20 +11010,10 @@ public _vacMapId(d: DevItem, tele: { mapId: string }, planHass = this.host._plan
|
||||
return this.host._vacMapId(d, tele, planHass);
|
||||
}
|
||||
|
||||
public _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine, routeId = ''): boolean {
|
||||
// HP-1540-01: a first-use vacuum has no marker yet — materialise it
|
||||
const dev = this.host._devices.find((x) => x.id === markerId);
|
||||
const m = dev ? this.host._vacEnsureMarker(dev)
|
||||
: 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 _vacSaveMatrix(
|
||||
markerId: string, source: string, mapId: string, matrix: Affine, routeId = '',
|
||||
): Promise<boolean> {
|
||||
return saveVacuumMatrix(this, markerId, source, mapId, matrix, routeId);
|
||||
}
|
||||
|
||||
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 }>;
|
||||
}
|
||||
|
||||
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 tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
|
||||
if (!src || !tele || tele.rooms.length < 3) {
|
||||
@@ -11038,32 +11058,18 @@ public _vacAutoCalibrate(d: DevItem): void {
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!this._vacSaveMatrix(d.id, src, mapId, res.matrix, target.routeId)) return;
|
||||
this.host._showToast(subst(this.host._t('vac.autocal_done'), { rooms: String(res.matched.length) }));
|
||||
await saveAutomaticCalibration(this, {
|
||||
markerId: d.id, source: src, mapId, routeId: target.routeId, space: target.space,
|
||||
matrix: res.matrix, rooms: res.matched.length,
|
||||
});
|
||||
}
|
||||
|
||||
public _vacApplyCalibrationProposal(manual: boolean): void {
|
||||
const proposal = this.host._vacCalConfirm;
|
||||
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 _vacApplyCalibrationProposal(manual: boolean): Promise<void> {
|
||||
return applyCalibrationProposal(this, manual);
|
||||
}
|
||||
|
||||
public _vacStartFit(d: DevItem, routeId = ''): void {
|
||||
if (this.host._markerDialog?.busy) return;
|
||||
const src = this.host._vacSource(d);
|
||||
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
|
||||
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 };
|
||||
}
|
||||
|
||||
public _vacFitSave(): void {
|
||||
const f = this.host._vacFit;
|
||||
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 _vacFitSave(): Promise<void> {
|
||||
return saveManualCalibration(this, fitMatrix);
|
||||
}
|
||||
|
||||
public _vacFitTurn(patch: Partial<FitParams>): void {
|
||||
const f = this.host._vacFit;
|
||||
if (!f) return;
|
||||
if (!f || f.busy) return;
|
||||
const tele = readVacTelemetry(this.host.hass?.states[f.source]?.attributes);
|
||||
const c = this._vacGhostCentre(tele?.rooms || []);
|
||||
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;
|
||||
if (!f) return;
|
||||
ev.stopPropagation();
|
||||
if (f.busy) return;
|
||||
if (ev.type === 'pointerdown') {
|
||||
const t = ev.target as HTMLElement;
|
||||
const corner = t.getAttribute?.('data-corner');
|
||||
|
||||
@@ -51,8 +51,7 @@ export function rollbackOptimistic<T>(
|
||||
): boolean {
|
||||
const current = host._serverCfg;
|
||||
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._cfgContentFingerprint = attempt.previousFingerprint;
|
||||
host.requestUpdate();
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -183,7 +183,7 @@ test('frontend write paths adopt canonical candidates before persistence (#224)'
|
||||
assert.match(source, /enqueueSerializedWrite\(this\._writeChain, async \(\) =>/);
|
||||
assert.match(
|
||||
source,
|
||||
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);/,
|
||||
/const candidate = this\._prepareConfigCandidate\(this\._serverCfg\);/,
|
||||
);
|
||||
assert.match(
|
||||
eagerCardSource,
|
||||
|
||||
@@ -49,7 +49,7 @@ test('stable space ids use exact lookup and abort before side effects', () => {
|
||||
const guardAt = saveMarker.indexOf('if (!targetSpaceModel) return;');
|
||||
const busyAt = saveMarker.indexOf('busy: true');
|
||||
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
|
||||
&& busyAt < migrateAt && migrateAt < configMutationAt);
|
||||
|
||||
|
||||
@@ -169,6 +169,6 @@ test('marker delete/re-add and opening save remain separate config transactions'
|
||||
const saveMarker = methodBody(source, '_saveMarker');
|
||||
assert.match(saveOpening, /sp\.openings/);
|
||||
assert.doesNotMatch(saveOpening, /cfg\.markers|this\._markers/);
|
||||
assert.match(saveMarker, /cfg\.markers/);
|
||||
assert.match(saveMarker, /candidate\.markers/);
|
||||
assert.doesNotMatch(saveMarker, /\.openings/);
|
||||
});
|
||||
|
||||
@@ -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(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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user