Skip to content

Commit 5db2ba5

Browse files
committed
feat(map): add MowerMapTrace renderer for trace points
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.
1 parent d88f690 commit 5db2ba5

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

deebot_client/mower_trace.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Mower trajectory accumulator and SVG renderer.
2+
3+
Mowers (e.g. Ecovacs GOAT family) do not expose the regular ``map``
4+
capability used by vacuums, but their firmware pushes trajectory points
5+
through :class:`~deebot_client.events.map.MapTraceEvent`. This module
6+
keeps the parsing, accumulation and rendering of those points in one
7+
place so consumers (e.g. the Home Assistant integration) only have to
8+
forward the event payload and read back an SVG.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
14+
class MowerMapTrace:
15+
"""Accumulator and SVG renderer for mower trajectory traces."""
16+
17+
MAX_POINTS = 5000
18+
19+
_STROKE_COLOR = "#1976d2"
20+
21+
def __init__(self) -> None:
22+
self._points: list[tuple[int, int]] = []
23+
24+
@property
25+
def has_points(self) -> bool:
26+
"""Return whether any trace points have been accumulated."""
27+
return bool(self._points)
28+
29+
def clear(self) -> None:
30+
"""Drop all accumulated trace points."""
31+
self._points.clear()
32+
33+
def add_data(self, raw: str) -> int:
34+
"""Parse a ``MapTraceEvent.data`` string and accumulate points.
35+
36+
Tokens are ``"x,y"`` separated by ``";"``. Malformed tokens are
37+
skipped silently. The accumulator keeps at most :attr:`MAX_POINTS`
38+
points (FIFO drop). Returns the number of points actually added.
39+
"""
40+
new_points: list[tuple[int, int]] = []
41+
for token in raw.split(";"):
42+
token = token.strip()
43+
if not token:
44+
continue
45+
try:
46+
x_str, y_str = token.split(",")
47+
new_points.append((int(x_str), int(y_str)))
48+
except ValueError:
49+
continue
50+
51+
if not new_points:
52+
return 0
53+
54+
self._points.extend(new_points)
55+
if len(self._points) > self.MAX_POINTS:
56+
self._points = self._points[-self.MAX_POINTS :]
57+
return len(new_points)
58+
59+
def to_svg(self) -> str | None:
60+
"""Render the accumulated trace as an SVG polyline.
61+
62+
Returns ``None`` when no points have been accumulated yet.
63+
"""
64+
if not self._points:
65+
return None
66+
67+
xs = [p[0] for p in self._points]
68+
ys = [p[1] for p in self._points]
69+
min_x, max_x = min(xs), max(xs)
70+
min_y, max_y = min(ys), max(ys)
71+
72+
# 5% padding on the larger of the two dimensions, with a 50-unit floor
73+
# so a near-zero-area trace still has visible margin.
74+
padding = max(50, max(max_x - min_x, max_y - min_y) // 20)
75+
min_x -= padding
76+
max_x += padding
77+
min_y -= padding
78+
max_y += padding
79+
80+
width = max_x - min_x
81+
height = max_y - min_y
82+
83+
# Mower coordinates use bottom-up Y; flip for SVG top-down rendering.
84+
flipped = " ".join(f"{x},{max_y + min_y - y}" for x, y in self._points)
85+
86+
# Stroke scales with width so the line stays visible on tiny lawns
87+
# and isn't a hairline on huge ones.
88+
stroke_width = max(20, width // 200)
89+
90+
return (
91+
f'<svg xmlns="http://www.w3.org/2000/svg" '
92+
f'viewBox="{min_x} {min_y} {width} {height}" '
93+
f'preserveAspectRatio="xMidYMid meet">'
94+
f'<polyline points="{flipped}" fill="none" '
95+
f'stroke="{self._STROKE_COLOR}" stroke-width="{stroke_width}" '
96+
f'stroke-linejoin="round" stroke-linecap="round"/>'
97+
f"</svg>"
98+
)

tests/test_mower_trace.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for the MowerMapTrace accumulator and SVG renderer."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from deebot_client.mower_trace import MowerMapTrace
8+
9+
10+
def test_empty_trace_renders_to_none() -> None:
11+
trace = MowerMapTrace()
12+
assert not trace.has_points
13+
assert trace.to_svg() is None
14+
15+
16+
def test_add_data_parses_tokens_and_returns_count() -> None:
17+
trace = MowerMapTrace()
18+
added = trace.add_data("0,0;100,200;300,400")
19+
assert added == 3
20+
assert trace.has_points
21+
22+
23+
def test_add_data_skips_malformed_tokens() -> None:
24+
trace = MowerMapTrace()
25+
added = trace.add_data("0,0;not-a-point;100,abc;200,300;;")
26+
# Only "0,0" and "200,300" parse cleanly.
27+
assert added == 2
28+
29+
30+
def test_add_data_returns_zero_on_all_invalid() -> None:
31+
trace = MowerMapTrace()
32+
assert trace.add_data(";;not-valid;") == 0
33+
assert not trace.has_points
34+
35+
36+
def test_clear_drops_all_points() -> None:
37+
trace = MowerMapTrace()
38+
trace.add_data("1,2;3,4")
39+
trace.clear()
40+
assert not trace.has_points
41+
assert trace.to_svg() is None
42+
43+
44+
def test_max_points_fifo_drop() -> None:
45+
trace = MowerMapTrace()
46+
over = MowerMapTrace.MAX_POINTS + 100
47+
payload = ";".join(f"{i},{i}" for i in range(over))
48+
added = trace.add_data(payload)
49+
assert added == over
50+
51+
svg = trace.to_svg()
52+
assert svg is not None
53+
54+
# Polyline holds exactly MAX_POINTS coordinate pairs after FIFO drop.
55+
points_attr = svg.split('points="', 1)[1].split('"', 1)[0]
56+
assert len(points_attr.split(" ")) == MowerMapTrace.MAX_POINTS
57+
58+
# The MAX_POINTS most recent points are kept; first kept point has
59+
# x = (over - MAX_POINTS), here 100.
60+
first_kept_x = over - MowerMapTrace.MAX_POINTS
61+
assert points_attr.startswith(f"{first_kept_x},")
62+
63+
64+
@pytest.mark.parametrize(
65+
("raw", "expected_substrings", "absent_substrings"),
66+
[
67+
# Single-point trace: padding gives a non-zero viewBox.
68+
("500,500", ["polyline", "viewBox=", "stroke="], []),
69+
# Two-point trace: SVG contains the flipped polyline points.
70+
("0,0;100,100", ["polyline points=", "viewBox="], []),
71+
],
72+
)
73+
def test_to_svg_structure(
74+
raw: str, expected_substrings: list[str], absent_substrings: list[str]
75+
) -> None:
76+
trace = MowerMapTrace()
77+
trace.add_data(raw)
78+
svg = trace.to_svg()
79+
assert svg is not None
80+
assert svg.startswith("<svg")
81+
assert svg.endswith("</svg>")
82+
for needle in expected_substrings:
83+
assert needle in svg
84+
for needle in absent_substrings:
85+
assert needle not in svg
86+
87+
88+
def test_to_svg_y_axis_is_flipped() -> None:
89+
"""SVG renders top-down; mower coords are bottom-up. Verify the flip."""
90+
trace = MowerMapTrace()
91+
trace.add_data("0,0;0,1000")
92+
svg = trace.to_svg()
93+
assert svg is not None
94+
# Extract the polyline points attribute.
95+
points = svg.split('points="', 1)[1].split('"', 1)[0].split(" ")
96+
# Two points, both with x=0; y values must be swapped versus input
97+
# (input 0 -> output max_y+min_y-0 = larger value;
98+
# input 1000 -> output max_y+min_y-1000 = smaller value).
99+
y0 = int(points[0].split(",")[1])
100+
y1 = int(points[1].split(",")[1])
101+
assert y0 > y1

0 commit comments

Comments
 (0)