J’ai un spa gonflable Intex PureSpa Baltik dans le jardin. Il fonctionne très bien. L’application iOS qui va avec, en revanche, est un drame : il faut un compte cloud Tuya, l’auth lâche tous les trois jours, la moitié des commandes mettent 10 secondes à arriver, et l’interface ressemble à un POC oublié en 2018. Pour un objet qui passe sa vie sur mon Wi-Fi à 3 mètres de mon Mac, l’aller-retour par un serveur en Chine me paraissait inutilement absurde.
Donc j’ai jeté l’app et j’ai écrit la mienne. Un week-end de reverse engineering, puis quelques soirs pour empiler les features que l’app officielle n’aura jamais. Voici ce que ça donne.
Le reverse engineering en 30 minutes
J’ai eu une chance énorme : mathieu-mp/aio-intex-spa avait déjà fait le boulot ingrat. Le module Wi-Fi du spa écoute en TCP sur le port 8990, le protocole est binaire avec un checksum simple (modulo 0xFF, pas 0x100, c’est le piège classique), et les commandes fonctionnelles (power, chauffe, filtration, bulles) sont des toggles : on lit l’état courant et on n’envoie que si l’état désiré diffère. C’est idempotent par construction.
J’ai validé byte-for-byte contre mon vrai spa avec un probe.py autonome (stdlib only, zéro dépendance), puis empaqueté ça dans une couche protocol.py pure qui se teste hors ligne.
Trois invariants qui dictent toute l’architecture :
- Une seule connexion TCP. Le firmware n’accepte qu’un client à la fois sur 8990. Tout passe par un unique
IntexSpaClientavec un lock asyncio, possédé par un uniqueSupervisor. - Le polling sert de keepalive. Le firmware ferme la socket si rien ne parle pendant trop longtemps. Donc on poll toutes les 10 s et ça nourrit aussi l’UI en SSE.
- Stale-but-useful. Si le spa devient injoignable, on garde la dernière lecture connue et on affiche un bandeau « hors ligne ». Le prochain poll récupère tout.
Ce que l’app officielle ne fera jamais
Une vraie web UI
FastAPI + HTMX + Chart.js (vendorisé, pas de CDN — l’app vit sur le LAN, elle doit marcher si Internet tombe). Une page mobile-first, un graphique 7 jours de la température, des contrôles instantanés. Le tout servi en un seul processus uvicorn sur mon Mac qui tourne H24.

Un scheduler météo-aware
L’app Intex propose un timer rudimentaire qui n’est même pas exposé via le protocole LAN — donc j’ai écrit le mien. Trois primitives : consigne par tranche horaire, fenêtres de filtration, et surtout « prêt à 18 h » qui calcule le moment de démarrer la chauffe.
La nouveauté intéressante : le taux de chauffe n’est pas constant. Un spa perd de la chaleur proportionnellement à (eau − air extérieur). Donc je tire la météo locale depuis Open-Meteo (gratuit, sans clé API), j’apprends le coefficient de pertes thermiques sur l’historique des phases de chauffe et de refroidissement, et le scheduler décale automatiquement le démarrage plus tôt quand il fait froid. Si la nuit s’annonce à 4 °C, on commence à chauffer 90 min avant ; si c’est 18 °C, 30 min suffisent.
Une caméra avec détection de housse (expérimentale)
Le spa est à moitié visible dans le champ d’une caméra IP du jardin. J’ai branché ffmpeg pour grabber une frame toutes les 10 s, l’écrire atomiquement (tmp + replace), reconstruire un timelapse mp4 quotidien à la volée, et — pour le fun — détecter si la housse est en place via une ROI calibrable et une heuristique luma + écart-type. C’est partiel et capricieux la nuit, donc ce n’est pas branché sur le scheduler en v1. Mais la plomberie est là pour le jour où.

Un service launchd qui survit aux silent failures
C’est la partie qui m’a pris le plus de temps et que personne ne raconte. Sur cette machine, CPython 3.14 tuait silencieusement le service au bout de ~30 s sous launchd — pas de traceback, pas de log, juste un process qui meurt. Coupable : un segfault dans uvloop / httptools / pydantic-core en cas de boucle longue. Retour à 3.12, problème évaporé. Et puis tous les paramètres plist qu’on apprend à la dure : ThrottleInterval=15 pour éviter les boucles de respawn qui saturent launchd, ProcessType=Adaptive (pas Background, sinon le jetsam nous tue en premier sous pression mémoire), ExitTimeOut=20 pour laisser le temps de fermer la TCP proprement, et surtout pas de --workers 1 sur uvicorn (ce flag bascule en mode multiprocess et wedge sous launchd ; le défaut single-process fait exactement ce qu’on veut).
Stack
Python 3.12 + FastAPI + HTMX + Chart.js + ffmpeg + Open-Meteo + launchd + ngrok pour l’accès distant. Pas de base de données : tout l’état tient dans une poignée de fichiers JSON et JSONL sous state/. 135 tests offline qui tournent en 3 secondes sans toucher au vrai spa (un fake_spa.py rejoue le protocole).
Le pattern réutilisable
Cette boîte noire ressemble à toutes les autres : un device IoT bas de gamme qui exige un compte cloud pour faire le boulot le plus trivial. Le pattern marche aussi pour les ampoules Tuya, les volets Somfy, et probablement la moitié des trucs dans votre maison :
- Trouver le port TCP ouvert sur le LAN (un
nmapsuffit souvent). - Capturer quelques échanges avec Wireshark depuis l’app officielle.
- Identifier le checksum et le framing (la doc du protocole est rarement publique mais rarement compliquée).
- Réécrire un client minimal, valider byte-for-byte, ajouter ce que le vendeur n’a jamais voulu construire.
- Bloquer la sortie WAN du device au routeur, fin du phone-home.
Le code est public : github.com/sxnlabs/intex-spa. Licence permissive. Si vous avez un PureSpa Baltik, vous pouvez littéralement le forker et l’installer. Et si vous avez un autre device avec le même pattern, lisez intex_spa/protocol.py — c’est un bon template pour démarrer le vôtre.
Si vous avez ça dans votre boîte
Cette histoire de boîte noire, je la croise régulièrement côté pro : une machine industrielle avec une API non documentée, un capteur derrière une app cloud qui ralentit tout le monde, une intégration domotique B2B qu’un fournisseur refuse de faire. C’est exactement ce que je débloque chez SXN Labs. Si vous avez un device coincé qui devrait juste parler à votre SI, écrivez-moi.