Unclouding the Ecovacs O500

Root access, TLS interception, and the path to local control

Philipp A. Baer

October 26, 2026

About

Computer Science · Security · DevSecOps · Robotics

Keep things local

I try to keep things off the cloud as much as possible.

  • Local services: Home Assistant, ESPHome, Frigate for video, Forgejo for Git.
  • Unclouded devices: Dreame vacuum with Valetudo; Sonoff NSPanel with NSPanelEasy.
  • Open-source projects: WallDisplay · WaterRower Local BLE.

One device remains to uncloud: the Ecovacs O500 Panorama.

The Ecovacs O500 Panorama

What would it take to make an owner-controlled mower work without Ecovacs services?

  • Primary goal: control the robot locally (start, stop, and status).
  • Secondary goal: local map storage and retrieval, add a Bumper-like service [2].
  • Maybe future: local video streaming (it has a separate Kinesis / STUN / ICE path).

Understand the TLS and MQTT paths, authentication / topic semantics, firmware and boot chain. Get a root shell on the device first.

Broke the debug interface in the process. Mower is still functional, UART echo prevents reliable command execution.

IMAGE SLOT · Ecovacs O500 Panorama in the yard; landscape photo

Cloud control: what we observed

G phone ECOVACS HOME on the phone ha Home Assistant (local instance) api NG-IoT API phone->api HTTPS POST observed by Frida mower GOAT O500 Hydra jmq Regional MQTT endpoint ha->jmq Working MQTT control GOAT · 300lc5 video GOAT O500 video mower->jmq TLS / 443 MQTT 3.1.1 observed media Kinesis / STUN services video->media TLS + STUN / ICE api->jmq App-to-MQTT handoff inferred

Android · Frida observed

  • POST /api/iot/endpoint/control [1]
  • Android · 0.0.22 · start/stop · auto
  • Auth and response not captured.

Home Assistant · working

  • GOAT O500 (300lc5) via Ecovacs MQTT [1, 3].
  • GOAT command topic: iot/p2p/clean [3].

Inferred: Android API-to-MQTT handoff.

Cloud control: connection overview

Path Endpoint type Route Finding
App control HTTPS API Android → NG-IoT Frida observed POST
Response not captured
HA control MQTT over TLS Home Assistant → Ecovacs MQTT → O500 (300lc5) Control works through Ecovacs cloud
Video Kinesis + STUN/ICE O500 → video services Separate cloud path

Still inferred: app API → MQTT handoff [1].

Untested: control with Ecovacs services unavailable.

The mobile app

Test setup: Rooted Android (LineageOS) · ECOVACS HOME 3.14.0 · Frida [1]

G cluster_process APP PROCESS · Frida observers http OkHttp request request HTTP method + route JSON keys + types http->request observe

Control API: POST api-ngiot.dc-eu.ww.ecouser.net/api/iot/endpoint/control [1]

Observed envelope: header.m=request · header.channel=Android · header.ver=0.0.22

Probe:

  • TLS 1.3, Let’s Encrypt certificate.
  • App trust remains untested (pinned or not). No client certificate or private key found.
  • Body-level authentication unknown (no header names visible at OkHttp hooks)

The mobile app: Start/Stop request shape

POST /api/iot/endpoint/control

body.data.act              start | stop
body.data.content.type     auto

Frida observed the app constructing this request shape. The API itself was not tested, and no response was captured.

Other observed body.data keys: name, schedAct, sid, subsets, using (values withheld).

Analysis paused

  • Home Assistant as well as the robot itself interact only via MQTT.
  • Is the IoT API worth being inspected at all?

The mobile app: What public research adds

  • Working O500 path: Home Assistant controls the GOAT O500 via MQTT, class 300lc5 [1, 3].
  • GOAT-specific Home Assistant integration [3]
    • Send control to iot/p2p/clean, not iot/p2p/clean_V2
    • Handles pushed charge and schedule state.
  • 2025 community capture: same /api/iot/endpoint/control, clean_V2 request family [4].
  • It records start, pause, resume, stop, and go (iOS, China-region).

The Android app’s HTTPS endpoint remains a Frida request-shape finding [1]. Ecovacs’ Open Platform documents a separate API [5].

