diff --git a/README.md b/README.md index e392107..04bceac 100755 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.ru.md b/README.ru.md index d33d219..80376de 100644 --- a/README.ru.md +++ b/README.ru.md @@ -38,6 +38,11 @@ никогда случайным тапом. - 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между этажами, автокарусель, свои размеры на каждом экране. +- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая + шайба ездит по плану в реальном времени, «выливая» путь из-под себя; + текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик + (по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi + Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo. - 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка хранится **на сервере HA** — один план для всех экранов, живая синхронизация. diff --git a/custom_components/houseplan/trails.py b/custom_components/houseplan/trails.py index f47852c..4628d1c 100755 --- a/custom_components/houseplan/trails.py +++ b/custom_components/houseplan/trails.py @@ -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: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e3f3aa..ba69504 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 `..`, deletes nothing | | `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) | diff --git a/docs/TESTING.md b/docs/TESTING.md index ddcfe08..c3075a6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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). diff --git a/tests_backend/test_trail_recorder.py b/tests_backend/test_trail_recorder.py new file mode 100644 index 0000000..ddc9b18 --- /dev/null +++ b/tests_backend/test_trail_recorder.py @@ -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)