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.
17 KiB
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.doshould return the bridge's own IP for bothEcoMsgNewandEcoUpdate). - 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)
{
"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←GetLifeSpanvalforSideBrush/Brush/DustCaseHeap.lifespan_total.*←total(unit unknown; all three were 365). Poll on every READY and whensend_commandget_lifespanis used.schedules← replace from everySched2orGetSchedresult (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. Onlytype='auto'was stored in a schedule.- HA's MQTT vacuum state payload has
stateand optionalfan_speedonly.batteryis not a legalsupported_featuresvalue. Battery and consumables show up as attributes. Optional retained sensor discovery, same device identifiers, can publish{base}/{did}/battery(and the three consumable percents) withdevice_classbatteryfor the battery sensor.
4. Schedule CRUD over MQTT (bridge extension)
send_command JSON payloads ({"command": "…", "k": "v"} — HA flattens params):
{"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)
{"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):
- 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 answersresult. No push was seen in the second before the next step. - Send
SetTime(current epoch + tz). In both boots the first push was an empty or populatedSched2about 100 ms after this result. - Fan out
GetBatteryInfo,GetCleanState,GetChargeState,GetCleanSpeed,GetSched, andGetLifeSpanforSideBrush,Brush, andDustCaseHeap. Give each a distinct ctl id. - 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.
{
"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.