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

17 KiB
Raw Permalink Blame History

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)

{
  "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):

{"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):

  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.

{
  "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.