Metadata-Version: 2.4
Name: espspapy
Version: 2.0.0
Summary: Wi-Fi enrollment (ESP-Touch) and local control of ESP modules
License-Expression: GPL-3.0-or-later
Keywords: esp,esptouch,async
Platform: any
Classifier: Programming Language :: Python
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Topic :: Home Automation
Requires-Python: >=3.14.0
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: prek; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Dynamic: license-file

# 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.

> [!NOTE]
> This project is not affiliated with or endorsed by Intex. Intex and PureSpa
> are trademarks of their owner, used only to indicate compatibility. Newer
> models use another protocol and are not supported.

> [!WARNING]
> Use at your own risk: the library drives a heater and pumps. It comes without
> any warranty (see the license); keep an eye on the device when automating it.

## Installation

```bash
pip install espspapy
```

Requires Python 3.14+.

## Usage

```python
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",
        bssid="00:00:1A:2B:3C:4D",  # 2.4 GHz access point to join
        # local_ip="192.168.1.10",  # optional: this host, detected by default
    )
    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)`  | Wi-Fi pairing, returns `PairingResult(mac, ip)`                              |
| `get_local_ip()`                                       | IP address of this host on the LAN                                           |
| `get_current_bssid(interface=None)`                    | BSSID of the 2.4 GHz access point this host is on (Linux), else `None`       |
| `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_filter_timer(hours, delay)`       | Run the filter for `hours`, after `delay` (`timedelta`, default now)         |
| `Spa(host).async_set_heater_timer(hours, delay, temp)` | Heat for `hours` after `delay`, at `temp` (default: current target)          |
| `Spa(host).async_set_sanitizer_timer(hours, delay)`    | Run the sanitizer for `hours` after `delay` (untested on a device)           |
| `Spa(host).async_clear_timer(device)`                  | Clear the timers of `FILTER`, `HEATER` or `SANITIZER`                        |
| `Spa(host).scheduled_timers`                           | Slots sent by this client that have not ended (the spa does not report them) |
| `Spa(host).async_set_timers(device, slots)`            | Replace the timers of a device with a list of `TimerSlot`                    |
| `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`.

Timers are one-shot, relative to the time of sending, like the control panel
(delayed start and duration). A new timer replaces the previous one of the same
device. The spa does not report a timer before it starts, so the client keeps
what it sent in `scheduled_timers`:

```python
from datetime import timedelta

from espspapy import Device

await spa.async_set_filter_timer(4)  # filter now, for 4 hours
await spa.async_set_heater_timer(3, delay=timedelta(hours=2))  # current target
await spa.async_set_heater_timer(1, timedelta(minutes=15), temperature=38)
print(spa.scheduled_timers)  # {Device.HEATER: (TimerSlot(start=…, duration=1, …),)}
await spa.async_clear_timer(Device.HEATER)
```

Several slots per device (up to 7) go through `async_set_timers()` with a list
of `TimerSlot(start, duration, temperature)`.

#### Control panel and library compared

The control panel only offers the values of the user manual. The library sends
the timer frames of the mobile app, which accept more: those extra values are
allowed by the frame format, but most were not tried on a device.

| Setting              | Control panel (user manual)                | Library (frame format)                            | Checked on a device                           |
| -------------------- | ------------------------------------------ | ------------------------------------------------- | --------------------------------------------- |
| Filter duration      | 2, 4 or 6 hours                            | 1 to 48 hours                                     | 2 hours                                       |
| Filter start         | now, then again every day at the same time | now or after a delay, once (no daily repeat)      | now                                           |
| Sanitizer duration   | 3, 5 or 8 hours                            | 1 to 48 hours                                     | 3 hours (status byte 13: `84`)                |
| Sanitizer start      | now                                        | now or after a delay                              | now                                           |
| Heater delayed start | 0 to 120 hours, in hours, heater off only  | to the minute, the slot ending within 7 days      | now and 15 minutes                            |
| Heater duration      | 0 to 48 hours, 0 = no end                  | 1 to 48 hours (no "no end")                       | 1 hour                                        |
| Heater temperature   | current target                             | any target in range, per slot                     | current target (28 °C)                        |
| Timers per device    | 1                                          | up to 7 slots                                     | 1                                             |
| Read back            | shown on the panel                         | not reported before the start: `scheduled_timers` | timers set on the panel: status bytes 9-14    |
| Clear                | panel buttons                              | `async_clear_timer()`                             | running: stops at once; pending: never starts |

