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.
35 KiB
Deebot N95 Local XMPP-to-Home Assistant MQTT Bridge — Full Specification
Privacy notice: Device serials, MAC addresses, authentication values, controller identifiers, and other identifying values shown here have been consistently replaced with synthetic values.
1. Purpose and conformance
This is the complete, implementation-agnostic specification for a local solution that:
- boots and accepts an already-provisioned Ecovacs Deebot N95;
- replaces the Ecovacs bootstrap, firmware-check and legacy XMPP endpoints;
- monitors and controls the robot through its legacy
com:ctlXMPP dialect; - exposes a Home Assistant entity using the native MQTT vacuum schema;
- exposes capabilities outside that schema through documented auxiliary MQTT commands and attributes; and
- reports unparsed protocol messages without losing the connection.
An implementation conforms when a provisioned N95 can be redirected by the single DNS record in §3, complete the exchanges in §§4–6, reconnect according to §7, and use every mapping in §§9–13.
This specification consolidates findings from four packet captures. Values
marked observed appeared on the wire. Values marked library-known come
from the sucks client but were not exercised by this N95 capture set.
No captured credential is required by a replacement server: accept whatever SASL PLAIN authcid/password the provisioned robot presents. Never log the password or decoded SASL payload.
2. System boundaries and deployment prerequisites
N95 --DNS--> local resolver
N95 --HTTP:8007--> bridge /lookup.do
N95 --HTTP:8005--> bridge firmware 404
N95 --TCP:5223 plaintext XMPP--> bridge
bridge --MQTT--> broker <--MQTT--> Home Assistant
Required:
- the N95 receives a DNS resolver through DHCP option 6;
- that resolver returns the bridge listener address for
lbo.ecouser.net; - the address remains stable for the robot's powered-on lifetime;
- TCP 8007, 8005 and 5223 are reachable at that address;
- the bridge can connect to an MQTT broker used by Home Assistant;
- Home Assistant's MQTT integration and discovery are enabled (default
discovery prefix
homeassistant).
Only lbo.ecouser.net was queried in all boot captures. A compatibility setup
may also override lbo.ecovacs.net, but it is not required by this captured
N95 firmware. The XMPP domain 155.ecorobot.net is a virtual/JID domain and
was never DNS-resolved by the robot.
Observed identity (use configuration/discovery rather than hard-coding where possible):
| Field | Value |
|---|---|
| Product/platform | wukong |
| Device class | 155 |
| Serial / DID / XMPP authcid | E2998877665544332211 |
| Resource | atom |
| Full bot JID | E2998877665544332211@155.ecorobot.net/atom |
| DHCP hostname | deebot |
| MAC in captures | 02:00:00:00:95:01 |
3. Network bootstrap
3.1 DHCP and DNS
The robot uses the first DNS server in DHCP option 6. At boot it sends two near-identical A queries for:
lbo.ecouser.net
The public response used a CNAME then A record, but a direct local A response is sufficient. After obtaining an address the robot opens two parallel TCP connections to port 8007.
Other boot traffic needing no bridge response:
- ARP probes/announcement for its DHCP address;
- IGMPv2 report to
226.1.1.1(and after one reconnect,224.0.0.1).
3.2 Service discovery — HTTP port 8007
The robot sends one HTTP/1.0 POST /lookup.do per service, with no Host
header, and closes the connection. Accept that request. The two connections
are parallel; answer each on its own socket. Parse JSON semantically; the
observed requests are:
{"todo":"FindBest","service":"EcoMsgNew"}
{"todo":"FindBest","service":"EcoUpdate"}
Respond 200 OK, Content-Type: application/json; charset=utf-8, with compact
JSON, no spaces, and port as a JSON number:
{"result":"ok","ip":"<bridge-ip>","port":5223}
{"result":"ok","ip":"<bridge-ip>","port":8005}
The response order follows the request, not a fixed connection order.
EcoMsgNew is the XMPP endpoint and EcoUpdate the firmware endpoint.
3.3 Firmware check — HTTP port 8005
The robot requests:
GET /products/wukong/class/155/firmware/latest.json HTTP/1.0
Connection: Close
Accept: Application/json
Content-Type: application/json
Return HTTP 404 and close. The captured success-path response was
Content-Type: text/plain; charset=utf-8 and body Not Found (9 bytes, no
newline). The robot opened XMPP about 180 ms later and did not retry. A JSON
body and a successful manifest were not observed. Do not serve one.
3.4 Endpoint caching
DNS, /lookup.do, and firmware lookup occur at power-on. After wifi loss or a
half-open XMPP failure, the robot reconnects directly to the cached
EcoMsgNew IP and port. It does not repeat DNS or HTTP discovery. Therefore:
- keep the bridge listener address stable;
- an address change requires a robot reboot to rediscover it;
- the HTTP services need not participate in ordinary XMPP reconnects.
4. XMPP transport and framing
Listen on TCP port 5223. Traffic is plaintext XML despite the conventional port and despite the captured server advertising required STARTTLS. The robot ignores STARTTLS and sends SASL PLAIN immediately. TLS is not required for this model; advertising STARTTLS is optional fidelity.
XMPP is a continuous XML stream, not a complete XML document:
- the opening
<stream:stream>remains unclosed while the session lives; - a stanza can span TCP packets;
- several stanzas can share one TCP packet;
- TCP packet boundaries must never be treated as stanza boundaries;
- handle XML declaration, stream open, complete top-level stanzas and
</stream:stream>incrementally; - tolerate namespace prefixes and attribute ordering differences;
- reject malformed XML safely, but log and ignore unknown valid stanzas rather than terminating the session.
4.1 Complete handshake
C→S <?xml version='1.0'?><stream:stream
xmlns:stream='http://etherx.jabber.org/streams'
xmlns='jabber:client' to='155.ecorobot.net' version='1.0'>
S→C <stream:stream xmlns:stream="http://etherx.jabber.org/streams"
xmlns="jabber:client" version="1.0" id="{opaque-stream-id}"
from="155.ecorobot.net">
S→C <stream:features>
<auth xmlns="http://jabber.org/features/iq-auth"/>
<starttls xmlns="urn:ietf:params:xml:ns:xmpp-tls"><required/></starttls>
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
</mechanisms>
</stream:features>
C→S <auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl'
mechanism='PLAIN'>{base64(NUL + serial + NUL + password)}</auth>
S→C <success xmlns="urn:ietf:params:xml:ns:xmpp-sasl"/>
C→S <?xml version='1.0'?><stream:stream ...
to='155.ecorobot.net' version='1.0'>
S→C <stream:stream ... id="{same-opaque-id}" from="155.ecorobot.net">
S→C <stream:features>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/>
<session xmlns="urn:ietf:params:xml:ns:xmpp-session"/>
</stream:features>
C→S <iq type='set' id='{bot-id}'><bind
xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>atom</resource></bind></iq>
S→C <iq type="result" id="{bot-id}"><bind
xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>{serial}@155.ecorobot.net/atom</jid>
</bind></iq>
C→S <iq type='set' id='{next-bot-id}'><session
xmlns='urn:ietf:params:xml:ns:xmpp-session'/></iq>
S→C <iq type="result" id="{next-bot-id}"/>
C→S <presence><status>hello world</status></presence>
S→C <presence to="{full-bot-jid}"> dummy </presence>
Server rules:
- derive class/domain from initial stream
to=; - decode SASL enough to obtain authcid if desired, but accept all passwords;
- never log the raw or decoded SASL credential;
- use the requested resource (
atom) in the returned JID; - READY is reached after session result and
hello worldpresence; - mimic the literal dummy presence content, including the surrounding spaces;
- repeat the same stream id on the post-SASL stream open; a new TCP connection gets a new id. The robot does not validate either;
iq-authis advertised but not used;- robot iq ids are monotonic for a powered-on robot and continue across XMPP reconnects; do not assume reset or small values;
- server-generated ids are opaque and only need to be unique among outstanding requests.
4.2 Session replacement
There is one active connection per full bot JID. The captured server sent
</stream:stream> and FIN about 20 ms after the new session IQ and before
presence; the robot double-RSTs if that FIN arrives. Doing the replacement at
bind is early relative to that server and is still correct, because the new
connection is authoritative before any command. On a successful new bind:
- atomically replace the JID→connection mapping;
- make the new connection authoritative;
- send
</stream:stream>and close the old local socket if possible; - do not reject the new bind because an old socket still appears connected;
- ignore later reads, closes or callbacks from the obsolete session generation.
The old path can be black-holed, so sending its stream close may never reach the robot. Correctness must not depend on delivery.
5. Controller identity, report registration and pings
The bridge acts as a virtual XMPP controller. Use a stable syntactically valid JID such as:
n95bridge@ecouser.net/homeassistant
The exact localpart/resource are arbitrary. The bot learns where to send
responses and unsolicited reports from the from= of incoming controller
stanzas. This registration is per XMPP session.
Immediately after every READY, send:
<iq id="{sid}" to="{bot-jid}" from="{controller-jid}" type="get">
<ping xmlns="urn:xmpp:ping"/>
</iq>
The bot replies:
<iq type='result' from='{bot-jid}' to='{controller-jid}' id='{sid}'/>
A session that never sees a controller from= produces no Sched2,
BatteryInfo, CleanReport, ChargeState, or error (both reconnects in
capture 3). In the two boots the first push was Sched2, about 100 ms after
the SetTime result, not in the second between the announce ping and
SetTime. Send the ping and then SetTime. Re-announce after every re-bind.
The app's first ping was 12.9 s or 38.8 s after ready; that delay is the user
opening the app, not a robot timer. The app sometimes sent a second ping a
few hundred milliseconds later. One ping is enough.
5.1 Keepalive directions
-
Bot→server, every ~120 s:
<iq from='{bot-jid}' to='155.ecorobot.net' id='{bot-id}' type='get'> <ping xmlns='urn:xmpp:ping'/> </iq>Answer immediately:
<iq type="result" to="{bot-jid}" from="155.ecorobot.net" id="{bot-id}"/> -
Bridge/controller→bot: the observed app used ~90 s. The bridge should use ~60 s and require a matching result within 10–15 s. This provides faster HA availability detection while retaining protocol semantics.
6. com:ctl envelope and correlation
6.1 Command
<iq id="{sid}" to="{bot-jid}" from="{controller-jid}" type="set">
<query xmlns="com:ctl">
<ctl td="{Command}" id="{cid}">...</ctl>
</query>
</iq>
sid: XMPP stanza id chosen by bridge.cid: command correlation id; the app uses random zero-padded 8-digit strings. Generate a unique value among outstanding commands.Moveis the exception: its<ctl>has noid.
6.2 Two-part response
Most commands produce both:
-
stanza receipt ack, correlated by
iq/@id == sid:<iq to='{controller-jid}' type='result' id='{sid}'/> -
command result in a new iq-set, correlated by
ctl/@id == cid:<iq to='{controller-jid}' type='set' id='{bot-id}'> <query xmlns='com:ctl'> <ctl id='{cid}' ret='ok'>...payload, and errno='' on queries...</ctl> </query> </iq>
ret was only ok in these captures. errno='' is present on Get* and
SetCleanSpeed results and omitted on SetTime, Clean, Charge,
PlaySound, AddSched, ModSched, and DelSched. A missing errno with
ret='ok' is success. Move produced only the stanza ack. The app reused
iq ids across later commands and reused a ctl id on an immediate status retry;
each request still had one result. Complete a cid once, and do not treat a
later request that reuses a finished id as a duplicate.
Do not confuse a command response's ret/errno with an unsolicited
td='error' event.
6.3 Unsolicited push
<iq to='{controller-jid}' type='set' id='{bot-id}'>
<query xmlns='com:ctl'><ctl td='{Report}'>...</ctl></query>
</iq>
Observed pushes have to= but no from=. Contrary to normal IQ-set practice,
the captured app sent no iq result ack for pushes; do not wait for or send
one.
Parser quirks:
- a bare
<battery power='NNN'/>can appear directly under<query>without<ctl>, as its own<iq type='set'>with a bot sequence id. It was seen twice, 70–90 ms after a normalGetBatteryInforesult and carrying the same power. Treat it as BatteryInfo and do not ack it; - ignore whitespace text nodes;
- unknown children/attributes must not fail the session;
- publish/log an unparsed diagnostic for unknown valid messages (§13).
7. Half-open failure and automatic recovery
Observed complete behavior when router state was cleared:
- last healthy bot ping was answered;
- 120 s later, next bot ping was transmitted but black-holed;
- the identical TCP segment (same sequence and XMPP id) was retransmitted. With a short RTT the delays were +0.668, +2.342, +5.368, +11.426, +23.468, +47.666 and +95.863 s. An earlier black-holed ping used a longer RTO (+0.92, +3.00, +7.02, +15.04, +31.12, +63.29 s, capture ended during that series). The backoff is adaptive. Do not hard-code it;
- at +120.002 s the robot sent TCP FIN, without XMPP stream close;
- it did not wait for FIN-ACK;
- at FIN+4.999 s it opened a new TCP connection to cached IP:5223;
- full stream/SASL/re-stream/bind/session/presence completed in 0.455 s (0.42–0.48 s on the other reconnects);
- there was no DNS, HTTP lookup, DHCP renewal, special resume token or XEP-0198 stream resumption.
Required bridge behavior:
- mark MQTT availability offline on TCP close, XML stream close, or a bridge ping missing its 10–15 s response deadline;
- accept the new TCP connection and full authentication unconditionally;
- replace stale session state atomically (§4.2);
- after READY rerun the complete initialization in §8;
- do not carry outstanding command correlations into the new session; fail them locally as connection-lost;
- retained state may remain for display, but availability remains offline until reinitialization succeeds.
The server cannot make firmware reconnect sooner once the network is black-holed. Its own ping deadline can make Home Assistant show offline sooner.
8. READY initialization and availability
On every READY, in order:
- controller announce ping (§5), wait for result;
- send
SetTimewith current Unix seconds and local UTC offset; - issue
GetBatteryInfo,GetCleanState,GetChargeState,GetCleanSpeed,GetSched, and threeGetLifeSpanrequests; - process their result payloads through the same MQTT state/attribute mapping used for pushes;
- publish retained availability
onlineafter announce succeeds (the status fan-out may finish asynchronously).
MQTT commands received while unavailable must not be silently queued: reject or drop them and expose a diagnostic/last-error value. Commands are actions; executing stale retained/queued commands after reconnect is unsafe.
Set MQTT Last Will and Testament to retained offline on the robot's
availability topic. Also publish retained offline on graceful bridge
shutdown.
9. MQTT namespace and delivery rules
Let:
root = ecovacs/{did}
did = robot serial
For the observed robot, root is
ecovacs/E2998877665544332211.
| Topic | Direction | Payload | Retain |
|---|---|---|---|
{root}/command |
HA→bridge | HA vacuum command string | no |
{root}/set_fan_speed |
HA→bridge | standard or strong |
no |
{root}/send_command |
HA/client→bridge | extension string/JSON | no |
{root}/state |
bridge→HA | HA vacuum state JSON | yes |
{root}/json_attributes |
bridge→HA | complete attributes JSON | yes |
{root}/availability |
bridge→HA | online / offline |
yes |
{root}/raw |
bridge→diagnostics | normalized JSON containing raw XML and parse status | no |
{root}/command_result |
bridge→diagnostics | command correlation/result JSON | no |
QoS 0 is sufficient and matches native HA defaults. QoS 1 is permissible for inbound command topics, but the bridge must deduplicate redelivery and never retain command messages.
Always republish the complete state object and complete attributes object,
not partial JSON, so retained data remains coherent.
Recommended diagnostic envelopes:
{"direction":"robot_to_bridge","kind":"unparsed","timestamp":"RFC3339",
"session":12,"xml":"<iq ...>...</iq>","reason":"unknown td"}
{"sid":"1234","cid":"01234567","command":"Clean","phase":"result",
"ret":"ok","errno":"","timestamp":"RFC3339"}
Raw XML can contain identifiers; access-control the topic and never include SASL auth data.
10. Home Assistant native vacuum contract
10.1 Discovery
Publish retained to the single-component discovery topic. The component is
the topic, so the payload has no platform key (platform belongs to
homeassistant/device/... payloads). Put the serial in the object id.
homeassistant/vacuum/ecovacs_E2998877665544332211/config
Republish when the bridge reconnects to MQTT and when Home Assistant's birth
message online arrives on homeassistant/status (default).
{
"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"]]
}
}
Do not advertise pause until act='p' is verified on this firmware. Do not
advertise segment cleaning; the N95 is non-mapping and no segments were
observed. Do not list battery in supported_features; current Home
Assistant rejects that value on MQTT vacuum. The state payload is only
state and fan_speed. Battery and consumables are attributes.
Optional, not required for conformance: also discover retained sensors on the
same device for battery_level (device_class battery, unit %) and the
three consumable percents, fed from {root}/battery and
{root}/consumables/{side_brush,main_brush,filter}. Those topics are in
addition to the canonical attribute object.
10.2 State payload
{"state":"docked","fan_speed":"strong"}
Allowed HA states: cleaning, docked, paused, idle, returning,
error.
State derivation and precedence:
| Robot observation | HA state/action |
|---|---|
td=error, errno != 100 |
error; store code |
td=error, errno=100 |
clear active error; wait for/follow subsequent CleanReport/ChargeState |
charge SlotCharging |
docked |
charge going |
returning |
charge Idle |
set charge_state to Idle and re-derive. This ends docked. It does not start a clean |
clean type auto, border, spot, singleRoom |
cleaning |
clean type stop |
docked if last charge state is SlotCharging, otherwise idle |
verified accepted act=p |
paused |
| no state yet after initialization | idle |
When simultaneous information conflicts, precedence is:
active error > docked > returning > paused > cleaning > idle
errno=100 is an observed all-clear event, not a fault: in one capture it was
followed by resumed auto cleaning; in another by stop + SlotCharging.
10.3 Attributes
Example full retained object:
{
"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": null,
"last_command_error": null,
"schedules": [
{
"name":"17901970980846",
"on":true,
"time":"21:59",
"repeat":"0001000",
"flag":"p",
"action":{"td":"clean","type":"auto"}
}
]
}
Home Assistant's native vacuum state schema represents state and fan speed;
battery, consumables, schedules and diagnostics are attributes. A deployment
may additionally publish MQTT sensor discovery entities sourced from these
attributes, but they are optional and must not change the canonical topics or
units here: battery/consumables are integer percent; total is preserved as
reported because its unit was not established.
11. HA commands mapped to XMPP
Wrap every shown <ctl> in the command envelope from §6.1.
| MQTT input | XMPP <ctl> |
Verification |
|---|---|---|
start on {root}/command |
<ctl td="Clean" id="{cid}"><clean type="auto" speed="{fan}" act="s"/></ctl> |
observed |
stop |
<ctl td="Clean" id="{cid}"><clean type="stop" speed="{fan}" act="h"/></ctl> |
observed |
return_to_base |
<ctl td="Charge" id="{cid}"><charge type="go"/></ctl> |
observed |
clean_spot |
<ctl td="Clean" id="{cid}"><clean type="spot" speed="{fan}" act="s"/></ctl> |
observed |
locate |
<ctl td="PlaySound" sid="0" id="{cid}"/> |
observed |
pause |
<ctl td="Clean" id="{cid}"><clean type="{current}" speed="{fan}" act="p"/></ctl> |
library-known, not captured; not advertised |
standard / strong on set-fan topic |
<ctl td="SetCleanSpeed" id="{cid}" speed="{payload}"/> |
observed |
{fan} is last known standard|strong, defaulting to standard until queried.
{current} is the last active clean type.
Expected consequences:
- Clean commands: stanza ack, ctl result, then CleanReport.
- Charge
go: stanza ack, ctl result, CleanReport stop, ChargeState going; later SlotCharging when docked. - SetCleanSpeed: stanza ack and ctl result
ret='ok' errno=''with no speed echo. Store{fan}on that result. During an active clean a CleanReport at the new speed followed; while stopped it did not always follow. - PlaySound: stanza ack + ctl result.
12. Extension commands and exact protocol vocabulary
HA's vacuum.send_command publishes either a string or flattened JSON to
{root}/send_command. Use JSON below.
12.1 Clean modes
{"command":"clean","clean_type":"auto|border|spot|singleRoom"}
<ctl td="Clean" id="{cid}">
<clean type="{clean_type}" speed="{fan}" act="s"/>
</ctl>
Observed clean types, both as commands and as CleanReport values: auto,
border, spot, singleRoom, stop. singleRoom case is exact. SpotArea
exists in library code but was not observed and is not specified as supported
here. Schedule entries stored only auto (§12.7).
Resume (not captured; library-known):
{"command":"resume"}
uses act="r"; expose only after validation.
12.2 Manual movement
{"command":"move","action":"forward|SpinLeft|SpinRight|TurnAround|stop"}
<ctl td="Move"><move action="{action}"/></ctl>
Observed actions: forward, SpinLeft, SpinRight, TurnAround, stop.
backward is library-known but unobserved. Move has no ctl id and only an IQ
stanza ack. A new Move was sent without a stop first, including forward
followed by another forward. Do not insert a stop, and do not retain or
replay movement messages. Captured bursts lasted 0.24–3.1 s; a firmware
timeout beyond that was not observed.
12.3 Dock cancellation
{"command":"cancel_return"}
<ctl td="Charge" id="{cid}"><charge type="stopGo"/></ctl>
Observed command charge values: go, stopGo.
Observed state values, including pushes: Idle, going, SlotCharging.
go produced going within about 50 ms and SlotCharging on arrival (21 s
later in one capture). stopGo produced Idle. Leaving the dock also pushed
Idle.
12.4 Time
{"command":"set_time"}
<ctl td="SetTime" id="{cid}">
<time t="{unix-seconds}" tz="{signed-whole-hours}" tzm="{signed-minutes}"/>
</ctl>
Observed UTC+1 example used tz="1" tzm="0". Compute both components from
local UTC offset; don't copy that example globally.
12.5 Status refresh
{"command":"get_status"}
Fan out:
<ctl id="{cid}" td="GetBatteryInfo"/>
<ctl id="{cid}" td="GetCleanState"/>
<ctl id="{cid}" td="GetChargeState"/>
<ctl id="{cid}" td="GetCleanSpeed"/>
<ctl id="{cid}" td="GetSched"/>
Issue a unique cid for each request.
Expected result payloads:
<battery power='076'/>
<clean type='stop' speed='standard' st='h' t='' a=''/>
<charge type='Idle'/>
<ctl id='{cid}' ret='ok' errno='' speed='standard'/>
<s ...>...</s>
Map these exactly as equivalent pushes: response data is state data, not only a command acknowledgement.
12.6 Consumable lifespan
{"command":"get_lifespan"}
Send three requests:
<ctl id="{cid}" td="GetLifeSpan" type="SideBrush"/>
<ctl id="{cid}" td="GetLifeSpan" type="Brush"/>
<ctl id="{cid}" td="GetLifeSpan" type="DustCaseHeap"/>
Response:
<ctl id='{cid}' ret='ok' errno='' type='SideBrush' val='068' total='365'/>
Mapping: SideBrush→side_brush, Brush→main_brush,
DustCaseHeap→filter; parse val as integer percent and preserve total as
integer with unknown unit.
12.7 Schedule CRUD
Add:
{"command":"add_sched","name":"17901970980846","on":"1",
"time":"21:30","repeat":"0111000","clean_type":"auto"}
<ctl td="AddSched" id="{cid}">
<sched name="{name}" on="{on}" time="{HH:MM}" repeat="{mask}">
<ctl td="Clean"><clean type="{clean_type}"/></ctl>
</sched>
</ctl>
Modify:
{"command":"mod_sched","name":"17901970980846","on":"0",
"time":"21:30","repeat":"0111000","clean_type":"auto"}
<ctl td="ModSched" id="{cid}">
<ModSched name="{existing-name}">
<sched name="{name}" on="{on}" time="{HH:MM}" repeat="{mask}">
<ctl td="Clean"><clean type="{clean_type}"/></ctl>
</sched>
</ModSched>
</ctl>
Delete:
{"command":"del_sched","name":"17901970980846"}
<ctl td="DelSched" id="{cid}"><DelSched name="{name}"/></ctl>
Get:
{"command":"get_sched"}
<ctl td="GetSched" id="{cid}"/>
Schedule representation received in GetSched result or Sched2 push:
<s n='17901970980846' o='1' t='21:59' r='0001000' f='p'>
<ctl td='clean' type='auto'/>
</s>
Mapping:
| Wire | MQTT schedule field |
|---|---|
n |
name (opaque; preserve exactly) |
o='0'/'1' |
on=false/true |
t |
time, local HH:MM |
r |
repeat, 7 chars; index 0 Sunday through index 6 Saturday |
f |
flag; observed always p, preserve without interpretation |
inner lowercase ctl td='clean' type=X |
action:{"td":"clean","type":X} |
An empty GetSched result is <ctl id ret='ok' errno=''/> with no <s>
children. An empty Sched2 push is <ctl td='Sched2'/>. Both mean an empty
schedule array. Sched2 is pushed after the controller is known (in both
boots, about 100 ms after the first SetTime, not at XMPP ready), after every
mutation, and when a schedule fires. The 21:59 / 0001000 entry fired at
21:58:59 local: Sched2, then CleanReport auto 73 ms later. Each <s> has a
space before its inner <ctl> and before </s>; there is no text between
adjacent <s> elements. Only type='auto' was stored. ModSched edits
changed on, time, and repeat; the inner name always matched the wrapper
name, so a rename is unverified. f was p on every entry.
12.8 Raw diagnostic command
{"command":"raw","xml":"<ctl .../>"}
This optional expert interface wraps valid ctl XML in §6.1. It must be access controlled, size limited, XML parsed (not string-concatenated), prohibited from injecting stream/auth elements, and disabled by default.
13. Robot reports and complete MQTT mapping
| XMPP message | Observed payload | MQTT analogue/action |
|---|---|---|
| stream open/features/auth/bind/session | §4 | internal session state; no direct MQTT; failures affect availability and diagnostics |
hello world presence |
§4 | READY transition; run §8; then availability online |
| dummy presence | server→bot | no MQTT output |
| controller ping/result | §5 | report registration/liveness; no state topic; timeout→offline |
| bot ping/result | §5 | server liveness; answer; no MQTT output |
| command stanza ack | iq result by sid |
{root}/command_result, phase ack; internal correlation |
| ctl command result | ret/errno, by cid |
{root}/command_result, phase result; update last_command_error; parse any payload below |
GetBatteryInfo result |
<battery power='NNN'/> |
attributes battery_level integer |
| bare query battery quirk | <query><battery power='NNN'/></query> |
same battery mapping; diagnostic may flag quirk but it is parsed |
GetCleanState result |
clean type/speed/st/t/a |
derive HA state; set clean_type, fan_speed; preserve unknown nonempty fields diagnostically |
GetChargeState result |
charge type | derive HA state; set charge_state |
GetCleanSpeed result |
ctl speed |
HA state JSON fan_speed |
GetLifeSpan result |
type/val/total | consumable attributes |
GetSched result |
zero or more <s> |
complete schedules attribute |
CleanReport push |
clean type/speed/st/rsn |
derive HA state, fan_speed, clean_type; preserve nonblank unknown st/rsn diagnostically |
ChargeState push |
Idle/going/SlotCharging |
derive HA state + charge_state |
BatteryInfo push |
battery power | battery attribute |
Sched2 push |
schedule list | replace complete schedules attribute |
error push errno 103 |
cliff/stair halt observed | HA error, last_error="103"; following reports may change motion state but error remains until clear 100 |
error push errno 100 |
all-clear observed | clear last_error; following reports determine HA state |
| TCP/XML session close | FIN/RST/EOF/stream close | retained availability offline |
| unknown valid stanza/td/field | any | {root}/raw unparsed diagnostic; do not terminate session |
All message forms observed across the four captures therefore have either:
- a Home Assistant state/attribute/availability mapping;
- a command/diagnostic MQTT mapping; or
- an explicitly documented internal transport role with no meaningful HA state analogue.
There are no observed application messages left silently unmapped.
13.1 Explicitly not mapped to HA vacuum state
These are intentionally internal or diagnostic because HA's existing vacuum schema has no equivalent:
- XMPP stream negotiation, SASL, bind/session, iq receipt acks and pings;
- raw
sid/cid,ret, command errno (published oncommand_resultand attributes, not vacuum state); - schedule flag
f='p'(preserved asflag); - unknown
CleanReport st/rsnvalues (blank in captures; preserve/log if not); - GetCleanState
t/a(empty in captures; preserve/log if not); - lifespan
totalunit (value retained; unit intentionally unspecified); - IGMP and ARP traffic;
- OTA success manifest, which was never observed and is unnecessary.
14. Validation, error handling and security requirements
- Accept any provisioned robot's SASL password, but require valid SASL PLAIN structure and a nonempty authcid to avoid parser ambiguity.
- Never publish/log auth stanzas, decoded passwords, broker credentials or other secrets.
- XML-escape all dynamic identifiers and attribute values; never construct XML from untrusted raw fragments except the disabled expert interface.
- Validate MQTT enum values, schedule masks (
^[01]{7}$), time (HH:MM), booleans and payload sizes before issuing commands. - Complete each cid once. A later command may reuse a finished id; the captured app also reused a ctl id before its result. MQTT redelivery of a command is still deduplicated.
- Put finite deadlines on pending sid/cid correlations and fail them on session replacement.
- Unknown well-formed XML is nonfatal and goes to
{root}/raw; malformed XML terminates only the affected session and marks availability offline. - Do not retain command topics or raw diagnostics.
- Subscribe to MQTT commands before publishing discovery/online so HA can control immediately.
- On MQTT reconnect, republish discovery, availability and current full retained state/attributes as needed.
- State mutations should be serialized per robot to keep reports, responses and session replacement ordered.
15. Implementation acceptance checklist
Bootstrap and XMPP
- DNS A override for
lbo.ecouser.netreaches a stable listener address. - 8007 returns exact compact numeric-port FindBest responses.
- 8005 returns acceptable 404 for firmware path.
- 5223 implements streaming plaintext XMPP and complete handshake.
- SASL values are accepted but never logged.
- New same-JID bind atomically supersedes stale/half-open sessions.
- Bot domain pings are answered exactly with matching id.
- Controller JID is announced after every READY.
- Half-open connection becomes MQTT offline before robot recovery where possible; recovered full handshake becomes online again.
Commands and state
- All HA commands in §11 emit exact
com:ctlforms. - All extensions in §12 validate inputs and correlate results.
- Both ack (
sid) and command result (cid) phases are handled. - Response payloads and pushes share the mappings in §13.
errno=100clears rather than creates an error.- Bare battery iq stanzas are parsed and not acked. A ctl id reused before its result still completes once.
- Schedule mask is Sunday-first and schedule reports replace the full list.
- Unknown messages are logged/published without disconnecting.
MQTT and Home Assistant
- Discovery payload is retained under configured HA discovery prefix.
- State JSON always contains a valid HA vacuum state.
- Fan speed is
standard|strongand discovery list matches. - Availability and LWT are retained; commands are not retained.
- Offline commands are rejected/dropped, never replayed after reconnect.
- Battery, consumables, schedules and errors remain visible as attributes.
pause, resume and unobserved actions are not advertised as verified.
Passing this checklist is sufficient to begin end-to-end testing with the provisioned N95 and Home Assistant without dependence on Ecovacs cloud services after the DNS redirect.