Live robot vacuums, P1 (docs/VACUUM.md)

The base marker never moves — it is the dock. While the robot cleans, a
round pulsing puck (no badge plate) drives the plan over an affine
transform solved from vacuum-map coordinates: auto-calibration matches
the robot's room list against plan rooms by name, and a three-point
wizard covers integrations without room data. The trail rides the
integration's own path when offered (it predates the card being opened)
and a self-recorded thinned buffer otherwise, lingering ten minutes
after docking. Adapters read the Map Extractor / Tasshack / Valetudo
attribute dialects through one tolerant parser. Display only — no
commands, per the owner's decision.

vacuum.ts is pure logic under 8 new unit tests; the marker schema grew
an optional vacuum block (56 backend tests); smoke_vacuum drives 19
browser asserts including the wizard end to end.
This commit is contained in:
Matysh
2026-07-31 09:19:56 +03:00
parent 831e694f83
commit 40abbccbf0
15 changed files with 1348 additions and 99 deletions
+277 -1
View File
@@ -15,7 +15,7 @@ import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
snapToWall, openingAmount, interiorPoint, poleOfInaccessibility,
snapToWall, openingAmount, interiorPoint, poleOfInaccessibility, subst,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
@@ -25,6 +25,11 @@ import {
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
} from './logic';
import { ContentSigner } from './signing';
import {
Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
autoCalibrate, pushTrailPoint, isVacMoving, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
VAC_TRAIL_LINGER_MS, Pt as VacPt,
} from './vacuum';
import { buildDevices, seedHiddenBindings, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, litLightEntity, type AreaClimate } from './devices';
import type {
OpeningCfg,
@@ -340,6 +345,11 @@ class HouseplanCard extends LitElement {
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 };
private _kioskDialog = false;
/** live-vacuum runtime per marker: RAW robot coords (matrix applied at render) */
private _vacRt = new Map<string, { trail: VacPt[]; lastKey: string; lastTs: number;
moving: boolean; jump: boolean; endedTs: number }>();
private _vacCal: { markerId: string; source: string; mapId: string;
pairs: Array<[VacPt, VacPt]> } | null = null;
private _kioskDots = false;
private _kioskDotsTimer?: number;
private _kioskHoldTimer?: number;
@@ -394,6 +404,7 @@ class HouseplanCard extends LitElement {
_decorSel: { state: true },
_decorTextDialog: { state: true },
_kioskDialog: { state: true },
_vacCal: { state: true },
_kioskDots: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
@@ -461,6 +472,12 @@ class HouseplanCard extends LitElement {
}
private _onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && this._vacCal) {
this._vacCal = null;
this._showToast(this._t('vac.cal_cancelled'));
e.stopPropagation();
return;
}
if (e.key === 'Escape') {
// close the topmost open dialog; info popups first, then editors
if (this._tapConfirm) { this._tapConfirm = null; return; }
@@ -769,6 +786,7 @@ class HouseplanCard extends LitElement {
}
protected willUpdate(changed: PropertyValues): void {
if (changed?.has?.('hass')) this._vacTick();
if (changed.has('hass') && this.hass) {
if (!this._loadOk && !this._loading && this._loadTries < 8) {
this._loadFromServer();
@@ -1941,6 +1959,7 @@ class HouseplanCard extends LitElement {
}
private _markupClick(ev: MouseEvent): void {
if (this._vacCal) { this._vacCalClick(ev); return; }
if (!this._markup) return;
// a pan or pinch just happened — the synthesized click is not a draw
if (this._suppressClick) return;
@@ -3065,8 +3084,12 @@ class HouseplanCard extends LitElement {
if (dlg.binding === 'virtual' && !space) space = this._space;
id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
const oldId = dlg.devId;
// the vacuum block is edited live outside the dialog transaction —
// the rebuild below must carry it over, not erase it
const prevVac = cfg.markers.find((m0: Marker) => m0.id === id || m0.id === oldId)?.vacuum || null;
const marker: Marker = {
id,
vacuum: prevVac,
binding: dlg.binding,
name: dlg.name.trim() || null,
icon: dlg.icon || null,
@@ -4220,6 +4243,7 @@ class HouseplanCard extends LitElement {
</svg>
<div class="devlayer" style="--icon-size:${((iconPct * vb[2] * (this._kiosk ? this._kioskScale.icon : 1)) / view.w).toFixed(3)}cqw;--rl-font:${this._kiosk ? this._kioskScale.font : 1}">
${devs.map((d) => this._renderDevice(d, view, showLqi, disp.fill === 'glow' && !this._markup))}
${this._renderVacuums(devs, view)}
${this._renderOpeningLocks(view)}
${disp.showNames || this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
@@ -4264,6 +4288,10 @@ class HouseplanCard extends LitElement {
</div>`
: nothing}
${this._kioskDialog ? this._renderKioskDialog() : nothing}
${this._vacCal ? html`<div class="vaccalbar">
<span>${subst(this._t('vac.cal_point'), { n: String(this._vacCal.pairs.length + 1) })}</span>
<button class="btn ghostbtn" @click=${() => { this._vacCal = null; }}>${this._t('btn.cancel')}</button>
</div>` : nothing}
${this._tapConfirm
? html`<div class="menuwrap dialogwrap" @click=${() => (this._tapConfirm = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
@@ -4283,6 +4311,252 @@ class HouseplanCard extends LitElement {
`;
}
// ---------------- live robot vacuums (docs/VACUUM.md) ----------------
/** The live-position source entity for a vacuum device, or null. */
private _vacSource(d: DevItem): string | null {
const v = d.marker?.vacuum;
if (v?.live === false) return null;
if (v?.source && this.hass?.states[v.source]) return v.source;
for (const eid of d.entities || []) {
if (isVacSourceState(this.hass?.states[eid])) return eid;
}
return null;
}
private _vacEntity(d: DevItem): string | null {
if (d.primary?.startsWith('vacuum.')) return d.primary;
return (d.entities || []).find((e) => e.startsWith('vacuum.')) || null;
}
private _isVacDev(d: DevItem): boolean {
return !!this._vacEntity(d);
}
/**
* Trail buffers live OUTSIDE render: every hass tick appends the raw robot
* position, so the trail survives view switches and zoom without ever being
* part of reactive state (600 points re-rendering the world would hurt).
*/
private _vacTick(): void {
if (!this.hass) return;
for (const d of this._devices) {
if (d.hidden || !this._isVacDev(d)) continue;
const src = this._vacSource(d);
if (!src) continue;
const vacEnt = this._vacEntity(d);
const moving = isVacMoving(this.hass.states[vacEnt || '']?.state);
const tele = readVacTelemetry(this.hass.states[src]?.attributes);
let rt = this._vacRt.get(d.id);
if (!rt) {
rt = { trail: [], lastKey: '', lastTs: 0, moving: false, jump: false, endedTs: 0 };
this._vacRt.set(d.id, rt);
}
if (moving && !rt.moving) rt.trail = []; // a fresh run clears the old trail
if (!moving && rt.moving) rt.endedTs = Date.now();
rt.moving = moving;
const pos = tele?.pos;
if (moving && pos) {
const key = pos.x + ':' + pos.y;
if (key !== rt.lastKey) {
const now = Date.now();
// a long silence then a far point = sparse cloud data: teleport, no glide
rt.jump = rt.lastTs > 0 && now - rt.lastTs > VAC_TELEPORT_GAP_MS;
rt.lastKey = key;
rt.lastTs = now;
if (d.marker?.vacuum?.trail !== false && !tele!.path) {
rt.trail = pushTrailPoint(rt.trail, [pos.x, pos.y], 40);
}
}
}
}
}
/** «Живая позиция» in the device dialog — vacuum markers only. */
private _renderVacSection(dlg: any): TemplateResult | typeof nothing {
const dev = this._devices.find((x) => x.id === dlg.devId);
if (!dev || !this._isVacDev(dev)) return nothing;
const v = dev.marker?.vacuum || {};
const src = this._vacSource(dev);
const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null;
const tierA = !!(tele && tele.rooms.length >= 3);
const status = tele?.pos
? subst(this._t('vac.status_found'), { name: src || '' })
: this._t('vac.status_none');
const cals = Object.keys(v.calibration || {});
const setVac = (patch: Record<string, unknown>) => {
const cfg = this._serverCfg;
const m = cfg?.markers?.find((x: Marker) => x.id === dev.id);
if (!m) return;
m.vacuum = { ...(m.vacuum || {}), ...patch };
this._regSignature = '';
this._saveConfig();
this.requestUpdate();
};
return html`
<label>${this._t('vac.section')}</label>
<div class="bindbox vacbox">
<div class="rhint">${status}</div>
${tele ? html`
<div class="vacbtns">
${tierA ? html`<button class="btn" @click=${() => this._vacAutoCalibrate(dev)}>${this._t('vac.autocal')}</button>` : nothing}
<button class="btn ghostbtn" @click=${() => this._vacStartWizard(dev)}>${this._t('vac.wizard')}</button>
</div>
<label class="srcrow">
<input type="checkbox" .checked=${v.live !== false}
@change=${(e: Event) => setVac({ live: (e.target as HTMLInputElement).checked ? null : false })} />
<span>${this._t('vac.live')}</span>
</label>
<label class="srcrow">
<input type="checkbox" .checked=${v.trail !== false}
@change=${(e: Event) => setVac({ trail: (e.target as HTMLInputElement).checked ? null : false })} />
<span>${this._t('vac.trail')}</span>
</label>
${cals.length ? html`<div class="rhint">${subst(this._t('vac.cal_maps'), { maps: cals.join(', ') })}</div>` : nothing}
` : nothing}
</div>`;
}
/** Persist a solved matrix into marker.vacuum.calibration[mapId]. */
private _vacSaveMatrix(markerId: string, source: string, mapId: string, matrix: Affine): void {
const cfg = this._serverCfg;
const m = cfg?.markers?.find((x: Marker) => x.id === markerId);
if (!m) return;
const v = { ...(m.vacuum || {}) };
v.source = source;
v.calibration = { ...(v.calibration || {}), [mapId]: matrix.map((n) => Number(n.toFixed(6))) };
m.vacuum = v;
this._regSignature = '';
this._saveConfig();
this.requestUpdate();
}
/** «Настроить автоматически»: robot rooms ↔ plan rooms by name. */
private _vacAutoCalibrate(d: DevItem): void {
const src = this._vacSource(d);
const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null;
if (!src || !tele || tele.rooms.length < 3) {
this._showToast(this._t('vac.autocal_no_rooms'));
return;
}
const sp = this._spaceModel(d.space);
const planRooms = (sp?.rooms || [])
.filter((r: any) => r.name && r.poly?.length >= 3)
.map((r: any) => {
const c = poleOfInaccessibility(r.poly);
return { name: r.name, cx: c[0], cy: c[1] };
});
const res = autoCalibrate(tele.rooms, planRooms);
if (!res) {
this._showToast(this._t('vac.autocal_no_match'));
return;
}
// residual gate: 5% of the canvas ≈ 40 cm in a typical house
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._showToast(subst(this._t('vac.autocal_done'), { rooms: String(res.matched.length) }));
}
/** «Калибровка по трём точкам»: arm the wizard and leave the dialog. */
private _vacStartWizard(d: DevItem): void {
const src = this._vacSource(d);
const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null;
if (!src || !tele?.pos) {
this._showToast(this._t('vac.cal_need_pos'));
return;
}
this._markerDialog = null;
this._vacCal = { markerId: d.id, source: src, mapId: tele.mapId, pairs: [] };
}
private _vacCalClick(ev: MouseEvent): void {
const cal = this._vacCal;
if (!cal) return;
const tele = readVacTelemetry(this.hass?.states[cal.source]?.attributes);
if (!tele?.pos) {
this._showToast(this._t('vac.cal_need_pos'));
return;
}
const pt = this._svgPoint(ev);
const pairs = [...cal.pairs, [[tele.pos.x, tele.pos.y], [pt[0], pt[1]]] as [VacPt, VacPt]];
if (pairs.length < 3) {
this._vacCal = { ...cal, pairs };
return;
}
const matrix = solveAffine(pairs);
if (!matrix) {
// collinear or repeated points — drop the last one and ask again
this._vacCal = { ...cal, pairs: pairs.slice(0, -1) };
this._showToast(this._t('vac.cal_degenerate'));
return;
}
if (affineResidual(matrix, pairs) > NORM_W * 0.05 && pairs.length < 4) {
this._vacCal = { ...cal, pairs };
this._showToast(this._t('vac.cal_res_warn'));
return;
}
this._vacSaveMatrix(cal.markerId, cal.source, cal.mapId, matrix);
this._vacCal = null;
this._showToast(this._t('vac.cal_done'));
}
/** Puck + trail for every live vacuum of the space. */
private _renderVacuums(devs: DevItem[], view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
if (this._markup || this._mode === 'decor') return nothing;
const pucks: TemplateResult[] = [];
const trails: TemplateResult[] = [];
for (const d of devs) {
if (d.hidden || !this._isVacDev(d)) continue;
const src = this._vacSource(d);
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;
if (!matrix || matrix.length !== 6) continue;
const rt = this._vacRt.get(d.id);
const moving = rt?.moving ?? false;
const lingering = !moving && rt?.endedTs && Date.now() - rt.endedTs < VAC_TRAIL_LINGER_MS;
// trail: integration path wins (it predates the card being opened)
if (d.marker?.vacuum?.trail !== false && (moving || lingering)) {
const raw: VacPt[] = tele.path || rt?.trail || [];
if (raw.length > 1) {
const ptsStr = raw.map(([x, y]) => {
const [cx, cy] = applyAffine(matrix, x, y);
return cx.toFixed(1) + ',' + cy.toFixed(1);
}).join(' ');
trails.push(svg`<polyline points="${ptsStr}"></polyline>`);
}
}
if (!moving || !tele.pos) continue;
const [cx, cy] = applyAffine(matrix, tele.pos.x, tele.pos.y);
const left = ((cx - view.x) / view.w) * 100;
const top = ((cy - view.y) / view.h) * 100;
const stale = rt && rt.lastTs > 0 && Date.now() - rt.lastTs > VAC_STALE_MS;
// heading follows the map rotation baked into the matrix
const wedge = tele.pos.a != null
? tele.pos.a + (Math.atan2(matrix[3], matrix[0]) * 180) / Math.PI
: null;
const icon = d.marker?.icon || d.icon || 'mdi:robot-vacuum';
pucks.push(html`<div
class="vacpuck ${rt?.jump ? 'jump' : ''} ${stale ? 'stale' : ''}"
style="left:${left}%;top:${top}%"
title=${d.name}
@click=${(e: Event) => { e.stopPropagation(); const ve = this._vacEntity(d); if (ve) this._openMoreInfo(ve); }}>
${wedge != null ? html`<div class="vacwedge" style="transform:rotate(${wedge}deg)"></div>` : nothing}
<ha-icon .icon=${icon}></ha-icon>
</div>`);
}
if (!pucks.length && !trails.length) return nothing;
return html`
${trails.length ? svg`<svg class="vactrail" viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="none">${trails}</svg>` : nothing}
${pucks}`;
}
private _renderDevice(d: DevItem, view: { x: number; y: number; w: number; h: number }, showLqi = true, glowFill = false): TemplateResult {
const p = this._pos(d);
const left = ((p.x - view.x) / view.w) * 100;
@@ -5383,6 +5657,8 @@ class HouseplanCard extends LitElement {
)}
</select>
${this._renderVacSection(d)}
<label>${this._t('marker.tap_label')}</label>
<select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>