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

978 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```text
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:
```text
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:
```json
{"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:
```json
{"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:
```http
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
```xml
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:
```text
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:
```xml
<iq id="{sid}" to="{bot-jid}" from="{controller-jid}" type="get">
<ping xmlns="urn:xmpp:ping"/>
</iq>
```
The bot replies:
```xml
<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:
```xml
<iq from='{bot-jid}' to='155.ecorobot.net' id='{bot-id}' type='get'>
<ping xmlns='urn:xmpp:ping'/>
</iq>
```
Answer immediately:
```xml
<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
```xml
<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`:
```xml
<iq to='{controller-jid}' type='result' id='{sid}'/>
```
2. command result in a new iq-set, correlated by `ctl/@id == cid`:
```xml
<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
```xml
<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:
```text
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:
```json
{"direction":"robot_to_bridge","kind":"unparsed","timestamp":"RFC3339",
"session":12,"xml":"<iq ...>...</iq>","reason":"unknown td"}
```
```json
{"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.
```text
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).
```json
{
"name": "Deebot N95",
"unique_id": "ecovacs_E2998877665544332211",
"command_topic": "ecovacs/E2998877665544332211/command",
"set_fan_speed_topic": "ecovacs/E2998877665544332211/set_fan_speed",
"send_command_topic": "ecovacs/E2998877665544332211/send_command",
"state_topic": "ecovacs/E2998877665544332211/state",
"json_attributes_topic": "ecovacs/E2998877665544332211/json_attributes",
"availability_topic": "ecovacs/E2998877665544332211/availability",
"payload_available": "online",
"payload_not_available": "offline",
"fan_speed_list": ["standard", "strong"],
"supported_features": [
"start", "stop", "return_home", "status", "locate",
"clean_spot", "fan_speed", "send_command"
],
"device": {
"identifiers": ["E2998877665544332211"],
"manufacturer": "Ecovacs",
"model": "Deebot N95 (wukong/155)",
"serial_number": "E2998877665544332211",
"connections": [["mac", "02:00:00:00:95:01"]]
}
}
```
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
```json
{"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:
```text
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:
```json
{
"battery_level": 76,
"side_brush": 68,
"main_brush": 90,
"filter": 70,
"lifespan_total": {"side_brush":365,"main_brush":365,"filter":365},
"clean_type": "auto",
"charge_state": "Idle",
"last_error": 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
```json
{"command":"clean","clean_type":"auto|border|spot|singleRoom"}
```
```xml
<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):
```json
{"command":"resume"}
```
uses `act="r"`; expose only after validation.
### 12.2 Manual movement
```json
{"command":"move","action":"forward|SpinLeft|SpinRight|TurnAround|stop"}
```
```xml
<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
```json
{"command":"cancel_return"}
```
```xml
<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
```json
{"command":"set_time"}
```
```xml
<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
```json
{"command":"get_status"}
```
Fan out:
```xml
<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:
```xml
<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
```json
{"command":"get_lifespan"}
```
Send three requests:
```xml
<ctl id="{cid}" td="GetLifeSpan" type="SideBrush"/>
<ctl id="{cid}" td="GetLifeSpan" type="Brush"/>
<ctl id="{cid}" td="GetLifeSpan" type="DustCaseHeap"/>
```
Response:
```xml
<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:
```json
{"command":"add_sched","name":"17901970980846","on":"1",
"time":"21:30","repeat":"0111000","clean_type":"auto"}
```
```xml
<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:
```json
{"command":"mod_sched","name":"17901970980846","on":"0",
"time":"21:30","repeat":"0111000","clean_type":"auto"}
```
```xml
<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:
```json
{"command":"del_sched","name":"17901970980846"}
```
```xml
<ctl td="DelSched" id="{cid}"><DelSched name="{name}"/></ctl>
```
Get:
```json
{"command":"get_sched"}
```
```xml
<ctl td="GetSched" id="{cid}"/>
```
Schedule representation received in GetSched result or Sched2 push:
```xml
<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
```json
{"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.