From 18ac9b459f2e4a1333cdd9785c434de93ed1b9ba Mon Sep 17 00:00:00 2001 From: Matysh Date: Fri, 31 Jul 2026 12:04:33 +0300 Subject: [PATCH] =?UTF-8?q?Trail=20recorder=20reads=20object-style=20posit?= =?UTF-8?q?ions=20=E2=80=94=20the=20real-world=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught live on the owner's X50 mid-cleanup with temporary logging: the recorder saw every camera state change and rejected every one, because server-side Tasshack keeps vacuum_position as a Point OBJECT — it only becomes a dict when serialised to the frontend, which is why the card adapter (and MCP inspection) always saw a dict and the stub test faithfully reproduced the same wrong assumption. getattr fallback added, regression test pinned, diagnostic logging removed (setup line kept at INFO). --- custom_components/houseplan/trails.py | 20 +++++++++++++++----- tests_backend/test_trail_recorder.py | 10 ++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/custom_components/houseplan/trails.py b/custom_components/houseplan/trails.py index 4628d1c..ad00dfc 100755 --- a/custom_components/houseplan/trails.py +++ b/custom_components/houseplan/trails.py @@ -19,6 +19,9 @@ 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 @@ -99,6 +102,7 @@ class TrailRecorder: 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 @@ -139,12 +143,18 @@ class TrailRecorder: 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 + 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(pos["x"]), float(pos["y"]) - except (KeyError, TypeError, ValueError): + x, y = float(px), float(py) # type: ignore[arg-type] + except (TypeError, ValueError): return False map_id = str( attrs.get("map_name") diff --git a/tests_backend/test_trail_recorder.py b/tests_backend/test_trail_recorder.py index e47ae17..adcff4a 100644 --- a/tests_backend/test_trail_recorder.py +++ b/tests_backend/test_trail_recorder.py @@ -130,3 +130,13 @@ def test_junk_position_ignored(): 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]]