Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3bc3f15
Add decode_fields, remove_field and encoders to the protobuf codec
Sep 20, 2026
5b7b30e
Decode MQTT map frames into VacuumMap
Sep 20, 2026
b46b5d7
Add suction, find robot and resume settings to the sharkiq_v1 MQTT ma…
Sep 20, 2026
65a35c9
Add settings, room and spot cleaning to VacuumClient
Sep 20, 2026
3db5dc8
Document map data, new actions and update the RV2610BFCA report
Sep 20, 2026
e93e8c2
Add to_dict and from_dict to VacuumMap so a persisted map can be stored
Sep 27, 2026
c439c19
Version 0.3.0.dev1 for the map, rooms and settings branch
Sep 27, 2026
ee5cb80
Build the REST SSL context when the client is constructed
Sep 27, 2026
0618bc5
Version 0.3.0.dev2
Sep 27, 2026
5a85de1
Read cleaned area from the grid and clean time from the event log
Sep 27, 2026
8a1ae04
Version 0.3.0.dev3
Sep 27, 2026
15c59c6
Treat the saved spot zone as a zone, not a room
Sep 27, 2026
f408f63
Version 0.3.0.dev4
Sep 27, 2026
3615f3f
Name the robot's codes, decode live errors and warnings, correct the …
Sep 27, 2026
49118fc
Version 0.3.0.dev5
Sep 27, 2026
c594750
Decode device info and motor speeds; add pause, edge clean, explore a…
Sep 27, 2026
3e716b1
Version 0.3.0.dev6
Sep 27, 2026
b532b43
Add compat: which features a model has, as the SharkClean app decides
Sep 27, 2026
5880ebb
Version 0.3.0.dev7
Sep 27, 2026
2ff89e7
Seven robot types that stand in for the model table
Sep 27, 2026
8fe82f7
Version 0.3.0.dev8
Sep 27, 2026
65b7db6
Decode event-log entries streamed mid-job
Sep 27, 2026
dd07c04
Version 0.3.0.dev9
Sep 27, 2026
98150b5
Update the RV2610BFCA compatibility report
Sep 27, 2026
1a44b13
Treat a REST timeout as unreachable
Sep 27, 2026
b2cf993
Version 0.3.0.dev10
Sep 27, 2026
792ddf6
Ask the robot for its saved map
Sep 27, 2026
b14e4b7
Version 0.3.0.dev11
Sep 27, 2026
bb035fe
Import aiomqtt once, at module level
Sep 27, 2026
27455aa
Version 0.3.0.dev12
Sep 27, 2026
816dd25
A job's empty opening grid is no map
Sep 27, 2026
d90d9b9
Version 0.3.0.dev13
Sep 27, 2026
76d335b
Harden local discovery on Windows
rellerton Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ sharklocal/
├── client.py # VacuumClient — unified entry point with transport selection
├── rest_client.py # RESTVacuumClient — async HTTPS/HTTP client (aiohttp)
├── mqtt_client.py # MQTTVacuumClient — async MQTT client (aiomqtt)
├── protobuf.py # Pure-Python schema-free protobuf decoder
├── models.py # VacuumStatus, VacuumEvent, DeviceInfo, VacuumMode
├── protobuf.py # Pure-Python schema-free protobuf codec
├── vacuum_map.py # Map / room / trajectory decoding and room-command encoding (MQTT)
├── models.py # VacuumStatus, VacuumMap, VacuumEvent, DeviceInfo, VacuumMode
├── exceptions.py # Typed exception hierarchy
└── mappings/
├── __init__.py # load_* / list_* utilities
Expand Down Expand Up @@ -155,6 +156,23 @@ All transport clients return normalized model objects. See [docs/data-models.md]

---

## Maps, Rooms and Settings (MQTT)

