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.
978 lines
35 KiB
Markdown
978 lines
35 KiB
Markdown
# 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.
|