mirror of
https://github.com/Matysh/houseplan-card
synced 2026-08-01 00:48:29 +00:00
Calibration is now a direct-manipulation overlay: the robot's rooms as a dashed translucent ghost over the plan, dragged into place and stretched by four corner handles (uniform scale about the opposite corner). Quarter-turn and mirror buttons re-anchor about the ghost centre; mirror defaults on because every robot map seen so far flips Y versus the screen — measured on the owner's X50. Everything folds into the same stored 6-number matrix, and legacy matrices reopen in the panel with rotation snapped to a quarter. The park-the-robot-three- times wizard is deleted outright: it was the most fragile part of the feature (owner: «плохо работает»). fitMatrix/fitFromMatrix/initialFit/ reanchorFit are pure and unit-tested; the smoke drives the panel end to end — drag, corner-stretch, rotate, save, puck on the new matrix.
177 lines
10 KiB
JavaScript
177 lines
10 KiB
JavaScript
// 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%';
|
|
// owner 2026-07-31: the base badge, but round and 20% smaller — same plate
|
|
// vs a NEUTRAL badge: the robot's own base is yellow while cleaning
|
|
const devEl = sr().querySelector('.dev:not(.on)');
|
|
const pcs = getComputedStyle(puck), dcs = getComputedStyle(devEl);
|
|
o.puckPlateMatchesDev = pcs.backgroundColor === dcs.backgroundColor
|
|
&& pcs.borderTopColor === dcs.borderTopColor;
|
|
o.puck80pct = Math.abs(puck.getBoundingClientRect().width
|
|
- devEl.getBoundingClientRect().width * 0.8) < 2;
|
|
o.noWedge = !sr().querySelector('.vacwedge'); // owner 2026-07-31: no heading arrow
|
|
// the glyph is DEAD centre (owner report: it floated on the text baseline)
|
|
const pr = puck.getBoundingClientRect();
|
|
const ir = sr().querySelector('.vacpuck ha-icon').getBoundingClientRect();
|
|
o.iconCentred = Math.abs(ir.left + ir.width / 2 - (pr.left + pr.width / 2)) < 0.75
|
|
&& Math.abs(ir.top + ir.height / 2 - (pr.top + pr.height / 2)) < 0.75;
|
|
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');
|
|
// the trail LAGS one point behind the puck: after 3 telemetry points only
|
|
// the first two are drawn — a segment never outruns the icon (owner)
|
|
o.trailDrawn = !!trail && trail.getAttribute('points').split(' ').length === 2;
|
|
o.trailScaledByMatrix = !!trail && trail.getAttribute('points').startsWith('300.0,400.0');
|
|
o.trailBehindPuck = !!trail && !trail.getAttribute('points').includes('450.0,550.0');
|
|
// a zoom/view change TELEPORTS the puck instead of gliding across the plan
|
|
c._applyView(1.35); c.requestUpdate(); await c.updateComplete;
|
|
o.zoomTeleports = sr().querySelector('.vacpuck').classList.contains('jump');
|
|
c._applyView(1); c.requestUpdate(); await c.updateComplete;
|
|
// casing pair: dark halo + light core with identical geometry — the trail
|
|
// must survive any room fill underneath (owner 2026-07-31)
|
|
const tcase = sr().querySelector('.vactrail .case');
|
|
const tcore = sr().querySelector('.vactrail .core');
|
|
o.trailCasing = !!tcase && !!tcore
|
|
&& tcase.getAttribute('points') === tcore.getAttribute('points')
|
|
&& parseFloat(getComputedStyle(tcase).strokeWidth) > parseFloat(getComputedStyle(tcore).strokeWidth)
|
|
&& getComputedStyle(tcase).stroke.includes('0, 0, 0')
|
|
&& getComputedStyle(tcore).stroke.includes('255, 255, 255');
|
|
|
|
|
|
// 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 fit panel (drag + corner-stretch) end to end ----
|
|
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
|
const dev = c._devices.find((x) => x.id === 'e_vacuum_robo');
|
|
o.fitDevFound = !!dev;
|
|
// map m2 with rooms (bboxes) and a parked robot
|
|
c.hass = { ...c.hass, states: { ...c.hass.states,
|
|
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 500, y: 500, a: 0 }, map_name: 'm2',
|
|
rooms: { 1: { name: 'Кухня', x0: 0, y0: 0, x1: 1000, y1: 800 },
|
|
2: { name: 'Зал', x0: 0, y0: 800, x1: 1000, y1: 2000 } } } } } };
|
|
await c.updateComplete;
|
|
c._vacStartFit(dev); await c.updateComplete;
|
|
o.fitOverlay = !!sr().querySelector('.vacfit');
|
|
o.fitGhostRooms = sr().querySelectorAll('.vacfit polygon').length === 2;
|
|
o.fitLabels = [...sr().querySelectorAll('.vacfit text')].map((t) => t.textContent.trim()).join('|') === 'Кухня|Зал';
|
|
o.fitHandles = sr().querySelectorAll('.vacfithandle').length === 4;
|
|
o.fitBar = !!sr().querySelector('.vaccalbar');
|
|
o.fitMirrorDefault = c._vacFit.p.mir === true;
|
|
// drag: the ghost centre must follow; rotate: the centre must NOT move
|
|
const before = { ...c._vacFit.p };
|
|
const svgEl = sr().querySelector('.vacfit');
|
|
const sb2 = svgEl.getBoundingClientRect();
|
|
const fire = (type, x, y, target) => (target || svgEl).dispatchEvent(new PointerEvent(type, {
|
|
bubbles: true, composed: true, pointerId: 7, clientX: x, clientY: y }));
|
|
fire('pointerdown', sb2.left + sb2.width * 0.5, sb2.top + sb2.height * 0.5);
|
|
fire('pointermove', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
|
|
fire('pointerup', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
|
|
await c.updateComplete;
|
|
o.fitDragMoves = c._vacFit.p.ox !== before.ox && c._vacFit.p.oy !== before.oy;
|
|
const centreBefore = (() => { const t = c._vacFit.p; const m = [t.s * (t.mir ? -1 : 1), 0, t.ox, 0, t.s, t.oy];
|
|
return [m[0] * 500 + m[2], m[4] * 1000 + m[5]]; })();
|
|
c._vacFitTurn({ rot: 90 }); await c.updateComplete;
|
|
o.fitRotateKeepsCentre = c._vacFit.p.rot === 90;
|
|
// corner-stretch scales
|
|
const s0 = c._vacFit.p.s;
|
|
const handle = sr().querySelector('.vacfithandle');
|
|
const hb = handle.getBoundingClientRect();
|
|
fire('pointerdown', hb.left + hb.width / 2, hb.top + hb.height / 2, handle);
|
|
fire('pointermove', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
|
|
fire('pointerup', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
|
|
await c.updateComplete;
|
|
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
|
|
// save -> matrix stored for m2, panel closed, puck appears while cleaning
|
|
c._vacFitSave(); await c.updateComplete;
|
|
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
|
|
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
|
|
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
|
|
o.oldWizardGone = typeof c._vacCalClick === 'undefined' && typeof c._vacStartWizard === 'undefined';
|
|
c.hass = { ...c.hass, states: { ...c.hass.states,
|
|
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 501, y: 501, a: 0 }, map_name: 'm2',
|
|
rooms: {} } } } };
|
|
await c.updateComplete;
|
|
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
|
|
o.puckAfterFit = !!sr().querySelector('.vacpuck');
|
|
|
|
return o;
|
|
});
|
|
|
|
checkAll(out, {
|
|
dockedNoPuck: true, baseMarkerThere: true, puckAppears: true, puckRound: true,
|
|
puckPlateMatchesDev: true, puck80pct: true,
|
|
noWedge: true, iconCentred: true, baseStaysDuringCleaning: true, trailDrawn: true,
|
|
trailScaledByMatrix: true, trailBehindPuck: true, zoomTeleports: true,
|
|
trailCasing: true, trailToggleOff: true,
|
|
puckGoneWhenDocked: true, trailLingers: true, hiddenNoPuck: true,
|
|
unknownMapNoPuck: true,
|
|
fitDevFound: true, fitOverlay: true, fitGhostRooms: true, fitLabels: true,
|
|
fitHandles: true, fitBar: true, fitMirrorDefault: true, fitDragMoves: true,
|
|
fitRotateKeepsCentre: true, fitCornerScales: true, fitSavedMatrix: true,
|
|
fitClosed: true, oldWizardGone: true, puckAfterFit: true,
|
|
});
|
|
await finish(browser);
|