`}_vacMapId(t,e){if("default"!==e.mapId)return e.mapId;const i=this._vacEntity(t),s=i?this.hass?.states[i]?.attributes?.selected_map:null;return s?String(s):"default"}_vacSaveMatrix(t,e,i,s){const o=this._serverCfg,n=o?.markers?.find(e=>e.id===t);if(!n)return;const r={...n.vacuum||{}};r.source=e,r.calibration={...r.calibration||{},[i]:s.map(t=>Number(t.toFixed(6)))},n.vacuum=r,this._regSignature="",this._saveConfig(),this.requestUpdate()}_vacAutoCalibrate(t){const e=this._vacSource(t),i=e?Pi(this.hass?.states[e]?.attributes):null;if(!e||!i||i.rooms.length<3)return void this._showToast(this._t("vac.autocal_no_rooms"));const s=this._spaceModel(t.space),o=(s?.rooms||[]).filter(t=>t.name&&t.poly?.length>=3).map(t=>{const e=Ae(t.poly);return{name:t.name,cx:e[0],cy:e[1]}}),n=function(t,e){const i=new Map(e.map(t=>[Ai(t.name),t])),s=[],o=[];for(const e of t){const t=i.get(Ai(e.name));t&&(s.push([[e.cx,e.cy],[t.cx,t.cy]]),o.push(e.name))}if(s.length<3)return null;const n=Di(s);return n?{matrix:n,matched:o,residual:Ti(n,s)}:null}(i.rooms,o);n?(n.residual>50&&this._showToast(ti(this._t("vac.autocal_res_warn"),{rooms:String(n.matched.length)})),this._vacSaveMatrix(t.id,e,this._vacMapId(t,i),n.matrix),this._showToast(ti(this._t("vac.autocal_done"),{rooms:String(n.matched.length)}))):this._showToast(this._t("vac.autocal_no_match"))}_vacStartWizard(t){const e=this._vacSource(t),i=e?Pi(this.hass?.states[e]?.attributes):null;e&&i?.pos?(this._markerDialog=null,this._vacCal={markerId:t.id,source:e,mapId:this._vacMapId(t,i),pairs:[]}):this._showToast(this._t("vac.cal_need_pos"))}_vacCalClick(t){const e=this._vacCal;if(!e)return;const i=Pi(this.hass?.states[e.source]?.attributes);if(!i?.pos)return void this._showToast(this._t("vac.cal_need_pos"));const s=this._svgPoint(t),o=[...e.pairs,[[i.pos.x,i.pos.y],[s[0],s[1]]]];if(o.length<3)return void(this._vacCal={...e,pairs:o});const n=Di(o);return n?Ti(n,o)>50&&o.length<4?(this._vacCal={...e,pairs:o},void this._showToast(this._t("vac.cal_res_warn"))):(this._vacSaveMatrix(e.markerId,e.source,e.mapId,n),this._vacCal=null,void this._showToast(this._t("vac.cal_done"))):(this._vacCal={...e,pairs:o.slice(0,-1)},void this._showToast(this._t("vac.cal_degenerate")))}_renderVacuums(t,e){if(this._markup||"decor"===this._mode)return G;const i=[],s=[];for(const o of t){if(o.hidden||!this._isVacDev(o))continue;const t=this._vacSource(o);if(!t)continue;const n=Pi(this.hass?.states[t]?.attributes);if(!n)continue;const r=o.marker?.vacuum?.calibration?.[this._vacMapId(o,n)];if(!r||6!==r.length)continue;const a=this._vacRt.get(o.id),l=a?.moving??!1,c=!l&&a?.endedTs&&Date.now()-a.endedTs<6e5;if(!1!==o.marker?.vacuum?.trail&&(l||c)){const t=n.path||a?.trail||[];if(t.length>1){const e=t.map(([t,e])=>{const[i,s]=Ci(r,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");s.push(j`
`)}}if(!l||!n.pos)continue;const[h,d]=Ci(r,n.pos.x,n.pos.y),p=(h-e.x)/e.w*100,u=(d-e.y)/e.h*100,_=a&&a.lastTs>0&&Date.now()-a.lastTs>6e4,m=null!=n.pos.a?n.pos.a+180*Math.atan2(r[3],r[0])/Math.PI:null,g=o.marker?.icon||o.icon||"mdi:robot-vacuum";i.push(B`
`}_vacSaveMatrix(t,e,i,s){const o=this._serverCfg,n=o?.markers?.find(e=>e.id===t);if(!n)return;const r={...n.vacuum||{}};r.source=e,r.calibration={...r.calibration||{},[i]:s.map(t=>Number(t.toFixed(6)))},n.vacuum=r,this._regSignature="",this._saveConfig(),this.requestUpdate()}_vacAutoCalibrate(t){const e=this._vacSource(t),i=e?Pi(this.hass?.states[e]?.attributes):null;if(!e||!i||i.rooms.length<3)return void this._showToast(this._t("vac.autocal_no_rooms"));const s=this._spaceModel(t.space),o=(s?.rooms||[]).filter(t=>t.name&&t.poly?.length>=3).map(t=>{const e=Ae(t.poly);return{name:t.name,cx:e[0],cy:e[1]}}),n=function(t,e){const i=new Map(e.map(t=>[Ai(t.name),t])),s=[],o=[];for(const e of t){const t=i.get(Ai(e.name));t&&(s.push([[e.cx,e.cy],[t.cx,t.cy]]),o.push(e.name))}if(s.length<3)return null;const n=Di(s);return n?{matrix:n,matched:o,residual:Ti(n,s)}:null}(i.rooms,o);n?(n.residual>50&&this._showToast(ti(this._t("vac.autocal_res_warn"),{rooms:String(n.matched.length)})),this._vacSaveMatrix(t.id,e,i.mapId,n.matrix),this._showToast(ti(this._t("vac.autocal_done"),{rooms:String(n.matched.length)}))):this._showToast(this._t("vac.autocal_no_match"))}_vacStartWizard(t){const e=this._vacSource(t),i=e?Pi(this.hass?.states[e]?.attributes):null;e&&i?.pos?(this._markerDialog=null,this._vacCal={markerId:t.id,source:e,mapId:i.mapId,pairs:[]}):this._showToast(this._t("vac.cal_need_pos"))}_vacCalClick(t){const e=this._vacCal;if(!e)return;const i=Pi(this.hass?.states[e.source]?.attributes);if(!i?.pos)return void this._showToast(this._t("vac.cal_need_pos"));const s=this._svgPoint(t),o=[...e.pairs,[[i.pos.x,i.pos.y],[s[0],s[1]]]];if(o.length<3)return void(this._vacCal={...e,pairs:o});const n=Di(o);return n?Ti(n,o)>50&&o.length<4?(this._vacCal={...e,pairs:o},void this._showToast(this._t("vac.cal_res_warn"))):(this._vacSaveMatrix(e.markerId,e.source,e.mapId,n),this._vacCal=null,void this._showToast(this._t("vac.cal_done"))):(this._vacCal={...e,pairs:o.slice(0,-1)},void this._showToast(this._t("vac.cal_degenerate")))}_renderVacuums(t,e){if(this._markup||"decor"===this._mode)return G;const i=[],s=[];for(const o of t){if(o.hidden||!this._isVacDev(o))continue;const t=this._vacSource(o);if(!t)continue;const n=Pi(this.hass?.states[t]?.attributes);if(!n)continue;const r=o.marker?.vacuum?.calibration?.[n.mapId];if(!r||6!==r.length)continue;const a=this._vacRt.get(o.id),l=a?.moving??!1,c=!l&&a?.endedTs&&Date.now()-a.endedTs<6e5;if(!1!==o.marker?.vacuum?.trail&&(l||c)){const t=n.path||a?.trail||[];if(t.length>1){const e=t.map(([t,e])=>{const[i,s]=Ci(r,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");s.push(j`
`)}}if(!l||!n.pos)continue;const[h,d]=Ci(r,n.pos.x,n.pos.y),p=(h-e.x)/e.w*100,u=(d-e.y)/e.h*100,_=a&&a.lastTs>0&&Date.now()-a.lastTs>6e4,m=null!=n.pos.a?n.pos.a+180*Math.atan2(r[3],r[0])/Math.PI:null,g=o.marker?.icon||o.icon||"mdi:robot-vacuum";i.push(B`
`}_vacMapId(t,e){if("default"!==e.mapId)return e.mapId;const i=this._vacEntity(t),s=i?this.hass?.states[i]?.attributes?.selected_map:null;return s?String(s):"default"}_vacSaveMatrix(t,e,i,s){const o=this._serverCfg,n=o?.markers?.find(e=>e.id===t);if(!n)return;const r={...n.vacuum||{}};r.source=e,r.calibration={...r.calibration||{},[i]:s.map(t=>Number(t.toFixed(6)))},n.vacuum=r,this._regSignature="",this._saveConfig(),this.requestUpdate()}_vacAutoCalibrate(t){const e=this._vacSource(t),i=e?Pi(this.hass?.states[e]?.attributes):null;if(!e||!i||i.rooms.length<3)return void this._showToast(this._t("vac.autocal_no_rooms"));const s=this._spaceModel(t.space),o=(s?.rooms||[]).filter(t=>t.name&&t.poly?.length>=3).map(t=>{const e=Ae(t.poly);return{name:t.name,cx:e[0],cy:e[1]}}),n=function(t,e){const i=new Map(e.map(t=>[Ai(t.name),t])),s=[],o=[];for(const e of t){const t=i.get(Ai(e.name));t&&(s.push([[e.cx,e.cy],[t.cx,t.cy]]),o.push(e.name))}if(s.length<3)return null;const n=Di(s);return n?{matrix:n,matched:o,residual:Ti(n,s)}:null}(i.rooms,o);n?(n.residual>50&&this._showToast(ti(this._t("vac.autocal_res_warn"),{rooms:String(n.matched.length)})),this._vacSaveMatrix(t.id,e,this._vacMapId(t,i),n.matrix),this._showToast(ti(this._t("vac.autocal_done"),{rooms:String(n.matched.length)}))):this._showToast(this._t("vac.autocal_no_match"))}_vacStartWizard(t){const e=this._vacSource(t),i=e?Pi(this.hass?.states[e]?.attributes):null;e&&i?.pos?(this._markerDialog=null,this._vacCal={markerId:t.id,source:e,mapId:this._vacMapId(t,i),pairs:[]}):this._showToast(this._t("vac.cal_need_pos"))}_vacCalClick(t){const e=this._vacCal;if(!e)return;const i=Pi(this.hass?.states[e.source]?.attributes);if(!i?.pos)return void this._showToast(this._t("vac.cal_need_pos"));const s=this._svgPoint(t),o=[...e.pairs,[[i.pos.x,i.pos.y],[s[0],s[1]]]];if(o.length<3)return void(this._vacCal={...e,pairs:o});const n=Di(o);return n?Ti(n,o)>50&&o.length<4?(this._vacCal={...e,pairs:o},void this._showToast(this._t("vac.cal_res_warn"))):(this._vacSaveMatrix(e.markerId,e.source,e.mapId,n),this._vacCal=null,void this._showToast(this._t("vac.cal_done"))):(this._vacCal={...e,pairs:o.slice(0,-1)},void this._showToast(this._t("vac.cal_degenerate")))}_renderVacuums(t,e){if(this._markup||"decor"===this._mode)return G;const i=[],s=[];for(const o of t){if(o.hidden||!this._isVacDev(o))continue;const t=this._vacSource(o);if(!t)continue;const n=Pi(this.hass?.states[t]?.attributes);if(!n)continue;const r=o.marker?.vacuum?.calibration?.[this._vacMapId(o,n)];if(!r||6!==r.length)continue;const a=this._vacRt.get(o.id),l=a?.moving??!1,c=!l&&a?.endedTs&&Date.now()-a.endedTs<6e5;if(!1!==o.marker?.vacuum?.trail&&(l||c)){const t=n.path||a?.trail||[];if(t.length>1){const e=t.map(([t,e])=>{const[i,s]=Ci(r,t,e);return i.toFixed(1)+","+s.toFixed(1)}).join(" ");s.push(j`
`)}}if(!l||!n.pos)continue;const[h,d]=Ci(r,n.pos.x,n.pos.y),p=(h-e.x)/e.w*100,u=(d-e.y)/e.h*100,_=a&&a.lastTs>0&&Date.now()-a.lastTs>6e4,m=null!=n.pos.a?n.pos.a+180*Math.atan2(r[3],r[0])/Math.PI:null,g=o.marker?.icon||o.icon||"mdi:robot-vacuum";i.push(B`
`;
}
+ /**
+ * The active-map id. The camera rarely names its map; Dreame keeps the
+ * human-readable one on the vacuum entity (selected_map, verified against a
+ * live X50 Master) — without this both floors would share one matrix.
+ */
+ private _vacMapId(d: DevItem, tele: { mapId: string }): string {
+ if (tele.mapId !== 'default') return tele.mapId;
+ const ve = this._vacEntity(d);
+ const sel = ve ? this.hass?.states[ve]?.attributes?.selected_map : null;
+ return sel ? String(sel) : 'default';
+ }
+
/** Persist a solved matrix into marker.vacuum.calibration[mapId]. */
private _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine): void {
const cfg = this._serverCfg;
@@ -4458,7 +4470,7 @@ class HouseplanCard extends LitElement {
if (res.residual > NORM_W * 0.05) {
this._showToast(subst(this._t('vac.autocal_res_warn'), { rooms: String(res.matched.length) }));
}
- this._vacSaveMatrix(d.id, src, tele.mapId, res.matrix);
+ this._vacSaveMatrix(d.id, src, this._vacMapId(d, tele), res.matrix);
this._showToast(subst(this._t('vac.autocal_done'), { rooms: String(res.matched.length) }));
}
@@ -4471,7 +4483,7 @@ class HouseplanCard extends LitElement {
return;
}
this._markerDialog = null;
- this._vacCal = { markerId: d.id, source: src, mapId: tele.mapId, pairs: [] };
+ this._vacCal = { markerId: d.id, source: src, mapId: this._vacMapId(d, tele), pairs: [] };
}
private _vacCalClick(ev: MouseEvent): void {
@@ -4516,7 +4528,7 @@ class HouseplanCard extends LitElement {
if (!src) continue;
const tele = readVacTelemetry(this.hass?.states[src]?.attributes);
if (!tele) continue;
- const matrix = d.marker?.vacuum?.calibration?.[tele.mapId] as Affine | undefined;
+ const matrix = d.marker?.vacuum?.calibration?.[this._vacMapId(d, tele)] as Affine | undefined;
if (!matrix || matrix.length !== 6) continue;
const rt = this._vacRt.get(d.id);
const moving = rt?.moving ?? false;
diff --git a/src/vacuum.ts b/src/vacuum.ts
index 07d795c..0850d29 100755
--- a/src/vacuum.ts
+++ b/src/vacuum.ts
@@ -120,6 +120,9 @@ export function readVacTelemetry(attrs: Record | null | undefined):
cx = (x0 + x1) / 2; cy = (y0 + y1) / 2;
}
}
+ // Tasshack dreame-vacuum: the room centre is plain x/y (verified against
+ // a live X50 Master; x/y sits within its own x0..x1 bbox)
+ if (cx == null || cy == null) { cx = num(r.x); cy = num(r.y); }
if (name && cx != null && cy != null) rooms.push({ id, name, cx, cy });
}
const mapId = String(attrs.map_name ?? attrs.current_map ?? attrs.map_index ?? attrs.selected_map ?? 'default');
diff --git a/test/vacuum.test.mjs b/test/vacuum.test.mjs
index 079ccfc..ef652a2 100644
--- a/test/vacuum.test.mjs
+++ b/test/vacuum.test.mjs
@@ -48,6 +48,19 @@ test('readVacTelemetry: Valetudo/Tasshack shapes + junk safety', () => {
assert.ok(!isVacSourceState({ attributes: { battery: 1 } }));
});
+test('readVacTelemetry: Tasshack room centres come as plain x/y', () => {
+ // shape captured from a live Dreame X50 Master (dacha, 2026-07-31)
+ const t = readVacTelemetry({
+ vacuum_position: { x: 1399, y: -55, a: 181 },
+ rooms: { 2: { room_id: 2, name: 'Кладовка', x0: 800, y0: -2000, x1: 4200, y1: 300, x: 2575, y: -825 } },
+ });
+ assert.equal(t.rooms[0].name, 'Кладовка');
+ // bbox wins when present (both are valid anchors); x/y covers bbox-less dialects
+ assert.equal(t.rooms[0].cx, 2500);
+ const t2 = readVacTelemetry({ vacuum_position: { x: 0, y: 0 }, rooms: { 2: { name: 'Кладовка', x: 2575, y: -825 } } });
+ assert.equal(t2.rooms[0].cx, 2575);
+});
+
test('autoCalibrate matches by name and solves', () => {
const f = ([x, y]) => [0.01 * x + 100, -0.01 * y + 900];
const vac = [