Jandy AquaLink RS
Driver: jandy
Control Jandy AquaLink RS pool/spa controllers over their RS485 bus via a serial-to-Ethernet bridge (e.g. USR-TCP232). The driver speaks the raw #-command protocol, supports circuit on/off and heater setpoints, and monitors per-zone state, temperature, and setpoint. On connect it subscribes to asynchronous change pushes (#COSMSGS=1), so most updates arrive unsolicited rather than from polling.
Prerequisites
- A Jandy AquaLink RS system wired to a serial-to-Ethernet bridge on the RS485 bus.
- The bridge reachable on the LAN from the GEM server (typically TCP port 23).
Setup
- Connect the serial-to-Ethernet bridge to the Jandy AquaLink RS485 bus.
- In GEM, System → Devices → Add Device with driver
jandy. Enter the bridgeipandport. - Let the controller run for a minute so registers populate the device
discovered_addresseslist, then run thesync_zonescommand to auto-create one zone per register. - (Optional) For a composite zone whose state, temperature, and setpoint live at different registers, create it by hand and set
jandy_state_address/jandy_temperature_address/jandy_setpoint_addressinstead of relying on the zone address.
Addressing
Every Jandy zone is bound to one or more AquaLink registers (e.g. S01, POOLTMP, POOLHTR). A register name is a letter followed by up to 15 more letters or digits, with no spaces. There are two ways to bind a zone:
- Single register (auto-create path). Put one register in the zone address field. The driver decides what that register means — on/off state, temperature, or setpoint — by classifying it (see below) and tracks that one role. This is exactly what
sync_zonescreates: one register per zone. - Composite zone (hand-built). When one piece of equipment exposes its state, temperature, and setpoint at different registers (e.g. a heater whose on/off is one register and target temperature another), make a single zone and fill in the per-role attributes. An explicit attribute always wins for its role; leave the ones you don't need empty.
| Role | Attribute | Falls back to the zone address when the attribute is empty? |
|---|---|---|
| on/off | jandy_state_address | Yes — always, as long as the address looks like a register. |
| setpoint | jandy_setpoint_address | Only when the address itself classifies as a setpoint register. |
| temperature | jandy_temperature_address | Only when the address itself classifies as a temperature register. |
So the fallback is asymmetric: on/off always falls through to the address, but setpoint and temperature fall through only when the address is itself that kind of register. A bare-address zone therefore tracks exactly one role (whatever its register classifies as) — use the composite attributes when one zone must follow more than one register.
How a register is classified
Whenever the driver has only the address field to go on, it classifies the register to decide which role it drives — and, at sync_zones time, which control type the new zone gets. A live value wins when there is one; otherwise the register name decides:
- value
on/off(or1/2→ on,0→ off) → on/off state - value like
78 For24 C→ temperature - name ends in
TMPor containsTEMP→ temperature - name contains
HTR,SETPT,STPT, or ends inSP→ setpoint - anything else → on/off state
So POOLTMP is read as a temperature, POOLHTR as a heater setpoint, and S01 (a spa circuit) as on/off — with no per-role attributes at all. sync_zones classifies by name only (it runs before any value is on the wire), so name your registers accordingly where you can.
sync_zones
sync_zones reads device.discovered_addresses and creates one zone per register, then:
- classifies each register by name (no value is known yet) and gives the new zone a matching control type: on/off →
water_onoff, setpoint →water_onoff_setpoint, temperature →water_temperature; - places every zone under the water subsystem.
wateris one of GEM's default subsystems, so it normally already exists.sync_zonesdoes not split pool vs. spa and does not create the subsystem — ifwateris missing, every register is skipped with acould not resolve subsystemwarning; - names each new zone after its register, so the zone label is the raw register (e.g.
POOLHTR). Rename it to something friendly like "Pool Heater" afterward — the address stays the same, so the binding is unaffected; - is idempotent — registers already represented by an existing zone (by address or any
jandy_*_addressattribute) are skipped, never modified.
Run it any time after discovered_addresses has populated. If it returns "no discovered addresses yet", let the controller run longer so registers appear on the wire, then re-run.
Attributes
Device
| Attribute | Required | Default | Description |
|---|---|---|---|
ip | yes | — | Serial bridge IP. |
port | yes | — | Serial bridge TCP port. |
polling_rate | no | 3000 | Delay (ms) between individual register reads in the heartbeat sweep. RS485 is single-master — don't set below the panel's own poll cadence. |
heartbeat_interval | no | 60000 | How often (ms) to re-sweep configured zone registers as a sanity check against the COSMSGS push subscription. |
command_throttle | no | 1000 | Minimum spacing (ms) between commands. |
discovered_addresses | — (read-only) | — | Comma-separated registers seen on the wire. Auto-populated; the menu for hand-wiring zones and the source list sync_zones builds from. |
Zone
| Attribute | Description |
|---|---|
jandy_state_address | AquaLink register for on/off state (composite override). |
jandy_temperature_address | AquaLink register for temperature (composite override). |
jandy_setpoint_address | AquaLink register for the heater/temperature setpoint (composite override). |
Commands
| Command | Args | Effect |
|---|---|---|
get | address | Query the value at an AquaLink register. |
on | address | Turn on the circuit at the register (or the zone's state register). |
off | address | Turn off the circuit at the register (or the zone's state register). |
setpoint | address, setpoint | Set the heater/temperature setpoint (or the zone's setpoint register). |
set | address, value | Set an arbitrary register to a value. |
passthrough | command | Send a raw AquaLink command string. |
reset | — | Reset the AquaLink controller. |
sync_zones | — | Auto-create one zone per discovered register (idempotent — see above). |
Known behavior
- On every (re)connect the driver sends
#COSMSGS=1so the bridge pushes register changes asynchronously; most state updates come in unsolicited. A slow heartbeat sweep (everyheartbeat_interval, default 60 s) re-reads each configured register as a fallback in case the push subscription silently dies. - On/off circuits report in several forms —
on/off,1/0,yes/no,true/false— and a circuit running on a schedule may report2; the driver treats1and2alike as on. Temperatures arrive as75 For23 Cand are normalized to a whole number, converting Celsius to Fahrenheit, before storage. - on/off/setpoint commands schedule a delayed re-poll (about 1 s later) of the matching register as a belt-and-suspenders confirmation, on top of whatever
#COSMSGS=1pushes.
Troubleshooting
| Symptom | Check |
|---|---|
jandy error response in logs / no zone updates | Verify the register address — invalid AquaLink registers are echoed back with a leading ?. |
| Status updates lag behind reality | Live updates ride the #COSMSGS=1 push subscription; if it stops delivering, the only backstop is the heartbeat sweep (heartbeat_interval, default 60 s). Lower heartbeat_interval for a faster backstop, and/or lower polling_rate to tighten the per-register cadence within a sweep — but watch for RS485 bus contention with the AquaLink panel itself. |
| Setpoint command silently does nothing | Confirm a setpoint register resolves for the zone (its address classifies as a setpoint, or jandy_setpoint_address is set) and that the register supports writes — some are read-only. |
no discovered addresses yet from sync_zones | Let the controller run so registers appear in discovered_addresses, then run sync_zones again. |
sync_zones skips every register / creates no zones | Confirm a water subsystem exists — sync_zones places all Jandy zones under it and skips registers (logging could not resolve subsystem) when it's missing. |