The broker is EMQX 6.0.1, not 5.x as first assumed -- 6.x keeps serving its
management API under /api/v5 for backward compatibility, so the API path is
not a version indicator. Menu paths in the setup guide verified against the
current EMQX documentation.
tools/check_broker.py verifies broker configuration end to end: anonymous
refused, made-up credentials refused, the configured account accepted, and
publishes permitted to both topic trees the firmware uses. It reads the local
config header by path and never prints the account secret, so its output is
safe to paste anywhere.
Two bugs found and fixed while testing it against the live broker:
- paho 2.x hands the VERSION2 on_connect callback a ReasonCode object, not
an int. int(rc) raised, every probe fell back to the timeout path, and the
script reported an open broker as secure -- a false pass on the one check
that most needs to be trustworthy.
- An inconclusive result (timeout, transport error) was rendered as a pass.
It now reports INCONCLUSIVE and fails the run, because "could not tell"
must never read as "locked down".
The reason-code table now covers both MQTT 3.1.1 CONNACK codes and the MQTT 5
numbering paho normalises onto, so refusals print as text rather than 134/135.
Co-Authored-By: Claude <noreply@anthropic.com>
190 lines
6.9 KiB
Markdown
190 lines
6.9 KiB
Markdown
# EMQX + Home Assistant setup
|
||
|
||
Broker for this deployment: **EMQX 6.0.1 at 192.168.1.31**.
|
||
|
||
Note that EMQX 6.x still serves its management API under `/api/v5` for
|
||
backward compatibility — the API path is not a version indicator.
|
||
|
||
## 0. Broker state
|
||
|
||
Probed 2026-08-14, before and after the authenticator was created:
|
||
|
||
| Check | Before | After |
|
||
|---|---|---|
|
||
| Listeners 1883 / 8883 / 8083 / 8084 / 18083 | all open | all open |
|
||
| Anonymous MQTT connect | **accepted** | refused (bad username or password) |
|
||
| Connect with a made-up username/password | **accepted** | refused (not authorized) |
|
||
| Connect as `soil-node` from `secrets.h` | n/a | accepted |
|
||
| Publish to `soil/#` | allowed | allowed |
|
||
| Publish to `homeassistant/sensor/#` | allowed | allowed |
|
||
| Retained publish + replay to a new client | works | works |
|
||
| Management API (`/api/v5/clients`) | protected, HTTP 401 | protected |
|
||
| Public `/api/v5/status` | 200 (normal, by design) | 200 |
|
||
|
||
The "before" column is what an EMQX install looks like with **no authenticator
|
||
configured**: every client on the LAN can connect, subscribe to `#`, and
|
||
publish anywhere — including to `homeassistant/...` discovery topics, meaning
|
||
anything on the network could create or overwrite entities in Home Assistant.
|
||
|
||
In that state the `MQTT_USER` / `MQTT_PASSWORD` values in `secrets.h` are
|
||
decorative: EMQX accepts them without checking, so a node connecting
|
||
successfully proves nothing about whether the credentials are correct.
|
||
|
||
Re-run the check at any time:
|
||
|
||
```bash
|
||
python3 tools/check_broker.py
|
||
```
|
||
|
||
## 1. Enable authentication in EMQX ✅ done
|
||
|
||
Dashboard → **http://192.168.1.31:18083**
|
||
|
||
1. **Access Control → Authentication**, then **Create** (top right)
|
||
2. Mechanism **Password-Based**, Backend **Built-in Database** → Next
|
||
3. Configure:
|
||
- **UserID Type**: `username` (matches the CONNECT packet's Username field,
|
||
which is what `MQTT_USER` in `secrets.h` sets)
|
||
- **Password Hash**: `bcrypt`
|
||
- **Salt Rounds**: 10 (valid range 5–10; higher is better)
|
||
4. Create.
|
||
|
||
The moment an authenticator exists, EMQX stops accepting anonymous
|
||
connections. Existing sessions stay up until they next reconnect, so there is
|
||
a grace period — but create the accounts in step 2 immediately, and expect
|
||
Home Assistant to fall off the broker until you give it credentials too.
|
||
|
||
## 2. Create one account per client
|
||
|
||
Click the authenticator row you just created, then the **Users** tab → **Add**:
|
||
|
||
| Username | Used by |
|
||
|---|---|
|
||
| `soil-node` | every ESP32 node (they share this account; the MQTT *client id* is what stays unique, derived from each MAC) |
|
||
| `homeassistant` | the HA MQTT integration |
|
||
|
||
Separate accounts matter here: it lets you write the ACLs in step 3, and it
|
||
means rotating the node credential doesn't knock HA off the broker.
|
||
|
||
Use long random passwords. Generate them where they will not land in a
|
||
terminal transcript, e.g. write straight to a file:
|
||
|
||
```bash
|
||
openssl rand -base64 24 > ~/soil-node.pass # then open the file to read it
|
||
```
|
||
|
||
## 3. Restrict what each account may do (recommended)
|
||
|
||
**Access Control → Authorization → Built-in Database**, then add rules.
|
||
|
||
For `soil-node` — it only ever publishes, and never subscribes:
|
||
|
||
| Action | Topic | Permission |
|
||
|---|---|---|
|
||
| Publish | `soil/#` | Allow |
|
||
| Publish | `homeassistant/sensor/#` | Allow |
|
||
| Subscribe | `#` | Deny |
|
||
|
||
For `homeassistant`:
|
||
|
||
| Action | Topic | Permission |
|
||
|---|---|---|
|
||
| Subscribe | `homeassistant/#` | Allow |
|
||
| Subscribe | `soil/#` | Allow |
|
||
| Publish | `homeassistant/status` | Allow |
|
||
|
||
Then set **no-match action to `Deny`** in the authorization settings.
|
||
Leaving it at the default `Allow` makes the rules above cosmetic — anything
|
||
not explicitly matched is still permitted.
|
||
|
||
## 4. Fill in `include/secrets.h`
|
||
|
||
Already pre-filled with the broker address. Replace the four `CHANGEME` values:
|
||
|
||
```c
|
||
#define WIFI_SSID "..."
|
||
#define WIFI_PASSWORD "..."
|
||
#define MQTT_HOST "192.168.1.31"
|
||
#define MQTT_PORT 1883
|
||
#define MQTT_USER "soil-node"
|
||
#define MQTT_PASSWORD "..."
|
||
```
|
||
|
||
This file is gitignored. The broker IP is deliberately **not** in
|
||
`secrets.h.example`, because that template is committed and the Gitea repo is
|
||
public.
|
||
|
||
### Why port 1883 and not 8883
|
||
|
||
TLS is available on 8883, but PubSubClient over `WiFiClientSecure` means
|
||
shipping a CA bundle and paying a full handshake on every deep-sleep wake —
|
||
meaningful battery cost on the remote nodes for a sensor reading that is not
|
||
confidential, on a LAN. Plain 1883 with per-account ACLs is the sensible trade
|
||
here. Revisit if these ever cross a network you don't control.
|
||
|
||
## 5. Point Home Assistant at EMQX
|
||
|
||
Settings → Devices & Services → **MQTT** → Configure.
|
||
|
||
- Broker `192.168.1.31`, port `1883`
|
||
- Username `homeassistant`, password from step 2
|
||
- Enable discovery, discovery prefix `homeassistant` (must match
|
||
`HA_DISCOVERY_PREFIX` in `include/config.h`)
|
||
|
||
If MQTT is already configured against a *different* broker, repoint it —
|
||
nodes publishing to EMQX will never appear in an HA that is listening to
|
||
Mosquitto somewhere else. This is the single most common reason a node looks
|
||
healthy on the wire but produces no entities.
|
||
|
||
## 6. Flash and verify
|
||
|
||
```bash
|
||
pio run -e mains -t upload -t monitor
|
||
```
|
||
|
||
Expected serial output:
|
||
|
||
```
|
||
=== Soil Node A1B2C3 (aa:bb:cc:a1:b2:c3) fw 1.0.0 ===
|
||
profile: mains | publishing every 60s
|
||
wifi: 192.168.1.x, rssi -55
|
||
mqtt: connecting to 192.168.1.31:1883 as soil-node-a1b2c3
|
||
discovery: publishing entities for soil-node-a1b2c3
|
||
state: {"node_id":"soil-node-a1b2c3","moisture_1":...}
|
||
```
|
||
|
||
Watch the broker side independently:
|
||
|
||
```bash
|
||
python3 tools/mqtt_watch.py --host 192.168.1.31 --user soil-node
|
||
```
|
||
|
||
Then confirm in HA: Settings → Devices & Services → MQTT → *Soil Node XXXXXX*
|
||
with 6 moisture entities plus the diagnostics.
|
||
|
||
## Troubleshooting
|
||
|
||
`mqtt: connect failed, state N` — N is PubSubClient's code:
|
||
|
||
| N | Meaning | Likely cause |
|
||
|---|---|---|
|
||
| 4 | Bad credentials | `MQTT_USER`/`MQTT_PASSWORD` don't match the EMQX user |
|
||
| 5 | Not authorized | Authenticated, but ACL denied the connection |
|
||
| 3 | Server unavailable | Broker reachable but refusing |
|
||
| -2 | Connect failed | TCP never established — wrong IP, firewall, VLAN |
|
||
| -4 | Connection timeout | Broker accepted TCP but never sent CONNACK |
|
||
|
||
**Node publishes but no entities in HA.** Almost always HA pointed at a
|
||
different broker (step 5), or a discovery-prefix mismatch. Check that
|
||
`homeassistant/sensor/soil-node-xxxxxx/moisture_1/config` exists on the broker
|
||
with the watcher script.
|
||
|
||
**Entities appear then go *unavailable*.** On the mains profile that is the
|
||
availability topic plus last-will doing its job — the node really did drop.
|
||
On the battery profile it means `expire_after` elapsed, so the node missed
|
||
more than ~2.5 sleep intervals.
|
||
|
||
**Entities show *unknown* rather than a number.** That is the firmware
|
||
reporting a probe outside its calibrated window as `null` on purpose, not a
|
||
transport problem. See `CALIBRATION.md`.
|