This commit is contained in:
Matysh
2026-07-31 12:07:50 +03:00
35 changed files with 3067 additions and 413 deletions
+5
View File
@@ -40,6 +40,11 @@ right on your Lovelace dashboard.
explicit button, never an accidental tap.
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
auto-carousel, per-screen icon sizes.
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
drives the plan in real time, pouring its path out from under itself;
current and previous cleanup runs are recorded server-side. Calibration is
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
+5
View File
@@ -38,6 +38,11 @@
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
+12
View File
@@ -50,6 +50,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
entry.runtime_data = data
# server-side vacuum trails: the integration records the path itself
from .trails import TrailRecorder
recorder = TrailRecorder(hass, data)
await recorder.async_setup()
# setdefault: the CI harness sets entries up without async_setup, so
# hass.data[DOMAIN] may not exist yet — a KeyError here failed EVERY
# downstream WS test with unknown_error
hass.data.setdefault(DOMAIN, {})["trail_recorder"] = recorder
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
plans_path = Path(hass.config.path(PLANS_DIR))
files_path = Path(hass.config.path(FILES_DIR))
@@ -194,6 +203,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
if rec:
rec.teardown()
"""Unload the entry.
WS commands and the HTTP view are global (async_setup) and stay registered —
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.53.1"
VERSION = "1.54.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.53.1"
"version": "1.54.0"
}
+190
View File
@@ -0,0 +1,190 @@
"""Server-side vacuum trails.
The integration records the robot's path ITSELF by watching the source
entity's state changes — no card involvement. This removes every client-side
race (N open tabs would fight over writes), survives page reloads by
construction, and keeps recording while no card is open at all. Stored: the
current run and one previous run per marker (owner call 2026-07-31 — users
want to see where the cleanup has already been).
"""
from __future__ import annotations
import time
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
from homeassistant.helpers.storage import Store
from .const import DOMAIN
import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
class TrailBook:
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
A run is {"map_id", "started", "ended", "points": [[x, y], …]} in RAW
robot coordinates — recalibration never invalidates a stored trail.
"""
def __init__(self, data: dict[str, Any] | None = None) -> None:
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
rec = self.data.setdefault(marker, {})
cur = rec.get("current")
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
# a new run begins: the old one becomes "previous" (and the one
# before it is forgotten — we keep exactly two, per the owner)
if cur:
rec["previous"] = cur
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
rec["current"] = cur
pts: list[list[float]] = cur["points"]
if pts and pts[-1][0] == x and pts[-1][1] == y:
return False
pts.append([x, y])
if len(pts) > TRAIL_CAP:
# decimate by two but never lose the freshest point
half = pts[0::2]
if half[-1] != pts[-1]:
half.append(pts[-1])
cur["points"] = half
return True
def end_run(self, marker: str, now: float) -> bool:
cur = (self.data.get(marker) or {}).get("current")
if cur and not cur.get("ended"):
cur["ended"] = now
return True
return False
class TrailRecorder:
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
self.hass = hass
self.rt = rt
self.store = Store(hass, 1, f"{DOMAIN}.trails")
self.book = TrailBook()
self.pairs: dict[str, tuple[str, str]] = {} # source → (marker, vacuum)
self._unsub_track = None
self._unsub_save = None
self._last_fire = 0.0
async def async_setup(self) -> None:
self.book = TrailBook(await self.store.async_load() or {})
await self.async_refresh()
async def async_refresh(self) -> None:
"""(Re)subscribe after any config change — markers may come and go."""
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
stored = await self.rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
self.pairs = {}
for m in cfg.get("markers") or []:
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
continue
vac = self._vacuum_entity(m)
if vac:
self.pairs[src] = (str(m.get("id")), vac)
ents = set(self.pairs) | {vac for _, vac in self.pairs.values()}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
# A run already in progress (HA restarted mid-cleanup, or the user just
# finished calibrating) must start recording NOW, not at the next
# state change — otherwise the first seconds of the path are lost.
for src in self.pairs:
self._sample(src, time.time())
def teardown(self) -> None:
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
b = str(m.get("binding") or "")
if b.startswith("entity:vacuum."):
return b[len("entity:"):]
if b.startswith("device:"):
reg = er.async_get(self.hass)
for e in er.async_entries_for_device(reg, b[len("device:"):]):
if e.entity_id.startswith("vacuum."):
return e.entity_id
return None
def _sample(self, src: str, now: float) -> bool:
"""Record one point (or end the run) for a single source entity."""
pair = self.pairs.get(src)
if not pair:
return False
marker, vac = pair
st_vac = self.hass.states.get(vac)
if not st_vac or st_vac.state not in MOVING_STATES:
return self.book.end_run(marker, now)
st_src = self.hass.states.get(src)
attrs = st_src.attributes if st_src else {}
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
# Server-side these attributes are often OBJECTS (Tasshack keeps a
# Point dataclass in memory — it only becomes a dict when serialised
# to the frontend). Caught live on the owner's X50: the recorder saw
# every state change and rejected every single one.
if isinstance(raw, dict):
px, py = raw.get("x"), raw.get("y")
else:
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
try:
x, y = float(px), float(py) # type: ignore[arg-type]
except (TypeError, ValueError):
return False
map_id = str(
attrs.get("map_name")
or attrs.get("current_map")
or attrs.get("map_index")
or st_vac.attributes.get("selected_map")
or "default"
)
return self.book.on_point(marker, map_id, x, y, now)
@callback
def _on_state(self, event: Any) -> None:
eid = event.data.get("entity_id")
now = time.time()
changed = False
for src, (_marker, vac) in self.pairs.items():
if eid in (src, vac):
changed |= self._sample(src, now)
if changed:
self._schedule_save()
if now - self._last_fire >= FIRE_THROTTLE_S:
self._last_fire = now
self.hass.bus.async_fire("houseplan_trail_updated", {})
def _schedule_save(self) -> None:
if self._unsub_save:
return
async def _save(_now: Any) -> None:
self._unsub_save = None
await self.store.async_save(self.book.data)
self._unsub_save = async_call_later(self.hass, SAVE_DELAY_S, _save)
+19
View File
@@ -275,6 +275,25 @@ 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("trail_mode"): vol.Any(
None, vol.In(["never", "cleaning", "always"])
),
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),
@@ -16,6 +16,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import (
DOMAIN,
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
@@ -40,6 +41,7 @@ _LOGGER = logging.getLogger(__name__)
def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_trail_get)
websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_geometry_repair)
websocket_api.async_register_command(hass, ws_layout_update)
@@ -692,6 +694,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
except Exception: # noqa: BLE001 — see above: the commit stands regardless
_LOGGER.exception("House Plan: collecting superseded files failed")
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
_refresh_trail_recorder(hass)
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)
if entry is not None:
@@ -767,3 +770,18 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
connection.send_error(msg["id"], err.reason, err.detail)
return
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
def _refresh_trail_recorder(hass: HomeAssistant) -> None:
"""Markers changed — the trail recorder must re-resolve what it watches."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
if rec:
hass.async_create_task(rec.async_refresh())
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
@websocket_api.async_response
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
"""Current + previous cleanup runs per marker, raw robot coordinates."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
+3 -1
View File
@@ -56,6 +56,8 @@ checkAll(res, {
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
"lqiFills": 0,
"atticSquare": true,
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
// Existing spaces with an absent fill_mode still resolve to 'none'.
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
});
await finish(browser, res);
+231
View File
@@ -0,0 +1,231 @@
// 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');
// default mode 'cleaning': the trail HIDES the moment the run ends (owner)
o.trailHiddenAfterDock = !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');
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'always';
c._vacSrvTrails = { e_vacuum_robo: {
current: { map_id: 'm1', started: 1, ended: null,
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
previous: { map_id: 'm1', started: 0, ended: 1,
points: [[600, 1300], [800, 1300], [800, 1500]] },
} };
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 950, y: 1150, a: 0 }, map_name: 'm1' } } } };
await c.updateComplete;
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
const prevG = sr().querySelector('.vactrail g.prev');
o.srvPrevRunShown = !!prevG && prevG.querySelectorAll('polyline').length === 2
&& prevG.querySelector('.case').getAttribute('points').split(' ').length === 3;
const curLines = [...sr().querySelectorAll('.vactrail > polyline.case, .vactrail g:not(.prev) polyline.case')];
const cur = curLines.find((x) => !x.closest('g.prev'));
// 4 server points, the live tail trimmed while moving -> 3 rendered
o.srvCurTrimmed = !!cur && cur.getAttribute('points').split(' ').length === 3;
o.srvPrevFaded = !!prevG && getComputedStyle(prevG).opacity === '0.4';
// the growing tip is glued to the puck by the rAF sampler
const tip = sr().querySelector('line.tip');
o.tipExists = !!tip;
await new Promise((r) => setTimeout(r, 250));
const puckNow = sr().querySelector('.vacpuck').getBoundingClientRect();
const svgT = sr().querySelector('.vactrail');
const vbT = svgT.viewBox.baseVal; const sbT = svgT.getBoundingClientRect();
const tx = sbT.left + ((parseFloat(tip.getAttribute('x2')) - vbT.x) / vbT.width) * sbT.width;
const ty = sbT.top + ((parseFloat(tip.getAttribute('y2')) - vbT.y) / vbT.height) * sbT.height;
o.tipGluedToPuck = Math.hypot(tx - (puckNow.left + puckNow.width / 2), ty - (puckNow.top + puckNow.height / 2)) < 12;
// 'never' kills every line including the previous run
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'never';
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.neverHidesAll = !sr().querySelector('.vactrail');
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = null;
c._vacSrvTrails = {};
// ---- 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;
// REAL hit-tests: what would an actual click land on? Synthetic dispatch
// bypasses pointer-events, and that let the untouchable overlay pass once.
const hitAt = (el) => {
const r = el.getBoundingClientRect();
let n = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
while (n && n.shadowRoot) {
const deeper = n.shadowRoot.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (!deeper || deeper === n) break;
n = deeper;
}
return n;
};
o.fitHandleHittable = hitAt(sr().querySelector('.vacfithandle'))?.classList?.contains('vacfithandle') === true;
o.fitGhostHittable = !!hitAt(sr().querySelector('.vacfit polygon'))?.closest?.('.vacfit');
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, trailHiddenAfterDock: true, hiddenNoPuck: true,
unknownMapNoPuck: true,
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
tipExists: true, tipGluedToPuck: true, neverHidesAll: true,
fitDevFound: true, fitOverlay: true, fitGhostRooms: true, fitLabels: true,
fitHandles: true, fitHandleHittable: true, fitGhostHittable: true, fitBar: true, fitMirrorDefault: true, fitDragMoves: true,
fitRotateKeepsCentre: true, fitCornerScales: true, fitSavedMatrix: true,
fitClosed: true, oldWizardGone: true, puckAfterFit: true,
});
await finish(browser);
File diff suppressed because one or more lines are too long
+309 -131
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -17,6 +17,7 @@ houseplan-card/
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
├─ custom_components/houseplan/ # the HA integration
│ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
│ ├─ trails.py # server-side vacuum trail recorder (state-change driven)
│ ├─ websocket_api.py # houseplan/layout/get|set|update
│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
@@ -189,6 +190,7 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## v1.54.0 — 2026-07-31
### Live robot vacuums
The plan now shows the robot while it works. The device marker stays where
you put it — that is the dock — and a round puck drives the plan in real
time, pouring its path out from under itself. Everything is display only:
the card never commands the robot.
- **Calibration without arithmetic.** «Настроить автоматически» matches the
robot's room list against your rooms by name and solves the transform in
one click. When names do not match, the fit panel lays the robot's rooms
over the plan as a dashed ghost: drag it into place, stretch it by the
corner handles, quarter-turn and mirror with two buttons. Mirror is on by
default — every robot map we have measured flips Y versus the screen.
One calibration per robot map, so two floors stay independent.
- **The path is recorded server-side.** The integration watches the robot
itself, so the trail records with no card open, survives page reloads, and
every screen sees the same line. The current run and one previous run are
kept — cleaned versus not-yet-cleaned at a glance.
- **«Показывать путь робота»**: never / while cleaning (default) / always.
Only the last mode also draws the previous run, faded.
- The trail never runs ahead of the icon: drawn segments lag one point and
the growing tip is glued to the puck's animated centre every frame. It is
drawn as a dark halo under a light core, so it stays readable over any
room fill. The puck teleports instead of gliding when the view changes —
zoom, floor switch or a return to the browser tab.
- Adapters: Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack), Valetudo.
Verified against a live Dreame X50 Master.
### Also
- **«Свет по источникам» is the default fill for new spaces** and leads the
options list. Existing plans are untouched: a space whose owner never
chose a fill still renders as before.
- **Fixed: the "what to run" search showed no results.** The list is a
scrollable box and, as a flex item, collapsed into a 1px sliver — the
matches were there, rendered into nothing.
## v1.53.1 — 2026-07-30
- **Fix: the "what to run" search showed no results.** The results were
+39
View File
@@ -6,6 +6,45 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.54.0 — 2026-07-31
### Роботы-пылесосы вживую
План теперь показывает робота за работой. Маркер устройства остаётся там,
куда вы его поставили — это база, — а по плану в реальном времени едет
круглая шайба, «выливая» путь из-под себя. Только отображение: карточка
ничего роботу не командует.
- **Калибровка без арифметики.** «Настроить автоматически» сопоставляет
комнаты робота с вашими по именам и решает привязку в один клик. Если
имена не совпали, панель подгонки кладёт комнаты робота на план
пунктирным призраком: перетащите на место, растяните за уголки, поворот
на 90° и зеркало — двумя кнопками. Зеркало включено по умолчанию: у всех
измеренных нами роботов ось Y перевёрнута относительно экрана. Своя
калибровка на каждую карту робота, поэтому два этажа не мешают друг другу.
- **Путь пишется на сервере.** Интеграция следит за роботом сама, поэтому
след записывается даже без открытой карточки, переживает перезагрузку
страницы, и все экраны видят одну и ту же линию. Хранятся текущая уборка
и одна предыдущая — сразу видно, где робот уже прошёл, а где ещё нет.
- **«Показывать путь робота»**: никогда / во время уборки (по умолчанию) /
всегда. Прошлая уборка бледной линией рисуется только в последнем режиме.
- След никогда не обгоняет иконку: отрисованные отрезки отстают на точку, а
растущий кончик каждый кадр приклеен к центру едущей шайбы. Рисуется
тёмным ореолом со светлой сердцевиной — читается на любой заливке комнат.
При смене вида (зум, этаж, возврат на вкладку) шайба телепортируется, а
не едет через весь план.
- Поддержаны Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack) и
Valetudo. Проверено на живом Dreame X50 Master.
### Кроме того
- **«Свет по источникам» — режим по умолчанию для новых пространств** и
первый в списке. Существующие планы не трогаем: пространство, где режим
не выбирали, выглядит как раньше.
- **Исправлено: поиск «что запускать» не показывал результатов.** Список —
прокручиваемый блок, и как flex-элемент он схлопывался в полоску 1px:
совпадения были, но отрисовывались в ничто.
## v1.53.1 — 2026-07-30
- **Фикс: поиск «что запускать» не показывал результатов.** Результаты были —
+1 -1
View File
@@ -9,7 +9,7 @@ An interactive floor plan for Home Assistant delivered as one HACS package:
a storage **integration** (server-side config, WS API, file uploads, auth) + a
**Lovelace card** (rendering, room markup editor, drag layout, zoom, live states,
temperature, Zigbee LQI, device metadata with PDF manuals, virtual markers,
en/ru localization). GUI-first: no YAML, no hand-made SVG.
live robot vacuums, en/ru localization). GUI-first: no YAML, no hand-made SVG.
## Competitive landscape (stars verified 2026-07-06)
+7 -1
View File
@@ -57,7 +57,13 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
mapping (regex/domain/device_class → mdi icon) stored in config, shipping EN+RU
defaults; drop dacha-specific patterns from code.
- [x] **Click actions** per device (v1.13.0, simplified v1.38.1): card / more-info /
toggle, with the lock/alarm safety model.
toggle, with the lock/alarm safety model; running an automation/script/scene
with an optional confirmation landed in v1.53.0.
- [x] **Live robot vacuums** (docs/VACUUM.md): a puck driving the plan over a
solved affine transform, one-click calibration by room names or a
drag-and-stretch fit panel, server-recorded trails (current + previous run)
with never/cleaning/always display modes. Adapters: Xiaomi Cloud Map
Extractor, Tasshack dreame-vacuum, Valetudo. Display only — no commands.
- [x] **Theming**: light-theme pass done in v1.13.0; HA theme variables
everywhere, optional per-space background color.
- [ ] Multi-instance question: keep single-instance (one house) but support **multiple
+4 -2
View File
@@ -15,14 +15,16 @@
| Item | State |
|---|---|
| Version | **v1.53.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.54.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant``/config` does NOT exist in this SSH environment), deployed **v1.53.1** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant``/config` does NOT exist in this SSH environment), deployed **v1.54.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Host: `ssh -i ~/.ssh/hp_stand hp@135.106.166.146`; layout, seeds and gotchas in the memory note `houseplan-demo-stand` |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
+26
View File
@@ -807,3 +807,29 @@ 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)
- Docked robot: only the base marker, at the user-placed spot (the dock).
- Cleaning + calibrated map: a round pulsing puck — the base badge but
circular and 20% smaller, same plate colors, glyph dead-centre — drives
the plan; the base marker never moves. No heading arrow.
- Puck motion: glides ~1.2 s between telemetry points; TELEPORTS (no glide)
on zoom, pan, space switch, browser-tab return, and after a >10 s data
gap. Stale coords (>60 s while cleaning) freeze and dim it.
- «Показывать путь робота» has three modes: never / while cleaning (default
— the line hides the instant the run ends) / always (the only mode that
also shows the previous run at 40% opacity).
- The trail is server-recorded (trails.py watches the source entity; two
runs kept per marker, survives reloads, shared by every screen) and never
outruns the icon: drawn segments lag one point, and the last segment is a
rAF-driven tip glued to the puck centre every frame.
- Trail style: cartography casing (dark halo 2.25 + light core 0.9),
readable over any room fill.
- Hidden marker: neither puck nor trail. Uncalibrated active map: no puck.
- Calibration: «Настроить автоматически» (≥3 rooms matched by name) or the
fit panel — drag the dashed room ghost, stretch by 4 corner handles,
rotate 90°/mirror buttons (mirror ON by default), Save/Cancel/Esc. Real
clicks must land on the overlay (elementFromPoint smoke guards it).
- Multi-floor: one matrix per robot map (Dreame `selected_map` on the
vacuum entity names the active one).
+152
View File
@@ -0,0 +1,152 @@
# Live robot vacuums on the plan — the spec (source of truth)
Status: approved by the owner 2026-07-31. Scope decisions final: all
three Tier-A adapters in P1, the trail ships in P1, the marker's placed
position IS the dock, and tap-to-clean is out — display only, no
commands (owner: «не нужно вообще»).
## Principle
The device marker «Пылесос» NEVER moves: it stands where the user
placed it — that is the base/dock — with its normal badge, states and
tap actions. While the robot cleans, a SECOND visual appears: a round
puck (the vacuum icon in a circle), no badge plate, with a soft
pulse, driving around the plan on live coordinates. Owner's exact
wording: «Иконка движущегося пылесоса должна быть круглой, без
подложки и с легкой пульсацией. Основное устройство "Пылесос" при этом
остается всегда на своем месте (база)». The puck is not clickable UI
chrome duplicating the device — a tap on it opens the same more-info as
the base marker. When the robot docks, the puck drives home and
dissolves into the base marker.
## Data tiers (auto-detected, zero user input)
- **Tier A — coordinates + own calibration data.** `vacuum_position
{x,y,angle}` (+ `calibration_points`, rooms, sometimes `path`):
Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack), Valetudo camera
(sca075). All three ship in P1.
- **Tier B — bare coordinates.** Roomba core (`position {x,y,theta}`),
raw Valetudo MQTT, template sensors. Works after manual calibration.
P3.
- **Tier C — current room only.** `current_room` / `current_segment`.
The room being cleaned gets a soft fill pulse + a 🤖 badge in the
room card; no puck. Segment→room matched by name, manual override in
the dialog.
- **Tier D — nothing.** Today's behaviour; yellow badge on the base
marker while cleaning per the «yellow = working right now» principle.
Tiers degrade gracefully: coords gone → room highlight; that gone too →
just the base marker.
## Coordinate binding
Affine transform (translate+rotate+scale+mirror), 6 numbers, least
squares over ≥3 point pairs. Stored per robot map:
`marker.vacuum.calibration[map_id]` — multi-floor robots get one matrix
per map; an active map without a matrix falls back to Tier C.
- **Path 1, auto-calibration by rooms (the default):** Tier-A adapters
expose the robot's room list with coordinates; our rooms carry HA
area bindings. Match by name, take centroids of ≥3 matches, solve,
show a live preview («робот сейчас здесь») — one click to confirm.
- **Path 2, the fit panel (replaced the 3-point wizard, owner call
2026-07-31):** the robot's rooms render as a translucent dashed ghost
over the plan; the user DRAGS the ghost into place and stretches it by
its four corner handles (uniform scale about the opposite corner, like
a graphics-editor frame). Rotation is quarter-turn buttons, mirror is
one toggle — both re-anchor about the ghost centre. Mirror defaults ON:
every robot map seen so far has Y flipped versus the screen. No numeric
fields; the old park-the-robot-three-times wizard is gone entirely —
it was the most fragile part of the feature.
- The panel folds into the same stored 6-number matrix
(S·R(rot)·mirror + offset); legacy matrices reopen in the panel with
the rotation snapped to the nearest quarter.
## Puck behaviour
Appears when the robot leaves `docked` AND live coords flow; CSS
interpolation ~1.2 s between updates; a data gap over 10 s teleports
without animation (no gliding through walls on sparse cloud updates).
Soft pulse always while visible (respects prefers-reduced-motion: the
pulse freezes, position still animates). Size: same --icon-size
system as devices, circle, transparent background, no badge plate.
Stale coords (>60 s while «cleaning»): puck freezes and dims. On
`docked`/`idle`-at-base the puck rides home and fades out. Hidden
markers render neither base nor puck (general hidden rules). Works in
the full card, static card and kiosk; in editors no puck — the base
marker is edited as usual.
## Trail (ships in P1)
- Integration `path` when available (Map Extractor) — transform and
draw as-is, includes history from before the card was opened.
- Otherwise self-recorded: client-side ring buffer, ~600 points with
Douglas-Peucker thinning, one SVG polyline.
- Style fixed, no options: cartography casing — a dark translucent halo under a light core. Neutral (pure black/white alphas) and readable over any room fill; blend modes were rejected: each has a blind luminance where the line vanishes, and mix-blend-mode is costly on old kiosk WebViews. Lifecycle:
appears on cleaning start, lives until dock + 10 min, dissolves;
`docked → cleaning` clears the old trail. Recorded SERVER-SIDE by the integration itself (trails.py): it watches the source entity, so the path records with zero cards open and every screen sees the same line. Stored per marker: the current run and ONE previous run (owner call — users compare cleaned vs uncleaned). The previous run renders at 40% opacity even at rest; the current run trims its live tail while moving. Rotation on run start or map switch; 2000-point cap with decimation; store writes debounced 10 s; houseplan_trail_updated notifies live cards.
- Marker option «Показывать след уборки», on by default where data
exists.
## Setup UX
A «Живая позиция» section in the device dialog, vacuum markers only:
status line (which source was found / room-only / nothing), one button
(«Настроить автоматически» for Tier A, «Калибровка по трём точкам» for
Tier B), three checkboxes (live position / trail / room highlight, all
on), and for multi-map robots a per-map calibration status list. The
source entity is discovered via the device registry — no YAML, no
entity pickers.
## Storage & validation
`marker.vacuum: { live, trail, room_highlight, source, calibration:
{[map_id]: [6 numbers]}, segment_map: {[segment]: room_id} }` — all
optional (old configs stay valid), matrices are 6 finite numbers,
backend-validated. Calibration is server-side state shared by every
screen, like the whole plan.
## Cases covered explicitly
Two vacuums (independent markers, calibrations, pucks); one robot on
two floors (maps ↔ spaces, the puck only renders in the space whose map
is active); robot outside the plan (±4 canvas bounds allow it, trail
clipped by viewport); integration restart changes map_id (rebind by
room-list match, else ask to recalibrate); user redraws rooms (the
matrix does not depend on rooms); demo stand gets a scripted synthetic
robot as the showcase.
## Out of scope
Commands of any kind (owner decision: display only) — no tap-to-clean,
no zone sending. No-go zones, cleaned-area polygons, cleaning history,
multi-robot collision avoidance — v2 candidates.
## Trail display modes
`marker.vacuum.trail_mode`: `never` | `cleaning` (default — the line hides
the instant the run ends) | `always` (the only mode that also draws the
previous run, at 40% opacity). The legacy boolean `trail` still maps in
(`false` → never). Recording is independent of the mode: the server always
records, the mode only decides what is drawn.
The last segment is a rAF-driven tip line whose endpoint is glued to the
puck's animated centre every frame, so the path pours out from under the
icon instead of popping in when the next telemetry point lands.
## Phases
- **P1:** adapter framework + all three Tier-A adapters +
auto-calibration by rooms + manual 3-point wizard + the puck + trail (both sources).
- **P2 (next):** Tier C room highlight + the demo-stand scripted robot.
- **P3:** Tier B zoo (Roomba, raw Valetudo MQTT), Deebot, multi-map
polish by feedback.
## Shipped in P1
Adapters for the three Tier-A integrations, auto-calibration by room
names, the drag-and-stretch fit panel, the puck, server-side trails
(current + previous run) with the three display modes. Verified against a
live Dreame X50 Master: room centres arrive as plain x/y, the active map
name lives on the vacuum entity (`selected_map`), and the robot's Y axis
is flipped versus the screen — hence mirror-on by default.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.53.1",
"version": "1.54.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.53.1",
"version": "1.54.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.53.1",
"version": "1.54.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+504 -4
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,12 @@ 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, vacTrailMode, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
FitParams, fitMatrix, fitFromMatrix, initialFit, reanchorFit, VacRoom,
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,
@@ -36,7 +42,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.53.1';
const CARD_VERSION = '1.54.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -340,6 +346,28 @@ 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; lastPos: VacPt | null }>();
/** view signature of the previous vacuum render: a changed view (zoom, pan,
space switch) or a tab return must TELEPORT the puck animating left/top
through a viewport change reads as «едет через весь план» (owner). */
private _vacViewKey = '';
private _vacLastView: { x: number; y: number; w: number; h: number } | null = null;
private _vacRaf = 0;
/** server-recorded runs per marker: {current, previous} in raw robot coords */
private _vacSrvTrails: Record<string, any> = {};
private _unsubTrail?: () => void;
private _vacJumpOnce = false;
private _vacVisHandler = () => {
if (document.visibilityState === 'visible') {
this._vacJumpOnce = true;
this.requestUpdate();
}
};
private _vacFit: { markerId: string; source: string; mapId: string; p: FitParams;
drag: null | { kind: 'move' | 'scale'; sx: number; sy: number; p0: FitParams;
fx: number; fy: number } } | null = null;
private _kioskDots = false;
private _kioskDotsTimer?: number;
private _kioskHoldTimer?: number;
@@ -394,6 +422,7 @@ class HouseplanCard extends LitElement {
_decorSel: { state: true },
_decorTextDialog: { state: true },
_kioskDialog: { state: true },
_vacFit: { state: true },
_kioskDots: { state: true },
_areaSel: { state: true },
_nameSel: { state: true },
@@ -417,6 +446,7 @@ class HouseplanCard extends LitElement {
};
public connectedCallback(): void {
document.addEventListener('visibilitychange', this._vacVisHandler);
super.connectedCallback();
window.addEventListener('keydown', this._keyHandler);
// signatures expire (24 h); refresh well before that on long-lived screens
@@ -429,6 +459,8 @@ class HouseplanCard extends LitElement {
}
public disconnectedCallback(): void {
document.removeEventListener('visibilitychange', this._vacVisHandler);
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
window.removeEventListener('keydown', this._keyHandler);
clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer);
@@ -461,6 +493,12 @@ class HouseplanCard extends LitElement {
}
private _onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && this._vacFit) {
this._vacFit = 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 +807,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();
@@ -858,6 +897,20 @@ class HouseplanCard extends LitElement {
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
}, 'houseplan_config_updated');
}
// server-side trails are additive: an older backend without the WS
// command just leaves the map empty and the card shows live-only trails
this.hass.callWS({ type: 'houseplan/trail/get' })
.then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); })
.catch(() => undefined);
if (!this._unsubTrail) {
this._unsubTrail = await this.hass.connection.subscribeEvents(async () => {
try {
const r: any = await this.hass.callWS({ type: 'houseplan/trail/get' });
this._vacSrvTrails = r?.trails || {};
this.requestUpdate();
} catch { /* transient WS hiccup — the next event retries */ }
}, 'houseplan_trail_updated');
}
if (!this._unsubLayout) {
// Positions are separate state. The static card learned to follow them
// in v1.46.0 and the full one did not, so two full cards side by side
@@ -1508,6 +1561,7 @@ class HouseplanCard extends LitElement {
}
private _stagePointerDown(ev: PointerEvent): void {
if (this._vacFit) return; // no pan/swipe while fitting the robot map
if (this._kiosk) {
this._cyclePausedUntil = Date.now() + 60000;
if (this._pointers.size === 0) {
@@ -1941,6 +1995,7 @@ class HouseplanCard extends LitElement {
}
private _markupClick(ev: MouseEvent): void {
if (this._vacFit) return; // the fit overlay owns all pointer input
if (!this._markup) return;
// a pan or pinch just happened — the synthesized click is not a draw
if (this._suppressClick) return;
@@ -3065,8 +3120,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,
@@ -3266,7 +3325,7 @@ class HouseplanCard extends LitElement {
mode, title: '', planUrl: null, planFile: null,
source: 'file',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
@@ -3599,7 +3658,7 @@ class HouseplanCard extends LitElement {
mode: 'create', title, planUrl: null, planFile: null,
source: 'file',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
@@ -4220,6 +4279,8 @@ 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._renderVacFit(view)}
${this._renderOpeningLocks(view)}
${disp.showNames || this._markup
? space.rooms.map((r) => this._renderRoomLabel(r, space, view, disp))
@@ -4264,6 +4325,13 @@ class HouseplanCard extends LitElement {
</div>`
: nothing}
${this._kioskDialog ? this._renderKioskDialog() : nothing}
${this._vacFit ? html`<div class="vaccalbar">
<span>${this._t('vac.fit_hint')}</span>
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ rot: ((this._vacFit!.p.rot + 90) % 360) as any })}>${this._t('vac.fit_rotate')}</button>
<button class="btn ghostbtn" @click=${() => this._vacFitTurn({ mir: !this._vacFit!.p.mir })}>${this._t('vac.fit_mirror')}</button>
<button class="btn" @click=${() => this._vacFitSave()}>${this._t('btn.save')}</button>
<button class="btn ghostbtn" @click=${() => { this._vacFit = 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 +4351,436 @@ 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, lastPos: null };
this._vacRt.set(d.id, rt);
}
if (moving && !rt.moving) { rt.trail = []; rt.lastPos = null; } // a fresh run
const wantTrail = vacTrailMode(d.marker?.vacuum) !== 'never' && !tele?.path;
if (!moving && rt.moving) {
rt.endedTs = Date.now();
// the run is over: the puck has arrived everywhere it was going
if (wantTrail && rt.lastPos) rt.trail = pushTrailPoint(rt.trail, rt.lastPos, 40);
rt.lastPos = null;
}
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;
// the trail lags ONE point behind: when a new target arrives the puck
// has just (visually) reached the previous one — a segment must never
// outrun the icon (owner report 2026-07-31)
if (wantTrail && rt.lastPos) rt.trail = pushTrailPoint(rt.trail, rt.lastPos, 40);
rt.lastPos = [pos.x, pos.y];
}
}
}
}
/** «Живая позиция» 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._vacStartFit(dev)}>${this._t('vac.fit')}</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>${this._t('vac.trail')}</label>
<select class="areasel"
@change=${(e: Event) => setVac({ trail_mode: (e.target as HTMLSelectElement).value, trail: null })}>
${(['never', 'cleaning', 'always'] as const).map((mv) => html`
<option value=${mv} ?selected=${vacTrailMode(v) === mv}>${this._t(('vac.trail_' + mv) as any)}</option>`)}
</select>
${cals.length ? html`<div class="rhint">${subst(this._t('vac.cal_maps'), { maps: cals.join(', ') })}</div>` : nothing}
` : nothing}
</div>`;
}
/**
* The active-map id. The camera rarely names its map; Dreame keeps the
* human-readable one on the vacuum entity (selected_map, verified against a
* live X50 Master) without this both floors would share one matrix.
*/
private _vacMapId(d: DevItem, tele: { mapId: string }): string {
if (tele.mapId !== 'default') return tele.mapId;
const ve = this._vacEntity(d);
const sel = ve ? this.hass?.states[ve]?.attributes?.selected_map : null;
return sel ? String(sel) : 'default';
}
/** 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, this._vacMapId(d, tele), res.matrix);
this._showToast(subst(this._t('vac.autocal_done'), { rooms: String(res.matched.length) }));
}
/** «Подогнать вручную»: open the fit overlay and leave the dialog. */
private _vacStartFit(d: DevItem): void {
const src = this._vacSource(d);
const tele = src ? readVacTelemetry(this.hass?.states[src]?.attributes) : null;
if (!src || !tele) {
this._showToast(this._t('vac.cal_need_pos'));
return;
}
const mapId = this._vacMapId(d, tele);
const existing = d.marker?.vacuum?.calibration?.[mapId] as Affine | undefined;
const sp = this._spaceModel(d.space);
const vb = (sp?.vb || [0, 0, NORM_W, NORM_W]) as [number, number, number, number];
const p = (existing && existing.length === 6 && fitFromMatrix(existing))
|| initialFit(tele.rooms, vb);
this._markerDialog = null;
if (d.space !== this._space) this._space = d.space;
this._vacFit = { markerId: d.id, source: src, mapId, p, drag: null };
}
private _vacFitSave(): void {
const f = this._vacFit;
if (!f) return;
this._vacSaveMatrix(f.markerId, f.source, f.mapId, fitMatrix(f.p));
this._vacFit = null;
this._showToast(this._t('vac.cal_done'));
}
/** Rotate/mirror around the ghost centre so the map does not fly away. */
private _vacFitTurn(patch: Partial<FitParams>): void {
const f = this._vacFit;
if (!f) return;
const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes);
const c = this._vacGhostCentre(tele?.rooms || []);
const next = { ...f.p, ...patch } as FitParams;
this._vacFit = { ...f, p: reanchorFit(next, f.p, c[0], c[1]) };
}
private _vacGhostCentre(rooms: VacRoom[]): VacPt {
const xs: number[] = [], ys: number[] = [];
for (const r of rooms) {
xs.push(r.x0 ?? r.cx, r.x1 ?? r.cx);
ys.push(r.y0 ?? r.cy, r.y1 ?? r.cy);
}
if (!xs.length) return [0, 0];
return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
}
/** px→canvas-units for a pointer delta, via the current stage size. */
private _vacDelta(view: { w: number; h: number }, dxPx: number, dyPx: number): VacPt {
const st = this._stageEl;
const w = st?.clientWidth || 1, h = st?.clientHeight || 1;
return [(dxPx / w) * view.w, (dyPx / h) * view.h];
}
private _vacFitPointer(ev: PointerEvent, view: { x: number; y: number; w: number; h: number }): void {
const f = this._vacFit;
if (!f) return;
ev.stopPropagation();
if (ev.type === 'pointerdown') {
const t = ev.target as HTMLElement;
const corner = t.getAttribute?.('data-corner');
try {
(ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
} catch { /* synthetic pointers (tests) have no active id — capture is a nicety */ }
this._vacFit = { ...f, drag: corner
? { kind: 'scale', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p },
fx: Number(corner.split(',')[0]), fy: Number(corner.split(',')[1]) }
: { kind: 'move', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p }, fx: 0, fy: 0 } };
return;
}
const d = f.drag;
if (!d) return;
if (ev.type === 'pointermove') {
const [dx, dy] = this._vacDelta(view, ev.clientX - d.sx, ev.clientY - d.sy);
if (d.kind === 'move') {
this._vacFit = { ...f, p: { ...d.p0, ox: d.p0.ox + dx, oy: d.p0.oy + dy } };
} else {
// corner-stretch: uniform scale about the OPPOSITE corner (fx, fy —
// the fixed corner in robot coords), like a graphics-editor frame
const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes);
const c = this._vacGhostCentre(tele?.rooms || []);
const m0 = fitMatrix(d.p0);
const [gx, gy] = applyAffine(m0, c[0], c[1]);
const [px0, py0] = applyAffine(m0, d.fx, d.fy);
const span0 = Math.hypot(gx - px0, gy - py0) || 1;
// distance change of the dragged corner (opposite of fixed) from centre
const [cx0, cy0] = [2 * gx - px0, 2 * gy - py0];
const span1 = Math.hypot(cx0 + dx * 2 - px0, cy0 + dy * 2 - py0) / 2;
const k = Math.max(0.05, span1 / span0);
const next = { ...d.p0, s: d.p0.s * k } as FitParams;
this._vacFit = { ...f, p: reanchorFit(next, d.p0, d.fx, d.fy) };
}
return;
}
if (ev.type === 'pointerup' || ev.type === 'pointercancel') {
this._vacFit = { ...f, drag: null };
}
}
/** The translucent robot map over the plan while fitting. */
private _renderVacFit(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
const f = this._vacFit;
if (!f) return nothing;
const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes);
if (!tele) return nothing;
const m = fitMatrix(f.p);
const rects: TemplateResult[] = [];
const xs: number[] = [], ys: number[] = [];
for (const r of tele.rooms) {
if (r.x0 == null) continue;
const cs = [[r.x0!, r.y0!], [r.x1!, r.y0!], [r.x1!, r.y1!], [r.x0!, r.y1!]]
.map(([x, y]) => applyAffine(m, x, y));
cs.forEach(([x, y]) => { xs.push(x); ys.push(y); });
const [lx, ly] = applyAffine(m, r.cx, r.cy);
rects.push(svg`<polygon points="${cs.map((q) => q[0].toFixed(1) + ',' + q[1].toFixed(1)).join(' ')}"></polygon>
<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}">${r.name}</text>`);
}
let dot: TemplateResult | typeof nothing = nothing;
if (tele.pos) {
const [dx2, dy2] = applyAffine(m, tele.pos.x, tele.pos.y);
dot = svg`<circle class="vacfitdot" cx="${dx2.toFixed(1)}" cy="${dy2.toFixed(1)}" r="${(view.w * 0.012).toFixed(1)}"></circle>`;
}
// corner handles on the ghost bbox; data-corner carries the FIXED corner
// (the opposite one) in robot coordinates
const handles: TemplateResult[] = [];
if (xs.length) {
const inv = ((): ((x: number, y: number) => VacPt) => {
const det = m[0] * m[4] - m[1] * m[3];
return (x, y) => [
(m[4] * (x - m[2]) - m[1] * (y - m[5])) / det,
(-m[3] * (x - m[2]) + m[0] * (y - m[5])) / det,
];
})();
const x0 = Math.min(...xs), x1 = Math.max(...xs);
const y0 = Math.min(...ys), y1 = Math.max(...ys);
const r = view.w * 0.022; // finger-sized: these are grabbed on tablets
for (const [hx, hy, ox2, oy2] of [[x0, y0, x1, y1], [x1, y0, x0, y1], [x1, y1, x0, y0], [x0, y1, x1, y0]] as number[][]) {
const fixed = inv(ox2, oy2);
handles.push(svg`<circle class="vacfithandle" data-corner="${fixed[0] + ',' + fixed[1]}"
cx="${hx.toFixed(1)}" cy="${hy.toFixed(1)}" r="${r.toFixed(1)}"></circle>`);
}
}
return html`<svg class="vacfit" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="none"
@pointerdown=${(e: PointerEvent) => this._vacFitPointer(e, view)}
@pointermove=${(e: PointerEvent) => this._vacFitPointer(e, view)}
@pointerup=${(e: PointerEvent) => this._vacFitPointer(e, view)}
@pointercancel=${(e: PointerEvent) => this._vacFitPointer(e, view)}>${rects}${dot}${handles}</svg>`;
}
/** Every frame: glue the growing tip segment to the animated puck centre. */
private _vacRafLoop(): void {
this._vacRaf = requestAnimationFrame(() => {
const sr = this.renderRoot as ShadowRoot;
const stage = this._stageEl;
const view = this._vacLastView;
const pucks = sr?.querySelectorAll?.('.vacpuck') || [];
if (!stage || !view || !pucks.length) { this._vacRaf = 0; return; }
const sb = stage.getBoundingClientRect();
for (const puck of pucks as any) {
const mid = puck.getAttribute('data-mid');
const pb = puck.getBoundingClientRect();
const cx = view.x + ((pb.left + pb.width / 2 - sb.left) / sb.width) * view.w;
const cy = view.y + ((pb.top + pb.height / 2 - sb.top) / sb.height) * view.h;
for (const line of sr.querySelectorAll(`line.tip[data-mid="${mid}"]`)) {
line.setAttribute('x2', cx.toFixed(1));
line.setAttribute('y2', cy.toFixed(1));
}
}
this._vacRafLoop();
});
}
/** 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 viewKey = this._space + '|' + view.x + '|' + view.y + '|' + view.w + '|' + view.h;
const jumpAll = this._vacJumpOnce || viewKey !== this._vacViewKey;
this._vacViewKey = viewKey;
this._vacJumpOnce = false;
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?.[this._vacMapId(d, tele)] as Affine | undefined;
if (!matrix || matrix.length !== 6) continue;
const rt = this._vacRt.get(d.id);
const moving = rt?.moving ?? false;
const tmode = vacTrailMode(d.marker?.vacuum);
// owner 2026-07-31: hide when the cleanup is over (default), unless the
// mode says always; the previous run only ever shows in 'always'
const showCur = tmode === 'always' || (tmode === 'cleaning' && moving);
const srv = this._vacSrvTrails[d.id];
const mapNow = this._vacMapId(d, tele);
const srvCur = srv?.current?.map_id === mapNow && Array.isArray(srv.current.points) ? srv.current : null;
const srvPrev = srv?.previous?.map_id === mapNow && Array.isArray(srv.previous.points) ? srv.previous : null;
// the PREVIOUS run stays visible even at rest: users compare where the
// robot has been against where it has not (owner call 2026-07-31)
if (tmode === 'always' && srvPrev && srvPrev.points.length > 1) {
const pts = srvPrev.points.map(([x, y]: number[]) => {
const [cx2, cy2] = applyAffine(matrix, x, y);
return cx2.toFixed(1) + ',' + cy2.toFixed(1);
}).join(' ');
trails.push(svg`<g class="prev"><polyline class="case" points="${pts}"></polyline><polyline class="core" points="${pts}"></polyline></g>`);
}
// trail source order: server current run (survives reloads, shared by
// every screen) → integration path → the local live buffer
if (showCur && (moving || srvCur)) {
// any server/integration path ends at the CURRENT target — trim the
// live tail while moving so the line never runs ahead of the puck
const full: VacPt[] = (srvCur?.points as VacPt[]) || tele.path || rt?.trail || [];
const trim = moving && (srvCur || tele.path) && full.length > 1;
const raw: VacPt[] = trim ? full.slice(0, -1) : full;
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(' ');
// cartography casing: a dark halo under a light core. Neutral and
// visible over ANY room fill — blend modes all have a blind
// luminance where the line vanishes (owner request 2026-07-31).
trails.push(svg`<polyline class="case" points="${ptsStr}"></polyline><polyline class="core" points="${ptsStr}"></polyline>`);
// the LAST segment grows glued to the icon (owner: «след появлялся
// строго за иконкой»): a rAF sampler drags x2/y2 to the puck centre
if (moving) {
const [ax, ay] = applyAffine(matrix, raw[raw.length - 1][0], raw[raw.length - 1][1]);
const a1 = ax.toFixed(1), a2 = ay.toFixed(1);
trails.push(svg`<line class="case tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line><line class="core tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line>`);
}
}
}
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;
const icon = d.marker?.icon || d.icon || 'mdi:robot-vacuum';
pucks.push(html`<div
data-mid="${d.id}"
class="vacpuck ${rt?.jump || jumpAll ? '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); }}>
<ha-icon .icon=${icon}></ha-icon>
</div>`);
}
this._vacLastView = view;
if (pucks.length && !this._vacRaf) this._vacRafLoop();
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 +5881,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 })}>
+23 -2
View File
@@ -355,5 +355,26 @@
"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.live": "Live position on the plan",
"vac.trail": "Show the robot's path",
"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_need_pos": "The robot is not reporting coordinates — start a cleanup and pause it",
"vac.cal_done": "Calibration saved. Start a cleanup and check",
"vac.cal_cancelled": "Calibration cancelled",
"vac.fit": "Fit manually",
"vac.fit_hint": "Drag the robot map into place, stretch by the corners",
"vac.fit_rotate": "Rotate 90°",
"vac.fit_mirror": "Mirror",
"vac.trail_never": "Never",
"vac.trail_cleaning": "While cleaning",
"vac.trail_always": "Always"
}
+23 -2
View File
@@ -355,5 +355,26 @@
"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.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_need_pos": "Робот сейчас не отдаёт координаты — запустите уборку и поставьте на паузу",
"vac.cal_done": "Калибровка сохранена. Запустите уборку и проверьте",
"vac.cal_cancelled": "Калибровка отменена",
"vac.fit": "Подогнать вручную",
"vac.fit_hint": "Перетащите карту робота на место, растяните за уголки",
"vac.fit_rotate": "Повернуть 90°",
"vac.fit_mirror": "Отразить",
"vac.trail_never": "Не показывать никогда",
"vac.trail_cleaning": "Во время уборки",
"vac.trail_always": "Показывать всегда"
}
+5 -1
View File
@@ -617,7 +617,11 @@ export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run'] as const;
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
// 'glow' leads: it is the default for new spaces since v1.54 — the owner's
// call, it sells the card best. Existing configs keep whatever they chose;
// an absent fill_mode still falls back to 'none' (spaceDisplayOf), so an
// update never repaints somebody's plan.
export const SPACE_FILL_MODES = ['glow', 'none', 'lqi', 'light', 'temp'] as const;
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier', 'cover', 'valve']);
+129
View File
@@ -1233,6 +1233,135 @@ 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;
/* the base badge, but round and 20% smaller the owner's wording:
«иконка похожа на иконку базы, только круглая и чуть меньше» */
--puck-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.8);
width: var(--puck-size);
height: var(--puck-size);
border-radius: 50%;
transform: translate(-50%, -50%);
background: var(--hp-bg);
border: 1px solid var(--hp-line);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
color: var(--hp-txt);
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(--puck-size) * 0.68);
color: var(--hp-txt);
/* same centering recipe as .dev ha-icon: without flex + line-height 0
the glyph sits on its text baseline and appears to float around the
circle (owner report 2026-07-31) */
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
width: var(--mdc-icon-size);
height: var(--mdc-icon-size);
}
@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-linejoin: round;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
/* dark halo + light core: neutral, and one of the two always contrasts
with whatever fill is underneath */
.vactrail g.prev { opacity: 0.4; }
.vactrail .case {
stroke: rgba(0, 0, 0, 0.4);
stroke-width: 2.25;
}
.vactrail .core {
stroke: rgba(255, 255, 255, 0.82);
stroke-width: 0.9;
}
.vacbox .vacbtns { display: flex; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
.vacfit {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 12;
overflow: visible;
touch-action: none;
cursor: grab;
/* the devlayer is pointer-events: none and every child opts back in
without this line real clicks flew straight through the overlay
(owner: «уголки не кликабельны»; synthetic smoke events bypass
hit-testing, which is why they lied) */
pointer-events: auto;
}
.vacfit:active { cursor: grabbing; }
.vacfit polygon {
fill: color-mix(in srgb, var(--hp-accent) 16%, transparent);
stroke: var(--hp-accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
stroke-dasharray: 6 4;
}
.vacfit text {
fill: var(--hp-accent);
font-size: 26px;
text-anchor: middle;
dominant-baseline: middle;
pointer-events: none;
user-select: none;
}
.vacfitdot { fill: var(--hp-accent); pointer-events: none; }
.vacfithandle {
fill: var(--hp-bg);
stroke: var(--hp-accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
cursor: nwse-resize;
}
.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;
+10
View File
@@ -55,6 +55,16 @@ 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; // legacy bool; trail_mode wins
trail_mode?: 'never' | 'cleaning' | 'always' | 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
+302
View File
@@ -0,0 +1,302 @@
/**
* 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;
x0?: number; y0?: number; x1?: number; y1?: 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;
}
}
// Tasshack dreame-vacuum: the room centre is plain x/y (verified against
// a live X50 Master; x/y sits within its own x0..x1 bbox)
if (cx == null || cy == null) { cx = num(r.x); cy = num(r.y); }
if (name && cx != null && cy != null) {
const room: VacRoom = { id, name, cx, cy };
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) {
room.x0 = Math.min(x0, x1); room.y0 = Math.min(y0, y1);
room.x1 = Math.max(x0, x1); room.y1 = Math.max(y0, y1);
}
rooms.push(room);
}
}
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';
}
// ---------------- the fit panel (drag + corner-stretch calibration) ----------------
/** What the user manipulates; folds into the same stored 6-number matrix. */
export interface FitParams {
ox: number; oy: number; // translation, canvas units
s: number; // uniform scale, canvas units per robot unit
rot: 0 | 90 | 180 | 270; // whole-quarter rotation
mir: boolean; // mirror (robots' Y usually grows the other way)
}
const ROT_CS: Record<number, [number, number]> = { 0: [1, 0], 90: [0, 1], 180: [-1, 0], 270: [0, -1] };
/** target = S · R(rot) · diag(mir ? 1 : 1, 1) · source + (ox, oy) */
export function fitMatrix(p: FitParams): Affine {
const [c, s_] = ROT_CS[p.rot] || [1, 0];
const mx = p.mir ? -1 : 1;
return [p.s * c * mx, -p.s * s_, p.ox, p.s * s_ * mx, p.s * c, p.oy];
}
/**
* Decompose a stored matrix back into panel params. Rotation snaps to the
* nearest quarter a legacy 3-point matrix reopens as an editable start,
* not verbatim, and that is fine: the ghost shows the result live.
*/
export function fitFromMatrix(m: Affine): FitParams | null {
const det = m[0] * m[4] - m[1] * m[3];
if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
const mir = det < 0;
const s = Math.sqrt(Math.abs(det));
// the second column (b, e) = S·(sin, cos) is mirror-free
let ang = Math.atan2(-m[1], m[4]) * 180 / Math.PI;
ang = ((Math.round(ang / 90) * 90) % 360 + 360) % 360;
return { ox: m[2], oy: m[5], s, rot: ang as FitParams['rot'], mir };
}
/** A sane opening position: the robot map centred over the plan at 60% size. */
export function initialFit(
rooms: VacRoom[],
vb: [number, number, number, number],
): FitParams {
const bx: number[] = [], by: number[] = [];
for (const r of rooms) {
if (r.x0 != null) { bx.push(r.x0, r.x1!); by.push(r.y0!, r.y1!); }
else { bx.push(r.cx); by.push(r.cy); }
}
// no rooms at all: an arbitrary honest guess the user will drag anyway
if (!bx.length) return { ox: vb[0] + vb[2] / 2, oy: vb[1] + vb[3] / 2, s: vb[2] / 10000, rot: 0, mir: true };
const minX = Math.min(...bx), maxX = Math.max(...bx);
const minY = Math.min(...by), maxY = Math.max(...by);
const span = Math.max(maxX - minX, maxY - minY) || 1;
const s = (Math.min(vb[2], vb[3]) * 0.6) / span;
// mirror on by default: every robot map seen so far has Y flipped vs screen
const p: FitParams = { ox: 0, oy: 0, s, rot: 0, mir: true };
const m = fitMatrix(p);
const [ccx, ccy] = applyAffine(m, (minX + maxX) / 2, (minY + maxY) / 2);
p.ox = vb[0] + vb[2] / 2 - ccx;
p.oy = vb[1] + vb[3] / 2 - ccy;
return p;
}
/** Re-anchor params so the source point (sx, sy) stays at the same target spot. */
export function reanchorFit(p: FitParams, prev: FitParams, sx: number, sy: number): FitParams {
const [px, py] = applyAffine(fitMatrix(prev), sx, sy);
const trial = fitMatrix({ ...p, ox: 0, oy: 0 });
const [qx, qy] = applyAffine(trial, sx, sy);
return { ...p, ox: px - qx, oy: py - qy };
}
export type VacTrailMode = 'never' | 'cleaning' | 'always';
/** marker.vacuum → display mode; legacy bool maps in (false = never). */
export function vacTrailMode(v: { trail?: boolean | null; trail_mode?: string | null } | null | undefined): VacTrailMode {
const m = v?.trail_mode;
if (m === 'never' || m === 'cleaning' || m === 'always') return m;
if (v?.trail === false) return 'never';
return 'cleaning';
}
+137
View File
@@ -0,0 +1,137 @@
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('readVacTelemetry: Tasshack room centres come as plain x/y', () => {
// shape captured from a live Dreame X50 Master (dacha, 2026-07-31)
const t = readVacTelemetry({
vacuum_position: { x: 1399, y: -55, a: 181 },
rooms: { 2: { room_id: 2, name: 'Кладовка', x0: 800, y0: -2000, x1: 4200, y1: 300, x: 2575, y: -825 } },
});
assert.equal(t.rooms[0].name, 'Кладовка');
// bbox wins when present (both are valid anchors); x/y covers bbox-less dialects
assert.equal(t.rooms[0].cx, 2500);
const t2 = readVacTelemetry({ vacuum_position: { x: 0, y: 0 }, rooms: { 2: { name: 'Кладовка', x: 2575, y: -825 } } });
assert.equal(t2.rooms[0].cx, 2575);
});
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));
});
test('fitMatrix/fitFromMatrix round-trip, mirror and quarters', async () => {
const { fitMatrix, fitFromMatrix, initialFit, reanchorFit } = await import('../test-build/vacuum.js');
for (const rot of [0, 90, 180, 270]) for (const mir of [false, true]) {
const p = { ox: 123.4, oy: -55.5, s: 0.083, rot, mir };
const q = fitFromMatrix(fitMatrix(p));
assert.equal(q.rot, rot, `rot ${rot} mir ${mir}`);
assert.equal(q.mir, mir);
assert.ok(Math.abs(q.s - p.s) < 1e-9 && Math.abs(q.ox - p.ox) < 1e-9 && Math.abs(q.oy - p.oy) < 1e-9);
}
// the real X50 matrix shape: X forward, Y flipped == mir + 180? decompose sanity
const q = fitFromMatrix([0.08, 0, 590, 0, -0.08, 677]);
assert.equal(q.mir, true);
// initialFit centres the map bbox on the canvas
const rooms = [{ id: '1', name: 'A', cx: 500, cy: 500, x0: 0, y0: 0, x1: 1000, y1: 1000 }];
const f = initialFit(rooms, [0, 0, 1000, 1000]);
const m = fitMatrix(f);
const c = applyAffine(m, 500, 500);
assert.ok(Math.abs(c[0] - 500) < 1e-6 && Math.abs(c[1] - 500) < 1e-6);
assert.ok(Math.abs(1000 * f.s - 600) < 1e-6); // 60% of the canvas
assert.equal(f.mir, true);
// reanchor keeps the chosen source point fixed through a rotation
const p2 = { ...f, rot: 90 };
const r2 = reanchorFit(p2, f, 500, 500);
const c2 = applyAffine(fitMatrix(r2), 500, 500);
assert.ok(Math.abs(c2[0] - c[0]) < 1e-6 && Math.abs(c2[1] - c[1]) < 1e-6);
});
+142
View File
@@ -0,0 +1,142 @@
"""TrailRecorder wiring: subscription callback, dialects, run end.
Loads trails.py with STUBBED Home Assistant modules but only inside a
snapshot of sys.modules that is restored immediately afterwards. Injecting
fake `homeassistant` modules globally poisons the real HA harness running in
the same pytest session (it did, once).
"""
import sys, types, pathlib, importlib.util
ROOT = pathlib.Path(__file__).resolve().parent.parent
def _load_trails():
saved = {k: v for k, v in sys.modules.items() if k == "homeassistant" or k.startswith(("homeassistant.", "houseplan"))}
try:
for name in list(sys.modules):
if name == "homeassistant" or name.startswith(("homeassistant.", "houseplan")):
del sys.modules[name]
ha = types.ModuleType("homeassistant"); sys.modules["homeassistant"] = ha
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda f: f
sys.modules["homeassistant.core"] = core
sys.modules["homeassistant.helpers"] = types.ModuleType("homeassistant.helpers")
er = types.ModuleType("homeassistant.helpers.entity_registry")
er.async_get = lambda hass: None
er.async_entries_for_device = lambda reg, dev: []
sys.modules["homeassistant.helpers.entity_registry"] = er
ev = types.ModuleType("homeassistant.helpers.event")
ev.async_call_later = lambda hass, delay, cb: (lambda: None)
ev.async_track_state_change_event = lambda hass, ents, cb: (lambda: None)
sys.modules["homeassistant.helpers.event"] = ev
stm = types.ModuleType("homeassistant.helpers.storage")
class _Store:
def __init__(self, *a, **k): pass
async def async_load(self): return None
async def async_save(self, d): pass
stm.Store = _Store
sys.modules["homeassistant.helpers.storage"] = stm
pkg = types.ModuleType("houseplan"); pkg.__path__ = [str(ROOT / "custom_components" / "houseplan")]
sys.modules["houseplan"] = pkg
c = types.ModuleType("houseplan.const"); c.DOMAIN = "houseplan"
sys.modules["houseplan.const"] = c
spec = importlib.util.spec_from_file_location(
"houseplan.trails", str(ROOT / "custom_components" / "houseplan" / "trails.py")
)
mod = importlib.util.module_from_spec(spec)
sys.modules["houseplan.trails"] = mod
spec.loader.exec_module(mod)
return mod
finally:
for name in list(sys.modules):
if name == "homeassistant" or name.startswith(("homeassistant.", "houseplan")):
del sys.modules[name]
sys.modules.update(saved)
trails = _load_trails()
class S:
def __init__(self, state, attrs): self.state, self.attributes = state, attrs
class States:
def __init__(self, d): self.d = d
def get(self, k): return self.d.get(k)
class Bus:
def __init__(self): self.fired = []
def async_fire(self, *a): self.fired.append(a)
class Hass:
def __init__(self, states): self.states, self.bus, self.data = States(states), Bus(), {}
def _rec():
states = {
"vacuum.x50": S("cleaning", {"selected_map": "Первый этаж"}),
"camera.map": S("idle", {"vacuum_position": {"x": 1000, "y": -500, "a": 90}, "map_index": 1}),
}
hass = Hass(states)
rec = trails.TrailRecorder(hass, None)
rec.pairs = {"camera.map": ("m1", "vacuum.x50")}
return rec, hass, states
class E:
def __init__(self, eid): self.data = {"entity_id": eid}
def test_state_events_record_points_and_map_id():
rec, hass, states = _rec()
rec._on_state(E("camera.map"))
states["camera.map"] = S("idle", {"vacuum_position": {"x": 1100, "y": -500}, "map_index": 1})
rec._on_state(E("camera.map"))
run = rec.book.data["m1"]["current"]
assert run["points"] == [[1000.0, -500.0], [1100.0, -500.0]]
assert run["map_id"] == "1"
assert hass.bus.fired, "live cards must be notified"
def test_docking_ends_the_run():
rec, hass, states = _rec()
rec._on_state(E("camera.map"))
states["vacuum.x50"] = S("docked", {})
rec._on_state(E("vacuum.x50"))
assert rec.book.data["m1"]["current"]["ended"] is not None
def test_sample_seeds_a_run_already_in_progress():
# HA restarted mid-cleanup: the first point must not wait for an event
rec, _hass, _states = _rec()
assert rec._sample("camera.map", 123.0)
assert rec.book.data["m1"]["current"]["points"] == [[1000.0, -500.0]]
def test_junk_position_ignored():
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": {"x": "nope", "y": 1}})
assert not rec._sample("camera.map", 1.0)
states["camera.map"] = S("idle", {})
assert not rec._sample("camera.map", 1.0)
def test_unknown_source_is_noop():
rec, _hass, _states = _rec()
assert not rec._sample("camera.other", 1.0)
def test_object_style_position_is_read():
# Tasshack in-memory attributes hold a Point OBJECT, not a dict
class Point:
def __init__(self, x, y): self.x, self.y = x, y
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": Point(2020, 3096), "map_index": 1})
assert rec._sample("camera.map", 5.0)
assert rec.book.data["m1"]["current"]["points"] == [[2020.0, 3096.0]]
+66
View File
@@ -0,0 +1,66 @@
"""TrailBook: the pure part of the server-side vacuum trails."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan"))
import importlib.util
spec = importlib.util.spec_from_file_location(
"trailbook_pure",
pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan" / "trails.py",
)
# import only TrailBook without HA deps: read the source and exec the class
src = (pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan" / "trails.py").read_text(encoding="utf-8")
ns = {"Any": object, "annotations": None}
exec(src[src.index("TRAIL_CAP"):src.index("class TrailRecorder")], ns)
TrailBook = ns["TrailBook"]
TRAIL_CAP = ns["TRAIL_CAP"]
def test_append_dedups_and_records():
b = TrailBook()
assert b.on_point("m", "0", 1.0, 2.0, 100.0)
assert not b.on_point("m", "0", 1.0, 2.0, 101.0) # same point
assert b.on_point("m", "0", 3.0, 4.0, 102.0)
run = b.data["m"]["current"]
assert run["points"] == [[1.0, 2.0], [3.0, 4.0]]
assert run["ended"] is None
def test_end_then_new_run_rotates_current_to_previous():
b = TrailBook()
b.on_point("m", "0", 1, 1, 1.0)
b.on_point("m", "0", 2, 2, 2.0)
assert b.end_run("m", 3.0)
assert not b.end_run("m", 4.0) # idempotent
b.on_point("m", "0", 9, 9, 5.0)
rec = b.data["m"]
assert rec["previous"]["points"] == [[1, 1], [2, 2]]
assert rec["previous"]["ended"] == 3.0
assert rec["current"]["points"] == [[9, 9]]
# a third run forgets the first entirely — exactly two are kept
b.end_run("m", 6.0)
b.on_point("m", "0", 7, 7, 7.0)
assert rec if rec is b.data["m"] else True
assert b.data["m"]["previous"]["points"] == [[9, 9]]
def test_map_switch_mid_run_starts_a_new_run():
b = TrailBook()
b.on_point("m", "floor1", 1, 1, 1.0)
b.on_point("m", "floor2", 2, 2, 2.0)
assert b.data["m"]["current"]["map_id"] == "floor2"
assert b.data["m"]["previous"]["map_id"] == "floor1"
def test_cap_decimates_but_keeps_the_freshest_point():
b = TrailBook()
for i in range(TRAIL_CAP + 1):
b.on_point("m", "0", float(i), 0.0, float(i))
pts = b.data["m"]["current"]["points"]
assert len(pts) <= TRAIL_CAP // 2 + 2
assert pts[-1] == [float(TRAIL_CAP), 0.0]
def test_junk_store_data_tolerated():
b = TrailBook("not a dict")
assert b.data == {}
b.on_point("m", "0", 1, 1, 1.0)
assert b.data["m"]["current"]["points"] == [[1, 1]]
+40
View File
@@ -923,3 +923,43 @@ 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}))
def test_trail_mode_bounded(self):
import pytest
for ok in ("never", "cleaning", "always", None):
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": ok}))
with pytest.raises(Exception):
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": "sometimes"}))
+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",