Skip to content

feat(messages): add OnMapTrace for mower firmware (LZMA-wrapped variant)#1567

Open
Beennnn wants to merge 12 commits into
DeebotUniverse:devfrom
Beennnn:feat/parse-mower-getmaptrace-fw-1.15
Open

feat(messages): add OnMapTrace for mower firmware (LZMA-wrapped variant)#1567
Beennnn wants to merge 12 commits into
DeebotUniverse:devfrom
Beennnn:feat/parse-mower-getmaptrace-fw-1.15

Conversation

@Beennnn

@Beennnn Beennnn commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Mower firmwares (observed: GOAT A1600 RTK fw 1.15.13) push spontaneous onMapTrace MQTT messages whose body schema is completely different from the existing GetMapTrace response — same NAME, different shape:

{
  "header": {"fwVer": "1.15.13", "tzm": 120, "ts": "...", ...},
  "body": {"data": {
    "mid": "...", "batid": "...", "serial": "1",
    "index": "0", "type": "4",
    "info": "<base64 of LZMA1-compressed JSON>",
    "infoSize": 3455
  }}
}

Decompressing the info field yields a small JSON document of the form:

[
  ["5", "0;-11850,-28849;-11800,-28899;...;", "0;-12850,-23699;...;"],
  ["6", "0;-7899,-39700;...;"]
]

— a list of trajectory groups, each holding one or more ;-separated coordinate strings.

The current GetMapTrace._handle_body_data_dict expects traceValue and raises a KeyError, which the legacy fallback catches and logs as WARNING — Could not parse getMapTrace. In one observed setup this produced 217 520 identical warnings in 3 days (~70k/day, ~50/min during mowing).

Scope (narrowed after review)

This PR is now strictly the parser side. Per @edenhaus review, mower_trace.py (the SVG accumulator/renderer that used to be here) has been removed — rendering belongs with a proper map capability, not a mower-only workaround.

