A FastAPI microservice that controls a Jandy iAquaLink pool/spa system via the iAquaLink cloud API. Exposes HTTP endpoints for spa/pool control and runs a background safety watchdog that monitors temperatures, triggers emergency shutdowns, and sends Pushover alerts when thresholds are exceeded.
- Spa on/off — toggles spa pump, spa heater, air blower (aux_1), and pool heater in the correct sequence
- One-touch scene execution — All OFF and Spa Mode via the controller's built-in scenes
- Overheat protection — watchdog shuts everything off and sends a Pushover alert if spa or pool temp exceeds
TEMP_OVERHEAT_THRESHOLD(default 105°F) - Freeze/underheat protection — watchdog turns on pool heater and sends a Pushover alert if spa or pool temp drops below
TEMP_UNDERHEAT_THRESHOLD(default 80°F) - Configurable watchdog interval — poll frequency controlled by
TEMP_MONITOR_INTERVAL(default 600s) - Mechanical cooldown lock — configurable lockout between valve-switching commands to protect the diverter (default 60s)
- API key authentication — all endpoints require an
X-API-Keyheader - Debug endpoints — inspect all devices and one-touch scenes at runtime
- Python 3.14
- A Jandy iAquaLink-compatible controller registered with the iAquaLink cloud service
| Package | Purpose |
|---|---|
fastapi |
Web framework / API server |
uvicorn[standard] |
ASGI server |
iaqualink |
iAquaLink cloud API client |
python-dotenv |
Loads credentials from .env |
httpx |
HTTP client (Pushover alerts, iaqualink, test_tool.py) |
-
Install dependencies
pip install -r requirements.txt
-
Create a
.envfile in the project root:AQUALINK_EMAIL=your@email.com AQUALINK_PASSWORD=yourpassword AQUALINK_SPACONTROL_API_KEY=your-secret-api-key AQUALINK_SERIAL=your_system_serial_number TEMP_OVERHEAT_THRESHOLD=105 TEMP_UNDERHEAT_THRESHOLD=80 TEMP_MONITOR_INTERVAL=600 VALVE_COOLDOWN=60 PUSHOVER_USER=your_pushover_user_key PUSHOVER_TOKEN=your_pushover_api_token BASE_URL=http://127.0.0.1:8000
AQUALINK_EMAIL,AQUALINK_PASSWORD, andAQUALINK_SPACONTROL_API_KEYare required — the server will refuse to start without them. All other variables are optional; defaults are shown above. -
Run the server
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
The API is now available at
http://127.0.0.1:8000. -
Interactive API docs are auto-generated by FastAPI at:
http://127.0.0.1:8000/docs
All endpoints require an X-API-Key header matching the AQUALINK_SPACONTROL_API_KEY environment variable. Requests without a valid key receive a 401 Unauthorized response.
Header format:
X-API-Key: your-secret-api-key
All control endpoints accept both GET and POST for compatibility with browsers and iOS Shortcuts.
| Method | Path | Description |
|---|---|---|
GET/POST |
/spa-on |
Turns on spa pump + spa heater; turns off pool heater; enables air blower (aux_1) after 30s |
GET/POST |
/spa-off |
Turns off spa pump + spa heater + air blower; turns on pool heater |
GET/POST |
/all-off |
Executes one-touch scene 1 — shuts everything off |
GET/POST |
/spa-mode |
Executes one-touch scene 2 — activates spa mode via controller scene |
| Method | Path | Description |
|---|---|---|
GET |
/devices |
Returns all devices with key, type, state, is_on, and label |
GET |
/onetouch |
Returns raw one-touch scene list from the controller |
Requires LODGIFY_API_KEY; returns 503 if not set, 502 if Lodgify returns an error. The client lives in lodgify.py — add new Lodgify endpoints as methods on LodgifyClient.
| Method | Path | Description |
|---|---|---|
GET |
/lodgify/bookings?page=1&size=50&stay_filter=Current |
Lists Lodgify bookings (GET /v2/reservations/bookings) |
A mobile web page guests use instead of the Leviton switch (no IFTTT in the path). The root URL / redirects here, so a QR code to the domain works.
- Login: the guest enters their 4-digit door code. It's matched against
rooms[].key_codeon the current Lodgify booking (stayFilter=Current). - Access window: from the arrival date until the departure date at
check_out.time, inPROPERTY_TIMEZONE. Update the checkout time in Lodgify for a late checkout and access extends automatically (within ~60s). - Every action re-checks Lodgify, so a cancelled booking or changed door code stops working right away.
- Controls: spa on/off (same logic and
VALVE_COOLDOWNas/spa-on//spa-off), blower (aux_1), spa/pool set points limited toSPA_TEMP_*/POOL_TEMP_*, and live spa/pool/air temperatures. - Brute-force protection: 5 wrong codes from one IP → 15-minute lockout; 30 wrong codes in an hour from anywhere → guest login disabled for 1 hour. Both send a Pushover alert and can be cleared from the admin page.
- Pushover alert on each successful guest login.
Password login (ADMIN_PASSWORD). Shows the current booking (door code masked), live spa status, active lockouts (with Clear), and an activity log of guest logins/failures, lockouts, guest actions, owner API calls, watchdog alarms and admin logins. Events are stored in SQLite at EVENT_DB_PATH and kept for 90 days.
Spa on/off — success
{"status": "success", "action": "spa_on", "note": "aux_1 will enable in 30s"}Cooldown active
{"status": "ignored", "reason": "Mechanical cooldown in progress"}Unauthorized
{"detail": "Invalid API Key"}One-touch — success
{"status": "success", "action": "all_off", "response": {...}}| Key | Type | Label |
|---|---|---|
spa_pump |
IaquaSwitch | Spa Pump |
pool_pump |
IaquaSwitch | Pool Pump |
spa_heater |
IaquaSwitch | Spa Heater |
pool_heater |
IaquaSwitch | Pool Heater |
spa_set_point |
IaquaThermostat | Spa Set Point (102°F) |
pool_set_point |
IaquaThermostat | Pool Set Point (89°F) |
spa_temp |
IaquaSensor | Spa Temp |
pool_temp |
IaquaSensor | Pool Temp |
air_temp |
IaquaSensor | Air Temp |
aux_1 |
IaquaAuxSwitch | Air Blower |
aux_2 |
IaquaAuxSwitch | Aux2 |
aux_3 |
IaquaAuxSwitch | Aux3 |
aux_EA |
IaquaAuxSwitch | Extra Aux |
| Scene | Command | Label |
|---|---|---|
onetouch_1 |
set_onetouch_1 |
All OFF |
onetouch_2 |
set_onetouch_2 |
Spa Mode |
onetouch_3 |
set_onetouch_3 |
Clean Mode |
onetouch_4 |
set_onetouch_4 |
Pool Heat |
test_tool.py is a command-line client for the running server. It reads BASE_URL and AQUALINK_SPACONTROL_API_KEY from .env and sends the API key on every request.
python test_tool.py spa-on
python test_tool.py spa-off
python test_tool.py all-off
python test_tool.py spa-mode
python test_tool.py devices
python test_tool.py onetouchSet BASE_URL in .env to target the Railway deployment:
BASE_URL=https://your_domain.up.railway.appOmit BASE_URL to default to http://127.0.0.1:8000 for local testing.
Railway can host this service with a public HTTPS URL and automatic deploys from GitHub.
Make sure the repo is pushed to GitHub. Railway deploys directly from a branch.
- Go to railway.app and log in
- Click New Project → Deploy from GitHub repo
- Select this repository and the
mainbranch
The railway.toml in the repo root configures the start command automatically:
[deploy]
startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT --proxy-headers --forwarded-allow-ips='*'"Railway injects $PORT — no additional configuration needed. --proxy-headers makes the real client IP visible to the app (needed for guest login lockouts and the activity log).
In the Railway dashboard, go to your service → Settings → Networking → Generate Domain. When prompted for a port, enter 8000. Railway will assign $PORT=8000 and generate a public HTTPS URL.
In the Railway dashboard, go to your service → Variables and add:
| Variable | Required | Notes |
|---|---|---|
AQUALINK_EMAIL |
yes | iAquaLink account email |
AQUALINK_PASSWORD |
yes | iAquaLink account password |
AQUALINK_SPACONTROL_API_KEY |
yes | Secret key sent in X-API-Key header |
AQUALINK_SERIAL |
no | Controller serial; defaults to first system on account |
TEMP_OVERHEAT_THRESHOLD |
no | Default 105 |
TEMP_UNDERHEAT_THRESHOLD |
no | Default 80 |
TEMP_MONITOR_INTERVAL |
no | Default 600 (seconds) |
VALVE_COOLDOWN |
no | Default 60 (seconds) |
PUSHOVER_USER |
no | Pushover user key for alerts |
PUSHOVER_TOKEN |
no | Pushover API token for alerts |
LODGIFY_API_KEY |
no | Lodgify public API key (enables /lodgify/* and /guest) |
SESSION_SECRET |
for /guest, /admin | Long random string for signing login cookies |
ADMIN_PASSWORD |
for /admin | Admin activity page password |
EVENT_DB_PATH |
recommended | /data/events.db (see volume below) |
SPA_TEMP_MIN / SPA_TEMP_MAX |
no | Guest spa set-point range, default 90–102 |
POOL_TEMP_MIN / POOL_TEMP_MAX |
no | Guest pool set-point range, default 82–90 |
PROPERTY_TIMEZONE |
no | Default America/New_York |
Do not commit a .env file to the repo.
Event log volume: Railway's filesystem is wiped on every deploy. To keep the admin activity history, add a volume to the service (right-click the service → Attach Volume) mounted at /data, and set EVENT_DB_PATH=/data/events.db.
Generate a SESSION_SECRET with:
python -c "import secrets; print(secrets.token_urlsafe(48))"The .python-version file in the repo pins Python 3.14. Railway's Nixpacks builder reads this file automatically — no additional configuration needed.
Railway triggers a deploy on every push to the selected branch. Once deployed, your public URL will be available in the Railway dashboard.
| Variable | Required | Default | Description |
|---|---|---|---|
AQUALINK_EMAIL |
yes | — | iAquaLink account email |
AQUALINK_PASSWORD |
yes | — | iAquaLink account password |
AQUALINK_SPACONTROL_API_KEY |
yes | — | API key required on all requests (X-API-Key header) |
AQUALINK_SERIAL |
no | first system | Controller serial number |
TEMP_OVERHEAT_THRESHOLD |
no | 105 | Overheat alarm threshold in °F |
TEMP_UNDERHEAT_THRESHOLD |
no | 80 | Underheat/freeze alarm threshold in °F |
TEMP_MONITOR_INTERVAL |
no | 600 | Watchdog poll interval (seconds) |
VALVE_COOLDOWN |
no | 60 | Lockout between valve commands (seconds) |
PUSHOVER_USER |
no | — | Pushover user key |
PUSHOVER_TOKEN |
no | — | Pushover API token |
LODGIFY_API_KEY |
no | — | Lodgify public API key (X-ApiKey); enables /lodgify/* and the guest portal |
SESSION_SECRET |
for /guest, /admin | — | Secret for signing login cookies (32+ random chars) |
ADMIN_PASSWORD |
for /admin | — | Admin activity page password |
SPA_TEMP_MIN |
no | 90 | Lowest spa set point a guest can choose (°F) |
SPA_TEMP_MAX |
no | 102 | Highest spa set point a guest can choose — must be ≤ 104 and below TEMP_OVERHEAT_THRESHOLD |
POOL_TEMP_MIN |
no | 82 | Lowest pool set point a guest can choose (°F) |
POOL_TEMP_MAX |
no | 90 | Highest pool set point a guest can choose — same limits as spa |
PROPERTY_TIMEZONE |
no | America/New_York | Timezone for arrival/checkout times |
EVENT_DB_PATH |
no | ./data/events.db | SQLite activity log location |
BOOKING_SITE_URL |
no | https://sunshinevilladeerfield.com/ | "Book direct" link on the guest page |
COOKIE_SECURE |
no | true | Set false only for local testing over plain http |
BASE_URL |
no | http://127.0.0.1:8000 | Target URL for test_tool.py |
- Overheat protection — the watchdog monitors
spa_tempandpool_tempeveryTEMP_MONITOR_INTERVALseconds. If either reads ≥TEMP_OVERHEAT_THRESHOLD°F,set_onetouch_1(All OFF) is triggered and a Pushover alert is sent. If the scene call fails, pumps are shut off individually as a fallback. - Freeze/underheat protection — if either sensor reads ≤
TEMP_UNDERHEAT_THRESHOLD°F,set_onetouch_4(Pool Heat) is triggered and a Pushover alert is sent. - Mechanical cooldown —
VALVE_COOLDOWNseconds must elapse between valve-switching commands to protect the diverter. - Credentials are never logged or exposed through any API endpoint.
| Command | Description |
|---|---|
get_home |
Home-screen state (online status, temps, heaters, pumps) |
get_devices |
Auxiliary device state (lights, aux switches) |
get_onetouch |
Saved one-touch scenes |
| Command | Triggered by | Description |
|---|---|---|
set_spa_pump |
spa_pump.turn_on/off() |
Toggles spa pump / spa mode |
set_pool_pump |
pool_pump.turn_on/off() |
Toggles pool pump |
set_spa_heater |
spa_heater.turn_on/off() |
Toggles spa heater |
set_pool_heater |
pool_heater.turn_on/off() |
Toggles pool heater |
set_solar_heater |
solar device | Toggles solar heater |
set_temps |
thermostat.set_temperature(n) |
Sets spa/pool target temperature |
set_aux |
aux_switch.turn_on/off() |
Toggles an auxiliary switch or light |
set_light |
light.set_brightness/effect() |
Sets brightness or color effect |
set_onetouch_N |
/all-off, /spa-mode |
Executes one-touch scene N |