Files

179 lines
19 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 MQTT bridge
> **Privacy notice:** Device serials, MAC addresses, authentication values, controller identifiers, and other identifying values shown in the included captures and documentation have been consistently replaced with synthetic values.
This service redirects an **already provisioned Ecovacs Deebot N95** from its legacy cloud bootstrap and XMPP endpoint to a local bridge. It exposes the robot to Home Assistant through MQTT discovery as a native MQTT vacuum. The bridge supports multiple robots, with one MQTT client and one topic tree per robot serial number. It does not provision a robot or run a DNS server.
```text
N95 ── DNS ──> your LAN resolver
N95 ── HTTP 8007 / 8005, XMPP 5223 ──> n95bridge ──> MQTT broker ──> Home Assistant
```
The bridge's robot-facing XMPP connection is **plaintext**, including the robot's SASL PLAIN login. Run it on a trusted LAN and limit access to its listener ports. MQTT can be protected separately with TLS and broker authentication. The robot's existing password is accepted during the local XMPP handshake; there is no robot credential to enter into the bridge configuration.
## Before you start
You need:
- An already provisioned Deebot N95 using the `wukong` / class `155` protocol captured for this project. Other Ecovacs models are not established as compatible.
- A Docker host (or a machine running the Go binary) with a **stable LAN IPv4 address** reachable from the robot. Reserve its address in DHCP.
- Control of the DNS resolver supplied to the robot by DHCP. It must answer `lbo.ecouser.net` with that LAN address. A direct A record is sufficient. The bridge does not provide DNS. The XMPP JID domain `155.ecorobot.net` does not need a DNS override for this captured firmware.
- TCP **8007**, **8005**, and **5223** reachable from the robot at that address. TCP **8080** is the optional health endpoint; it need not be open to the robot. If that port is already taken (for example OpenThread Border Router on Home Assistant OS), the process logs the bind error and keeps the robot listeners running. Set `HEALTH_PORT` to a free port if you still want `/healthz`.
- An MQTT broker reachable from the bridge and Home Assistant configured to use that broker, with MQTT discovery enabled. Its discovery prefix must match `HA_DISCOVERY_PREFIX` (normally `homeassistant`).
- A correct host clock and local timezone. The bridge sends the robot the current time and the process's local UTC offset on each session.
The N95 caches the XMPP IP and port after boot. If the bridge address changes while the robot is powered on, update DNS and **reboot the robot** so it performs bootstrap again. A normal XMPP reconnect goes directly to the cached address.
## Docker Compose setup
The supplied [docker-compose.yml](docker-compose.yml) uses published ports on Docker's default bridge network. Its `192.0.2.10` and `mqtt.example.invalid` values are **documentation placeholders**. Edit the file before starting it:
1. Reserve the Docker host's LAN IPv4 address. Set `ADVERTISE_IP` to that address and configure your LAN DNS resolver to return it for `lbo.ecouser.net`. Ensure the robot receives that resolver through DHCP.
2. Set `MQTT_HOST` to the broker's hostname or IP address **as seen from inside the container**. Enter a bare host, with no `tcp://`, `mqtt://`, or `:port`. If the broker runs on the Docker host, `localhost` inside the container refers to the container, so use an address or hostname the container can reach.
3. Set `TZ` to your local IANA timezone, for example `Europe/London`. Keep `BIND_ADDRESS: "0.0.0.0"` inside the container. `ADVERTISE_IP` is the host's LAN address, not this bind address or a Docker subnet address.
4. If your broker requires authentication, uncomment the `env_file` block and create an uncommitted `.env` beside the Compose file containing `MQTT_USERNAME=...` and `MQTT_PASSWORD=...`. The file is gitignored and excluded from the image build. Compose does **not** pass values from its automatic `.env` interpolation file into the container unless they are referenced in `environment` or included through `env_file`. Keep credentials out of the Compose file and source control.
5. If your broker uses TLS, set `MQTT_TLS: "true"` (default port 8883), and set `MQTT_PORT` in the Compose `environment` block if your broker uses a different port. For a private CA, mount a readable PEM certificate bundle into the container and set `MQTT_CA_FILE` to its **container path**. For example, add `volumes: ["./broker-ca.pem:/certs/broker-ca.pem:ro"]` and `MQTT_CA_FILE: "/certs/broker-ca.pem"`. The runtime is non-root (UID 65532), so the mounted file must be readable by that user. The CA is added to the system trust pool; TLS hostname verification remains enabled.
6. If you change a robot-facing port, change its `PORT_*` variable **and** the matching Compose published port. The host port must equal the port returned by `/lookup.do`. Leave the four listener ports distinct.
Start and inspect the service:
```sh
docker compose up -d --build
docker compose ps
docker compose logs -f n95bridge
```
The image builds a static Go binary and runs it as a non-root user. The Compose health check executes `/n95bridge healthcheck` inside the container. On `SIGTERM`, the bridge attempts to publish retained `offline` before closing XMPP; the shutdown budget is five seconds.
The four PCAP files at the repository root are test fixtures containing captured traffic. They are not needed at runtime, and `.dockerignore` excludes them from the image. Treat those files as sensitive.
### Run without Docker
Install the Go version specified by [go.mod](go.mod), currently **Go 1.27.1**, and build from the repository root:
```sh
go build -o n95bridge ./cmd/n95bridge
ADVERTISE_IP=192.0.2.10 MQTT_HOST=broker.example.net TZ=Europe/London ./n95bridge
```
Replace both example addresses. Set any other variables from the table below in the service manager or process environment. The binary reads environment variables; it does not parse `.env` itself. Arrange for it to start on boot, receive `SIGTERM` for orderly shutdown, and bind the configured ports. `./n95bridge healthcheck` uses the same environment and checks `http://127.0.0.1:${HEALTH_PORT:-8080}/healthz`.
## Configuration reference
Only `ADVERTISE_IP` and `MQTT_HOST` are required. An absent variable uses its default. Empty values are allowed for optional `MQTT_CA_FILE`, `MQTT_USERNAME`, and `MQTT_PASSWORD`; empty `MQTT_PORT` and `MQTT_CLIENT_ID` use their defaults. Other configured values must be nonempty. Boolean values must be exactly `true` or `false` in lowercase. All four listener ports must be different and within 1–65535.
| Variable | Default | Meaning and constraints |
| --- | --- | --- |
| `ADVERTISE_IP` | required | Literal nonzero IPv4 returned in both lookup responses; the robot must be able to reach it for its powered-on lifetime. |
| `BIND_ADDRESS` | `0.0.0.0` | IP address on which all four listeners bind. It is never advertised. For the supplied container networking, keep `0.0.0.0`. |
| `PORT_LOOKUP` | `8007` | HTTP `POST /lookup.do` for `EcoMsgNew` and `EcoUpdate`. |
| `PORT_FIRMWARE` | `8005` | HTTP firmware check; returns the expected 404. |
| `PORT_XMPP` | `5223` | Plaintext robot XMPP listener; advertised for `EcoMsgNew`. |
| `HEALTH_PORT` | `8080` | HTTP `GET /healthz` and the `healthcheck` subcommand. Bind failure is non-fatal; robot listeners keep running. |
| `MQTT_HOST` | required | Broker DNS name or address, without a scheme or port. Also used as the TLS server name. |
| `MQTT_PORT` | `1883` or `8883` | Broker port; defaults to 8883 when `MQTT_TLS=true`, otherwise 1883. |
| `MQTT_TLS` | `false` | Enable TLS from the start of the MQTT TCP connection. Uses the system CA pool and verifies the broker hostname. |
| `MQTT_CA_FILE` | unset | Optional readable PEM certificate bundle added to the TLS trust pool. Mount it into a container if applicable. |
| `MQTT_USERNAME` | unset | Optional broker username. |
| `MQTT_PASSWORD` | unset | Optional broker password; a nonempty password requires a username. The bridge omits the password from its configuration log. |
| `MQTT_CLIENT_ID` | `n95bridge-<hostname>` | Prefix for per-robot broker client IDs. Explicit values must match `[A-Za-z0-9_-]{1,64}`. Use distinct prefixes if running separate bridge instances against the same broker. |
| `MQTT_BASE` | `ecovacs` | Single MQTT topic level before `/<serial>`; letters, digits, `_`, and `-` only. |
| `HA_DISCOVERY_PREFIX` | `homeassistant` | Single topic level for Home Assistant MQTT discovery; letters, digits, `_`, and `-` only. Must match Home Assistant's setting. |
| `CONTROLLER_JID` | `n95bridge@ecouser.net/homeassistant` | Virtual XMPP sender JID in `local@domain/resource` form. The robot learns this address from bridge requests. |
| `RAW_COMMANDS` | `false` | Enable the optional raw `<ctl>` command input. Keep disabled unless you explicitly need protocol experimentation. |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` on stderr. |
`TZ` is a standard process timezone setting, not a bridge-specific option. It controls the local offset sent by `SetTime`; the binary includes timezone data for the distroless image. The default MQTT client ID prefix is derived from the hostname. The bridge appends each robot's serial to make its final broker client ID.
The bridge creates an MQTT client **only after a robot reaches its XMPP READY state and reveals its serial**. No MQTT connection at initial process startup is expected. It reconnects to the broker automatically when needed.
## Check the installation
From a machine on the robot's LAN, first verify the DNS response using the resolver the robot receives, then check the listeners. Replace the example addresses with your deployment values:
```sh
nslookup lbo.ecouser.net 192.0.2.53
curl -sS -X POST -H 'Content-Type: application/json' \
--data '{"todo":"FindBest","service":"EcoMsgNew"}' \
http://192.0.2.10:8007/lookup.do
curl -sS -X POST -H 'Content-Type: application/json' \
--data '{"todo":"FindBest","service":"EcoUpdate"}' \
http://192.0.2.10:8007/lookup.do
curl -i http://192.0.2.10:8005/products/wukong/class/155/firmware/latest.json
curl -sS http://192.0.2.10:8080/healthz
```
The two lookup responses should contain the advertised LAN IP with numeric ports `5223` and `8005`, respectively. The firmware request should return **404** with body `Not Found`; `/healthz` should return `ok`. If the health port is bound only locally or blocked from your test machine, run the health check inside the container instead:
```sh
docker compose exec n95bridge /n95bridge healthcheck
```
Boot or reboot the robot after the DNS override is active. The log line `sasl authenticated` includes `authcid=<serial>`, which identifies the robot's MQTT topic tree. Once its announce ping succeeds, `{MQTT_BASE}/{serial}/availability` becomes retained `online` and Home Assistant should discover a `Deebot N95` vacuum. A robot disconnect sets retained `offline`; an abrupt bridge failure is also covered by the broker's retained MQTT last will. Initial status, schedules, battery, and consumable requests follow the session announcement. State and attributes are retained so Home Assistant can recover them after a restart.
`/healthz` checks **process liveness only**. It can return `ok` while the robot is offline or the broker is unavailable. Use MQTT availability and bridge logs to diagnose connectivity.
## MQTT and Home Assistant interface
The serial is learned from the robot's XMPP login; it is **not** an environment variable. With defaults, the discovery topic is `homeassistant/vacuum/ecovacs_<serial>/config`, and the per-robot root is `ecovacs/<serial>`. Changing `MQTT_BASE` changes the per-robot topics, while the discovery object ID and unique ID remain `ecovacs_<serial>`.
| Topic under `{MQTT_BASE}/{serial}/` | Direction | Contents | Retained |
| --- | --- | --- | --- |
| `command` | to bridge | Exact string: `start`, `stop`, `return_to_base`, `clean_spot`, or `locate` | No |
| `set_fan_speed` | to bridge | Exact string: `standard` or `strong` | No |
| `send_command` | to bridge | JSON extension command described below | No |
| `availability` | from bridge | `online` or `offline` | Yes |
| `state` | from bridge | JSON with `state` and `fan_speed` | Yes |
| `json_attributes` | from bridge | Complete JSON snapshot of battery, consumables, schedules, and errors | Yes |
| `command_result` | from bridge | JSON command trace with `sid`, `cid`, `command`, `phase`, `ret`, `errno`, and `timestamp` | No |
| `raw` | from bridge | JSON for unparsed protocol data, including direction, timestamp, XML, and reason | No |
Discovery, availability, state, and attributes are published at MQTT QoS 0. The bridge subscribes to command topics at QoS 1. Do not publish retained command messages: an old command could be delivered again after a subscription or reconnect. The bridge republishes discovery when it reconnects to MQTT and when Home Assistant sends `online` to `{HA_DISCOVERY_PREFIX}/status`.
If your broker uses topic ACLs, allow each bridge client to **read** `{MQTT_BASE}/+/command`, `{MQTT_BASE}/+/set_fan_speed`, `{MQTT_BASE}/+/send_command`, and `{HA_DISCOVERY_PREFIX}/status`; allow it to **write** `{MQTT_BASE}/+/availability`, `{MQTT_BASE}/+/state`, `{MQTT_BASE}/+/json_attributes`, `{MQTT_BASE}/+/raw`, `{MQTT_BASE}/+/command_result`, and `{HA_DISCOVERY_PREFIX}/vacuum/+/config`. The availability write permission also covers the broker's last will. The `+` stands for one robot serial or discovery object ID. Home Assistant needs the corresponding subscribe permissions for discovery and state and publish permissions for commands.
The vacuum exposes start, stop, dock, spot, locate, and fan speed. It does not advertise pause, resume, mapping, segment cleaning, or a separate battery feature. State may be `idle`, `cleaning`, `returning`, `docked`, or `error`; attributes include `battery_level`, `side_brush`, `main_brush`, `filter`, `lifespan_total`, `clean_type`, `charge_state`, `last_error`, `last_command_error`, and `schedules`. Unknown readings start as JSON `null`. The bridge distinguishes a robot fault (`last_error`) from a failed or rejected command (`last_command_error`).
For a direct MQTT client, publish to `ecovacs/<serial>/command` with payload `start` (substitute your topic root and serial). Home Assistant normally publishes these commands through the discovered vacuum entity.
### Extension commands
The `send_command` topic accepts JSON objects with a `command` field; its maximum payload is **4096 bytes**. Examples below are MQTT payloads, not shell commands:
```text
{"command":"clean","clean_type":"border"}
{"command":"move","action":"SpinLeft"}
{"command":"cancel_return"}
{"command":"set_time"}
{"command":"get_sched"}
{"command":"add_sched","name":"weekday","on":"1","time":"21:30","repeat":"0111110","clean_type":"auto"}
{"command":"mod_sched","name":"weekday","on":"0","time":"21:30","repeat":"0111110","clean_type":"auto"}
{"command":"del_sched","name":"weekday"}
```
`clean_type` for `clean` may be `auto`, `border`, `spot`, or `singleRoom`. `move` accepts `forward`, `SpinLeft`, `SpinRight`, `TurnAround`, or `stop`; it does not automatically send a stop after a movement. The schedule `repeat` mask has seven `0`/`1` characters in **Sunday through Saturday** order. Schedule mutation supports `clean_type: "auto"` only. `on` accepts `"1"`, `"0"`, `"true"`, or `"false"`; `name` must be 1–64 bytes, and `time` must have `HH:MM` form. `get_sched` refreshes the schedule attribute. The bridge also retrieves battery, cleaning state, charge state, speed, schedules, and three consumable values on each successful session announcement.
Current limitation: the JSON parser accepts `{"command":"get_status"}` and `{"command":"get_lifespan"}`, but their actor paths do not build outbound XMPP queries. Do not rely on these two manual refresh commands in this version. Their values still update from the automatic session queries and incoming reports.
`RAW_COMMANDS=true` enables `{"command":"raw","xml":"<ctl td=\"...\" id=\"...\">...</ctl>"}`. It is disabled by default. The bridge accepts a `<ctl>` root, re-encodes its inner XML, takes `td`, and generates its own command ID; it does not forward the supplied root attributes verbatim. Use it only with a clear understanding of the N95 protocol. `resume` and `backward` are explicitly rejected.
## Troubleshooting
| Symptom | Check |
| --- | --- |
| Container exits with `invalid configuration` | Read the joined validation errors in `docker compose logs n95bridge`. Check required addresses, strict boolean spelling, distinct ports, and PEM path/readability. |
| Health check passes but no robot appears | Verify the robot's DHCP DNS server and its `lbo.ecouser.net` answer, then test both lookup responses and the firmware 404 from the LAN. Reboot the robot after changing its cached endpoint. |
| `sasl authenticated` never appears | Check robot reachability to the published TCP 5223 port and whether its DNS and lookup requests reach this host. The XMPP listener uses plaintext. |
| Robot authenticates, but MQTT discovery is absent | Check for the `mqtt connect` log line, broker reachability from the container, broker credentials/TLS CA, and the Home Assistant discovery prefix. The MQTT client starts after robot READY. |
| Vacuum is present but `offline` | The robot session has not completed its announce ping, has disconnected, or has missed a keepalive response. The bridge pings about every 60 seconds and treats a missing response after 12 seconds as offline. |
| Values or commands do not update | Inspect `json_attributes.last_command_error`, then the nonretained `command_result` and `raw` topics if your broker permissions allow. Command payloads are exact and case sensitive. Offline commands are dropped rather than queued. |
| Time or schedules are wrong | Check the machine clock, `TZ`, and the Sunday-first schedule mask. |
The bridge logs to stderr. `LOG_LEVEL=debug` adds redacted XMPP stanza logging. The `raw` MQTT diagnostic topic can contain protocol XML, so grant access only to trusted MQTT clients. The bridge does not intentionally publish SASL authentication material there.
## Development and project documents
Run `go test ./...` from the repository root. The capture replay tests use the four sanitized `packetcapture-ix1.12-*.pcap` fixtures committed under `docs/pcaps/`; the runtime build does not need them. A broker integration test is available with `go test -tags mqttintegration ./cmd/n95bridge` and `MQTT_TEST_URL` set to a test broker URL; it skips when that variable is unset. `MQTT_TEST_URL` is test-only, not a service setting.
Protocol and design details are in [the full N95 specification](docs/N95-FULL-SPECIFICATION.md), [packet capture analysis](docs/PCAP-ANALYSIS.md), [MQTT bridge mapping](docs/MQTT-BRIDGE.md), and [the milestone plan](docs/M1-MEGAPLAN.md). Those documents describe some intended or observed protocol behavior beyond the currently implemented runtime; the configuration and limitations above reflect the code in this repository.