fix: keep vacuum routes pending until a floor is chosen

Issue: #441
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-03 19:22:55 +00:00
committed by claude[bot]
parent 79e922203a
commit fa41d2981c
26 changed files with 901 additions and 506 deletions
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "657e45b11db7e7ff870131c04228933d27e226165848926d21fd2d5f25651aa9",
"fingerprint": "c53b73316a258130996655c414c72f311c57727d88a947b8ef0feaf9ab6d127a",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-KiAYj10F.js",
"houseplan-assets/houseplan-card-DTTdyKSp.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 292984,
"initialViewGzipBytes": 292981,
"lazyFiles": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/de-8ycErMrc.js",
"houseplan-assets/editor-Btgdn4cS.js",
"houseplan-assets/fr-DESFTmoo.js",
"houseplan-assets/houseplan-editor-runtime-BMOXcbvu.js",
"houseplan-assets/houseplan-onboarding-runtime-D0HESg_j.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/de-Bk5XCaFW.js",
"houseplan-assets/editor-DV9aaxh3.js",
"houseplan-assets/fr-Bc8GcNwg.js",
"houseplan-assets/houseplan-editor-runtime-Cv3Tj4ec.js",
"houseplan-assets/houseplan-onboarding-runtime-B0SCo751.js"
],
"lazyGzipBytes": 220425,
"lazyGzipBytes": 221159,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/editor-Btgdn4cS.js",
"houseplan-assets/houseplan-editor-runtime-BMOXcbvu.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/editor-DV9aaxh3.js",
"houseplan-assets/houseplan-editor-runtime-Cv3Tj4ec.js"
],
"lazyEditorGzipBytes": 164374,
"lazyEditorGzipBytes": 165116,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/houseplan-onboarding-runtime-D0HESg_j.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/houseplan-onboarding-runtime-B0SCo751.js"
],
"lazyOnboardingGzipBytes": 14238,
"lazyOnboardingGzipBytes": 14235,
"lazyLocaleFiles": [
"houseplan-assets/de-8ycErMrc.js",
"houseplan-assets/fr-DESFTmoo.js"
"houseplan-assets/de-Bk5XCaFW.js",
"houseplan-assets/fr-Bc8GcNwg.js"
],
"lazyLocaleGzipBytes": 49093,
"lazyLocaleGzipBytes": 49089,
"files": [
{
"path": "houseplan-assets/backdrop-pick-BSlJmdKA.js",
"sha256": "96c1bdff6d868047b7493712c8fe6c9bbae2b1746bfceaef9a4099b5fcd9a63a",
"path": "houseplan-assets/backdrop-pick-38iG-EgB.js",
"sha256": "418abed95e5fe5029e8e1b63cdb63fa7c334d6a5ba7673786363d596975ca2fb",
"rawBytes": 21418,
"gzipBytes": 7280,
"gzipBytes": 7281,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/houseplan-card-DTTdyKSp.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-8ycErMrc.js",
"sha256": "7ba3842af5b58f96a7ffec9b01937aa5d7219577dbb25259a6f33fd06d6d002c",
"path": "houseplan-assets/de-Bk5XCaFW.js",
"sha256": "26becc9124786aa6a0e221214c6019ff1d0d6e6c24ee3d387f063ca6aa4a616d",
"rawBytes": 82140,
"gzipBytes": 24815,
"gzipBytes": 24814,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Btgdn4cS.js",
"sha256": "e6c4ef572f15ca3e8418ee1872b55e3d0c85f2d33c25bcd2660687f2ac62360c",
"path": "houseplan-assets/editor-DV9aaxh3.js",
"sha256": "a62e2706d2f58371e5056aab0a4a96051493ae1441fa7b419e95058b8c34202a",
"rawBytes": 3826,
"gzipBytes": 1584,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/houseplan-card-DTTdyKSp.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DESFTmoo.js",
"sha256": "2806c8942afed7e7cbf87b344605685b886dbcfbef059c8b78a0867745ca66aa",
"path": "houseplan-assets/fr-Bc8GcNwg.js",
"sha256": "e75de097700b0db364d2d3c0bca4a10f22343d17c9eaa6dbeba7a789e235af05",
"rawBytes": 84267,
"gzipBytes": 24278,
"gzipBytes": 24275,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-KiAYj10F.js",
"sha256": "939a6e9dcbb126cb53925d30c8a4193ddc301b419d9f14959b536fbbb78b861f",
"path": "houseplan-assets/houseplan-card-DTTdyKSp.js",
"sha256": "8f045ff75d013ccdab48546a218d9cbe15f2cdec038060cdf018c1915a68400c",
"rawBytes": 1039585,
"gzipBytes": 292186,
"gzipBytes": 292185,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-8ycErMrc.js",
"houseplan-assets/editor-Btgdn4cS.js",
"houseplan-assets/fr-DESFTmoo.js",
"houseplan-assets/houseplan-editor-runtime-BMOXcbvu.js",
"houseplan-assets/houseplan-onboarding-runtime-D0HESg_j.js"
"houseplan-assets/de-Bk5XCaFW.js",
"houseplan-assets/editor-DV9aaxh3.js",
"houseplan-assets/fr-Bc8GcNwg.js",
"houseplan-assets/houseplan-editor-runtime-Cv3Tj4ec.js",
"houseplan-assets/houseplan-onboarding-runtime-B0SCo751.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-BMOXcbvu.js",
"sha256": "506cd2dc007e85e6b676e96bc0bbe37ac998f3289ec8ced749fd03bedf014db6",
"rawBytes": 592159,
"gzipBytes": 155510,
"path": "houseplan-assets/houseplan-editor-runtime-Cv3Tj4ec.js",
"sha256": "9ddf1cf2fcc3c446fea56579599cdd1104736e55f2404837be9064109dbc1f29",
"rawBytes": 595044,
"gzipBytes": 156252,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/houseplan-card-DTTdyKSp.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-D0HESg_j.js",
"sha256": "49b2e0228b4c4af5743344552eca63821fd148ca3e5fa195a38fdb868a192f81",
"path": "houseplan-assets/houseplan-onboarding-runtime-B0SCo751.js",
"sha256": "a9e31ea033c1df4c50f28dc6ccd013a1a12335176c2ce358b1b7144c9c70e14c",
"rawBytes": 28088,
"gzipBytes": 6958,
"gzipBytes": 6954,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/houseplan-card-DTTdyKSp.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "937b428bc4e8c0edab1ea83c5f026c895da89484810fe40731e28d0d33fca31d",
"sha256": "77de82b5156123bd48fa0ab2d385ee50404135c634f0208c1c6e453690cc6aca",
"rawBytes": 1183,
"gzipBytes": 798,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/houseplan-card-DTTdyKSp.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="657e45b11db7e7ff870131c04228933d27e226165848926d21fd2d5f25651aa9";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-KiAYj10F.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__="c53b73316a258130996655c414c72f311c57727d88a947b8ef0feaf9ab6d127a";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-DTTdyKSp.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}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="657e45b11db7e7ff870131c04228933d27e226165848926d21fd2d5f25651aa9";try{await import("./houseplan-assets/houseplan-card-KiAYj10F.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__="c53b73316a258130996655c414c72f311c57727d88a947b8ef0feaf9ab6d127a";try{await import("./houseplan-assets/houseplan-card-DTTdyKSp.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)}
+155
View File
@@ -0,0 +1,155 @@
// #441: adding a second vacuum map is a UI draft, never an invalid route with
// `space: ''`. The same smoke covers both add entry points and rejected saves.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const o = {};
const sr = () => c.shadowRoot || c.renderRoot;
const waitFor = async (read, timeout = 2000) => {
const started = Date.now();
while (Date.now() - started < timeout) {
const value = read();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 20));
}
return null;
};
await new Promise((resolve) => setTimeout(resolve, 650));
c._saveConfigDebounced?.flush?.();
await c._writeChain;
const originalCallWS = c.hass.callWS;
c.hass = {
...c.hass,
entities: {
...c.hass.entities,
'camera.mower_map': {
entity_id: 'camera.mower_map', device_id: 'd_mower', platform: 'demo', disabled_by: null,
},
'camera.mower_upstairs': {
entity_id: 'camera.mower_upstairs', device_id: 'd_mower', platform: 'demo', disabled_by: null,
},
},
states: {
...c.hass.states,
'camera.mower_map': {
state: 'idle', attributes: {
friendly_name: 'Main map', map_name: 'm1', vacuum_position: { x: 1, y: 2 },
},
},
'camera.mower_upstairs': {
state: 'idle', attributes: {
friendly_name: 'Upstairs map', map_name: 'm2', vacuum_position: { x: 1, y: 2 },
},
},
},
};
c._serverCfg.markers = [{
id: 'd_mower', binding: 'device:d_mower', space: 'garden', area: 'garden',
vacuum: { source: 'camera.mower_map', map_routes: [] },
}];
c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('devices');
await c.updateComplete;
c._openMarkerDialog(c._devices.find((device) => device.id === 'd_mower'));
await c.updateComplete;
const routes = () => c._serverCfg.markers.find((marker) => marker.id === 'd_mower')
?.vacuum?.map_routes || [];
const addCurrent = () => sr().querySelector('.vacroute-add-current');
const pending = () => sr().querySelector('.vacroute.pending');
// The first route is a draft too, but its only safe default is the dock floor.
addCurrent()?.click();
await c.updateComplete;
o.firstRoutePreselectsDock = pending()?.querySelector('.vacroute-draft-space')?.value === 'garden';
o.firstDraftDoesNotWrite = routes().length === 0;
pending()?.querySelector('button.ghostbtn')?.click();
await c.updateComplete;
// Start with one accepted route, then exercise the separate-source entry.
c._serverCfg.markers[0].vacuum.map_routes = [{
id: 'r1', source: 'camera.mower_map', map_id: 'm1', space: 'garden', calibration: null,
}];
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
const sourceDetails = sr().querySelector('details.vacroute-sources');
if (sourceDetails) sourceDetails.open = true;
await c.updateComplete;
[...(sourceDetails?.querySelectorAll('button.vacsource') || [])]
.find((button) => button.textContent.includes('camera.mower_upstairs'))?.click();
await c.updateComplete;
o.separateSourceStartsBlank = pending()?.querySelector('.vacroute-draft-space')?.value === '';
o.blankDraftCannotSave = pending()?.querySelector('.vacroute-draft-save')?.disabled === true;
o.separateSourceDoesNotWrite = routes().length === 1;
pending()?.querySelector('button.ghostbtn')?.click();
await c.updateComplete;
o.cancelLeavesAcceptedRoutes = !pending() && routes().length === 1;
// The same source reports another map. Confirm once against a rejecting
// backend, then retry successfully without reopening the dialog.
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.mower_map': {
state: 'idle', attributes: {
friendly_name: 'Main map', map_name: 'm2', vacuum_position: { x: 1, y: 2 },
},
},
} };
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
addCurrent()?.click();
await c.updateComplete;
o.secondCurrentStartsBlank = pending()?.querySelector('.vacroute-draft-space')?.value === '';
const select = pending()?.querySelector('.vacroute-draft-space');
if (select) {
select.value = 'f1';
select.dispatchEvent(new Event('change', { bubbles: true }));
}
await c.updateComplete;
o.validFloorEnablesSave = pending()?.querySelector('.vacroute-draft-save')?.disabled === false;
const writes = [];
let rejectNext = true;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/set') {
writes.push(message);
if (rejectNext) {
rejectNext = false;
throw new Error('synthetic route rejection');
}
}
return originalCallWS(message);
} };
pending()?.querySelector('.vacroute-draft-save')?.click();
await waitFor(() => writes.length === 1 && pending()?.querySelector('.vacroute-draft-save')?.disabled === false);
o.rejectionRestoresAcceptedMarker = routes().length === 1 && routes()[0].id === 'r1';
o.rejectionKeepsEditorUsable = !!pending()
&& pending().querySelector('.vacroute-draft-space')?.value === 'f1';
o.rejectedPayloadWasValid = writes[0]?.config?.markers
?.find((marker) => marker.id === 'd_mower')?.vacuum?.map_routes
?.every((route) => !!route.space) === true;
pending()?.querySelector('.vacroute-draft-save')?.click();
await waitFor(() => routes().length === 2 && !pending());
const added = routes().find((route) => route.map_id === 'm2');
o.retryAddsExactlyOneRoute = routes().length === 2;
o.retryKeepsExactIdentity = added?.source === 'camera.mower_map'
&& added?.space === 'f1' && added?.calibration === null;
o.noEmptySpaceEverSubmitted = writes.every((write) => write.config.markers
.find((marker) => marker.id === 'd_mower').vacuum.map_routes
.every((route) => !!route.space));
c.hass = { ...c.hass, callWS: originalCallWS };
return o;
});
checkAll(out);
await finish(browser, out);
+56 -56
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "657e45b11db7e7ff870131c04228933d27e226165848926d21fd2d5f25651aa9",
"fingerprint": "c53b73316a258130996655c414c72f311c57727d88a947b8ef0feaf9ab6d127a",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-KiAYj10F.js",
"houseplan-assets/houseplan-card-DTTdyKSp.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 292984,
"initialViewGzipBytes": 292981,
"lazyFiles": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/de-8ycErMrc.js",
"houseplan-assets/editor-Btgdn4cS.js",
"houseplan-assets/fr-DESFTmoo.js",
"houseplan-assets/houseplan-editor-runtime-BMOXcbvu.js",
"houseplan-assets/houseplan-onboarding-runtime-D0HESg_j.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/de-Bk5XCaFW.js",
"houseplan-assets/editor-DV9aaxh3.js",
"houseplan-assets/fr-Bc8GcNwg.js",
"houseplan-assets/houseplan-editor-runtime-Cv3Tj4ec.js",
"houseplan-assets/houseplan-onboarding-runtime-B0SCo751.js"
],
"lazyGzipBytes": 220425,
"lazyGzipBytes": 221159,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/editor-Btgdn4cS.js",
"houseplan-assets/houseplan-editor-runtime-BMOXcbvu.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/editor-DV9aaxh3.js",
"houseplan-assets/houseplan-editor-runtime-Cv3Tj4ec.js"
],
"lazyEditorGzipBytes": 164374,
"lazyEditorGzipBytes": 165116,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/houseplan-onboarding-runtime-D0HESg_j.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/houseplan-onboarding-runtime-B0SCo751.js"
],
"lazyOnboardingGzipBytes": 14238,
"lazyOnboardingGzipBytes": 14235,
"lazyLocaleFiles": [
"houseplan-assets/de-8ycErMrc.js",
"houseplan-assets/fr-DESFTmoo.js"
"houseplan-assets/de-Bk5XCaFW.js",
"houseplan-assets/fr-Bc8GcNwg.js"
],
"lazyLocaleGzipBytes": 49093,
"lazyLocaleGzipBytes": 49089,
"files": [
{
"path": "houseplan-assets/backdrop-pick-BSlJmdKA.js",
"sha256": "96c1bdff6d868047b7493712c8fe6c9bbae2b1746bfceaef9a4099b5fcd9a63a",
"path": "houseplan-assets/backdrop-pick-38iG-EgB.js",
"sha256": "418abed95e5fe5029e8e1b63cdb63fa7c334d6a5ba7673786363d596975ca2fb",
"rawBytes": 21418,
"gzipBytes": 7280,
"gzipBytes": 7281,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/houseplan-card-DTTdyKSp.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-8ycErMrc.js",
"sha256": "7ba3842af5b58f96a7ffec9b01937aa5d7219577dbb25259a6f33fd06d6d002c",
"path": "houseplan-assets/de-Bk5XCaFW.js",
"sha256": "26becc9124786aa6a0e221214c6019ff1d0d6e6c24ee3d387f063ca6aa4a616d",
"rawBytes": 82140,
"gzipBytes": 24815,
"gzipBytes": 24814,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Btgdn4cS.js",
"sha256": "e6c4ef572f15ca3e8418ee1872b55e3d0c85f2d33c25bcd2660687f2ac62360c",
"path": "houseplan-assets/editor-DV9aaxh3.js",
"sha256": "a62e2706d2f58371e5056aab0a4a96051493ae1441fa7b419e95058b8c34202a",
"rawBytes": 3826,
"gzipBytes": 1584,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/houseplan-card-DTTdyKSp.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DESFTmoo.js",
"sha256": "2806c8942afed7e7cbf87b344605685b886dbcfbef059c8b78a0867745ca66aa",
"path": "houseplan-assets/fr-Bc8GcNwg.js",
"sha256": "e75de097700b0db364d2d3c0bca4a10f22343d17c9eaa6dbeba7a789e235af05",
"rawBytes": 84267,
"gzipBytes": 24278,
"gzipBytes": 24275,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-KiAYj10F.js",
"sha256": "939a6e9dcbb126cb53925d30c8a4193ddc301b419d9f14959b536fbbb78b861f",
"path": "houseplan-assets/houseplan-card-DTTdyKSp.js",
"sha256": "8f045ff75d013ccdab48546a218d9cbe15f2cdec038060cdf018c1915a68400c",
"rawBytes": 1039585,
"gzipBytes": 292186,
"gzipBytes": 292185,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-8ycErMrc.js",
"houseplan-assets/editor-Btgdn4cS.js",
"houseplan-assets/fr-DESFTmoo.js",
"houseplan-assets/houseplan-editor-runtime-BMOXcbvu.js",
"houseplan-assets/houseplan-onboarding-runtime-D0HESg_j.js"
"houseplan-assets/de-Bk5XCaFW.js",
"houseplan-assets/editor-DV9aaxh3.js",
"houseplan-assets/fr-Bc8GcNwg.js",
"houseplan-assets/houseplan-editor-runtime-Cv3Tj4ec.js",
"houseplan-assets/houseplan-onboarding-runtime-B0SCo751.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-BMOXcbvu.js",
"sha256": "506cd2dc007e85e6b676e96bc0bbe37ac998f3289ec8ced749fd03bedf014db6",
"rawBytes": 592159,
"gzipBytes": 155510,
"path": "houseplan-assets/houseplan-editor-runtime-Cv3Tj4ec.js",
"sha256": "9ddf1cf2fcc3c446fea56579599cdd1104736e55f2404837be9064109dbc1f29",
"rawBytes": 595044,
"gzipBytes": 156252,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/houseplan-card-DTTdyKSp.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-D0HESg_j.js",
"sha256": "49b2e0228b4c4af5743344552eca63821fd148ca3e5fa195a38fdb868a192f81",
"path": "houseplan-assets/houseplan-onboarding-runtime-B0SCo751.js",
"sha256": "a9e31ea033c1df4c50f28dc6ccd013a1a12335176c2ce358b1b7144c9c70e14c",
"rawBytes": 28088,
"gzipBytes": 6958,
"gzipBytes": 6954,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BSlJmdKA.js",
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/backdrop-pick-38iG-EgB.js",
"houseplan-assets/houseplan-card-DTTdyKSp.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "937b428bc4e8c0edab1ea83c5f026c895da89484810fe40731e28d0d33fca31d",
"sha256": "77de82b5156123bd48fa0ab2d385ee50404135c634f0208c1c6e453690cc6aca",
"rawBytes": 1183,
"gzipBytes": 798,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-KiAYj10F.js"
"houseplan-assets/houseplan-card-DTTdyKSp.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,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="657e45b11db7e7ff870131c04228933d27e226165848926d21fd2d5f25651aa9";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-KiAYj10F.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__="c53b73316a258130996655c414c72f311c57727d88a947b8ef0feaf9ab6d127a";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-DTTdyKSp.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}
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__="657e45b11db7e7ff870131c04228933d27e226165848926d21fd2d5f25651aa9";try{await import("./houseplan-assets/houseplan-card-KiAYj10F.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__="c53b73316a258130996655c414c72f311c57727d88a947b8ef0feaf9ab6d127a";try{await import("./houseplan-assets/houseplan-card-DTTdyKSp.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- A robot vacuum's second and later maps can now be added: the editor asks for
the target floor before saving the route, and a Home Assistant rejection no
longer leaves a phantom local route
([#441](https://github.com/Matysh/houseplan-card/issues/441)).
## v1.71.0-beta.3 — 2026-09-03
- When a robot vacuum is cleaning but its current map has no floor assigned, is
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Вторую и последующие карты робота теперь можно добавить: сначала
редактор просит выбрать этаж и только потом сохраняет маршрут,
а отказ Home Assistant не оставляет ложную локальную запись
([#441](https://github.com/Matysh/houseplan-card/issues/441)).
## v1.71.0-beta.3 — 2026-09-03
- Если робот убирает, а его текущая карта не сопоставлена этажу, не
+10
View File
@@ -27,6 +27,16 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'beginVacuumRouteDraft', 'chooseVacuumRouteSpace', 'commitVacuumRouteDraft',
'VacuumRouteDraft', 'VacuumMapsHost', 'VacuumMapsCardHost',
],
smokes: ['smoke_vacuum_route_draft.mjs'],
because: 'the #441 production-bundle scenario drives both add-map controls, first-route '
+ 'defaulting, required floor selection, cancel, a rejected config write with rollback and '
+ 'a successful retry; those UI and transport effects are outside the pure draft helpers',
},
{
symbols: ['showRoomTooltipOf'],
smokes: ['smoke_room_tooltip_toggle.mjs'],
+140 -30
View File
@@ -8,14 +8,18 @@
*/
import { TemplateResult, html, nothing } from 'lit';
import type { HpConfirmRequest } from '../danger-confirm';
import type { DevItem } from '../types';
import type { DevItem, Marker, ServerConfig } from '../types';
import type { VacSourceCandidate, VacSourceResolution } from '../vacuum';
import { langOf, type I18nKey } from '../i18n';
import { supportT, type SupportI18nKey } from '../i18n/support';
import { VacuumMapRoute, effectiveRoutes, observedMapIds, resolveRoute } from '../vacuum-routes';
import {
addRoute, changeRouteSpace, convertLegacyRoutes, newRouteId, removeRoute,
beginVacuumRouteDraft, changeRouteSpace, chooseVacuumRouteSpace,
commitVacuumRouteDraft, convertLegacyRoutes, newRouteId, removeRoute,
type VacuumRouteDraft,
} from '../vacuum-route-edit';
import { optimisticAttempt, rollbackOptimistic } from '../serialized-write-queue';
import { contentFingerprint } from '../visual-continuity';
interface SpaceRow { id?: unknown; name?: unknown }
@@ -23,20 +27,30 @@ interface SpaceRow { id?: unknown; name?: unknown }
export interface VacuumMapsCardHost {
hass?: { states?: Record<string, unknown> } | null;
_config?: { language?: string | null } | null;
_serverCfg?: { spaces?: SpaceRow[] } | null;
_serverCfg: (ServerConfig & { spaces: SpaceRow[] }) | null;
_cfgContentFingerprint: string;
_cfgRev: number;
_saveConfigDebounced: { pending: () => boolean; cancel: () => void };
_regSignature: string;
_t: (key: I18nKey, vars?: Record<string, string | number>) => string;
_errText: (error: unknown) => string;
_maybeRebuildDevices: () => void;
_showToast: (message: string) => void;
_vacSource: (dev: DevItem) => string | null;
_vacObservedMapId: (dev: DevItem, source: string) => string | undefined;
_confirmDanger: (request: HpConfirmRequest) => Promise<boolean>;
requestUpdate: () => void;
}
export interface VacuumMapsHost {
host: VacuumMapsCardHost;
_saveConfig: () => void;
_saveConfigNow: () => Promise<void>;
_vacAutoCalibrate: (dev: DevItem) => void;
_vacStartFit: (dev: DevItem, routeId?: string) => void;
}
const pendingRoute = new WeakMap<VacuumMapsHost, VacuumRouteDraft & { saving?: boolean }>();
const spaceName = (host: VacuumMapsCardHost, spaceId: string): string => {
const space = (host._serverCfg?.spaces || []).find((item) => item?.id === spaceId);
return String(space?.name || space?.id || spaceId);
@@ -45,12 +59,11 @@ const spaceName = (host: VacuumMapsCardHost, spaceId: string): string => {
/**
* 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.
* Additions stay in module-owned UI state until the floor is valid; accepted
* edits use the card's serialized config writer and roll back on rejection.
*/
export function renderVacuumMapsSection(
runtime: VacuumMapsHost, dev: DevItem, setVac: (patch: Record<string, unknown>) => void,
sources: VacSourceResolution,
runtime: VacuumMapsHost, dev: DevItem, sources: VacSourceResolution,
): TemplateResult | typeof nothing {
const host = runtime.host;
const lang = langOf(host.hass, host._config?.language);
@@ -72,30 +85,63 @@ export function renderVacuumMapsSection(
|| resolution.kind === 'missing_space') ? resolution.route.id : '';
const currentMapId = rootSource ? observed[rootSource] : undefined;
/** Persist route changes atomically, restoring the last accepted config on rejection. */
const persistRoutes = async (nextRoutes: VacuumMapRoute[]): Promise<boolean> => {
const previous = host._serverCfg;
if (!previous) return false;
const nextConfig = JSON.parse(JSON.stringify(previous)) as ServerConfig;
nextConfig.markers = nextConfig.markers || [];
let marker = nextConfig.markers.find((item) => item.id === dev.id);
if (!marker) {
if ((dev.bindingKind !== 'device' && dev.bindingKind !== 'entity') || !dev.bindingRef) return false;
marker = {
id: dev.id, binding: `${dev.bindingKind}:${dev.bindingRef}`,
space: dev.space || null, area: dev.area || null, hidden: dev.hidden ? true : false,
} as Marker;
nextConfig.markers.push(marker);
}
marker.vacuum = { ...(marker.vacuum || {}), map_routes: nextRoutes };
delete marker.vacuum.calibration;
const attempt = optimisticAttempt(previous, nextConfig, host._cfgContentFingerprint,
host._cfgRev, contentFingerprint);
host._serverCfg = nextConfig;
host._regSignature = '';
host._maybeRebuildDevices();
host.requestUpdate();
if (host._saveConfigDebounced.pending()) host._saveConfigDebounced.cancel();
try {
await runtime._saveConfigNow();
return true;
} catch (error) {
rollbackOptimistic(host, attempt, contentFingerprint);
host._regSignature = '';
host._maybeRebuildDevices();
host.requestUpdate();
host._showToast(host._t('toast.cfg_save_failed', { err: host._errText(error) }));
return false;
}
};
/** Every routing edit converts legacy data first — all of it, or none. */
const writeRoutes = (next: (current: VacuumMapRoute[]) => VacuumMapRoute[]): boolean => {
const writeRoutes = async (
next: (current: VacuumMapRoute[]) => VacuumMapRoute[] | null,
): Promise<boolean> => {
let base: VacuumMapRoute[] | null = explicit ? (vacuum.map_routes ?? null) : 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 candidate = next(base);
return candidate ? persistRoutes(candidate) : false;
};
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()),
));
pendingRoute.set(runtime,
beginVacuumRouteDraft(dev.id, routes, dev.space, rootSource, currentMapId));
host.requestUpdate();
};
/**
@@ -109,11 +155,9 @@ export function renderVacuumMapsSection(
const addSource = (candidate: VacSourceCandidate) => {
const mapId = host._vacObservedMapId(dev, candidate.entityId);
if (mapId === undefined) return;
writeRoutes((current) => addRoute(
current,
{ source: candidate.entityId, map_id: mapId, space: current.length ? '' : dev.space },
newRouteId(takenIds()),
));
pendingRoute.set(runtime,
beginVacuumRouteDraft(dev.id, routes, dev.space, candidate.entityId, mapId));
host.requestUpdate();
};
const spare = sources.candidates.filter((candidate) => candidate.entityId !== rootSource
@@ -131,7 +175,7 @@ export function renderVacuumMapsSection(
cancelLabel: host._t('btn.cancel'),
});
if (!accepted) return;
writeRoutes((current) => changeRouteSpace(current, route.id, space, newRouteId(takenIds())));
await writeRoutes((current) => changeRouteSpace(current, route.id, space, newRouteId(takenIds())));
};
const drop = async (route: VacuumMapRoute) => {
@@ -145,7 +189,7 @@ export function renderVacuumMapsSection(
cancelLabel: host._t('btn.cancel'),
});
if (!accepted) return;
writeRoutes((current) => removeRoute(current, route.id));
await writeRoutes((current) => removeRoute(current, route.id));
};
// Space order, then map id, then source: a stable reading order that never
@@ -166,6 +210,50 @@ export function renderVacuumMapsSection(
const canAddCurrent = !!rootSource && currentMapId !== undefined
&& !routes.some((route) => route.source === rootSource && route.map_id === currentMapId);
let draft = pendingRoute.get(runtime);
const draftSource = draft?.source || '';
const draftMapId = draft?.mapId || '';
const draftIdentityExists = draft
? routes.some((route) => route.source === draftSource && route.map_id === draftMapId)
: false;
if (draft && (draft.markerId !== dev.id
|| host._vacObservedMapId(dev, draft.source) !== draft.mapId
|| (draftIdentityExists && !draft.saving))) {
pendingRoute.delete(runtime);
draft = undefined;
}
if (draft?.space && !spaceIds.has(draft.space)) {
draft = chooseVacuumRouteSpace(draft, '', spaceIds);
pendingRoute.set(runtime, draft);
}
const selectDraftSpace = (space: string) => {
const current = pendingRoute.get(runtime);
if (!current || current.saving) return;
pendingRoute.set(runtime, chooseVacuumRouteSpace(current, space, spaceIds));
host.requestUpdate();
};
const cancelDraft = () => {
if (pendingRoute.get(runtime)?.saving) return;
pendingRoute.delete(runtime);
host.requestUpdate();
};
const confirmDraft = async () => {
const current = pendingRoute.get(runtime);
if (!current || current.saving || !spaceIds.has(current.space)) return;
pendingRoute.set(runtime, { ...current, saving: true });
host.requestUpdate();
const saved = await writeRoutes((base) => commitVacuumRouteDraft(
base, current, spaceIds, newRouteId(new Set(base.map((route) => route.id))),
));
const live = pendingRoute.get(runtime);
if (live?.markerId === current.markerId && live.source === current.source
&& live.mapId === current.mapId) {
if (saved) pendingRoute.delete(runtime);
else pendingRoute.set(runtime, current);
}
host.requestUpdate();
};
return html`
<label>${t('vac.routes_section')}</label>
<div class="bindbox vacroutes">
@@ -174,7 +262,28 @@ export function renderVacuumMapsSection(
? 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.length || draft ? html`<ul class="vacroute-list">
${draft ? html`
<li class="vacroute pending">
<div class="vacroute-head"><b>${draft.mapId || t('vac.route_map_default')}</b></div>
<small>${draft.source}</small>
<label class="srcrow">
<span>${t('vac.route_space')}</span>
<select class="areasel vacroute-draft-space" ?disabled=${!!draft.saving}
@change=${(event: Event) => selectDraftSpace((event.target as HTMLSelectElement).value)}>
<option value="" ?selected=${!draft.space}>${t('vac.route_space_none')}</option>
${spaces.map((space) => html`
<option value=${space.id} ?selected=${space.id === draft!.space}>${space.name}</option>`)}
</select>
</label>
<div class="vacbtns">
<button type="button" class="btn ghostbtn" ?disabled=${!!draft.saving}
@click=${cancelDraft}>${host._t('btn.cancel')}</button>
<button type="button" class="btn vacroute-draft-save"
?disabled=${!!draft.saving || !spaceIds.has(draft.space)}
@click=${() => void confirmDraft()}>${host._t('btn.save')}</button>
</div>
</li>` : nothing}
${rows.map((route) => html`
<li class="vacroute ${route.id === activeId ? 'on' : ''}">
<div class="vacroute-head">
@@ -206,7 +315,7 @@ export function renderVacuumMapsSection(
</li>`)}
</ul>` : html`<div class="rhint">${t('vac.routes_empty')}</div>`}
<div class="vacbtns">
<button type="button" class="btn" ?disabled=${!canAddCurrent}
<button type="button" class="btn vacroute-add-current" ?disabled=${!canAddCurrent || !!draft}
@click=${addCurrent}>${t('vac.route_add_current')}</button>
</div>
${spare.length ? html`<details class="vacroute-sources">
@@ -215,7 +324,8 @@ export function renderVacuumMapsSection(
<div class="vacsource-list">
${spare.map((candidate) => {
const mapId = host._vacObservedMapId(dev, candidate.entityId);
return html`<button type="button" class="vacsource" ?disabled=${mapId === undefined}
return html`<button type="button" class="vacsource"
?disabled=${mapId === undefined || !!draft}
@click=${() => addSource(candidate)}>
<span><b>${candidate.name}</b><small>${candidate.entityId}</small></span>
<span class="vacsource-meta">${mapId === undefined
+1 -1
View File
@@ -10970,7 +10970,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>
${renderVacuumMapsSection(this, dev, setVac, resolution)}
${renderVacuumMapsSection(this, dev, resolution)}
` : nothing}
</div>`;
}
+41
View File
@@ -14,6 +14,47 @@ import {
Affine, VacuumMapRoute, VacuumRouteMarkerCfg, effectiveRoutes, normalizeRouteMatrix,
} from './vacuum-routes';
/** A route which exists only in the editor until a real space is confirmed. */
export interface VacuumRouteDraft {
markerId: string;
source: string;
mapId: string;
space: string;
}
/**
* Begin the add-route transaction without creating an invalid persisted row.
* Only the very first route may inherit the dock floor; every later map must
* be assigned explicitly by the user (#162 §9.2, #441).
*/
export function beginVacuumRouteDraft(
markerId: string, routes: readonly VacuumMapRoute[], dockSpace: string,
source: string, mapId: string,
): VacuumRouteDraft {
return { markerId, source, mapId, space: routes.length ? '' : dockSpace };
}
/** Unknown/deleted spaces are represented as no selection, never persisted. */
export function chooseVacuumRouteSpace(
draft: VacuumRouteDraft, space: string, spaceIds: ReadonlySet<string>,
): VacuumRouteDraft {
return { ...draft, space: spaceIds.has(space) ? space : '' };
}
/** Materialise exactly one valid route, or refuse to produce a candidate. */
export function commitVacuumRouteDraft(
routes: VacuumMapRoute[], draft: VacuumRouteDraft,
spaceIds: ReadonlySet<string>, id: string,
): VacuumMapRoute[] | null {
if (!draft.source || !spaceIds.has(draft.space)
|| routes.some((route) => route.source === draft.source && route.map_id === draft.mapId)) {
return null;
}
return addRoute(routes, {
source: draft.source, map_id: draft.mapId, space: draft.space,
}, id);
}
/** 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);
+26 -1
View File
@@ -8,7 +8,8 @@ import {
} from '../test-build/vacuum-routes.js';
import {
newRouteId, addRoute, removeRoute, changeRouteSpace, saveRouteCalibration, convertLegacyRoutes,
writeVacuumMatrix, planVacuumFit, calibrationTarget,
writeVacuumMatrix, planVacuumFit, calibrationTarget, beginVacuumRouteDraft,
chooseVacuumRouteSpace, commitVacuumRouteDraft,
} from '../test-build/vacuum-route-edit.js';
const fixture = (name) => JSON.parse(readFileSync(
@@ -275,6 +276,30 @@ test('добавление, удаление и запись матрицы ма
assert.deepEqual(removeRoute(calibrated, 'нет такого'), calibrated);
});
test('новый маршрут живёт в черновике до выбора этажа (#441 AC1)', () => {
const existing = [route()];
const first = beginVacuumRouteDraft('mk', [], 'floor1', 'camera.robot', 'm1');
const second = beginVacuumRouteDraft('mk', existing, 'floor1', 'camera.robot', 'm2');
assert.equal(first.space, 'floor1', 'первый маршрут наследует этаж дока');
assert.equal(second.space, '', 'второй маршрут не угадывает этаж');
assert.equal(commitVacuumRouteDraft(existing, second, spaces('floor1', 'floor2'), 'r2'), null);
assert.equal(existing.length, 1, 'черновик не меняет маршруты');
});
test('черновик материализует ровно один валидный маршрут (#441 AC2)', () => {
const existing = [route()];
const draft = beginVacuumRouteDraft('mk', existing, 'floor1', 'camera.robot', 'm2');
const unknown = chooseVacuumRouteSpace(draft, 'gone', spaces('floor1', 'floor2'));
assert.equal(unknown.space, '');
const chosen = chooseVacuumRouteSpace(draft, 'floor2', spaces('floor1', 'floor2'));
const committed = commitVacuumRouteDraft(existing, chosen, spaces('floor1', 'floor2'), 'r2');
assert.deepEqual(committed, [existing[0], {
id: 'r2', source: 'camera.robot', map_id: 'm2', space: 'floor2', calibration: null,
}]);
assert.equal(commitVacuumRouteDraft(committed, chosen, spaces('floor1', 'floor2'), 'r3'), null,
'одну карту нельзя добавить дважды');
});
test('конверсия легаси переносит ВСЕ матрицы или ничего (AC13)', () => {
const marker = { source: 'camera.robot', calibration: { m1: M1, m2: M2, bad: [1, 2] } };
let n = 0;