Skip to content

Install firmware from the official app's Update button (opt-in) - #255

Draft
virtitnerd wants to merge 21 commits into
Python-roborock:mainfrom
virtitnerd:pr/firmware-install
Draft

virtitnerd wants to merge 21 commits into
Python-roborock:mainfrom
virtitnerd:pr/firmware-install

Conversation

@virtitnerd

@virtitnerd virtitnerd commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Depends on: #251, #254

Depends on:

  • the last-known state PR (the robot's reported version is recorded in that store)
  • the firmware check PR (the cloud answers the update information)

The branch merges both, so until they land the diff also shows their commits. The three new commits are
Install firmware from the official app's Update button, Show the firmware version a robot reports itself and
Document firmware updates and the [ota] setting.

Why

A robot paired to the local server never talks to Roborock's cloud, so it never learns about new firmware and the
official app cannot install it. Keeping a robot out of the cloud should not mean never updating it. People in #158,
#150 and #36 describe updating before moving to the local server for this reason.

What

Off by default: [ota] enable_install = true or ROBOROCK_SERVER_OTA_ENABLE_INSTALL=true (the variable wins).

With it on, the Update button (POST /ota/device/<duid>/upgrade):

  1. asks the cloud for the offered version and the package address (/user/devices/<duid>/fw?version=...; the file
    name carries the MD5);
  2. downloads the package to the data volume, checks the MD5, and serves it to the robot from a one-time URL;
  3. refuses unless the robot is docked (or asleep on its charger), idle, not cleaning, error free and at 80% battery
    or more, with no other install running;
  4. sends the robot's own OTA command (miIO.ota, download and install) and follows it through the reboot.

Two things that were not obvious and are handled:

  • The robot's downloader only accepts capitalised header names (Content-Length). Starlette lowercases them, so
    packages are served with the names kept as written.
  • The cloud answers the app's status poll with an error when it knows of no update. The official app reads an error
    from that poll as a failed update, and polls it whenever the robot is silent for 90 seconds, which the reboot
    causes. While an install runs the server answers that poll itself from the robot's real state (and IDLE, which
    the app reads as success, only after the robot is back).

The robot also reports its version (protocol 203) on every connect. The server keeps it and uses it for the update
check and the device list, because the cloud never learns the new version and would keep offering the same update.

Testing

Unit tests for each part, 550 passed in total. On hardware, a Qrevo Curv 2 Flow went from 02.33.48 to 02.35.52 through
the iOS app's Update button: 283 MB downloaded in 1m24s, installed in 2m15s, offline for 52s, and the robot reported
02.35.52 when it reconnected. The app showed success. An earlier install of a Qrevo Pro's current package confirmed
the delivery path.

Not included

