- Rust 68.8%
- HTML 25.3%
- Shell 5.6%
- Makefile 0.3%
Clean-room reimplementation of the Valetudo MQTT + REST wire contract in Rust: local miio transport, dummycloud/FDS map pipeline, Home Assistant autodiscovery, and a single-file web UI. Obstacle detection sourced from the live ai_obstacle map layer with per-obstacle camera crops. |
||
|---|---|---|
| .cargo | ||
| api | ||
| assets/branding | ||
| core | ||
| deploy | ||
| docs | ||
| firmware | ||
| models/dreame-l10s-ultra | ||
| mqtt | ||
| scripts | ||
| valetini | ||
| webui | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| Cross.toml | ||
| DEPLOYMENT.md | ||
| Makefile | ||
| README.md | ||
| SPEC.md | ||
| TESTING.md | ||
| THIRD-PARTY-LICENSES.md | ||
valetini
A minimal, ultra-fast Rust alternative to Valetudo, scoped to a single device:
the Dreame L10s Ultra (dreame.vacuum.r2228o, Allwinner MR813 / sun50iw10, ARM64, "Athena").
It speaks the robot's local miio protocol directly, exposes a Valetudo-compatible REST + SSE API (so existing Home Assistant cards and the vacuumstreamer integration work as drop-ins), and streams a live map as Valetudo-compatible JSON.
This is not a general-purpose multi-device port. It is structured, however, so that adding a second model later means writing a new crate — not restructuring anything.
Protocol details, IDs, quirks, map format, and the AV integration plan are documented in
SPEC.md. Manual validation steps are inTESTING.md.
Workspace layout — what's generic vs. Dreame-specific
valetini/
├── core/ # valetini-core — DEVICE-AGNOSTIC. No Dreame/miio knowledge.
│ ├── config.rs # ValetiniConfig store & MQTT live hot-reload
│ ├── model.rs # RobotModel trait (the seam every model implements)
│ ├── map.rs # MapSource trait (map delivery seam)
│ ├── presets.rs # Named zone presets store (JSON atomic persistence)
│ ├── history.rs # Cleaning history store (JSON atomic persistence, capped at 50)
│ ├── types.rs # generic domain types (Status, OperationMode, Quirk, Restrictions...)
│ └── error.rs
├── api/ # valetini-api — DEVICE-AGNOSTIC. axum REST+SSE over the traits.
│ ├── build.rs # Compile-time gzip pre-compression & ETag generation
│ └── lib.rs # Valetudo-compatible routes + embedded compressed Web UI & static assets
├── mqtt/ # valetini-mqtt — Home Assistant MQTT discovery & Homie 4 conventions
├── models/
│ └── dreame-l10s-ultra/ # THE ONLY DREAME-SPECIFIC CRATE.
│ ├── constants.rs # GEN2 SIID/PIID/AIID table, quirk UUIDs, enum mappings
│ ├── miio.rs # miio packet codec (AES-128-CBC + MD5 + 0x2131 header)
│ ├── transport.rs # get/set_properties + action over the local socket
│ ├── map.rs # Dreame map I-frame + differential P-frame parser → ValetudoMap JSON
│ ├── cloud.rs # dummycloud UDP :8053 + FDS HTTP :8079 + gslb :80
│ └── robot.rs # DreameL10sUltra: impl RobotModel
├── valetini/ # the binary — wires model + API + cloud map source + history watcher
├── webui/ # the web UI — self-contained responsive SPA
│ ├── index.html # embedded into the binary (raw + gzip), served at /
│ ├── valetini_logo.png # custom branding icon
│ └── valetini_text.png # custom branding logotype
├── deploy/ # on-device integration scripts (see "vacuumstreamer" below)
├── SPEC.md TESTING.md Makefile Cross.toml
A second model (e.g. models/roborock-xyz/) is a peer crate that implements RobotModel.
Swapping it in is a one-line change in valetini/Cargo.toml (which model crate it depends on) —
core/ and api/ never change. Everything that would differ per-device lives only in the model
crate: message framing, IDs, quirk encodings, map format.
Build
Target: aarch64-unknown-linux-musl, statically linked. (The L10s Ultra runs a static-musl
Node binary for Valetudo today; static musl is the direct Rust equivalent and avoids any dependency
on the device's libc.)
Prereqs: Docker (for cross) or native rust-lld toolchain.
make build # cross-compiles target/aarch64-unknown-linux-musl/release/valetini
make size # show the stripped binary size (~2.1 MB)
make test # run the workspace unit tests on the host
Adding another build target later = one more [target.<triple>] block in Cross.toml and a copy of
the aarch64 block in the Makefile (change TARGET=). Not a new build system.
The current build produces a ~1.3 MB statically-linked aarch64 binary (file reports
ELF 64-bit LSB executable, ARM aarch64, statically linked, stripped) — no dependency on the
device's libc.
Reproducibility note:
serde_jsonis pinned (seeCargo.lock) below the version that pulls a build-script dependency which segfaults under QEMU during cross-compilation. If you bump it andmake buildfails with a build-scriptSIGSEGV, that pin is why.env_loggeralso has default features disabled (drops a heavy timestamp dependency) — logs are un-timestamped, which is fine since the init system timestamps them.
Configuration (environment variables)
Environment variables use the VALETINI_* prefix (with complete backward compatibility fallback to VALERUST_*):
| Var | Default | Meaning |
|---|---|---|
VALETINI_IP |
127.0.0.1 |
Robot IP (localhost when running on-device). |
VALETINI_TOKEN |
(required) | Local token from /data/config/miio/device.token. Accepts 16 raw bytes (the file's actual format) or 32-hex. |
VALETINI_DID |
(required) | Device id (decimal), from /data/config/miio/device.conf did=. |
VALETINI_BIND |
0.0.0.0:8080 |
API bind address(es). Supports comma-separated list, e.g. 0.0.0.0:80,0.0.0.0:8080 to run on port 80 like Valetudo. |
VALETINI_MAP_POLL_SECS |
5 |
Live-map I-frame poll interval; 0 disables map polling. |
VALETINI_MAP_MODE |
poll |
cloud = impersonate the Xiaomi cloud for map push (works on r2228o); poll = local I-frame poll (empty on r2228o). |
VALETINI_CLOUD_SECRET |
(auto) | 16-byte/32-hex cloud secret; auto-read from /mnt/private/ULI/factory/key.txt if unset. Needed for cloud mode. |
VALETINI_CLOUD_IP |
127.0.0.1 |
IP the robot reaches our cloud servers on (localhost on-device). |
VALETINI_CLOUD_BIND_IP |
0.0.0.0 |
Bind address for the gslb/dummycloud/FDS servers. |
Deploy & run side-by-side with Valetudo
Your robot is rooted and already runs Valetudo. You can run valetini next to Valetudo to compare them before cutting over. Note: only one process can own the miio local socket / the control path at a time in practice, so the safe pattern is stop-Valetudo → run-valetini → (revert by restarting Valetudo).
# 1. Copy the binary + integration scripts to the robot
make deploy VACUUM_IP=192.168.1.31 # -> /data/valetini/{valetini,tts_handler.sh,_root_postboot.sh}
# 2. On the robot, stop Valetudo for the test window
ssh root@192.168.1.31
# (however your setup starts it, e.g.)
killall valetudo 2>/dev/null
# 3. Run valetini (foreground, to watch logs)
cd /data/valetini
VALETINI_TOKEN=$(head -c 16 /data/config/miio/device.token) \
VALETINI_DID=$(sed -n 's/^did=//p' /data/config/miio/device.conf) \
./valetini
Then from your workstation:
curl http://192.168.1.31:8080/api/info
curl http://192.168.1.31:8080/api/v2/robot/state/attributes
curl http://192.168.1.31:8080/api/map | head -c 200
Revert: stop valetini (Ctrl-C / killall valetini) and restart Valetudo. Nothing valetini does
is persistent unless you install the boot script (below).
Making it permanent
Once you're happy, replace the Valetudo startup with valetini in the boot script — a ready-made
deploy/_root_postboot.sh does this (it launches valetini instead of Valetudo and keeps the
vacuumstreamer AV stack). Review it, then install it as your /data/_root_postboot.sh.
Web UI & Feature Set
Valerust embeds a lightweight, highly optimized single-page web dashboard (valetini) directly into the binary with zero build steps or external dependencies:
- Interactive Map Tools:
- Segment Cleaning: Click room segments to select in custom cleaning order with 1×/2×/3× pass selectors. Segment renaming is firmware-backed (
MapSegmentRenameCapability). - Ad-Hoc Zone Clean ("Draw to Clean"): Drag-to-draw rectangular zones on canvas with real-time dimensions badge (
m \times mand aream^2), pass selectors, and immediate dispatch (ZoneCleaningCapability). - Go-To Location: Drop interactive target pins with pulsating radar rings on the canvas to navigate the robot to any coordinate (
GoToLocationCapability). - Named Zone Presets: Save custom multi-zone cleaning templates to persistent device storage (
ZonePresetCapability).
- Segment Cleaning: Click room segments to select in custom cleaning order with 1×/2×/3× pass selectors. Segment renaming is firmware-backed (
- In-UI Virtual Restrictions Editor:
- Visual interactive canvas editor to create, resize, delete, and save No-Go Areas (red dashed), No-Mop Areas (purple dashed), and linear Virtual Walls (
CombinedVirtualRestrictionsCapability).
- Visual interactive canvas editor to create, resize, delete, and save No-Go Areas (red dashed), No-Mop Areas (purple dashed), and linear Virtual Walls (
- Consumables Health & Cleaning History:
- Hardware lifespan monitors with color-coded health gauges (Main brush, Side brush, Filter, Sensors, Mop pads, Detergent) and dynamic hero warning chips (
ConsumableMonitoringCapability). - Durable on-device cleaning run history logging duration, area, and status, automatically recorded on cycle completion (
CleanHistoryCapability).
- Hardware lifespan monitors with color-coded health gauges (Main brush, Side brush, Filter, Sensors, Mop pads, Detergent) and dynamic hero warning chips (
- High Performance & Resource Efficiency:
- Asset Pre-Compression: Compile-time gzip pre-compression reduces initial Web UI payload from ~120 KB to 31.6 KB (74% bandwidth savings).
- HTTP 304 Caching: Fast ETag validation for HTML and branding assets; reloads take 0 bytes payload transfer.
- Differential P-Frame Map Streaming: Parses incremental movement deltas (
FRAME_TYPE_P = 80) in<0.01 mswithout re-processing static segment layers, keeping CPU utilization under 1%. - Ultra-low footprint: Entire binary is ~2.0 MB static ARM64 musl; runtime RAM footprint is ~21 MB (vs 150–250 MB for Node.js Valetudo).
Live map
Two modes, selected by VALETINI_MAP_MODE:
-
cloud(validated working on the r2228o) — valetini impersonates the Xiaomi cloud (gslb :80 + dummycloud :8053 + FDS :8079). The robot uploads its map to us; we decode it to Valetudo-compatible JSON. This is the mode that produces a live map on this device. -
poll(default) — local-socket I-frame polling. On the r2228o the local poll returns an emptyMAP_DATA(the map only comes via the cloud path), so usecloudfor a real map here. -
GET /api/map— current map snapshot (Valetudo-compatibleValetudoMapJSON). -
GET /api/map/sse— Server-Sent Events stream; emits the current map immediately, then a fresh map whenever the robot uploads one.
Because the JSON matches Valetudo's shape, a Home Assistant Valetudo map card can render it directly.
Verified end-to-end on the real r2228o (2024): gslb signed redirect → dummycloud handshake → presigned-URL request → FDS upload → decode →
/api/mapreturns a real map (6 room segments + robot position). Two operational notes from that validation:
- After stopping Valetudo,
killall miio_clientonce so it re-resolves its cloud to our servers (the deploy boot script handles this cleanly on reboot).- A docked/idle robot does not spontaneously upload a map — it uploads when there's map activity (a cleanup). Start a clean to get the first map.
Web UI
valetini serves its own web UI at / — point a browser at http://<robot-ip>:8080/.
- Self-contained: a single
webui/index.html(vanilla JS/CSS, no framework, no build step). It's embedded into the binary at compile time viainclude_str!, so there are no extra files to deploy — the one static binary is the whole app. - Own MIT code: the design/feature set is inspired by Valetudo's UI, but none of Valetudo's (AGPL) frontend source was copied. See the License section.
What it shows / does (all over the same Valetudo-compatible API the HA cards use):
- Live map — renders the
ValetudoMapJSON on a<canvas>(decompresses the RLEcompressedPixels, draws floor → segment → wall layers, then robot/charger/path entities). It loads the current map fromGET /api/mapand then subscribes toGET /api/map/ssefor live updates, so the map refreshes whenever the robot uploads a new one. - Status bar — polls
GET /api/v2/robot/state/attributes(state, battery, mode, dock). - Controls — start / pause / stop / dock / locate, plus mode / fan-speed / water-usage presets.
- Rooms — click a segment on the map (or its button) to select it, then clean the selection
(
MapSegmentationCapability). - Quirks — lists and edits device quirks (
QuirksCapability). - Settings — full in-UI dashboard for MQTT broker configuration, friendly robot naming, speaker volume slider with chime locator, and system diagnostics (Wi-Fi SSID, RSSI dBm, uptime, RAM, firmware version).
Because the UI only talks to the REST/SSE API, it works identically whether you're running valetini side-by-side for testing or as the installed boot service.
On the r2228o the live map only appears in
cloudmap mode (see "Live map" above), and a docked/idle robot won't upload one until there's cleaning activity — start a clean to populate the map.
Camera / audio / TTS — vacuumstreamer integration
valetini does not reimplement video/audio. It reuses vacuumstreamer unchanged for the hard part (RTC video interception → go2rtc → WebRTC/RTSP), and only re-points vacuumstreamer's control proxy from Valetudo's API to valetini's (identical routes).
What changed vs. upstream vacuumstreamer (diff summary)
KEPT 100% AS-IS (native, no dependency on the control backend):
vacuumstreamer.c / .so LD_PRELOAD video hook → TCP :6969
video_monitor + ava conf stock vendor binary
go2rtc + go2rtc.yaml WebRTC/RTSP/still-frame (:1984/:8554/:8555)
play_pcm.sh speaker backchannel (aplay)
libc.so.6_r2228 bundled glibc for the C binary
CHANGED:
deploy/tts_handler.sh
- NATIVE endpoints unchanged: /say /play /play_raw /play_ogg /test
/volume[/N] /mic_volume[/N] /video_quality[/PROFILE] /drive/speed[/N]
- PROXY endpoints: base URL VALETUDO="http://127.0.0.1" →
VALETINI="http://127.0.0.1:8080"
(the /api/v2/robot/... routes are byte-for-byte the same, so only the
base URL differs). Added a /quirk/UUID/VALUE set form and /auto_empty.
deploy/_root_postboot.sh
- starts /data/valetini/valetini instead of /data/valetudo (+ maploader).
- exports VALETINI_TOKEN/DID from the on-device miio files.
- vacuumstreamer AV launch block unchanged.
Old → new endpoint table (for your Home Assistant config)
All HA rest_command/sensor URLs stay the same (they point at the tts_handler on :6971); the
change is purely which backend tts_handler proxies to. This table shows the backend mapping:
tts_handler (:6971) |
Backend route (Valetudo and valetini) |
|---|---|
GET /status |
GET /api/v2/robot/state/attributes |
GET /start /stop /pause /home |
PUT /api/v2/robot/capabilities/BasicControlCapability {action} |
GET /mode, /mode/MODE |
GET state / PUT .../OperationModeControlCapability/preset {name} |
GET /fan_speed, /fan_speed/S |
PUT .../FanSpeedControlCapability/preset {name} |
GET /water_usage, /water_usage/L |
PUT .../WaterUsageControlCapability/preset {name} |
GET /drive/enable /disable, POST /drive/move |
PUT .../HighResolutionManualControlCapability |
GET /segments, POST /segments/clean |
GET/PUT .../MapSegmentationCapability |
GET /statistics |
GET .../TotalStatisticsCapability + .../CurrentStatisticsCapability |
GET /consumables, /consumables/reset/T |
GET/PUT .../ConsumableMonitoringCapability[/T] |
GET /quirks, POST /quirks, GET /quirk/ID, GET /quirk/ID/VAL |
GET/PUT .../QuirksCapability |
GET /auto_empty (added) |
PUT .../AutoEmptyDockManualTriggerCapability |
(native) /say /play* /test /volume* /mic_volume* /video_quality* /drive/speed* |
handled in-shell; no backend |
Known gaps (verify on device — see SPEC.md §12.4)
/obstacle_avoidance— the L10s Ultra's obstacle avoidance sits under an AI-camera capability with a different name than the genericObstacleAvoidanceControlCapabilityvacuumstreamer expects. Not yet mapped; that endpoint may return unsupported./carpet_mode,/obstacle_images,/child_lock,/dnd,/auto_empty_interval— these capabilities are specced but not all are wired into valetini's API yet (control set focuses on the core clean/mode/fan/water/segments/quirks/stats path). Extendapi/+RobotModelas needed./auto_empty_intervalvalue names (normal|frequent|every_clean) need confirming against the device.
What a second-model port would touch
Only a new models/<device>/ crate implementing RobotModel (+ its own map decode if it streams
maps). core/, api/, and the binary wiring are model-agnostic. The Dreame-specific things that a
second model would NOT share are enumerated in SPEC.md §10 (packed operation-mode byte, miio
framing, quirk encodings via MISC_TUNABLES/pseudo-service 99, map binary format, etc.).
License
MIT for valetini's own code. Note the reference implementations studied during development
(Valetudo — AGPL-3.0; dreame-vacuum; vacuumstreamer) were used only as behavioral references; no
source was copied. The deploy/ scripts are forks of vacuumstreamer's shell scripts and carry that
project's licensing.