mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96275b8019 | ||
|
|
ffbf8242ab | ||
|
|
f9ac1eab35 | ||
|
|
fcd4e3ddfe | ||
|
|
77ac72d15d | ||
|
|
a8426268db |
@@ -1,125 +1,125 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5",
|
||||
"fingerprint": "56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js",
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 293046,
|
||||
"initialViewGzipBytes": 293163,
|
||||
"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-oWJolMYh.js",
|
||||
"houseplan-assets/de-CF711iTL.js",
|
||||
"houseplan-assets/editor-DBDu1fmS.js",
|
||||
"houseplan-assets/fr-A4-MjeBb.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
|
||||
],
|
||||
"lazyGzipBytes": 221355,
|
||||
"lazyGzipBytes": 222109,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/editor-veGv06fc.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js"
|
||||
"houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"houseplan-assets/editor-DBDu1fmS.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 165335,
|
||||
"lazyEditorGzipBytes": 166061,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
|
||||
"houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14233,
|
||||
"lazyOnboardingGzipBytes": 14241,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-D6tfUEEi.js",
|
||||
"houseplan-assets/fr-B9jqd_9T.js"
|
||||
"houseplan-assets/de-CF711iTL.js",
|
||||
"houseplan-assets/fr-A4-MjeBb.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49066,
|
||||
"lazyLocaleGzipBytes": 49090,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"sha256": "f163ec3c045e02b53c31d00b7b18b87394b1d485d01963878d390db5280e9d3c",
|
||||
"path": "houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"sha256": "77bedfa35c239b51d4395faff3decd94de86ab24d16e35746ea592696b6eeba2",
|
||||
"rawBytes": 21418,
|
||||
"gzipBytes": 7279,
|
||||
"gzipBytes": 7283,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-D6tfUEEi.js",
|
||||
"sha256": "8bbf9c291b9973c77de053a1677d19846cc61e44232988739af1e31412812a1e",
|
||||
"path": "houseplan-assets/de-CF711iTL.js",
|
||||
"sha256": "fc710917dd87b57c5d2bba9b636a28473373540d02348a4175fee903771fb6ec",
|
||||
"rawBytes": 82140,
|
||||
"gzipBytes": 24802,
|
||||
"gzipBytes": 24814,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-veGv06fc.js",
|
||||
"sha256": "40fcfabb1c6b1df59264c1a0da9d74cb67cfd2230e731f0e2c20e9fdaa21c01e",
|
||||
"path": "houseplan-assets/editor-DBDu1fmS.js",
|
||||
"sha256": "5e321ab61ddb1ac20c3911c9e82e8ddc6a62404d5d8dbca6780db37cc6ff0c98",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1580,
|
||||
"gzipBytes": 1585,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-B9jqd_9T.js",
|
||||
"sha256": "4ec01fd1b61ce5f0a796edb86bcbe4f87dcfba1b37c6f12afca535590ce36b9d",
|
||||
"path": "houseplan-assets/fr-A4-MjeBb.js",
|
||||
"sha256": "d247900cdb0bdd2988b3f601bb08e7182c54c2ea05918a8d8edbd6cb5fdc8a70",
|
||||
"rawBytes": 84267,
|
||||
"gzipBytes": 24264,
|
||||
"gzipBytes": 24276,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CfQ49v8a.js",
|
||||
"sha256": "46dbdf5dc1881b44d03804f95f9406242f6f491f534466acdfcb1f478618b61d",
|
||||
"rawBytes": 1040074,
|
||||
"gzipBytes": 292251,
|
||||
"path": "houseplan-assets/houseplan-card-BYuSDJH2.js",
|
||||
"sha256": "f39bf9406326084842e9819c4eb8591cee812635e7b78c25809a72dc8f7f5373",
|
||||
"rawBytes": 1040585,
|
||||
"gzipBytes": 292365,
|
||||
"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-CF711iTL.js",
|
||||
"houseplan-assets/editor-DBDu1fmS.js",
|
||||
"houseplan-assets/fr-A4-MjeBb.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
|
||||
"sha256": "3eabc904f3eec1ad453ad7746433e3232caa38ef0797adbf7833318ee22cb946",
|
||||
"rawBytes": 595676,
|
||||
"gzipBytes": 156476,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
|
||||
"sha256": "c0df87b7ae1657caa84c811f8efc016c0063d5a17221e221029b00176f5dc466",
|
||||
"rawBytes": 597836,
|
||||
"gzipBytes": 157193,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js",
|
||||
"sha256": "d3645c23d4dd4dc4b1dd93452f047b3936e5878f90688a14d5d8693da424a0f7",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js",
|
||||
"sha256": "e7ce402a46b4f03687f86ae010905d4d75b5b972e7784a1e34b4fd36c463c117",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6954,
|
||||
"gzipBytes": 6958,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "5a6cb0840f0836d5c09b7de0313c57b74c0bb1d5df585067062229b553e89bab",
|
||||
"sha256": "cf7f4bd0b7bd554d86d54119c7367b553e9f5d8272ee2e95b48469c169e25928",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 795,
|
||||
"gzipBytes": 798,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
+1
-1
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__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";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-BYuSDJH2.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
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";import{l as t,aN as s,A as e,t as o,c as a,cc as l,cd as i,ce as h,cf as c,aK as n,cg as r,cl as p,ch as d,ci as _,cj as u,ck as g,db as m,aJ as b,aL as $,dc as v}from"./houseplan-card-CfQ49v8a.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-DsJZC1kQ.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";import{l as t,aN as s,A as e,t as o,c as a,cd as l,ce as i,cf as h,cg as c,aK as n,ch as r,cm as p,ci as d,cj as _,ck as u,cl as g,dc as m,aJ as b,aL as $,dd as v}from"./houseplan-card-BYuSDJH2.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-oWJolMYh.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:_,tempMax:d,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
|
||||
${s.map(s=>{return a`
|
||||
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
|
||||
@@ -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__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";try{await import("./houseplan-assets/houseplan-card-BYuSDJH2.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": "56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js",
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 293046,
|
||||
"initialViewGzipBytes": 293163,
|
||||
"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-oWJolMYh.js",
|
||||
"houseplan-assets/de-CF711iTL.js",
|
||||
"houseplan-assets/editor-DBDu1fmS.js",
|
||||
"houseplan-assets/fr-A4-MjeBb.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
|
||||
],
|
||||
"lazyGzipBytes": 221355,
|
||||
"lazyGzipBytes": 222109,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/editor-veGv06fc.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js"
|
||||
"houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"houseplan-assets/editor-DBDu1fmS.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 165335,
|
||||
"lazyEditorGzipBytes": 166061,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js"
|
||||
"houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14233,
|
||||
"lazyOnboardingGzipBytes": 14241,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-D6tfUEEi.js",
|
||||
"houseplan-assets/fr-B9jqd_9T.js"
|
||||
"houseplan-assets/de-CF711iTL.js",
|
||||
"houseplan-assets/fr-A4-MjeBb.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49066,
|
||||
"lazyLocaleGzipBytes": 49090,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"sha256": "f163ec3c045e02b53c31d00b7b18b87394b1d485d01963878d390db5280e9d3c",
|
||||
"path": "houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"sha256": "77bedfa35c239b51d4395faff3decd94de86ab24d16e35746ea592696b6eeba2",
|
||||
"rawBytes": 21418,
|
||||
"gzipBytes": 7279,
|
||||
"gzipBytes": 7283,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-D6tfUEEi.js",
|
||||
"sha256": "8bbf9c291b9973c77de053a1677d19846cc61e44232988739af1e31412812a1e",
|
||||
"path": "houseplan-assets/de-CF711iTL.js",
|
||||
"sha256": "fc710917dd87b57c5d2bba9b636a28473373540d02348a4175fee903771fb6ec",
|
||||
"rawBytes": 82140,
|
||||
"gzipBytes": 24802,
|
||||
"gzipBytes": 24814,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-veGv06fc.js",
|
||||
"sha256": "40fcfabb1c6b1df59264c1a0da9d74cb67cfd2230e731f0e2c20e9fdaa21c01e",
|
||||
"path": "houseplan-assets/editor-DBDu1fmS.js",
|
||||
"sha256": "5e321ab61ddb1ac20c3911c9e82e8ddc6a62404d5d8dbca6780db37cc6ff0c98",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1580,
|
||||
"gzipBytes": 1585,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-B9jqd_9T.js",
|
||||
"sha256": "4ec01fd1b61ce5f0a796edb86bcbe4f87dcfba1b37c6f12afca535590ce36b9d",
|
||||
"path": "houseplan-assets/fr-A4-MjeBb.js",
|
||||
"sha256": "d247900cdb0bdd2988b3f601bb08e7182c54c2ea05918a8d8edbd6cb5fdc8a70",
|
||||
"rawBytes": 84267,
|
||||
"gzipBytes": 24264,
|
||||
"gzipBytes": 24276,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CfQ49v8a.js",
|
||||
"sha256": "46dbdf5dc1881b44d03804f95f9406242f6f491f534466acdfcb1f478618b61d",
|
||||
"rawBytes": 1040074,
|
||||
"gzipBytes": 292251,
|
||||
"path": "houseplan-assets/houseplan-card-BYuSDJH2.js",
|
||||
"sha256": "f39bf9406326084842e9819c4eb8591cee812635e7b78c25809a72dc8f7f5373",
|
||||
"rawBytes": 1040585,
|
||||
"gzipBytes": 292365,
|
||||
"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-CF711iTL.js",
|
||||
"houseplan-assets/editor-DBDu1fmS.js",
|
||||
"houseplan-assets/fr-A4-MjeBb.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DV_Pwgjt.js",
|
||||
"sha256": "3eabc904f3eec1ad453ad7746433e3232caa38ef0797adbf7833318ee22cb946",
|
||||
"rawBytes": 595676,
|
||||
"gzipBytes": 156476,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-B9vxxIYH.js",
|
||||
"sha256": "c0df87b7ae1657caa84c811f8efc016c0063d5a17221e221029b00176f5dc466",
|
||||
"rawBytes": 597836,
|
||||
"gzipBytes": 157193,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-CRc0yadY.js",
|
||||
"sha256": "d3645c23d4dd4dc4b1dd93452f047b3936e5878f90688a14d5d8693da424a0f7",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-2O6rGYyJ.js",
|
||||
"sha256": "e7ce402a46b4f03687f86ae010905d4d75b5b972e7784a1e34b4fd36c463c117",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6954,
|
||||
"gzipBytes": 6958,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DsJZC1kQ.js",
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/backdrop-pick-oWJolMYh.js",
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "5a6cb0840f0836d5c09b7de0313c57b74c0bb1d5df585067062229b553e89bab",
|
||||
"sha256": "cf7f4bd0b7bd554d86d54119c7367b553e9f5d8272ee2e95b48469c169e25928",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 795,
|
||||
"gzipBytes": 798,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CfQ49v8a.js"
|
||||
"houseplan-assets/houseplan-card-BYuSDJH2.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
Vendored
+1
-1
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__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";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-BYuSDJH2.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
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";import{l as t,aN as s,A as e,t as o,c as a,cc as l,cd as i,ce as h,cf as c,aK as n,cg as r,cl as p,ch as d,ci as _,cj as u,ck as g,db as m,aJ as b,aL as $,dc as v}from"./houseplan-card-CfQ49v8a.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-DsJZC1kQ.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="5191b8ed9d87bd62f3cd141bdd9cbfbdc4442075b5c0c1c32baded001f300db5";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";import{l as t,aN as s,A as e,t as o,c as a,cd as l,ce as i,cf as h,cg as c,aK as n,ch as r,cm as p,ci as d,cj as _,ck as u,cl as g,dc as m,aJ as b,aL as $,dd as v}from"./houseplan-card-BYuSDJH2.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-oWJolMYh.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:_,tempMax:d,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
|
||||
${s.map(s=>{return a`
|
||||
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
|
||||
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__="56c4f193c6899f80181491944c9d6679698be4658a0ed5ba54dc08d1409538e3";try{await import("./houseplan-assets/houseplan-card-BYuSDJH2.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`:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"oxipng": "oxipng 10.2.0",
|
||||
"sourceFingerprint": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceFingerprint": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"captureScriptSha256": "cadb8e1bcab9f1dcdd7d75b3b90ddcbaaeb2b8c2a098f575a21f39ff70f5c59c",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "80a70361dc18dd0461568df332062e6482c633af5d280954f8b675701418a76d"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "4106cc28847047505f46921ff95765d8abdf5b382d9d17d4c5e4ad129dd8f6be"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "617b51b3648498787b5039980c9f3eceb75ba56ed63a1a20e616bc05bc304362"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "1dab6cc3b9d1bf7d8c40f0e5137f8c688683c9b7eabc5167da99d41ecfdd5b79"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "94ef50762753c0ac6ddc84d2521c4232a3f9ecd89843810d2df61c517592bed7"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "7a601769de38aa19c2e280f6ff4cf3695b854b1d1c6d1f8de4e6b5e8f7eb1559"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "0939875f8631694f4de0ef4fd01032010ede8c7d49fb7c0a857612d4b3afff93"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "054170fd9ef45762b602b4d5c9c3b9ea9724858be61af137970c246f485c13bb"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -103,7 +103,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "2ae4a58853d98e10d12456b2078ec2f6a0b597722c8310d7b016abd2bc42561e"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e52b6f87a818f9abece38fe6026feede60b32a9017d7b255945720ebdbf8c23e",
|
||||
"sourceSha256": "9ead3fe88e3ea7346dc04221f5d652b0d1bb94331a26a651550117e5e3b45f0a",
|
||||
"imageSha256": "cb37f7eefd936f98ef44969b21dfe8e40ee27d1f558abe8d53c0223f6488b8a6"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# CODE-REVIEW-442-r1
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/442
|
||||
- Этап: code (PROCESS.md §2.7)
|
||||
- Заход: r1 (первый код-ревью цикл; спек-ревью r1 уже зелёный)
|
||||
- Ветка: `issue/442-marker-write-rollback`, HEAD `ffbf8242abf441196f731012f454f850c102dfaa`
|
||||
- ТЗ: `docs/specs/442-marker-write-rollback.md` (принято зелёным на SHA `a8426268`)
|
||||
- Диапазон: `git diff origin/dev...HEAD` — 40 файлов, +1732/-459 (без учёта `dist/**`,
|
||||
`custom_components/houseplan/frontend/**` — class D, не проверяется построчно,
|
||||
проверена побайтовая идентичность с пересобранным `dist`)
|
||||
|
||||
## Скоуп
|
||||
|
||||
Атомарный immutable candidate + guarded rollback для трёх путей, которые могут
|
||||
задеть четыре marker semantic validators: основной Save устройства
|
||||
(`_saveMarker`), запись матрицы калибровки робота (`saveVacuumMatrix` /
|
||||
`_vacSaveMatrix`), три сценария calibration UX (low-residual auto, high-residual
|
||||
proposal, manual fit). Плюс отдельная граница durable config acceptance vs
|
||||
layout/file side effects, и более строгий `rollbackOptimistic` guard (сравнение
|
||||
content по фингерпринту обязательно даже при совпадении object identity, #442
|
||||
AC2). Не в скоупе, и диффом не тронуто: 18 generic `_saveConfig()` вызовов
|
||||
(Hide/Show, discovery seeding, обычные vacuum-настройки), общий settings-путь
|
||||
`_saveSettingsDialog` (#439), backend/schema/i18n, геометрия, маршрутизация
|
||||
карт кроме сохранения уже готового `persistRoutes()` (#441).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
**Дешёвые гейты (прогнаны сам, зелёного Validate на этом SHA не было):**
|
||||
|
||||
| Гейт | Результат |
|
||||
|---|---|
|
||||
| `npx tsc --noEmit` | green, 0 ошибок |
|
||||
| `npm test` | 1863 pass, 1 skip, 0 fail (`# tests 1864`) |
|
||||
| `npm run build` | green; `git status --porcelain` после билда пуст — dist воспроизводится байт-в-байт |
|
||||
| `npm run bundle:sync` | green; SHA-256 `dist/houseplan-card.js` и `houseplan-assets.json` совпадают с `custom_components/houseplan/frontend/**` |
|
||||
| `node scripts/check-docs.mjs` | green: «Documentation checks passed (7 files, 10 external links)» — diff трогает `src/**`, поэтому обязателен |
|
||||
|
||||
**Инварианты модели:** неприменимо — diff не трогает рёбра комнат, толщину,
|
||||
`layout`, `marker.space`/`open_spans`; единственная геометрическая функция
|
||||
(`canonicalizeConfigGeometry`) не изменена, только обёрнута в
|
||||
`_prepareConfigCandidate` без изменения поведения (подтверждено чтением и
|
||||
`npm test`, включая `#278`/`#224`-серии).
|
||||
|
||||
**Mutation witnesses (`scripts/mutation-gate.mjs --id=<id>`), каждый
|
||||
запущен лично и подтверждён как «покраснел, как обязан»:**
|
||||
|
||||
| id | guard | Что проверяет |
|
||||
|---|---|---|
|
||||
| `marker-reject-keeps-optimistic-candidate` | `smoke_marker_write_rollback` | AC1: отсутствие `rollbackOptimistic` красит смок |
|
||||
| `marker-rollback-keeps-enqueue-time-revision` | `smoke_marker_write_rollback` | AC2: без ребиндинга `attempt.revision` смок красит |
|
||||
| `accepted-marker-rolled-back-by-layout-failure` | `smoke_marker_write_rollback` | AC3: безусловный rollback после accept красит смок |
|
||||
| `vacuum-reject-keeps-optimistic-matrix` | `test/vacuum-calibration-write.test.mjs` | AC5–AC7: без rollback в `saveVacuumMatrix` тест красит |
|
||||
| `vacuum-auto-reports-success-before-acceptance` | `test/vacuum-calibration-write.test.mjs` | AC4: ранний `saved=true` до ответа сервера красит тест |
|
||||
| `optimistic-rollback-skips-same-root-fingerprint` | `test/serialized-write-queue.test.mjs` | AC2: возврат identity-шортката красит тест |
|
||||
|
||||
Также перепроверены два **не новых**, но переехавших вместе с рефакторингом
|
||||
мутанта (`src/houseplan-card.ts`→`src/houseplan-editor-runtime.ts` и
|
||||
`src/vacuum-calibration-write.ts`): `vacuum-manual-fit-after-proposal-uses-the-dock`
|
||||
и `child-readd-clears-parent-tombstone` — оба по-прежнему красят целевой
|
||||
смок/гейт после переноса паттерна. Полный `mutation-gate` без `--id` не
|
||||
гонялся: он падает на несвязанном `backend-test-guard` (`No module named
|
||||
pytest`), окружение без Python-гейта — не относится к этой задаче.
|
||||
|
||||
**Browser smokes.** `node scripts/smoke-select.mjs --base origin/dev --head HEAD`
|
||||
насчитал diff по 56 символам на изменённых строках `src/**` (4 файла), матрица
|
||||
219 смоков, порог «широкого» символа — 43. Инструмент выдал 36 «прямое
|
||||
совпадение» + 43 «слабая связь» (полный вывод учтён, не прикладываю целиком).
|
||||
|
||||
Прогнаны лично (все green), 12 из 36 прямых совпадений плюс AC8:
|
||||
|
||||
- `smoke_marker_write_rollback` (новый, AC1–AC3) — все 12 полей `true`
|
||||
- `smoke_vacuum_firstuse` (расширен, AC4–AC7) — все 23 поля `true`
|
||||
- `smoke_vacuum_multifloor`, `smoke_vacuum` — `OK`
|
||||
- `smoke_vacuum_route_draft` (AC8, автор назвал явно) — все 21 поле `true`
|
||||
- `smoke_dialog_zombie` (автор назвал явно) — все 8 полей `true`
|
||||
- `smoke_v8_draft_write` — прямое совпадение на `_cfgContentFingerprint`,
|
||||
`_cfgRev`, `_dropLegacySegments` (сигнатура которого реально изменилась:
|
||||
необязательный параметр `config`), `_saveConfigDebounced` — 16 полей `true`
|
||||
- `smoke_lattice_write_barrier` — прямое совпадение на `_writeConfig`
|
||||
(сигнатура которого реально изменилась: параметр `attempt`) — 9 полей `true`
|
||||
- `smoke_wall_thickness` — прямое совпадение на `_dropLegacySegments` — 18 полей `true`
|
||||
- `smoke_save_race` — прямое совпадение на разделяемый writer/`_cfgRev` — 4 поля `true`
|
||||
- `smoke_danger_confirmation` — прямое совпадение на `_markerDialog`/`_saveConfigNow`
|
||||
(сигнатура изменилась: параметр `attempt`) — 18 полей `true`
|
||||
- `smoke_config_writer` — прямое совпадение на `_cfgRev`/`_saveConfig` — `OK`
|
||||
|
||||
Решение по каждой из непрогнанных 24 «прямых совпадений» (`smoke_decor_default_persist`,
|
||||
`smoke_area_relocation_safety`, `smoke_controls`, `smoke_cover_tap`,
|
||||
`smoke_device_inbox`, `smoke_device_position_history`, `smoke_device_preview_parity`,
|
||||
`smoke_discovery_filters`, `smoke_editor_tabs`, `smoke_help_affordance`,
|
||||
`smoke_optimize_coordinate_canonicalization`, `smoke_partition_openings`,
|
||||
`smoke_room_resize`, `smoke_room_tooltip_toggle`, `smoke_sun`, `smoke_tap_ctx`,
|
||||
`smoke_value_face_source`, `smoke_danger_confirm_branches`, `smoke_decor_images`,
|
||||
`smoke_editor_gestures`, `smoke_fixed_floor`, `smoke_junction_limits`,
|
||||
`smoke_resize_audit_1550`, `smoke_space_tab_reorder`,
|
||||
`smoke_zero_wall_migration_unblocked`): не прогонялись. Совпадение у них —
|
||||
на общие символы (`_cfgRev`, `_showToast`, `_writeConfig`, `_commitSpace`,
|
||||
`_markerDialog`/`Marker`/`DevItem` как типы), а не на изменённое поведение;
|
||||
ни один не тестирует Device editor save, calibration UX или rollback guard
|
||||
напрямую. Сами изменённые функции (`_writeConfig`, `_saveConfigNow`,
|
||||
`_dropLegacySegments`, `rollbackOptimistic`, `optimisticAttempt`) уже
|
||||
прогнаны в 6 смоках выше плюс покрыты 1863 unit-тестами, включая
|
||||
`test/coordinate-canonicalization.test.mjs` (#224-серия, строчная сверка
|
||||
источника `_writeConfig`) и `test/serialized-write-queue.test.mjs`. Слабую
|
||||
связь (43 смока) не прогонял — ни один не совпал на изменённом символе,
|
||||
только на распространённых именах вне контракта записи.
|
||||
|
||||
`npm run golden:verify` не гонял — diff не меняет видимый рендер, только
|
||||
busy/disabled-атрибуты кнопок (`?disabled`, `aria-busy`); `check-docs`
|
||||
подтвердил 0 изменённых PNG и единственно принятый `sourceFingerprint`
|
||||
(скриншоты сделаны без калибровочного pending/reject UI, что ТЗ явно
|
||||
допускает: «Новая постоянная golden surface не требуется»).
|
||||
|
||||
`python -m pytest tests_backend` не гонял — diff не трогает
|
||||
`custom_components/**/*.py`.
|
||||
|
||||
Performance-профили не гонял — не названы в AC, diff не меняет
|
||||
чувствительные к перфу пути (только optimistic write/rollback JS-логику
|
||||
объёмом O(размер конфига), не в цикле рендера).
|
||||
|
||||
## Разбор кода
|
||||
|
||||
**`src/serialized-write-queue.ts`.** `rollbackOptimistic` больше не
|
||||
принимает object-identity как самостоятельное основание для отката —
|
||||
теперь всегда сравнивается `fingerprint(current) !== attempt.attemptedFingerprint`,
|
||||
и для той же ссылки тоже. Это именно то, что требует AC2: «более новая
|
||||
in-place правка не может быть затёрта старым reject». Проверено новым
|
||||
unit-тестом (`newer in-place content on the attempted root also wins`) и
|
||||
персонально перепроверенным мутантом `optimistic-rollback-skips-same-root-fingerprint`.
|
||||
|
||||
**`src/houseplan-editor-runtime.ts` — `_saveMarker()`.** Строит `candidate`
|
||||
глубоким клоном `cfg` **до** отправки (`JSON.parse(JSON.stringify(cfg))`),
|
||||
не мутирует исходный `cfg`. Перед установкой `optimisticAttempt` есть явная
|
||||
guard-проверка на смену корня во время async file-migrate шага
|
||||
(`this.host._serverCfg !== cfg || this.host._cfgRev !== baseRevision ||
|
||||
contentFingerprint(cfg) !== baseContent`) — конфликт корректно показывает
|
||||
`toast.conflict` и не строит candidate поверх устаревшего root. `attempt`
|
||||
создаётся, кандидат устанавливается оптимистично, debounce отменяется,
|
||||
`_saveConfigNow(attempt)` awaited. `configAccepted` выставляется в `true`
|
||||
**только** после успешного `_saveConfigNow`; catch откатывает
|
||||
(`rollbackOptimistic`) исключительно если `!configAccepted`. Layout
|
||||
update/obsolete cleanup/file cleanup идут после этой границы и их отказ не
|
||||
трогает `configAccepted` — соответствует контракту §1 ТЗ. Подтверждено
|
||||
чтением и смоком `smoke_marker_write_rollback` (`sideEffectFailureKeepsAcceptedConfig`).
|
||||
|
||||
**`_writeConfig(attempt)`.** Новая строка `if (attempt && candidateFingerprint
|
||||
=== attempt.attemptedFingerprint) attempt.revision = this.host._cfgRev;`
|
||||
решает реальную гонку: если попытка встала в очередь `_writeChain` позади
|
||||
другой уже выполняющейся записи, `_cfgRev` к моменту фактической отправки
|
||||
может уже уйти вперёд, и без ребиндинга guard в `rollbackOptimistic` увидел
|
||||
бы устаревшую `attempt.revision` и **тихо не откатил** законно отклонённый
|
||||
кандидат. Мутант `marker-rollback-keeps-enqueue-time-revision`,
|
||||
удаляющий эту строку, красит `smoke_marker_write_rollback` — сценарий
|
||||
«queue behind a prior write» там воспроизведён явно (строки 44–56 смока).
|
||||
|
||||
**`src/vacuum-calibration-write.ts` (новый модуль).** `saveVacuumMatrix`
|
||||
клонирует `previous`, находит или материализует minimal first-use marker
|
||||
на клоне (не на `previous`), пишет матрицу через существующий
|
||||
`writeVacuumMatrix()`, ставит `optimisticAttempt`, отменяет debounce,
|
||||
ждёт `_saveConfigNow(attempt)`; на reject — `rollbackOptimistic` +
|
||||
rebuild + toast. `saveAutomaticCalibration` дедуплицирует параллельные
|
||||
Apply через `WeakSet<runtime>`, держит `_markerDialog.busy` до ответа,
|
||||
снимает его в `finally` независимо от исхода. `applyCalibrationProposal`
|
||||
и `saveManualCalibration` следуют тому же паттерну busy→await→(close+toast
|
||||
| restore-draft+busy:false). Все четыре пути подтверждены и unit-тестом
|
||||
(`test/vacuum-calibration-write.test.mjs`, включая проверку `assert.notEqual`
|
||||
на изолированность отката от live-объекта), и browser-смоком
|
||||
(`smoke_vacuum_firstuse`), и мутантами.
|
||||
|
||||
**`src/houseplan-card.ts`.** Только типизация (`VacuumFit`/`CalibrationProposal`
|
||||
вместо инлайновых типов) и busy-wiring в разметке: `?disabled=${busy}` на
|
||||
кнопках Auto-calibrate/Fit/Save/Cancel/Apply, `aria-busy` на диалоге
|
||||
предложения и панели fit, Esc/scrim guard на `busy`. Согласуется с разделом
|
||||
«Touch, клавиатура и доступность» ТЗ — busy это реальный disabled, а не
|
||||
только визуальный индикатор.
|
||||
|
||||
## Находки
|
||||
|
||||
Ничего блокирующего. Два Low, оба вне обязательного скоупа задачи —
|
||||
оставлены с запиской, не правятся и не заводятся отдельным issue (severity
|
||||
ниже Medium, PROCESS.md §12 не требует issue для Low).
|
||||
|
||||
1. **Low — `docs/specs/README.md`, таблица раздела «P2».** Новая строка
|
||||
`#442` вставлена сразу после `#10`, а не в конце секции, где физически
|
||||
находится диапазон номеров `#4xx` (например, `#440`, `#443` идут в
|
||||
других местах таблицы). Секция и так не строго монотонна (`#294` после
|
||||
`#373`, `#443` после `#434`), так что это чисто косметическая
|
||||
навигационная неровность — `check-docs.mjs` не проверяет порядок,
|
||||
ссылка рабочая. Не блокирует.
|
||||
|
||||
2. **Low/наблюдение, не находка задачи — `_saveSettingsDialog()` (#439,
|
||||
`src/houseplan-editor-runtime.ts:10260`, diff этой задачи не трогает).**
|
||||
Этот путь по-прежнему вызывает `this._saveConfigNow()` **без** передачи
|
||||
`attempt`, поэтому новый ребиндинг `attempt.revision = this.host._cfgRev`
|
||||
в `_writeConfig` для него не работает — тот же класс гонки, который #442
|
||||
AC2 закрывает для маркеров (запись в очереди позади другой, `_cfgRev`
|
||||
ушёл вперёд), теоретически остаётся в settings-пути. Это **не
|
||||
регрессия этой задачи**: поведение не менялось, риск существовал уже
|
||||
в #439, воспроизведение требует двух параллельных pending-записей и не
|
||||
проверено мной в обе стороны в рамках бюджета этого раунда. Settings
|
||||
dialog не входит в скоуп #442 (не задевает четыре marker semantic
|
||||
validators) и явно исключён ТЗ («атомаризация всех 18 generic
|
||||
`_saveConfig()` call sites» — не-скоуп). Оставляю как наблюдение для
|
||||
будущей задачи, а не как находку, требующую отдельного issue сейчас —
|
||||
недостаточно данных, чтобы утверждать это как подтверждённый дефект, а
|
||||
не гипотетический край.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- AC1–AC9 — каждый либо доказан автотестом, который я лично прогнал и чьи
|
||||
свидетели проверены на способность покраснеть (mutation-gate), либо
|
||||
browser-smoke-ом с явными полями результата, все `true`.
|
||||
- Guarded rollback различает: обычный reject (откатывает), conflict reload
|
||||
(не трогает authoritative reload), более новую in-place правку той же
|
||||
ссылки (не трогает) — по коду и по unit/smoke сценариям.
|
||||
- Durable boundary между `configAccepted` и layout/file side effects — по
|
||||
коду и by `sideEffectFailureKeepsAcceptedConfig`/`sideEffectFailureHasNoSuccess`
|
||||
в смоке.
|
||||
- First-use synthetic marker не остаётся при reject — unit
|
||||
(`rejected first-use calibration leaves no synthetic marker`) и смок
|
||||
(`fitFreshNoMarker`, `highResidualRejectRestoresMatrix`).
|
||||
- #441 (route CRUD) не задет — `smoke_vacuum_route_draft` зелёный целиком,
|
||||
основной Save по коду не пишет `map_routes` напрямую (переносит блок
|
||||
`vacuum` как есть в кандидат).
|
||||
- Busy — настоящий disabled на кнопках/полях, Esc/scrim не воскрешают UI
|
||||
во время pending — по коду и `smoke_vacuum_firstuse`
|
||||
(`autoWaitsBusyDisabled`) плюс ручное чтение `_onKey`/шаблонов.
|
||||
- Оба changelog обновлены в одном user-visible коммите (`fcd4e3dd`),
|
||||
трейлеры `Issue:`/`User-Visible:` корректны на всех 5 коммитах диапазона.
|
||||
- `docs/CONFIG-COMPATIBILITY.md` и `docs/VACUUM.md` формулировки совпадают
|
||||
с фактическим контрактом кода (revision+content guard, durable boundary,
|
||||
retry-draft).
|
||||
- Screenshot provenance (`docs/images/screenshots.json`): только
|
||||
`sourceFingerprint` изменился на всех 10 сценариях, все `imageSha256`
|
||||
идентичны — реальных визуальных изменений нет, что ожидаемо (только
|
||||
busy/disabled-атрибуты, не заснятые в текущей матрице).
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный `mutation-gate` без `--id` (падает на несвязанном
|
||||
backend-test-guard из-за отсутствия pytest в этом окружении — вне
|
||||
диапазона задачи).
|
||||
- 24 из 36 «прямое совпадение» и все 43 «слабая связь» browser smoke —
|
||||
см. таблицу выше с обоснованием по каждой не прогнанной группе.
|
||||
- `npm run golden:verify`, `performance_smoke`, полный Linux HA harness —
|
||||
предрелизный гейт по процессу, не гейт код-ревью; diff не меняет
|
||||
видимый рендер (только busy/disabled-атрибуты) и не трогает backend.
|
||||
- Ручное подтверждение в реальном браузере (только автоматизированные
|
||||
Playwright-смоки и unit-тесты).
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. AC1–AC9 закрыты и доказаны воспроизводимо (тесты умеют падать —
|
||||
проверено лично на 6 новых + 2 переехавших мутантах); дешёвые гейты и
|
||||
целевая browser-смок-выборка зелёные; находок уровня High/Medium нет.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/442-marker-write-rollback`, коммит `ffbf8242abf4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `4bc5d44a158273057ec9cf1cf34591def23ad7ce`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 4bc5d44a1582
|
||||
```
|
||||
- ТЗ `docs/specs/442-marker-write-rollback.md`, блоб `a9dcca2ff269a1c309387902875a4937efbd4ebf`
|
||||
```
|
||||
git log --all --find-object=a9dcca2ff269a1c309387902875a4937efbd4ebf -- docs/specs/442-marker-write-rollback.md
|
||||
```
|
||||
@@ -0,0 +1,216 @@
|
||||
# SPEC-REVIEW-442-r1
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/442
|
||||
- ТЗ: `docs/specs/442-marker-write-rollback.md`
|
||||
- Этап: `S4-spec-review`, заход r1, блокирующих циклов израсходовано 0 из 4
|
||||
- Ревьюер: Claude (роль «ревьюер ТЗ», отдельная сессия от автора)
|
||||
- Материал ревью: ТЗ на коммите `a8426268db90666920801f9e09554aa81343ceba`
|
||||
(ветка `issue/442-marker-write-rollback`), тело issue #442 и все его
|
||||
комментарии на момент разбора (2026-09-03)
|
||||
|
||||
## Скоуп разбора
|
||||
|
||||
Первый заход — разбор полный: продуктовая рамка (`docs/SCOPE.md`), процесс
|
||||
(`PROCESS.md` §2.4/§7.1, `AGENTS.md`), тело issue #442 и вся цепочка
|
||||
комментариев (аналитика → вопросы владельцу → решение владельца → готовое
|
||||
ТЗ), связанные issue #439 (guarded rollback общих настроек) и #441 (атомарный
|
||||
route CRUD), канонические `docs/CONFIG-COMPATIBILITY.md` и `docs/VACUUM.md`,
|
||||
терминология `docs/USER-GUIDE.ru.md`. Отдельно верифицированы факты о текущем
|
||||
коде, на которых ТЗ строит контракт: `src/serialized-write-queue.ts`,
|
||||
`src/houseplan-editor-runtime.ts`, `src/editors/vacuum-maps-section.ts`,
|
||||
`custom_components/houseplan/validation.py`, наличие названных тестов/смоков.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
1. Прочитаны `docs/SCOPE.md`, `PROCESS.md` (§1–§10), `AGENTS.md` полностью.
|
||||
2. Прочитаны тело issue #442 и все 4 комментария (аналитика с оценками
|
||||
8/10 польза · 9/10 разработка · 5/10 сложность · 5/10 риск · P2 · full
|
||||
track; пачка вопросов Q1/Q2 с default-вариантами; решение владельца,
|
||||
принявшее оба default; финальный комментарий со ссылкой на готовое ТЗ).
|
||||
3. Прочитан весь текст `docs/specs/442-marker-write-rollback.md` (379 строк).
|
||||
4. Прочитаны связанные issue #439 и #441 — оба уже приняты и описывают ровно
|
||||
тот прецедент (guarded rollback, атомарный route writer), на который ТЗ
|
||||
#442 ссылается как на эталон и явно не дублирует.
|
||||
5. Прочитан `docs/CONFIG-COMPATIBILITY.md` целиком — не найдено противоречий
|
||||
между заявленным «revision + content fingerprint» guard'ом и уже
|
||||
задокументированным контрактом ревизий (#340), геометрии (#282/#306/#224)
|
||||
и маршрутов пылесоса (#162).
|
||||
6. Прочитан `docs/VACUUM.md` целиком — раздел «Calibration» действительно не
|
||||
документирует порядок toast/подтверждение относительно ответа сервера, то
|
||||
есть заявленный в ТЗ release-артефакт (обновление `VACUUM.md`) не
|
||||
дублирует уже существующий текст, а закрывает реальный пробел.
|
||||
7. Проверена терминология `docs/USER-GUIDE.ru.md`: «Редактор устройств»,
|
||||
«Карты и этажи», «ручная подгонка», «Применить» — используемые в ТЗ
|
||||
термины (кроме одного, см. Low ниже) не расходятся с интерфейсом.
|
||||
8. Делегирована фактологическая проверка кода отдельному агенту (Explore) по
|
||||
9 конкретным пунктам — см. «Верификация фактов» ниже. Каждый пункт
|
||||
перепроверен по конкретным строкам файла, не на веру.
|
||||
9. Самостоятельно прочитан `src/serialized-write-queue.ts` целиком (60 строк)
|
||||
— независимо подтверждён guard: `host._cfgRev !== attempt.revision ||
|
||||
(current !== attempt.attempted && fingerprint(current) !==
|
||||
attempt.attemptedFingerprint)`, то есть ревизия **и** контент-фингерпринт
|
||||
одновременно, включая ветку одного and того же object identity —
|
||||
именно то, что ТЗ требует мутационно проверить в AC2.
|
||||
10. Самостоятельно найден `_writeChain`/`expected_rev` (`src/houseplan-card.ts:7662-7730`,
|
||||
`src/houseplan-editor-runtime.ts:1175` и далее) — сериализация запросов,
|
||||
на которую ссылается контракт §2, существует и используется.
|
||||
|
||||
## Верификация фактов ТЗ против текущего кода (HEAD `a8426268`)
|
||||
|
||||
| # | Утверждение ТЗ/issue | Результат |
|
||||
|---|---|---|
|
||||
| 1 | `_saveMarker()` мутирует `cfg.markers` до `await _saveConfigNow()`, `catch` только снимает busy и показывает toast | **Подтверждено** — `src/houseplan-editor-runtime.ts:8270-8285` мутация, `:8330` await, `catch` `:8359-8366` без восстановления `_serverCfg` и без rebuild devices |
|
||||
| 2 | *(из тела issue, не из ТЗ)* готовый guarded rollback применён «ровно к одному пути — общим настройкам (`_updateDecorStyle`)» | **Неточность в тексте issue.** Пара `optimisticAttempt`/`rollbackOptimistic` действительно встречается 3 раза (импорт + одна пара вызовов), но пара живёт в `_saveSettingsDialog` (`:10231-10275`, вызовы на `:10260`/`:10271`), а не в `_updateDecorStyle` (`:10204`) — та не содержит optimistic-логики вовсе и делегирует в `_persistDecorStyle` → обычный debounced `_saveConfig()`. Счётчик «18 вызовов `_saveConfig()`» корректен |
|
||||
| 3 | `_vacSaveMatrix()` мутирует in-place, коллеры показывают success toast и закрывают UI до подтверждения записи сервером | **Подтверждено** — мутация `m.vacuum`, `_maybeRebuildDevices()`, невawait-нутый debounced `_saveConfig()`; `toast.autocal_done`/`toast.cal_done` показываются сразу после синхронного (boolean) возврата `_vacSaveMatrix`, без ожидания сервера |
|
||||
| 4 | Route CRUD в `vacuum-maps-section.ts` уже использует `optimisticAttempt`/`rollbackOptimistic` (эталон #441) | **Подтверждено** — импорт `:21`, вызовы `:105`/`:116` |
|
||||
| 5 | Guard сравнивает revision **и** content fingerprint; есть `_writeChain`/`expected_rev` | **Подтверждено** самостоятельно (см. «Как проверялось» п.9-10) |
|
||||
| 6 | Четыре семантических валидатора маркера существуют под этими именами | **Подтверждено** ровно с этими именами (кроме generic «проверка controls», которую ТЗ и не называет точным именем) — `validate_marker_value_badges` (`:811`), `validate_marker_vacuum_routes` (`:886`), `validate_marker_light_entities` (`:930`), `validate_marker_controls` (`:972`, цикл — `:1049`) в `custom_components/houseplan/validation.py` |
|
||||
| 7 | Названные в плане тестирования файлы существуют | **Подтверждено** — все 6: `test/serialized-write-queue.test.mjs`, `demo/smoke_vacuum_route_draft.mjs`, `demo/smoke_vacuum_firstuse.mjs`, `demo/smoke_vacuum_multifloor.mjs`, `demo/smoke_vacuum.mjs`, `demo/smoke_dialog_zombie.mjs` |
|
||||
| 8 | `docs/VACUUM.md` ещё не документирует reject/success UX калибровки | **Подтверждено** — раздел «Calibration» молчит именно об этом |
|
||||
| 9 | `scripts/mutation-gate.mjs` существует | **Подтверждено** |
|
||||
|
||||
Итог верификации: единственное расхождение с кодом (пункт 2) находится в
|
||||
**теле issue**, не в тексте ТЗ — сам документ `docs/specs/442-...md` нигде не
|
||||
называет `_updateDecorStyle` и не строит контракт на этом имени; он ссылается
|
||||
на механизм обобщённо («общий writer», «уже готовый `optimisticAttempt`/
|
||||
`rollbackOptimistic`»), что после проверки оказалось верным независимо от
|
||||
того, в какой именно функции этот механизм физически живёт. Так что дефект
|
||||
не проникает в исполнимость ТЗ — см. Low ниже, почему он всё равно стоит
|
||||
записи.
|
||||
|
||||
## Соответствие §7.1
|
||||
|
||||
Все обязательные разделы присутствуют: Сценарий · Что человек увидит до и
|
||||
после · Подтверждённая проблема · Скоуп/Не-скоуп · Контракт поведения (6
|
||||
подпунктов) · Данные и совместимость · Touch/клавиатура/доступность ·
|
||||
Ошибки и крайние случаи · AC1–AC9 с доказательством (unit/smoke/test double
|
||||
у каждого) · План тестирования · Карта реализации · Риски и rollback ·
|
||||
Release-артефакты · Принятые предположения.
|
||||
|
||||
Владелец отвечал только на продуктовые вопросы (Q1 — какие marker-пути
|
||||
входят в атомизацию; Q2 — что делать с calibration UI при отказе), оба с
|
||||
default-вариантом и явным принятием; технические решения (immutable
|
||||
candidate, где именно живёт guard, разбиение на pure-модуль) автор
|
||||
резервирует себе явным блоком «Принятые предположения» в конце — ровно так,
|
||||
как требует процесс.
|
||||
|
||||
## Находки
|
||||
|
||||
### Low-1. Неточное имя функции в теле issue (не в ТЗ)
|
||||
|
||||
Issue #442 в разделе «Проблема» называет уже реализованный guarded-rollback
|
||||
путь `_updateDecorStyle`; фактически это `_saveSettingsDialog`
|
||||
(`src/houseplan-editor-runtime.ts:10231`). `_updateDecorStyle` — соседняя
|
||||
функция без optimistic-логики. Ошибка не искажает контракт ТЗ (там имя
|
||||
функции не упоминается вовсе, только «общий writer» и сам механизм), поэтому
|
||||
не блокирует и не требует правки ТЗ. Фиксирую как заметку для реализации:
|
||||
если разработчик будет ориентироваться на текст issue при поиске «уже
|
||||
готового пути», неверное имя может стоить нескольких минут поиска не там —
|
||||
не более того.
|
||||
|
||||
**Решение ревьюера:** снимается без правки, запись сделана для истории раунда.
|
||||
|
||||
### Low-2. Явного раздела «i18n» нет — контент есть, но не под заголовком
|
||||
|
||||
§7.1 перечисляет i18n отдельным обязательным разделом. В ТЗ #442 подтверждение
|
||||
«новых строк нет» разнесено по двум местам («Новых targets, жестов и строк
|
||||
i18n нет» в разделе Touch; «новых текстов ошибок... не добавляется» в
|
||||
Не-скоуп) без отдельного заголовка. По содержанию требование выполнено
|
||||
(эксплицитно и дважды непротиворечиво), поэтому не блокирует переход в
|
||||
`S5-ready` — DoR требует «i18n: ключи en+ru перечислены», а перечислять
|
||||
здесь нечего, что и сказано явно. Отмечаю как стилистическую придирку, не
|
||||
находку, которая должна кого-то останавливать.
|
||||
|
||||
**Решение ревьюера:** снимается без правки.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Продуктовая рамка.** Задача закрывает J6 (`docs/SCOPE.md`: «Keep the plan
|
||||
true as the home evolves» — «View не должен показывать конфигурацию,
|
||||
которую сервер отверг»), сценарий и «что человек увидит» — оба заполнены
|
||||
без терминов реализации первым предложением, персона (Home admin) и
|
||||
поверхность (Device editor + calibration overlay) названы.
|
||||
- **Скоуп/не-скоуп** зеркалят принятые owner defaults Q1/Q2 дословно, без
|
||||
расширения и без урезания против решения владельца.
|
||||
- **Технические утверждения о текущем коде** — 8 из 9 проверенных фактов
|
||||
подтверждены чтением реальных строк; единственное расхождение (Low-1) не
|
||||
влияет на сам контракт ТЗ.
|
||||
- **Контракт поведения** опирается на реально существующий примитив
|
||||
(`optimisticAttempt`/`rollbackOptimistic` с двойным guard'ом ревизия+
|
||||
фингерпринт, уже обкатанным в #441 и в `_saveSettingsDialog`), а не
|
||||
изобретает новый механизм с нуля — минимизирует риск.
|
||||
- **AC1–AC9** каждый указывает способ доказательства (unit / browser smoke /
|
||||
test double) и достаточно конкретен для написания теста без дополнительных
|
||||
решений: например, AC2 явно называет два обязательных мутанта
|
||||
(unconditional rollback; пропуск fingerprint для same identity),
|
||||
предвосхищая требование код-ревью §2.7 о «таблице чем краснеет».
|
||||
Формулировки допускают ровно одну трактовку каждая — не нашёл AC, который
|
||||
можно закрыть двумя разными реализациями с разным видимым поведением.
|
||||
- **Регрессионный периметр** (AC8, таблица «Ошибки и крайние случаи», строка
|
||||
«route CRUD #441 reject/retry») явно защищает уже принятый атомарный route
|
||||
writer от повторной реализации или порчи — соответствует правилу «не
|
||||
подменять существующий механизм».
|
||||
- **Данные и совместимость**: явно «миграции нет», persisted schema не
|
||||
меняется, что соответствует характеру задачи (только client-side
|
||||
транзакционность) и не противоречит ни одному разделу
|
||||
`CONFIG-COMPATIBILITY.md`.
|
||||
- **Touch/доступность**: busy — настоящий disabled, а не только визуальный
|
||||
индикатор; фокус остаётся в открытом диалоге — соответствует
|
||||
`TOUCH-SUPPORT.md` (редакторы desktop-first, но контракт не деградирует
|
||||
специально под touch).
|
||||
- **Release-артефакты** называют оба changelog, `CONFIG-COMPATIBILITY.md`,
|
||||
`VACUUM.md` (подтверждено выше, что там действительно есть пробел для
|
||||
заполнения), условие для docs-скриншотов и явное «golden не нужен, если
|
||||
подходящей канонической поверхности нет» — не оставляет открытых пунктов
|
||||
DoR.
|
||||
- **Догадки, выданные за решения**: не найдено. Каждое место, где решение
|
||||
не следует напрямую из существующего документа, либо помечено как
|
||||
default-ответ владельца (Q1/Q2), либо вынесено в явный блок «Принятые
|
||||
предположения» в конце ТЗ, что ревьюер вправе (и не находит повода)
|
||||
оспорить.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Не запускал `npx tsc --noEmit`/`npm test`/`npm run build` — на этапе
|
||||
ревью ТЗ кода ещё нет (это этап `spec`, не `code`), гейты §8 к этому
|
||||
документу неприменимы.
|
||||
- Не проверял мутационные сценарии AC2 практически (мутант ещё не написан —
|
||||
это работа код-ревью, не спек-ревью); отметил только, что защитный
|
||||
примитив уже существует и уже используется в проде (#441).
|
||||
- Не проверял браузерные смоки исполнением — только их наличие как файлов
|
||||
(см. таблицу верификации, п.7). Их фактическое покрытие AC — задача
|
||||
реализации и код-ревью.
|
||||
- Не проверял `docs/specs/README.md` на предмет обязательной строки в
|
||||
таблице issue↔ТЗ — это релиз-артефакт автора ТЗ, не блокирует ревью.
|
||||
|
||||
## Вердикт
|
||||
|
||||
**Зелёный.** High: 0, Medium: 0. Обе Low-находки сняты без правки текста ТЗ
|
||||
(записаны выше для истории раунда, не блокируют). ТЗ полно по §7.1, каждый
|
||||
AC однозначен и снабжён способом доказательства, продуктовые вопросы решены
|
||||
владельцем до написания ТЗ, а технические утверждения о существующем коде
|
||||
подтверждены независимым чтением, а не приняты на веру.
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/442-marker-write-rollback`
|
||||
- SHA ТЗ: `a8426268db90666920801f9e09554aa81343ceba`
|
||||
- Путь ТЗ: `docs/specs/442-marker-write-rollback.md`
|
||||
- Найти дерево: `git log --all --format='%H %T' | grep <дерево HEAD>`
|
||||
- Найти блоб ТЗ: `git log --all --find-object=<блоб> -- docs/specs/442-marker-write-rollback.md`
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/442-marker-write-rollback`, коммит `a8426268db90` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `5d3fbe71add69e722a9f6d87b1ce04dcea586729`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 5d3fbe71add6
|
||||
```
|
||||
- ТЗ `docs/specs/442-marker-write-rollback.md`, блоб `a9dcca2ff269a1c309387902875a4937efbd4ebf`
|
||||
```
|
||||
git log --all --find-object=a9dcca2ff269a1c309387902875a4937efbd4ebf -- docs/specs/442-marker-write-rollback.md
|
||||
```
|
||||
@@ -0,0 +1,378 @@
|
||||
# ТЗ #442 — Атомарный откат отклонённых записей маркера
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/442
|
||||
- Приоритет: P2, `bug`
|
||||
- Статус ТЗ: готово к ревью
|
||||
- Маршрут: full; меняются основной Save устройства и сохранение калибровки
|
||||
робота, включая отказный UX и конкурентный контракт
|
||||
- Связанные контракты: #439 (guarded optimistic rollback общих настроек),
|
||||
#441 (атомарные CRUD-операции маршрутов карт), #162 (маршрутная
|
||||
калибровка робота), #314 (отдельный откат физической геометрии)
|
||||
|
||||
## Сценарий
|
||||
|
||||
Home admin редактирует устройство либо калибрует карту робота. Backend
|
||||
отклоняет `config/set` из-за semantic validation, конфликта или транспортной
|
||||
ошибки. Карточка должна сразу снова показывать последнее подтверждённое
|
||||
сервером состояние, но не терять введённый в открытом UI draft: пользователь
|
||||
исправляет значение или повторяет сохранение без перезагрузки карточки и без
|
||||
повторной ручной подгонки.
|
||||
|
||||
## Что человек увидит до и после
|
||||
|
||||
**До:** после отказа основной Device editor сообщает об ошибке, но локальный
|
||||
marker остаётся изменённым и может попасть в View либо в следующую запись.
|
||||
Калибровка закрывается и показывает success до ответа сервера; при отказе на
|
||||
экране остаётся фантомная матрица, а ручную подгонку приходится повторять.
|
||||
|
||||
**После:** непринятый marker config автоматически возвращается к последнему
|
||||
подтверждённому состоянию. Device editor остаётся открыт с введёнными полями.
|
||||
Auto/manual calibration остаётся открытой и занятой до ответа: при успехе она
|
||||
закрывается и только затем сообщает об успехе, при отказе сохраняет рассчитанный
|
||||
draft для Retry и показывает ошибку.
|
||||
|
||||
## Подтверждённая проблема
|
||||
|
||||
1. `HouseplanEditorRuntime._saveMarker()` меняет `cfg.markers` до
|
||||
`await _saveConfigNow()`. Обычный `catch` снимает `busy` и показывает toast,
|
||||
но не возвращает `_serverCfg`, не перестраивает devices и не отделяет отказ
|
||||
`config/set` от ошибок последующих layout/file housekeeping.
|
||||
2. Backend реально может отклонить доступные из Device editor изменения через
|
||||
`validate_marker_controls`, `validate_marker_light_entities`,
|
||||
`validate_marker_value_badges` и `validate_marker_vacuum_routes`.
|
||||
3. `_vacSaveMatrix()` мутирует marker in-place, перестраивает devices и вызывает
|
||||
debounced `_saveConfig()`, после чего callers сразу закрывают
|
||||
`_vacCalConfirm`/`_vacFit` и показывают `vac.autocal_done` либо
|
||||
`vac.cal_done`. Promise результата у UI нет.
|
||||
4. CRUD маршрутов в `src/editors/vacuum-maps-section.ts` уже использует
|
||||
`optimisticAttempt()`/`rollbackOptimistic()` и остаётся эталоном, а не второй
|
||||
реализацией в рамках #442.
|
||||
5. Общий writer уже сериализует запросы и отдельно обрабатывает conflict и
|
||||
physical geometry. #442 не должен подменять эти механизмы глобальным reload
|
||||
либо откатом несвязанной транзакции.
|
||||
|
||||
## Скоуп
|
||||
|
||||
В скоупе:
|
||||
|
||||
- атомарный immutable candidate для основного Save устройства;
|
||||
- guarded rollback всего config-кандидата при отказе именно его `config/set`;
|
||||
- восстановление производных marker/device представлений после отката;
|
||||
- сохранение draft Device editor и возможность Retry;
|
||||
- асинхронная запись auto-calibration, принятого high-residual proposal и manual
|
||||
fit;
|
||||
- busy-состояние калибровочного UI до ответа сервера;
|
||||
- success toast и закрытие calibration UI только после подтверждённой записи;
|
||||
- сохранение рассчитанной матрицы или параметров ручной подгонки при отказе;
|
||||
- конкурентные случаи conflict reload и более новой локальной ревизии;
|
||||
- регрессия атомарных маршрутов #441;
|
||||
- unit, browser smoke и mutation witnesses;
|
||||
- документация и оба changelog.
|
||||
|
||||
## Не-скоуп
|
||||
|
||||
- атомаризация всех 18 generic `_saveConfig()` call sites;
|
||||
- Hide/Show, удаление marker, discovery seeding, обычные `live`, `trail_mode` и
|
||||
`source` настройки робота, если они не входят в сохраняемый calibration/route
|
||||
candidate;
|
||||
- изменение четырёх backend validators, schema либо формата ошибок;
|
||||
- изменение математики auto-calibration, residual threshold или manual fit;
|
||||
- изменение маршрутизации карт, route identity либо выбора этажа;
|
||||
- откат физической геометрии или замена механизма #314;
|
||||
- транзакционное удаление безвредных файлов-копий после rejected rebind;
|
||||
- новый глобальный transaction manager для всех editor writes;
|
||||
- новые тексты ошибок, отдельные модальные предупреждения или дополнительные
|
||||
подтверждения.
|
||||
|
||||
## Контракт поведения
|
||||
|
||||
### 1. Граница marker-транзакции
|
||||
|
||||
Основной Save строит новый `ServerConfig` как отдельный candidate из текущего
|
||||
подтверждённого root. До отправки не допускается in-place изменение предыдущего
|
||||
`cfg.markers`: предыдущий config должен оставаться пригодным для точного
|
||||
восстановления.
|
||||
|
||||
Одна попытка фиксирует:
|
||||
|
||||
- глубокую копию предыдущего config;
|
||||
- предыдущий `_cfgContentFingerprint`;
|
||||
- `_cfgRev` до отправки;
|
||||
- candidate и его content fingerprint.
|
||||
|
||||
Candidate может быть показан оптимистично, но при отклонении его
|
||||
`config/set` вызывается guarded rollback. После успешного rollback карточка
|
||||
сбрасывает marker-derived caches/signature, перестраивает devices и запрашивает
|
||||
render. Ни одно поле непринятого marker не остаётся в View или следующей записи.
|
||||
|
||||
Конфигурационная транзакция заканчивается сразу после успешного
|
||||
`_saveConfigNow()`. Layout update, очистка старого layout id и файловое
|
||||
housekeeping выполняются только после durable config acceptance. Ошибка такого
|
||||
последующего best-effort шага не имеет права откатывать уже принятый сервером
|
||||
config.
|
||||
|
||||
### 2. Guarded rollback и конкуренция
|
||||
|
||||
Rollback применяется только если текущие revision и content fingerprint всё
|
||||
ещё принадлежат не принятому candidate. Сравнение content обязательно и для
|
||||
того же object identity: более новая in-place правка не может быть затёрта
|
||||
старым reject.
|
||||
|
||||
Таблица решений:
|
||||
|
||||
| Событие во время попытки | Итог |
|
||||
|---|---|
|
||||
| semantic/schema/transport reject, candidate всё ещё текущий | восстановить previous config и прежний fingerprint |
|
||||
| `conflict`, `_saveConfigNow()` уже перечитал server truth | не откатывать authoritative reload |
|
||||
| появилась более новая local revision/content | не откатывать новую правку |
|
||||
| пользователь закрыл диалог во время запроса | config откатывается по guard; диалог не воскрешается; toast остаётся |
|
||||
| config принят, затем упал layout/file side effect | принятый config остаётся; сообщается ошибка соответствующего шага |
|
||||
|
||||
Сериализация через существующую `_writeChain` и `expected_rev` сохраняется.
|
||||
Pending debounced write не должен обгонять прямой marker/calibration save; один
|
||||
и тот же candidate не отправляется повторно скрытым debounce.
|
||||
|
||||
### 3. Основной Device editor
|
||||
|
||||
При Save UI становится busy и не запускает вторую попытку. После успеха
|
||||
сохраняется текущий UX: диалог закрывается, devices перестраиваются и
|
||||
показывается `toast.marker_saved`.
|
||||
|
||||
При отказе config-записи:
|
||||
|
||||
- `_serverCfg` и отображение возвращаются к accepted state;
|
||||
- диалог, если он ещё открыт, остаётся открыт и выходит из busy;
|
||||
- его локальные поля остаются такими, какими их ввёл пользователь;
|
||||
- Retry строит новый immutable candidate поверх актуального server config;
|
||||
- success toast не показывается; показывается существующая локализованная
|
||||
ошибка;
|
||||
- если диалог закрыт пользователем, он не создаётся заново.
|
||||
|
||||
Rebind сохраняет порядок безопасности файлов: copy допустим до config save,
|
||||
cleanup — только после acceptance. Оставшаяся после reject копия безвредна и
|
||||
остаётся вне скоупа housekeeping.
|
||||
|
||||
### 4. Routes/maps
|
||||
|
||||
Добавление, переназначение и удаление `map_routes` продолжает использовать
|
||||
атомарный `persistRoutes()` из #441. #442 не возвращает эти операции к общему
|
||||
debounce и не создаёт второй route writer.
|
||||
|
||||
Основной Save устройства обязан переносить текущий vacuum block в candidate,
|
||||
не стирая уже принятую route transaction. Ошибка основного Save откатывает его
|
||||
полный candidate к состоянию непосредственно перед попыткой, а не к снимку до
|
||||
последней успешно принятой route-операции.
|
||||
|
||||
### 5. Запись калибровки
|
||||
|
||||
`_vacSaveMatrix()` становится асинхронной атомарной операцией либо делегирует
|
||||
такой операции. Она:
|
||||
|
||||
1. строит отдельный config candidate;
|
||||
2. материализует минимальный marker в candidate для first-use vacuum, не
|
||||
изменяя previous config;
|
||||
3. записывает matrix в exact route/legacy target через существующий
|
||||
`writeVacuumMatrix()`;
|
||||
4. фиксирует optimistic attempt, присваивает candidate и перестраивает preview;
|
||||
5. ожидает `_saveConfigNow()`;
|
||||
6. возвращает success только после server acceptance;
|
||||
7. при reject выполняет guarded rollback, перестраивает devices и возвращает
|
||||
failure вызывающему UI.
|
||||
|
||||
### 6. Calibration UX
|
||||
|
||||
Для low-residual auto-calibration Device editor остаётся на экране. На время
|
||||
записи соответствующие calibration controls busy/disabled; повторный клик не
|
||||
создаёт второй запрос. `vac.autocal_done` появляется только после acceptance.
|
||||
При reject диалог остаётся открыт, busy снимается, показывается ошибка; Retry
|
||||
повторно использует тот же пользовательский вход и снова рассчитывает matrix.
|
||||
|
||||
Для high-residual proposal:
|
||||
|
||||
- Apply не очищает `_vacCalConfirm` до ответа;
|
||||
- proposal получает busy state, закрытие, Cancel, Fit и повторный Apply на это
|
||||
время недоступны;
|
||||
- success закрывает proposal и показывает `vac.autocal_done`;
|
||||
- reject оставляет тот же proposal/matrix открытым, снимает busy и позволяет
|
||||
Retry либо переход в ручную подгонку.
|
||||
|
||||
Для manual fit:
|
||||
|
||||
- Save не очищает `_vacFit` до ответа;
|
||||
- overlay получает busy state; drag, rotate/mirror, Save и выход, который мог бы
|
||||
потерять draft, на время запроса не создают новую попытку;
|
||||
- success закрывает overlay и показывает `vac.cal_done`;
|
||||
- reject оставляет exact `FitParams` и route identity, снимает busy, возвращает
|
||||
accepted config и позволяет Retry без повторной подгонки.
|
||||
|
||||
Обычный Cancel до начала записи сохраняет прежнее поведение. Нового
|
||||
предупреждения при ошибке не добавляется.
|
||||
|
||||
## Данные и совместимость
|
||||
|
||||
- Persisted schema, marker/vacuum shape и revision protocol не меняются.
|
||||
- Миграции данных нет.
|
||||
- Успешные marker, route и calibration payloads должны быть эквивалентны
|
||||
текущим после canonicalization.
|
||||
- Legacy calibration и explicit `map_routes` сохраняют контракт #162/#443.
|
||||
- First-use write сохраняет остальные принятые поля marker: clone-and-patch не
|
||||
удаляет неизвестные/future поля marker, config или vacuum.
|
||||
|
||||
## Touch, клавиатура и доступность
|
||||
|
||||
Busy является настоящим disabled-состоянием controls, а не только визуальным
|
||||
индикатором. Повторные touch/click/Enter не создают дополнительный Save.
|
||||
Focus остаётся в том же открытом dialog/overlay после reject. Закрытие через
|
||||
Esc/scrim во время уже начатой попытки не воскрешает UI после ответа и не
|
||||
мешает config rollback. Новых targets, жестов и строк i18n нет.
|
||||
|
||||
## Ошибки и крайние случаи
|
||||
|
||||
| Случай | Ожидаемое поведение |
|
||||
|---|---|
|
||||
| controls cycle отклонён backend | marker в View прежний, draft dialog сохранён, Retry доступен |
|
||||
| invalid light/value source отклонён | тот же guarded rollback без частичного marker |
|
||||
| route calibration отклонена | accepted matrix остаётся в config, новая fit/proposal остаётся UI-draft |
|
||||
| first first-use vacuum calibration отклонена | synthetic marker не остаётся локально |
|
||||
| повторный Save во время pending | один `config/set` |
|
||||
| conflict reload во время reject | server truth выигрывает, старый rollback no-op |
|
||||
| новая локальная правка поверх candidate | новая content revision выигрывает |
|
||||
| Esc закрыл Device editor в полёте | config восстановлен, dialog не воскрешён, error toast виден |
|
||||
| config принят, layout update не удался | config не откатывается и не расходится с сервером |
|
||||
| route CRUD #441 reject/retry | прежний атомарный UX остаётся зелёным |
|
||||
|
||||
## Acceptance criteria и доказательства
|
||||
|
||||
### AC1. Основной Save атомарен
|
||||
|
||||
Unit/contract test доказывает, что `_saveMarker()` строит отдельный candidate и
|
||||
при semantic reject восстанавливает весь previous config/fingerprint. Browser
|
||||
smoke отклоняет `controls` или другой реально валидируемый field, сверяет View,
|
||||
сохранённый dialog draft, busy=false и успешный Retry.
|
||||
|
||||
### AC2. Откат не затирает владельца новой ревизии
|
||||
|
||||
Unit покрывает обычный reject, conflict reload, заменённый root и более новую
|
||||
in-place правку того же candidate. Mutant с unconditional rollback и mutant,
|
||||
который пропускает fingerprint для same identity, обязаны краснеть.
|
||||
|
||||
### AC3. Main Save отделяет durable config от side effects
|
||||
|
||||
Test double принимает `config/set`, затем отклоняет layout/file operation.
|
||||
Принятый marker остаётся локально, старый config не восстанавливается, а
|
||||
операция не показывает `toast.marker_saved` до завершения обязательной части
|
||||
успешного пути.
|
||||
|
||||
### AC4. Auto-calibration ждёт сервер
|
||||
|
||||
Browser smoke держит `config/set` deferred: до resolve отсутствует success,
|
||||
Device editor/proposal остаётся открыт и busy, повторный Apply не пишет второй
|
||||
раз. Resolve закрывает нужный UI и даёт ровно один success toast.
|
||||
|
||||
### AC5. Reject auto proposal сохраняет Retry
|
||||
|
||||
High-residual proposal при reject остаётся с той же matrix/route identity,
|
||||
accepted config восстановлен, busy снят и второй Apply может успешно записать
|
||||
ровно одну калибровку.
|
||||
|
||||
### AC6. Reject manual fit сохраняет подгонку
|
||||
|
||||
Browser smoke фиксирует изменённые `FitParams`, отклоняет Save и проверяет:
|
||||
overlay не закрыт, параметры не изменились, pointer/кнопки снова доступны,
|
||||
config вернулся к accepted matrix. Retry success закрывает overlay и только
|
||||
тогда показывает `vac.cal_done`.
|
||||
|
||||
### AC7. First-use и route identity сохранены
|
||||
|
||||
Тест отклоняет первую калибровку auto-discovered vacuum: minimal marker не
|
||||
остаётся в `_serverCfg`. Существующие multifloor/first-use tests подтверждают,
|
||||
что successful retry пишет matrix в exact route и не возвращает legacy path.
|
||||
|
||||
### AC8. #441 не регрессировал
|
||||
|
||||
`smoke_vacuum_route_draft` продолжает доказывать atomic add/reassign/delete,
|
||||
reject/retry и отсутствие пустого route draft в persisted config. Основной
|
||||
marker Save не стирает только что принятую route transaction.
|
||||
|
||||
### AC9. Общие гейты
|
||||
|
||||
Зелёные typecheck, unit, build и синхрон трёх bundle trees. Для diff в `src/**`
|
||||
обязателен `check-docs`; `smoke-select` определяет дополнительные browser
|
||||
scenarios. Полный Linux HA harness остаётся каноническим CI. Изменение не
|
||||
трогает геометрию, поэтому `model-invariants` неприменим. Visible busy/reject UX
|
||||
проверяется smoke; постоянный golden добавляется только при наличии подходящей
|
||||
canonical surface.
|
||||
|
||||
## План тестирования
|
||||
|
||||
- расширить `test/serialized-write-queue.test.mjs` проверкой same-identity
|
||||
content change;
|
||||
- выделить pure candidate/rollback helpers для marker и calibration там, где
|
||||
это уменьшает stateful browser setup;
|
||||
- добавить browser smoke rejected marker Save + preserved draft + Retry;
|
||||
- расширить vacuum smoke deferred/rejected auto proposal и manual fit;
|
||||
- сохранить зелёными `smoke_vacuum_route_draft`, `smoke_vacuum_firstuse`,
|
||||
`smoke_vacuum_multifloor`, `smoke_vacuum` и `smoke_dialog_zombie`;
|
||||
- добавить mutation-gate anchors для отсутствующего marker/calibration
|
||||
rollback, раннего success/close и same-identity fingerprint guard;
|
||||
- выполнить `typecheck`, `npm test`, `build`, `bundle:sync`, `check-docs`,
|
||||
выбранные smokes и обязательный CI на exact SHA.
|
||||
|
||||
## Карта реализации
|
||||
|
||||
- `src/serialized-write-queue.ts` — строгий guarded rollback при изменённом
|
||||
content того же object identity;
|
||||
- `src/houseplan-editor-runtime.ts` — immutable marker/calibration candidates,
|
||||
async persistence, rollback/rebuild и delayed success/close;
|
||||
- `src/houseplan-card.ts` — типы busy-state, render/disabled contract и async
|
||||
delegates;
|
||||
- при необходимости небольшой отдельный pure-модуль marker candidate, чтобы не
|
||||
раздувать editor runtime;
|
||||
- `test/`, `demo/smoke_*.mjs`, `scripts/mutation-gate.mjs` — witnesses;
|
||||
- `docs/CONFIG-COMPATIBILITY.md`, `docs/VACUUM.md`, оба changelog и screenshot
|
||||
fingerprint — release artifacts.
|
||||
|
||||
## Риски и rollback
|
||||
|
||||
- Главный риск — откатить authoritative conflict reload или более новую правку;
|
||||
закрывается revision + unconditional content-fingerprint guard.
|
||||
- Второй риск — принять config на сервере, затем ошибочно вернуть локально
|
||||
previous из-за layout/file failure; закрывается явной durable boundary.
|
||||
- Третий риск — потерять first-use marker либо route identity при clone; его
|
||||
закрывают first-use и multifloor witnesses.
|
||||
- Четвёртый риск — закрыть calibration UI до реального ответа и потерять draft;
|
||||
закрывается deferred/reject/retry smoke.
|
||||
- Rollback реализации — revert frontend/tests/docs/bundle. Data migration нет;
|
||||
принятые marker/calibration payloads совместимы с предыдущей версией.
|
||||
|
||||
## Release-артефакты
|
||||
|
||||
- В том же user-visible implementation commit обновляются
|
||||
`docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #442.
|
||||
- `docs/CONFIG-COMPATIBILITY.md` фиксирует: semantic reject marker write не
|
||||
остаётся локальной конфигурацией, conflict/newer revision выигрывают.
|
||||
- `docs/VACUUM.md` фиксирует, что success калибровки означает подтверждённую
|
||||
запись, а reject сохраняет auto/manual draft для Retry.
|
||||
- Любой `src/**` diff требует канонической `Docs screenshots` съёмки и приёмки
|
||||
fingerprint по процессу. Если существующий кадр реально меняется, его diff
|
||||
просматривается; несвязанные локальные baseline differences не принимаются.
|
||||
- Новая постоянная golden surface не требуется, если calibration pending/reject
|
||||
UI отсутствует в текущей canonical matrix; light/dark и keyboard/touch
|
||||
состояние подтверждается browser smoke.
|
||||
- Нового performance или security artifact не требуется: payload size,
|
||||
вычислительная геометрия и trust boundary не меняются.
|
||||
|
||||
## Принятые предположения
|
||||
|
||||
- Default Q1: в #442 входят только UI-пути, которые могут изменить поля четырёх
|
||||
marker semantic validators; generic marker/config writes вне этого множества
|
||||
не атомаризируются.
|
||||
- Default Q2: calibration UI остаётся открытым и busy до ответа; reject
|
||||
сохраняет рассчитанный draft для Retry, success закрывает UI после acceptance.
|
||||
- Любой reject semantic marker attempt откатывает только эту попытку; conflict
|
||||
reload и более новая revision/content имеют приоритет.
|
||||
- Draft основного Device editor живёт отдельно от `_serverCfg` и не теряется при
|
||||
rollback.
|
||||
- Backend/schema/i18n, успешный View и физическая геометрия не меняются.
|
||||
- Файлы, скопированные перед rejected rebind, остаются безопасным остатком и не
|
||||
удаляются новой клиентской гонкой.
|
||||
@@ -92,6 +92,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
|
||||
| Issue | ТЗ |
|
||||
|---|---|
|
||||
| [#10](https://github.com/Matysh/houseplan-card/issues/10) Roomba live position | [010-vacuum-roomba-live-position.md](010-vacuum-roomba-live-position.md) |
|
||||
| [#442](https://github.com/Matysh/houseplan-card/issues/442) Атомарный откат отклонённых записей маркера | [442-marker-write-rollback.md](442-marker-write-rollback.md) |
|
||||
| [#11](https://github.com/Matysh/houseplan-card/issues/11) Vacuum source health | [011-vacuum-source-health.md](011-vacuum-source-health.md) |
|
||||
| [#12](https://github.com/Matysh/houseplan-card/issues/12) Room cleaning highlight | [012-vacuum-room-cleaning-highlight.md](012-vacuum-room-cleaning-highlight.md) |
|
||||
| [#13](https://github.com/Matysh/houseplan-card/issues/13) Golden open context tray | [013-golden-open-context-tray.md](013-golden-open-context-tray.md) |
|
||||
|
||||
@@ -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,83 @@ 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: ' if (!configAccepted && attempt) {\n'
|
||||
+ ' rollbackOptimistic(this.host, attempt, contentFingerprint);',
|
||||
replace: ' if (attempt) {\n'
|
||||
+ ' this.host._serverCfg = attempt.previous;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
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 FitParams['rot'] })}>${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: { busy: boolean } | null;
|
||||
_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