Trail recorder: seed a run already in progress, and test the wiring

Only TrailBook was covered; the HA-facing half — subscription callback,
attribute dialects, map-id resolution, run end on docking — had no test
at all. It does now, against a stubbed hass, which is also where the
missing behaviour showed up: recording started at the NEXT state change,
so an HA restart (or finishing calibration) mid-cleanup dropped the
opening seconds of the path. Sampling is factored out and runs once per
source on setup and on every refresh.
This commit is contained in:
Matysh
2026-07-31 11:47:31 +03:00
parent 75524d9d85
commit f3bc3278e5
6 changed files with 171 additions and 35 deletions
+5
View File
@@ -40,6 +40,11 @@ right on your Lovelace dashboard.
explicit button, never an accidental tap.
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
auto-carousel, per-screen icon sizes.
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
drives the plan in real time, pouring its path out from under itself;
current and previous cleanup runs are recorded server-side. Calibration is
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
+5
View File
@@ -38,6 +38,11 @@
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
+35 -25
View File
@@ -103,6 +103,11 @@ class TrailRecorder:
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:
@@ -123,36 +128,41 @@ class TrailRecorder:
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 {}
pos = attrs.get("vacuum_position") or attrs.get("robot_position")
if not isinstance(pos, dict):
return False
try:
x, y = float(pos["x"]), float(pos["y"])
except (KeyError, 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 not in (src, vac):
continue
st_vac = self.hass.states.get(vac)
moving = bool(st_vac and st_vac.state in MOVING_STATES)
if not moving:
changed |= self.book.end_run(marker, now)
continue
st_src = self.hass.states.get(src)
attrs = st_src.attributes if st_src else {}
pos = attrs.get("vacuum_position") or attrs.get("robot_position")
if not isinstance(pos, dict):
continue
try:
x, y = float(pos["x"]), float(pos["y"])
except (KeyError, TypeError, ValueError):
continue
map_id = str(
attrs.get("map_name")
or attrs.get("current_map")
or attrs.get("map_index")
or (st_vac.attributes.get("selected_map") if st_vac else None)
or "default"
)
changed |= self.book.on_point(marker, map_id, x, y, now)
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:
+2
View File
@@ -17,6 +17,7 @@ houseplan-card/
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
├─ custom_components/houseplan/ # the HA integration
│ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
│ ├─ trails.py # server-side vacuum trail recorder (state-change driven)
│ ├─ websocket_api.py # houseplan/layout/get|set|update
│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
@@ -189,6 +190,7 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
+23 -10
View File
@@ -808,15 +808,28 @@ require hands on real hardware — they remain for the human pass.
- [ ] Single click still opens the status card; double click opens the properties dialog;
a drag does NOT open either
## Live vacuums (docs/VACUUM.md, P1)
## Live vacuums (docs/VACUUM.md)
- Docked robot: only the base marker, at the user-placed spot. No puck.
- Cleaning + calibrated map: a round pulsing puck (no badge plate) drives the
plan; the base marker never moves. No heading arrow on the puck
(owner removed it 2026-07-31).
- Trail: integration `path` preferred, else self-recorded; lingers 10 min
after docking; a new run clears it; `trail: false` hides it.
- 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.
- Wizard: 3 stage clicks with the robot parked at 3 spots → matrix saved per
map id; Esc cancels; collinear points are rejected with a toast.
- Auto-calibration needs ≥3 rooms matched by name between the robot and plan.
- 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).
+101
View File
@@ -0,0 +1,101 @@
"""Does TrailRecorder._on_state actually record? (the untested link)"""
import sys, types, pathlib, time
# minimal HA stubs so trails.py imports without Home Assistant installed
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
helpers = types.ModuleType("homeassistant.helpers"); sys.modules["homeassistant.helpers"] = 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
st = 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
st.Store = Store
sys.modules["homeassistant.helpers.storage"] = st
const = types.ModuleType("custom_components.houseplan.const")
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "custom_components"))
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
import importlib.util
spec = importlib.util.spec_from_file_location("houseplan.trails", str(ROOT / "custom_components" / "houseplan" / "trails.py"))
trails = importlib.util.module_from_spec(spec); sys.modules["houseplan.trails"] = trails
spec.loader.exec_module(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)