mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
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:
@@ -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** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user