feat: assign each robot map to a floor in the device editor

User-Visible: yes
Issue: #162
This commit is contained in:
Codex
2026-09-03 19:18:13 +03:00
parent fec99b907b
commit ca6ebc3035
38 changed files with 2169 additions and 1579 deletions
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "0aa082f67d7e4e87ee87e11227a79f9f754d47a470f79cea2084b6001cf18246",
"fingerprint": "d2e52a98c720731b44a0fe51783aa8191a5218f555f1f86893701f28e99f200f",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CRBn43Vb.js",
"houseplan-assets/houseplan-card-C1YZDmj6.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291203,
"initialViewGzipBytes": 292278,
"lazyFiles": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/de-BkROu8Tw.js",
"houseplan-assets/editor-GNX-fEXr.js",
"houseplan-assets/fr-C03DhXy5.js",
"houseplan-assets/houseplan-editor-runtime-CvmR2VuV.js",
"houseplan-assets/houseplan-onboarding-runtime-BVA5mIBD.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/de-DYuCBO-k.js",
"houseplan-assets/editor-CVgkVajn.js",
"houseplan-assets/fr-C2GpV0Jw.js",
"houseplan-assets/houseplan-editor-runtime-Bpo_GpxS.js",
"houseplan-assets/houseplan-onboarding-runtime-DCUDjODM.js"
],
"lazyGzipBytes": 215550,
"lazyGzipBytes": 219265,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/editor-GNX-fEXr.js",
"houseplan-assets/houseplan-editor-runtime-CvmR2VuV.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/editor-CVgkVajn.js",
"houseplan-assets/houseplan-editor-runtime-Bpo_GpxS.js"
],
"lazyEditorGzipBytes": 159870,
"lazyEditorGzipBytes": 163598,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/houseplan-onboarding-runtime-BVA5mIBD.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/houseplan-onboarding-runtime-DCUDjODM.js"
],
"lazyOnboardingGzipBytes": 14127,
"lazyOnboardingGzipBytes": 14135,
"lazyLocaleFiles": [
"houseplan-assets/de-BkROu8Tw.js",
"houseplan-assets/fr-C03DhXy5.js"
"houseplan-assets/de-DYuCBO-k.js",
"houseplan-assets/fr-C2GpV0Jw.js"
],
"lazyLocaleGzipBytes": 48727,
"lazyLocaleGzipBytes": 48711,
"files": [
{
"path": "houseplan-assets/backdrop-pick-D3Vafz-C.js",
"sha256": "01ab21891c6d5f844630b8bebe539f50b3f8d25725baa32017b2638d1341f92f",
"path": "houseplan-assets/backdrop-pick-CfOh3CA7.js",
"sha256": "8e6708537de7c137d4329702fe275413c56b07f14ac1376febfc19065c9fbdd0",
"rawBytes": 20996,
"gzipBytes": 7174,
"gzipBytes": 7179,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BkROu8Tw.js",
"sha256": "c48c3b1e09eae9066e9feac83e80109e089474c344415f27e87b0b737ef93fc3",
"rawBytes": 81632,
"gzipBytes": 24635,
"path": "houseplan-assets/de-DYuCBO-k.js",
"sha256": "d2a1c7f04fdb2e127905f083a6e8581b6ce975a301fe776c6a9f65763d86a8c4",
"rawBytes": 81588,
"gzipBytes": 24627,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-GNX-fEXr.js",
"sha256": "05c249b5b3ead0987635b53de549ec1af7339eb863abf70bcc45d51dd3f2521b",
"path": "houseplan-assets/editor-CVgkVajn.js",
"sha256": "cde4b2f73563f74dc3c66a210b01bd83dd9c8fda30914a02cd845db2feaf029e",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-C03DhXy5.js",
"sha256": "0843d25037a8ab2277a96738ca3d6e97be303b52dbffa1dc6161c4576a9c1edb",
"rawBytes": 83754,
"gzipBytes": 24092,
"path": "houseplan-assets/fr-C2GpV0Jw.js",
"sha256": "f111011a8f45476386a3d0743402d3fad8b5f4d6d3b61be30036aa983f58e3f1",
"rawBytes": 83710,
"gzipBytes": 24084,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CRBn43Vb.js",
"sha256": "9914dd884036dd91d1c2cc663e34bad77cfad3d2a3db34f84baf2e007b1b73a1",
"rawBytes": 1033763,
"gzipBytes": 290407,
"path": "houseplan-assets/houseplan-card-C1YZDmj6.js",
"sha256": "c6de4fca90b84eb8ab47f5e6e5b23d40f9929ca58ac58809cd1be4398e4a1f05",
"rawBytes": 1036950,
"gzipBytes": 291480,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-BkROu8Tw.js",
"houseplan-assets/editor-GNX-fEXr.js",
"houseplan-assets/fr-C03DhXy5.js",
"houseplan-assets/houseplan-editor-runtime-CvmR2VuV.js",
"houseplan-assets/houseplan-onboarding-runtime-BVA5mIBD.js"
"houseplan-assets/de-DYuCBO-k.js",
"houseplan-assets/editor-CVgkVajn.js",
"houseplan-assets/fr-C2GpV0Jw.js",
"houseplan-assets/houseplan-editor-runtime-Bpo_GpxS.js",
"houseplan-assets/houseplan-onboarding-runtime-DCUDjODM.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-CvmR2VuV.js",
"sha256": "4ab71bdc852a95fb7488cbedcc24f69c0203a6aac39f961f3a752561f352e452",
"rawBytes": 576162,
"gzipBytes": 151116,
"path": "houseplan-assets/houseplan-editor-runtime-Bpo_GpxS.js",
"sha256": "0710c7a4a73f7a2532d041984b1294727abc5a1a76a17ab395c9f347ee1a05ab",
"rawBytes": 589868,
"gzipBytes": 154836,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-BVA5mIBD.js",
"sha256": "36ebdfc8bb84657533fba40b9e6df64828d069a0f7be45755c6e859f0c714736",
"path": "houseplan-assets/houseplan-onboarding-runtime-DCUDjODM.js",
"sha256": "37bbf57bbaede7f9ff15617e537978faf5ab76de510a758cb286e016c6717676",
"rawBytes": 28088,
"gzipBytes": 6953,
"gzipBytes": 6956,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "dcebb936b7f104ef69cc667539a7a6c5120c55ef01fd17821aac04b2fa9987a8",
"sha256": "4bdef27d1c8e8265021aca1648b32ba32265ed970b6743f8e9c89942dec5ed32",
"rawBytes": 1183,
"gzipBytes": 796,
"gzipBytes": 798,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa082f67d7e4e87ee87e11227a79f9f754d47a470f79cea2084b6001cf18246";import{b as e,l as o,t,di as s,A as a,dj as i,dk as l,dl as n,E as r,c}from"./houseplan-card-CRBn43Vb.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__="d2e52a98c720731b44a0fe51783aa8191a5218f555f1f86893701f28e99f200f";import{g 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-C1YZDmj6.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa082f67d7e4e87ee87e11227a79f9f754d47a470f79cea2084b6001cf18246";try{await import("./houseplan-assets/houseplan-card-CRBn43Vb.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__="d2e52a98c720731b44a0fe51783aa8191a5218f555f1f86893701f28e99f200f";try{await import("./houseplan-assets/houseplan-card-C1YZDmj6.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)}
+59 -59
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "0aa082f67d7e4e87ee87e11227a79f9f754d47a470f79cea2084b6001cf18246",
"fingerprint": "d2e52a98c720731b44a0fe51783aa8191a5218f555f1f86893701f28e99f200f",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CRBn43Vb.js",
"houseplan-assets/houseplan-card-C1YZDmj6.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291203,
"initialViewGzipBytes": 292278,
"lazyFiles": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/de-BkROu8Tw.js",
"houseplan-assets/editor-GNX-fEXr.js",
"houseplan-assets/fr-C03DhXy5.js",
"houseplan-assets/houseplan-editor-runtime-CvmR2VuV.js",
"houseplan-assets/houseplan-onboarding-runtime-BVA5mIBD.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/de-DYuCBO-k.js",
"houseplan-assets/editor-CVgkVajn.js",
"houseplan-assets/fr-C2GpV0Jw.js",
"houseplan-assets/houseplan-editor-runtime-Bpo_GpxS.js",
"houseplan-assets/houseplan-onboarding-runtime-DCUDjODM.js"
],
"lazyGzipBytes": 215550,
"lazyGzipBytes": 219265,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/editor-GNX-fEXr.js",
"houseplan-assets/houseplan-editor-runtime-CvmR2VuV.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/editor-CVgkVajn.js",
"houseplan-assets/houseplan-editor-runtime-Bpo_GpxS.js"
],
"lazyEditorGzipBytes": 159870,
"lazyEditorGzipBytes": 163598,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/houseplan-onboarding-runtime-BVA5mIBD.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/houseplan-onboarding-runtime-DCUDjODM.js"
],
"lazyOnboardingGzipBytes": 14127,
"lazyOnboardingGzipBytes": 14135,
"lazyLocaleFiles": [
"houseplan-assets/de-BkROu8Tw.js",
"houseplan-assets/fr-C03DhXy5.js"
"houseplan-assets/de-DYuCBO-k.js",
"houseplan-assets/fr-C2GpV0Jw.js"
],
"lazyLocaleGzipBytes": 48727,
"lazyLocaleGzipBytes": 48711,
"files": [
{
"path": "houseplan-assets/backdrop-pick-D3Vafz-C.js",
"sha256": "01ab21891c6d5f844630b8bebe539f50b3f8d25725baa32017b2638d1341f92f",
"path": "houseplan-assets/backdrop-pick-CfOh3CA7.js",
"sha256": "8e6708537de7c137d4329702fe275413c56b07f14ac1376febfc19065c9fbdd0",
"rawBytes": 20996,
"gzipBytes": 7174,
"gzipBytes": 7179,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BkROu8Tw.js",
"sha256": "c48c3b1e09eae9066e9feac83e80109e089474c344415f27e87b0b737ef93fc3",
"rawBytes": 81632,
"gzipBytes": 24635,
"path": "houseplan-assets/de-DYuCBO-k.js",
"sha256": "d2a1c7f04fdb2e127905f083a6e8581b6ce975a301fe776c6a9f65763d86a8c4",
"rawBytes": 81588,
"gzipBytes": 24627,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-GNX-fEXr.js",
"sha256": "05c249b5b3ead0987635b53de549ec1af7339eb863abf70bcc45d51dd3f2521b",
"path": "houseplan-assets/editor-CVgkVajn.js",
"sha256": "cde4b2f73563f74dc3c66a210b01bd83dd9c8fda30914a02cd845db2feaf029e",
"rawBytes": 3826,
"gzipBytes": 1580,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-C03DhXy5.js",
"sha256": "0843d25037a8ab2277a96738ca3d6e97be303b52dbffa1dc6161c4576a9c1edb",
"rawBytes": 83754,
"gzipBytes": 24092,
"path": "houseplan-assets/fr-C2GpV0Jw.js",
"sha256": "f111011a8f45476386a3d0743402d3fad8b5f4d6d3b61be30036aa983f58e3f1",
"rawBytes": 83710,
"gzipBytes": 24084,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CRBn43Vb.js",
"sha256": "9914dd884036dd91d1c2cc663e34bad77cfad3d2a3db34f84baf2e007b1b73a1",
"rawBytes": 1033763,
"gzipBytes": 290407,
"path": "houseplan-assets/houseplan-card-C1YZDmj6.js",
"sha256": "c6de4fca90b84eb8ab47f5e6e5b23d40f9929ca58ac58809cd1be4398e4a1f05",
"rawBytes": 1036950,
"gzipBytes": 291480,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-BkROu8Tw.js",
"houseplan-assets/editor-GNX-fEXr.js",
"houseplan-assets/fr-C03DhXy5.js",
"houseplan-assets/houseplan-editor-runtime-CvmR2VuV.js",
"houseplan-assets/houseplan-onboarding-runtime-BVA5mIBD.js"
"houseplan-assets/de-DYuCBO-k.js",
"houseplan-assets/editor-CVgkVajn.js",
"houseplan-assets/fr-C2GpV0Jw.js",
"houseplan-assets/houseplan-editor-runtime-Bpo_GpxS.js",
"houseplan-assets/houseplan-onboarding-runtime-DCUDjODM.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-CvmR2VuV.js",
"sha256": "4ab71bdc852a95fb7488cbedcc24f69c0203a6aac39f961f3a752561f352e452",
"rawBytes": 576162,
"gzipBytes": 151116,
"path": "houseplan-assets/houseplan-editor-runtime-Bpo_GpxS.js",
"sha256": "0710c7a4a73f7a2532d041984b1294727abc5a1a76a17ab395c9f347ee1a05ab",
"rawBytes": 589868,
"gzipBytes": 154836,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-BVA5mIBD.js",
"sha256": "36ebdfc8bb84657533fba40b9e6df64828d069a0f7be45755c6e859f0c714736",
"path": "houseplan-assets/houseplan-onboarding-runtime-DCUDjODM.js",
"sha256": "37bbf57bbaede7f9ff15617e537978faf5ab76de510a758cb286e016c6717676",
"rawBytes": 28088,
"gzipBytes": 6953,
"gzipBytes": 6956,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-D3Vafz-C.js",
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/backdrop-pick-CfOh3CA7.js",
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "dcebb936b7f104ef69cc667539a7a6c5120c55ef01fd17821aac04b2fa9987a8",
"sha256": "4bdef27d1c8e8265021aca1648b32ba32265ed970b6743f8e9c89942dec5ed32",
"rawBytes": 1183,
"gzipBytes": 796,
"gzipBytes": 798,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CRBn43Vb.js"
"houseplan-assets/houseplan-card-C1YZDmj6.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa082f67d7e4e87ee87e11227a79f9f754d47a470f79cea2084b6001cf18246";import{b as e,l as o,t,di as s,A as a,dj as i,dk as l,dl as n,E as r,c}from"./houseplan-card-CRBn43Vb.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__="d2e52a98c720731b44a0fe51783aa8191a5218f555f1f86893701f28e99f200f";import{g 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-C1YZDmj6.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa082f67d7e4e87ee87e11227a79f9f754d47a470f79cea2084b6001cf18246";try{await import("./houseplan-assets/houseplan-card-CRBn43Vb.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__="d2e52a98c720731b44a0fe51783aa8191a5218f555f1f86893701f28e99f200f";try{await import("./houseplan-assets/houseplan-card-C1YZDmj6.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)}
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- The device editor now has a “Maps and floors” block: each map a robot vacuum
reports can be assigned to a space, calibrated against that space and moved or
removed later, with the dock left where it is
([#162](https://github.com/Matysh/houseplan-card/issues/162)).
- A robot vacuum with several maps can now show each map on its own floor: the
dock stays in its own space while the live position and the trail appear in
the space the active map is assigned to, and a map that is unassigned,
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- В редакторе устройства появился блок «Карты и этажи»: каждую карту робота
можно сопоставить пространству, откалибровать её именно в этом пространстве, а
позже перенести или удалить — док при этом остаётся на своём месте
([#162](https://github.com/Matysh/houseplan-card/issues/162)).
- Робот-пылесос с несколькими картами теперь показывается на своём этаже: док
остаётся в своём пространстве, а живое положение и след рисуются в том
пространстве, которому назначена активная карта; несопоставленная,
+13 -1
View File
@@ -79,8 +79,20 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* 291 069 при потолке 292 000 — это 931 Б до отказа сверху и 1 069 Б снизу.
* Иначе одна из сторон срабатывает на десятках байт, то есть на шуме: правило
* должно требовать решения от РЕАЛЬНОГО изменения, а не от переименования.
*
* 2026-09-03, #162: потолок поднят 292 000 → 293 000. Причина — маршрутизация
* карт робота по пространствам: `src/vacuum-routes.ts` обязан быть в
* initial-графе, потому что решение «какая карта на каком этаже» принимает
* View, а не редактор. Замеренная цена 1 075 Б gzip (291 203 → 292 278).
* Редакторская половина при этом в initial-граф НЕ попала: правки маршрутов
* вынесены в `src/vacuum-route-edit.ts`, редакторский UI — в
* `src/editors/vacuum-maps-section.ts`, а тексты блока — в ленивый словарь
* `src/i18n/support/*.json` (приём #423). Эти три выноса дали −1 326 Б от
* первой редакции правки (293 604 → 292 278); без них рост был бы вдвое
* больше. Факт 292 278 лежит в 722 Б от нового потолка и в 1 278 Б от нижней
* границы полосы — обе стороны дальше шума.
*/
export const INITIAL_VIEW_GZIP_CEILING = 292_000;
export const INITIAL_VIEW_GZIP_CEILING = 293_000;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
+1 -1
View File
@@ -3521,7 +3521,7 @@ const MUTANT_DEFINITIONS = [
+ 'of rotating into previous; focused pure and recorder sequences must reject the old unconditional split',
patches: [{
file: 'custom_components/houseplan/trails.py',
find: ' resumed = bool(cur and can_resume_trail_run(cur, map_id, now))',
find: ' resumed = bool(cur and can_resume_trail_run(cur, map_id, now, route_id))',
replace: ' resumed = False',
}],
},
+181
View File
@@ -0,0 +1,181 @@
/**
* Device editor: "Maps and floors" for a multi-floor robot (#162).
*
* Lives outside the editor runtime on purpose. The runtime is at its size
* ceiling (test/core-file-budget.test.mjs), and this block is only ever needed
* with an opened device dialog, so it belongs in the lazy editor graph rather
* than in a core file.
*/
import { TemplateResult, html, nothing } from 'lit';
import { langOf } from '../i18n';
import { supportT, type SupportI18nKey } from '../i18n/support';
import { VacuumMapRoute, effectiveRoutes, observedMapIds, resolveRoute } from '../vacuum-routes';
import {
addRoute, changeRouteSpace, convertLegacyRoutes, newRouteId, removeRoute,
} from '../vacuum-route-edit';
export interface VacuumMapsHost {
host: any;
_saveConfig: () => void;
_vacAutoCalibrate: (dev: any) => void;
_vacStartFit: (dev: any, routeId?: string) => void;
}
const spaceName = (host: any, spaceId: string): string => {
const space = (host._serverCfg?.spaces || []).find((item: any) => item?.id === spaceId);
return String(space?.name || space?.id || spaceId);
};
/**
* Render the block, and own every routing edit it offers.
*
* `setVac` is the same patch writer the rest of the vacuum section uses, so a
* routing change goes through one save path with everything else.
*/
export function renderVacuumMapsSection(
runtime: VacuumMapsHost, dev: any, setVac: (patch: Record<string, unknown>) => void,
): TemplateResult | typeof nothing {
const host = runtime.host;
const lang = langOf(host.hass, host._config?.language);
const t = (key: string, vars?: Record<string, string>) => supportT(
lang, key as SupportI18nKey, vars);
const vacuum = dev.marker?.vacuum || {};
const explicit = Array.isArray(vacuum.map_routes) && vacuum.map_routes.length > 0;
const rootSource: string = host._vacSource(dev) || '';
const routes: VacuumMapRoute[] = effectiveRoutes(dev.id, vacuum, dev.space, rootSource);
const spaces: Array<{ id: string; name: string }> = (host._serverCfg?.spaces || [])
.filter((space: any) => typeof space?.id === 'string' && space.id)
.map((space: any) => ({ id: space.id, name: String(space.name || space.id) }));
const spaceIds = new Set(spaces.map((space) => space.id));
const observed = observedMapIds(routes, [rootSource],
(source) => host._vacObservedMapId(dev, source));
const resolution = resolveRoute({ routes, observed, spaceIds });
const activeId = (resolution.kind === 'ready' || resolution.kind === 'needs_calibration'
|| resolution.kind === 'missing_space') ? resolution.route.id : '';
const currentMapId = rootSource ? observed[rootSource] : undefined;
/** Every routing edit converts legacy data first — all of it, or none. */
const writeRoutes = (next: (current: VacuumMapRoute[]) => VacuumMapRoute[]): boolean => {
let base: VacuumMapRoute[] | null = explicit ? vacuum.map_routes : null;
if (!base) {
base = convertLegacyRoutes(vacuum, dev.space, rootSource,
(taken) => newRouteId(taken)) ?? [];
if (!rootSource && Object.keys(vacuum.calibration || {}).length) return false;
}
setVac({ map_routes: next(base), calibration: undefined });
return true;
};
const takenIds = () => new Set(routes.map((route) => route.id));
const addCurrent = () => {
if (!rootSource || currentMapId === undefined) return;
writeRoutes((current) => addRoute(
current,
{
source: rootSource,
map_id: currentMapId,
space: current.length ? '' : dev.space,
},
newRouteId(takenIds()),
));
};
const retarget = async (route: VacuumMapRoute, space: string) => {
if (!space || space === route.space) return;
const accepted = await host._confirmDanger({
key: 'vacuum_route_space',
kind: 'warning',
title: t('vac.route_space_title'),
message: t('vac.route_space_body'),
objectName: route.map_id || t('vac.route_map_default'),
confirmLabel: t('vac.route_space_confirm'),
cancelLabel: host._t('btn.cancel'),
});
if (!accepted) return;
writeRoutes((current) => changeRouteSpace(current, route.id, space, newRouteId(takenIds())));
};
const drop = async (route: VacuumMapRoute) => {
const accepted = await host._confirmDanger({
key: 'vacuum_route_delete',
kind: 'danger',
title: t('vac.route_delete_title'),
message: t('vac.route_delete_body'),
objectName: route.map_id || t('vac.route_map_default'),
confirmLabel: t('vac.route_delete_confirm'),
cancelLabel: host._t('btn.cancel'),
});
if (!accepted) return;
writeRoutes((current) => removeRoute(current, route.id));
};
// Space order, then map id, then source: a stable reading order that never
// depends on the order the routes happen to be stored in.
const order = new Map(spaces.map((space, index) => [space.id, index]));
const rows = [...routes].sort((a, b) => (order.get(a.space) ?? 1e6) - (order.get(b.space) ?? 1e6)
|| a.map_id.localeCompare(b.map_id) || a.source.localeCompare(b.source));
const statusOf = (route: VacuumMapRoute): string => {
if (!spaceIds.has(route.space)) return t('vac.route_status_missing_space');
if (!route.calibration) return t('vac.route_status_needs_calibration');
if (resolution.kind === 'ambiguous' && resolution.routeIds.includes(route.id)) {
return t('vac.route_status_ambiguous');
}
return t('vac.route_status_ready');
};
const canAddCurrent = !!rootSource && currentMapId !== undefined
&& !routes.some((route) => route.source === rootSource && route.map_id === currentMapId);
return html`
<label>${t('vac.routes_section')}</label>
<div class="bindbox vacroutes">
<div class="vacdiag" role="status">
<div><span>${t('vac.routes_current_map')}</span><b>${currentMapId === undefined
? t('vac.routes_no_map') : (currentMapId || t('vac.route_map_default'))}</b></div>
<div><span>${t('vac.routes_status')}</span><b>${t(`vac.routes_state_${resolution.kind}`)}</b></div>
</div>
${rows.length ? html`<ul class="vacroute-list">
${rows.map((route) => html`
<li class="vacroute ${route.id === activeId ? 'on' : ''}">
<div class="vacroute-head">
<b>${route.map_id || t('vac.route_map_default')}</b>
${route.id === activeId
? html`<span class="vacroute-active">${t('vac.route_active')}</span>` : nothing}
</div>
<small>${route.source}</small>
<div class="vacroute-status">${statusOf(route)}</div>
<label class="srcrow">
<span>${t('vac.route_space')}</span>
<select class="areasel" @change=${(event: Event) => {
const select = event.target as HTMLSelectElement;
const next = select.value;
select.value = route.space;
void retarget(route, next);
}}>
<option value="" ?selected=${!route.space}>${t('vac.route_space_none')}</option>
${spaces.map((space) => html`
<option value=${space.id} ?selected=${space.id === route.space}>${space.name}</option>`)}
</select>
</label>
<div class="vacbtns">
<button type="button" class="btn ghostbtn"
@click=${() => runtime._vacStartFit(dev, route.id)}>${host._t('vac.fit')}</button>
<button type="button" class="btn ghostbtn danger"
@click=${() => void drop(route)}>${t('vac.route_delete')}</button>
</div>
</li>`)}
</ul>` : html`<div class="rhint">${t('vac.routes_empty')}</div>`}
<div class="vacbtns">
<button type="button" class="btn" ?disabled=${!canAddCurrent}
@click=${addCurrent}>${t('vac.route_add_current')}</button>
</div>
${rows.some((route) => !spaceIds.has(route.space)) ? html`
<div class="warn">${t('vac.route_missing_space_hint')}</div>` : nothing}
${routes.some((route) => route.map_id === 'default') ? html`
<div class="rhint">${t('vac.route_default_hint')}</div>` : nothing}
${rows.length ? html`<div class="rhint">${t('vac.route_target_hint', {
space: spaceName(host, dev.space),
})}</div>` : nothing}
</div>`;
}
+1 -1
View File
@@ -2503,7 +2503,7 @@ export class HouseplanCard extends LitElement {
};
private _resumeSettling = false;
private _viewportInvalidAt = 0;
private _vacFit: { markerId: string; source: string; mapId: string; p: FitParams;
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;
/** Marker whose lazy «All cameras» candidate section is expanded. */
+19 -18
View File
@@ -8,6 +8,8 @@
*/
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
import { guard } from 'lit/directives/guard.js';
import { renderVacuumMapsSection } from './editors/vacuum-maps-section';
import { planVacuumFit, writeVacuumMatrix } from './vacuum-route-edit';
import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog';
import type { HpDialog } from './hp-dialog';
@@ -1149,7 +1151,7 @@ export interface HouseplanEditorHostPort {
_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; p: FitParams; drag: null | { kind: "move" | "scale"; sx: number; sy: number; p0: FitParams; fx: number; fy: number; }; } | null;
_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;
_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;
@@ -10850,7 +10852,6 @@ public _renderVacSection(dlg: any): TemplateResult | typeof nothing {
const roomMatches = tele
? vacRoomNameMatchCount(tele.rooms, planRooms.map((room) => room.name)) : 0;
const tierA = !!(canUseSource && tele && roomMatches >= 3);
const cals = Object.keys(v.calibration || {});
const setVac = (patch: Record<string, unknown>) => {
// HP-1540-01: materialise the marker first — find() alone silently
// dropped every edit for a vacuum that had never been saved
@@ -10967,7 +10968,7 @@ public _renderVacSection(dlg: any): TemplateResult | typeof nothing {
${(['never', 'cleaning', 'always'] as const).map((mv) => html`
<option value=${mv} ?selected=${vacTrailMode(v) === mv}>${this.host._t(('vac.trail_' + mv) as any)}</option>`)}
</select>
${cals.length ? html`<div class="rhint">${subst(this.host._t('vac.cal_maps'), { maps: cals.join(', ') })}</div>` : nothing}
${renderVacuumMapsSection(this, dev, setVac)}
` : nothing}
</div>`;
}
@@ -10978,16 +10979,17 @@ 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): boolean {
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;
const v = { ...(m.vacuum || {}) };
v.source = source;
v.calibration = { ...(v.calibration || {}), [mapId]: matrix.map((n) => Number(n.toFixed(6))) };
m.vacuum = v;
// #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 }) as any;
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this._saveConfig();
@@ -11061,7 +11063,7 @@ public _vacApplyCalibrationProposal(manual: boolean): void {
)) this.host._showToast(subst(this.host._t('vac.autocal_done'), { rooms: String(proposal.rooms) }));
}
public _vacStartFit(d: DevItem): void {
public _vacStartFit(d: DevItem, routeId = ''): void {
const src = this.host._vacSource(d);
const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null;
if (!src || !tele) {
@@ -11069,22 +11071,21 @@ public _vacStartFit(d: DevItem): void {
return;
}
const mapId = this._vacMapId(d, tele);
const existing = d.marker?.vacuum?.calibration?.[mapId] as Affine | undefined;
const sp = this.host._spaceModelById(d.space);
if (!sp) return;
const vb = sp.vb as [number, number, number, number];
const p = (existing && existing.length === 6 && fitFromMatrix(existing))
|| initialFit(tele.rooms, vb);
const plan = planVacuumFit(d.id, d.marker?.vacuum ?? null, {
routeId, source: src, mapId, dockSpace: d.space, rooms: tele.rooms,
viewBoxOf: (id) => (this.host._spaceModelById(id)?.vb as [number, number, number, number]) ?? null,
});
if (!plan) return;
this.host._markerDialog = null;
if (d.space !== this.host._space && !this.host._commitSpace(d.space)) return;
this.host._vacFit = { markerId: d.id, source: src, mapId, p, drag: null };
if (plan.space !== this.host._space && !this.host._commitSpace(plan.space)) return;
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));
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'));
}
-1
View File
@@ -814,7 +814,6 @@
"vac.autocal": "Automatisch einrichten",
"vac.live": "Aktuelle Position im Plan",
"vac.trail": "Roboterpfad anzeigen",
"vac.cal_maps": "Kalibrierte Karten: {maps}",
"vac.autocal_no_rooms": "Die Integration meldet keine Raumliste — öffnen Sie \"Manuell anpassen\"",
"vac.autocal_no_match": "Raumnamen stimmten nicht überein (mindestens 3 müssen übereinstimmen) — öffnen Sie \"Manuell anpassen\"",
"vac.autocal_done": "Fertig: über {rooms} Räume zugeordnet. Eine Reinigung starten und das Ergebnis prüfen.",
-1
View File
@@ -814,7 +814,6 @@
"vac.autocal": "Set up automatically",
"vac.live": "Live position on the plan",
"vac.trail": "Show the robot's path",
"vac.cal_maps": "Calibrated maps: {maps}",
"vac.autocal_no_rooms": "The integration reports no room list — open “Fit manually”",
"vac.autocal_no_match": "Room names did not match (need ≥3 in common) — open “Fit manually”",
"vac.autocal_done": "Done: bound via {rooms} rooms. Start a cleanup and check",
-1
View File
@@ -814,7 +814,6 @@
"vac.autocal": "Configurer automatiquement",
"vac.live": "Position en direct sur le plan",
"vac.trail": "Afficher le trajet du robot",
"vac.cal_maps": "Cartes calibrées : {maps}",
"vac.autocal_no_rooms": "L’intégration ne fournit aucune liste de pièces — ouvrez « Ajuster manuellement »",
"vac.autocal_no_match": "Les noms de pièces ne correspondent pas (il en faut ≥3 en commun) — ouvrez « Ajuster manuellement »",
"vac.autocal_done": "Terminé : association via {rooms} pièces. Lancez un nettoyage et vérifiez",
-1
View File
@@ -814,7 +814,6 @@
"vac.autocal": "Настроить автоматически",
"vac.live": "Живая позиция на плане",
"vac.trail": "Показывать путь робота",
"vac.cal_maps": "Откалиброваны карты: {maps}",
"vac.autocal_no_rooms": "Интеграция не отдаёт список комнат — откройте «Подогнать вручную»",
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — откройте «Подогнать вручную»",
"vac.autocal_done": "Готово: привязка по {rooms} комнатам. Запустите уборку и проверьте",
+31 -1
View File
@@ -42,5 +42,35 @@
"support.error.support_rate_limited": "Zu viele Berichte oder Vorschauen wurden angefordert. Versuchen Sie es später erneut.",
"support.error.support_unavailable": "Der private Supportdienst ist vorübergehend nicht verfügbar. Der Entwurf bleibt in diesem Fenster erhalten.",
"support.error.support_rejected": "Der Bericht hat die Prüfung des Supportdienstes nicht bestanden.",
"support.error.unauthorized": "Sie dürfen diesen Bericht nicht senden."
"support.error.unauthorized": "Sie dürfen diesen Bericht nicht senden.",
"vac.routes_section": "Karten und Etagen",
"vac.routes_current_map": "Aktuelle Karte",
"vac.routes_no_map": "keine Telemetrie",
"vac.routes_status": "Zuordnung",
"vac.routes_state_ready": "Karte zugeordnet und kalibriert",
"vac.routes_state_needs_calibration": "Karte zugeordnet, Kalibrierung nötig",
"vac.routes_state_unmapped": "aktuelle Karte keiner Etage zugeordnet",
"vac.routes_state_ambiguous": "zwei Karten nicht unterscheidbar — keine Etage gewählt",
"vac.routes_state_missing_space": "der Raum der Karte wurde gelöscht",
"vac.routes_state_none": "keine Kartendaten",
"vac.routes_empty": "Noch keine Karte einer Etage zugeordnet.",
"vac.route_map_default": "ohne Karten-ID",
"vac.route_active": "Aktiv",
"vac.route_space": "Raum",
"vac.route_space_none": "nicht gewählt",
"vac.route_status_ready": "bereit",
"vac.route_status_needs_calibration": "Kalibrierung nötig",
"vac.route_status_ambiguous": "mehrdeutig",
"vac.route_status_missing_space": "Raum gelöscht",
"vac.route_add_current": "Aktuelle Karte hinzufügen",
"vac.route_delete": "Karte entfernen",
"vac.route_delete_title": "Karte von der Etage entfernen?",
"vac.route_delete_body": "Kalibrierung und gespeicherte Pfade dieser Karte werden entfernt. Fortfahren?",
"vac.route_delete_confirm": "Entfernen",
"vac.route_space_title": "Karte in einen anderen Raum verschieben?",
"vac.route_space_body": "Kalibrierung und gespeicherte Pfade dieser Karte werden entfernt. Fortfahren?",
"vac.route_space_confirm": "Verschieben",
"vac.route_missing_space_hint": "Eine Karte verweist auf einen gelöschten Raum: neuen wählen oder Karte entfernen. Bis dahin wird der Roboter für diese Karte nicht gezeichnet.",
"vac.route_default_hint": "Die Quelle meldet keine stabile Karten-ID. Mehrere Etagen lassen sich ohne andere Quelle oder die vom Roboter gewählte Karte nicht unterscheiden.",
"vac.route_target_hint": "Die Station bleibt unabhängig von der aktuellen Karte im Raum „{space}“."
}
+31 -1
View File
@@ -42,5 +42,35 @@
"support.error.support_rate_limited": "Too many reports or previews were requested. Try again later.",
"support.error.support_unavailable": "The private support service is temporarily unavailable. Your draft remains here.",
"support.error.support_rejected": "The report did not pass the support service validation.",
"support.error.unauthorized": "You do not have permission to send this report."
"support.error.unauthorized": "You do not have permission to send this report.",
"vac.routes_section": "Maps and floors",
"vac.routes_current_map": "Current map",
"vac.routes_no_map": "no telemetry",
"vac.routes_status": "Routing",
"vac.routes_state_ready": "map assigned and calibrated",
"vac.routes_state_needs_calibration": "map assigned, calibration needed",
"vac.routes_state_unmapped": "current map is not assigned to a floor",
"vac.routes_state_ambiguous": "two maps are indistinguishable — no floor is chosen",
"vac.routes_state_missing_space": "the map's space was deleted",
"vac.routes_state_none": "no map data",
"vac.routes_empty": "No map is assigned to a floor yet.",
"vac.route_map_default": "no map id",
"vac.route_active": "Active",
"vac.route_space": "Space",
"vac.route_space_none": "not selected",
"vac.route_status_ready": "ready",
"vac.route_status_needs_calibration": "needs calibration",
"vac.route_status_ambiguous": "ambiguous",
"vac.route_status_missing_space": "space deleted",
"vac.route_add_current": "Add current map",
"vac.route_delete": "Remove map",
"vac.route_delete_title": "Remove the map from its floor?",
"vac.route_delete_body": "Calibration and saved paths for this map will be removed. Continue?",
"vac.route_delete_confirm": "Remove",
"vac.route_space_title": "Move the map to another space?",
"vac.route_space_body": "Calibration and saved paths for this map will be removed. Continue?",
"vac.route_space_confirm": "Move",
"vac.route_missing_space_hint": "One of the maps points at a deleted space: pick a new one or remove the map. Until then the robot is not drawn for that map.",
"vac.route_default_hint": "The source reports no stable map id. Several floors cannot be told apart without another source or the robot's own selected map.",
"vac.route_target_hint": "The dock stays in the “{space}” space regardless of the current map."
}
+31 -1
View File
@@ -42,5 +42,35 @@
"support.error.support_rate_limited": "Trop de rapports ou d’aperçus ont été demandés. Réessayez plus tard.",
"support.error.support_unavailable": "Le service d’assistance privé est temporairement indisponible. Le brouillon reste dans cette fenêtre.",
"support.error.support_rejected": "Le rapport n’a pas passé la validation du service d’assistance.",
"support.error.unauthorized": "Vous n’êtes pas autorisé à envoyer ce rapport."
"support.error.unauthorized": "Vous n’êtes pas autorisé à envoyer ce rapport.",
"vac.routes_section": "Cartes et étages",
"vac.routes_current_map": "Carte actuelle",
"vac.routes_no_map": "aucune télémétrie",
"vac.routes_status": "Routage",
"vac.routes_state_ready": "carte assignée et calibrée",
"vac.routes_state_needs_calibration": "carte assignée, calibrage requis",
"vac.routes_state_unmapped": "carte actuelle non assignée à un étage",
"vac.routes_state_ambiguous": "deux cartes indiscernables — aucun étage choisi",
"vac.routes_state_missing_space": "l'espace de la carte a été supprimé",
"vac.routes_state_none": "aucune donnée de carte",
"vac.routes_empty": "Aucune carte n'est encore assignée à un étage.",
"vac.route_map_default": "sans identifiant",
"vac.route_active": "Active",
"vac.route_space": "Espace",
"vac.route_space_none": "non sélectionné",
"vac.route_status_ready": "prête",
"vac.route_status_needs_calibration": "calibrage requis",
"vac.route_status_ambiguous": "ambiguë",
"vac.route_status_missing_space": "espace supprimé",
"vac.route_add_current": "Ajouter la carte actuelle",
"vac.route_delete": "Supprimer la carte",
"vac.route_delete_title": "Retirer la carte de son étage ?",
"vac.route_delete_body": "Le calibrage et les trajets enregistrés de cette carte seront supprimés. Continuer ?",
"vac.route_delete_confirm": "Supprimer",
"vac.route_space_title": "Déplacer la carte vers un autre espace ?",
"vac.route_space_body": "Le calibrage et les trajets enregistrés de cette carte seront supprimés. Continuer ?",
"vac.route_space_confirm": "Déplacer",
"vac.route_missing_space_hint": "Une carte pointe vers un espace supprimé : choisissez-en un autre ou supprimez la carte. D'ici là, le robot n'est pas dessiné pour cette carte.",
"vac.route_default_hint": "La source ne fournit pas d'identifiant de carte stable. Plusieurs étages ne peuvent pas être distingués sans une autre source ou la carte sélectionnée du robot.",
"vac.route_target_hint": "La station reste dans l'espace « {space} » quelle que soit la carte actuelle."
}
+31 -1
View File
@@ -42,5 +42,35 @@
"support.error.support_rate_limited": "Запрошено слишком много репортов или предпросмотров. Повторите позже.",
"support.error.support_unavailable": "Закрытая служба поддержки временно недоступна. Черновик сохранён в этом окне.",
"support.error.support_rejected": "Репорт не прошёл проверку службы поддержки.",
"support.error.unauthorized": "У вас нет прав на отправку этого репорта."
"support.error.unauthorized": "У вас нет прав на отправку этого репорта.",
"vac.routes_section": "Карты и этажи",
"vac.routes_current_map": "Текущая карта",
"vac.routes_no_map": "источник молчит",
"vac.routes_status": "Маршрутизация",
"vac.routes_state_ready": "карта сопоставлена и откалибрована",
"vac.routes_state_needs_calibration": "карта сопоставлена, нужна калибровка",
"vac.routes_state_unmapped": "текущая карта не сопоставлена этажу",
"vac.routes_state_ambiguous": "две карты неразличимы — этаж не выбирается",
"vac.routes_state_missing_space": "пространство карты удалено",
"vac.routes_state_none": "нет данных о карте",
"vac.routes_empty": "Ни одна карта ещё не сопоставлена этажу.",
"vac.route_map_default": "без идентификатора",
"vac.route_active": "Текущая",
"vac.route_space": "Пространство",
"vac.route_space_none": "не выбрано",
"vac.route_status_ready": "готова",
"vac.route_status_needs_calibration": "нужна калибровка",
"vac.route_status_ambiguous": "неоднозначна",
"vac.route_status_missing_space": "пространство удалено",
"vac.route_add_current": "Добавить текущую карту",
"vac.route_delete": "Удалить карту",
"vac.route_delete_title": "Удалить карту с этажа?",
"vac.route_delete_body": "Калибровка и сохранённые пути этой карты будут удалены. Продолжить?",
"vac.route_delete_confirm": "Удалить",
"vac.route_space_title": "Перенести карту в другое пространство?",
"vac.route_space_body": "Калибровка и сохранённые пути этой карты будут удалены. Продолжить?",
"vac.route_space_confirm": "Перенести",
"vac.route_missing_space_hint": "Одна из карт ссылается на удалённое пространство: выберите новое или удалите карту. Пока ссылка не исправлена, робот на этой карте не рисуется.",
"vac.route_default_hint": "Источник не сообщает стабильный идентификатор карты. Несколько этажей нельзя различить без другого источника или собственной карты робота.",
"vac.route_target_hint": "Док остаётся в пространстве «{space}» независимо от текущей карты."
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Editor-only half of vacuum map routing (#162).
*
* Adding, re-targeting and deleting routes only ever happens in the device
* editor, so these helpers live in the lazy editor graph: the View card must
* not pay for code it can never run (docs/ARCHITECTURE.md, #367).
*/
import { FitParams, fitFromMatrix, initialFit, VacRoom } from './vacuum';
import {
Affine, VacuumMapRoute, VacuumRouteMarkerCfg, effectiveRoutes, normalizeRouteMatrix,
} from './vacuum-routes';
/** A marker-local id that no existing route uses. */
export function newRouteId(existing: Iterable<string>, seed: () => number = Math.random): string {
const taken = new Set(existing);
for (let attempt = 0; attempt < 64; attempt += 1) {
const id = 'vr_' + Math.floor(seed() * 0xffffff).toString(36).padStart(5, '0');
if (!taken.has(id)) return id;
}
return 'vr_' + Date.now().toString(36);
}
export function addRoute(
routes: VacuumMapRoute[], route: Omit<VacuumMapRoute, 'id'>, id: string,
): VacuumMapRoute[] {
return [...routes, { ...route, id, calibration: route.calibration ?? null }];
}
export function removeRoute(routes: VacuumMapRoute[], routeId: string): VacuumMapRoute[] {
return routes.filter((route) => route.id !== routeId);
}
/**
* Re-target a route to another space.
*
* A new space is a NEW identity, not an edited one: the calibration was solved
* against the old space's geometry and the recorded runs were filed under the
* old id, so both must go rather than silently apply to a different floor.
*/
export function changeRouteSpace(
routes: VacuumMapRoute[], routeId: string, space: string, newId: string,
): VacuumMapRoute[] {
return routes.map((route) => (route.id === routeId
? { ...route, id: newId, space, calibration: null } : route));
}
export function saveRouteCalibration(
routes: VacuumMapRoute[], routeId: string, matrix: Affine,
): VacuumMapRoute[] {
return routes.map((route) => (route.id === routeId ? { ...route, calibration: matrix } : route));
}
/**
* Turn a legacy `calibration` dictionary into explicit routes — all of it.
*
* Converting only the map the user happens to be looking at would drop the
* other floors' calibrations on the first routing edit, so this is all or
* nothing: without an exact source there is no conversion at all and the
* caller must ask for one first (spec 7.3).
*/
export function convertLegacyRoutes(
marker: VacuumRouteMarkerCfg | null | undefined,
dockSpace: string,
source: string,
nextId: (taken: Set<string>) => string,
): VacuumMapRoute[] | null {
if (!source) return null;
const calibration = marker?.calibration;
if (!calibration || typeof calibration !== 'object') return null;
const taken = new Set<string>();
const routes: VacuumMapRoute[] = [];
for (const mapId of Object.keys(calibration)) {
const matrix = normalizeRouteMatrix(calibration[mapId]);
if (!matrix) continue;
const id = nextId(taken);
taken.add(id);
routes.push({ id, source, map_id: mapId, space: dockSpace, calibration: matrix });
}
return routes.length ? routes : null;
}
/**
* Where a solved matrix belongs.
*
* With explicit routes the matrix is the route's own property; an unmapped map
* still falls back to the Stage 1 dictionary, because refusing to save a
* calibration the user just solved would lose real work over bookkeeping.
*/
export function writeVacuumMatrix(
vacuum: VacuumRouteMarkerCfg | null | undefined,
opts: { source: string; mapId: string; routeId?: string; matrix: Affine },
): VacuumRouteMarkerCfg {
const rounded = opts.matrix.map((n) => Number(n.toFixed(6))) as Affine;
const routes = vacuum?.map_routes;
if (Array.isArray(routes) && routes.length) {
const target = routes.find((route) => route.id === opts.routeId)
|| routes.find((route) => route.source === opts.source && route.map_id === opts.mapId);
if (target) {
return { ...vacuum, map_routes: saveRouteCalibration(routes, target.id, rounded) };
}
}
return {
...vacuum,
source: opts.source,
calibration: { ...(vacuum?.calibration || {}), [opts.mapId]: rounded },
};
}
export interface VacuumFitPlan {
space: string;
routeId: string;
params: FitParams;
}
/**
* Which space the manual fit canvas opens on, and from which matrix.
*
* Calibration is solved against the geometry of the space the map is assigned
* to — not the dock's space, which is where a multi-floor robot's fit used to
* land regardless of the map on screen (#162).
*/
export function planVacuumFit(
markerId: string,
vacuum: VacuumRouteMarkerCfg | null | undefined,
opts: {
routeId?: string; source: string; mapId: string; dockSpace: string; rooms: VacRoom[];
viewBoxOf: (spaceId: string) => [number, number, number, number] | null;
},
): VacuumFitPlan | null {
const routes = effectiveRoutes(markerId, vacuum, opts.dockSpace, opts.source);
const route = routes.find((item) => item.id === opts.routeId)
|| routes.find((item) => item.source === opts.source && item.map_id === opts.mapId)
|| null;
const space = route?.space || opts.dockSpace;
const viewBox = opts.viewBoxOf(space);
if (!viewBox) return null;
const stored = normalizeRouteMatrix(route ? route.calibration : vacuum?.calibration?.[opts.mapId]);
const params = (stored && fitFromMatrix(stored)) || initialFit(opts.rooms, viewBox);
return { space, routeId: route?.id || '', params };
}
+2
View File
@@ -11,6 +11,8 @@
* each guessing a floor. Nothing here touches the DOM, `hass` or storage.
*/
import { FitParams, fitFromMatrix, initialFit, VacRoom } from './vacuum';
export type Affine = [number, number, number, number, number, number];
/** One saved answer to "this exact map of this exact source lives here". */
+1 -1
View File
@@ -130,7 +130,7 @@ test('i18n: lazy editor dictionaries carry matching keys and placeholders', () =
const supportEn = supportDictionaries.get('en');
const keys = Object.keys(supportEn).sort();
const placeholders = (value) => (String(value).match(/\{\w+\}/gu) || []).sort();
assert.equal(keys.length, 44);
assert.equal(keys.length, 74);
assert.equal(Object.keys(en).filter((key) => key.startsWith('support.')).join(','), 'support.title');
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = supportDictionaries.get(code);
+48
View File
@@ -6,6 +6,10 @@ import {
normalizeRouteMatrix, isEntityIdLike, VAC_ROUTE_LIMIT, VAC_ROUTE_ERROR,
observedMapIds, runRoute, planVacuumOverlay,
} from '../test-build/vacuum-routes.js';
import {
newRouteId, addRoute, removeRoute, changeRouteSpace, saveRouteCalibration, convertLegacyRoutes,
writeVacuumMatrix, planVacuumFit,
} from '../test-build/vacuum-route-edit.js';
const fixture = (name) => JSON.parse(readFileSync(
new URL(`./fixtures/vacuum-routes/${name}.json`, import.meta.url), 'utf8',
@@ -238,3 +242,47 @@ test('легаси-конфиг сохраняет прежнее правило
});
assert.deepEqual(same.previous, M2, 'прогон активной карты виден, как и раньше');
});
test('новый id маршрута не сталкивается с существующими', () => {
const values = [0.5, 0.5, 0.9];
let i = 0;
const first = newRouteId([], () => values[i++]);
const second = newRouteId([first], () => values[i++]);
assert.match(first, /^vr_/);
assert.notEqual(second, first, 'занятый id пропускается');
});
test('смена пространства — это новая идентичность без калибровки', () => {
const before = [
{ id: 'r1', source: 'camera.robot', map_id: 'm1', space: 'floor1', calibration: M1 },
{ id: 'r2', source: 'camera.robot', map_id: 'm2', space: 'floor2', calibration: M2 },
];
const after = changeRouteSpace(before, 'r1', 'floor3', 'vr_new');
assert.deepEqual(after[0], {
id: 'vr_new', source: 'camera.robot', map_id: 'm1', space: 'floor3', calibration: null,
});
assert.deepEqual(after[1], before[1], 'соседний маршрут не тронут');
assert.deepEqual(before[0].calibration, M1, 'вход не мутирован');
});
test('добавление, удаление и запись матрицы маршрута', () => {
const added = addRoute([], { source: 'camera.robot', map_id: 'm1', space: 'floor1' }, 'r1');
assert.deepEqual(added, [{ id: 'r1', source: 'camera.robot', map_id: 'm1', space: 'floor1', calibration: null }]);
const calibrated = saveRouteCalibration(added, 'r1', M2);
assert.deepEqual(calibrated[0].calibration, M2);
assert.deepEqual(added[0].calibration, null, 'вход не мутирован');
assert.deepEqual(removeRoute(calibrated, 'r1'), []);
assert.deepEqual(removeRoute(calibrated, 'нет такого'), calibrated);
});
test('конверсия легаси переносит ВСЕ матрицы или ничего (AC13)', () => {
const marker = { source: 'camera.robot', calibration: { m1: M1, m2: M2, bad: [1, 2] } };
let n = 0;
const routes = convertLegacyRoutes(marker, 'floor1', 'camera.robot', () => `vr_${n++}`);
assert.equal(routes.length, 2, 'обе валидные карты');
assert.deepEqual(routes.map((r) => r.map_id), ['m1', 'm2']);
assert.ok(routes.every((r) => r.space === 'floor1' && r.source === 'camera.robot'));
assert.deepEqual(routes.map((r) => r.calibration), [M1, M2]);
assert.equal(convertLegacyRoutes(marker, 'floor1', '', () => 'vr'), null, 'без источника конверсии нет');
assert.equal(convertLegacyRoutes({ calibration: {} }, 'floor1', 'camera.robot', () => 'vr'), null);
});
+1 -1
View File
@@ -13,7 +13,7 @@
"src/color.ts", "src/styles.ts", "src/styles/*.styles.ts", "src/logic.ts", "src/i18n.ts", "src/i18n/registry.ts", "src/i18n/language-runtime.ts", "src/glow-blend.ts", "src/grid-scale.ts", "src/device-visual.ts", "src/device-pulse.ts", "src/device-presentation.ts", "src/device-marker-geometry.ts",
"src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts", "src/device-area-relocation.ts",
"src/ha-binding-status.ts",
"src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts",
"src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/vacuum-route-edit.ts",
"src/sun.ts",
"src/light-visibility.ts", "src/glow-scene.ts", "src/space-render.ts",
"src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts",