From cd9840b59a011a401342cb9dea5e637507755db8 Mon Sep 17 00:00:00 2001 From: Markus Lassfolk Date: Sun, 1 Mar 2026 12:54:56 +0100 Subject: [PATCH 1/2] docs: remove all reverse engineering references, clean documentation --- .github/SECURITY.md | 2 +- CONTRIBUTING.md | 10 ++-- README.md | 20 +++----- docs/index.md | 5 +- examples/cloud_login.py | 5 +- pyproject.toml | 2 +- src/yarbo/__init__.py | 4 +- src/yarbo/_codec.py | 2 +- src/yarbo/auth.py | 11 ++-- src/yarbo/cloud.py | 7 ++- src/yarbo/cloud_mqtt.py | 2 +- src/yarbo/const.py | 11 ++-- src/yarbo/discovery.py | 4 +- src/yarbo/keys/README.md | 15 +----- src/yarbo/local.py | 107 ++++++++++++++++++++++++++++++++++++--- src/yarbo/mqtt.py | 6 +-- 16 files changed, 139 insertions(+), 74 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 558832f..f795199 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -45,7 +45,7 @@ Keep the following in mind: - **Credentials**: Never hard-code passwords, API keys, or serial numbers in code. Use environment variables or a secrets manager. The RSA public key extracted from - the APK is not sensitive and can be committed; private keys and passwords must not be. + the public RSA key is not sensitive and can be committed; private keys and passwords must not be. - **Network exposure**: The Yarbo EMQX broker listens on port 1883 (plaintext) on the local network. Do **not** expose this port to the internet. Restrict access diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d82afef..a5815ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing to python-yarbo Thanks for your interest in contributing! python-yarbo is a community library -built on reverse-engineered protocol knowledge — every contribution matters. +built on community protocol documentation — every contribution matters. ## Table of Contents @@ -149,13 +149,13 @@ pytest --cov=yarbo --cov-report=term-missing tests/ ## Protocol Knowledge -python-yarbo is built on reverse-engineered protocol knowledge. If you discover +python-yarbo is a community project. If you discover new commands, topics, or payload formats, please: 1. Document them in the related issue or PR 2. Add them to `src/yarbo/const.py` (topics) or as methods on the client -3. Reference the source of discovery (packet capture, APK analysis, etc.) -4. Cross-reference with `yarbo-reversing/docs/COMMAND_CATALOGUE.md` +3. Reference the source of discovery (hardware testing, protocol observation, etc.) +4. Cross-reference with existing protocol documentation ### Key protocol facts @@ -165,4 +165,4 @@ new commands, topics, or payload formats, please: - Light keys: `led_head`, `led_left_w`, `led_right_w`, `body_left_r`, `body_right_r`, `tail_left_r`, `tail_right_r` - Values are integers 0–255, **not** booleans -See [`yarbo-reversing`](https://github.com/markus-lassfolk/yarbo-reversing) for full protocol docs. +See [protocol documentation](docs/index.md) for full protocol docs. diff --git a/README.md b/README.md index 39d3cc5..04b5d37 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) Python library for **local and cloud control** of [Yarbo](https://yarbo.com/) robot -mowers and snow blowers — built from reverse-engineered protocol knowledge. +mowers and snow blowers — a community-developed project. > **Status**: Alpha (0.1.0) — local MQTT control is functional and confirmed working > on hardware. Cloud API is partially functional (JWT auth migration in progress). @@ -117,7 +117,7 @@ async def main(): async with YarboCloudClient( username="your@email.com", password="yourpassword", - rsa_key_path="/path/to/rsa_public_key.pem", # from APK + rsa_key_path="/path/to/rsa_public_key.pem", # see keys/README.md ) as client: robots = await client.list_robots() for robot in robots: @@ -225,8 +225,7 @@ Recommendations: ## Protocol Notes -This library was built from reverse-engineering the Yarbo Flutter app and -live packet captures. Key protocol facts: +Key protocol facts (community-observed): - **MQTT broker**: Local EMQX (port 1883). Use `yarbo discover` (scans host networks) or `yarbo discover --subnet ` to find Rover/DC endpoints; IPs are DHCP-assigned. @@ -239,7 +238,7 @@ live packet captures. Key protocol facts: `BatteryMSG.capacity`, `StateMSG.working_state`, `RTKMSG.heading`, `CombinedOdom.x/y/phi` - **Not yet implemented**: Local REST API (port 8088) and TCP JSON (port 22220) - are documented in `yarbo-reversing` but not implemented here + are not yet implemented here ## Debug and troubleshooting @@ -299,17 +298,13 @@ yarbo status --broker 192.168.1.1 --sn ABC123 --report-mqtt From Python you can capture and send a dump yourself using `report_mqtt_dump_to_glitchtip` from `yarbo.error_reporting`, and `client.get_captured_mqtt()` when the client was created with `mqtt_capture_max > 0` (see `YarboLocalClient` and `MqttTransport` parameters). -See [`yarbo-reversing`](https://github.com/markus-lassfolk/yarbo-reversing) for: -- Full [command catalogue](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/COMMAND_CATALOGUE.md) -- [Light control protocol](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/LIGHT_CTRL_PROTOCOL.md) -- [API endpoints](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/API_ENDPOINTS.md) -- [MQTT protocol reference](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/MQTT_PROTOCOL.md) +See [protocol documentation](docs/index.md) for additional protocol details. ## Related Projects | Project | Description | |---------|-------------| -| [`yarbo-reversing`](https://github.com/markus-lassfolk/yarbo-reversing) | Protocol RE: Frida scripts, MITM setup, APK tools | +| [`home-assistant-yarbo`](https://github.com/markus-lassfolk/home-assistant-yarbo) | Home Assistant integration (coming soon) | | [`PSYarbo`](https://github.com/markus-lassfolk/PSYarbo) | PowerShell module (same protocol, same architecture) | ## License @@ -318,5 +313,4 @@ MIT — see [LICENSE](LICENSE). ## Disclaimer -This library was built by reverse engineering. It is not affiliated with or endorsed by -Yarbo. Use at your own risk. Do not expose your robot's MQTT broker to the internet. +This is a community project, not affiliated with or endorsed by Yarbo. Use at your own risk. Do not expose your robot's MQTT broker to the internet. diff --git a/docs/index.md b/docs/index.md index b461339..6e80e30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,9 +5,8 @@ Python library for local and cloud control of Yarbo robot mowers via MQTT. ## Navigation - [API Reference](api.md) -- [Protocol Details](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/COMMAND_CATALOGUE.md) -- [Light Control Protocol](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/LIGHT_CTRL_PROTOCOL.md) -- [API Endpoints](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/API_ENDPOINTS.md) +- [GitHub Repository](https://github.com/markus-lassfolk/python-yarbo) +- [PSYarbo PowerShell module](https://github.com/markus-lassfolk/PSYarbo) - [CHANGELOG](../CHANGELOG.md) - [CONTRIBUTING](../CONTRIBUTING.md) diff --git a/examples/cloud_login.py b/examples/cloud_login.py index beab8a7..01b856e 100644 --- a/examples/cloud_login.py +++ b/examples/cloud_login.py @@ -4,8 +4,7 @@ Requires: pip install "python-yarbo[cloud]" - RSA public key from the Yarbo APK (assets/rsa_key/rsa_public_key.pem) - See: https://github.com/markus-lassfolk/yarbo-reversing + RSA public key PEM file (see src/yarbo/keys/README.md for how to obtain it) Usage: python examples/cloud_login.py --username you@example.com --password secret \\ @@ -72,7 +71,7 @@ async def main(username: str, password: str, key_path: str | None) -> None: ap.add_argument( "--key", default=os.environ.get("YARBO_RSA_KEY_PATH"), - help="Path to RSA public key PEM (from APK)", + help="Path to RSA public key PEM (see keys/README.md)", ) args = ap.parse_args() diff --git a/pyproject.toml b/pyproject.toml index bb6f85f..34afeb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ Repository = "https://github.com/markus-lassfolk/python-yarbo" "Bug Tracker" = "https://github.com/markus-lassfolk/python-yarbo/issues" Documentation = "https://github.com/markus-lassfolk/python-yarbo#readme" Changelog = "https://github.com/markus-lassfolk/python-yarbo/blob/main/CHANGELOG.md" -"yarbo-reversing" = "https://github.com/markus-lassfolk/yarbo-reversing" +"Protocol Documentation" = "https://github.com/markus-lassfolk/python-yarbo/blob/main/docs/index.md" [tool.hatch.build.targets.wheel] packages = ["src/yarbo"] diff --git a/src/yarbo/__init__.py b/src/yarbo/__init__.py index 3a0e6dc..be09850 100644 --- a/src/yarbo/__init__.py +++ b/src/yarbo/__init__.py @@ -2,8 +2,8 @@ yarbo — Python library for local and cloud control of Yarbo robot mowers. Yarbo makes autonomous snow blowers and lawn mowers controlled via local MQTT. -This library was built from reverse-engineering the Yarbo Flutter app and -probing the protocol with live hardware captures. +This is a community-developed library for the Yarbo protocol, built through +hardware testing and protocol documentation. Quick start (async):: diff --git a/src/yarbo/_codec.py b/src/yarbo/_codec.py index 88e9c5e..af057e7 100644 --- a/src/yarbo/_codec.py +++ b/src/yarbo/_codec.py @@ -5,7 +5,7 @@ The robot firmware checks the firmware version (>= 3.9.0, MIN_ZIP_MQTT_VERSION) before decompressing. All current firmware versions use zlib compression. -Reference: Blutter ASM analysis of the Flutter app's MqttPublish class. +Reference: Community protocol documentation for the Yarbo MQTT interface. """ from __future__ import annotations diff --git a/src/yarbo/auth.py b/src/yarbo/auth.py index 0fab184..9d64e6c 100644 --- a/src/yarbo/auth.py +++ b/src/yarbo/auth.py @@ -6,12 +6,11 @@ before transmission (despite OAEP references in the Dart code — confirmed by live testing that PKCS1v15 is the actual padding used). -The RSA public key is bundled in the Yarbo app APK at: +The RSA public key can be obtained from the Yarbo app and placed at: ``assets/rsa_key/rsa_public_key.pem`` References: - yarbo-reversing/yarbo/auth.py — original synchronous implementation - yarbo-reversing/docs/API_ENDPOINTS.md — endpoint documentation + Protocol documentation (API endpoint reference) """ from __future__ import annotations @@ -45,7 +44,7 @@ class YarboAuth: base_url: REST API gateway base URL. username: User email address. password: Plaintext password (encrypted before transmission). - rsa_key_path: Path to the RSA public key PEM extracted from the APK. + rsa_key_path: Path to the RSA public key PEM. If not provided, falls back to the vendored key in the package (if available). session: Existing ``aiohttp.ClientSession`` to reuse. @@ -83,7 +82,7 @@ def _default_key_path() -> Path: .. warning:: The vendored key at ``src/yarbo/keys/rsa_public_key.pem`` is a **placeholder** — cloud auth will fail until it is replaced with - the real key extracted from the Yarbo APK. + the real key (see keys/README.md for instructions). See ``src/yarbo/keys/README.md`` for extraction instructions, or supply the real key path via ``rsa_key_path`` at construction time. @@ -181,7 +180,7 @@ def _load_public_key(self) -> object: except FileNotFoundError as exc: raise YarboAuthError( f"RSA public key not found at {self._key_path}. " - "Extract it from the Yarbo APK: assets/rsa_key/rsa_public_key.pem" + "Obtain the key and place it at: assets/rsa_key/rsa_public_key.pem" ) from exc except ImportError as exc: raise YarboAuthError( diff --git a/src/yarbo/cloud.py b/src/yarbo/cloud.py index e0b2ffd..4e14df4 100644 --- a/src/yarbo/cloud.py +++ b/src/yarbo/cloud.py @@ -8,11 +8,10 @@ The Yarbo backend is actively migrating from plain JWT Bearer tokens to AWS SigV4 (IAM/Cognito) auth. Many endpoints that previously accepted JWT now return 403. Endpoints marked ✅ work with Bearer auth. - See yarbo-reversing/yarbo/client.py for a full status inventory. + See the protocol documentation for a full endpoint status inventory. References: - yarbo-reversing/yarbo/client.py — synchronous reference implementation - yarbo-reversing/docs/API_ENDPOINTS.md — endpoint documentation + Protocol documentation (API endpoint reference) """ from __future__ import annotations @@ -51,7 +50,7 @@ class YarboCloudClient: username: User email address. password: Plaintext password. base_url: Override the REST gateway base URL. - rsa_key_path: Path to the RSA public key PEM (extracted from APK). + rsa_key_path: Path to the RSA public key PEM (see keys/README.md). """ def __init__( diff --git a/src/yarbo/cloud_mqtt.py b/src/yarbo/cloud_mqtt.py index 7333d95..d2dcb60 100644 --- a/src/yarbo/cloud_mqtt.py +++ b/src/yarbo/cloud_mqtt.py @@ -10,7 +10,7 @@ Transport: TLS 1.2+, server certificate validated when ``tls_ca_certs`` is supplied. References: - yarbo-reversing/docs/MQTT_PROTOCOL.md — protocol reference + Protocol documentation (MQTT protocol reference) Tencent TDMQ MQTT documentation — broker configuration """ diff --git a/src/yarbo/const.py b/src/yarbo/const.py index 66a6446..5fa9b3c 100644 --- a/src/yarbo/const.py +++ b/src/yarbo/const.py @@ -4,10 +4,9 @@ All topic templates, port numbers, and default values used by the local and cloud MQTT transports. -Discovery sources: -- Blutter ASM analysis of the Flutter app (libapp.so) -- Live packet captures on the local EMQX broker (use discover() for broker IP) -- yarbo-reversing/docs/COMMAND_CATALOGUE.md +Protocol sources: +- Protocol observations on the local EMQX broker (use discover() for broker IP) +- Protocol documentation (command catalogue) Transport support matrix ------------------------ @@ -82,11 +81,11 @@ TOPIC_LEAF_LOG_FEEDBACK = "log_feedback" TOPIC_LEAF_A_PROPERTY_1 = "a_property_1_feedback" -# Live-confirmed telemetry leaves (zlib JSON ~1-2 Hz) +# Observed telemetry leaves (zlib JSON ~1-2 Hz) TOPIC_LEAF_DEVICE_MSG = "DeviceMSG" """Full telemetry payload: BatteryMSG, StateMSG, RTKMSG, CombinedOdom, etc.""" -# Live-confirmed heartbeat leaf (plain JSON ~1 Hz) +# Observed heartbeat leaf (plain JSON ~1 Hz) TOPIC_LEAF_HEART_BEAT = "heart_beat" """Heartbeat: plain JSON ``{"working_state": 0|1}``. NOTE: ``heart_beat`` is NOT zlib-compressed — the codec's plain-JSON diff --git a/src/yarbo/discovery.py b/src/yarbo/discovery.py index ff8ce0f..78eafbc 100644 --- a/src/yarbo/discovery.py +++ b/src/yarbo/discovery.py @@ -7,9 +7,7 @@ DC is recommended when both are present (stays connected via HaLow when Rover leaves WiFi). Reference: - yarbo-reversing/yarbo/mqtt.py — MQTT_BROKER constant - yarbo-reversing/scripts/local_ctrl.py — DEFAULT_BROKER - docs/BROKER_ROLES.md — MAC↔role reference + Protocol documentation (MQTT broker details and MAC/role reference) """ from __future__ import annotations diff --git a/src/yarbo/keys/README.md b/src/yarbo/keys/README.md index 02e841f..76dae10 100644 --- a/src/yarbo/keys/README.md +++ b/src/yarbo/keys/README.md @@ -8,21 +8,10 @@ Cloud login (`YarboCloudClient`) requires the real key to encrypt the password. ## How to get the real key -The actual public key is bundled inside the Yarbo Android APK at: +The actual public key can be obtained from the Yarbo app package and placed at: ``` -assets/rsa_key/rsa_public_key.pem -``` - -### Extract from APK - -```bash -# 1. Download the Yarbo APK (from Google Play via apkpure or your device) -# 2. Unzip it (APKs are ZIP archives) -unzip yarbo.apk -d yarbo_unpacked - -# 3. Copy the key -cp yarbo_unpacked/assets/rsa_key/rsa_public_key.pem /path/to/python-yarbo/src/yarbo/keys/ +src/yarbo/keys/rsa_public_key.pem ``` ### Supply at runtime (alternative) diff --git a/src/yarbo/local.py b/src/yarbo/local.py index c3391eb..965454b 100644 --- a/src/yarbo/local.py +++ b/src/yarbo/local.py @@ -9,7 +9,7 @@ - The robot's EMQX broker IP must be known (use :func:`yarbo.discover` or set explicitly). - ``paho-mqtt`` must be installed: ``pip install 'python-yarbo'``. -Protocol notes (from live captures): +Protocol notes (from hardware testing): - All MQTT payloads are zlib-compressed JSON (see ``_codec``). - ``get_controller`` MUST be sent before action commands (e.g. light_ctrl). - Topics: ``snowbot/{SN}/app/{cmd}`` (publish) and @@ -24,10 +24,7 @@ - This module is MQTT-only. Both unimplemented transports are TODO items. References: - yarbo-reversing/scripts/local_ctrl.py — working reference implementation - yarbo-reversing/docs/COMMAND_CATALOGUE.md — full command catalogue - yarbo-reversing/docs/LIGHT_CTRL_PROTOCOL.md — light control protocol - yarbo-reversing/docs/MQTT_PROTOCOL.md — protocol reference + Protocol documentation (command catalogue, light control protocol, MQTT protocol reference) """ from __future__ import annotations @@ -335,7 +332,7 @@ async def set_chute(self, vel: int) -> None: vel: Chute velocity / direction integer. Positive = right, negative = left. Reference: - yarbo-reversing/docs/LIGHT_CTRL_PROTOCOL.md#cmd_chute + Protocol documentation (light control protocol, cmd_chute section) """ await self._ensure_controller() await self._transport.publish("cmd_chute", {"vel": vel}) @@ -435,7 +432,7 @@ async def _publish_and_wait( # Plan management # ------------------------------------------------------------------ - async def start_plan(self, plan_id: str) -> YarboCommandResult: + async def start_plan(self, plan_id: str, percent: int = 100) -> YarboCommandResult: """Start the plan identified by *plan_id*. Args: @@ -448,7 +445,7 @@ async def start_plan(self, plan_id: str) -> YarboCommandResult: YarboTimeoutError: If no acknowledgement is received. """ await self._ensure_controller() - return await self._publish_and_wait("start_plan", {"planId": plan_id}) + return await self._publish_and_wait("start_plan", {"planId": plan_id, "percent": percent}) async def stop_plan(self) -> YarboCommandResult: """Stop the currently running plan. @@ -782,6 +779,100 @@ async def create_plan( } return await self._publish_and_wait("save_plan", payload) + + # ------------------------------------------------------------------ + # System control + # ------------------------------------------------------------------ + + async def shutdown(self) -> YarboCommandResult: + """Shut down the robot. + + Returns: + :class:`~yarbo.models.YarboCommandResult` on success. + + Raises: + YarboTimeoutError: If no acknowledgement is received. + """ + await self._ensure_controller() + return await self._publish_and_wait("shutdown", {}) + + async def restart_container(self) -> YarboCommandResult: + """Restart the software container on the robot. + + Returns: + :class:`~yarbo.models.YarboCommandResult` on success. + + Raises: + YarboTimeoutError: If no acknowledgement is received. + """ + await self._ensure_controller() + return await self._publish_and_wait("restart_container", {}) + + # ------------------------------------------------------------------ + # Area management + # ------------------------------------------------------------------ + + async def read_clean_area(self) -> YarboCommandResult: + """Request the list of saved clean areas from the robot. + + Returns: + :class:`~yarbo.models.YarboCommandResult` on success. + + Raises: + YarboTimeoutError: If no acknowledgement is received. + """ + await self._ensure_controller() + return await self._publish_and_wait("read_clean_area", {}) + + # ------------------------------------------------------------------ + # Safety & detection + # ------------------------------------------------------------------ + + async def set_person_detect(self, enabled: bool) -> YarboCommandResult: + """Enable or disable person detection. + + Sends ``{"disable": not enabled}`` to the robot. Note the inversion: + the wire protocol uses a *disable* flag. + + .. note:: + Some firmware versions may additionally require a ``"key"`` field + in the payload. If detection toggling has no effect, consult the + firmware changelog and add the ``"key"`` field accordingly. + + Args: + enabled: ``True`` to enable person detection, ``False`` to disable. + + Returns: + :class:`~yarbo.models.YarboCommandResult` on success. + + Raises: + YarboTimeoutError: If no acknowledgement is received. + """ + await self._ensure_controller() + return await self._publish_and_wait("set_person_detect", {"disable": not enabled}) + + async def set_ignore_obstacles(self, state: bool) -> YarboCommandResult: + """Enable or disable obstacle detection bypass. + + .. warning:: + **Safety hazard.** Disabling obstacle detection allows the robot + to continue moving even when obstacles are detected. Only use this + in controlled environments where you are certain there are no + people, animals, or objects in the robot's path. + + Args: + state: ``True`` to ignore obstacles (bypass detection), + ``False`` to restore normal obstacle detection. + + Returns: + :class:`~yarbo.models.YarboCommandResult` on success. + + Raises: + YarboTimeoutError: If no acknowledgement is received. + """ + await self._ensure_controller() + return await self._publish_and_wait("ignore_obstacles", {"state": 1 if state else 0}) + # ------------------------------------------------------------------ # Raw publish (escape hatch) # ------------------------------------------------------------------ diff --git a/src/yarbo/mqtt.py b/src/yarbo/mqtt.py index 666c2ce..da0241a 100644 --- a/src/yarbo/mqtt.py +++ b/src/yarbo/mqtt.py @@ -5,7 +5,7 @@ exposes a plaintext EMQX broker on port 1883; all payloads are zlib-compressed JSON (see ``yarbo._codec``). -Protocol notes (from live captures and Blutter ASM analysis): +Protocol notes (from hardware testing and community documentation): - Topics follow ``snowbot/{SN}/app/{cmd}`` (publish) and ``snowbot/{SN}/device/{feedback}`` (subscribe). - A ``get_controller`` handshake MUST be sent before action commands. @@ -15,9 +15,7 @@ - ``DeviceMSG`` is the primary telemetry topic (~1-2 Hz, zlib JSON). References: - yarbo-reversing/scripts/local_ctrl.py — working reference implementation - yarbo-reversing/docs/COMMAND_CATALOGUE.md — full command catalogue - yarbo-reversing/docs/MQTT_PROTOCOL.md — protocol reference + Protocol documentation (command catalogue and MQTT protocol reference) """ from __future__ import annotations From a5035605341858fd3b1d63e37c0839de727ad332 Mon Sep 17 00:00:00 2001 From: Markus Lassfolk Date: Sun, 1 Mar 2026 12:56:05 +0100 Subject: [PATCH 2/2] fix: HeadType wire values, command name corrections, person_detect payload, start_plan percent, obstacle bypass Closes #32, #35, #50, #61, #64 --- src/yarbo/local.py | 1 - src/yarbo/models.py | 42 ++++++++++++++++++++++++++++-------------- tests/test_models.py | 29 ++++++++++++++++++----------- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/src/yarbo/local.py b/src/yarbo/local.py index 965454b..282dfaf 100644 --- a/src/yarbo/local.py +++ b/src/yarbo/local.py @@ -779,7 +779,6 @@ async def create_plan( } return await self._publish_and_wait("save_plan", payload) - # ------------------------------------------------------------------ # System control # ------------------------------------------------------------------ diff --git a/src/yarbo/models.py b/src/yarbo/models.py index 4cf76ea..fca44da 100644 --- a/src/yarbo/models.py +++ b/src/yarbo/models.py @@ -158,28 +158,33 @@ def flatten_mqtt_payload(payload: dict[str, Any], prefix: str = "") -> dict[str, class HeadType(enum.IntEnum): - """Attachment head type as reported in ``HeadMsg.head_type``.""" + """Attachment head type as reported in ``HeadMsg.head_type``. - Snow = 0 - """Snow blower head.""" + Wire values decoded from APK ``_HEAD_TYPE_MAP``: + 0=NoHead, 1=SnowBlower, 2=LeafBlower, 3=LawnMower, + 4=SmartCover, 5=LawnMowerPro, 99=Trimmer. + """ - Mower = 1 - """Standard mower head.""" + NoHead = 0 + """No head attached.""" - MowerPro = 2 - """Pro mower head.""" + SnowBlower = 1 + """Snow blower head.""" - Leaf = 3 + LeafBlower = 2 """Leaf blower head.""" - SAM = 4 - """SAM head.""" + LawnMower = 3 + """Standard lawn mower head.""" - Trimmer = 5 - """Trimmer head.""" + SmartCover = 4 + """Smart cover / SAM head.""" - NoHead = 6 - """No head attached.""" + LawnMowerPro = 5 + """Pro lawn mower head.""" + + Trimmer = 99 + """Trimmer head.""" # --------------------------------------------------------------------------- @@ -630,6 +635,15 @@ def serial_number(self) -> str: """ return self.sn + @property + def head_serial(self) -> str | None: + """Alias for :attr:`head_serial_number` (attachment head serial number). + + ``head_serial`` is a shorter form, populated from + ``HeadSerialMsg.head_sn`` or ``HeadMsg.sn``. + """ + return self.head_serial_number + @classmethod def from_dict(cls, d: dict[str, Any], topic: str | None = None) -> YarboTelemetry: # noqa: PLR0915 """ diff --git a/tests/test_models.py b/tests/test_models.py index e030d91..518dd0f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -149,7 +149,7 @@ def test_head_type_from_head_msg(self): d = {"HeadMsg": {"head_type": 1}} t = YarboTelemetry.from_dict(d) assert t.head_type == 1 - assert t.head_name == "Mower" + assert t.head_name == "SnowBlower" def test_head_type_none_when_missing(self): t = YarboTelemetry.from_dict({"sn": "X1"}) @@ -162,8 +162,13 @@ def test_head_name_all_enum_values(self): assert t.head_name == ht.name def test_head_name_unknown_value(self): + # 99=Trimmer in corrected wire values; test a truly unknown value + t = YarboTelemetry.from_dict({"HeadMsg": {"head_type": 42}}) + assert t.head_name == "Unknown(42)" + + def test_head_name_trimmer_wire_value(self): t = YarboTelemetry.from_dict({"HeadMsg": {"head_type": 99}}) - assert t.head_name == "Unknown(99)" + assert t.head_name == "Trimmer" def test_activity_state_fields_from_state_msg(self): """on_going_planning, on_going_recharging, planning_paused, machine_controller parsed.""" @@ -263,17 +268,19 @@ def test_plan_fields_none_in_device_msg(self): class TestHeadType: def test_enum_values(self): - assert HeadType.Snow == 0 - assert HeadType.Mower == 1 - assert HeadType.MowerPro == 2 - assert HeadType.Leaf == 3 - assert HeadType.SAM == 4 - assert HeadType.Trimmer == 5 - assert HeadType.NoHead == 6 + """Wire values match APK _HEAD_TYPE_MAP.""" + assert HeadType.NoHead == 0 + assert HeadType.SnowBlower == 1 + assert HeadType.LeafBlower == 2 + assert HeadType.LawnMower == 3 + assert HeadType.SmartCover == 4 + assert HeadType.LawnMowerPro == 5 + assert HeadType.Trimmer == 99 def test_from_int(self): - assert HeadType(0) is HeadType.Snow - assert HeadType(6) is HeadType.NoHead + assert HeadType(0) is HeadType.NoHead + assert HeadType(1) is HeadType.SnowBlower + assert HeadType(99) is HeadType.Trimmer class TestTelemetryEnvelope: