No description
  • Rust 68.8%
  • HTML 25.3%
  • Shell 5.6%
  • Makefile 0.3%
Find a file
Andreas A Reitz 68875b08c9 valetini: Valetudo-compatible firmware for the Dreame L10s Ultra
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.
2026-11-04 21:05:37 +01:00
.cargo valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
api valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
assets/branding valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
core valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
deploy valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
docs valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
firmware valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
models/dreame-l10s-ultra valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
mqtt valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
scripts valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
valetini valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
webui valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
.gitignore valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
Cargo.lock valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
Cargo.toml valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
Cross.toml valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
DEPLOYMENT.md valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
Makefile valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
README.md valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
SPEC.md valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
TESTING.md valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00
THIRD-PARTY-LICENSES.md valetini: Valetudo-compatible firmware for the Dreame L10s Ultra 2026-11-04 21:05:37 +01:00

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 in TESTING.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_json is pinned (see Cargo.lock) below the version that pulls a build-script dependency which segfaults under QEMU during cross-compilation. If you bump it and make build fails with a build-script SIGSEGV, that pin is why. env_logger also 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 m and area m^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).
  • 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).
  • 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).
  • 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 ms without 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 empty MAP_DATA (the map only comes via the cloud path), so use cloud for a real map here.

  • GET /api/map — current map snapshot (Valetudo-compatible ValetudoMap JSON).

  • 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/map returns a real map (6 room segments + robot position). Two operational notes from that validation:

  1. After stopping Valetudo, killall miio_client once so it re-resolves its cloud to our servers (the deploy boot script handles this cleanly on reboot).
  2. 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 via include_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 ValetudoMap JSON on a <canvas> (decompresses the RLE compressedPixels, draws floor → segment → wall layers, then robot/charger/path entities). It loads the current map from GET /api/map and then subscribes to GET /api/map/sse for 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 cloud map 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 generic ObstacleAvoidanceControlCapability vacuumstreamer 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). Extend api/ + RobotModel as needed.
  • /auto_empty_interval value 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.