---
name: roborock-v1-vacuum
description: Drive a Roborock robot vacuum that speaks the V1 protocol (Q8 Max, S7, S8 and kin) with python-roborock - read its status and rooms, send it to a point without cleaning, clean a room, remote-control it, bring it back to its dock. Use when the owner wants their Roborock to move or report, once the model is confirmed V1.
compatibility: Requires Python 3.11+ and uv, the robot on the same local network, and a one-time login by the owner to their Roborock account.
metadata:
  hardhub-card: hardhub.yaml
---

# Roborock V1 vacuum

Control a Roborock robot vacuum from an agent, with the community library python-roborock (the same one Home Assistant's Roborock integration uses). Everything below was run on a real Q8 Max+ SE on 14/10/2026 unless marked "not tried".

## Rules before anything

- **The robot really moves.** Before every movement command, tell the owner what it will do and get a yes. Check the floor is clear (cables, socks, pets) and respect quiet hours.
- **Never print, copy or send** `~/.roborock` (the cached session), the device id (duid), the local key, the serial number, the robot's IP address or the Wi-Fi name. Keep the duid in a shell variable; leave identifiers out of any report.
- **Note every setting you change** (fan power, volume, auto-empty) and restore it when done.

## Identify the device

- Ask for the model on the label under the robot, or read it after login: python-roborock reports a model id such as `roborock.vacuum.a73`.
- This recipe is for the **V1 protocol only**: in python-roborock, `device.v1_properties is not None`. A01 devices (Dyad, Zeo) and B01 devices (Q7, Q10) need another recipe. Do not pick this recipe from the brand name alone.
- Network clues before login: a DHCP hostname like `roborock-vacuum-a73` (the end is the model id), a MAC address starting with `B0:4A:39` or `24:9E:7D` (Home Assistant's matchers). The robot announces nothing over mDNS or SSDP.

## Prerequisites

- Python 3.11 or later and `uv`. Below, `rr` stands for `uvx --from 'python-roborock[cli]' roborock`.
- **One login, done by the owner in their own terminal** (the account is theirs, the code comes by email):
  `uvx --from 'python-roborock[cli]' roborock login --email <their email>`
  Never use `--password`: it lands in the shell history and in the agent's context. The session is cached in `~/.roborock` and reused by every later command.
- The device id, kept in a variable and never echoed:
  `DUID=$(rr list-devices | python3 -c 'import json,sys; print(next(iter(json.load(sys.stdin).values())))')`
  (`list-devices` prints `{name: duid}`; pick by name if there are several robots.)
- The agent's machine on the same local network as the robot, so the library can use the local channel (TCP 58867).

## Workflow

### Read

| What | Command | Notes |
|---|---|---|
| Status | `rr command --device_id "$DUID" --cmd get_status` | Raw keys in snake_case (`state`, `battery`, `fan_power`, `map_status`, `error_code`, `in_cleaning`). `rr status` gives the same in camelCase |
| Rooms | `rr rooms --device_id "$DUID"` | `segmentId` and the name given in the Roborock app. Empty if rooms were never named, or if the robot is on a temporary map (see Known traps) |
| Dock and robot position | `rr map-data --device_id "$DUID"` | `charger {x, y, a}` and `vacuum_position {x, y, a}`, millimetres in the map frame, angle in degrees |
| Saved maps | `rr command --device_id "$DUID" --cmd get_multi_maps_list` | `mapFlag` of each saved map |

State codes (python-roborock `RoborockStateCode`): 2 charger disconnected (off the dock), 3 idle, 5 cleaning, 6 returning home, 7 manual mode (remote control), 8 charging, 10 paused, 11 spot cleaning, 12 error, 15 docking, 16 going to target, 18 room cleaning, 22 emptying the bin, 100 charging complete.

### Act

Raw V1 commands: `rr command --device_id "$DUID" --cmd <command> [--params '<json>']`. The names come from python-roborock's `RoborockCommand` enum (`roborock/roborock_typing.py`); look others up there.

| Goal | Command and params | Tried on 14/10/2026 |
|---|---|---|
| Go to a point without cleaning | `app_goto_target` `[x, y]` | Yes: state 16, then 3, stopped 59 mm from the target |
| Back to the dock | `app_charge` | Yes: state 6, then 8, about 30 s from 1 m away |
| Load a saved map | `load_multi_map` `[mapFlag]` | Yes, robot on its dock |
| Fan power | `set_custom_mode` `[101]` to `[104]` (quiet, balanced, turbo, max on this model) | Yes |
| Volume | `change_sound_volume` `[0..100]`, read with `get_sound_volume` | Yes |
| Auto-empty when docking | `set_dust_collection_switch_status` `{"status": 0}` or `1` | Yes |
| Clean rooms | `app_segment_clean` `[{"segments": [16], "repeat": 1}]` (older firmwares take `[16]`) | Not tried |
| Full clean, pause, spot | `app_start`, `app_pause`, `app_spot` | Not tried |
| "I'm here" sound | `find_me` | Not tried |

**Going to a point.** A safe first target is in front of the dock: `x = charger.x + d * cos(a)`, `y = charger.y + d * sin(a)` with `d` around 800 to 1000 mm. On 14/10/2026 (`a` = -180) a target 1 m out was reached in under a minute. Choose open floor. Not known yet: whether the fan runs during the trip when it is not on quiet, so set `set_custom_mode [101]` first if the owner wants silence, and restore it after.

**Remote control** needs a long-lived session, not one CLI call per move:

1. `app_rc_start`, then wait about 1.5 s.
2. `app_rc_move` every 250 ms with `[{"omega": <rad/s>, "velocity": <m/s>, "seqnum": <n>, "duration": 600}]`. `velocity` stays within plus or minus 0.3 m/s; `omega` up to about 2 rad/s worked; `seqnum` goes up by one each time. The 600 ms duration is the dead man's switch: if the agent dies, the robot stops by itself.
3. A last move at zero, then `app_rc_end`. The robot reports state 7 while driven.

```python
# Run: uv run --no-project --with 'python-roborock[cli]' drive.py
import asyncio

from roborock.cli import RoborockContext  # reuses the CLI login cache; internal API, checked on 7.12.3


async def main() -> None:
    manager = await RoborockContext().get_device_manager()
    await manager.ensure_device_manager()
    try:
        device = next(d for d in await manager.device_manager.get_devices() if d.v1_properties)
        send = device.v1_properties.command.send
        await send("app_rc_start")
        try:
            await asyncio.sleep(1.5)
            for seq in range(1, 9):  # about 2 s forward, slowly
                await send("app_rc_move", [{"omega": 0.0, "velocity": 0.1, "seqnum": seq, "duration": 600}])
                await asyncio.sleep(0.25)
            await send("app_rc_move", [{"omega": 0.0, "velocity": 0.0, "seqnum": 9, "duration": 600}])
        finally:
            await send("app_rc_end")
            await asyncio.sleep(1.5)  # see Known traps, 1
    finally:
        await manager.device_manager.close()


asyncio.run(main())
```

## Verify the result

- `["ok"]` only means the robot accepted the command. Poll `get_status` every 1 to 2 s and check the state moves as expected: go to a point, 16 then 3; return, 6 then 8; room cleaning, 18.
- **Returning is not docked.** Docked is state 8 or 100. State 2 means off the dock.
- After any movement session, check that `map_status >> 2` is not 63 and that `rr rooms` is not empty.
- Tell the owner what moved, where the robot is now, and which settings were restored.

## Known traps

Each one cost time on the real robot on 14/10/2026 (6 to 8 on 16/10/2026).

1. **`app_charge` right after `app_rc_end` is ignored.** The robot answers `["ok"]` and stays idle (state 3) off its dock. Wait 1.5 s after `app_rc_end`, check for state 6 within 5 s, and resend once if it is not there.
2. **A remote-control session can leave the robot on a temporary map.** Seen once in two sessions: `map_status` 253 (253 >> 2 = 63, "no map"), rooms empty, dock position wrong. The saved map was still listed by `get_multi_maps_list`. Fix: with the robot on its dock, `load_multi_map [<mapFlag of the saved map>]` (0 when there is one map); `map_status` went back to 3 and the rooms came back.
3. **A driven robot is left where it stopped.** After remote control, ending the session does not send it home: say so to the owner, and send `app_charge` only with their yes.
4. **Positions move by about 10 cm** between two reads of the map, so expect about 10 cm precision.
5. **The robot's voice cannot be replaced.** Since the third generation (2019), Roborock robots install only voice packs signed by Roborock, and no command plays arbitrary audio or text. To make the robot talk, use a speaker riding on it (Bluetooth from the computer) that does not cover the laser turret on top, and mind the extra height under low furniture.
6. **A robot whose dock was unplugged goes silent.** Its battery runs down and it drops off Wi-Fi: no ARP answer, no ping, local port closed, and the cloud command times out after 10 s. Nothing to redo in the app: plug the dock back in, set the robot on it, and it rejoins Wi-Fi on its own. Tell the owner to keep the dock always powered.
7. **Neither `state` 3 nor `charge_status` tells it is charging.** `charge_status` stayed 1 (charging) for 25 minutes while `state` was 2 (`charger_disconnected`) and the battery fell from 77 to 75; before that, `state` 3 with `charge_status` 1 and the battery stuck at 77. Only `state` 8 (charging) or 100 (charging complete) means the robot sits on powered contacts, or a battery that rises between two reads a few minutes apart.
8. **`app_start_collect_dust` answers `["ok"]` even when the dock cannot empty.** With the robot off its charging contacts (`state` 2), the dock stayed silent and `dust_collection_status` stayed 0. Check `state` 8 or 100 before sending it, then expect `state` 22 (emptying the bin) within a few seconds; no 22 means it did not run. The do-not-disturb timer also blocks emptying when its `actions.dust` is 1 (`get_dnd_timer`).

## Limits

- Verified on one robot only: Q8 Max+ SE (Q80ULL, `roborock.vacuum.a73`), firmware 02.06.86, python-roborock 7.12.3. Other V1 models listed by python-roborock (S4 to S8, Q5, Q7 Max, Q8 Max, Q Revo) are likely close but were not tried with this recipe.
- Not cloud-free: the login goes through Roborock's cloud and the library keeps a cloud MQTT session next to the local channel. For a cloud-free setup, see local_roborock_server (DNS redirection, own domain and certificate). Valetudo does not support the Q8 Max.
- The remote-control snippet uses `roborock.cli.RoborockContext`, an internal API that may change between versions; the documented path is `create_device_manager(UserParams(...))` after a login with `RoborockApiClient`.

## Sources

- python-roborock, version 7.12.3 (14/10/2026): https://github.com/Python-roborock/python-roborock, command names in `roborock/roborock_typing.py`, state codes in `roborock/data/v1/v1_code_mappings.py`, map flag in `roborock/data/v1/v1_containers.py` (`current_map`).
- Home Assistant Roborock integration, dev branch (14/10/2026): DHCP matchers in `manifest.json`, `set_vacuum_goto_position` in `services.yaml`; vacuum services (`clean_area` and others) in `homeassistant/components/vacuum/services.yaml`.
- Signed voice packs: https://github.com/andreypopov/node-red-contrib-miio-roborock/issues/18 (2020).
- Local channel on TCP 58867: https://github.com/Python-roborock/python-roborock/blob/HEAD/roborock/devices/transport/local_channel.py
- Runs on a real robot: the author's verification notes of 14/10/2026.