Find the console

Service connector and UART signal-conditioning circuit. Red and blue markers identify pin 1 at the lower-right contact and pin 2 at the upper-right contact. Debug Head (Robot) UART adapter (PC)

10-pin header

Pin Function
1 3.3 V rail
2 X5 UART TX
3 GND
4 X5 UART RX
7 Baize UART TX
8–9 Unassigned
10 1.8 V rail

X5 console [1]

Horizon X5 ASIC BootROM
U-Boot 2022.10 · Linux 6.1
Ubuntu 22.04 LTS · systemd
console=ttyS0,115200n8
  • Pins 2 TX / 4 RX · 115200 8N1
  • Interrupting autoboot reaches Hobot>.

Serial link

  • 3.3 V · 115200 8N1 · common GND
  • X5 TX: pin 2 · X5 RX: pin 4

Inside the firmware

G hardware Horizon X5 · hardware boot U-Boot 2022.10 · A/B selection + AVB hardware->boot os Linux 6.1 + Ubuntu 22.04 LTS + systemd boot->os apps ROS + Ecovacs services eco_main_tools · Wi-Fi Manager · Qconnectmanager Camera Provider · Hydra · ADB os->apps

U-Boot selects an A/B slot and verifies it [1, 6].

  • system_a/b is read-only under dm-verity [1, 6]
  • /data is a separate writable userdata partition [1].

Test 1: Temporary boot parameter

Question: can Linux start a maintenance shell from a one-boot kernel argument?

  1. U-Boot: append init=/bin/sh to the boot arguments temporarily in RAM.
  2. Boot the known _a slot with hb_avb_helper.

We skipped eco_ab_select to avoid updating the A/B boot-attempt record.

Outcome: no shell obtained

UART echoed id and Ctrl-C, but returned no prompt or command output. The echo does not confirm that the shell started. No flash or boot files changed; power-cycle restored the normal environment [1].

Test 2: use the bundled recovery image

boot_a is a FIT bundle with two boot configurations [1, 8]:

Configuration Contents
boardid-0x2000 Normal mower boot
boardid-0x010202 x5-evb-v2-recovery with a ramdisk

We loaded boot_a into RAM at 0x90000000, then selected recovery:

bootm 0x90000000#boardid-0x010202

This ran the bundled recovery configuration for one boot; it did not install a new bootloader or write the image to flash.

Neither temporary boot gave us a shell

The init=/bin/sh attempt echoed id and Ctrl-C, but returned no prompt or command output.

The recovery configuration gave no UART output, USB device, or normal LAN address.

These tests were inconclusive—not root access. Power-cycle restored normal boot.

Find the boot hook from U-Boot

Read-only inspection: we examined selected files on system_a at U-Boot to trace the startup sequence [1]. No full image dump or system-partition write was involved.

U-Boot tools: ext4ls lists files; ext4load reads them into RAM; md.b displays bytes.

Files inspected:

autostart.service · /usr/bin/autostart.sh
serial-getty@.service · /usr/bin/serial_tty.sh

