Skip to content

Commit 84aea48

Browse files
committed
feat(ecovacs): add trace map image entity for mowers
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.
1 parent 9c9b626 commit 84aea48

2 files changed

Lines changed: 93 additions & 3 deletions

File tree

homeassistant/components/ecovacs/image.py

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Ecovacs image entities."""
22

3+
from datetime import UTC, datetime
34
from typing import cast
45

5-
from deebot_client.capabilities import CapabilityMap
6+
from deebot_client.capabilities import Capabilities, CapabilityMap, DeviceType
67
from deebot_client.device import Device
7-
from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent
8+
from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent, MapTraceEvent
89
from deebot_client.map import Map
910

1011
from homeassistant.components.image import ImageEntity
@@ -15,6 +16,8 @@
1516
from . import EcovacsConfigEntry
1617
from .entity import EcovacsEntity
1718

19+
_TRACE_MAX_POINTS = 5000
20+
1821

1922
async def async_setup_entry(
2023
hass: HomeAssistant,
@@ -23,11 +26,16 @@ async def async_setup_entry(
2326
) -> None:
2427
"""Add entities for passed config_entry in HA."""
2528
controller = config_entry.runtime_data
26-
entities = [
29+
entities: list[ImageEntity] = [
2730
EcovacsMap(device, caps, hass)
2831
for device in controller.devices
2932
if (caps := device.capabilities.map)
3033
]
34+
entities.extend(
35+
EcovacsMowerTraceMap(device, hass)
36+
for device in controller.devices
37+
if device.capabilities.device_type is DeviceType.MOWER
38+
)
3139

3240
if entities:
3341
async_add_entities(entities)
@@ -87,3 +95,82 @@ async def async_update(self) -> None:
8795
"""
8896
await super().async_update()
8997
self._map.refresh()
98+
99+
100+
class EcovacsMowerTraceMap(
101+
EcovacsEntity[Capabilities],
102+
ImageEntity,
103+
):
104+
"""Mower trajectory image rendered from MapTraceEvent."""
105+
106+
_attr_content_type = "image/svg+xml"
107+
108+
entity_description = EntityDescription(
109+
key="trace_map",
110+
translation_key="trace_map",
111+
)
112+
113+
def __init__(self, device: Device, hass: HomeAssistant) -> None:
114+
"""Initialize entity."""
115+
super().__init__(device, device.capabilities, hass=hass)
116+
self._points: list[tuple[int, int]] = []
117+
self._svg_cache: bytes | None = None
118+
119+
def image(self) -> bytes | None:
120+
"""Return bytes of image or None."""
121+
if not self._points:
122+
return None
123+
if self._svg_cache is None:
124+
self._svg_cache = self._render_svg().encode()
125+
return self._svg_cache
126+
127+
def _render_svg(self) -> str:
128+
xs = [p[0] for p in self._points]
129+
ys = [p[1] for p in self._points]
130+
min_x, max_x = min(xs), max(xs)
131+
min_y, max_y = min(ys), max(ys)
132+
padding = max(50, (max(max_x - min_x, max_y - min_y)) // 20)
133+
min_x -= padding
134+
max_x += padding
135+
min_y -= padding
136+
max_y += padding
137+
width = max_x - min_x
138+
height = max_y - min_y
139+
# Mower coordinates use bottom-up Y; flip for SVG top-down rendering.
140+
flipped = " ".join(f"{x},{max_y + min_y - y}" for x, y in self._points)
141+
stroke_width = max(20, width // 200)
142+
return (
143+
f'<svg xmlns="http://www.w3.org/2000/svg" '
144+
f'viewBox="{min_x} {min_y} {width} {height}" '
145+
f'preserveAspectRatio="xMidYMid meet">'
146+
f'<polyline points="{flipped}" fill="none" '
147+
f'stroke="#1976d2" stroke-width="{stroke_width}" '
148+
f'stroke-linejoin="round" stroke-linecap="round"/>'
149+
f"</svg>"
150+
)
151+
152+
async def async_added_to_hass(self) -> None:
153+
"""Set up the event listeners now that hass is ready."""
154+
await super().async_added_to_hass()
155+
156+
async def on_trace(event: MapTraceEvent) -> None:
157+
new_points: list[tuple[int, int]] = []
158+
for token in event.data.split(";"):
159+
token = token.strip()
160+
if not token:
161+
continue
162+
try:
163+
x_str, y_str = token.split(",")
164+
new_points.append((int(x_str), int(y_str)))
165+
except ValueError:
166+
continue
167+
if not new_points:
168+
return
169+
self._points.extend(new_points)
170+
if len(self._points) > _TRACE_MAX_POINTS:
171+
self._points = self._points[-_TRACE_MAX_POINTS:]
172+
self._svg_cache = None
173+
self._attr_image_last_updated = datetime.now(tz=UTC)
174+
self.async_write_ha_state()
175+
176+
self._subscribe(MapTraceEvent, on_trace)

homeassistant/components/ecovacs/strings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
"image": {
110110
"map": {
111111
"name": "Map"
112+
},
113+
"trace_map": {
114+
"name": "Trace map"
112115
}
113116
},
114117
"number": {

0 commit comments

Comments
 (0)