Kept in this PR:

  1. OnMapTrace handler that recognises the mower-flavoured envelope, reassembles chunks across index values (per @monsivar's O1200 LiDAR Pro capture analysis), decompresses LZMA, and parses the payload.
  2. MowerMapTraceEvent carrying the structured {group_id, segments[]} shape — semantic information that any downstream consumer (map capability, custom renderer, dashboard) can consume.
  3. Legacy MapTraceEvent with a flattened compat projection so the Rust Map renderer used by vacuums keeps working unaffected.
  4. Safety nets: per-key buffer cap (512 kB), total cap (2 MB), oldest-cycle eviction, out-of-order chunk reassembly, fresh-cycle reset on retry.

Not in this PR

  • Rendering / SVG output — belongs with a mower map capability.
  • Full static map (onMI / onArI / getAreaSet) — planned as a follow-up PR with @monsivar sharing sanitised MQTT captures + a Python viewer for anchor-matching validation.

Compatibility with companion PRs

Test plan

  • test_OnMapTrace_emits_structured_event_and_flat_compat — single-group happy path
  • test_OnMapTrace_preserves_groups_and_segments — 2 groups × 2-3 segments
  • test_OnMapTrace_no_info_field_returns_analyse — defer to legacy handler
  • test_OnMapTrace_empty_groups_returns_analyse — no event emitted when there are no points
  • test_OnMapTrace_corrupt_info_returns_analyse — 3 corruption modes, no exception escapes
  • test_OnMapTrace_uses_constant_compat_start_not_serial — start=1 constant, not the reused serial
  • test_OnMapTrace_chunk_in_progress_emits_nothing_then_completes — multi-chunk reassembly
  • test_OnMapTrace_chunks_out_of_order_still_reassemble — sorted-index reassembly
  • test_OnMapTrace_new_cycle_drops_partial_previous_buffer — retry reset
  • test_OnMapTrace_different_keys_have_independent_buffers — concurrent mowing cycles
  • test_OnMapTrace_per_key_byte_cap_drops_runaway_buffer — memory safety
  • Full suite: 705/705 pass, no regression
  • Real-world: reproduced against captured payloads from Disable getMapTrace() for Ecovacs Goat Lawn Mower #1376

Refs

reniko pushed a commit to reniko/client.py that referenced this pull request May 1, 2026
GetMapTrace V2 format is better handled by upstream PRs DeebotUniverse#1565 and
DeebotUniverse#1567. The xmp9ds test duplicates existing coverage per maintainer
feedback on DeebotUniverse#1564.

https://claude.ai/code/session_01YFYjxwixRZrtjfv1aUfoVQ
Comment thread deebot_client/messages/json/map/__init__.py Fixed
Comment thread deebot_client/messages/json/map/__init__.py Fixed
Comment on lines +32 to +41
def _ecovacs_encode(payload: bytes) -> str:
raw = lzma.compress(
payload,
format=lzma.FORMAT_RAW,
filters=[{"id": lzma.FILTER_LZMA1,
"preset": lzma.PRESET_DEFAULT,
"dict_size": _DICT_SIZE}],
)
header = bytes([0x5D]) + _DICT_SIZE.to_bytes(4, "little") + len(payload).to_bytes(4, "little")
return base64.b64encode(header + raw).decode("ascii")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not encode the data at runtime. Just use the encoded as static string for the test input

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in d88f690 (May 5) — the test inputs are now static pre-encoded base64 constants (_SINGLE_GROUP, _MULTI_GROUP, _EMPTY_GROUPS, _SERIAL_TEST, _NON_JSON at the top of the test file). No runtime encoding happens.

Beennnn added a commit to Beennnn/client.py that referenced this pull request May 5, 2026
Address review feedback from edenhaus on DeebotUniverse#1567:
- Replace runtime LZMA encoding in tests with pre-computed static
  base64 strings. Test inputs are now constants, not computed at
  test time.
- Remove mid/batid from debug log message to satisfy CodeQL
  "clear-text logging of sensitive information" alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Beennnn

Beennnn commented May 9, 2026

Copy link
Copy Markdown
Contributor Author

Pushed MowerMapTrace (accumulator + SVG renderer) on the same branch.

Addresses @edenhaus' review on home-assistant/core#169590 ("the creation of the map should go into the library"): moves the rendering and FIFO-cap accumulation out of the HA integration and into this lib. 9 unit tests added (tests/test_mower_trace.py). The HA Core PR is now a thin consumer.

Beennnn added a commit to Beennnn/core that referenced this pull request May 9, 2026
Mower devices (Ecovacs GOAT family) do not expose a ``map=`` capability
in their hardware definitions, so the existing ``EcovacsMap`` image
entity is not created for them — yet recent mower firmwares actively
push trajectory points via ``MapTraceEvent``.

This adds a sibling ``EcovacsMowerTraceMap`` image entity that is
created for any device whose ``capabilities.device_type is MOWER``.
It subscribes directly to ``MapTraceEvent`` on the device's event bus,
parses the ``"x,y;x,y;..."`` payload into a list of points, and
renders a simple SVG polyline of the cumulative trajectory.

Implementation notes:

- The trace is accumulated in memory (cap at 5 000 points to bound
  memory; older points are dropped FIFO).
- Y axis is flipped because the mower coordinate frame is bottom-up
  while SVG is top-down.
- ``viewBox`` is computed per push from the bounding box, with a 5%
  padding, so the entire trajectory remains visible regardless of
  garden size.
- ``stroke-width`` scales with the bounding-box width so the line
  remains visible on both small and large lawns.

Requires ``deebot-client>=18.3.0`` (which ships ``OnMapTrace`` —
DeebotUniverse/client.py#1567).

Tests pending — opening as draft for design feedback first.
@Beennnn Beennnn force-pushed the feat/parse-mower-getmaptrace-fw-1.15 branch from 5d454e6 to 6fed632 Compare May 12, 2026 10:16
Comment thread deebot_client/mower_trace.py Outdated
@@ -0,0 +1,98 @@
"""Mower trajectory accumulator and SVG renderer.

Mowers (e.g. Ecovacs GOAT family) do not expose the regular ``map``

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the map capability. MapTraceEvent is also used by the vacuum bots. I also know that mower have a full map, so we should implement that one instead creating a new workaround just for the traces

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — pushed 550a750 that removes mower_trace.py (and its tests) from this PR entirely. This scope-narrows #1567 to just the parser side: it stops the ~217k warning storm and emits structured events. The rendering / full-map capability for mowers is the right home for the SVG side of things — I'll open a separate PR for that once we have the static map (onMI/onArI/getAreaSet) parsing done. @monsivar generously offered sanitised MQTT captures + a Python viewer to validate the anchor-matching approach for zone registration, so that follow-up work has a real reference to build against.

@monsivar

Copy link
Copy Markdown
Contributor

Thanks for working on this. I have been analysing a separate GOAT MQTT capture (O1200 LiDAR Pro) containing both the static map and live mowing data. I think a few observations may be relevant to this PR.

1. index appears to represent chunks of one compressed stream

In my capture, payloads with the same (mid, batid, serial, type) and different index values must be assembled before LZMA decompression.

Only index=0 contains the LZMA header. Later indexes contain continuation bytes and cannot be decompressed independently.

The decoding flow that worked was:

key = (mid, batid, serial, type)

chunks[key][int(index)] = base64.b64decode(info)

compressed = b"".join(
    chunks[key][index]
    for index in sorted(chunks[key])
)

decoded = decompress_lzma(compressed)

infoSize appears to be the total byte length of the decompressed JSON, rather than the size of an individual compressed chunk. I use it as a completion check:

len(decoded) == infoSize

This also seems consistent with the sample in issue #1376, where index=0 begins with the LZMA header and index=1 appears to continue the same compressed stream.

2. serial may not be a monotonic trace/cycle identifier

I would be careful about using serial as MapTraceEvent.start.

The logs in #1376 contain several different batid values with the same serial=2. My own captures also suggest that serial identifies a dataset or layer and may be reused across multiple batches.

At least (mid, batid, serial, type) should probably be preserved when assembling or resetting state.

3. Group and segment boundaries should probably be preserved

The current implementation drops the leading "0" and concatenates all segments from all groups into one polyline.

That may create artificial lines between otherwise independent segments:

end of group 5 segment 1
    -> start of group 5 segment 2
    -> start of group 6 segment 1

In other GOAT map payloads I have decoded, group IDs, subtypes and record IDs carry important semantic information. I would therefore preserve the original structure, for example:

[
    {
        "group_id": "5",
        "segments": [
            [(x1, y1), (x2, y2)],
            [(x3, y3), (x4, y4)],
        ],
    },
]

A flattened compatibility representation could still be generated later, but the original group and path-break information would not be lost.

The leading "0" may be a segment-start/path-break marker rather than an ordinary point. Dropping it as a coordinate makes sense, but the segment boundary itself should probably remain represented.

4. GOAT devices also expose a complete static map

My capture confirms the maintainer's observation that GOAT devices have a full map in addition to the trajectory data.

For the device I tested, the relevant MQTT data is split approximately as follows:

onMI
    main map boundary

onArI, layer 1
    area/zone boundaries

getAreaSet
    area IDs, names, adjacency and reference metadata

onMapTrack
    live mowing plan, remaining rows and working contours

onPos
    live mower position

The static onMI and onArI geometry is encoded as a start coordinate followed by an RLE-compressed eight-direction path:

DIRECTIONS = {
    "1": ( 1,  0),
    "2": ( 1, -1),
    "3": ( 0, -1),
    "4": (-1, -1),
    "5": (-1,  0),
    "6": (-1,  1),
    "7": ( 0,  1),
    "8": ( 1,  1),
}

One decoded direction step is 50 coordinate units in my capture.

The area records are not always stored at their final main-map position. I was able to place them without hardcoded offsets by:

  1. decoding the main-map and area direction sequences;
  2. finding their longest shared contiguous direction sequence;
  3. using the corresponding points as anchors;
  4. translating the complete area polygon into the main-map coordinate system.
offset_x = main_anchor_x - area_anchor_x
offset_y = main_anchor_y - area_anchor_y

The matched boundaries were then verified point by point. For all three zones in my sample, every point in the selected shared boundary matched exactly after translation.

This may provide a path towards implementing the regular map capability for GOAT devices rather than only rendering a cumulative trace.

5. Static maps and live tracks should remain separate concepts

The captures suggest that these are distinct datasets:

  • static main-map boundary;
  • persistent area polygons;
  • live planned mowing rows;
  • completed or remaining work contours;
  • robot-position updates.

Flattening all of these into one cumulative trajectory may make an initial image possible, but it will make it difficult to reproduce what the Ecovacs app displays.

I can provide sanitised MQTT samples, decoded JSON files and the experimental Python viewer used to verify the map and zone registration if that would be useful.

@Beennnn

Beennnn commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the extremely detailed review — these observations land squarely on the limits of the current PR. Your captures of an O1200 LiDAR Pro reach further than the single A1600 firmware sample I had, and 1–3 + 5 are clearly blocking issues for a correct implementation.

Here is what I plan to revise on this PR, plus what I think belongs in a follow-up:

1. Chunk assembly before LZMA decompression — will fix

You are right. The current handler treats every index as a standalone compressed blob and only succeeds because the single-chunk A1600 capture happened to fit in index=0. The fix is to buffer per (mid, batid, serial, type) and only decompress once the concatenated payload reaches infoSize. I will refactor along the lines of:

key = (mid, batid, serial, type_)
chunks[key][int(index)] = base64.b64decode(info)

if sum(len(c) for c in chunks[key].values()) >= info_size:
    compressed = b"".join(chunks[key][i] for i in sorted(chunks[key]))
    decoded = decompress_lzma(compressed)
    assert len(decoded) == info_size
    chunks.pop(key)

with a bounded buffer (drop incomplete keys after N seconds or M kB) so a missed final chunk doesn't leak memory across mowing cycles.

2. serial is not monotonic — will fix

Confirmed against the #1376 sample (same serial=2 across different batid values). Using it as MapTraceEvent.start was incorrect; it bypassed the Map helper's reset only by accident. I'll switch the assembly key to (mid, batid, serial, type) as you suggest, and stop overloading start with semantics it doesn't have.

3. Group and segment boundaries — will preserve

Agreed, flattening was a deliberate shortcut to land something usable in HA before the design was settled, but it conflates information that may matter (path breaks, distinct contours, per-group semantics). I'll switch the event payload to:

[
  {"group_id": "5", "segments": [[(x1, y1), ...], [(x3, y3), ...]]},
  ...
]

and keep the flat string as an optional compatibility projection, generated downstream if a consumer still needs it. The leading "0" becomes an explicit segment-start marker rather than being silently dropped.

4. Static map via onMI / onArI / getAreaSet — follow-up PR

This is genuinely new scope (RLE-encoded eight-direction path, area registration via shared-boundary anchor matching) and I'd rather not bundle it with the live trace fix to keep this PR reviewable. I will open a separate PR once #1567 lands, and would very much welcome the sanitised samples + your experimental viewer for that work — it would let me validate the anchor-matching approach against a known-good reference instead of guessing.

5. Keep static maps and live tracks as distinct concepts — agreed

This is the design principle that holds the other four fixes together. I'll make sure the refactored event keeps onMapTrack (live mowing plan), onPos (live position), and the eventual onMI/onArI static geometry on separate channels rather than concatenating them into one trajectory.

Samples

If you can share the sanitised MQTT captures + decoded JSON + your Python viewer, that would be hugely helpful — both for landing 1-3 correctly here and for scoping the static-map PR. Happy to receive them as a gist, a PR to my fork, or any other channel that suits you.

I'll push a revised commit on this branch addressing 1–3 + 5 over the next few days.

Beennnn added a commit to Beennnn/client.py that referenced this pull request Jun 30, 2026
…onMapTrace

Address review feedback from @monsivar on DeebotUniverse#1567 based on real
O1200 LiDAR Pro MQTT captures.

1. Reassemble chunked LZMA streams before decompression. Firmware
   paginates one contiguous stream across `index` values; only
   `index=0` carries the LZMA header. Previously each chunk was
   treated as standalone, which only worked for single-chunk
   captures. Now buffered per `(mid, batid, serial, type)` and
   decompressed once `len(decoded) >= infoSize`.

2. Stop using `serial` as `MapTraceEvent.start`. Real captures
   reuse the same serial across different batid values; the
   previous behaviour bypassed the `Map` Rust helper's reset
   logic by accident. Use a stable non-zero constant (always
   ≠ 0 so the Map helper appends rather than clearing).

3. Add `MowerMapTraceEvent` to preserve `group_id` and
   per-segment boundaries from the firmware payload (zone /
   layer / cycle semantics). The legacy `MapTraceEvent` with
   a flattened compatibility string is still emitted for the
   Rust `Map` renderer; structure-aware consumers subscribe
   to the new event instead.

4. Static maps and live traces stay distinct concepts. The
   handler now emits one event per concept rather than
   collapsing groups across cycles into a single polyline.

Safety:

- Per-key buffer cap (512 kB) drops runaway buffers when a
  firmware never sends the final chunk.
- Total buffer cap (2 MB) bounds memory across in-flight
  cycles; oldest cycle is evicted on overflow.
- Out-of-order chunk arrivals reassemble in sorted index
  order, so the LZMA header always lands first.
- A second `index=0` for an already-completed-or-started
  key resets just that key's buffer (firmware retry).

Tests:

- 13 OnMapTrace tests (single/multi group, chunk reassembly,
  out-of-order arrivals, per-key cap, fresh-cycle reset,
  independent concurrent keys, corrupt payload paths).
- 3 new MowerMapTrace.add_groups tests for structured
  consumption.
- Full suite: 711 passed (11 docker-marked tests skipped).

Refs: DeebotUniverse#1567 review comment 4826137614
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread deebot_client/messages/json/map/__init__.py Fixed
@Beennnn Beennnn force-pushed the feat/parse-mower-getmaptrace-fw-1.15 branch from fd24877 to 144807b Compare June 30, 2026 18:07
Beennnn added a commit to Beennnn/client.py that referenced this pull request Jun 30, 2026
Address review feedback from edenhaus on DeebotUniverse#1567:
- Replace runtime LZMA encoding in tests with pre-computed static
  base64 strings. Test inputs are now constants, not computed at
  test time.
- Remove mid/batid from debug log message to satisfy CodeQL
  "clear-text logging of sensitive information" alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Beennnn added a commit to Beennnn/client.py that referenced this pull request Jun 30, 2026
…onMapTrace

Address review feedback from @monsivar on DeebotUniverse#1567 based on real
O1200 LiDAR Pro MQTT captures.

1. Reassemble chunked LZMA streams before decompression. Firmware
   paginates one contiguous stream across `index` values; only
   `index=0` carries the LZMA header. Previously each chunk was
   treated as standalone, which only worked for single-chunk
   captures. Now buffered per `(mid, batid, serial, type)` and
   decompressed once `len(decoded) >= infoSize`.

2. Stop using `serial` as `MapTraceEvent.start`. Real captures
   reuse the same serial across different batid values; the
   previous behaviour bypassed the `Map` Rust helper's reset
   logic by accident. Use a stable non-zero constant (always
   ≠ 0 so the Map helper appends rather than clearing).

3. Add `MowerMapTraceEvent` to preserve `group_id` and
   per-segment boundaries from the firmware payload (zone /
   layer / cycle semantics). The legacy `MapTraceEvent` with
   a flattened compatibility string is still emitted for the
   Rust `Map` renderer; structure-aware consumers subscribe
   to the new event instead.

4. Static maps and live traces stay distinct concepts. The
   handler now emits one event per concept rather than
   collapsing groups across cycles into a single polyline.

Safety:

- Per-key buffer cap (512 kB) drops runaway buffers when a
  firmware never sends the final chunk.
- Total buffer cap (2 MB) bounds memory across in-flight
  cycles; oldest cycle is evicted on overflow.
- Out-of-order chunk arrivals reassemble in sorted index
  order, so the LZMA header always lands first.
- A second `index=0` for an already-completed-or-started
  key resets just that key's buffer (firmware retry).

Tests:

- 13 OnMapTrace tests (single/multi group, chunk reassembly,
  out-of-order arrivals, per-key cap, fresh-cycle reset,
  independent concurrent keys, corrupt payload paths).
- 3 new MowerMapTrace.add_groups tests for structured
  consumption.
- Full suite: 711 passed (11 docker-marked tests skipped).

Refs: DeebotUniverse#1567 review comment 4826137614
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Beennnn Beennnn force-pushed the feat/parse-mower-getmaptrace-fw-1.15 branch from 144807b to 6e9eea0 Compare June 30, 2026 20:47
Beennnn added a commit to Beennnn/client.py that referenced this pull request Jun 30, 2026
Address review feedback from edenhaus on DeebotUniverse#1567:
- Replace runtime LZMA encoding in tests with pre-computed static
  base64 strings. Test inputs are now constants, not computed at
  test time.
- Remove mid/batid from debug log message to satisfy CodeQL
  "clear-text logging of sensitive information" alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Beennnn added a commit to Beennnn/client.py that referenced this pull request Jun 30, 2026
…onMapTrace

Address review feedback from @monsivar on DeebotUniverse#1567 based on real
O1200 LiDAR Pro MQTT captures.

1. Reassemble chunked LZMA streams before decompression. Firmware
   paginates one contiguous stream across `index` values; only
   `index=0` carries the LZMA header. Previously each chunk was
   treated as standalone, which only worked for single-chunk
   captures. Now buffered per `(mid, batid, serial, type)` and
   decompressed once `len(decoded) >= infoSize`.

2. Stop using `serial` as `MapTraceEvent.start`. Real captures
   reuse the same serial across different batid values; the
   previous behaviour bypassed the `Map` Rust helper's reset
   logic by accident. Use a stable non-zero constant (always
   ≠ 0 so the Map helper appends rather than clearing).

3. Add `MowerMapTraceEvent` to preserve `group_id` and
   per-segment boundaries from the firmware payload (zone /
   layer / cycle semantics). The legacy `MapTraceEvent` with
   a flattened compatibility string is still emitted for the
   Rust `Map` renderer; structure-aware consumers subscribe
   to the new event instead.

4. Static maps and live traces stay distinct concepts. The
   handler now emits one event per concept rather than
   collapsing groups across cycles into a single polyline.

Safety:

- Per-key buffer cap (512 kB) drops runaway buffers when a
  firmware never sends the final chunk.
- Total buffer cap (2 MB) bounds memory across in-flight
  cycles; oldest cycle is evicted on overflow.
- Out-of-order chunk arrivals reassemble in sorted index
  order, so the LZMA header always lands first.
- A second `index=0` for an already-completed-or-started
  key resets just that key's buffer (firmware retry).

Tests:

- 13 OnMapTrace tests (single/multi group, chunk reassembly,
  out-of-order arrivals, per-key cap, fresh-cycle reset,
  independent concurrent keys, corrupt payload paths).
- 3 new MowerMapTrace.add_groups tests for structured
  consumption.
- Full suite: 711 passed (11 docker-marked tests skipped).

Refs: DeebotUniverse#1567 review comment 4826137614
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.87234% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.13%. Comparing base (7fb6b38) to head (6f1a5b7).
⚠️ Report is 10 commits behind head on dev.

Files with missing lines Patch % Lines
deebot_client/messages/json/map/__init__.py 97.60% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #1567      +/-   ##
==========================================
+ Coverage   95.02%   95.13%   +0.11%     
==========================================
  Files         159      161       +2     
  Lines        6234     6439     +205     
  Branches      353      376      +23     
==========================================
+ Hits         5924     6126     +202     
- Misses        248      249       +1     
- Partials       62       64       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented Jun 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 226 untouched benchmarks


Comparing Beennnn:feat/parse-mower-getmaptrace-fw-1.15 (6f1a5b7) with dev (2a7b432)

Open in CodSpeed

Beennnn and others added 7 commits July 1, 2026 08:54
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:

- DeebotUniverse#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 DeebotUniverse#1565 (skip legacy fallback for mowers) and DeebotUniverse#1566
  (warn-once rate limit). DeebotUniverse#1565 still serves as a safety net for any
  remaining unhandled map messages on mowers.
Address review feedback from edenhaus on DeebotUniverse#1567:
- Replace runtime LZMA encoding in tests with pre-computed static
  base64 strings. Test inputs are now constants, not computed at
  test time.
- Remove mid/batid from debug log message to satisfy CodeQL
  "clear-text logging of sensitive information" alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mowers don't expose the regular map capability used by vacuums; their
trajectory only comes through MapTraceEvent. Move accumulation,
FIFO cap and SVG rendering into the library so consumers only forward
the event payload and read back an SVG.
- json → orjson per TID251 (banned import)
- mower_trace: rename for-loop var to avoid PLW2901 reassignment
- restore `except (TypeError, ValueError):` parens (ruff format had stripped them, breaking Python 3 syntax)
- imports reorganised by ruff
- ruff format applied to test_on_map_trace.py and the map __init__.py

8 unit tests for OnMapTrace still pass.

Addresses CI 'Run prek checks' fail.
ruff-format 0.15.11 (pinned in .pre-commit-config.yaml) incorrectly
rewrites `except (TypeError, ValueError):` to `except TypeError, ValueError:`
which is invalid Python 3 syntax. Confirmed reproducible locally with
`uvx ruff@0.15.11 format --diff`. Newer ruff releases are fine.

Wrap the offending block with `# fmt: off` / `# fmt: on` so prek doesn't
strip the tuple parens. The semantics (single int() call, two distinct
exception types caught) are unchanged.
…onMapTrace

Address review feedback from @monsivar on DeebotUniverse#1567 based on real
O1200 LiDAR Pro MQTT captures.

1. Reassemble chunked LZMA streams before decompression. Firmware
   paginates one contiguous stream across `index` values; only
   `index=0` carries the LZMA header. Previously each chunk was
   treated as standalone, which only worked for single-chunk
   captures. Now buffered per `(mid, batid, serial, type)` and
   decompressed once `len(decoded) >= infoSize`.

2. Stop using `serial` as `MapTraceEvent.start`. Real captures
   reuse the same serial across different batid values; the
   previous behaviour bypassed the `Map` Rust helper's reset
   logic by accident. Use a stable non-zero constant (always
   ≠ 0 so the Map helper appends rather than clearing).

3. Add `MowerMapTraceEvent` to preserve `group_id` and
   per-segment boundaries from the firmware payload (zone /
   layer / cycle semantics). The legacy `MapTraceEvent` with
   a flattened compatibility string is still emitted for the
   Rust `Map` renderer; structure-aware consumers subscribe
   to the new event instead.

4. Static maps and live traces stay distinct concepts. The
   handler now emits one event per concept rather than
   collapsing groups across cycles into a single polyline.

Safety:

- Per-key buffer cap (512 kB) drops runaway buffers when a
  firmware never sends the final chunk.
- Total buffer cap (2 MB) bounds memory across in-flight
  cycles; oldest cycle is evicted on overflow.
- Out-of-order chunk arrivals reassemble in sorted index
  order, so the LZMA header always lands first.
- A second `index=0` for an already-completed-or-started
  key resets just that key's buffer (firmware retry).

Tests:

- 13 OnMapTrace tests (single/multi group, chunk reassembly,
  out-of-order arrivals, per-key cap, fresh-cycle reset,
  independent concurrent keys, corrupt payload paths).
- 3 new MowerMapTrace.add_groups tests for structured
  consumption.
- Full suite: 711 passed (11 docker-marked tests skipped).

Refs: DeebotUniverse#1567 review comment 4826137614
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL flagged the log message in OnMapTrace as 'Clear-text logging of
sensitive information' because the formatted tuple included mid/batid.
Same class of issue as the earlier d88f690 fix on this branch — only the
cap value is needed for diagnosis, the device identifiers are not.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new test asserts that get_message(name, static) returns the right
handler per device class. With the OnMapTrace handler registered for
mowers (xmp9ds), the expected value flips from None to OnMapTrace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Beennnn Beennnn force-pushed the feat/parse-mower-getmaptrace-fw-1.15 branch from 6e9eea0 to 0874e65 Compare July 1, 2026 06:55
Beennnn and others added 2 commits July 1, 2026 08:57
Per @edenhaus review on mower_trace.py:3 ("we should use the map
capability … instead of creating a workaround just for the traces"),
the SVG accumulator/renderer is out of scope for this PR.

Kept in this PR:
- The onMapTrace message handler that stops the 217k-warning log storm
- The MowerMapTraceEvent structure that preserves group/segment
  semantics (needed by any future consumer, whether the map capability
  or a downstream renderer)
- The compatibility MapTraceEvent flat projection so today's Rust Map
  renderer keeps working for vacuums

Removed:
- deebot_client/mower_trace.py (MowerMapTrace accumulator + SVG renderer)
- tests/test_mower_trace.py (12 tests for the renderer)

The full-map capability for mowers (onMI / onArI / getAreaSet static
map + zone registration) is the right home for rendering. That work is
tracked separately with @monsivar sharing sanitised MQTT captures + a
Python viewer for anchor-matching validation — planned as a follow-up
PR once this parser lands.

Suite: 705/705 pass (was 717, minus the 12 removed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Codecov patch coverage report (was 87.43% patch on this PR):

- test_OnMapTrace_parse_groups_handles_malformed_input: exercises the
  non-list-group / empty-group / non-string-segment / invalid-point-token
  skip branches inside _parse_groups. Also invalid-JSON and non-list-root
  guard paths.
- test_OnMapTrace_evict_to_make_room_drops_oldest_by_total_bytes: covers
  the while _total() + incoming > _MAX_TOTAL_BYTES loop that per-key
  eviction alone doesn't hit.
- test_OnMapTrace_evict_to_make_room_caps_keys_tracked: covers the
  _MAX_KEYS_TRACKED eviction path.

Suite: 708 pass (was 705, +3 targeted tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@monsivar

monsivar commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

If you can share the sanitised MQTT captures + decoded JSON + your Python viewer, that would be hugely helpful — both for landing 1-3 correctly here and for scoping the static-map PR. Happy to receive them as a gist, a PR to my fork, or any other channel that suits you.

Thanks for the response. I have prepared a sanitised package containing the material I have collected and the tools I have used so far:

The package includes:

  • sanitised MQTT captures for the relevant static-map and live-map topics, including onMI, onArI, onMapTrack, onPos and related request/response messages;
  • the decoded JSON output from the compressed map payloads;
  • a chunk-aware Base64/LZMA decoding example;
  • a Python viewer for reconstructing the static main map and registering the area boundaries;
  • an experimental viewer for the live MapTrack data, planned mowing rows and robot position;
  • normalised JSON exports that preserve groups, record IDs, batch IDs and segment boundaries;
  • notes describing the observed formats, assumptions, confirmed findings and unresolved questions;
  • a sanitisation report describing which identifiers and credentials were removed or replaced.

The most important findings in the package are:

  1. Payload chunks with the same (mid, batid, serial, type) appear to be parts of one compressed LZMA stream and must be concatenated in index order before decompression.

  2. infoSize appears to represent the size of the decompressed JSON data.

  3. serial does not appear to be a unique batch or mowing-session identifier by itself. Different batid values may reuse the same serial.

  4. Group IDs, subtypes, record IDs and segment boundaries should be preserved. Flattening everything into one polyline may create artificial connections between unrelated paths.

  5. The static main map and area boundaries are represented as run-length-encoded eight-direction paths.

  6. Area boundaries can be registered against the main map without hardcoded offsets by finding the longest shared contiguous direction sequence and using the corresponding points as anchors.

  7. The live onMapTrack data appears to contain separate records for planned rows, working contours and incremental updates. This part is still experimental and should not yet be considered a complete protocol specification.

A small disclaimer: I am not a software developer and do not have a strong background in protocol reverse engineering. I have worked through this using the limited technical knowledge I have, a lot of testing and comparison, and assistance from AI tools. The viewers and documentation should therefore be treated as reference material and experimental findings rather than production-ready code.

I am happy to collect more captures, test hypotheses or inspect specific app actions. It would help me greatly if requests could be made as clearly and specifically as possible, for example:

  • perform one named action in the app;
  • capture the MQTT traffic before, during and after that action;
  • provide a capture while the mower is in a particular state;
  • compare two settings while changing only one value;
  • verify a specific field or message type.

With a clear description of the required action and the data you need, I will do my best to provide additional sanitised captures and results.

goat_map_mqtt_capture_sanitized.zip

@Beennnn

Beennnn commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the sanitised package — this is genuinely more than I was hoping for, and it lines up cleanly with what's already in this PR after the refactor. Cross-checking your FORMAT_NOTES.md against the current diff:

§1 — LZMA-alone header repair. The insert-four-zeros-at-byte-8 step is already what the existing Rust helper decompress_base64_data does (src/util.rs:36-38), and this PR reuses it as-is for the chunked path.

§2 — Chunk assembly with (mid, batid, serial, type). Same key here (_CHUNK_BUFFER: dict[tuple[str, str, str, str], dict[int, bytes]]), with an empty string when type is absent, and reassembly sorted by integer index before decompression. Your finding §3 — "serial isn't a unique batch id by itself" — is precisely why batid is part of the key rather than a bare serial lookup.

§7 — preserve structure. The refactor now emits MowerMapTraceEvent(groups=list[MowerMapTraceGroup]), each with its own group_id and segments: list[MowerMapTraceSegment], so group/segment breaks and record IDs survive. The flat data field is derived once for legacy consumers, not the source of truth. Original raw record text isn't kept — happy to add it if you'd find it useful downstream.

§3, §4, §5, §6, §8 — static main map, onArI layer-1 zones, getAreaSet, onMapTrack full/patch, deebotPos/chargePos semantics. These are exactly the surface I was hoping to scope for the follow-up static-map PR. The RLE 8-direction paths + longest-shared-sequence anchor alignment, the ["1","1"] vs ["1","2"] full/patch discrimination on onMapTrack, the chargePos.invalid flag on onPos — all of that would land there rather than trying to bolt it onto onMapTrace.

Two small pinpoint questions to make sure I map them correctly:

  1. In §6, is the "route ID" you key state by exactly the triple (record_type, subtype, record_id) from 2;1;99;… — i.e. (2, 1, 99) — or is one of those a per-batch counter that shouldn't be part of the identity?
  2. In §4, when the longest shared direction sequence returns zero, do you drop the zone or fall back to local_start_x,local_start_y as-is? A recipe for the degenerate case would save me a wrong assumption later.

If you're OK with it, I'd like to open the follow-up PR referencing docs/FORMAT_NOTES.md from your package (with attribution) as the interpretation source. Nothing you shared changes the shape of this PR — the refactor already reflects it — so I don't think #1567 needs another push on my side unless you spot something the diff still gets wrong.

@monsivar

monsivar commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Thanks, that all sounds good to me, and I am happy for you to reference docs/FORMAT_NOTES.md from the package with attribution.

Also good to hear that the current refactor already matches the chunking and structure-preserving parts of the findings. I do not currently see anything in #1567 that obviously contradicts the captures I shared.

Regarding your two questions:

1. Route identity in onMapTrack

For the live onMapTrack state, the key I used is the full triple:

(record_type, subtype, record_id)

So for:
2;1;99;...
I treated the identity as:
("2", "1", "99")

In my capture, this was important because the same record_id value may have different meaning depending on the first two fields.

My current interpretation is:

the first field is a high-level work/status group;
the second field is a subtype, for example rows vs contour-like geometry;
the third field is the record ID within that namespace.

For example, the active row was repeatedly updated as:
2;1;99;...
The later onMapTrack patch messages did not append a new route. They replaced the current state for the same key ("2", "1", "99").

So my current recipe is:

key = (parts[0], parts[1], parts[2])
records[key] = parsed_record

For a full snapshot, I would replace the whole current onMapTrack state. For a patch/update, I would update records by that triple key.

I would not treat 99 alone as the route identity.

I also would not yet assume that any of the three fields is globally unique across different batches or map sessions. The triple is only the safest observed identity inside the current reconstructed onMapTrack state.

Keeping the original raw record text would be useful downstream if it is not too much trouble. It makes it much easier to re-check assumptions later, especially while the meaning of the first two fields is still being reverse engineered.

  1. Longest shared sequence returns zero

For static area alignment, I would not fall back to local_start_x, local_start_y as if it were already in the main-map coordinate system.

In my sample, the area start coordinates were local to the area geometry and were not always the final global placement. The correct placement came from the shared-boundary anchor, not from the local start coordinate.

So my suggested degenerate-case recipe is:

Decode the area geometry and keep it in the output as raw/local geometry.
Try to find the longest shared contiguous direction sequence against the main map.
If a sufficiently long match is found, compute the translation from the two anchor points and mark the area as registered.
If the match length is zero, or below a confidence threshold, do not place the area onto the main map as a confirmed polygon.
Mark it as unregistered / local-only / low-confidence in the parsed result.

Pseudo-logic:

match = longest_shared_sequence(main_tokens, area_tokens)

if match.size >= MIN_SHARED_STEPS:
    offset = main_points[match.main_index] - area_points[match.area_index]
    registered_points = translate(area_points, offset)
    registered = True
else:
    registered_points = None
    local_points = area_points
    registered = False

For the sample I shared, the matches were large:

area 1: 399 shared direction steps
area 2: 859 shared direction steps
area 4: 583 shared direction steps

and every point along the selected shared boundary matched exactly after translation.

I would therefore treat a zero-length match as “we do not know how to place this area yet”, rather than assuming the local start coordinate is a valid global anchor.

A later fallback might be possible using getAreaSet reference coordinates, bounding boxes, adjacency, or another contour layer, but I have not verified that yet. In my capture, the getAreaSet reference coordinates did not behave like direct polygon origins.

One extra robustness point: I have only verified the direct same-direction boundary match in my own capture. A future parser may also want to try the reversed boundary direction, since another firmware could theoretically encode the same shared boundary in the opposite traversal direction. But if both direct and reversed matching fail, I would keep the area unregistered rather than placing it incorrectly.

Thanks again for picking this up. I agree that #1567 does not need to absorb the static-map logic. Keeping this PR focused on the structured mower onMapTrace parser and then doing onMI / onArI / getAreaSet / onMapTrack in a follow-up static-map PR sounds like the right split to me.

And please remember I am still learning while working on this, so please excuse any mistakes, wrong assumptions or misunderstandings in my notes or terminology.

Downstream consumers can re-check assumptions about the leading marker
and the x,y split as the format is reverse-engineered further. Per
monsivar's format notes on DeebotUniverse#1567.
@Beennnn

Beennnn commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, that's exactly the recipes I needed.

I just pushed d521cca which adds raw: str | None = None to MowerMapTraceSegment and populates it with the original firmware segment string during parsing. So the leading-marker + x,y split assumptions can be re-verified downstream without going back to the compressed stream. Kept as None by default so old constructor call-sites don't need touching, and default-frozen dataclass equality still works.

On your two recipes — both go verbatim into the follow-up static-map PR, no reinterpretation needed on my side:

Route identity — triple (record_type, subtype, record_id), replacement semantics on both full snapshots and patches, no per-field uniqueness assumption across batches. That maps cleanly to a dict[tuple[str, str, str], Record] keyed on the parsed triple, updated in place. Your point that the same record_id value carries different meaning under different (record_type, subtype) pairs is exactly the kind of thing the raw field will help sanity-check as more captures come in.

Zero-shared-sequence fallback — no coercion of local coordinates into the main-map frame. I'll model each area as {registered_points: list | None, local_points: list, registered: bool, shared_steps: int} and require shared_steps >= MIN_SHARED_STEPS (starting at some conservative floor like 100, tunable against your capture where the smallest confirmed match was 399) before setting registered=True. The reversed-boundary attempt goes as a second pass before giving up. Everything below threshold ends up as registered=False with the local geometry preserved — the UI/consumer decides whether to render it at all.

I'll also lean on docs/FORMAT_NOTES.md from your package as the reference source in that follow-up PR, with attribution.

And nothing to excuse — the notes are precise and the failure modes you called out (per-batch identity, degenerate anchor, reversed traversal) are the ones that would have bitten a first-pass implementation. Much easier to design the follow-up against them than to discover them after the fact.

…h paths

Both branches (except KeyError/TypeError/ValueError on envelope fields,
same on int(infoSize)) were flagged by codecov/patch on d521cca as the
missing coverage delta on DeebotUniverse#1567.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants