Skip to main content

espspapy

Unofficial asynchronous Python library, compatible with the Intex PureSpa Wi-Fi models released before 2024: it enrolls their ESP module on the Wi-Fi network (ESP-Touch v1) and drives it on the local network.

Installation

pip install espspapy

Requires Python 3.14+.

Usage

import asyncio

from espspapy import Device, Spa, async_discover, async_pair


async def main() -> None:
    # Enroll a module in pairing mode on the Wi-Fi (ESP-Touch)
    result = await async_pair(
        ssid="MyWifi",  # 2.4 GHz network
        password="secret",
        # local_ip="192.168.1.10",  # optional: this host, detected by default
        # bssid="00:00:1A:2B:3C:4D",  # optional: 2.4 GHz access point to join
    )
    print(result.mac, result.ip)

    # Find the modules of the LAN
    for info in await async_discover():
        print(info.uid, info.ip)

    # Drive a module (persistent connection + heartbeat)
    async with Spa("192.168.1.10") as spa:
        print(await spa.async_identify())
        status = await spa.async_get_status()
        print(status.current_temperature, status.target_temperature)
        await spa.async_set_device(Device.BUBBLES, True)
        await spa.async_set_temperature(38)


asyncio.run(main())

Pairing

  1. Hold the C/F Wi-Fi button of the control panel for about 5 seconds, until the Wi-Fi light blinks quickly: the module stays in pairing mode for about one minute.
  2. Run async_pair() (or espspapy pair) from a host on the 2.4 GHz network. The credentials are sent with ESP-Touch v1 in broadcast mode (multicast is not used).
  3. Open a TCP connection to the module right after the pairing (Spa(ip)): without a client on port 8990, the module may drop off the network.

The TCP server accepts a single client: close the original mobile client before driving the module, and the other way round.

API

Call Description
async_pair(ssid, password, local_ip=None, bssid=None) Wi-Fi pairing, returns PairingResult(mac, ip)
get_local_ip() IP address of this host on the LAN
async_discover(timeout) LAN discovery, returns list[SpaInfo]
Spa(host).async_identify() SpaInfo(uid, ip, dtype)
Spa(host).async_get_status() SpaStatus
Spa(host).async_set_device(device, on) Switch Device.POWER, FILTER, HEATER, JETS, BUBBLES, SANITIZER
Spa(host).async_toggle(device) Raw toggle
Spa(host).async_set_temperature(value) Target temperature, in the device unit (10-40 °C or 50-104 °F)
Spa(host).async_send_frame(frame) Raw hex frame (checksum added)
Spa(host).async_heartbeat() Keep-alive, when heartbeat_interval=None
Spa(host).async_set_timers(device, slots) Replace the timers of FILTER, HEATER or SANITIZER (untested)
build_timer_frame(device, slots) Timer frame without checksum, [] clears the timers

Exceptions derive from SpaException: SpaConnectionError, TimeoutExceededError, SpaNotRespondingError (the Wi-Fi module answered but not the controller board), InvalidFrameError, CommandError, PairingError.

Timer slots are one-shot, relative to the time of sending:

from datetime import datetime, timedelta

from espspapy import Device, TimerSlot

start = datetime.now() + timedelta(hours=2)
await spa.async_set_timers(Device.HEATER, [TimerSlot(start, 3, temperature=38)])
await spa.async_set_timers(Device.HEATER, [])  # clear

Command line

espspapy discover
espspapy pair --ssid MyWifi
espspapy pair --ssid MyWifi --bssid 00:00:1A:2B:3C:4D  # force an access point
espspapy status 192.168.1.10
espspapy monitor 192.168.1.10 --interval 10
espspapy bubbles 192.168.1.10 on
espspapy temp 192.168.1.10 38
espspapy --json status 192.168.1.10

Protocol

Port Protocol Use
7001 → UDP broadcast ESP-Touch v1 pairing (lengths of packets)
18266 ← UDP Pairing answer: marker, MAC, IP
10549 → / 10500 ← UDP broadcast Discovery (spa_request)
8990 TCP JSON messages {"data", "sid", "type"}
23 TCP Debug shell of the firmware (e.g. task-stats), see below

Message types: 0 heartbeat, 1 command, 2 status report, 3 identification, 7 firmware version (answer in data, not used by this library). Frames are hex strings followed by the checksum 255 - sum(bytes) % 255. The status request is 8888060FEE0F01DA; the answer is FFFF110F01…:

Byte Content
5 Devices: bit 0 power, 1 filter, 2 heater, 3 jets, 4 bubbles, 5 sanitizer, 6 heater (temperature reached)
6 Flashing: bit 0 error code
7 Water temperature, or error code + 100 (E81…E99, 200 = END)
12-14 Filter, sanitizer, heater timers (bit 7 active, half hours)
15 Target temperature (10-40 °C or 50-104 °F)

