MQTT and Home Assistant integration¶
For pool owners running a release build who want to publish telemetry, send commands, and auto-discover entities in Home Assistant. This is the deep-dive companion to the configuration reference; the bare option table lives there.
This guide covers the MQTT control and telemetry surface: how to connect, the complete topic scheme, command and response topics, and Home Assistant auto-discovery — including a worked Filter Pump example and copy-paste mosquitto and Home Assistant setup.
On Home Assistant OS / Supervised? The Home Assistant add-on wires all of this up for you — it auto-discovers the broker through the Supervisor and enables discovery by default, so you can skip the manual MQTT setup below.
Want ready-made dashboards and alerts? Once discovery is running, the companion package provides a stock-cards pool dashboard, alert/self-healing automation blueprints, and the helpers they build on — no YAML authoring required.
Overview¶
MQTT is off by default. It is opt-in via the --mqtt flag; with MQTT disabled the integration is never created. Once enabled, the application connects to your broker and publishes:
- System status, version, and equipment summary.
- Pool temperatures, chemistry, circulation, and configuration.
- Per-body temperatures and a per-device state for every pump, heater, chlorinator, auxiliary, and light.
- Statistics (message counts, bandwidth, latency, serial health).
- A consolidated alert document.
It also subscribes to a command topic tree so you can change setpoints, toggle devices, drive the chlorinator, and switch circulation modes.
Home Assistant auto-discovery is a separate opt-in on top of MQTT (--home-assistant, which requires --mqtt). When enabled, the application publishes a single retained discovery payload that registers one Home Assistant device with all of its entities — temperature sensors, setpoints, chemistry sensors, mode sensors, alert binary sensors, and one entity per discovered pool device.
Note: Every option below has a config-file equivalent. The config-file key is the option's long name without the leading dashes (for example --mqtt-host becomes mqtt-host in the file). The config file is a flat INI with no sections. See INSTALL.md for running with a config file or under Docker.
Connection and CLI options¶
| Option | Short | Type | Default | Description |
|---|---|---|---|---|
--mqtt |
bool | false |
Enable the MQTT client. All other MQTT options are inert without this. | |
--mqtt-host |
string | localhost |
Broker hostname or IP address. | |
--mqtt-port |
uint16 | 1883 |
Broker port. Switches to 8883 automatically when --mqtt-tls is set and no port was given explicitly (see below). |
|
--mqtt-tls |
bool | false |
Enable TLS/SSL for the broker connection. | |
--mqtt-ca-cert |
string | (none) | CA certificate file used to verify the broker. | |
--mqtt-client-cert |
string | (none) | Client certificate for mutual TLS. Requires --mqtt-client-key. |
|
--mqtt-client-key |
string | (none) | Client private key for mutual TLS. Requires --mqtt-client-cert. |
|
--mqtt-tls-skip-verify |
bool | false |
Skip broker certificate verification. Not for production (see Security). | |
--mqtt-client-id |
string | auto | MQTT client identifier. Auto-generated as aqualink-<8 hex chars> when empty. |
|
--mqtt-username |
string | (none) | Username for broker authentication. | |
--mqtt-password |
string | (none) | Password. Sent only alongside a username (see below). | |
--mqtt-prefix |
string | aqualink |
Base topic prefix prepended to every published and subscribed subtopic. | |
--mqtt-status-interval |
uint32 | 5 |
Seconds between system, pool, and device status publishes. | |
--mqtt-stats-interval |
uint32 | 10 |
Seconds between statistics publishes. | |
--mqtt-on-change |
bool | true |
Publish immediately on data changes (250 ms debounce) in addition to the periodic interval. Chlorinator setpoint / output / health changes count (checked once a second), so a change made at the panel or in iAqualink reaches Home Assistant promptly rather than at the next periodic publish. | |
--home-assistant |
bool | false |
Enable Home Assistant MQTT discovery. Requires --mqtt. |
|
--ha-discovery-prefix |
string | homeassistant |
Discovery topic prefix Home Assistant listens on. | |
--ha-device-id |
string | derived | Home Assistant device identifier. Requires --home-assistant. Defaults to aqualink_<slug(prefix)>. |
TLS port switch¶
If you set --mqtt-tls and do not pass --mqtt-port explicitly, the default port changes from 1883 to 8883. Passing --mqtt-port always wins.
# TLS on, port left implicit -> connects on 8883
aqualink-automate --mqtt --mqtt-host broker.example.com --mqtt-tls
# TLS on, explicit port -> connects on 8884
aqualink-automate --mqtt --mqtt-host broker.example.com --mqtt-tls --mqtt-port 8884
Dependency rules¶
These are enforced at startup; violating one is a configuration error that stops the program:
--mqtt-client-certand--mqtt-client-keyare mutually required — set both or neither.--home-assistantrequires--mqtt.--ha-device-idrequires--home-assistant.
Auth and password handling¶
- The password is sent only when a username is also set. A password configured without
--mqtt-usernameis omitted from the connection and a warning is logged. - If a password is set but
--mqtt-tlsisfalse, a warning logs that the credentials will be transmitted in cleartext.
Derived identifiers¶
- When
--mqtt-client-idis empty, the client id is generated asaqualink-<8 hex chars>. - When
--ha-device-idis empty, it is derived asaqualink_<slug of the topic prefix>. With the default prefixaqualink, the device id isaqualink_aqualink.
Security¶
Security: MQTT credentials are cleartext without TLS. When you set --mqtt-username and --mqtt-password but leave --mqtt-tls off, the username and password cross the network unencrypted. For any broker reachable beyond localhost, enable --mqtt-tls so credentials and telemetry are protected.
Security: --mqtt-tls-skip-verify disables certificate validation and is not for production. With it set, the client establishes TLS but does not verify the broker's identity, so the connection is vulnerable to interception. Use it only for local testing against a self-signed broker.
When TLS verification is left on (the default once --mqtt-tls is set), the client:
- Negotiates TLS 1.2 as the minimum, disabling SSLv2, SSLv3, TLS 1.0, and TLS 1.1.
- Loads the system trust store (plus
--mqtt-ca-certwhen supplied). - Performs peer verification with RFC 6125 hostname checking and sends SNI.
Topic scheme¶
Every topic is built by prepending <prefix>/ to a subtopic, where <prefix> is --mqtt-prefix (default aqualink). An empty prefix produces the bare subtopic. The examples below assume the default prefix aqualink.
State topics (retained)¶
These carry current state and are published retained, so a subscriber that connects later immediately receives the last known value.
| Topic | Contents |
|---|---|
aqualink/system/status |
{ "online": true, "uptime_seconds": ... } |
aqualink/system/version |
Application version and build date. |
aqualink/system/equipment |
model_number, firmware_revision. |
aqualink/pool/temperatures |
air, pool, spa, freeze_protect, pool_setpoint, pool_setpoint_2, spa_setpoint, pool_heater_2_enabled. Each temperature is an object with celsius/fahrenheit (null when not reported); all except pool_setpoint_2 add a last_updated timestamp and a stale flag, which is only ever true for the live readings (air, pool, spa). |
aqualink/pool/chemistry |
orp.value_mv, ph.value, salt.value_ppm. |
aqualink/pool/circulation |
mode, spa_mode, clean_mode, pool_configuration, optional active_body, optional timeout_remaining_seconds. |
aqualink/pool/configuration |
pool_type, system_board, equipment_mode, date, time. |
aqualink/body/{body}/temperature |
One per body of water: current, setpoint, is_active. {body} is the lowercased body name. |
aqualink/device/{slug} |
Full JSON status blob for one device (see below). |
aqualink/ha/{category}_{slug} |
Short single-string state for one device (see below). (only when --home-assistant is enabled) |
aqualink/status/availability |
online / offline availability. Retained, and used as the Last Will (see below). (only when --home-assistant is enabled) |
aqualink/alert/state |
Consolidated alert document with a boolean per alert condition. |
Statistics topics (not retained)¶
Statistics are published on the --mqtt-stats-interval cadence and are not retained — they are point-in-time samples, so a late subscriber waits for the next interval rather than receiving a stale snapshot.
| Topic | Contents |
|---|---|
aqualink/statistics/messages |
Per-message-type counts. |
aqualink/statistics/bandwidth |
Read/write byte totals and utilisation averages. |
aqualink/statistics/latency |
Serial read/write and message-processing latency snapshots. |
aqualink/statistics/serial |
Error rate, overflow/underflow, transmission failures, write-queue depth. |
Availability and Last Will¶
The availability topic and the MQTT Last Will are only set up when Home Assistant discovery is enabled (--home-assistant). With --mqtt alone there is no Last Will registered and the availability topic is never published.
When --home-assistant is enabled, aqualink/status/availability is retained and carries online or offline. It doubles as the MQTT Last Will: the broker is told to publish offline (retained) if the connection drops ungracefully. The application publishes online on every successful connect, so the device shows available in Home Assistant as soon as it connects and unavailable if it disappears.
Per-device JSON blob and short-string state¶
Each discovered device produces two retained topics. The {slug} is the device label lowercased with spaces, hyphens, and dots collapsed to underscores — so the label Filter Pump becomes the slug filter_pump.
aqualink/device/{slug}— the full JSON status blob for the device, plus atypefield (aux,heater,pump,chlorinator, orlight) and an optionalbody_of_water. Template-based Home Assistant entities (for example the chlorinator's generating-% and boost entities) read this topic.aqualink/ha/{category}_{slug}— a short single-string state namespaced by category, so two devices sharing a label in different categories do not collide. Simple Home Assistant switch and sensor entities read this topic.{category}is one ofaux,heater,pump,chlorinator,light.
Worked example: prefix aqualink, device Filter Pump¶
With the default prefix and a pump labelled Filter Pump (category pump, slug filter_pump):
| Topic | Retained | Carries |
|---|---|---|
aqualink/device/filter_pump |
yes | Full JSON status blob, with "type": "pump". |
aqualink/ha/pump_filter_pump |
yes | Short state string (Running or Off). |
aqualink/command/device/filter_pump |
n/a (subscribe) | Accepts ON / OFF. |
Subscribe to everything under the prefix to watch it live:
Command and response topics¶
On connect, the integration subscribes to aqualink/command/# (QoS 0). Publish a message to a command topic to act; the command name is the part of the topic after command/.
Inbound payloads are parsed as JSON. If a payload is not valid JSON, the raw string is wrapped as { "raw": "<payload>" } — which is why the plain-text commands below (a bare number, or ON / OFF) work as well as JSON. Numeric values are range-checked and rejected when out of range.
| Command topic | Payload | Effect |
|---|---|---|
aqualink/command/status |
(any) | Republish all status topics. |
aqualink/command/refresh |
(any) | Force a status refresh; publishes a response. |
aqualink/command/device |
JSON { "device_id": ..., "action": ... } |
Toggle a device by UUID or label. This command always toggles; the action value is ignored (echoed back in the response only). For explicit on/off, use command/device/{slug} with ON/OFF. |
aqualink/command/device/{slug} |
ON or OFF |
Drive one discovered device on/off. |
aqualink/command/setpoint |
JSON { "target": "pool"\|"spa", "temperature": <celsius> } |
Set a body's temperature. Always Celsius (stable API contract). |
aqualink/command/setpoint/pool |
plain number | Set the pool setpoint. Interpreted in the temperature_units display preference's unit (Celsius by default) — these are the HA number entities' command topics and must match their declared unit. |
aqualink/command/setpoint/spa |
plain number | Set the spa setpoint. Same unit rule as setpoint/pool. |
aqualink/command/chlorinator/percentage |
number 0–100 |
Set the pool chlorinator output percentage. |
aqualink/command/chlorinator/pool/percentage |
number 0–100 |
The same, stated explicitly. |
aqualink/command/chlorinator/spa/percentage |
number 0–100 |
Set the spa chlorinator output percentage. |
aqualink/command/chlorinator/boost |
ON or OFF |
Enable or disable chlorinator boost. |
aqualink/command/circulation/mode |
pool, spa, or spillover |
Set the circulation mode. |
aqualink/command/heater/{slug} |
ON or OFF |
Enable or disable one discovered heater's thermostat (routed to the heater's body of water; the controller decides when to actually fire). |
Note: The device/{slug}, heater/{slug}, chlorinator/*, and per-device commands are registered once the matching pool devices are discovered on the wire. Until a device is seen, its command topic has no handler.
Responses¶
The refresh, device, and setpoint commands publish a response to aqualink/response/{command} as JSON (the status command republishes all status topics but publishes no response):
{
"command": "setpoint",
"status": "success",
"timestamp": 1718409600,
"target": "pool",
"temperature": 28.5
}
The status field reflects the dispatch outcome (for example success, device_not_found, no_serial_adapter, unknown_equipment_type, invalid_value).
Home Assistant discovery¶
When --home-assistant is set, the application publishes a single retained discovery payload to:
With defaults this is homeassistant/device/aqualink_aqualink/config. This is the single-device, bundled-components form: one payload registers the whole device and all its entities. Its top-level keys are:
dev— the device object.o— the origin (this application).availability— points ataqualink/status/availabilitywithonline/offline.cmps— the components map; each member is one entity carrying ap(platform) field.
Device object¶
The dev object identifies the single Home Assistant device:
identifiers:[<ha-device-id>]name:aqualink-automatemanufacturer:Jandy / Zodiacsw_version: the project versionmodelandhw_version: filled from the panel's equipment model and firmware when known.
Each entity's unique_id follows the pattern <ha-device-id>_<suffix>.
Static entity types¶
These entities are always published (pool-/spa-specific rows are omitted on systems without that body; before the pool configuration is detected, both bodies' entities are emitted):
| Entity | Platform | Notes |
|---|---|---|
| Pool / Spa / Air / Freeze Protect temperatures | sensor |
device_class: temperature, unit degC. |
| Pool / Spa / Air Temperature Last Updated | sensor |
device_class: timestamp, entity_category: diagnostic. Freshness companion for each live temperature, reading its last_updated field. |
| Pool / Spa / Air Temperature Stale | binary_sensor |
device_class: problem, entity_category: diagnostic. On when the reading is older than --temperature-staleness-threshold (a never-seen reading is null, not stale). Lets you see a reading has gone old without the value disappearing — the controller only reports temperatures while the filter pump runs. |
| Pool / Spa setpoint | number |
Follows the temperature_units preference: min 15, max 41, step 0.5, °C (default), or min 59, max 106, step 1, °F. Command on command/setpoint/{pool,spa}, interpreted in the declared unit. Changing the preference republishes discovery so HA picks up the new unit/range. (Temperature sensors stay declared °C — their payload is Celsius and HA converts sensors to its own unit system.) |
| Pool Setpoint 2 | sensor |
The POOLSP2 / panel "TEMP2" maintenance setpoint (reported only on single-body systems). Read-only — a sensor, not a writable number — because the POOLSP2 write path is not yet validated on live hardware. Shows a value only when the panel reports it. |
| Pool Heater 2 (TEMP2) | binary_sensor |
Whether TEMP2 maintenance heating is enabled. Read-only, payload_on: true / payload_off: false. |
| Spa Mode (switch) | switch |
Dual-body systems only. Convenient pool↔spa toggle: on sends spa, off sends pool on command/circulation/mode (the same handler as the select below). |
| ORP | sensor |
device_class: voltage, unit mV. |
| pH | sensor |
device_class: ph. |
| Salt Level | sensor |
unit ppm. |
| Circulation Mode | sensor |
From pool/circulation. |
| Equipment Mode | sensor |
From pool/configuration (Normal / Service / TimeOut). |
| Spa Mode | binary_sensor |
payload_on: true / payload_off: false. |
| Clean Mode | binary_sensor |
payload_on: true / payload_off: false. |
| Circulation Mode (select) | select |
Dual-body systems only. Options Pool, Spa, Spillover. Command on command/circulation/mode. |
| Uptime | sensor |
device_class: duration, entity_category: diagnostic. |
| Alerts (6) | binary_sensor |
device_class: problem. See below. |
Alert binary sensors¶
Six binary_sensor entities (device_class: problem, payload_on: true / payload_off: false) read the consolidated document at aqualink/alert/state:
| Key | Display name |
|---|---|
chlorinator_fault |
Chlorinator Fault |
chlorinator_warning |
Chlorinator Warning |
salt_low |
Salt Low |
service_mode |
Service Mode |
serial_comms_loss |
Serial Comms Loss |
temperature_stale |
Temperature Stale |
temperature_stale raises when the filter pump is running but the active body's water temperature has not refreshed within --temperature-staleness-threshold; a reading going stale while the pump is off is expected (no flow past the sensor) and never raises.
Dynamic device entities¶
One entity (or a small set) is published per discovered pool device:
| Device kind | Entity | State values |
|---|---|---|
| Pump | switch |
state_on: Running, state_off: Off. |
| Auxiliary | switch |
state_on: On, state_off: Off. |
| Heater | sensor + switch |
Sensor: Off / Heating / Enabled. Switch "{label} Enable": on when the heater is Heating or Enabled, command on command/heater/{slug} — it enables/disables the heater's thermostat; the controller decides when to actually fire and enforces its own preconditions (e.g. spa heat requires spa mode + pump). |
| Chlorinator | switch + extras |
state_on: On, state_off: Off, plus the entities below. |
| Light | sensor + binary_sensor |
Read-only. Sensor: the raw state (On / Off / Enabled / Pending / Unknown). Binary sensor "{label} On": ON when the raw state is On or Enabled, else OFF. Plus a sensor "{label} Colour Mode" reading light_mode from the device's JSON blob. |
Switch entities use payload_on: ON / payload_off: OFF on their command topic.
Why lights are read-only. A light here is a separate RS-485 colour-light controller (bus ids
0xF0–0xF4) discovered by snooping its status messages — not the aux relay that switches it. That relay is published in its own right as a controllableAuxiliaryswitch, and is the entity to automate against. A light device carries no aux id and only a synthetic label, so every actuation path (the serial adapter, which needs an aux id; the IAQ and OneTouch, which match an on-screen button by label) fails to map it — a switch entity here would be a permanently-broken duplicate of the working aux switch. What the light device does contribute is the colour/mode byte, which the relay cannot report.Making lights writable needs a light-controller → aux-relay correlation, which is not derivable from the wire and so would need a configuration surface.
The chlorinator's extra entities read its JSON blob at aqualink/device/{slug}:
sensor"Generating %" fromgenerating_percentage(unit%) — the instantaneous output,0whenever the cell is idle.sensor"Target %" fromsetpoint_percent(unit%) — the configured output for the currently active body, which "Generating %" never shows while idle.sensor"Output State" fromgenerating_reason— why the cell is at that output. Without it a0reading is ambiguous in an automation. Values:Generating,Off(setpoint is 0%),PumpOff(configured, but no filter pump is running),NoFlow(a pump is running but the cell reports no flow),Fault(the cell is in a warning/error state),Idle(configured and not generating, no nameable reason),Unknown(nothing reported yet).sensor"Boost Mode" fromboost_mode.sensor"Health" fromchlorinator_health.number"Pool Output" — min 0, max 100, readspool_setpoint_percent, command oncommand/chlorinator/percentage.number"Spa Output" — min 0, max 100, readsspa_setpoint_percent, command oncommand/chlorinator/spa/percentage.switch"Boost" — command oncommand/chlorinator/boost.
The pool and spa setpoints are independent — the panel holds one for each body, so setting one never changes the other. The pool control keeps the unique id the old single "Generating Setpoint" had (which already drove the pool), so existing dashboards and automations keep working; its value template is corrected, since it previously read the instantaneous output and so snapped back to 0 whenever the cell was idle.
A chlorinator sitting at
0 %withOutput State: PumpOffis working normally — it produces only while water is flowing past the cell. Automate ongenerating_reason, not on the percentage alone.
Worked setup¶
This walks you from a fresh broker to discovered entities in Home Assistant.
1. Run a mosquitto broker¶
If you do not already have a broker, the simplest path is a local mosquitto with a minimal config:
# /etc/mosquitto/conf.d/aqualink.conf
listener 1883
allow_anonymous true # local testing only; require auth + TLS for anything networked
2. Start aqualink-automate with MQTT and Home Assistant¶
aqualink-automate \
--serial-port /dev/ttyUSB0 \
--mqtt --mqtt-host 192.168.1.100 --mqtt-port 1883 \
--home-assistant --ha-discovery-prefix homeassistant
The same settings as a config file (keys are the long names without dashes):
# Serial
serial-port = /dev/ttyUSB0
# MQTT
mqtt = true
mqtt-host = 192.168.1.100
mqtt-port = 1883
# Home Assistant
home-assistant = true
ha-discovery-prefix = homeassistant
The shipped examples/config-serial.conf and examples/config-network.conf already contain a working MQTT + Home Assistant block to copy from.
3. Enable the Home Assistant MQTT integration¶
In Home Assistant, add the MQTT integration and point it at the same broker (192.168.1.100:1883). With the discovery prefix left at the default homeassistant, the aqualink device and all its entities appear automatically — no YAML required. The device shows as aqualink-automate by Jandy / Zodiac.
4. Verify and control from the command line¶
Watch the live tree:
Read the Filter Pump short state:
Turn the Filter Pump on:
# Plain ON/OFF payload on the per-device command topic
mosquitto_pub -h 192.168.1.100 -t 'aqualink/command/device/filter_pump' -m 'ON'
Set the pool setpoint to 28.5 C two equivalent ways:
# Plain number on the per-target topic
mosquitto_pub -h 192.168.1.100 -t 'aqualink/command/setpoint/pool' -m '28.5'
# JSON on the generic setpoint topic
mosquitto_pub -h 192.168.1.100 -t 'aqualink/command/setpoint' -m '{"target":"pool","temperature":28.5}'
Connection behavior¶
The MQTT client speaks MQTT 3.1.1 (protocol level 4), QoS 0 only, with a clean session and a 60-second keepalive. The CONNACK timeout is 10 seconds. On disconnect it reconnects with exponential backoff and jitter (initial 5 s, max 300 s). The outbound publish queue is capped at 1000 messages, dropping the oldest when full, with a 1 MB read buffer.
Live MQTT diagnostics — queue depth, reconnect attempts, published and dropped counts, and the last error — are available over HTTP at GET /api/diagnostics/mqtt. See Usage and API for that endpoint and the HTTP control alternative to MQTT commands.
See also¶
- Configuration reference — the canonical option table for every flag and config key.
- Usage and API —
GET /api/diagnostics/mqttand the HTTP control surface. - INSTALL.md — running with a config file or under Docker.