Skip to content

Commit cd16a78

Browse files
committed
feat(messages): add OnMapTrace for mower firmware (LZMA-wrapped variant)
Mower firmwares (observed: GOAT A1600 RTK fw 1.15.13) push spontaneous ``onMapTrace`` messages whose body schema is completely different from the existing ``GetMapTrace`` response: { "header": {"fwVer": "1.15.13", ...}, "body": {"data": { "mid": "...", "batid": "...", "serial": "1", "index": "0", "type": "4", "info": "<base64 of LZMA1 compressed JSON>", "infoSize": 3455 }} } The compressed ``info`` field, once decompressed, is a JSON list of trajectory groups: ``[[group_id, "0;x1,y1;x2,y2;...;", "0;x,y;..."], ...]`` with negative-and-positive integer coordinates (relative to a map origin). This adds a dedicated ``OnMapTrace`` message handler that: 1. Detects the new format via the presence of ``info``. 2. Decompresses via the existing Rust ``decompress_base64_data`` helper (which already handles the firmware's trimmed 9-byte LZMA header). 3. Parses the JSON, drops the leading ``"0"`` anchor of each segment, and concatenates the remaining points across groups. 4. Notifies ``MapTraceEvent`` using the firmware ``serial`` as ``start`` so the ``Map`` Rust helper does not clear the trace on every push. Registered alongside the other JSON map messages so it is dispatched *before* the legacy ``getMapTrace`` fallback (which still serves vacuum firmwares unchanged). Tests: - Happy paths (single group, multi-group, multi-segment). - ``info`` missing → ANALYSE (defer to legacy handler). - Empty groups → ANALYSE (no event emitted). - Corrupt ``info`` (invalid base64, too short, decompresses to non-JSON) → ANALYSE (no exception escapes). - ``serial`` propagates as ``MapTraceEvent.start``. - Full suite: 705/705 pass, no regression. Refs: - #1376 (Disable getMapTrace for Goat) — this PR is the proper alternative: instead of disabling, the message is now parsed and surfaces as a usable trajectory. - Companion to #1565 (skip legacy fallback for mowers) and #1566 (warn-once rate limit). #1565 still serves as a safety net for any remaining unhandled map messages on mowers.
1 parent 8980d8c commit cd16a78

3 files changed

Lines changed: 259 additions & 2 deletions

File tree

deebot_client/messages/json/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .auto_empty import OnAutoEmpty
1111
from .battery import OnBattery
1212
from .gps_position import OnGpsPos
13-
from .map import OnCachedMapInfo, OnMajorMap, OnMapInfoV2, OnMapSetV2
13+
from .map import OnCachedMapInfo, OnMajorMap, OnMapInfoV2, OnMapSetV2, OnMapTrace
1414
from .station_state import OnStationState
1515
from .stats import OnStats, ReportStats
1616
from .work_state import OnWorkState
@@ -24,6 +24,7 @@
2424
"OnMajorMap",
2525
"OnMapInfoV2",
2626
"OnMapSetV2",
27+
"OnMapTrace",
2728
"OnStats",
2829
"OnWorkState",
2930
"ReportStats",
@@ -42,6 +43,7 @@
4243
OnMajorMap,
4344
OnMapInfoV2,
4445
OnMapSetV2,
46+
OnMapTrace,
4547

4648
OnStationState,
4749

deebot_client/messages/json/map/__init__.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,27 @@
22

33
from __future__ import annotations
44

5+
import json as _json
56
from typing import TYPE_CHECKING, Any
67

7-
from deebot_client.events.map import MajorMapEvent, MapInfoEvent, MapSetType
8+
from deebot_client.events.map import MajorMapEvent, MapInfoEvent, MapSetType, MapTraceEvent
9+
from deebot_client.logging_filter import get_logger
810
from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataDict
11+
from deebot_client.rs.util import decompress_base64_data
912

1013
from .cached_map_info import OnCachedMapInfo
1114

1215
if TYPE_CHECKING:
1316
from deebot_client.event_bus import EventBus
1417

18+
_LOGGER = get_logger(__name__)
19+
1520
__all__ = [
1621
"OnCachedMapInfo",
1722
"OnMajorMap",
1823
"OnMapInfoV2",
1924
"OnMapSetV2",
25+
"OnMapTrace",
2026
]
2127

2228

@@ -91,3 +97,80 @@ def _handle_body_data_dict(
9197
event_bus.notify(MapInfoEvent(map_id=data["mid"], info=data["info"]))
9298

9399
return HandlingResult.success()
100+
101+
102+
class OnMapTrace(MessageBodyDataDict):
103+
"""On map trace message — variant pushed by mower firmwares (e.g. GOAT 1.15.x).
104+
105+
The vacuum-style ``getMapTrace`` response carried ``traceValue`` directly.
106+
Mower firmwares instead push a compressed envelope:
107+
108+
.. code-block:: json
109+
110+
{
111+
"mid": "...", "batid": "...", "serial": "1",
112+
"index": "0", "type": "4",
113+
"info": "<base64 of LZMA-compressed JSON>",
114+
"infoSize": 3455
115+
}
116+
117+
The decompressed payload is itself a small JSON document of the form
118+
``[[group_id, "0;x1,y1;x2,y2;..."], ...]`` where each group is a
119+
contiguous trajectory segment. We flatten the points across groups
120+
into the ``"x,y;x,y;..."`` shape that downstream consumers (the
121+
``Map`` Rust helper in particular) already accept.
122+
123+
Used in lieu of the legacy ``GetMapTrace`` fallback for these devices.
124+
"""
125+
126+
NAME = "onMapTrace"
127+
128+
@classmethod
129+
def _handle_body_data_dict(
130+
cls, event_bus: EventBus, data: dict[str, Any]
131+
) -> HandlingResult:
132+
"""Handle message->body->data and notify the correct event subscribers."""
133+
info = data.get("info")
134+
if not info:
135+
# Not the compressed-envelope variant — leave to legacy GetMapTrace
136+
return HandlingResult.analyse()
137+
138+
try:
139+
decompressed = decompress_base64_data(info).decode("utf-8")
140+
groups = _json.loads(decompressed)
141+
except Exception:
142+
_LOGGER.debug(
143+
"Could not decompress/parse onMapTrace info field for mid=%s batid=%s "
144+
"(probably truncated by upstream logger)",
145+
data.get("mid"),
146+
data.get("batid"),
147+
)
148+
return HandlingResult.analyse()
149+
150+
flat_points: list[str] = []
151+
for group in groups:
152+
# Each group: [group_id_str, "0;x1,y1;x2,y2;...;", "0;x,y;..." ...]
153+
if not isinstance(group, list):
154+
continue
155+
for segment in group[1:]:
156+
if not isinstance(segment, str):
157+
continue
158+
# Drop the leading "0" anchor and any empty trailing segment
159+
pts = [p for p in segment.split(";") if p and p != "0"]
160+
flat_points.extend(pts)
161+
162+
if not flat_points:
163+
return HandlingResult.analyse()
164+
165+
# Use serial as a stable monotonic ``start`` so ``Map`` does not clear
166+
# the trace on every push — only the very first ever (serial == 0)
167+
# would trigger the reset, which is the firmware's intent.
168+
try:
169+
start = int(data.get("serial", 1))
170+
except (TypeError, ValueError):
171+
start = 1
172+
173+
event_bus.notify(
174+
MapTraceEvent(start=start, total=start, data=";".join(flat_points))
175+
)
176+
return HandlingResult.success()
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Tests for OnMapTrace — mower variant with LZMA-compressed info field."""
2+
3+
from __future__ import annotations
4+
5+
import base64
6+
import json as _json
7+
import lzma
8+
9+
import pytest
10+
11+
from deebot_client.events import FirmwareEvent
12+
from deebot_client.events.map import MapTraceEvent
13+
from deebot_client.message import HandlingState
14+
from deebot_client.messages.json.map import OnMapTrace
15+
from tests.messages.json import assert_message
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# Helper: encode bytes the same way mower firmware does
20+
# ---------------------------------------------------------------------------
21+
#
22+
# Real GOAT A1600 RTK firmware 1.15.x emits an LZMA1 stream with a
23+
# *trimmed* 9-byte header (props + dict_size_le + size_le_4_bytes) instead
24+
# of the standard LZMA1 ALONE 13-byte header. The Rust helper
25+
# ``decompress_base64_data`` accommodates this by injecting four zero
26+
# bytes after position 7 to rebuild a valid 13-byte header. Reproduce
27+
# that on the encoding side here so the round-trip works.
28+
29+
_DICT_SIZE = 1 << 18 # 256 KB — matches what real firmware advertises
30+
31+
32+
def _ecovacs_encode(payload: bytes) -> str:
33+
raw = lzma.compress(
34+
payload,
35+
format=lzma.FORMAT_RAW,
36+
filters=[{"id": lzma.FILTER_LZMA1,
37+
"preset": lzma.PRESET_DEFAULT,
38+
"dict_size": _DICT_SIZE}],
39+
)
40+
header = bytes([0x5D]) + _DICT_SIZE.to_bytes(4, "little") + len(payload).to_bytes(4, "little")
41+
return base64.b64encode(header + raw).decode("ascii")
42+
43+
44+
def _envelope(info_b64: str, info_size: int, fw: str = "1.15.13") -> dict:
45+
return {
46+
"header": {
47+
"pri": 1,
48+
"tzm": 120,
49+
"ts": "1777450352860644879",
50+
"fwVer": fw,
51+
"hwVer": "0.0.0",
52+
},
53+
"body": {
54+
"data": {
55+
"mid": "123456789",
56+
"batid": "hmfald",
57+
"serial": "1",
58+
"index": "0",
59+
"type": "4",
60+
"info": info_b64,
61+
"infoSize": info_size,
62+
}
63+
},
64+
}
65+
66+
67+
# ---------------------------------------------------------------------------
68+
# Happy path
69+
# ---------------------------------------------------------------------------
70+
71+
72+
def test_OnMapTrace_decompresses_and_flattens_groups() -> None:
73+
inner = '[["7","0;100,200;150,250;"]]'
74+
data = inner.encode("utf-8")
75+
info = _ecovacs_encode(data)
76+
77+
expected_trace = "100,200;150,250"
78+
assert_message(
79+
OnMapTrace,
80+
_envelope(info, len(data)),
81+
(FirmwareEvent("1.15.13"), MapTraceEvent(start=1, total=1, data=expected_trace)),
82+
device_class="xmp9ds",
83+
)
84+
85+
86+
def test_OnMapTrace_concatenates_multiple_groups_and_segments() -> None:
87+
inner = (
88+
'[["5","0;-11850,-28849;-11800,-28899;","0;-12850,-23699;-12800,-23750;"],'
89+
'["6","0;-7899,-39700;-7950,-39649;"]]'
90+
)
91+
data = inner.encode("utf-8")
92+
info = _ecovacs_encode(data)
93+
expected_trace = (
94+
"-11850,-28849;-11800,-28899;-12850,-23699;-12800,-23750;-7899,-39700;-7950,-39649"
95+
)
96+
assert_message(
97+
OnMapTrace,
98+
_envelope(info, len(data)),
99+
(FirmwareEvent("1.15.13"), MapTraceEvent(start=1, total=1, data=expected_trace)),
100+
device_class="xmp9ds",
101+
)
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# Edge cases
106+
# ---------------------------------------------------------------------------
107+
108+
109+
def test_OnMapTrace_no_info_field_returns_analyse() -> None:
110+
"""Without ``info``, defer to the legacy GetMapTrace handler — analyse, no event."""
111+
envelope = _envelope("", 0)
112+
envelope["body"]["data"].pop("info")
113+
envelope["body"]["data"].pop("infoSize")
114+
assert_message(
115+
OnMapTrace,
116+
envelope,
117+
FirmwareEvent("1.15.13"),
118+
device_class="xmp9ds",
119+
expected_state=HandlingState.ANALYSE_LOGGED,
120+
)
121+
122+
123+
def test_OnMapTrace_empty_groups_returns_analyse() -> None:
124+
"""A payload with no actual coordinates is unusable — analyse rather than emit empty event."""
125+
inner = "[]"
126+
data = inner.encode("utf-8")
127+
info = _ecovacs_encode(data)
128+
assert_message(
129+
OnMapTrace,
130+
_envelope(info, len(data)),
131+
FirmwareEvent("1.15.13"),
132+
device_class="xmp9ds",
133+
expected_state=HandlingState.ANALYSE_LOGGED,
134+
)
135+
136+
137+
@pytest.mark.parametrize(
138+
"broken_info",
139+
[
140+
"@@@not-base64@@@", # invalid base64
141+
base64.b64encode(b"too short").decode("ascii"), # too short for LZMA header
142+
_ecovacs_encode(b"not json content"), # decompresses to non-JSON
143+
],
144+
)
145+
def test_OnMapTrace_corrupt_info_returns_analyse(broken_info: str) -> None:
146+
"""Truncated/broken ``info`` (typical when upstream loggers truncate the payload)
147+
should not raise — log at debug and return analyse so the message is dropped silently.
148+
"""
149+
assert_message(
150+
OnMapTrace,
151+
_envelope(broken_info, 0),
152+
FirmwareEvent("1.15.13"),
153+
device_class="xmp9ds",
154+
expected_state=HandlingState.ANALYSE_LOGGED,
155+
)
156+
157+
158+
def test_OnMapTrace_uses_serial_as_event_start() -> None:
159+
"""``serial`` becomes the ``MapTraceEvent.start`` so the Map helper does not clear
160+
on every push (it only clears when ``start == 0``)."""
161+
inner = '[["1","0;1,2;3,4;"]]'
162+
data = inner.encode("utf-8")
163+
info = _ecovacs_encode(data)
164+
envelope = _envelope(info, len(data))
165+
envelope["body"]["data"]["serial"] = "42"
166+
167+
assert_message(
168+
OnMapTrace,
169+
envelope,
170+
(FirmwareEvent("1.15.13"), MapTraceEvent(start=42, total=42, data="1,2;3,4")),
171+
device_class="xmp9ds",
172+
)

0 commit comments

Comments
 (0)