Skip to content

Commit 485e8d4

Browse files
committed
feat(commands): add getRTK command and RtkEvent for mowers
Parses the cloud response of getRTK so consumers can read RTK status (rover/base satellite counts, signal scores, occlusion rates, base station SN/firmware) instead of only seeing it in the official Ecovacs Home app. Wired into the GOAT A1600 RTK (xmp9ds) capability tree as an optional CapabilityEvent on Capabilities.rtk; other devices stay unaffected (rtk defaults to None). Sample payload comes from a real GOAT A1600 RTK (firmware 1.15.13); tests cover the full payload and the no-base-station edge case.
1 parent f87b6c9 commit 485e8d4

7 files changed

Lines changed: 296 additions & 0 deletions

File tree

deebot_client/capabilities.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
PositionsEvent,
4242
ReportStatsEvent,
4343
RoomsEvent,
44+
RtkEvent,
4445
SafeProtectEvent,
4546
StateEvent,
4647
StationEvent,
@@ -276,6 +277,7 @@ class Capabilities(ABC):
276277
map: CapabilityMap | None = None
277278
network: CapabilityEvent[NetworkInfoEvent]
278279
play_sound: CapabilityExecute[[]]
280+
rtk: CapabilityEvent[RtkEvent] | None = None
279281
settings: CapabilitySettings
280282
state: CapabilityEvent[StateEvent]
281283
station: CapabilityStation | None = None

deebot_client/commands/json/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from .play_sound import PlaySound
4545
from .pos import GetPos
4646
from .relocation import SetRelocationState
47+
from .rtk import GetRtk
4748
from .safe_protect import GetSafeProtect, SetSafeProtect
4849
from .stats import GetStats, GetTotalStats
4950
from .sweep_mode import GetSweepMode, SetSweepMode
@@ -98,6 +99,7 @@
9899
"GetNetInfoLegacy",
99100
"GetOta",
100101
"GetPos",
102+
"GetRtk",
101103
"GetSafeProtect",
102104
"GetStats",
103105
"GetSweepMode",
@@ -226,6 +228,8 @@
226228

227229
GetPos,
228230

231+
GetRtk,
232+
229233
SetRelocationState,
230234

231235
GetSafeProtect,

deebot_client/commands/json/rtk.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""RTK status command (mowers only)."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any
6+
7+
from deebot_client.events.rtk import RtkBaseStation, RtkEvent
8+
from deebot_client.message import HandlingResult, MessageBodyDataDict
9+
10+
from .common import JsonCommandWithMessageHandling
11+
12+
if TYPE_CHECKING:
13+
from deebot_client.event_bus import EventBus
14+
15+
16+
def _coerce_base_station(raw: dict[str, Any]) -> RtkBaseStation:
17+
return RtkBaseStation(
18+
serial_number=str(raw.get("sn", "")),
19+
satellites_visible=int(raw.get("star", 0) or 0),
20+
firmware=raw.get("version"),
21+
state=raw.get("state"),
22+
mode=raw.get("mode"),
23+
)
24+
25+
26+
class GetRtk(JsonCommandWithMessageHandling, MessageBodyDataDict):
27+
r"""Get RTK status command for mower devices.
28+
29+
Sends ``getRTK`` to the device and notifies a :class:`RtkEvent` with
30+
the parsed response. The cloud payload is shaped like this (sample
31+
captured from a GOAT A1600 RTK on firmware 1.15.13)::
32+
33+
{
34+
"result": 0,
35+
"rtks": [
36+
{"sn": "908276", "star": 30, "state": 0, "mode": 0,
37+
"version": "...,QD302 1.3.8,...,QD302 1.3.1"}
38+
],
39+
"observations": {
40+
"solStat": 0, "poseType": 50,
41+
"roverId": "908336", "roverSvs": 35, "roverSolnSvs": 30,
42+
"roverSignalRate": 44, "roverSignalScore": 90,
43+
"roverOcclusionRate": 8,
44+
"baseStnId": "\\"1544\\"", "baseSolnSvs": 29,
45+
"baseSignalRate": 45, "baseSignalScore": 94,
46+
"baseOcclusionRate": 24
47+
}
48+
}
49+
"""
50+
51+
NAME = "getRTK"
52+
53+
@classmethod
54+
def _handle_body_data_dict(
55+
cls, event_bus: EventBus, data: dict[str, Any]
56+
) -> HandlingResult:
57+
observations = data.get("observations") or {}
58+
if not observations:
59+
return HandlingResult.analyse()
60+
61+
rtks_raw = data.get("rtks") or []
62+
base_stations = [
63+
_coerce_base_station(r) for r in rtks_raw if isinstance(r, dict)
64+
]
65+
66+
event_bus.notify(
67+
RtkEvent(
68+
rover_serial_number=str(observations.get("roverId", "")),
69+
rover_satellites_visible=int(observations.get("roverSvs", 0) or 0),
70+
rover_satellites_used=int(observations.get("roverSolnSvs", 0) or 0),
71+
base_satellites_used=int(observations.get("baseSolnSvs", 0) or 0),
72+
rover_signal_score=int(observations.get("roverSignalScore", 0) or 0),
73+
base_signal_score=int(observations.get("baseSignalScore", 0) or 0),
74+
rover_occlusion_rate=int(
75+
observations.get("roverOcclusionRate", 0) or 0
76+
),
77+
base_occlusion_rate=int(observations.get("baseOcclusionRate", 0) or 0),
78+
base_stations=base_stations,
79+
base_station_id=observations.get("baseStnId"),
80+
solution_status=observations.get("solStat"),
81+
pose_type=observations.get("poseType"),
82+
)
83+
)
84+
return HandlingResult.success()

