I have an Intex PureSpa Baltik inflatable spa in the garden. It works great. The iOS app that comes with it, on the other hand, is a disaster: you need a Tuya cloud account, auth drops every three days, half the commands take 10 seconds to land, and the UI looks like a POC abandoned in 2018. For a device that lives on my Wi-Fi 3 meters from my Mac, the round trip through a server in China felt pointlessly absurd.
So I dumped the app and wrote my own. One weekend of reverse engineering, then a few evenings stacking the features the official app will never ship. Here’s how it turned out.
Reverse engineering in 30 minutes
I got insanely lucky: mathieu-mp/aio-intex-spa had already done the thankless work. The spa’s Wi-Fi module listens on TCP port 8990, the protocol is binary with a simple checksum (modulo 0xFF, not 0x100 — that’s the classic trap), and the functional commands (power, heat, filtration, bubbles) are toggles: you read the current state and only send if the desired state differs. Idempotent by construction.
I validated it byte-for-byte against my actual spa with a standalone probe.py (stdlib only, zero deps), then packaged that into a pure protocol.py layer that tests offline.
Three invariants drive the whole architecture:
- One TCP connection only. The firmware accepts a single client at a time on 8990. Everything goes through one
IntexSpaClientwith an asyncio lock, owned by oneSupervisor. - Polling doubles as keepalive. The firmware closes the socket if nothing talks for too long. So we poll every 10 s, and that also feeds the UI over SSE.
- Stale-but-useful. If the spa becomes unreachable, we keep the last known reading and show an “offline” banner. The next poll catches everything up.
What the official app will never do
A real web UI
FastAPI + HTMX + Chart.js (vendored, no CDN — the app lives on the LAN, it has to work if the internet drops). A mobile-first page, a 7-day temperature chart, instant controls. All served by a single uvicorn process on my Mac running 24/7.

A weather-aware scheduler
The Intex app offers a rudimentary timer that isn’t even exposed over the LAN protocol — so I wrote my own. Three primitives: setpoint by time slot, filtration windows, and most importantly “ready by 6 PM” which figures out when to start heating.
The interesting twist: the heating rate isn’t constant. A spa loses heat proportionally to (water − outdoor air). So I pull local weather from Open-Meteo (free, no API key), learn the thermal loss coefficient from the history of heating and cooling phases, and the scheduler automatically shifts the start time earlier when it’s cold. If the night will be 4 °C, we start heating 90 min ahead; if it’s 18 °C, 30 min is enough.
A camera with cover detection (experimental)
The spa is partly visible in the field of an outdoor IP camera. I plugged in ffmpeg to grab a frame every 10 s, write it atomically (tmp + replace), rebuild a daily mp4 timelapse on the fly, and — for kicks — detect whether the cover is on via a calibratable ROI and a luma + std-dev heuristic. It’s partial and flaky at night, so it isn’t wired into the scheduler in v1. But the plumbing is there for the day it is.

A launchd service that survives silent failures
This is the part that took me the longest and that nobody talks about. On this machine, CPython 3.14 was silently killing the service after ~30 s under launchd — no traceback, no log, just a dying process. Culprit: a segfault in uvloop / httptools / pydantic-core on long loops. Back to 3.12, problem gone. And then all the plist settings you only learn the hard way: ThrottleInterval=15 to avoid respawn loops that saturate launchd, ProcessType=Adaptive (not Background, otherwise jetsam kills us first under memory pressure), ExitTimeOut=20 to let the TCP connection close cleanly, and crucially no --workers 1 on uvicorn (that flag flips it into multiprocess mode and wedges under launchd; the default single-process does exactly what you want).
Stack
Python 3.12 + FastAPI + HTMX + Chart.js + ffmpeg + Open-Meteo + launchd + ngrok for remote access. No database: the whole state fits in a handful of JSON and JSONL files under state/. 135 offline tests that run in 3 seconds without touching the real spa (a fake_spa.py replays the protocol).
The reusable pattern
This black box looks like every other one: a low-end IoT device that demands a cloud account to do the most trivial job. The pattern works for Tuya bulbs, Somfy shutters, and probably half the things in your house too:
- Find the open TCP port on the LAN (an
nmapusually does it). - Capture a few exchanges with Wireshark from the official app.
- Identify the checksum and framing (protocol docs are rarely public, but rarely complicated either).
- Rewrite a minimal client, validate byte-for-byte, add what the vendor never wanted to build.
- Block the device’s WAN egress at the router, end of phone-home.
The code is public: github.com/sxnlabs/intex-spa. Permissive license. If you have a PureSpa Baltik, you can literally fork and install it. And if you have another device with the same pattern, read intex_spa/protocol.py — it’s a good template for starting your own.
If you have one of these at work
I run into this black-box situation regularly on the consulting side: an industrial machine with an undocumented API, a sensor stuck behind a cloud app that bottlenecks the whole team, a B2B home-automation integration the vendor won’t build. That’s the kind of thing I unblock at SXN Labs. If you have a device that should just talk to your stack, drop me a line.