The development tools used to find this (an admin dry run of the robot's OTA command and a cloud path explorer) are
not part of this PR. They are described in docs/firmware_updates.md.

Security

Installs are opt-in. Only the version the cloud offers can be installed, and the package is checked against the MD5
in the address the cloud returns. The robot verifies the firmware's signature itself. The latest-stable call stays
refused.

Related

#158, #150, #36 (context). There is no open issue for firmware updates; happy to open one if you prefer.

AlexanderBabel and others added 21 commits October 30, 2026 21:17
The cloud lists newer models (e.g. roborock.vacuum.a226) under a string
product id, so the import never fetched their app plugin and /api/v5/product
answered with a hashed id that matches no plugin. The app then fails with
"device initialization failed".

The import now fetches the cloud's /api/v5/product to map models to their
numeric id, and fetches /api/v1/appplugin for both type 2 (Android) and
type 1 (iOS), which get different zips. The appplugin route answers iOS
requests from the iOS catalog, falling back to the existing one.

Fixes Python-roborock#221
The last status, battery and error a robot sent, its last position and the map it was on,
and when it last spoke or went offline, written under device_snapshots/ so it survives a
restart. A robot that dies or loses Wi-Fi stops answering; this is what is left to say
where it was.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Robot-originated frames update the store: status pushes (dps 120/121/122), get_status
answers, and the map reply, which is decrypted with the nonce of whichever client's
request asked for it. A closing robot connection marks the device offline at once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Returns status, position, last_seen_at and offline_at; ?map=1 adds the last map the robot
sent so a client can draw it while the robot cannot be asked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A robot that went quiet before the store existed still has its last status, battery and map
in the decoded MQTT log. On startup, in a background thread, devices the store has no snapshot
for are filled in from that log: last time heard, last status, and the last map decoded with
the nonce of the request that asked for it. Devices that already have a snapshot are left alone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…h it

The snapshot had the last map but not which segment is which room, so an
offline map could only show generic labels. The proxy now records each
get_room_mapping answer, the startup seed reads it from the MQTT log, and
/last_known adds each room's name from the home data.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The snapshot keeps the cloud login and the plugin catalogs fetched at sign-in, and the
plugin routes serve those. Newer robots can need newer plugins than the snapshot holds, so
CloudImportManager.refresh_plugin_catalogs asks the cloud again with the stored login and
replaces only the catalogs. The sign-in now also stores the device identifier the cloud
ties the login to. A rejected login or an empty answer leaves the snapshot as it was.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
POST /admin/api/cloud/refresh-plugins re-reads them with the stored login, taking an
optional platform type. GET /admin/api/cloud/plugins shows what is stored without a cloud
request. Both return each plugin's version, API level and download URL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The refresh calls the same _fetch_plugin_catalogs as the sign-in import, so it gets
whatever that fetches, instead of carrying its own platform type.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The firmware check is a Hawk-signed IoT call, so it uses the rriot credentials the sign-in
stored. The refresh and the check now share one helper for loading that login.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
GET /ota/firmware/<duid>/updatev2 returns what the cloud says, so the official app and
LocalRock can show an available update and its notes. POST /ota/device/<duid>/upgrade used to
answer success and do nothing; it is now refused with a clear error, since installing a
firmware needs the command the cloud pushes to the robot, which is not known yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ture

The app checks with GET /ota/firmware/updatev2?duids=...&lang=..., reads progress from
/ota/firmware/upgrade/status?duids=..., and installs with POST /ota/device/<duid>/upgrade (or
/upgrade/latest-stable). The check and the progress are now read from the cloud, both upgrade
forms are refused, and the older path-style check still works.

These routes answer from the stored cloud login, so they now need the same Hawk signature as
the /user/ routes instead of being open.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The app sends the firmware check and the progress read within milliseconds of each other and
gives them the same timestamp and nonce. The replay check keys on timestamp and nonce, not the
path, so the second request was refused and the Firmware Update screen showed a network error
until the app retried. The signature and its age are still verified for these read-only GETs;
only the repeated-nonce refusal is skipped. Everything else, including the upgrade POST, keeps it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
GET /admin/api/cloud/firmware asks the cloud about each robot in the home data the sign-in
stored, including robots that are not paired to this server, so an available update can be
seen before pairing. A failure for one robot is reported on its row without hiding the rest.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts:
#	tests/test_admin_api.py
With [ota] enable_install = true (or ROBOROCK_SERVER_OTA_ENABLE_INSTALL=true), the
Update button's POST /ota/device/<duid>/upgrade downloads the package the cloud
offers, checks it against the MD5 in its address, serves it to the robot from
this server and sends the robot's own OTA command. The robot has to be docked,
idle and at 80% battery or more. While it runs, the app's status polls are
answered from the robot's real state, since the cloud would answer them with an
error that the app reads as a failed update.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The cloud only learns a robot's version from the robot's own connection to it, so
after a local update it keeps offering the update and showing the old version.
The robot reports its version (protocol 203) each time it connects; the server now
keeps it and uses it for the update check and for the device list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants