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.
315 lines
17 KiB
Markdown
315 lines
17 KiB
Markdown
# 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.
|