Protocol notes¶
What the robot answers over its local TPAP (SPAKE2+) transport, learned from live traffic,
the forks, and the Tapo Android app's code. tapo_rv30.send_raw lets you try any method
against your own robot, docked.
Room cleaning¶
The setSwitchClean payload for selective room cleaning was reverse-engineered here
from live device traffic and later contributed to
python-kasa
(#1660).
The payload is:
{
"clean_mode": 3,
"clean_on": true,
"clean_order": true,
"force_clean": false,
"map_id": <int>,
"room_list": [<room_id>, ...],
"start_type": 1
}
clean_mode values: 0 whole house, 1 whole house with per-room settings, 2 spot,
3 selected rooms (room_list), 4 zones (area_list of rectangles in mm, which is also
what the app uses for spot clean), 5 a saved preset (custom_rule_id, listed by
getCustomizationRulesInfo). room_list holds the room ids, which are the pixel values
in the LZ4 map. tapo_rv30.send_raw lets you try any method against your own robot.
Per-room settings. The app writes them with setCleanAttr {"type": "custom", "area_list":
[...]} (RobotRepoClean.X2 in the app). RV30 Max Plus firmware 1.3.3 answers -1001 to every
shape of that call, and -1008 to getCleanAttr {"type": "custom"}, so on that firmware the
only per-room settings that stick are the ones inside a preset. set_room_settings stays for
firmware that accepts it and explains itself when it is refused; the card's long-press sheet
starts a clean of that room with the chosen levels instead.
Do not probe a robot while it is working. On 2026-10-21 an RV30 Max Plus was sent a
batch of read-only setup and backup queries lifted from the app (getQsComponentList,
getInheritInfo, getCommissionedStatus, getMatterSetupInfo, getMapBRProcess,
getOflMapSwitch) about 38 minutes into a scheduled clean. Within seconds it aborted the
run with an error, its saved maps and clean history were gone, and the map backups in the
Tapo app with them. None of those methods is documented as destructive and the cause is
not proven, but the timing is hard to ignore. send_raw therefore refuses anything but the
methods the integration itself uses while the robot is cleaning, returning or paused,
unless you pass force: true. Probe docked, and leave setup, inherit, commissioning and
map backup / recovery methods alone unless you can afford to remap.
Furniture placed in the app comes back from getFurnitureInfo {"map_id": <int>} (and inside
the map blob) as {id, type, angle, vertexs}; the map camera lists it in
map_geometry.furniture with the type name, and the map draws each item with its own glyph.
Type codes (from the app's RobotFurnitureType): 1 single bed, 2 double bed, 101 single
sofa, 102 double sofa, 103 sectional sofa, 201 table and chairs, 202 square table, 203 end
table, 301 TV cabinet, 302 wardrobe, 303 bedside table, 401 refrigerator, 402 front-load
washer, 403 vertical air conditioner, 503 green plants, 601 toilet, 701 pet house, 702 cat
litter box, 703 pet bowl. The glyphs are the integration's own drawings, not the app's icons.
Remote control¶
From the app's RobotRemoteControlViewModel and its control view (component
direction_control). Verified on an RV30 Max Plus firmware 1.3.3.
directionControl {"direction": 0, "control": true}enters remote control; the answer carriesaway_sta_ms, how long the robot takes to leave the dock (the app counts it down and assumes 4000 ms when absent).{"direction": 4, "control": false}leaves it.directionControl {"direction": n}drives: 0 forward, 1 backward, 2 left, 3 right, 4 stop. The app sends the held direction, waits for the answer, pauses 300 ms and sends it again while the finger stays down; lifting sends 4. Status is 3 ("Remote Control") meanwhile.getSweepWalk/setSweepWalk {"sweepWalk": bool}is "clean while moving" (the Sweep Walk switch). The app's "spot clean here" is an ordinary spot clean (clean_mode 2).
Schedules¶
The app posts the schedule rule as-is. Verified on an RV30 Max Plus:
addScheduleRulewith{"enable", "mode": "repeat", "week_day", "s_min", "start_remind", "alarm_min", "clean_attr": {...}}answers{"id": "S1"}.editScheduleRuleneeds the whole rule includingid(a partial body is -1008).removeScheduleRules {"remove_all": bool, "rule_list": ["S1"]}.week_dayis a bitmask, Sun=1 ... Sat=64.s_minis minutes after midnight in the robot's own region; the app only shifts it when the phone's zone differs from the robot's.clean_attrcarriesclean_mode(0 whole house, 3 rooms withroom_listandmap_id, 5 preset withcustom_rule_id),suction,cistern,clean_number,clean_order.- A one-off rule (
mode: "once"withyear,month,day) was stored by the robot on a different date than asked, so the integration refuses to create them. - A schedule whose
s_minfalls inside the Do Not Disturb window (getDoNotDisturb,s_min/e_min, may cross midnight) is accepted and stored but never fires: with DND covering the time, a daily rule three minutes ahead left the robot docked and logged nothing. The app warns about this; the integration flags such schedules (blocked_by_dndin the schedules sensor) and refuses to create or enable one unless asked withignore_dnd.
Path and records¶
getPathData {"path_id": N, "start_pos": 0}returns the run's path as an LZ4 block: 8-byte header, then big-endian int16 (x, y) pairs in mm.getCleanRecordExtraInfo {"record_index": N}returns a finished run with its own map grid (cells the robot cleaned are 255, skipped rooms keep their ids), overlays, furniture and the path (pos_array,pos_len). This is what the Last Clean Map image renders.
Maps: backup, restore and setup¶
The map has two layers with completely different write paths.
The grid - the occupancy pixels, i.e. the walls and obstacles the robot sensed - is built
by SLAM and is not writable by anything, the app included. getMapData {"map_id"} returns it
as an LZ4 block (map_data, pix_len, pix_lz4len, width, height, resolution) with
origin_coor/real_origin_coor, charge_coor, vac_coor and map_hash.
Everything on top - rooms and their names, no-go zones, virtual walls and carpets - is the
area_list, and it is writable (see setAreaInfo below).
Cloud backup and restore (mapBackup / mapRecovery)¶
"Back up map" in the app is mapBackup {"map_id": <int>}: the robot uploads its own map to
TP-Link storage (an AWS S3 bucket, prd-iot-as-euw1.s3.eu-west-1.amazonaws.com/<thing>/robotVacMap/<map_id>).
getMapBRProcess {"map_id"} reports {type, process, status} - type 1 backup / 2 restore /
0 idle, status 2 running / 3 success / 13 failed, process percent. The account's backup
list is a signed GET {thingUrl}/v1/things/{thingName}/map/detail returning
[{mapId, mapUrl, mapMd5, snapshotUrl, timestamp}]. The backup file is the robot's own binary
format: a short header ("MZ" + 16 bytes + 0xAA55), a gzip tar of the raw map files
(map.gridmap, map.segmentmap, area_info.json, area_user.json, charger_pose.json,
navmap_bound.json, path_record, path_map), then a JSON tail. The 16-byte header is a
keyed digest, not a plain hash; the rest is plaintext.
Restore is mapRecovery {"map_id", "map_md5", "url"}: the robot downloads the file at url,
checks its MD5, unpacks it and applies it. It works from a local URL - no cloud round-trip.
The integration serves the backup from Home Assistant and the robot fetches and applies it.
Verified end to end on an RV30 Max Plus (fw 1.3.3): a change to a live map was restored back to
the backed-up state, getMapBRProcess reaching process: 100, status: 3, robot docked.
The one real constraint is the robot's HTTP client, and it caused a long detour worth recording:
- The robot's downloader needs a plain response with a correct
Content-Length(which aiohttp'sFileResponsesends). Served that way - Home Assistant's direct LAN address - the download completes and the map applies. - Served through a reverse proxy in front of Home Assistant (the external hostname here was
fronted by OpenResty), the proxy re-chunks the body. The robot's client reads the bytes but
never sees the end marker it understands, so
getMapBRProcessclimbs to ~92-99% and then flips tostatus: 13. This looked like an apply-step rejection but is an unfinished download; the direct-LAN URL fixes it. (A TP-Link S3 presigned URL also works, for the same reason - cleanContent-Length.) - Error codes seen:
-3016themap_idsent is not on the robot (the carrier must exist; the backup itself may be of a deleted map, see below),-3002the robot is mid-task,-3022/-3023on other preconditions.
The recovery logic itself was read from firmware. TP-Link also publishes unencrypted debug
builds (RVSD30_<ver>_Build_<n>.bin.gz) whose build numbers track the shipping firmware; the
build matching this robot (260206) unpacks to a plain squashfs. Its robot_control_node
(BackupInterface::recoveryMap) verifies the MD5, unpacks the .bkmap into the map's component
files (.pbstream, .area, forbid/carpet data, room info), then loads it via the Cartographer
interface. None of that is cloud-gated. (The separate ldrobot.scv nav-board image is
AES-encrypted, but the recovery path does not live there.)
Conclusion: a map backup can be restored to the robot entirely locally.
tapo_rv30.restore_map_backup does this, serving a backup that tapo_rv30.fetch_cloud_map_backups
has downloaded. Deleting a map in the app deletes its cloud backup too, so fetch backups ahead
of time.
Restoring a map that no longer exists. mapRecovery only accepts a map_id the robot
currently has (otherwise -3016, the same "no such map" code getMapData uses). But the backup
lands under the map id it was taken from, not the one passed: with the original map gone it is
recreated under its old id as the current map, unnamed and with is_saved: false in
getMapInfo, and saving it in the app keeps that id. So after a total loss, any map (even a
throwaway quick mapping run) can carry the restore. The grid and room segmentation come back;
room names, some no-go zones and the furniture did not, and presets
(getCustomizationRulesInfo) and schedules had gone with the map, which is why the integration
keeps its own map setup snapshots (addCustomizationRule with the same body
getCustomizationRuleData returns, minus rule_id, recreates a preset; the robot assigns a new
id).
Writing the editable layers (setAreaInfo)¶
setAreaInfo is the method the app uses for
every map edit, and it needs no cloud:
{"map_id": <int>, "type_list": ["forbid"], "area_list": [ { "type": "forbid", "vertexs": [[x,y], ...] } ]}
type_list names which area types the call replaces; the robot swaps all areas of those types
for area_list (an empty list clears them). Types: room, forbid (no-go zone),
virtual_wall, carpet_rectangle, carpet_round. Each entry (RobotAreaData) carries type,
id, name, vertexs and the finish fields (color, floor_texture, floor_material,
carpet_strategy). vertexs are world-coordinate (mm) polygons, so they re-apply onto any
grid whatever its shape - which is why they survive a remap. Verified on an RV30 Max Plus:
writing and clearing forbid and virtual_wall round-trips exactly (the robot renumbers ids
but keeps the polygons). Per-room clean settings (suction/water/passes) are not here; those go
through setCleanAttr, preset-only on 1.3.3 (see Room cleaning).
tapo_rv30.save_map_setup / apply_map_setup build on this: snapshot the area_list to a file
and write it back, restoring no-go zones, virtual walls, carpets and room names after a remap -
the practical recovery the cloud restore refuses - entirely over the local API. The grid itself
the robot re-maps on its own.
Presets (customisation rules)¶
A preset is a "customisation rule" on the robot, stored per map. Verified live on 1.3.3 by creating, editing and deleting a throwaway preset.
- List:
getCustomizationRulesInforeturnsrule_listof{rule_id, rule_name (base64), mode, map_id, invalid}. - Read one:
getCustomizationRuleData {"map_id", "rule_id"}returns the full body below. - Create:
addCustomizationRulewith{"rule_name": "<base64>", "mode": "room", "map_id": <int>, "clean_order": true, "area_list": [{"id": 2, "type": "room", "suction": 2, "cistern": 1, "clean_number": 1, "carpet_strategy": 1}]}area_listis in cleaning order;carpet_strategyis copied from the map's room and left out when the room has none. The robot picks the next freerule_id. - Edit: the same call with
rule_idadded. The app uses one call for both. - Delete:
delCustomizationRule {"map_id", "rule_id"}. - Run:
setSwitchCleanwithclean_mode5 andcustom_rule_id. Schedules that run a preset carry the samecustom_rule_idin theirclean_attr, so deleting a preset leaves those schedules pointing at nothing.
This is the only place per-room levels stick on firmware that rejects setCleanAttr type
custom. create_preset, update_preset and delete_preset wrap these calls.
Status prompts¶
getVacStatus carries prompt (a list of codes) and promptCode_id (a matching list of ids,
new for each time a notice is raised). They are the notices the app shows and the robot speaks.
Names follow the app's RobotPromptType; 32 and 36 come from the firmware's event table.
| Code | Notice | Code | Notice |
|---|---|---|---|
| 1, 2 | battery too low to start | 17 | firmware update succeeded |
| 3 | mop installed | 18 | recharged, returning to where it stopped |
| 4 | mop removed | 19 | charging stopped |
| 5 | emptying the dustbin | 20 | dustbin emptied |
| 6 | cleaning finished | 21 | some of the area is unreachable |
| 7 | low battery, returning to recharge | 22 | could not relocate, building a new map |
| 8 | updating firmware, cannot clean | 23 | started inside a no-go zone |
| 9 | charged, resuming the clean | 30 | stuck for too long |
| 10 | charging state unknown | 31 | paused to recharge |
| 11 | dust bag full | 32 | clean the charging contacts |
| 12 | dust bag missing | 33 | water tank empty |
| 13 | dust duct blocked | 36 | dock busy |
| 14 | emptying too often | ||
| 15 | battery too low, shutting down | ||
| 16 | firmware update failed |
The integration fires tapo_rv30_prompt once per new promptCode_id (or per new code on
firmware that sends no ids).
Other calls verified on the RV30 Max Plus¶
gotoPoint {"switch": true, "point": [x, y]} (status becomes 11, Going To Point; switch:
false cancels), getDustCollectionInfo / setDustCollectionInfo (dust_collection_mode
0 smart, 1 light, 2 balanced, 3 max), getAutoUpdateInfo / setAutoUpdateInfo
(enable, time, random_range), getCarpetClean / setCarpetClean
(carpet_clean_prefer boost / normal, the same setting as setCarpetAutoBoost),
getCurrentVoiceLanguage (an md5 of the voice pack; changing it needs a cloud URL),
getMatterSetupInfo, getCommissionedStatus, startCommission (see matter.md).
Omni dock actions: getBackWashMode / setWashMopSwitch, getDryMopMode / setDryMopSwitch,
getCutHairMode / setCutHairSwitch. The app sends {"switch": true} to all three setters
(BaseStationSwitchCommandParams and RobotCutHairStatus both serialise their one field as
switch); the per-action keys some forks use are not what the app sends. Untested here (no
Omni dock on the RV30 Max Plus).
Unknown on this firmware (-1002): voice pack listing, lifetime statistics beyond
getCleanRecords totals, getCleanOrder, getCleanTaskGroupList.
Full endpoint map¶
The robot's local API dispatcher (the VAC binary in the firmware) carries a table of method
names. The list below is from the unencrypted RVSD30 debug build cross-checked against the live
RV30 Max Plus on 1.3.3: each method was called read-only where safe, and marked by what the
robot actually answered. -1002 means the robot does not know the method; -1001 means it
knows it but rejected the call (wrong parameters or state).
Already used by the integration: getVacStatus, getBatteryInfo, getCleanInfo
(carries clean_percent, the live progress), getCleanStatus, getCleanAttr / setCleanAttr,
getMapInfo / setMapInfo, getMapData, getPathData, getAreaInfo / setAreaInfo,
getFurnitureInfo / setFurnitureInfo, getCleanRecords / getCleanRecordExtraInfo,
getScheduleRules / addScheduleRule / editScheduleRule / removeScheduleRules,
getCustomizationRulesInfo / getCustomizationRuleData / addCustomizationRule /
delCustomizationRule, getConsumablesInfo /
resetConsumablesTime, getVolume / setVolume, getDoNotDisturb / setDoNotDisturb,
getDustCollectionInfo / setDustCollectionInfo / setSwitchDustCollection, getMopState,
getChildLockInfo / setChildLockInfo, getCarpetAutoBoost / setCarpetAutoBoost,
getSweepWalk / setSweepWalk, getComponentList, getAutoUpdateInfo / setAutoUpdateInfo,
setRobotPause, setSwitchClean / setSwitchCharge, mapBackup / mapRecovery /
getMapBRProcess, deleteMap, playSelectAudio, plus the 1.3.x switch family
(get/setAiAvoidSwitch, get/setExtendedMoppingSwitch, get/setAutomaticRemoppingSwitch,
get/setCleanAlongFloorSwitch, get/setPetAreaDeepSwitch, get/setDryMopMode,
get/setCutHairMode, get/setBackWashMode) and the Matter calls.
Confirmed on 1.3.3 but newly wired up / available to wire up:
getAutoChangeMap/setAutoChangeMap({auto_change_map: bool}) - whether the robot picks the matching saved map automatically when it relocates on a different floor. Now exposed as the "Auto-change Map" switch.getRobotPauseTime({pause_min}) - how long the robot waits, paused, before it gives up and returns to the dock. Read-only companion tosetRobotPause; could be a number entity.getAreaUnit/setAreaUnit({area_unit}0 = m2) - unit for reported clean areas.getDeviceTime({time_diff, timestamp, region}),getDstTime/setDstTime/startDST- clock, timezone and daylight-saving.getInheritInfo({inherit_status}) reads butsetInheritInforejected the obvious shape (-1001); the write form is still unknown.getWifiBasic({ssid, key_type, rssi}),getWirelessConnectedStatus,getWirelessScanInfo- network info;rssiis already surfaced viagetDeviceInfo.- Voice:
getCurrentVoiceLanguage,getVoiceDownloadState,setVoiceLanguage- voice-pack management (changing the pack needs a cloud download URL).
Actions present in the dispatcher, not yet wired (each drives the robot, so they need supervised live testing before shipping):
switchFastMapping- run a quick mapping sweep to build a map without cleaning.switchExploringNewArea- send the robot to explore and extend the current map.runCleanTask- the unified clean entry point. The ROS service behind it (BridgeRoomCleanTask/BridgeTotalCleanTask/BridgeSelectCleanTask) takes per-areafan_level,water_level,clean_number,clean_order,clean_interval_type,clean_type,start_typeandclean_mode; the integration currently drives the same outcomes throughsetCleanAttr+setSwitchClean.saveMap/startUpdateMapData- map build lifecycle, used around mapping runs.deleteCleanRecord- remove a single run from history.
Not supported on this firmware (-1002): getLidarFreqPercent / setLidarEnabled,
getSupportVoiceLanguage, getNextEvent (-1001; present but rejected bare), getCleanOrder,
getCleanTaskGroupList.
Firmware details¶
Learned from the RVSD30 firmware build, the Tapo app and live calls on 1.3.3.
Clean start fields (setSwitchClean, and runCleanTask which takes the same keys):
clean_mode: 0 whole house, 1 whole house with per-room settings, 2 spot, 3 rooms, 4 zones, 5 preset.start_type: who started it. 0 the robot's button, 1 the app (what the integration sends), 4 a schedule; other values exist. Clean records report the same field.force_clean:truestarts the new job over one that is already running instead of being refused.clean_type(per job or per room, also in clean records): 0 vacuum and mop, 1 mop only, 2 vacuum only, 3 vacuum then mop, 4 AI.- Cleaning density, logged by the firmware as
clean_density: 1 daily, 2 fast (skips the edge pass), 3 meticulous. setCleanAttrtype:global(the default levels),pose(spot and go-to levels),running(the job in progress),custom(per-room, rejected on 1.3.3).
Other read calls: getCleanStatus returns {clean_status, is_working, is_mapping,
is_relocating}. getCleanAttr {"type": "pose"} works. getCarpetClean returns
{carpet_clean_prefer}. getConsumablesInfo includes rag_time (mop cloth minutes), reset
with resetConsumablesTime {"reset_list": ["rag"]} like the other parts.
playSelectAudio only accepts "seek_me" (the "find my robot" call) and "test".
Component gating. getComponentList returns {id, ver_code} pairs and the app hides
features by them. On an RV30 Max Plus (1.3.3): clean 3, map 2, auto_change_map 2,
map_lock 1, continue_breakpoint_sweep 1, charge_pose_clean 1. A missing component means
the app does not offer the feature, even when the method answers.
Unknown on 1.3.3 (-1002), though other firmware has them: getContinueThreshold,
getCliffDetectMode, getCarpetDefVal, getCarpetThreshold, getCarpetDeepSwitch,
getPetAreaDeepInfo, getMopPrefer, getValleyChargingTime, getOfflineMapSwitch,
getHiddenArea.
Status, error and level tables in the firmware (status codes 0 to 19, error codes, fan, water and auto-empty values) match the tables the integration already uses.
Never send these. The dispatcher holds 162 methods, including factory and debug ones
(exec_atcmd, erase_flash) and the onboarding, reset, reboot, firmware and Wi-Fi families.
send_raw refuses them, even with force. setInheritInfo and the quick-setup calls are on
that list: a batch of setup and inherit calls came minutes before one robot lost every map.
No camera/video endpoints exist - the RV30 navigates by LiDAR and structured light, so there is no live-view or snapshot stream to expose (the dispatcher has nothing of the sort).