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.
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/ class155protocol 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.netwith that LAN address. A direct A record is sufficient. The bridge does not provide DNS. The XMPP JID domain155.ecorobot.netdoes 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_PORTto 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(normallyhomeassistant). - 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 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:
- Reserve the Docker host's LAN IPv4 address. Set
ADVERTISE_IPto that address and configure your LAN DNS resolver to return it forlbo.ecouser.net. Ensure the robot receives that resolver through DHCP. - Set
MQTT_HOSTto the broker's hostname or IP address as seen from inside the container. Enter a bare host, with notcp://,mqtt://, or:port. If the broker runs on the Docker host,localhostinside the container refers to the container, so use an address or hostname the container can reach. - Set
TZto your local IANA timezone, for exampleEurope/London. KeepBIND_ADDRESS: "0.0.0.0"inside the container.ADVERTISE_IPis the host's LAN address, not this bind address or a Docker subnet address. - If your broker requires authentication, uncomment the
env_fileblock and create an uncommitted.envbeside the Compose file containingMQTT_USERNAME=...andMQTT_PASSWORD=.... The file is gitignored and excluded from the image build. Compose does not pass values from its automatic.envinterpolation file into the container unless they are referenced inenvironmentor included throughenv_file. Keep credentials out of the Compose file and source control. - If your broker uses TLS, set
MQTT_TLS: "true"(default port 8883), and setMQTT_PORTin the Composeenvironmentblock if your broker uses a different port. For a private CA, mount a readable PEM certificate bundle into the container and setMQTT_CA_FILEto its container path. For example, addvolumes: ["./broker-ca.pem:/certs/broker-ca.pem:ro"]andMQTT_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. - 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:
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, currently Go 1.27.1, and build from the repository root:
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:
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:
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:
{"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, packet capture analysis, MQTT bridge mapping, and the milestone plan. 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.