The toggle and temperature frames belong to command set 1/3; other models may use other frames. Switching the heater on also starts the filter (bits 1 and 2 set together), and switching it off stops both.

Device behaviour

From the user manual of a 2024 model with the same control panel; older models are expected to behave the same way, which is not confirmed.

Code Meaning
E81 Transmission failure
E90 No water flow
E91 Low salt level
E92 High salt level
E94 Water temperature too low
E95 Water temperature too high
E96 System error
E97 Dry-run protection
E99 Broken water temperature sensor
END After 72 hours of continuous heating, the pump goes to an extended standby
  • Timers set from the control panel: filter 2, 4 or 6 hours (the cycle restarts every day at the same time), sanitizer 3, 5 or 8 hours, heater start delay 0 to 120 hours and heater duration 0 to 48 hours. The controller only counts down: it has no clock.
  • Because of the power limit, only these combinations can run together: filter, heater and bubbles; filter, heater and jets; filter, bubbles, sanitizer and jets (without the heater).
  • Jets and bubbles stop by themselves after 30 minutes.
  • Frost protection: below 4 °C, the filter and the heater start until the water reaches 10 °C.
  • The default target temperature is 20 °C.

Timers

The module has no clock: the decompiled mobile client sends each timer list as delays from the time of sending, and resends it after every change. Spa.async_set_timers() sends one-shot slots of the filter, heater or sanitizer; the layout below comes from the decompiled code, only the combined frame was seen in a capture, and these frames were not tested on a device yet (see debug.py).

Frame Content
8888280F09 Filter timers
8888280F0A Heater timers
8888280F0B Sanitizer timers
8888710F11 The three lists (filter, heater, sanitizer), then the target temperature

Each list is 55 (or 00 to clear it) followed by up to 7 slots of 5 bytes, padded with zeros to 36 bytes; the frame ends with the checksum. A one-shot slot is:

Byte Content
1 Hours before the start, modulo 24 (modulo 168 for a long slot)
2 Minutes before the start, bit 7 set for a long slot (24 hours or more)
3 Duration in hours
4 Day bit: 1 << (hours before the start + duration) // 24 (// 168 long)
5 Target temperature in °C (heater only, 00 otherwise)

The library accepts slots that end within 7 days and last 1 to 48 hours. Weekly slots set bit 7 of the day byte and use a mask of the selected days; their exact layout is not confirmed, so they are not supported. The combined frame is not sent either.

Network and security

  • The debug shell on TCP port 23 is open without authentication: keep the module on a trusted network.
  • The module also connects to a cloud service (intexapphub.azure-devices.net, Azure IoT Hub). Local control does not need it, so it can be blocked with a DNS rewrite.

Sources

Interoperability

The vendor no longer provides a client for these modules. The protocol is documented here for interoperability only, so that owners keep the use of their hardware (article L122-6-1 of the French intellectual property code, article 6 of Directive 2009/24/EC).

  • The library is an independent implementation: it contains no code from the original client, only the information needed to talk to the module (ports, frames, encodings).
  • Neither the original client nor its decompiled code is distributed with this project, and none should be contributed.
  • New features should preferably be validated with network captures of the original client (observation of its behaviour) rather than with decompiled code.

Development

uv sync --extra dev
uv run prek run --all-files
uv run mypy espspapy tests
uv run pytest

debug.py prints the timer frames of a few examples; with --host and --send, it sends them and reads the status before and after each one:

uv run python debug.py
uv run python debug.py --host 192.168.1.10 --send filter clear-filter

Metadata

Release files for espspapy 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for espspapy 1.0.0
File Size Uploaded Provenance Actions
espspapy-1.0.0.tar.gz 69.2 kB Without provenance Details for espspapy-1.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for espspapy 1.0.0
File Interpreter ABI Platform Actions
espspapy-1.0.0-py3-none-any.whl Python 3 none any Details for espspapy-1.0.0-py3-none-any.whl

Total release size: 104.0 kB

Release files / espspapy-1.0.0.tar.gz

Download URL espspapy-1.0.0.tar.gz
Size 69.2 kB
Tags Source
SHA-256 checksum
How to use checksums
8c77d2909fc84731538604503d3d71eaae943b653d78a1bfaa15e2661d270504
BLAKE2b-256 checksum
How to use checksums
37841b57f490e6985d18147678f406438b8f790dc95dedc90f020bb2a335f68e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Release files / espspapy-1.0.0-py3-none-any.whl

Download URL espspapy-1.0.0-py3-none-any.whl
Size 34.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b34f6e645d98b632f8359c2f9cb375091a038fe76e512bb93f88dfc86533376f
BLAKE2b-256 checksum
How to use checksums
c6c0c11382f35778ff4cb35b8ddaedd98fa70d86d024fcc96b7fc589e26b346a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Security for espspapy 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page