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 })}>
+20 -2
View File
@@ -355,5 +355,23 @@
"toast.run_started": "Started: {name}",
"toast.run_target_missing": "Run target not found — check the device settings",
"toast.run_target_required": "Pick an automation, script or scene",
"btn.run": "Run"
}
"btn.run": "Run",
"vac.section": "Robot vacuum: live position",
"vac.status_found": "Position source found: {name}",
"vac.status_none": "The integration reports no coordinates — the robot will only be shown at its base",
"vac.autocal": "Set up automatically",
"vac.wizard": "Three-point calibration",
"vac.live": "Live position on the plan",
"vac.trail": "Show the cleaning trail",
"vac.cal_maps": "Calibrated maps: {maps}",
"vac.autocal_no_rooms": "The integration reports no room list — use point calibration",
"vac.autocal_no_match": "Room names did not match (need ≥3 in common) — use point calibration",
"vac.autocal_res_warn": "Matched {rooms} rooms but the fit is rough — verify and refine with points if needed",
"vac.autocal_done": "Done: bound via {rooms} rooms. Start a cleanup and check",
"vac.cal_point": "Point {n} of 3: click the spot on the plan where the robot is standing right now",
"vac.cal_need_pos": "The robot is not reporting coordinates — start a cleanup and pause it",
"vac.cal_degenerate": "Points are too close or collinear — pick another spot",
"vac.cal_res_warn": "The fit is imprecise — add a fourth point in another corner",
"vac.cal_done": "Calibration saved. Start a cleanup and check",
"vac.cal_cancelled": "Calibration cancelled"
}
+20 -2
View File
@@ -355,5 +355,23 @@
"toast.run_started": "Запущено: {name}",
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
"btn.run": "Выполнить"
}
"btn.run": "Выполнить",
"vac.section": "Робот-пылесос: живая позиция",
"vac.status_found": "Источник координат найден: {name}",
"vac.status_none": "Интеграция не отдаёт координаты — робот будет показан только на базе",
"vac.autocal": "Настроить автоматически",
"vac.wizard": "Калибровка по трём точкам",
"vac.live": "Живая позиция на плане",
"vac.trail": "Показывать след уборки",
"vac.cal_maps": "Откалиброваны карты: {maps}",
"vac.autocal_no_rooms": "Интеграция не отдаёт список комнат — используйте калибровку по точкам",
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — используйте калибровку по точкам",
"vac.autocal_res_warn": "Совпало комнат: {rooms}, но привязка грубовата — проверьте и при необходимости откалибруйте по точкам",
"vac.autocal_done": "Готово: привязка по {rooms} комнатам. Запустите уборку и проверьте",
"vac.cal_point": "Точка {n} из 3: кликните на плане место, где робот стоит сейчас",
"vac.cal_need_pos": "Робот сейчас не отдаёт координаты — запустите уборку и поставьте на паузу",
"vac.cal_degenerate": "Точки слишком близко или на одной линии — выберите другое место",
"vac.cal_res_warn": "Привязка неточная — добавьте четвёртую точку в другом углу",
"vac.cal_done": "Калибровка сохранена. Запустите уборку и проверьте",
"vac.cal_cancelled": "Калибровка отменена"
}
+80
View File
@@ -1233,6 +1233,86 @@ export const cardStyles = css`
color: var(--hp-muted);
font-size: 11px;
}
/* live vacuum: a round puck, no badge plate, soft pulse (docs/VACUUM.md) */
.vacpuck {
position: absolute;
width: var(--dev-size);
height: var(--dev-size);
border-radius: 50%;
transform: translate(-50%, -50%);
background: color-mix(in srgb, var(--hp-accent) 20%, transparent);
border: 1.5px solid var(--hp-accent);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 6;
/* glide between sparse position updates; .jump disables it so the robot
never appears to drive through walls after a data gap */
transition: left 1.2s linear, top 1.2s linear;
animation: vacpulse 2.2s ease-out infinite;
}
.vacpuck.jump { transition: none; }
.vacpuck.stale { opacity: 0.45; animation: none; }
.vacpuck ha-icon { --mdc-icon-size: calc(var(--dev-size) * 0.6); color: var(--hp-accent); }
.vacwedge {
position: absolute;
inset: 0;
pointer-events: none;
}
.vacwedge::before {
content: '';
position: absolute;
top: -5px;
left: 50%;
margin-left: -3.5px;
border-left: 3.5px solid transparent;
border-right: 3.5px solid transparent;
border-bottom: 6px solid var(--hp-accent);
}
@keyframes vacpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--hp-accent) 45%, transparent); }
70% { box-shadow: 0 0 0 12px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
@media (prefers-reduced-motion: reduce) {
.vacpuck { animation: none; }
}
.vactrail {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
overflow: visible;
}
.vactrail polyline {
fill: none;
stroke: var(--hp-accent);
stroke-opacity: 0.35;
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.vacbox .vacbtns { display: flex; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
.vaccalbar {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: flex;
gap: 12px;
align-items: center;
background: var(--hp-panel, #16212e);
color: var(--hp-fg, #e7eef7);
border: 1px solid var(--hp-accent);
border-radius: 10px;
padding: 10px 14px;
z-index: 60;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
}
.candlist {
max-height: 160px;
overflow-y: auto;
+9
View File
@@ -55,6 +55,15 @@ export interface Marker {
tap_target?: string | null;
/** Ask before toggle/run — accidental-tap guard (owner's spec). */
tap_confirm?: boolean | null;
/** live robot vacuum (docs/VACUUM.md); absent on non-vacuum markers */
vacuum?: {
live?: boolean | null;
trail?: boolean | null;
room_highlight?: boolean | null;
source?: string | null;
calibration?: Record<string, number[]>;
segment_map?: Record<string, string>;
} | null;
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
ripple_color?: string | null;
Executable
+212
View File
@@ -0,0 +1,212 @@
/**
* Live robot vacuums: coordinate math and integration adapters.
*
* Pure logic, no Lit — everything here is unit-tested directly. The renderer
* consumes three things: a solved affine matrix (vacuum mm → plan canvas
* units), normalised telemetry from whatever integration the user happens to
* run, and a thinned trail. See docs/VACUUM.md for the approved contract.
*/
export type Affine = [number, number, number, number, number, number];
export type Pt = [number, number];
/** target = [a b; d e]·source + [c f] */
export function applyAffine(m: Affine, x: number, y: number): Pt {
return [m[0] * x + m[1] * y + m[2], m[3] * x + m[4] * y + m[5]];
}
/**
* Least-squares affine over point pairs (≥3). Solves two independent 3-unknown
* systems via normal equations; returns null for degenerate input (collinear
* points make the normal matrix singular — the wizard asks for a spread).
*/
export function solveAffine(pairs: Array<[Pt, Pt]>): Affine | null {
if (pairs.length < 3) return null;
// normal matrix A^T A (3x3) and right-hand sides for tx and ty
let sxx = 0, sxy = 0, sx = 0, syy = 0, sy = 0, n = 0;
let bx0 = 0, bx1 = 0, bx2 = 0, by0 = 0, by1 = 0, by2 = 0;
for (const [[x, y], [tx, ty]] of pairs) {
if (![x, y, tx, ty].every(Number.isFinite)) return null;
sxx += x * x; sxy += x * y; sx += x; syy += y * y; sy += y; n += 1;
bx0 += x * tx; bx1 += y * tx; bx2 += tx;
by0 += x * ty; by1 += y * ty; by2 += ty;
}
const A = [sxx, sxy, sx, sxy, syy, sy, sx, sy, n];
const solve3 = (b: number[]): number[] | null => {
// Cramer via explicit inverse of the symmetric 3x3
const [a, b1, c, d, e, f, g, h, i] = A;
const det = a * (e * i - f * h) - b1 * (d * i - f * g) + c * (d * h - e * g);
if (!Number.isFinite(det) || Math.abs(det) < 1e-9) return null;
const inv = [
(e * i - f * h) / det, (c * h - b1 * i) / det, (b1 * f - c * e) / det,
(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det,
(d * h - e * g) / det, (b1 * g - a * h) / det, (a * e - b1 * d) / det,
];
return [
inv[0] * b[0] + inv[1] * b[1] + inv[2] * b[2],
inv[3] * b[0] + inv[4] * b[1] + inv[5] * b[2],
inv[6] * b[0] + inv[7] * b[1] + inv[8] * b[2],
];
};
const rx = solve3([bx0, bx1, bx2]);
const ry = solve3([by0, by1, by2]);
if (!rx || !ry) return null;
const m: Affine = [rx[0], rx[1], rx[2], ry[0], ry[1], ry[2]];
return m.every(Number.isFinite) ? m : null;
}
/** Worst residual in target units — the wizard warns above a threshold. */
export function affineResidual(m: Affine, pairs: Array<[Pt, Pt]>): number {
let worst = 0;
for (const [s, t] of pairs) {
const p = applyAffine(m, s[0], s[1]);
worst = Math.max(worst, Math.hypot(p[0] - t[0], p[1] - t[1]));
}
return worst;
}
// ---------------- telemetry adapters ----------------
export interface VacRoom { id: string; name: string; cx: number; cy: number }
export interface VacTelemetry {
pos: { x: number; y: number; a: number | null } | null;
path: Pt[] | null; // integration-provided full path, vacuum coords
rooms: VacRoom[]; // for auto-calibration; empty when unknown
mapId: string; // multi-floor robots: one calibration per map
}
const num = (v: unknown): number | null => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
/**
* Normalise the attribute zoo. One parser instead of per-brand classes: the
* three Tier-A integrations (Xiaomi Cloud Map Extractor, Tasshack
* dreame-vacuum, Valetudo camera) all descend from the map-card conventions
* and differ only in field spellings.
*/
export function readVacTelemetry(attrs: Record<string, any> | null | undefined): VacTelemetry | null {
if (!attrs) return null;
const p = attrs.vacuum_position || attrs.robot_position || null;
const pos = p && num(p.x) != null && num(p.y) != null
? { x: num(p.x)!, y: num(p.y)!, a: num(p.a ?? p.angle ?? p.theta) }
: null;
// path: [{x,y},…] (Map Extractor) or [[x,y],…]
let path: Pt[] | null = null;
const rawPath = attrs.path?.points ?? attrs.path;
if (Array.isArray(rawPath) && rawPath.length) {
path = [];
for (const q of rawPath) {
const x = num(Array.isArray(q) ? q[0] : q?.x);
const y = num(Array.isArray(q) ? q[1] : q?.y);
if (x != null && y != null) path.push([x, y]);
}
if (!path.length) path = null;
}
// rooms: {id:{name,x0,y0,x1,y1}} | [{id,name,x0..}] | {id:{name,outline}}
const rooms: VacRoom[] = [];
const rawRooms = attrs.rooms;
const entries: Array<[string, any]> = Array.isArray(rawRooms)
? rawRooms.map((r: any, i: number) => [String(r?.id ?? i), r])
: rawRooms && typeof rawRooms === 'object' ? Object.entries(rawRooms) : [];
for (const [id, r] of entries) {
if (!r || typeof r !== 'object') continue;
const name = String(r.name ?? r.label ?? '').trim();
let cx = num(r.cx ?? r.center?.x); let cy = num(r.cy ?? r.center?.y);
if (cx == null || cy == null) {
const x0 = num(r.x0), y0 = num(r.y0), x1 = num(r.x1), y1 = num(r.y1);
if (x0 != null && y0 != null && x1 != null && y1 != null) {
cx = (x0 + x1) / 2; cy = (y0 + y1) / 2;
}
}
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');
if (!pos && !rooms.length && !path) return null;
return { pos, path, rooms, mapId };
}
/** Attribute sets that mark an entity as a live-position source. */
export function isVacSourceState(st: { attributes?: Record<string, any> } | null | undefined): boolean {
const a = st?.attributes;
return !!(a && (a.vacuum_position || a.robot_position));
}
// ---------------- auto-calibration by rooms ----------------
const canonName = (s: string): string => s.toLowerCase().replace(/[\s_\-.,]+/g, '');
/**
* Match the robot's room list against plan rooms by name and solve the
* transform over centroids. Room centroids are coarse anchors, which is fine:
* the residual check below rejects a bad fit, and the user always sees a live
* preview before accepting.
*/
export function autoCalibrate(
vacRooms: VacRoom[],
planRooms: Array<{ name: string; cx: number; cy: number }>,
): { matrix: Affine; matched: string[]; residual: number } | null {
const byName = new Map(planRooms.map((r) => [canonName(r.name), r]));
const pairs: Array<[Pt, Pt]> = [];
const matched: string[] = [];
for (const vr of vacRooms) {
const pr = byName.get(canonName(vr.name));
if (!pr) continue;
pairs.push([[vr.cx, vr.cy], [pr.cx, pr.cy]]);
matched.push(vr.name);
}
if (pairs.length < 3) return null;
const matrix = solveAffine(pairs);
if (!matrix) return null;
return { matrix, matched, residual: affineResidual(matrix, pairs) };
}
// ---------------- trail ----------------
export const TRAIL_MAX = 600;
export const VAC_TELEPORT_GAP_MS = 10000;
export const VAC_STALE_MS = 60000;
export const VAC_TRAIL_LINGER_MS = 10 * 60000;
/** RamerDouglasPeucker; keeps ends, drops points under eps deviation. */
export function thinPath(pts: Pt[], eps: number): Pt[] {
if (pts.length < 3) return pts.slice();
const keep = new Uint8Array(pts.length);
keep[0] = keep[pts.length - 1] = 1;
const stack: Array<[number, number]> = [[0, pts.length - 1]];
while (stack.length) {
const [a, b] = stack.pop()!;
const [ax, ay] = pts[a]; const [bx, by] = pts[b];
const dx = bx - ax, dy = by - ay;
const len = Math.hypot(dx, dy) || 1e-9;
let worst = 0, wi = -1;
for (let i = a + 1; i < b; i++) {
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
if (d > worst) { worst = d; wi = i; }
}
if (wi > 0 && worst > eps) {
keep[wi] = 1;
stack.push([a, wi], [wi, b]);
}
}
const out: Pt[] = [];
for (let i = 0; i < pts.length; i++) if (keep[i]) out.push(pts[i]);
return out;
}
/** Append a point; over the cap → thin, and if thinning was not enough, decimate. */
export function pushTrailPoint(buf: Pt[], p: Pt, epsHint: number): Pt[] {
const last = buf[buf.length - 1];
if (last && last[0] === p[0] && last[1] === p[1]) return buf;
buf.push(p);
if (buf.length <= TRAIL_MAX) return buf;
let thinned = thinPath(buf, epsHint);
if (thinned.length > TRAIL_MAX) thinned = thinned.filter((_, i) => i % 2 === 0 || i === thinned.length - 1);
return thinned;
}
/** true → the robot is actively driving (a puck should exist). */
export function isVacMoving(state: string | undefined): boolean {
return state === 'cleaning' || state === 'returning' || state === 'on';
}