On models that publish map frames, monitoring yields the live map (occupancy grid, cleaned path, robot pose with heading) every few seconds during a job, and a persisted map (rooms, wall and door vectors, dock pose, job statistics, the robot's event log) each time the robot docks. The `sharkiq_v1` MQTT mapping also exposes suction level, find-robot and the Recharge & Resume / Evac & Resume settings, and `VacuumClient` can start room, Matrix and spot cleans.

```python
async with VacuumClient("192.168.1.100", mqtt_mappings="sharkiq_v1") as vacuum:
vacuum.on_status_update(lambda s: s.map and print(s.map.robot, len(s.map.path)))
await vacuum.start_monitoring()

await vacuum.set_suction(SuctionLevel.MAX)
await vacuum.clean_rooms(["Kitchen"], deep=True) # needs vacuum.last_map (persisted)
```

See [docs/vacuum-client.md](docs/vacuum-client.md#maps-rooms-and-settings) and the `VacuumMap` section of [docs/data-models.md](docs/data-models.md).

---

## Mapping Configuration

See [docs/mapping-configuration.md](docs/mapping-configuration.md) for annotated YAML examples for both transports, the full field reference, instructions for adding support for a new model, and how to register a custom MQTT decoder.
Expand Down Expand Up @@ -209,6 +227,10 @@ The minimum required coverage is **95%**. All PRs must pass before merging. See
- The `status_water_tank_removed` event type is fired for dustbin removal on vacuums, not only water tank removal on mops. Handle accordingly in Home Assistant event translation.
- The `/get/robot_id` endpoint does not expose a serial number. Use the `mac_address` from `/get/wifi_status` as the device `unique_id`.
- MQTT `go_home` and `stop` send identical payloads in the `sharkiq_v1` mapping — both issue the protobuf stop-and-return command.
- The SharkClean app bundles a suction change into its start and dock messages. The `sharkiq_v1` mapping sends the bare job commands, so `start_cleaning` and `go_home` leave the suction setting alone.
- The suction level is not reported in MQTT status; the robot echoes a change once (field 8) and is then silent about it. Remember the last level you set.
- Room and spot cleans re-upload the map's room definition, names included. Build them from the latest persisted map (`VacuumClient.last_map`) or a stale one renames rooms.
- On the RV2610BFCA, `BatteryInfo.ChargingState` is `3` in every frame, so `charging` is `True` mid-clean. Use `mode` to decide whether the robot is docked.
- The REST API uses a self-signed TLS certificate. SSL verification is disabled in the `sharkiq_v1` mapping (`verify_ssl: false`).
- The REST `charging` field returns `"connected"` or `"unconnected"` as strings, not a boolean. The library normalises this to `True`/`False` on `VacuumStatus.charging`.
- The REST `mode` field alone is insufficient to determine if a vacuum is docked. `mode: "ready"` with `charging: "connected"` means docked (`VacuumMode.DOCKED`); `mode: "ready"` with `charging: "unconnected"` means the vacuum is stopped but off the dock (`VacuumMode.IDLE`). This combined evaluation is handled automatically by the library.
Expand Down
4 changes: 3 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,6 @@ Send a specific command via a chosen transport (defaults to `mqtt`):
python -m sharklocal <IP_ADDRESS> --cmd dock --transport mqtt
```

Available commands: `start`, `stop`, `dock`, `status`, `events`, `info`.
Available commands: `start`, `stop`, `dock`, `status`, `events`, `info`, `find`, `suction-eco`, `suction-normal`, `suction-max`, `recharge-resume-on`, `recharge-resume-off`, `evac-resume-on`, `evac-resume-off`.

The settings and `find` commands exist only in the MQTT mapping. While `--monitor` is running, any status update that carries map data prints an extra `[MAP]` line with the grid size, the number of cleaned-path points, the robot's pose and the room names (persisted map only).
55 changes: 46 additions & 9 deletions docs/compatibility/rv2610bfca.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# RV2610BFCA — Compatibility Matrix
# RV2610BFCA (Shark Matrix Plus) — Compatibility Matrix

---

Expand All @@ -9,11 +9,23 @@
| Start cleaning | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Stop | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Return to dock | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Explore / Map | ❌ | ❌ | |
| Explore / Map | ❌ | 🟡 ³ | MQTT: `sharkiq_v1` |
| Get status | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Get event log | ❌ | ❌ | |
| Get event log | ❌ | ✅ ¹ | MQTT: `sharkiq_v1` |
| Get robot ID | ❌ | ❌ | |
| Get Wi-Fi status | ❌ | ❌ | |
| Find robot | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Set suction (eco / normal / max) | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Recharge & Resume on / off | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Evac & Resume on / off | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Clean rooms (incl. Matrix Clean) | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Spot clean | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Pause / resume | ❌ | 🟡 ³ | MQTT: `sharkiq_v1` |
| Do not disturb, volume | ❌ | 🟡 ³ | MQTT: `sharkiq_v1` |

> ¹ Via the event log embedded in the persisted map frame the robot publishes when it docks (`VacuumMap.log`), not via a request. Entries are also streamed during a job (`VacuumStatus.log_entries`).
>
> ³ Sent in the same format as the verified commands, but not yet run on this robot.

---

Expand All @@ -23,7 +35,23 @@
|-------|:----:|:----:|--------------------|
| Operating mode | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Battery level | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Charging status | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Charging status | ❌ | ✅ ² | MQTT: `sharkiq_v1` |
| Job active / deep clean | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Recharge & Resume / Evac & Resume | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Warnings active now | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Faults active now | ❌ | 🟡 ⁴ | MQTT: `sharkiq_v1` |
| Temperature, Wi-Fi state | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Suction / brushroll / side-brush motor speeds | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Live map (grid, cleaned path, robot pose with heading) | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Persisted map (rooms, room raster, walls, doors, dock, cleaned area, clean time, event log) | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Persisted map on request (`request_map`: the same, less the event log) | ❌ | ✅ | MQTT: `sharkiq_v1` |
| Firmware versions | ❌ | ✅ ⁵ | MQTT: `sharkiq_v1` |

> ² Derived from the robot's system state (charging on the dock). The device-info field once read as a charging state is the Wi-Fi state: it reads `3` in every frame, including mid-clean.
>
> ⁴ Decoded from the status frame; no fault occurred during testing, so not yet seen with a value.
>
> ⁵ From the event log (`DT_VERSION_L01`, `DT_VERSION_MCU`).

---

Expand All @@ -33,17 +61,26 @@
|------|:----:|:----:|--------------------|
| `cleaning` | ❌ | ✅ | MQTT: `sharkiq_v1` |
| `returning_to_dock` | ❌ | ✅ | MQTT: `sharkiq_v1` |
| `docking` | ❌ | ✅ | MQTT: `sharkiq_v1` |
| `docking` | ❌ | ❌ | |
| `docked` | ❌ | ✅ | MQTT: `sharkiq_v1` |
| `idle` | ❌ | ❌ | |
| `paused` | ❌ | ✅ | MQTT: `sharkiq_v1` |
| `error` | ❌ | ❌ | |
| `exploring` | ❌ | ❌ | |

---

## Known Issues / Notes

- **Robot type:** `lidar` in `sharklocal.compat` terms, with a self-emptying dock. The retail SKU is not in the SharkClean app's model table (the app keys it by the robot's cloud model string); the firmware line (`P7`) is the app's Three60 lidar platform.
- **REST API:** Port 443 is closed/refused. Port 80 is open, but `/get/status` and `/get/wifi_status` return `404 Not Found`, and `/` returns a forbidden HTML page.
- **MQTT:** Uses standard `sharkiq_v1` protobuf format. Status requests, passive monitoring, and basic commands work.
- **Observed MQTT status:** `mode=docked`, `battery_level=100`, `charging=true`.
- **Command test:** `start_cleaning` returned `True` and status changed to `cleaning` within about 2 seconds. `stop` returned `True` and status changed to `returning_to_dock` within about 2 seconds. `go_home` returned `True`; the robot reported `docked` about 28 seconds after the first return-to-dock command.
- **Command caveat:** In the current `sharkiq_v1` MQTT mapping, `stop` and `go_home` use the same payload, so both appear to initiate return-to-dock behavior on this model.
- **MQTT:** Uses standard `sharkiq_v1` protobuf format. Status requests, passive monitoring, and commands work.
- **Command test:** `start_cleaning` returned `True` and status changed to `cleaning` within about 2 seconds. `stop` returned `True` and status changed to `returning_to_dock` within about 2 seconds. `go_home` returned `True`; the robot reported `docked` about 28 seconds after the first return-to-dock command. Room clean, Matrix room clean and spot clean each cleaned the right area and returned to the dock on their own.
- **Lifecycle** (firmware `V6.6.10-P7.N3308.17.0`): battery full on the dock (`14`) → cleaning (`6`) → returning (`7`) → charging on the dock (`13`) → battery full (`14`). The persisted map frame is published as it reaches the dock. State `4` (paused) is reported when stopped off the dock or lifted.
- **Suction:** accepted while docked (the app only greys the control out). Echoed once on change; no status field.
- **Battery:** counts down through a job and snaps back to 100 within seconds of docking.
- **Maps:** a ~27 KB live frame every ~4 s during a job (0.06 m grid, cleaned path, pose log with heading); a ~92 KB persisted frame on docking with rooms, room-id raster, wall/door polylines, dock pose, job start time and the event log. Cleaned floor is marked `0x00` in the grid (every point of the cleaned path lands on one), and the log's `DT_*_CLEAN_TIME` entries give the time spent cleaning. The persisted frame's field 11 also carries two counters that look like a duration and a cell count, but they barely move between different jobs, so they are not decoded.
- **Spot clean:** the robot keeps the spot square in its saved map as a room named `PinDrop`, marked as the room the last job cleaned. `VacuumMap.named_rooms` leaves it out.
- **Warnings:** a dark room reports `WARN_LOW_LIGHT` live and logs `WARN_MM_LOWLIGHT` for the job.
- **Room commands re-upload the room definition**, names included. Build them from the latest persisted map.
- **Single map only:** every map seen carried one floor; the app's rules give multi-floor maps only to newer lines.
140 changes: 140 additions & 0 deletions docs/data-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class VacuumMode(str, Enum):
DOCKED = "docked"
IDLE = "idle" # Stopped and off the dock (mode=ready, charging=unconnected)
EXPLORING = "exploring" # Mapping/exploration run in progress
PAUSED = "paused" # A job paused mid-way (SharkIQ MQTT)
ERROR = "error" # Stopped by a fault (SharkIQ MQTT)
```

The REST API does not expose `docked` directly. `DOCKED` is derived automatically from two fields:
Expand Down Expand Up @@ -75,3 +77,141 @@ class DeviceInfo:
```

> **Note:** The MAC address returned by `get_wifi_status()` is the recommended value to use as `unique_id` when configuring a Home Assistant device. The robot ID endpoint does not expose a serial number.

---

## SuctionLevel

```python
class SuctionLevel(str, Enum):
ECO = "eco"
NORMAL = "normal"
MAX = "max"
```

Named as in the SharkClean app. Used by `VacuumClient.set_suction()`.

---

## VacuumStatus — MQTT-only fields

The SharkIQ MQTT status decoder fills in a few more fields; other transports leave them `None`:

```python
job_active: bool | None # A job is in progress
deep_clean: bool | None # Matrix / spot clean in progress (only set during a job)
recharge_resume: bool | None # Recharge & Resume setting
evac_resume: bool | None # Evac & Resume setting
map: VacuumMap | None # Only on map-bearing frames
```

The suction level is deliberately absent: the robot does not report it in status, only echoes it once when it changes.

---


`errors` and `warnings` are the codes active right now (SharkIQ status fields 5 and 6, repeated). They are numbers; `sharklocal.codes` names them (`codes.names(codes.ERROR_CODES, status.errors)`), with the robot's own enum names — the same names its event log writes as strings (`WARN_MM_LOWLIGHT`, `DE_USR_CTR_DOCK`). `codes.SYSTEM_STATES` names the state behind `mode` (field 4) and `codes.DOCK_EVENTS` the dock reasons.

## VacuumMap

Decoded from the map frames the robot publishes over MQTT. Live frames arrive every few seconds during a job; the *persisted* frame arrives once, as the robot reaches the dock, and carries everything the live frame does plus rooms, vectors, the dock pose, job statistics and the robot's event log.

```python
@dataclass
class VacuumMap:
grid: MapGrid
path: list[MapPoint] # Cleaned path so far, ~1 point per 0.12 m
poses: list[MapPose] # Pose log with heading, ~1 pose per 0.22 m
persisted: bool # True for the saved map: the end-of-job frame or a requested one
report: bool # True for the end-of-job frame only (the one with the event log)
map_id: str | None # e.g. "3CDBBC59" — persisted only
name: str | None # persisted only
dock: MapPose | None # persisted only
rooms: list[MapRoom] # persisted only
features: list[MapFeature] # persisted only: wall "edge" and "door" polylines
log: list[VacuumLogEntry] # persisted only: the robot's event log
job_started: int | None # Unix epoch seconds — persisted only
job_minutes: int | None # whole minutes of the job, docking included — persisted only
raw: dict

@property
def robot(self) -> MapPose | None: ... # last pose, i.e. where the robot is
@property
def cleaned_area(self) -> float: ... # m² covered by the job: grid.cleaned_cells × resolution²
@property
def clean_time(self) -> int | None: ... # s spent cleaning, summed from the log's DT_*_CLEAN_TIME entries
@property
def named_rooms(self) -> list[MapRoom]: ... # rooms without the spot zone
@property
def spot(self) -> MapRoom | None: ... # the saved spot zone ("PinDrop"), if any
```

All coordinates are metres in the map frame; headings are radians, `0` along +x, anticlockwise positive. Convert to grid cells with `col = (x - grid.origin.x) / grid.resolution` and `row = (y - grid.origin.y) / grid.resolution`; row 0 is the bottom of the map.

A Spot Clean is sent as an extra room named `"PinDrop"` (a ~1.5 m square, `vacuum_map.spot_polygon(x, y)`), and the robot keeps it in its saved map afterwards, marked `selected` — so `rooms` can contain it. `named_rooms` leaves it out, and room and spot cleaning never re-upload it.

The persisted map is only published when the robot docks, yet room and spot cleaning need it at any time. `map.to_dict()` returns a JSON-serialisable form (rasters base64-encoded, `raw` dropped) and `VacuumMap.from_dict(data)` rebuilds it, so a caller can store the latest persisted map and still clean rooms after a restart.

### MapGrid

```python
@dataclass
class MapGrid:
resolution: float # metres per cell (0.06 on the RV2610BFCA)
width: int
height: int
origin: MapPoint # world position of cell (0, 0)
cells: bytes # width × height occupancy values, row-major
room_ids: bytes | None # persisted only: room segment id per cell, 0 = unassigned

UNKNOWN = 0x4B # unexplored
CLEANED = 0x00 # floor covered by the job

@staticmethod
def is_wall(value: int) -> bool: ... # 0x50 and above
@staticmethod
def is_floor(value: int) -> bool: ... # explored free floor, cleaned or not
@staticmethod
def is_cleaned(value: int) -> bool: ... # CLEANED
@property
def cleaned_cells(self) -> int: ...
def cell(self, col: int, row: int) -> int: ...
def room_id(self, col: int, row: int) -> int | None: ...
```

### MapPoint, MapPose, MapRoom, MapFeature, VacuumLogEntry

```python
@dataclass
class MapPoint:
x: float
y: float

@dataclass
class MapPose:
x: float
y: float
heading: float # radians

@dataclass
class MapRoom:
name: str # as shown in the app
polygon: list[MapPoint] # bounding polygon drawn in the app
selected: bool # included in the most recent job
coverage: float | None # fraction cleaned in the most recent job

@dataclass
class MapFeature:
kind: str # "edge" (wall outline) or "door"
points: list[MapPoint]

@dataclass
class VacuumLogEntry:
key: str # e.g. "DT_STATE_CHANGE", "DT_WARNING_CODE"
time: int # Unix epoch seconds, as reported by the robot
code: str # e.g. "SYS_ST_CLEANING", "WARN_MM_LOWLIGHT"
```

Room segment ids in `room_ids` do not necessarily map one-to-one onto `rooms`: the robot segments the floor itself, and the app's named rooms are polygons drawn over those segments (two named rooms can share one segment). Use the polygons to name a location and the raster to colour it.

The event log's `time` values can jump when the robot re-syncs its clock mid-job; rely on list order, not `time`, for sequence.
9 changes: 8 additions & 1 deletion docs/mapping-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ status_decoder: sharkiq_protobuf_v1

# Maps protobuf OperatingMode integers to normalized VacuumMode strings
modes:
4: idle
6: cleaning
7: returning_to_dock
13: docking
Expand All @@ -77,7 +78,11 @@ modes:
actions:
start_cleaning:
type: command # Fire-and-forget MQTT publish
payload: "OgQKAhBLgAEJ"
payload: "gAEJ"

set_suction_max: # Settings are commands too; the robot echoes them in field 8
type: command
payload: "OgQKAhBk"

get_status:
type: status_request # Publish then wait for a response message
Expand All @@ -90,6 +95,8 @@ actions:
- `command` — publishes the payload and returns `True`
- `status_request` — publishes the payload, then subscribes and waits up to `timeout` seconds for the first response; returns the decoded `VacuumStatus`

Commands whose payload depends on state — room and spot selection carry the map's room definition — are not mapping actions. They are built at runtime by `sharklocal.vacuum_map` and published with `MQTTVacuumClient.send()`; `VacuumClient.clean_rooms()` / `clean_spot()` wrap that.

---

## Listing and Loading Mappings
Expand Down
Loading