Files
gronod ca723d15cf Publish sanitized protocol captures and documentation
Replace device credentials and identifiers consistently across packet
captures, documentation, and tests so the protocol evidence can be shared
publicly without exposing private device or network identities.
2026-09-25 13:49:11 +01:00

315 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MQTT Bridge — `com:ctl` (XMPP) ↔ MQTT ↔ Home Assistant
> **Privacy notice:** Device serials, MAC addresses, authentication values, controller identifiers, and other identifying values shown here have been consistently replaced with synthetic values.
Maps the robot-facing `com:ctl` protocol (see `PCAP-ANALYSIS.md`) onto an
**existing, supported MQTT control schema**: **Home Assistant's native
`mqtt.vacuum` integration**. No custom HA component, no YAML for the entity —
the bridge publishes a discovery config and HA does the rest.
```
[Robot N95] ──XMPP com:ctl──▶ [BRIDGE: local XMPP server + MQTT client]
│ ▲
▼ │
[MQTT broker] ◀──▶ [Home Assistant mqtt.vacuum]
```
Device under bridge: Ecovacs **Deebot N95** (`wukong` platform, class `155`,
serial `E2998877665544332211`, JID `E2998877665544332211@155.ecorobot.net/atom`).
**Why this schema:** `mqtt.vacuum` is built into HA (used by ~half of MQTT
installs, e.g. Valetudo). Its vocabulary — start/pause/stop/dock/spot/locate/
fan-speed/state — maps almost 1:1 onto the `com:ctl` dialect of this robot.
Valetudo's own schema was considered and rejected: it assumes a rooted,
mapping-capable robot (segments, maps, layers) that the N95 doesn't have.
**Prerequisites for a working deployment**
- DNS override: `lbo.ecouser.net` → the bridge host (PCAP-ANALYSIS §2).
- Bridge listeners on that host: HTTP `:8007` (`/lookup.do`), HTTP `:8005`
(`/products/…/latest.json` → 404), XMPP `:5223` (§14 checklist of
PCAP-ANALYSIS covers all three; `lookup.do` should return the bridge's own
IP for both `EcoMsgNew` and `EcoUpdate`).
- An existing MQTT broker; Home Assistant's MQTT integration pointed at it
with discovery enabled (default prefix `homeassistant`).
- Bridge config: broker host/credentials, `{base}` prefix, per-robot `{did}`.
---
## 1. Topic layout
Prefix per robot so the bridge can serve several: `{base}` defaults to
`ecovacs`, `{did}` = robot serial.
| Topic (relative to `{base}/{did}/`) | Dir | Payload | Purpose |
|---|---|---|---|
| `command` | HA→bridge | string | `start`, `return_to_base`, `stop`, `clean_spot`, `locate`. `pause` is not advertised until `act="p"` is verified |
| `set_fan_speed` | HA→bridge | string | `standard`, `strong` |
| `send_command` | HA→bridge | string or JSON | bridge extension commands (§5) |
| `state` | bridge→HA | JSON | `{"state": …, "fan_speed": …}` |
| `json_attributes` | bridge→HA | JSON | battery, consumables, schedules, last error |
| `availability` | bridge→HA | `online`/`offline` | XMPP session liveness (retained) |
| `raw` | bridge→diagnostics | JSON containing direction, timestamp, raw XML and parse status | unknown/unparsed protocol diagnostics |
| `command_result` | bridge→diagnostics | JSON | `sid`/`cid`, ack/result phase, `ret` and `errno` |
Bridge-internal command surface (not HA-facing): any MQTT client can inject
stanzas via the `send_command` `raw` extension (§5).
HA discovers the entity from a **retained** message on
`homeassistant/vacuum/{unique_id}/config` — §7.
## 2. Commands — MQTT `command` topic → `com:ctl`
Each row shows the `<ctl>` element to wrap in
`<iq type="set" to="{bot-jid}" from="{bridge-jid}" id="{sid}">
<query xmlns="com:ctl">…</query></iq>` (§6 of PCAP-ANALYSIS).
| MQTT payload | `<ctl>` to send | Effect / bot response |
|---|---|---|
| `start` | `<ctl td="Clean" id="{cid}"><clean type="auto" speed="{fan}" act="s"/></ctl>` | auto clean at current fan speed → `ret='ok'` + `CleanReport auto` |
| `pause` | `<ctl td="Clean" id="{cid}"><clean type="{current}" speed="{fan}" act="p"/></ctl>` | ⚠ `act="p"` is sucks-documented, **not seen in captures** — verify on this firmware before enabling `pause` in `supported_features`; fallback = don't advertise it |
| `stop` | `<ctl td="Clean" id="{cid}"><clean type="stop" speed="{fan}" act="h"/></ctl>` | halt → `ret='ok'` + `CleanReport stop` |
| `return_to_base` | `<ctl td="Charge" id="{cid}"><charge type="go"/></ctl>` | `ret='ok'` (no errno) + `CleanReport stop` + `ChargeState going` within ~50 ms; `SlotCharging` when it arrives (21 s later in one capture) |
| `clean_spot` | `<ctl td="Clean" id="{cid}"><clean type="spot" speed="{fan}" act="s"/></ctl>` | spot clean |
| `locate` | `<ctl td="PlaySound" sid="0" id="{cid}"/>` | find-me beep → `ret='ok'` |
| `standard` / `strong` on `set_fan_speed` | `<ctl td="SetCleanSpeed" id="{cid}" speed="{v}"/>` | `ret='ok' errno=''` (speed is not echoed). During an active clean a `CleanReport` follows at the new speed. While stopped, apply the speed on `ret='ok'` even if no report arrives |
`{fan}` = last known `speed` (`standard` default). `{cid}` = fresh zero-padded
8-digit id, unique among commands that have not yet returned. The stanza ack
matches `iq/@id`; the ctl result echoes `ctl/@id`. Publish `command_result`
for both phases.
## 3. Reports — `com:ctl` pushes → MQTT state
The bot pushes `<iq type="set"><query xmlns="com:ctl"><ctl td="R" …/></query>`
to the controller JID it learned from `from=` — i.e. the bridge's own virtual
controller JID (`{bridge-uid}@ecouser.net/{resource}`). Push stanzas carry
`to=` but no `from=` and are **never acked** by the controller (verified in
capture) — the bridge must not wait for or send `iq result` on them.
| `td` | Wire payload | MQTT output |
|---|---|---|
| `CleanReport` | `<clean type='T' speed='S' st=' ' rsn=' '/>` | update `fan_speed` and `clean_type`. `auto`/`border`/`spot`/`singleRoom` → cleaning; `stop` → docked only if charge state is still `SlotCharging`, otherwise idle. `st`/`rsn` were a single space in every report |
| `ChargeState` | `<charge type='SlotCharging'/>` | `state` ← docked, `charge_state` ← `SlotCharging` |
| `ChargeState` | `<charge type='going'/>` | `state` ← returning, `charge_state` ← `going` |
| `ChargeState` | `<charge type='Idle'/>` | set `charge_state` to `Idle` and re-derive. This leaves `docked`. It does not by itself mean cleaning |
| `BatteryInfo` | `<battery power='076'/>` | `battery_level` integer 76. The bare `<query><battery power='NNN'/></query>` iq (seen twice, own bot iq id, ~70–90 ms after a GetBatteryInfo result) maps the same way and is not acked |
| `error` | `<ctl td='error' errno='103'/>` | `state` stays `error` and `last_error` stays `"103"` until errno `100`. Later CleanReport/ChargeState still update `clean_type`, `charge_state`, and fan speed |
| `error` | `<ctl td='error' errno='100'/>` | clear `last_error`. The next CleanReport/ChargeState (24–56 ms later in the captures) sets the motion state |
| `Sched2` | `<s …/>` children, or none | replace the whole `schedules` array (§4) |
| *(response)* | `ret='ok'`, `errno` only on some commands | one result per cid. `Get*` and `SetCleanSpeed` include `errno=''`; `Clean`/`Charge`/`PlaySound`/`SetTime`/schedule mutations omit `errno`. A missing `errno` with `ret='ok'` is success. `ret='fail'` was not captured. **Result payloads use the same derivation as pushes** |
### 3.1 State derivation rules (HA `state` key)
HA states: `cleaning`, `docked`, `paused`, `idle`, `returning`, `error`.
```
SlotCharging → docked
going → returning
error push (errno≠100) → error (stays error until errno 100, even if a CleanReport follows 24 ms later)
error push errno=100 → clear error (all-clear; the reports that follow set state)
clean type=stop → docked if charge_state is SlotCharging, else idle
clean type=auto|border|spot|singleRoom → cleaning
act="p" accepted → paused (not captured; do not advertise)
no state yet → idle
```
Priority when several facts are current: `error` > `docked` > `returning` >
`paused` > `cleaning` > `idle`. Errno 103 is followed immediately by
`CleanReport stop`; if the report cleared the error, HA would never show it.
Publish the full state JSON (both keys) on every change.
### 3.2 Attribute feed → `json_attributes` (retained)
```json
{
"battery_level": 76,
"side_brush": 68,
"main_brush": 90,
"filter": 70,
"lifespan_total": {"side_brush": 365, "main_brush": 365, "filter": 365},
"clean_type": "auto",
"charge_state": "Idle",
"last_error": "103",
"last_command_error": null,
"schedules": [
{"name": "17901970980846", "on": true, "time": "21:59",
"repeat": "0001000", "flag": "p", "action": {"td": "clean", "type": "auto"}}
]
}
```
Every publish is the **complete** object. A partial object would replace the
retained document. `last_error` is the robot fault beacon (cleared only by
errno 100). `last_command_error` is a ctl `ret='fail'` or a command rejected
while offline, and must not overwrite `last_error`.
- `side_brush` / `main_brush` / `filter` ← `GetLifeSpan` `val` for
`SideBrush` / `Brush` / `DustCaseHeap`. `lifespan_total.*` ← `total` (unit
unknown; all three were 365). Poll on every READY and when
`send_command` `get_lifespan` is used.
- `schedules` ← replace from every `Sched2` or `GetSched` result (n→name,
o→on, t→time, r→repeat with index 0 = Sunday, f→flag, inner ctl→action).
Spaces inside `<s>` are text nodes, not fields. Only `type='auto'` was
stored in a schedule.
- HA's MQTT vacuum state payload has `state` and optional `fan_speed` only.
`battery` is not a legal `supported_features` value. Battery and consumables
show up as attributes. Optional retained sensor discovery, same device
identifiers, can publish `{base}/{did}/battery` (and the three consumable
percents) with `device_class` `battery` for the battery sensor.
## 4. Schedule CRUD over MQTT (bridge extension)
`send_command` JSON payloads (`{"command": "…", "k": "v"}` — HA flattens params):
```json
{"command":"add_sched","name":"{id}","on":"1","time":"21:30","repeat":"0111000","clean_type":"auto"}
→ <ctl td="AddSched" id="{cid}"><sched name="{name}" on="{on}" time="{time}"
repeat="{repeat}"><ctl td="Clean"><clean type="{clean_type}"/></ctl></sched></ctl>
{"command":"mod_sched","name":"{id}","on":"0","time":"21:30","repeat":"0111000","clean_type":"auto"}
→ <ctl td="ModSched" id="{cid}"><ModSched name="{name}"><sched …/></ModSched></ctl>
{"command":"del_sched","name":"{id}"}
→ <ctl td="DelSched" id="{cid}"><DelSched name="{name}"/></ctl>
{"command":"get_sched"}
→ <ctl td="GetSched" id="{cid}"/> (answer lands in json_attributes.schedules)
```
`repeat` = 7-char bitmask, index 0 = Sunday … index 6 = Saturday (verified).
## 5. Other extension commands (`send_command`)
```json
{"command":"move","action":"forward|SpinLeft|SpinRight|TurnAround|stop"}
→ <ctl td="Move"><move action="{action}"/></ctl> (no ctl id — stanza ack only)
`backward` is sucks vocabulary and was not captured. A new Move may follow
another Move with no stop in between. Do not auto-stop; captured bursts were
under about 3 s, so a long-run firmware timeout is untested.
{"command":"clean","clean_type":"auto|border|spot|singleRoom"}
→ <ctl td="Clean" id="{cid}"><clean type="{clean_type}" speed="{fan}" act="s"/></ctl>
{"command":"cancel_return"} → <ctl td="Charge" id="{cid}"><charge type="stopGo"/></ctl>
{"command":"resume"} → <ctl td="Clean" id="{cid}"><clean type="{current}" speed="{fan}" act="r"/></ctl> ⚠ unverified
{"command":"set_time"} → <ctl td="SetTime" id="{cid}"><time t="{epoch}" tz="{h}" tzm="{m}"/></ctl>
{"command":"get_status"} → fan out GetBatteryInfo+GetCleanState+GetChargeState+GetCleanSpeed+GetSched
{"command":"get_lifespan"} → GetLifeSpan ×3 (SideBrush, Brush, DustCaseHeap)
{"command":"raw","xml":"<ctl …/>"} → passthrough, off unless explicitly enabled
```
## 6. Session behavior — register before you can monitor
**Critical:** the bot only pushes reports to the controller JID it has seen in
an inbound `from=` — and that learning is **per-session** (PCAP-ANALYSIS §6.1).
A bridge that only listens will receive *nothing* — not even `Sched2`. So on
**every** robot session reaching READY (`hello world` presence — including
each re-bind after a reconnect):
1. **Announce:** `<iq type="get" to="{bot-jid}" from="{bridge-jid}"><ping
xmlns="urn:xmpp:ping"/></iq>`. The app sent this first (sometimes twice,
a few hundred milliseconds apart). The bot answers `result`. No push was
seen in the second before the next step.
2. Send `SetTime` (current epoch + tz). In both boots the first push was an
empty or populated `Sched2` about 100 ms after this result.
3. Fan out `GetBatteryInfo`, `GetCleanState`, `GetChargeState`,
`GetCleanSpeed`, `GetSched`, and `GetLifeSpan` for `SideBrush`, `Brush`,
and `DustCaseHeap`. Give each a distinct ctl id.
4. Publish `availability = online` (retained) after the announce result.
The status fan-out may still be in flight.
Thereafter: ping the bot `from="{bridge-jid}"` and correlate its iq result.
The real app used ~90 s; **60 s is recommended for the bridge**, with a
10–15 s response deadline. This both keeps the learned JID warm and detects a
black-holed connection independently of the bot's 120 s ping/retry cycle.
On XMPP disconnect/timeout — TCP drop, `</stream:stream>`, or one bridge ping
missing its response deadline — publish `availability = offline` (retained).
The fourth capture proves the bot itself takes ~120 s after its unanswered
ping, then waits ~5 s before reconnecting; don't leave HA falsely online for
that interval. Optional TCP keepalive on the XMPP listener is an additional
signal, not a substitute for iq-result correlation. Also set the bridge's
MQTT **LWT to `…/availability = offline` (retained)** so a dead bridge process
marks the entity unavailable, and publish `offline` on graceful shutdown.
Reconnects come straight to the cached endpoint — no bootstrap. The bridge
sees a fresh TCP connect + full SASL/bind/session handshake and re-runs the
READY sequence above. On a new bind, **atomically replace the old JID session**
even if its half-open TCP socket cannot be closed over the network; close the
old local socket and ignore any later callbacks/data from that obsolete
session generation. Keep the listener IP stable: the robot cannot rediscover
a moved endpoint without rebooting.
MQTT commands arriving while the bot session is down can't be delivered —
drop them and record that on `last_command_error`, not on `last_error`.
Do not queue them.
## 7. HA discovery config (publish retained)
Topic: `homeassistant/vacuum/ecovacs_E2998877665544332211/config`
The component is already named by the topic. Do not put `platform` in this
payload; that key is for device-discovery (`homeassistant/device/...`) only.
Use the serial in the object id so a second robot does not collide.
Resend the retained payload when the bridge's MQTT session reconnects and
when Home Assistant publishes `online` to `homeassistant/status`.
```json
{
"name": "Deebot N95",
"unique_id": "ecovacs_E2998877665544332211",
"command_topic": "ecovacs/E2998877665544332211/command",
"set_fan_speed_topic": "ecovacs/E2998877665544332211/set_fan_speed",
"send_command_topic": "ecovacs/E2998877665544332211/send_command",
"state_topic": "ecovacs/E2998877665544332211/state",
"json_attributes_topic": "ecovacs/E2998877665544332211/json_attributes",
"availability_topic": "ecovacs/E2998877665544332211/availability",
"payload_available": "online",
"payload_not_available": "offline",
"fan_speed_list": ["standard", "strong"],
"supported_features": ["start","stop","return_home","status","locate",
"clean_spot","fan_speed","send_command"],
"device": {
"identifiers": ["E2998877665544332211"],
"manufacturer": "Ecovacs",
"model": "Deebot N95 (wukong/155)",
"serial_number": "E2998877665544332211",
"connections": [["mac", "02:00:00:00:95:01"]]
}
}
```
(`pause` stays omitted until `act="p"` is verified. `clean_segments` stays
omitted — non-mapping robot. Do not list `battery`; current Home Assistant
rejects it on this integration. State JSON is `state` plus optional
`fan_speed` only.)
## 8. QoS / retain policy
| Topic | QoS | Retain | Why |
|---|---|---|---|
| `state`, `json_attributes`, `availability` (incl. LWT) | 0 | **yes** | HA restarts must see last state |
| discovery `…/config` | 0 | **yes** | required for discovery |
| `command`, `set_fan_speed`, `send_command` | 0–1 | no | commands are momentary |
| `raw`, `command_result` | 0 | no | diagnostic/event streams |
## 9. Verified vs assumed
| Mapping | Basis |
|---|---|
| start/stop/spot/dock/cancel-dock/locate/fan-speed/SetTime/Get*/schedules | **captured** verbatim |
| `paused` via `act="p"`, resume via `act="r"` | sucks vocabulary only — test before enabling |
| error→`error` state | captured (errno 103/100 pushes) |
| `singleRoom` exposed via `send_command.clean` | captured; HA has no native single-room concept |
| `backward` move | sucks vocabulary only |
| errno attribute omitted on Clean/Charge/PlaySound/SetTime/schedules | captured; `Get*` and `SetCleanSpeed` include `errno=''` |
| bare `<battery>` iq, twice | captured; same mapping as `BatteryInfo`, no ack |
| `going` then `SlotCharging` 21 s later; `Idle` on `stopGo` and on leaving the dock | captured |
| schedule inner type other than `auto`, ModSched rename, `act=p`/`act=r` | not captured |
Every HA command in the advertised feature list maps to a stanza in the four
captures, and every observed bot push has an MQTT output above. `pause`,
resume, and `backward` stay out of that list until they are captured on this
firmware.