deebot_client/events/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
PositionsEvent,
2929
)
3030
from .network import NetworkInfoEvent
31+
from .rtk import RtkBaseStation, RtkEvent
3132
from .station import StationEvent
3233
from .work_mode import WorkMode, WorkModeEvent
3334

@@ -58,6 +59,8 @@
5859
"NetworkInfoEvent",
5960
"Position",
6061
"PositionsEvent",
62+
"RtkBaseStation",
63+
"RtkEvent",
6164
"StationEvent",
6265
"SweepModeEvent",
6366
"WorkMode",

deebot_client/events/rtk.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""RTK status event for mowers (e.g. Ecovacs GOAT family)."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
7+
from .base import Event
8+
9+
10+
@dataclass(frozen=True, kw_only=True)
11+
class RtkBaseStation:
12+
"""RTK reference (base) station info as reported by the mower."""
13+
14+
serial_number: str
15+
"""Serial number of the base station, e.g. ``"908276"``."""
16+
17+
satellites_visible: int
18+
"""Number of satellites currently visible by the base station."""
19+
20+
firmware: str | None = None
21+
"""Firmware version string reported by the base station, if any."""
22+
23+
state: int | None = None
24+
"""Raw state code as reported by the device (meaning device-defined)."""
25+
26+
mode: int | None = None
27+
"""Raw mode code as reported by the device (meaning device-defined)."""
28+
29+
30+
@dataclass(frozen=True)
31+
class RtkEvent(Event):
32+
"""RTK status snapshot for a mower.
33+
34+
The Ecovacs GOAT family pairs a reference (base) station with a
35+
rover (the mower itself) and a Real-Time-Kinematic correction loop.
36+
The official Ecovacs Home app surfaces the relevant counters under
37+
"Settings → RTK" of the device. This event mirrors that screen.
38+
"""
39+
40+
rover_serial_number: str
41+
"""Serial number of the rover (mower), e.g. ``"908336"``."""
42+
43+
rover_satellites_visible: int
44+
"""Total number of satellites the rover currently sees."""
45+
46+
rover_satellites_used: int
47+
"""Satellites actually used in the rover's RTK solution."""
48+
49+
base_satellites_used: int
50+
"""Satellites actually used in the base station's RTK solution."""
51+
52+
rover_signal_score: int
53+
"""Quality score (0-100) of the rover's GNSS signal."""
54+
55+
base_signal_score: int
56+
"""Quality score (0-100) of the base station's GNSS signal."""
57+
58+
rover_occlusion_rate: int
59+
"""Estimated rover sky-occlusion rate (percent)."""
60+
61+
base_occlusion_rate: int
62+
"""Estimated base sky-occlusion rate (percent)."""
63+
64+
base_stations: list[RtkBaseStation] = field(default_factory=list)
65+
"""One entry per known reference station (usually exactly one)."""
66+
67+
base_station_id: str | None = None
68+
"""ID of the base station currently providing corrections, if any."""
69+
70+
solution_status: int | None = None
71+
"""Raw ``solStat`` from the device (meaning device-defined)."""
72+
73+
pose_type: int | None = None
74+
"""Raw ``poseType`` from the device (meaning device-defined)."""

