mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -275,6 +275,22 @@ MARKER_SCHEMA = vol.Schema(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
),
|
||||
vol.Optional("tap_confirm"): vol.Any(bool, None),
|
||||
# live robot vacuums (docs/VACUUM.md): everything optional so configs
|
||||
# from older versions stay valid untouched
|
||||
vol.Optional("vacuum"): vol.Any(
|
||||
None,
|
||||
vol.Schema({
|
||||
vol.Optional("live"): vol.Any(bool, None),
|
||||
vol.Optional("trail"): vol.Any(bool, None),
|
||||
vol.Optional("room_highlight"): vol.Any(bool, None),
|
||||
vol.Optional("source"): vol.Any(str, None),
|
||||
# one 6-number affine per robot map; numbers must be finite
|
||||
vol.Optional("calibration"): vol.Schema(
|
||||
{str: vol.All([_finite], vol.Length(min=6, max=6))}
|
||||
),
|
||||
vol.Optional("segment_map"): vol.Schema({str: str}),
|
||||
}),
|
||||
),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Live vacuum P1 (docs/VACUUM.md): puck, trail, calibration, hidden rules.
|
||||
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;
|
||||
// a vacuum device bound to space f1, dock placed by layout, plus a source
|
||||
// camera whose coords live in "robot mm": target = 0.02*x+? — we calibrate
|
||||
// via the 6-number matrix directly (0.5 scale: robot 0..2000 -> canvas 0..1000)
|
||||
const M = [0.5, 0, 0, 0, 0.5, 0];
|
||||
const mkAttrs = (x, y, extra) => ({ vacuum_position: { x, y, a: 45 }, map_name: 'm1', ...extra });
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(600, 800) },
|
||||
} };
|
||||
const cfg = c._serverCfg;
|
||||
cfg.markers = cfg.markers || [];
|
||||
cfg.markers.push({ id: 'e_vacuum_robo', binding: 'entity:vacuum.robo', space: 'f1',
|
||||
vacuum: { source: 'camera.robo_map', calibration: { m1: M } } });
|
||||
c._layout['e_vacuum_robo'] = { s: 'f1', x: 0.1, y: 0.1 };
|
||||
c._regSignature = ''; c._setMode('view'); await c.updateComplete;
|
||||
|
||||
o.dockedNoPuck = !sr().querySelector('.vacpuck');
|
||||
o.baseMarkerThere = !!sr().querySelector('.dev');
|
||||
|
||||
// start cleaning -> puck appears at transformed coords, base marker stays
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 50));
|
||||
let puck = sr().querySelector('.vacpuck');
|
||||
o.puckAppears = !!puck;
|
||||
o.puckRound = puck && getComputedStyle(puck).borderRadius === '50%';
|
||||
o.puckHasWedge = !!sr().querySelector('.vacwedge');
|
||||
o.baseStaysDuringCleaning = !!sr().querySelector('.dev');
|
||||
|
||||
// drive: two more points -> trail polyline grows; puck moves
|
||||
const p1 = puck.getBoundingClientRect();
|
||||
for (const [x, y] of [[900, 800], [900, 1100]]) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(x, y) } } };
|
||||
await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
const trail = sr().querySelector('.vactrail polyline');
|
||||
o.trailDrawn = !!trail && trail.getAttribute('points').split(' ').length >= 3;
|
||||
o.trailScaledByMatrix = !!trail && trail.getAttribute('points').startsWith('300.0,400.0');
|
||||
|
||||
// heading wedge rotates by a + matrix rotation (rotation=0 here)
|
||||
const wedge = sr().querySelector('.vacwedge');
|
||||
o.wedgeRotated = wedge && wedge.style.transform.includes('45');
|
||||
|
||||
// trail off via marker option
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = false;
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.trailToggleOff = !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = null;
|
||||
|
||||
// dock -> puck dissolves, trail lingers (linger window)
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } } } };
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
|
||||
o.puckGoneWhenDocked = !sr().querySelector('.vacpuck');
|
||||
o.trailLingers = !!sr().querySelector('.vactrail polyline');
|
||||
|
||||
// hidden marker renders neither puck nor trail
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = true;
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.hiddenNoPuck = !sr().querySelector('.vacpuck') && !sr().querySelector('.vactrail');
|
||||
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = false;
|
||||
|
||||
// no calibration for the active map -> no puck (graceful degradation)
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: mkAttrs(900, 1100, { map_name: 'm2' }) } } };
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
|
||||
|
||||
// ---- the three-point wizard end to end ----
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
const dev = c._devices.find((x) => x.id === 'e_vacuum_robo');
|
||||
o.wizardDevFound = !!dev;
|
||||
// robot parked at known coords on map m2 (uncalibrated so far)
|
||||
const putRobot = async (x, y) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x, y, a: 0 }, map_name: 'm2' } } } };
|
||||
await c.updateComplete;
|
||||
};
|
||||
await putRobot(200, 200);
|
||||
c._vacStartWizard(dev); await c.updateComplete;
|
||||
o.wizardBanner = !!sr().querySelector('.vaccalbar');
|
||||
// three clicks; the stage click handler converts client → canvas via
|
||||
// _svgPoint, so click through the real stage at chosen client points
|
||||
const stage = sr().querySelector('.stage');
|
||||
const sb = stage.getBoundingClientRect();
|
||||
const click = async (fx, fy) => {
|
||||
stage.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true,
|
||||
clientX: sb.left + sb.width * fx, clientY: sb.top + sb.height * fy }));
|
||||
await c.updateComplete;
|
||||
};
|
||||
await click(0.2, 0.2);
|
||||
await putRobot(1800, 200);
|
||||
await click(0.8, 0.2);
|
||||
await putRobot(1800, 1800);
|
||||
await click(0.8, 0.8);
|
||||
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
|
||||
o.wizardSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
|
||||
o.wizardClosed = !c._vacCal;
|
||||
// the freshly calibrated map now shows the puck while cleaning
|
||||
await putRobot(1000, 1000);
|
||||
o.puckAfterWizard = !!sr().querySelector('.vacpuck');
|
||||
|
||||
return o;
|
||||
});
|
||||
|
||||
checkAll(out, {
|
||||
dockedNoPuck: true, baseMarkerThere: true, puckAppears: true, puckRound: true,
|
||||
puckHasWedge: true, baseStaysDuringCleaning: true, trailDrawn: true,
|
||||
trailScaledByMatrix: true, wedgeRotated: true, trailToggleOff: true,
|
||||
puckGoneWhenDocked: true, trailLingers: true, hiddenNoPuck: true,
|
||||
unknownMapNoPuck: true,
|
||||
wizardDevFound: true, wizardBanner: true, wizardSavedMatrix: true,
|
||||
wizardClosed: true, puckAfterWizard: true,
|
||||
});
|
||||
await finish(browser);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+148
-31
File diff suppressed because one or more lines are too long
@@ -807,3 +807,16 @@ require hands on real hardware — they remain for the human pass.
|
||||
crossing wall-segment boundaries
|
||||
- [ ] Single click still opens the status card; double click opens the properties dialog;
|
||||
a drag does NOT open either
|
||||
|
||||
## Live vacuums (docs/VACUUM.md, P1)
|
||||
|
||||
- Docked robot: only the base marker, at the user-placed spot. No puck.
|
||||
- Cleaning + calibrated map: a round pulsing puck (no badge plate) drives the
|
||||
plan; the base marker never moves. Heading wedge follows angle+matrix
|
||||
rotation; no angle attribute — no wedge.
|
||||
- Trail: integration `path` preferred, else self-recorded; lingers 10 min
|
||||
after docking; a new run clears it; `trail: false` hides it.
|
||||
- Hidden marker: neither puck nor trail. Uncalibrated active map: no puck.
|
||||
- Wizard: 3 stage clicks with the robot parked at 3 spots → matrix saved per
|
||||
map id; Esc cancels; collinear points are rejected with a toast.
|
||||
- Auto-calibration needs ≥3 rooms matched by name between the robot and plan.
|
||||
|
||||
+277
-1
@@ -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
@@ -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
@@ -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": "Калибровка отменена"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
/** Ramer–Douglas–Peucker; 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';
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { solveAffine, applyAffine, affineResidual, readVacTelemetry, autoCalibrate, thinPath, pushTrailPoint, isVacMoving, isVacSourceState } from '../test-build/vacuum.js';
|
||||
|
||||
test('solveAffine recovers rotation+scale+mirror+offset exactly', () => {
|
||||
// target = mirror-X, rotate 90°, scale 0.02, offset (300, 400)
|
||||
const f = ([x, y]) => [300 + 0.02 * y, 400 + 0.02 * x];
|
||||
const src = [[1000, 2000], [8000, 2500], [3000, 9000], [7000, 7000]];
|
||||
const m = solveAffine(src.map((p) => [p, f(p)]));
|
||||
assert.ok(m);
|
||||
for (const p of [[5000, 5000], [0, 0], [12000, 3000]]) {
|
||||
const got = applyAffine(m, p[0], p[1]);
|
||||
const want = f(p);
|
||||
assert.ok(Math.hypot(got[0] - want[0], got[1] - want[1]) < 1e-6, String(p));
|
||||
}
|
||||
assert.ok(affineResidual(m, src.map((p) => [p, f(p)])) < 1e-6);
|
||||
});
|
||||
|
||||
test('solveAffine rejects degenerate input', () => {
|
||||
assert.equal(solveAffine([[[0, 0], [0, 0]], [[1, 1], [1, 1]]]), null); // 2 pairs
|
||||
// collinear
|
||||
assert.equal(solveAffine([[[0, 0], [0, 0]], [[1, 0], [1, 0]], [[2, 0], [2, 0]]]), null);
|
||||
assert.equal(solveAffine([[[0, NaN], [0, 0]], [[1, 0], [1, 0]], [[2, 3], [2, 0]]]), null);
|
||||
});
|
||||
|
||||
test('readVacTelemetry: Map Extractor shape', () => {
|
||||
const t = readVacTelemetry({
|
||||
vacuum_position: { x: 25500, y: 24800, a: 271 },
|
||||
path: [{ x: 25000, y: 24000 }, { x: 25100, y: 24100 }],
|
||||
rooms: { 16: { name: 'Kitchen', x0: 20000, y0: 20000, x1: 26000, y1: 25000 } },
|
||||
map_name: '0',
|
||||
});
|
||||
assert.deepEqual(t.pos, { x: 25500, y: 24800, a: 271 });
|
||||
assert.deepEqual(t.path, [[25000, 24000], [25100, 24100]]);
|
||||
assert.equal(t.rooms[0].name, 'Kitchen');
|
||||
assert.equal(t.rooms[0].cx, 23000);
|
||||
assert.equal(t.mapId, '0');
|
||||
});
|
||||
|
||||
test('readVacTelemetry: Valetudo/Tasshack shapes + junk safety', () => {
|
||||
const t = readVacTelemetry({ robot_position: { x: '120', y: '340', angle: '90' }, rooms: [{ id: 7, name: 'Спальня', cx: 10, cy: 20 }] });
|
||||
assert.deepEqual(t.pos, { x: 120, y: 340, a: 90 });
|
||||
assert.equal(t.rooms[0].id, '7');
|
||||
assert.equal(readVacTelemetry({ vacuum_position: { x: 'nope', y: 1 } }), null);
|
||||
assert.equal(readVacTelemetry({}), null);
|
||||
assert.equal(readVacTelemetry(null), null);
|
||||
assert.ok(isVacSourceState({ attributes: { vacuum_position: { x: 1, y: 2 } } }));
|
||||
assert.ok(!isVacSourceState({ attributes: { battery: 1 } }));
|
||||
});
|
||||
|
||||
test('autoCalibrate matches by name and solves', () => {
|
||||
const f = ([x, y]) => [0.01 * x + 100, -0.01 * y + 900];
|
||||
const vac = [
|
||||
{ id: '1', name: 'Kitchen', cx: 1000, cy: 2000 },
|
||||
{ id: '2', name: 'Bed Room', cx: 9000, cy: 2500 },
|
||||
{ id: '3', name: 'office', cx: 3000, cy: 8000 },
|
||||
{ id: '4', name: 'Garage', cx: 5000, cy: 5000 }, // no plan match
|
||||
];
|
||||
const plan = [
|
||||
{ name: 'kitchen', cx: f([1000, 2000])[0], cy: f([1000, 2000])[1] },
|
||||
{ name: 'BEDROOM', cx: f([9000, 2500])[0], cy: f([9000, 2500])[1] },
|
||||
{ name: 'Office', cx: f([3000, 8000])[0], cy: f([3000, 8000])[1] },
|
||||
];
|
||||
const r = autoCalibrate(vac, plan);
|
||||
assert.ok(r);
|
||||
assert.deepEqual(r.matched, ['Kitchen', 'Bed Room', 'office']);
|
||||
assert.ok(r.residual < 1e-6);
|
||||
// two matches only -> null
|
||||
assert.equal(autoCalibrate(vac.slice(0, 2), plan.slice(0, 2)), null);
|
||||
});
|
||||
|
||||
test('thinPath keeps corners, drops straight-line noise', () => {
|
||||
const pts = [];
|
||||
for (let i = 0; i <= 100; i++) pts.push([i, 0]);
|
||||
for (let i = 1; i <= 100; i++) pts.push([100, i]);
|
||||
const out = thinPath(pts, 0.5);
|
||||
assert.ok(out.length <= 5, String(out.length));
|
||||
assert.deepEqual(out[0], [0, 0]);
|
||||
assert.deepEqual(out[out.length - 1], [100, 100]);
|
||||
assert.ok(out.some((p) => p[0] === 100 && p[1] === 0)); // the corner survives
|
||||
});
|
||||
|
||||
test('pushTrailPoint dedups and respects the cap', () => {
|
||||
let buf = [];
|
||||
for (let i = 0; i < 3000; i++) buf = pushTrailPoint(buf, [i, (i * 7) % 13], 0.5);
|
||||
assert.ok(buf.length <= 600, String(buf.length));
|
||||
buf = pushTrailPoint(buf, buf[buf.length - 1], 0.5); // dup ignored
|
||||
assert.ok(buf.length <= 600);
|
||||
});
|
||||
|
||||
test('isVacMoving', () => {
|
||||
assert.ok(isVacMoving('cleaning'));
|
||||
assert.ok(isVacMoving('returning'));
|
||||
assert.ok(!isVacMoving('docked'));
|
||||
assert.ok(!isVacMoving('idle'));
|
||||
assert.ok(!isVacMoving(undefined));
|
||||
});
|
||||
@@ -923,3 +923,36 @@ def test_check_quota_refuses_when_the_disk_is_nearly_full(tmp_path, monkeypatch)
|
||||
with pytest.raises(plans.QuotaError) as e:
|
||||
plans.check_quota(d, 1, max_bytes=10 ** 12, max_files=10 ** 6)
|
||||
assert e.value.reason == "low_disk_space"
|
||||
|
||||
|
||||
class TestVacuum:
|
||||
"""marker.vacuum (docs/VACUUM.md): optional everywhere, matrices strict."""
|
||||
|
||||
def test_full_object_passes(self):
|
||||
v.MARKER_SCHEMA(_marker(vacuum={
|
||||
"live": True, "trail": True, "room_highlight": False,
|
||||
"source": "camera.robo_map",
|
||||
"calibration": {"0": [0.02, 0.0, 300.0, 0.0, 0.02, 400.0]},
|
||||
"segment_map": {"16": "kitchen"},
|
||||
}))
|
||||
|
||||
def test_absent_and_none_pass(self):
|
||||
v.MARKER_SCHEMA(_marker())
|
||||
v.MARKER_SCHEMA(_marker(vacuum=None))
|
||||
|
||||
def test_bad_matrices_rejected(self):
|
||||
import pytest
|
||||
for bad in (
|
||||
[1, 2, 3, 4, 5], # 5 numbers
|
||||
[1, 2, 3, 4, 5, 6, 7], # 7 numbers
|
||||
[1, 2, 3, 4, 5, float("nan")], # non-finite
|
||||
[1, 2, 3, 4, 5, float("inf")],
|
||||
[1, 2, 3, 4, 5, "x"], # junk type
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
v.MARKER_SCHEMA(_marker(vacuum={"calibration": {"0": bad}}))
|
||||
|
||||
def test_unknown_keys_rejected(self):
|
||||
import pytest
|
||||
with pytest.raises(Exception):
|
||||
v.MARKER_SCHEMA(_marker(vacuum={"teleport": True}))
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/logic.ts",
|
||||
"src/logic.ts", "src/vacuum.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts",
|
||||
|
||||
Reference in New Issue
Block a user