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 +37 -0
- espspapy/__main__.py +7 -0
- espspapy/cli.py +164 -0
- espspapy/const.py +60 -0
- espspapy/discovery.py +81 -0
- espspapy/esptouch.py +215 -0
- espspapy/exceptions.py +29 -0
- espspapy/helpers.py +45 -0
- espspapy/models.py +152 -0
- espspapy/py.typed +0 -0
- espspapy/spa.py +304 -0
- espspapy/timers.py +97 -0
- espspapy-1.0.0.dist-info/METADATA +290 -0
- espspapy-1.0.0.dist-info/RECORD +18 -0
- espspapy-1.0.0.dist-info/WHEEL +5 -0
- espspapy-1.0.0.dist-info/entry_points.txt +2 -0
- espspapy-1.0.0.dist-info/licenses/LICENSE +674 -0
- espspapy-1.0.0.dist-info/top_level.txt +1 -0
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
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])
|