python-yarbo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,306 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-yarbo
3
+ Version: 0.1.0
4
+ Summary: Python library for local and cloud control of Yarbo robot mowers via MQTT
5
+ Project-URL: Homepage, https://github.com/markus-lassfolk/python-yarbo
6
+ Project-URL: Repository, https://github.com/markus-lassfolk/python-yarbo
7
+ Project-URL: Bug Tracker, https://github.com/markus-lassfolk/python-yarbo/issues
8
+ Project-URL: Documentation, https://github.com/markus-lassfolk/python-yarbo#readme
9
+ Project-URL: Changelog, https://github.com/markus-lassfolk/python-yarbo/blob/main/CHANGELOG.md
10
+ Project-URL: yarbo-reversing, https://github.com/markus-lassfolk/yarbo-reversing
11
+ Author: Markus Lassfolk
12
+ License: MIT
13
+ License-File: LICENSE
14
+ Keywords: iot,mower,mqtt,robot,smart-home,snow-blower,yarbo
15
+ Classifier: Development Status :: 3 - Alpha
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Home Automation
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: aiohttp>=3.9
26
+ Requires-Dist: paho-mqtt>=2.0
27
+ Requires-Dist: sentry-sdk>=2.0
28
+ Provides-Extra: cloud
29
+ Requires-Dist: cryptography>=42.0; extra == 'cloud'
30
+ Provides-Extra: dev
31
+ Requires-Dist: bandit[toml]>=1.7; extra == 'dev'
32
+ Requires-Dist: cryptography>=42.0; extra == 'dev'
33
+ Requires-Dist: mypy>=1.10; extra == 'dev'
34
+ Requires-Dist: pip-audit>=2.7; extra == 'dev'
35
+ Requires-Dist: pre-commit>=3.7; extra == 'dev'
36
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
37
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
38
+ Requires-Dist: pytest>=8.0; extra == 'dev'
39
+ Requires-Dist: ruff>=0.4; extra == 'dev'
40
+ Requires-Dist: types-aiofiles; extra == 'dev'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # python-yarbo
44
+
45
+ [![CI](https://github.com/markus-lassfolk/python-yarbo/actions/workflows/ci.yml/badge.svg)](https://github.com/markus-lassfolk/python-yarbo/actions/workflows/ci.yml)
46
+ [![PyPI version](https://img.shields.io/pypi/v/python-yarbo.svg)](https://pypi.org/project/python-yarbo/)
47
+ [![Python versions](https://img.shields.io/pypi/pyversions/python-yarbo.svg)](https://pypi.org/project/python-yarbo/)
48
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
49
+
50
+ Python library for **local and cloud control** of [Yarbo](https://yarbo.com/) robot
51
+ mowers and snow blowers — built from reverse-engineered protocol knowledge.
52
+
53
+ > **Status**: Alpha (0.1.0) — local MQTT control is functional and confirmed working
54
+ > on hardware. Cloud API is partially functional (JWT auth migration in progress).
55
+
56
+ ## Features
57
+
58
+ - 🔌 **Local MQTT control** — no cloud account required
59
+ - 💡 **Full LED control** — 7 independent channels (head, fill, body, tail)
60
+ - 🔊 **Buzzer control**
61
+ - 🌨️ **Snow chute direction** (snow blower models)
62
+ - 📡 **Live telemetry stream** — battery, state, position, heading
63
+ - 🔍 **Auto-discovery** — finds Yarbo brokers on your local network
64
+ - ☁️ **Cloud API** — robot management, scheduling, notifications
65
+ - ⚡ **Async-first** — built on asyncio with sync wrappers for scripts
66
+ - 🏠 **Home Assistant ready** — see [`home-assistant-yarbo`](https://github.com/markus-lassfolk/home-assistant-yarbo) (coming soon)
67
+
68
+ ## Requirements
69
+
70
+ - Python ≥ 3.11
71
+ - Same WiFi network as the robot (for local control)
72
+ - `paho-mqtt` ≥ 2.0 (included)
73
+ - `aiohttp` ≥ 3.9 (included)
74
+
75
+ ## Installation
76
+
77
+ ```bash
78
+ pip install python-yarbo
79
+ ```
80
+
81
+ For cloud API features (RSA password encryption):
82
+
83
+ ```bash
84
+ pip install "python-yarbo[cloud]"
85
+ ```
86
+
87
+ ## Quick Start
88
+
89
+ ### Async (recommended)
90
+
91
+ ```python
92
+ import asyncio
93
+ from yarbo import YarboClient
94
+
95
+ async def main():
96
+ async with YarboClient(broker="192.168.1.24", sn="24400102L8HO5227") as client:
97
+ # Get a telemetry snapshot
98
+ status = await client.get_status()
99
+ if status:
100
+ print(f"Battery: {status.battery}% State: {status.state}")
101
+
102
+ # Light control
103
+ await client.lights_on()
104
+ await asyncio.sleep(2)
105
+ await client.lights_off()
106
+
107
+ # Buzzer
108
+ await client.buzzer(state=1)
109
+
110
+ # Live telemetry stream
111
+ async for telemetry in client.watch_telemetry():
112
+ print(f"Battery: {telemetry.battery}% Heading: {telemetry.heading}°")
113
+ if telemetry.battery and telemetry.battery < 20:
114
+ print("Low battery!")
115
+ break
116
+
117
+ asyncio.run(main())
118
+ ```
119
+
120
+ ### Sync (scripts / REPL)
121
+
122
+ ```python
123
+ from yarbo import YarboClient
124
+
125
+ client = YarboClient.connect_sync(broker="192.168.1.24", sn="24400102L8HO5227")
126
+ client.lights_on()
127
+ client.buzzer()
128
+ client.disconnect()
129
+ ```
130
+
131
+ ### Auto-discovery
132
+
133
+ ```python
134
+ import asyncio
135
+ from yarbo import discover_yarbo, YarboClient
136
+
137
+ async def main():
138
+ print("Scanning for Yarbo robots...")
139
+ robots = await discover_yarbo()
140
+
141
+ if not robots:
142
+ print("No robots found")
143
+ return
144
+
145
+ print(f"Found: {robots[0]}")
146
+ async with YarboClient(broker=robots[0].broker_host, sn=robots[0].sn) as client:
147
+ await client.lights_on()
148
+
149
+ asyncio.run(main())
150
+ ```
151
+
152
+ ### Cloud login (account management)
153
+
154
+ ```python
155
+ import asyncio
156
+ from yarbo import YarboCloudClient
157
+
158
+ async def main():
159
+ async with YarboCloudClient(
160
+ username="your@email.com",
161
+ password="yourpassword",
162
+ rsa_key_path="/path/to/rsa_public_key.pem", # from APK
163
+ ) as client:
164
+ robots = await client.list_robots()
165
+ for robot in robots:
166
+ print(f"{robot.sn}: {robot.name} (online: {robot.is_online})")
167
+
168
+ version = await client.get_latest_version()
169
+ print(f"App: {version['appVersion']} Firmware: {version['firmwareVersion']}")
170
+
171
+ asyncio.run(main())
172
+ ```
173
+
174
+ ## API Reference
175
+
176
+ ### `YarboClient` (hybrid)
177
+
178
+ | Method | Description |
179
+ |--------|-------------|
180
+ | `async with YarboClient(broker, sn)` | Connect via async context manager |
181
+ | `await client.get_status()` | Single telemetry snapshot → `YarboTelemetry` |
182
+ | `await client.watch_telemetry()` | Async generator of `YarboTelemetry` |
183
+ | `await client.lights_on()` | All LEDs → 255 |
184
+ | `await client.lights_off()` | All LEDs → 0 |
185
+ | `await client.set_lights(YarboLightState)` | Per-channel LED control |
186
+ | `await client.buzzer(state=1)` | Buzzer on (1) or off (0) |
187
+ | `await client.set_chute(vel)` | Snow chute direction |
188
+ | `await client.get_controller()` | Acquire controller role (auto-called) |
189
+ | `await client.publish_raw(cmd, payload)` | Arbitrary MQTT command |
190
+ | `await client.list_robots()` | Cloud: bound robots |
191
+ | `YarboClient.connect_sync(broker, sn)` | Sync wrapper factory |
192
+
193
+ ### `YarboLocalClient` (MQTT-only)
194
+
195
+ Same interface as `YarboClient`, local only, no cloud features.
196
+
197
+ ### `YarboLightState`
198
+
199
+ ```python
200
+ from yarbo import YarboLightState
201
+
202
+ # All on
203
+ state = YarboLightState.all_on()
204
+
205
+ # Custom
206
+ state = YarboLightState(
207
+ led_head=255, # Front white
208
+ led_left_w=128, # Left fill white
209
+ led_right_w=128, # Right fill white
210
+ body_left_r=255, # Left body red
211
+ body_right_r=255, # Right body red
212
+ tail_left_r=0, # Left tail red
213
+ tail_right_r=0, # Right tail red
214
+ )
215
+ async with YarboClient(...) as client:
216
+ await client.set_lights(state)
217
+ ```
218
+
219
+ ### `YarboTelemetry`
220
+
221
+ Parsed from `DeviceMSG` nested schema (`BatteryMSG`, `StateMSG`, `RTKMSG`, `CombinedOdom`).
222
+
223
+ | Field | Type | Source | Description |
224
+ |-------|------|--------|-------------|
225
+ | `battery` | `int \| None` | `BatteryMSG.capacity` | State of charge (0–100 %) |
226
+ | `state` | `str \| None` | derived | `"idle"` or `"active"` |
227
+ | `working_state` | `int \| None` | `StateMSG.working_state` | Raw state (0=idle, 1=active) |
228
+ | `charging_status` | `int \| None` | `StateMSG.charging_status` | 2 = charging/docked |
229
+ | `error_code` | `int \| str \| None` | `StateMSG.error_code` | Active fault code |
230
+ | `heading` | `float \| None` | `RTKMSG.heading` | Compass heading (degrees) |
231
+ | `position_x` | `float \| None` | `CombinedOdom.x` | Odometry X (metres) |
232
+ | `position_y` | `float \| None` | `CombinedOdom.y` | Odometry Y (metres) |
233
+ | `phi` | `float \| None` | `CombinedOdom.phi` | Odometry heading (radians) |
234
+ | `speed` | `float \| None` | flat | Current speed (m/s) |
235
+ | `raw` | `dict` | — | Complete raw DeviceMSG dict |
236
+
237
+ ## Cloud vs Local
238
+
239
+ | Feature | Local MQTT | Cloud REST |
240
+ |---------|-----------|------------|
241
+ | Robot control (lights, buzzer, …) | ✅ Yes | ❌ No |
242
+ | Live telemetry | ✅ Yes | ❌ No |
243
+ | List bound robots | ❌ No | ✅ Yes |
244
+ | Account management | ❌ No | ✅ Yes |
245
+ | Robot rename / bind / unbind | ❌ No | ✅ Yes |
246
+ | Notifications | ❌ No | ✅ Yes |
247
+ | Works offline | ✅ Yes | ❌ No |
248
+ | Requires cloud account | ❌ No | ✅ Yes |
249
+
250
+ > **⚠️ Cloud MQTT not implemented.** The Yarbo backend also provides a Tencent
251
+ > TDMQ MQTT broker (`mqtt-b8rkj5da-usw-public.mqtt.tencenttdmq.com:8883`) for
252
+ > remote control without LAN access. This library does **not** implement cloud
253
+ > MQTT — there is no remote-control fallback. All robot commands go via the
254
+ > local broker only.
255
+
256
+ ## Security Notes
257
+
258
+ > ⚠️ **The Yarbo local MQTT broker accepts anonymous connections without
259
+ > authentication.** Anyone on your WiFi network can connect and send commands
260
+ > to your robot.
261
+
262
+ Recommendations:
263
+ - Keep the robot on a dedicated IoT VLAN and firewall it from the internet.
264
+ - Do **not** port-forward port 1883 to the internet.
265
+ - Consider a firewall rule that allows only your home automation host to reach
266
+ port 1883 on the robot's IP.
267
+
268
+ ## Protocol Notes
269
+
270
+ This library was built from reverse-engineering the Yarbo Flutter app and
271
+ live packet captures. Key protocol facts:
272
+
273
+ - **MQTT broker**: Local EMQX at `192.168.1.24:1883` or `192.168.1.55:1883`
274
+ (check which IP your robot uses — both have been observed in production)
275
+ - **Payload encoding**: `zlib.compress(json.dumps(payload).encode())`
276
+ (exception: `heart_beat` topic uses plain uncompressed JSON)
277
+ - **Controller handshake**: `get_controller` must be sent before action commands
278
+ - **Topics**: `snowbot/{SN}/app/{cmd}` (publish) and
279
+ `snowbot/{SN}/device/{feedback}` (subscribe)
280
+ - **Telemetry topic**: `DeviceMSG` (~1–2 Hz) with nested schema:
281
+ `BatteryMSG.capacity`, `StateMSG.working_state`, `RTKMSG.heading`,
282
+ `CombinedOdom.x/y/phi`
283
+ - **Not yet implemented**: Local REST API (port 8088) and TCP JSON (port 22220)
284
+ are documented in `yarbo-reversing` but not implemented here
285
+
286
+ See [`yarbo-reversing`](https://github.com/markus-lassfolk/yarbo-reversing) for:
287
+ - Full [command catalogue](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/COMMAND_CATALOGUE.md)
288
+ - [Light control protocol](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/LIGHT_CTRL_PROTOCOL.md)
289
+ - [API endpoints](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/API_ENDPOINTS.md)
290
+ - [MQTT protocol reference](https://github.com/markus-lassfolk/yarbo-reversing/blob/main/docs/MQTT_PROTOCOL.md)
291
+
292
+ ## Related Projects
293
+
294
+ | Project | Description |
295
+ |---------|-------------|
296
+ | [`yarbo-reversing`](https://github.com/markus-lassfolk/yarbo-reversing) | Protocol RE: Frida scripts, MITM setup, APK tools |
297
+ | [`PSYarbo`](https://github.com/markus-lassfolk/PSYarbo) | PowerShell module (same protocol, same architecture) |
298
+
299
+ ## License
300
+
301
+ MIT — see [LICENSE](LICENSE).
302
+
303
+ ## Disclaimer
304
+
305
+ This library was built by reverse engineering. It is not affiliated with or endorsed by
306
+ Yarbo. Use at your own risk. Do not expose your robot's MQTT broker to the internet.
@@ -0,0 +1,18 @@
1
+ yarbo/__init__.py,sha256=Hzz1uzSNFF_M7kGI7RVwgu3xASJr2dJ_LCzgy8elk7Y,3047
2
+ yarbo/_codec.py,sha256=iyfn34p-LeHWJrNaKHVX6rAMyRrYG43W8ip03Madnu0,1944
3
+ yarbo/auth.py,sha256=t6nP_DrKCWGNENimPR2VbK3X0Ifr4wpO8qdJJRJUE70,9413
4
+ yarbo/client.py,sha256=o2lV9P5NAVxXXw7Xd5zx5g3SUG2yS5mkQvnBlSYLhOc,27539
5
+ yarbo/cloud.py,sha256=DBwC7gm2nA0G0o1iPv2xWJV3vm68rHvkz5qUVG04r_8,10032
6
+ yarbo/cloud_mqtt.py,sha256=_8fiB9mA51fdaPltlHSmi8G52abvzV90WFB7R76mpN0,4260
7
+ yarbo/const.py,sha256=qf9SRZ8kWmZVoyvc4eargSqcu1lVYyJiyyNByYbuxd8,6896
8
+ yarbo/discovery.py,sha256=pMy1hIkC_GyyUTd-KFXbHEVtkRRu-awCog5aDDbS6VE,7368
9
+ yarbo/error_reporting.py,sha256=9GpLnPD0eDG5q9Ifitg2c0eqWLBisf1utSOJi9E0_VE,3195
10
+ yarbo/exceptions.py,sha256=UQ5dg48YTx4DlVGnDepP-FA9Jxt4BkV3v4Wh_PmBbas,3443
11
+ yarbo/local.py,sha256=Wp-M95hXVsh5GFgDkgR-Cw1bC2kzbGBAPxImEJcJP4A,59737
12
+ yarbo/models.py,sha256=PTuNj19OX7mZ-xzxEHzVrYGMc5n3dskUDIpYzhRKzL4,26033
13
+ yarbo/mqtt.py,sha256=mMlqpRV-lXN3_p28UtR0b4m46u4BX961QS5PvW5hwpg,20063
14
+ yarbo/keys/README.md,sha256=2vDNU2O66QNO5FwLIw6vfeWJrRg5DeyIA95gmmpdNmU,1150
15
+ python_yarbo-0.1.0.dist-info/METADATA,sha256=mQz2W1zkrB1RHblYTMUoQVrthsC3swnIWcn9GR7vnh8,11560
16
+ python_yarbo-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
17
+ python_yarbo-0.1.0.dist-info/licenses/LICENSE,sha256=hnpk3bWclcT3ci3znvjdHFo112rtbsgEUwccm75AqT8,1072
18
+ python_yarbo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Markus Lassfolk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
yarbo/__init__.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ yarbo — Python library for local and cloud control of Yarbo robot mowers.
3
+
4
+ Yarbo makes autonomous snow blowers and lawn mowers controlled via local MQTT.
5
+ This library was built from reverse-engineering the Yarbo Flutter app and
6
+ probing the protocol with live hardware captures.
7
+
8
+ Quick start (async)::
9
+
10
+ import asyncio
11
+ from yarbo import YarboClient
12
+
13
+ async def main():
14
+ async with YarboClient(broker="192.168.1.24", sn="24400102L8HO5227") as client:
15
+ status = await client.get_status()
16
+ print(f"Battery: {status.battery}%")
17
+ await client.lights_on()
18
+ await client.buzzer()
19
+
20
+ asyncio.run(main())
21
+
22
+ Quick start (sync)::
23
+
24
+ from yarbo import YarboClient
25
+
26
+ client = YarboClient.connect(broker="192.168.1.24", sn="24400102L8HO5227")
27
+ client.lights_on()
28
+ client.disconnect()
29
+
30
+ Auto-discovery::
31
+
32
+ import asyncio
33
+ from yarbo import discover_yarbo, YarboClient
34
+
35
+ async def main():
36
+ robots = await discover_yarbo()
37
+ if robots:
38
+ async with YarboClient(broker=robots[0].broker_host, sn=robots[0].sn) as client:
39
+ await client.lights_on()
40
+
41
+ asyncio.run(main())
42
+
43
+ See README.md for full documentation.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ __version__ = "0.1.0"
49
+ __author__ = "Markus Lassfolk"
50
+ __license__ = "MIT"
51
+
52
+ from ._codec import decode, encode
53
+ from .client import YarboClient
54
+ from .cloud import YarboCloudClient
55
+ from .cloud_mqtt import YarboCloudMqttClient
56
+ from .const import Topic
57
+ from .discovery import DiscoveredRobot, discover_yarbo
58
+ from .error_reporting import init_error_reporting
59
+ from .exceptions import (
60
+ YarboAuthError,
61
+ YarboCommandError,
62
+ YarboConnectionError,
63
+ YarboError,
64
+ YarboNotControllerError,
65
+ YarboProtocolError,
66
+ YarboTimeoutError,
67
+ YarboTokenExpiredError,
68
+ )
69
+ from .local import YarboLocalClient
70
+ from .models import (
71
+ HeadType,
72
+ TelemetryEnvelope,
73
+ YarboCommandResult,
74
+ YarboLightState,
75
+ YarboPlan,
76
+ YarboPlanParams,
77
+ YarboRobot,
78
+ YarboSchedule,
79
+ YarboTelemetry,
80
+ )
81
+
82
+ __all__ = [ # noqa: RUF022 — grouped by category, alphabetical within each
83
+ # Version
84
+ "__version__",
85
+ # Codec helpers
86
+ "decode",
87
+ "encode",
88
+ # Error reporting
89
+ "init_error_reporting",
90
+ # Discovery
91
+ "DiscoveredRobot",
92
+ "discover_yarbo",
93
+ # Topic helper
94
+ "Topic",
95
+ # Models (alphabetical)
96
+ "HeadType",
97
+ "TelemetryEnvelope",
98
+ "YarboCommandResult",
99
+ "YarboLightState",
100
+ "YarboPlan",
101
+ "YarboPlanParams",
102
+ "YarboRobot",
103
+ "YarboSchedule",
104
+ "YarboTelemetry",
105
+ # Clients (alphabetical)
106
+ "YarboClient",
107
+ "YarboCloudClient",
108
+ "YarboCloudMqttClient",
109
+ "YarboLocalClient",
110
+ # Exceptions (alphabetical)
111
+ "YarboAuthError",
112
+ "YarboCommandError",
113
+ "YarboConnectionError",
114
+ "YarboError",
115
+ "YarboNotControllerError",
116
+ "YarboProtocolError",
117
+ "YarboTimeoutError",
118
+ "YarboTokenExpiredError",
119
+ ]
120
+
121
+ # Opt-out error reporting: enabled by default, disable via YARBO_SENTRY_DSN=""
122
+ init_error_reporting()
yarbo/_codec.py ADDED
@@ -0,0 +1,63 @@
1
+ """
2
+ yarbo._codec — zlib encode/decode helpers for MQTT payloads.
3
+
4
+ All MQTT payloads in the Yarbo protocol are zlib-compressed JSON.
5
+ The robot firmware checks the firmware version (>= 3.9.0, MIN_ZIP_MQTT_VERSION)
6
+ before decompressing. All current firmware versions use zlib compression.
7
+
8
+ Reference: Blutter ASM analysis of the Flutter app's MqttPublish class.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any, cast
15
+ import zlib
16
+
17
+
18
+ def encode(payload: dict[str, Any]) -> bytes:
19
+ """
20
+ Encode a Python dict to a zlib-compressed JSON byte string.
21
+
22
+ This is the wire format for ALL MQTT publishes to the Yarbo broker.
23
+
24
+ Args:
25
+ payload: Dict to encode (must be JSON-serialisable).
26
+
27
+ Returns:
28
+ Compressed bytes ready to publish.
29
+
30
+ Example::
31
+
32
+ from yarbo._codec import encode, decode
33
+ raw = encode({"led_head": 255, "led_left_w": 255})
34
+ assert decode(raw) == {"led_head": 255, "led_left_w": 255}
35
+ """
36
+ return zlib.compress(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
37
+
38
+
39
+ def decode(data: bytes) -> dict[str, Any]:
40
+ """
41
+ Decode a zlib-compressed JSON byte string to a Python dict.
42
+
43
+ Falls back to plain JSON if decompression fails.
44
+
45
+ .. note:: ``heart_beat`` exception
46
+ The ``heart_beat`` topic delivers plain (uncompressed) JSON, e.g.
47
+ ``{"working_state": 0}``. The zlib fallback path handles this
48
+ transparently — no special case is needed in callers.
49
+
50
+ Args:
51
+ data: Raw bytes received from the MQTT broker.
52
+
53
+ Returns:
54
+ Decoded dict. Returns ``{"_raw": data.hex()}`` on total failure.
55
+ """
56
+ try:
57
+ return cast("dict[str, Any]", json.loads(zlib.decompress(data)))
58
+ except (zlib.error, json.JSONDecodeError):
59
+ pass
60
+ try:
61
+ return cast("dict[str, Any]", json.loads(data))
62
+ except (json.JSONDecodeError, UnicodeDecodeError):
63
+ return {"_raw": data[:512].hex()}