mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
v1.54.0
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
никогда случайным тапом.
|
||||
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
|
||||
этажами, автокарусель, свои размеры на каждом экране.
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
|
||||
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Executable
+190
@@ -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)
|
||||
@@ -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 {}})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
+309
-131
File diff suppressed because one or more lines are too long
Vendored
+309
-131
File diff suppressed because one or more lines are too long
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
/** Ramer–Douglas–Peucker; keeps ends, drops points under eps deviation. */
|
||||
export function thinPath(pts: Pt[], eps: number): Pt[] {
|
||||
if (pts.length < 3) return pts.slice();
|
||||
const keep = new Uint8Array(pts.length);
|
||||
keep[0] = keep[pts.length - 1] = 1;
|
||||
const stack: Array<[number, number]> = [[0, pts.length - 1]];
|
||||
while (stack.length) {
|
||||
const [a, b] = stack.pop()!;
|
||||
const [ax, ay] = pts[a]; const [bx, by] = pts[b];
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const len = Math.hypot(dx, dy) || 1e-9;
|
||||
let worst = 0, wi = -1;
|
||||
for (let i = a + 1; i < b; i++) {
|
||||
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
|
||||
if (d > worst) { worst = d; wi = i; }
|
||||
}
|
||||
if (wi > 0 && worst > eps) {
|
||||
keep[wi] = 1;
|
||||
stack.push([a, wi], [wi, b]);
|
||||
}
|
||||
}
|
||||
const out: Pt[] = [];
|
||||
for (let i = 0; i < pts.length; i++) if (keep[i]) out.push(pts[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Append a point; over the cap → thin, and if thinning was not enough, decimate. */
|
||||
export function pushTrailPoint(buf: Pt[], p: Pt, epsHint: number): Pt[] {
|
||||
const last = buf[buf.length - 1];
|
||||
if (last && last[0] === p[0] && last[1] === p[1]) return buf;
|
||||
buf.push(p);
|
||||
if (buf.length <= TRAIL_MAX) return buf;
|
||||
let thinned = thinPath(buf, epsHint);
|
||||
if (thinned.length > TRAIL_MAX) thinned = thinned.filter((_, i) => i % 2 === 0 || i === thinned.length - 1);
|
||||
return thinned;
|
||||
}
|
||||
|
||||
/** true → the robot is actively driving (a puck should exist). */
|
||||
export function isVacMoving(state: string | undefined): boolean {
|
||||
return state === 'cleaning' || state === 'returning' || state === 'on';
|
||||
}
|
||||
|
||||
// ---------------- 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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]]
|
||||
@@ -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]]
|
||||
@@ -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
@@ -8,7 +8,7 @@
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/logic.ts",
|
||||
"src/logic.ts", "src/vacuum.ts",
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts",
|
||||
|
||||
Reference in New Issue
Block a user