espspapy 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
espspapy/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Local control of connected spas."""
2
+
3
+ from .discovery import async_discover
4
+ from .esptouch import async_pair
5
+ from .exceptions import (
6
+ CommandError,
7
+ InvalidFrameError,
8
+ PairingError,
9
+ SpaConnectionError,
10
+ SpaException,
11
+ SpaNotRespondingError,
12
+ TimeoutExceededError,
13
+ )
14
+ from .helpers import get_local_ip
15
+ from .models import Device, PairingResult, SpaInfo, SpaStatus
16
+ from .spa import Spa
17
+ from .timers import TimerSlot, build_timer_frame
18
+
19
+ __all__ = [
20
+ "CommandError",
21
+ "Device",
22
+ "InvalidFrameError",
23
+ "PairingError",
24
+ "PairingResult",
25
+ "Spa",
26
+ "SpaConnectionError",
27
+ "SpaException",
28
+ "SpaInfo",
29
+ "SpaNotRespondingError",
30
+ "SpaStatus",
31
+ "TimeoutExceededError",
32
+ "TimerSlot",
33
+ "async_discover",
34
+ "async_pair",
35
+ "build_timer_frame",
36
+ "get_local_ip",
37
+ ]
espspapy/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run ``python -m espspapy``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
espspapy/cli.py ADDED
@@ -0,0 +1,164 @@
1
+ """Command line interface: ``espspapy``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ from dataclasses import asdict
8
+ import getpass
9
+ import json
10
+ import logging
11
+ import sys
12
+ import time
13
+
14
+ from .discovery import async_discover
15
+ from .esptouch import async_pair
16
+ from .exceptions import SpaException
17
+ from .helpers import get_local_ip
18
+ from .models import Device, SpaInfo, SpaStatus
19
+ from .spa import Spa
20
+
21
+ STATES = {"on": True, "off": False}
22
+
23
+
24
+ def _format_status(status: SpaStatus) -> str:
25
+ unit = "°F" if status.fahrenheit else "°C"
26
+
27
+ def state(value: bool, hours: float | None = None) -> str:
28
+ timer = f" (timer {hours:g} h)" if hours is not None else ""
29
+ return ("on" if value else "off") + timer
30
+
31
+ def temperature(value: int | None) -> str:
32
+ return f"{value} {unit}" if value is not None else "?"
33
+
34
+ lines = [
35
+ f"Power : {state(status.power)}",
36
+ f"Filter : {state(status.filter, status.filter_hours)}",
37
+ f"Heater : {state(status.heater, status.heater_hours)}",
38
+ f"Jets : {state(status.jets)}",
39
+ f"Bubbles : {state(status.bubbles)}",
40
+ f"Sanitizer : {state(status.sanitizer, status.sanitizer_hours)}",
41
+ f"Temperature : {temperature(status.current_temperature)}",
42
+ f"Target : {temperature(status.target_temperature)}",
43
+ ]
44
+ if status.error_code:
45
+ lines.append(f"Error : {status.error_code}")
46
+ return "\n".join(lines)
47
+
48
+
49
+ def _print(value: SpaStatus | SpaInfo, as_json: bool) -> None:
50
+ if as_json:
51
+ print(json.dumps(asdict(value), ensure_ascii=False))
52
+ elif isinstance(value, SpaStatus):
53
+ print(_format_status(value))
54
+ else:
55
+ print(f"uid={value.uid} ip={value.ip} dtype={value.dtype}")
56
+
57
+
58
+ async def _run(args: argparse.Namespace) -> int:
59
+ if args.command == "discover":
60
+ spas = await async_discover(args.timeout)
61
+ for info in spas:
62
+ _print(info, args.json)
63
+ return 0 if spas else 1
64
+
65
+ if args.command == "pair":
66
+ password = args.password
67
+ if password is None:
68
+ password = getpass.getpass(f"Wi-Fi password for {args.ssid}: ")
69
+ local_ip = args.local_ip or get_local_ip()
70
+ print(f"Pairing {args.ssid} from {local_ip}…", file=sys.stderr)
71
+ result = await async_pair(args.ssid, password, local_ip, bssid=args.bssid)
72
+ print(f"Spa module {result.mac} joined with IP {result.ip}", file=sys.stderr)
73
+ args.host = result.ip
74
+
75
+ async with Spa(args.host) as spa:
76
+ if args.command in ("pair", "identify"):
77
+ _print(await spa.async_identify(), args.json)
78
+ elif args.command == "status":
79
+ _print(await spa.async_get_status(), args.json)
80
+ elif args.command == "monitor":
81
+ previous = None
82
+ while True:
83
+ status = await spa.async_get_status()
84
+ if status.raw != previous:
85
+ previous = status.raw
86
+ if not args.json:
87
+ print(f"--- {time.strftime('%Y-%m-%d %H:%M:%S')}")
88
+ _print(status, args.json)
89
+ sys.stdout.flush()
90
+ await asyncio.sleep(args.interval)
91
+ elif args.command == "temp":
92
+ _print(await spa.async_set_temperature(args.value), args.json)
93
+ elif args.command == "raw":
94
+ _print(await spa.async_send_frame(args.frame), args.json)
95
+ else:
96
+ device = Device[args.command.upper()]
97
+ if args.state == "toggle":
98
+ status = await spa.async_toggle(device)
99
+ else:
100
+ status = await spa.async_set_device(device, STATES[args.state])
101
+ _print(status, args.json)
102
+ return 0
103
+
104
+
105
+ def build_parser() -> argparse.ArgumentParser:
106
+ """Return the argument parser."""
107
+ parser = argparse.ArgumentParser(prog="espspapy", description="Control spas.")
108
+ parser.add_argument("--json", action="store_true", help="JSON output")
109
+ parser.add_argument("-v", "--verbose", action="store_true", help="debug logs")
110
+ sub = parser.add_subparsers(dest="command", required=True)
111
+
112
+ discover = sub.add_parser("discover", help="find the spas of the LAN")
113
+ discover.add_argument("--timeout", type=float, default=3.0)
114
+
115
+ pair = sub.add_parser("pair", help="connect a spa to the Wi-Fi (ESP-Touch)")
116
+ pair.add_argument("--ssid", required=True, help="2.4 GHz network")
117
+ pair.add_argument("--bssid", help="access point MAC address (optional)")
118
+ pair.add_argument("--password", help="Wi-Fi password (prompted if omitted)")
119
+ pair.add_argument("--local-ip", help="IP address of this host")
120
+
121
+ for name, help_text in (
122
+ ("identify", "show the spa identity"),
123
+ ("status", "show the spa status"),
124
+ ):
125
+ sub.add_parser(name, help=help_text).add_argument("host")
126
+
127
+ monitor = sub.add_parser("monitor", help="follow the status (Ctrl+C to stop)")
128
+ monitor.add_argument("host")
129
+ monitor.add_argument("--interval", type=float, default=30.0)
130
+
131
+ for device in Device:
132
+ name = device.name.lower()
133
+ command = sub.add_parser(name, help=f"switch {name}")
134
+ command.add_argument("host")
135
+ command.add_argument("state", choices=("on", "off", "toggle"))
136
+
137
+ temp = sub.add_parser("temp", help="set the target temperature")
138
+ temp.add_argument("host")
139
+ temp.add_argument("value", type=int)
140
+
141
+ raw = sub.add_parser("raw", help="send a hex frame (checksum added)")
142
+ raw.add_argument("host")
143
+ raw.add_argument("frame")
144
+ return parser
145
+
146
+
147
+ def main(argv: list[str] | None = None) -> int:
148
+ """Run the command line interface."""
149
+ args = build_parser().parse_args(argv)
150
+ logging.basicConfig(
151
+ level=logging.DEBUG if args.verbose else logging.WARNING,
152
+ format="%(levelname)s %(name)s %(message)s",
153
+ )
154
+ try:
155
+ return asyncio.run(_run(args))
156
+ except (SpaException, ValueError) as err:
157
+ print(f"Error: {err}", file=sys.stderr)
158
+ return 1
159
+ except KeyboardInterrupt:
160
+ return 0 if args.command == "monitor" else 130
161
+
162
+
163
+ if __name__ == "__main__":
164
+ sys.exit(main())
espspapy/const.py ADDED
@@ -0,0 +1,60 @@
1
+ """Constants of the spa protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ SPA_TCP_PORT = 8990
6
+
7
+ MSG_TYPE_HEARTBEAT = 0
8
+ MSG_TYPE_COMMAND = 1
9
+ MSG_TYPE_REPORT = 2
10
+ MSG_TYPE_IDENTIFY = 3
11
+
12
+ RESULT_OK = "ok"
13
+
14
+ DEFAULT_TIMEOUT = 15.0
15
+ DEFAULT_RETRIES = 3
16
+ RETRY_DELAY = 2.0
17
+ HEARTBEAT_INTERVAL = 50.0
18
+
19
+ # Frames without checksum, from command set 1/3.
20
+ FRAME_STATUS = "8888060FEE0F01"
21
+ FRAME_SET_TEMPERATURE = "8888050F0C"
22
+
23
+ # Timer frames: prefix + device (09 filter, 0A heater, 0B sanitizer) + list.
24
+ TIMER_FRAME_PREFIX = "8888280F"
25
+ TIMER_LIST_MARKER = 0x55
26
+ TIMER_LIST_CLEAR = 0x00
27
+ TIMER_LIST_LEN = 36
28
+ TIMER_MAX_SLOTS = 7
29
+ TIMER_MAX_DURATION = 48
30
+ TIMER_WINDOW_HOURS = 168
31
+
32
+ TEMP_RANGE_CELSIUS = (10, 40)
33
+ TEMP_RANGE_FAHRENHEIT = (50, 104)
34
+ ERROR_CODE_MIN = 180
35
+ ERROR_CODE_END = 200
36
+ # Heater on, target temperature maintained.
37
+ HEATER_STANDBY_BIT = 6
38
+
39
+ BROADCAST_ADDR = "255.255.255.255"
40
+
41
+ # LAN discovery
42
+ DISCOVERY_SEND_PORT = 10549
43
+ DISCOVERY_RECEIVE_PORT = 10500
44
+ DISCOVERY_REQUEST = b"spa_request"
45
+ DISCOVERY_TIMEOUT = 3.0
46
+
47
+ # ESP-Touch v1 (Espressif SDK, EsptouchTaskParameter)
48
+ ESPTOUCH_TARGET_PORT = 7001
49
+ ESPTOUCH_LISTEN_PORT = 18266
50
+ ESPTOUCH_PACKET_INTERVAL = 0.008
51
+ ESPTOUCH_GUIDE_DURATION = 2.0
52
+ ESPTOUCH_DATA_DURATION = 4.0
53
+ ESPTOUCH_SEND_TIMEOUT = 45.0
54
+ ESPTOUCH_RECEIVE_TIMEOUT = 15.0
55
+ ESPTOUCH_RESULT_LEN = 11
56
+ ESPTOUCH_GUIDE_CODES = (515, 514, 513, 512)
57
+ ESPTOUCH_DATA_OFFSET = 40
58
+ ESPTOUCH_MAX_INDEX = 127
59
+ ESPTOUCH_PAYLOAD_BYTE = b"1"
60
+ ESPTOUCH_UNKNOWN_BSSID = bytes(6)
espspapy/discovery.py ADDED
@@ -0,0 +1,81 @@
1
+ """Discovery of the spas of the local network (UDP broadcast)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import socket
9
+
10
+ from .const import (
11
+ BROADCAST_ADDR,
12
+ DISCOVERY_RECEIVE_PORT,
13
+ DISCOVERY_REQUEST,
14
+ DISCOVERY_SEND_PORT,
15
+ DISCOVERY_TIMEOUT,
16
+ )
17
+ from .exceptions import InvalidFrameError
18
+ from .models import SpaInfo
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+
23
+ def parse_discovery_answer(data: bytes, sender_ip: str) -> SpaInfo | None:
24
+ """Decode a discovery answer (JSON, NUL padded)."""
25
+ text = data.split(b"\x00", 1)[0].decode("utf-8", errors="replace")
26
+ if "{" not in text:
27
+ return None
28
+ try:
29
+ info = SpaInfo.from_dict(json.loads(text))
30
+ except (ValueError, InvalidFrameError) as err:
31
+ _LOGGER.debug("Ignored answer from %s: %s", sender_ip, err)
32
+ return None
33
+ if info.ip is None:
34
+ return SpaInfo(uid=info.uid, ip=sender_ip, dtype=info.dtype)
35
+ return info
36
+
37
+
38
+ class _DiscoveryProtocol(asyncio.DatagramProtocol):
39
+ def __init__(self) -> None:
40
+ self.found: dict[str, SpaInfo] = {}
41
+
42
+ def datagram_received(self, data: bytes, addr: tuple[str | int, int]) -> None:
43
+ _LOGGER.debug("Discovery answer from %s: %s", addr, data)
44
+ if info := parse_discovery_answer(data, str(addr[0])):
45
+ self.found[info.uid] = info
46
+
47
+
48
+ def _udp_socket(port: int) -> socket.socket:
49
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
50
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
51
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
52
+ sock.setblocking(False)
53
+ sock.bind(("", port))
54
+ return sock
55
+
56
+
57
+ async def async_discover(
58
+ timeout: float = DISCOVERY_TIMEOUT,
59
+ *,
60
+ broadcast_address: str = BROADCAST_ADDR,
61
+ send_port: int = DISCOVERY_SEND_PORT,
62
+ receive_port: int = DISCOVERY_RECEIVE_PORT,
63
+ ) -> list[SpaInfo]:
64
+ """Broadcast ``spa_request`` and return the spas that answered.
65
+
66
+ The spas answer on ``receive_port`` (10500), whatever the source port.
67
+ """
68
+ loop = asyncio.get_running_loop()
69
+ transport, protocol = await loop.create_datagram_endpoint(
70
+ _DiscoveryProtocol, sock=_udp_socket(receive_port)
71
+ )
72
+ sender = _udp_socket(send_port)
73
+ try:
74
+ await loop.sock_sendto(
75
+ sender, DISCOVERY_REQUEST, (broadcast_address, send_port)
76
+ )
77
+ await asyncio.sleep(timeout)
78
+ finally:
79
+ sender.close()
80
+ transport.close()
81
+ return list(protocol.found.values())
espspapy/esptouch.py ADDED
@@ -0,0 +1,215 @@
1
+ """Wi-Fi pairing of a spa with ESP-Touch v1.
2
+
3
+ The Wi-Fi module of the spa is an Espressif chip. Before joining a network it
4
+ sniffs the air and decodes SSID, BSSID and password from the *length* of UDP
5
+ broadcast packets (``com.espressif.iot.esptouch``). Once connected, it answers
6
+ with its MAC and IP address on UDP port 18266.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from contextlib import suppress
13
+ import ipaddress
14
+ import logging
15
+ import socket
16
+
17
+ from .const import (
18
+ BROADCAST_ADDR,
19
+ ESPTOUCH_DATA_DURATION,
20
+ ESPTOUCH_DATA_OFFSET,
21
+ ESPTOUCH_GUIDE_CODES,
22
+ ESPTOUCH_GUIDE_DURATION,
23
+ ESPTOUCH_LISTEN_PORT,
24
+ ESPTOUCH_MAX_INDEX,
25
+ ESPTOUCH_PACKET_INTERVAL,
26
+ ESPTOUCH_PAYLOAD_BYTE,
27
+ ESPTOUCH_RECEIVE_TIMEOUT,
28
+ ESPTOUCH_RESULT_LEN,
29
+ ESPTOUCH_SEND_TIMEOUT,
30
+ ESPTOUCH_TARGET_PORT,
31
+ ESPTOUCH_UNKNOWN_BSSID,
32
+ )
33
+ from .exceptions import PairingError
34
+ from .helpers import get_local_ip, parse_bssid
35
+ from .models import PairingResult
36
+
37
+ _LOGGER = logging.getLogger(__name__)
38
+
39
+
40
+ def _build_crc8_table() -> tuple[int, ...]:
41
+ table = []
42
+ for value in range(256):
43
+ crc = value
44
+ for _ in range(8):
45
+ crc = (crc >> 1) ^ 0x8C if crc & 1 else crc >> 1
46
+ table.append(crc)
47
+ return tuple(table)
48
+
49
+
50
+ _CRC8_TABLE = _build_crc8_table()
51
+
52
+
53
+ def crc8(data: bytes) -> int:
54
+ """Return the CRC8 Dallas/Maxim (reflected polynomial 0x8C, ``CRC8``)."""
55
+ crc = 0
56
+ for byte in data:
57
+ crc = _CRC8_TABLE[(byte ^ crc) & 0xFF]
58
+ return crc
59
+
60
+
61
+ def _data_code(value: int, index: int) -> tuple[int, int, int]:
62
+ """Encode a byte and its index into three 9-bit words (``DataCode``)."""
63
+ if index > ESPTOUCH_MAX_INDEX:
64
+ raise ValueError("SSID and password too long for ESP-Touch")
65
+ crc = crc8(bytes((value, index)))
66
+ return (
67
+ (crc & 0xF0) | (value >> 4),
68
+ 0x100 | index,
69
+ ((crc & 0x0F) << 4) | (value & 0x0F),
70
+ )
71
+
72
+
73
+ def build_datum_lengths(
74
+ ssid: bytes, bssid: bytes, password: bytes, local_ip: bytes
75
+ ) -> list[int]:
76
+ """Return the packet lengths of the data code (``DatumCode``)."""
77
+ total_len = 5 + len(local_ip) + len(password) + len(ssid)
78
+ ssid_crc = crc8(ssid)
79
+ bssid_crc = crc8(bssid)
80
+
81
+ entries = [(total_len, 0), (len(password), 1), (ssid_crc, 2), (bssid_crc, 3)]
82
+ checksum = total_len ^ len(password) ^ ssid_crc ^ bssid_crc
83
+ for offset, byte in enumerate(local_ip + password + ssid):
84
+ checksum ^= byte
85
+ entries.append((byte, 5 + offset))
86
+ entries.insert(4, (checksum, 4))
87
+
88
+ # BSSID bytes are interleaved every 4 entries.
89
+ position = 5
90
+ for offset, byte in enumerate(bssid):
91
+ entries.insert(min(position, len(entries)), (byte, total_len + offset))
92
+ position += 4
93
+
94
+ return [
95
+ word + ESPTOUCH_DATA_OFFSET
96
+ for value, index in entries
97
+ for word in _data_code(value, index)
98
+ ]
99
+
100
+
101
+ def build_packets(
102
+ ssid: str, password: str, local_ip: str, bssid: str | None = None
103
+ ) -> tuple[list[bytes], list[bytes]]:
104
+ """Return the guide and data packets to broadcast."""
105
+ parsed_bssid = parse_bssid(bssid) if bssid else ESPTOUCH_UNKNOWN_BSSID
106
+ guide = [ESPTOUCH_PAYLOAD_BYTE * length for length in ESPTOUCH_GUIDE_CODES]
107
+ datum = [
108
+ ESPTOUCH_PAYLOAD_BYTE * length
109
+ for length in build_datum_lengths(
110
+ ssid.encode(),
111
+ parsed_bssid,
112
+ password.encode(),
113
+ ipaddress.IPv4Address(local_ip).packed,
114
+ )
115
+ ]
116
+ return guide, datum
117
+
118
+
119
+ def expected_answer_marker(ssid: str, password: str) -> int:
120
+ """Return the first byte of the module answer."""
121
+ return (len(ssid.encode()) + len(password.encode()) + 9) & 0xFF
122
+
123
+
124
+ def parse_pairing_answer(data: bytes, marker: int) -> PairingResult | None:
125
+ """Decode the module answer: marker, MAC (6 bytes), IP (4 bytes)."""
126
+ if len(data) < ESPTOUCH_RESULT_LEN or data[0] != marker:
127
+ return None
128
+ return PairingResult(
129
+ mac=data[1:7].hex(":"), ip=str(ipaddress.IPv4Address(data[7:11]))
130
+ )
131
+
132
+
133
+ class _AnswerProtocol(asyncio.DatagramProtocol):
134
+ def __init__(self, marker: int, answer: asyncio.Future[PairingResult]) -> None:
135
+ self._marker = marker
136
+ self._answer = answer
137
+
138
+ def datagram_received(self, data: bytes, addr: tuple[str | int, int]) -> None:
139
+ _LOGGER.debug("ESP-Touch answer from %s: %s", addr, data.hex())
140
+ result = parse_pairing_answer(data, self._marker)
141
+ if result and not self._answer.done():
142
+ self._answer.set_result(result)
143
+
144
+
145
+ async def async_pair(
146
+ ssid: str,
147
+ password: str,
148
+ local_ip: str | None = None,
149
+ *,
150
+ bssid: str | None = None,
151
+ send_timeout: float = ESPTOUCH_SEND_TIMEOUT,
152
+ receive_timeout: float = ESPTOUCH_RECEIVE_TIMEOUT,
153
+ target: tuple[str, int] = (BROADCAST_ADDR, ESPTOUCH_TARGET_PORT),
154
+ listen_port: int = ESPTOUCH_LISTEN_PORT,
155
+ ) -> PairingResult:
156
+ """Send the Wi-Fi credentials to a spa in pairing mode.
157
+
158
+ ``local_ip`` is the address of this host on that network (sent to the spa
159
+ so that it can answer), detected when omitted. ``bssid``, the MAC address of the 2.4 GHz access
160
+ point, is optional: a zero BSSID is sent when it is unknown.
161
+ Raises :class:`PairingError` without answer.
162
+ """
163
+ if local_ip is None:
164
+ local_ip = get_local_ip()
165
+ guide, datum = build_packets(ssid, password, local_ip, bssid)
166
+ marker = expected_answer_marker(ssid, password)
167
+ loop = asyncio.get_running_loop()
168
+ answer: asyncio.Future[PairingResult] = loop.create_future()
169
+
170
+ listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
171
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
172
+ listener.setblocking(False)
173
+ listener.bind(("", listen_port))
174
+ transport, _ = await loop.create_datagram_endpoint(
175
+ lambda: _AnswerProtocol(marker, answer), sock=listener
176
+ )
177
+ sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
178
+ sender.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
179
+ sender.setblocking(False)
180
+
181
+ async def _send(packet: bytes) -> None:
182
+ await loop.sock_sendto(sender, packet, target)
183
+ await asyncio.sleep(ESPTOUCH_PACKET_INTERVAL)
184
+
185
+ async def _broadcast() -> None:
186
+ try:
187
+ sender.bind((local_ip, 0))
188
+ start = loop.time()
189
+ index = 0
190
+ while loop.time() - start < send_timeout:
191
+ phase_end = loop.time() + ESPTOUCH_GUIDE_DURATION
192
+ while loop.time() < phase_end:
193
+ for packet in guide:
194
+ await _send(packet)
195
+ phase_end = loop.time() + ESPTOUCH_DATA_DURATION
196
+ while loop.time() < phase_end:
197
+ for packet in datum[index : index + 3]:
198
+ await _send(packet)
199
+ index = (index + 3) % len(datum)
200
+ except OSError as err:
201
+ if not answer.done():
202
+ answer.set_exception(PairingError(f"Broadcast failed: {err}"))
203
+
204
+ broadcast = asyncio.create_task(_broadcast(), name="espspapy-esptouch")
205
+ try:
206
+ async with asyncio.timeout(send_timeout + receive_timeout):
207
+ return await answer
208
+ except TimeoutError as err:
209
+ raise PairingError("No answer from the spa") from err
210
+ finally:
211
+ broadcast.cancel()
212
+ with suppress(asyncio.CancelledError):
213
+ await broadcast
214
+ sender.close()
215
+ transport.close()
espspapy/exceptions.py ADDED
@@ -0,0 +1,29 @@
1
+ """Exceptions for espspapy."""
2
+
3
+
4
+ class SpaException(Exception):
5
+ """General exception."""
6
+
7
+
8
+ class SpaConnectionError(SpaException):
9
+ """The spa cannot be reached or closed the connection."""
10
+
11
+
12
+ class TimeoutExceededError(SpaException):
13
+ """The spa did not answer in time."""
14
+
15
+
16
+ class SpaNotRespondingError(SpaException):
17
+ """The Wi-Fi module answered but the spa control board did not."""
18
+
19
+
20
+ class InvalidFrameError(SpaException):
21
+ """The spa sent an unexpected answer."""
22
+
23
+
24
+ class CommandError(SpaException):
25
+ """The command cannot be executed in the current spa state."""
26
+
27
+
28
+ class PairingError(SpaException):
29
+ """ESP-Touch pairing failed."""
espspapy/helpers.py ADDED
@@ -0,0 +1,45 @@
1
+ """Helpers for the spa protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+
7
+
8
+ def checksum(frame: str) -> str:
9
+ """Return the checksum of a hex frame: ``255 - sum(bytes) % 255``."""
10
+ return f"{255 - sum(bytes.fromhex(frame)) % 255:02X}"
11
+
12
+
13
+ def with_checksum(frame: str) -> str:
14
+ """Return the hex frame followed by its checksum."""
15
+ frame = frame.upper()
16
+ return frame + checksum(frame)
17
+
18
+
19
+ def work_hours(value: int) -> float | None:
20
+ """Decode a timer byte.
21
+
22
+ Bit 7 means active, bits 0-6 count half hours. The spa sends ``0x80``
23
+ when no timer is programmed.
24
+ """
25
+ half_hours = value & 0x7F
26
+ return half_hours / 2 if value & 0x80 and half_hours else None
27
+
28
+
29
+ def parse_bssid(value: str) -> bytes:
30
+ """Parse ``aa:bb:cc:dd:ee:ff`` (any separator) into 6 bytes."""
31
+ cleaned = "".join(char for char in value if char in "0123456789abcdefABCDEF")
32
+ if len(cleaned) != 12:
33
+ raise ValueError(f"Invalid BSSID: {value}")
34
+ return bytes.fromhex(cleaned)
35
+
36
+
37
+ def get_local_ip() -> str:
38
+ """Return the IP address of this host on the LAN (nothing is sent).
39
+
40
+ Connecting a UDP socket sends no packet: it only asks the OS which
41
+ interface (and so which local address) would be used for that route.
42
+ """
43
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
44
+ sock.connect(("10.255.255.255", 1))
45
+ return str(sock.getsockname()[0])