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
File diff suppressed because one or more lines are too long
+16
View File
@@ -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),
+126
View File
@@ -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
+148 -31
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -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
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';
}
+97
View File
@@ -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));
});
+33
View File
@@ -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
View File
@@ -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",