deebot_client/hardware/xmp9ds.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from deebot_client.commands.json.life_span import GetLifeSpan, ResetLifeSpan
4141
from deebot_client.commands.json.network import GetNetInfo
4242
from deebot_client.commands.json.play_sound import PlaySound
43+
from deebot_client.commands.json.rtk import GetRtk
4344
from deebot_client.commands.json.stats import GetStats, GetTotalStats
4445
from deebot_client.commands.json.true_detect import GetTrueDetect, SetTrueDetect
4546
from deebot_client.commands.json.volume import GetVolume, SetVolume
@@ -59,6 +60,7 @@
5960
MoveUpWarningEvent,
6061
NetworkInfoEvent,
6162
ReportStatsEvent,
63+
RtkEvent,
6264
SafeProtectEvent,
6365
StateEvent,
6466
StatsEvent,
@@ -102,6 +104,7 @@ def get_device_info() -> StaticDeviceInfo:
102104
),
103105
network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]),
104106
play_sound=CapabilityExecute(PlaySound),
107+
rtk=CapabilityEvent(RtkEvent, [GetRtk()]),
105108
settings=CapabilitySettings(
106109
advanced_mode=CapabilitySetEnable(
107110
AdvancedModeEvent, [GetAdvancedMode()], SetAdvancedMode

tests/commands/json/test_rtk.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Tests for the GetRtk command."""
2+
3+
from __future__ import annotations
4+
5+
from deebot_client.commands.json import GetRtk
6+
from deebot_client.events import RtkBaseStation, RtkEvent
7+
from tests.commands.json import assert_command
8+
from tests.helpers import get_request_json, get_success_body
9+
10+
11+
async def test_GetRtk() -> None:
12+
"""Parse a real GOAT A1600 RTK getRTK response."""
13+
json, firmware_event = get_request_json(
14+
get_success_body(
15+
{
16+
"result": 0,
17+
"rtks": [
18+
{
19+
"x": -5332,
20+
"y": -518,
21+
"sn": "908276",
22+
"state": 0,
23+
"mode": 0,
24+
"star": 30,
25+
"version": (
26+
"630ZG-B23A5-1,QD302 1.3.8,630ZG-B23A5-1,QD302 1.3.1"
27+
),
28+
}
29+
],
30+
"observations": {
31+
"solStat": 0,
32+
"poseType": 50,
33+
"roverId": "908336",
34+
"roverSvs": 35,
35+
"roverSolnSvs": 30,
36+
"roverSignalRate": 44,
37+
"roverSignalScore": 90,
38+
"roverOcclusionRate": 8,
39+
"baseStnId": '"1544"',
40+
"baseSolnSvs": 29,
41+
"baseSignalRate": 45,
42+
"baseSignalScore": 94,
43+
"baseOcclusionRate": 24,
44+
},
45+
}
46+
)
47+
)
48+
await assert_command(
49+
GetRtk(),
50+
json,
51+
(
52+
firmware_event,
53+
RtkEvent(
54+
rover_serial_number="908336",
55+
rover_satellites_visible=35,
56+
rover_satellites_used=30,
57+
base_satellites_used=29,
58+
rover_signal_score=90,
59+
base_signal_score=94,
60+
rover_occlusion_rate=8,
61+
base_occlusion_rate=24,
62+
base_stations=[
63+
RtkBaseStation(
64+
serial_number="908276",
65+
satellites_visible=30,
66+
firmware=(
67+
"630ZG-B23A5-1,QD302 1.3.8,630ZG-B23A5-1,QD302 1.3.1"
68+
),
69+
state=0,
70+
mode=0,
71+
)
72+
],
73+
base_station_id='"1544"',
74+
solution_status=0,
75+
pose_type=50,
76+
),
77+
),
78+
)
79+
80+
81+
async def test_GetRtk_no_base_stations() -> None:
82+
"""A response with `rtks` empty still notifies the rover-side event."""
83+
json, firmware_event = get_request_json(
84+
get_success_body(
85+
{
86+
"result": 0,
87+
"rtks": [],
88+
"observations": {
89+
"solStat": 0,
90+
"poseType": 0,
91+
"roverId": "908336",
92+
"roverSvs": 12,
93+
"roverSolnSvs": 8,
94+
"roverSignalRate": 30,
95+
"roverSignalScore": 50,
96+
"roverOcclusionRate": 20,
97+
"baseStnId": "",
98+
"baseSolnSvs": 0,
99+
"baseSignalRate": 0,
100+
"baseSignalScore": 0,
101+
"baseOcclusionRate": 0,
102+
},
103+
}
104+
)
105+
)
106+
await assert_command(
107+
GetRtk(),
108+
json,
109+
(
110+
firmware_event,
111+
RtkEvent(
112+
rover_serial_number="908336",
113+
rover_satellites_visible=12,
114+
rover_satellites_used=8,
115+
base_satellites_used=0,
116+
rover_signal_score=50,
117+
base_signal_score=0,
118+
rover_occlusion_rate=20,
119+
base_occlusion_rate=0,
120+
base_stations=[],
121+
base_station_id="",
122+
solution_status=0,
123+
pose_type=0,
124+
),
125+
),
126+
)

0 commit comments

Comments
 (0)