Files
ha-n95-local-control/docs/N95-FULL-SPECIFICATION.md
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

35 KiB
Raw Permalink Blame History

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:

  1. boots and accepts an already-provisioned Ecovacs Deebot N95;
  2. replaces the Ecovacs bootstrap, firmware-check and legacy XMPP endpoints;
  3. monitors and controls the robot through its legacy com:ctl XMPP dialect;
  4. exposes a Home Assistant entity using the native MQTT vacuum schema;
  5. exposes capabilities outside that schema through documented auxiliary MQTT commands and attributes; and
  6. 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 world presence;
  • 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-auth is 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:

  1. atomically replace the JID→connection mapping;
  2. make the new connection authoritative;
  3. send </stream:stream> and close the old local socket if possible;
  4. do not reject the new bind because an old socket still appears connected;
  5. 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.
  • Move is the exception: its <ctl> has no id.

6.2 Two-part response

Most commands produce both:

  1. stanza receipt ack, correlated by iq/@id == sid:

    <iq to='{controller-jid}' type='result' id='{sid}'/>
    
  2. 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 normal GetBatteryInfo result 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:

  1. last healthy bot ping was answered;
  2. 120 s later, next bot ping was transmitted but black-holed;
  3. 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;
  4. at +120.002 s the robot sent TCP FIN, without XMPP stream close;
  5. it did not wait for FIN-ACK;
  6. at FIN+4.999 s it opened a new TCP connection to cached IP:5223;
  7. full stream/SASL/re-stream/bind/session/presence completed in 0.455 s (0.42–0.48 s on the other reconnects);
  8. 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:

  1. controller announce ping (§5), wait for result;
  2. send SetTime with current Unix seconds and local UTC offset;
  3. issue GetBatteryInfo, GetCleanState, GetChargeState, GetCleanSpeed, GetSched, and three GetLifeSpan requests;
  4. process their result payloads through the same MQTT state/attribute mapping used for pushes;
  5. publish retained availability online after 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 on command_result and attributes, not vacuum state);
  • schedule flag f='p' (preserved as flag);
  • unknown CleanReport st/rsn values (blank in captures; preserve/log if not);
  • GetCleanState t/a (empty in captures; preserve/log if not);
  • lifespan total unit (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.net reaches 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:ctl forms.
  • 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=100 clears 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|strong and 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.