Values outside the panel choices (a 3-hour filter, a 90-minute delay) are
therefore untested: check the result with `espspapy monitor` the first time.

### Command line

```bash
espspapy discover
espspapy pair --ssid MyWifi  # BSSID detected if this host is on 2.4 GHz Wi-Fi
espspapy pair --ssid MyWifi --bssid 00:00:1A:2B:3C:4D
espspapy status 192.168.1.10
espspapy monitor 192.168.1.10 --interval 10  # --changes: print only changes
espspapy bubbles 192.168.1.10 on
espspapy temp 192.168.1.10 38
espspapy timer 192.168.1.10 filter --hours 4
espspapy timer 192.168.1.10 heater --hours 3 --in 120 --temp 38  # in 2 hours
espspapy timer 192.168.1.10 heater --clear
espspapy shell 192.168.1.10  # debug shell of the Wi-Fi module
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 (enabled), 3 jets, 4 bubbles, 5 sanitizer |
| 6     | Flashing: bit 0 error code                                                         |
| 7     | Water temperature, or error code + 100 (E81…E99, 200 = END)                        |
| 9-10  | Heater delayed start: `80` when set, then coded like the filter timer (see below)  |
| 11    | Heater duration set (`00`: none, the heater then stays on), coded like byte 14     |
| 12-14 | Filter, sanitizer, heater timers (bit 7 active, see below)                         |
| 15    | Target temperature (10-40 °C or 50-104 °F)                                         |

Bit 2 means the heater function is enabled, not that the water is being
heated, and no byte says whether the heater element runs. With the heater
enabled, the frame reads `07` at 27 °C both for a 24 °C target (panel LED
green, idle) and for a 28 °C target (LED red, heating). The board measures
decimals: at 28 °C displayed for a 28 °C target, it kept heating for about an
hour, then the LED turned green, with the same frame. So `SpaStatus.heating` is
`True` below the target, `False` above it or with the heater off, and `None`
(unknown) when the water is displayed at the target. Bit 6, once assumed to
mean "temperature reached", was never seen set.

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.
- Timer bytes 12-14 of the status: the filter set to 2, 4 and 6 hours from the
  control panel reads `82`, `86` and `8A`, as does a 2-hour filter frame sent by
  this library (`82`). The value drops by one every 30 minutes (4 hours set at
  15:40: `86`, then `85` at 16:10), so the library decodes the low 7 bits as
  `bits / 2 + 1` remaining hours. Whether the last hour runs after the byte is
  back to `80`, and the sanitizer byte, are not checked yet.
- Heater timer from the control panel: one button sets the delayed start, read
  in bytes 9-10 (2 and 3 hours read `80 02` and `80 04`, decoded as
  `value / 2 + 1` hours like the filter; 1 hour reads `00 00`, like no delay, so
  the last hour of a delay is not visible), another the duration, read in bytes 11
  and 14 before the start: 1, 3, 4 and 48 hours read `81`, `85`, `87` and `DF`,
  decoded as `(bits + 1) / 2` hours. A duration of 0 (`00`, `80`) keeps the
  heater on once started. The delayed start can only be set while the heater is
  off (LED green or red: the button does nothing). A 1-hour delay, invisible in
  the frame, still started the heater and the filter one hour later (byte 5
  `01` → `07`, bytes 9-14 unchanged): the board counts the delay itself. The
  countdown of a longer delay is not checked yet.
- Heater timer frame sent by this library (`8888280F0A`, built like the
  decompiled app): a slot starting now for 1 hour started the heater at once
  (the board answered with a status) and stopped it one hour later, while bytes
  11 and 14 read `00` and `80` all along. The control panel showed the
  remaining hour. A running timer reads one unit less than a pending one, so
  its last hour is not visible, like the last hour of a delay. A slot starting
  in 15 minutes started the heater 15 minutes later, with nothing visible
  before (status frame and control panel): slots sent this way are stored on
  the board and cannot be read back.
- Sanitizer timer frame (`8888280F0B`) for 3 hours, starting now: byte 5 went
  `01` → `23` (filter and sanitizer), byte 13 to `84` (coded like the filter),
  and the control panel showed 3 hours. Clearing it a few seconds later
  (`async_clear_timer`) stopped the sanitizer and the filter at once (byte 5
  `01`, byte 13 `80`); the board answered both frames with a status.
- A filter slot starting in 15 minutes, cleared 5 seconds after it was sent,
  never started (status read every minute for 24 minutes).
- 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, and only the
combined frame was seen in a capture. A one-slot filter frame (start now, 2
hours) was tested on a device: the control board applies it but does not
answer, so the module reports `timeout`. The frame is therefore sent once,
without retry, and the status is read afterwards. The other slots (delayed
start, several slots, heater, sanitizer) are still untested (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 module forwards every command to the control board over a single serial
  link: with another client connected (Home Assistant, the mobile app), answers
  often come back as `timeout` or `mcu_busy`.
- The debug shell on TCP port 23 is open without authentication: keep the
  module on a trusted network. `espspapy shell <host>` opens it. It runs on the
  module, not on the spa control board: `help`, `task-stats`,
  `run-time-stats`, `heap-trace`, `echo-parameters`, `echo-3-parameters`,
  `reboot` and `smartconfig` (reboots in pairing mode, the module leaves the
  Wi-Fi network). The CLI asks for confirmation before the last two.
- 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

- ESP-Touch v1: [EspressifApp/EsptouchForAndroid](https://github.com/EspressifApp/EsptouchForAndroid),
  [KurdyMalloy/EsptouchPython](https://github.com/KurdyMalloy/EsptouchPython),
  [AtharvSavarkar/ESPTouch-Python](https://github.com/AtharvSavarkar/ESPTouch-Python)
- Pairing procedure: [Home Assistant integration, issue #51](https://github.com/mathieu-mp/homeassistant-intex-spa/issues/51),
  [manufacturer FAQ](https://www.intexdevelopment.com/support/faqlist?catkey=306&lst_lang=2)
- TCP protocol, message type 7, debug shell, cloud service and captured
  schedule frame: [djo1338/intex-mqtt-bridge, PROTOCOL.md](https://github.com/djo1338/intex-mqtt-bridge/blob/main/docs/PROTOCOL.md)
  and [REVERSE-ENGINEERING.md](https://github.com/djo1338/intex-mqtt-bridge/blob/main/docs/REVERSE-ENGINEERING.md)
- Commands and status frame: [mathieu-mp/aio-intex-spa](https://github.com/mathieu-mp/aio-intex-spa),
  [Home Assistant community thread](https://community.home-assistant.io/t/intex-pure-spa-wifi-control/323591)
- Error codes, timer ranges, power limit and automatic stops:
  [user manual of a 2024 model (351IO), in French](https://www.irripiscine.fr/media/akeneo_connector/asset_files/N/o/Notice_258b.pdf)
- Off-state temperature value and heater/filter dependency:
  [adrianjagielak/homebridge-intex-plus](https://github.com/adrianjagielak/homebridge-intex-plus/blob/latest/src/spaAccessory.ts)

## 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

```bash
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:

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