What they showed:

  • autostart.service runs after ROS and invokes /usr/bin/autostart.sh.
  • The script launches /data/autostart/*.sh with start in the background.
  • No User= override: the system service runs as root.

Why serial login was unavailable

The boot log: serial-getty@ttyS0 started, then stopped. No login prompt [1].

On system_a, serial-getty@.service calls /usr/bin/serial_tty.sh:

if [[ $1 != "ttyS0" || -f /data/serial_to_be_run ]]; then
    /sbin/agetty ... "$1" "$2"
fi

The marker enables agetty but supplies no credentials. I left it absent.

The root shell uses a separate hook: /data/autostart/*.sh, launched after ROS [1]. Neither this hook nor its installation depends on the failed boot-parameter experiment.

Export userdata over USB

Reach U-Boot (UART)

  • Interrupt autoboot over serial and wait for Hobot>
  • Send ums 1 mmc 0:10 at the prompt [1].

Export userdata (USB)

  • U-Boot presents the eMMC filesystem as USB mass storage.
  • The host detected a 10.5 GiB ext4 disk at /dev/sda.

Install the hook (USB)

  • Mount the disk on the host
  • Copy the startup script, sync and unmount before stopping UMS.

Successful route to the root shell

  1. Reach U-Boot: connect UART and interrupt autoboot at Hobot> [1]. No boot-parameter change is needed.
  2. Install the hook: run ums 1 mmc 0:10. Mount the exported ext4 userdata on the host; copy o500/tools/goat_serial_bootstrap.sh to autostart/10-serial-root.sh, set mode 0700, then sync and unmount cleanly.
  3. Boot normally: after ROS starts, autostart.service runs the script as root. It stops serial-getty, configures /dev/ttyS0, and launches Bash. Verify: GOAT# id → uid=0(root).

Separate failed test: init=/bin/sh is unrelated. UMS mapping is firmware-specific. The hook persists in /data; remove it through UMS.

ADB was not a useful route

After the root-shell path was established, the USB-A port was checked: it enumerated as hobot / xj3, but ADB remained unauthorized [1].

No ADB shell or filesystem commands were attempted; this route was not investigated further.

Get Hydra talking locally

Hydra is the mower process that connects to Ecovacs’ MQTT endpoint [1].

  • Endpoint: jmq-ngiot-*.
  • Hydra config option mqCa points to a writable TLS trust file: /data/certs/master.pem.

We redirected the MQTT endpoint to a local TLS listener (signed by a short-lived test CA) and placed both certificates (Ecovacs, ours) in /data/certs/master.pem:

  • ❌ Ecovacs CA first: TLS handshake failed.
  • ✅ Test CA first: TLS 1.3 handshake succeeded.

Restored the original trust file, removed the redirect. MQTT authentication or commands not tested.

MQTT framing observed

With the TLS trust path established, our metadata probe acted as a permissive MQTT peer, not a full broker [1]:

  • It accepted Hydra’s MQTT 3.1.1 CONNECT without checking the username/password.
  • It did not request a robot client certificate; the live Ecovacs endpoint does.
  • Hydra sent four QoS-1 subscriptions; the probe acknowledged them.

Implication: a local broker can skip Ecovacs client-certificate and MQTT credential checks. Bumper supports a dedicated MQTT listener, but O500 compatibility has not been tested [2].

Still unknown: command and status topics, payloads, and a working start/stop/status loop. The stock app’s HTTPS API is separate.

UART RX failure ended this test run

Before the interruption

  • We installed a /data/autostart bootstrap and verified a root shell on normal boot.
  • We redirected Hydra to a local TLS/MQTT probe and observed CONNECT plus four subscriptions.

Then: UART RX stopped accepting commands. Normal boot still reached GOAT#, but commands returned no output. The echo source and electrical cause remain unverified [1].

Open question: Bumper supports a dedicated MQTT listener, but O500 topic and status handling have not been tested [1, 2].

What we proved and what remains

Proven

  • /data/autostart yields a root shell.
  • Successful local TLS 1.3 handshake.
  • A stub MQTT peer
    • accepted CONNECT,
    • acknowledged four subscriptions,
    • skipped AuthN/AuthZ.

Still unknown

  • MQTT command and status topics and payloads.
  • Whether Bumper can work with the O500.
  • Whether local start, stop, and status are possible.
  • How the stock app hands off to MQTT; its API is separate HTTPS.

Afterward: RX damage blocked maintenance access

The earlier UART setup reached U-Boot, accepted commands, and enabled the root-shell installation. After the RX pin was damaged, maintenance input stopped working.

Normal boot still reaches GOAT#, but id produces matching bytes on the adapter RX without visible command output. The echo source is unverified; the command may not be reaching Bash.

The bootstrap remains installed. UART cannot currently remove it, UMS cannot be started, and USB ADB remains unauthorized [1].

Questions

What result would change your mind about local control?

Literature · research & community

Literature · platform & specifications

Slide deck changelog

  • 26 Oct 2026 · feat: Generate deck changelog and align graph typography (#35)
  • 26 Oct 2026 · fix(o500): open slides outside VitePress client router (#34)
  • 26 Oct 2026 · feat: add walldisplay, revise home, improve o500 presentation (#33)
  • 10 Oct 2026 · fix: open the title slide directly (#29)
  • 26 Sep 2026 · feat: improve site and integrate o500 research (#27)