zendure 0.3.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.
- zendure/__init__.py +90 -0
- zendure/_redact.py +14 -0
- zendure/ble/__init__.py +25 -0
- zendure/ble/advertisement.py +37 -0
- zendure/ble/const.py +53 -0
- zendure/ble/provision.py +345 -0
- zendure/catalog/__init__.py +33 -0
- zendure/catalog/_fault_tables.py +410 -0
- zendure/catalog/controls.py +172 -0
- zendure/catalog/devices.py +76 -0
- zendure/catalog/faults.py +94 -0
- zendure/catalog/sensors.py +131 -0
- zendure/client.py +238 -0
- zendure/events.py +120 -0
- zendure/exceptions.py +136 -0
- zendure/local.py +46 -0
- zendure/models.py +141 -0
- zendure/protocols/__init__.py +7 -0
- zendure/protocols/base.py +42 -0
- zendure/protocols/local_http/__init__.py +12 -0
- zendure/protocols/local_http/codec.py +111 -0
- zendure/protocols/local_http/discovery.py +36 -0
- zendure/protocols/local_http/messages.py +238 -0
- zendure/protocols/local_http/protocol.py +112 -0
- zendure/protocols/local_http/rest.py +67 -0
- zendure/protocols/local_http/write_errors.py +49 -0
- zendure/py.typed +0 -0
- zendure/state/__init__.py +11 -0
- zendure/state/merge.py +40 -0
- zendure/state/report.py +71 -0
- zendure/state/store.py +322 -0
- zendure/transport/__init__.py +15 -0
- zendure/transport/http.py +157 -0
- zendure/transport/poller.py +107 -0
- zendure-0.3.0.dist-info/METADATA +80 -0
- zendure-0.3.0.dist-info/RECORD +38 -0
- zendure-0.3.0.dist-info/WHEEL +4 -0
- zendure-0.3.0.dist-info/licenses/LICENSE +201 -0
zendure/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Async local-network client for Zendure energy devices.
|
|
2
|
+
|
|
3
|
+
Talks to a device on the LAN over the zenSDK HTTP protocol (``GET
|
|
4
|
+
/properties/report`` + ``POST /properties/write``). No cloud, no MQTT, no app
|
|
5
|
+
token. This package never imports ``homeassistant``: a thin Home Assistant
|
|
6
|
+
integration consumes it in a later phase.
|
|
7
|
+
|
|
8
|
+
Public surface is re-exported here; everything else is an implementation detail.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import ZendureClient
|
|
12
|
+
from .events import (
|
|
13
|
+
BatteryAdded,
|
|
14
|
+
BatteryPropertiesChanged,
|
|
15
|
+
BatteryRemoved,
|
|
16
|
+
DeviceAvailabilityChanged,
|
|
17
|
+
DevicePropertiesChanged,
|
|
18
|
+
EventListener,
|
|
19
|
+
FaultCleared,
|
|
20
|
+
FaultRaised,
|
|
21
|
+
ProtocolWarning,
|
|
22
|
+
ZendureEvent,
|
|
23
|
+
)
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
ClientClosedError,
|
|
26
|
+
DeviceMismatchError,
|
|
27
|
+
MalformedResponseError,
|
|
28
|
+
ProvisioningError,
|
|
29
|
+
ProvisioningFailedError,
|
|
30
|
+
ProvisioningTimeoutError,
|
|
31
|
+
UnsupportedDeviceError,
|
|
32
|
+
UnsupportedFeatureError,
|
|
33
|
+
UnsupportedPropertyError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
WriteNotConfirmedError,
|
|
36
|
+
WriteRejectedError,
|
|
37
|
+
ZendureConnectionError,
|
|
38
|
+
ZendureError,
|
|
39
|
+
ZendureProtocolError,
|
|
40
|
+
)
|
|
41
|
+
from .local import create_local_client
|
|
42
|
+
from .models import (
|
|
43
|
+
Availability,
|
|
44
|
+
BatterySnapshot,
|
|
45
|
+
ControlDescription,
|
|
46
|
+
DeviceSnapshot,
|
|
47
|
+
EntityKind,
|
|
48
|
+
PropertyValue,
|
|
49
|
+
SensorDescription,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__version__ = "0.3.0"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"Availability",
|
|
56
|
+
"BatteryAdded",
|
|
57
|
+
"BatteryPropertiesChanged",
|
|
58
|
+
"BatteryRemoved",
|
|
59
|
+
"BatterySnapshot",
|
|
60
|
+
"ClientClosedError",
|
|
61
|
+
"ControlDescription",
|
|
62
|
+
"DeviceAvailabilityChanged",
|
|
63
|
+
"DeviceMismatchError",
|
|
64
|
+
"DevicePropertiesChanged",
|
|
65
|
+
"DeviceSnapshot",
|
|
66
|
+
"EntityKind",
|
|
67
|
+
"EventListener",
|
|
68
|
+
"FaultCleared",
|
|
69
|
+
"FaultRaised",
|
|
70
|
+
"MalformedResponseError",
|
|
71
|
+
"PropertyValue",
|
|
72
|
+
"ProtocolWarning",
|
|
73
|
+
"ProvisioningError",
|
|
74
|
+
"ProvisioningFailedError",
|
|
75
|
+
"ProvisioningTimeoutError",
|
|
76
|
+
"SensorDescription",
|
|
77
|
+
"UnsupportedDeviceError",
|
|
78
|
+
"UnsupportedFeatureError",
|
|
79
|
+
"UnsupportedPropertyError",
|
|
80
|
+
"ValidationError",
|
|
81
|
+
"WriteNotConfirmedError",
|
|
82
|
+
"WriteRejectedError",
|
|
83
|
+
"ZendureClient",
|
|
84
|
+
"ZendureConnectionError",
|
|
85
|
+
"ZendureError",
|
|
86
|
+
"ZendureEvent",
|
|
87
|
+
"ZendureProtocolError",
|
|
88
|
+
"__version__",
|
|
89
|
+
"create_local_client",
|
|
90
|
+
]
|
zendure/_redact.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Helpers that keep sensitive identifiers out of logs and error messages."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def mask_serial(serial_number: str) -> str:
|
|
5
|
+
"""Keep a 4-character prefix for correlation and hide the rest.
|
|
6
|
+
|
|
7
|
+
``TESTDEV00000001`` -> ``TEST***``.
|
|
8
|
+
"""
|
|
9
|
+
if len(serial_number) <= 4:
|
|
10
|
+
return "***"
|
|
11
|
+
return f"{serial_number[:4]}***"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["mask_serial"]
|
zendure/ble/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""BLE Wi-Fi provisioning for Zendure devices (local, no cloud token).
|
|
2
|
+
|
|
3
|
+
Kept apart from the HTTP client so that only callers that provision devices
|
|
4
|
+
need ``bleak``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .advertisement import parse_serial_number
|
|
8
|
+
from .const import (
|
|
9
|
+
COMMAND_CHAR_UUID,
|
|
10
|
+
LOCAL_NAME_PREFIX,
|
|
11
|
+
NOTIFY_CHAR_UUID,
|
|
12
|
+
PROVISIONING_SERVICE_UUID,
|
|
13
|
+
)
|
|
14
|
+
from .provision import ProvisionResult, async_provision_wifi, build_provision_request
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"COMMAND_CHAR_UUID",
|
|
18
|
+
"LOCAL_NAME_PREFIX",
|
|
19
|
+
"NOTIFY_CHAR_UUID",
|
|
20
|
+
"PROVISIONING_SERVICE_UUID",
|
|
21
|
+
"ProvisionResult",
|
|
22
|
+
"async_provision_wifi",
|
|
23
|
+
"build_provision_request",
|
|
24
|
+
"parse_serial_number",
|
|
25
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Identify a Zendure device from its BLE advertisement.
|
|
2
|
+
|
|
3
|
+
The advertised local name (``Zen<x>_<n>``) is too short to identify a device,
|
|
4
|
+
so the serial number is read from manufacturer data instead: the 16-bit
|
|
5
|
+
company id (little endian) holds the first two serial characters and the
|
|
6
|
+
payload holds the remaining 13, followed by one trailing status byte.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from .const import SERIAL_NUMBER_LENGTH
|
|
13
|
+
|
|
14
|
+
# 2 company-id bytes + 13 serial bytes + 1 trailing byte.
|
|
15
|
+
_RAW_LENGTH: Final = SERIAL_NUMBER_LENGTH + 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_serial_number(manufacturer_data: Mapping[int, bytes]) -> str | None:
|
|
19
|
+
"""Return the serial number carried in ``manufacturer_data``, or ``None``.
|
|
20
|
+
|
|
21
|
+
``None`` means the advertisement is not a Zendure device in the expected
|
|
22
|
+
format, so callers can ignore unrelated devices whose name merely starts
|
|
23
|
+
with ``Zen``.
|
|
24
|
+
"""
|
|
25
|
+
for company_id, payload in manufacturer_data.items():
|
|
26
|
+
if not 0 <= company_id <= 0xFFFF:
|
|
27
|
+
continue
|
|
28
|
+
raw = company_id.to_bytes(2, "little") + bytes(payload)
|
|
29
|
+
if len(raw) != _RAW_LENGTH:
|
|
30
|
+
continue
|
|
31
|
+
serial = raw[:SERIAL_NUMBER_LENGTH]
|
|
32
|
+
if serial.isascii() and serial.isalnum() and serial.upper() == serial:
|
|
33
|
+
return serial.decode("ascii")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["parse_serial_number"]
|
zendure/ble/const.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""GATT layout and limits of the Zendure BLE provisioning interface.
|
|
2
|
+
|
|
3
|
+
Verified on a SolarFlow device (advertised as ``ZenEB4_5``). The device needs no
|
|
4
|
+
pairing; writes require a response.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
PROVISIONING_SERVICE_UUID: Final = "0000a002-0000-1000-8000-00805f9b34fb"
|
|
10
|
+
"""Vendor service that carries provisioning messages."""
|
|
11
|
+
|
|
12
|
+
COMMAND_CHAR_UUID: Final = "0000c304-0000-1000-8000-00805f9b34fb"
|
|
13
|
+
"""Write-with-response characteristic for requests."""
|
|
14
|
+
|
|
15
|
+
NOTIFY_CHAR_UUID: Final = "0000c305-0000-1000-8000-00805f9b34fb"
|
|
16
|
+
"""Read/notify characteristic the device pushes replies on."""
|
|
17
|
+
|
|
18
|
+
LOCAL_NAME_PREFIX: Final = "Zen"
|
|
19
|
+
"""Advertised local names start with this (``ZenEB4_5``); case-sensitive."""
|
|
20
|
+
|
|
21
|
+
MAX_PACKET_BYTES: Final = 500
|
|
22
|
+
"""Largest single write the device accepts (firmware limit)."""
|
|
23
|
+
|
|
24
|
+
SERIAL_NUMBER_LENGTH: Final = 15
|
|
25
|
+
|
|
26
|
+
DEFAULT_PROVISION_TIMEOUT: Final = 60.0
|
|
27
|
+
"""Seconds to wait for the join result, including the device's DHCP lease."""
|
|
28
|
+
|
|
29
|
+
HANDSHAKE_TIMEOUT: Final = 5.0
|
|
30
|
+
"""Seconds to wait for the device's ``BLESPP`` greeting after subscribing."""
|
|
31
|
+
|
|
32
|
+
WRITE_TIMEOUT: Final = 10.0
|
|
33
|
+
"""Seconds allowed for the request write itself to be acknowledged."""
|
|
34
|
+
|
|
35
|
+
SUBSCRIBE_TIMEOUT: Final = 10.0
|
|
36
|
+
"""Seconds allowed for enabling notifications (the CCCD write)."""
|
|
37
|
+
|
|
38
|
+
UNSUBSCRIBE_TIMEOUT: Final = 5.0
|
|
39
|
+
"""Seconds allowed for disabling notifications during cleanup."""
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"COMMAND_CHAR_UUID",
|
|
43
|
+
"DEFAULT_PROVISION_TIMEOUT",
|
|
44
|
+
"HANDSHAKE_TIMEOUT",
|
|
45
|
+
"LOCAL_NAME_PREFIX",
|
|
46
|
+
"MAX_PACKET_BYTES",
|
|
47
|
+
"NOTIFY_CHAR_UUID",
|
|
48
|
+
"PROVISIONING_SERVICE_UUID",
|
|
49
|
+
"SERIAL_NUMBER_LENGTH",
|
|
50
|
+
"SUBSCRIBE_TIMEOUT",
|
|
51
|
+
"UNSUBSCRIBE_TIMEOUT",
|
|
52
|
+
"WRITE_TIMEOUT",
|
|
53
|
+
]
|
zendure/ble/provision.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Hand Wi-Fi credentials to a Zendure device over BLE, without the cloud.
|
|
2
|
+
|
|
3
|
+
The caller owns the BLE connection (Home Assistant connects through its
|
|
4
|
+
Bluetooth stack and proxies); this module only exchanges one request and one
|
|
5
|
+
reply on an already connected client and never retries the write, because a
|
|
6
|
+
timed-out write may already have reached the device.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from ipaddress import IPv4Address
|
|
14
|
+
from typing import Any, Final
|
|
15
|
+
|
|
16
|
+
from bleak import BleakClient
|
|
17
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
18
|
+
from bleak.exc import BleakError
|
|
19
|
+
|
|
20
|
+
from ..exceptions import (
|
|
21
|
+
MalformedResponseError,
|
|
22
|
+
ProvisioningFailedError,
|
|
23
|
+
ProvisioningTimeoutError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
ZendureConnectionError,
|
|
26
|
+
ZendureProtocolError,
|
|
27
|
+
)
|
|
28
|
+
from .const import (
|
|
29
|
+
COMMAND_CHAR_UUID,
|
|
30
|
+
DEFAULT_PROVISION_TIMEOUT,
|
|
31
|
+
HANDSHAKE_TIMEOUT,
|
|
32
|
+
MAX_PACKET_BYTES,
|
|
33
|
+
NOTIFY_CHAR_UUID,
|
|
34
|
+
SUBSCRIBE_TIMEOUT,
|
|
35
|
+
UNSUBSCRIBE_TIMEOUT,
|
|
36
|
+
WRITE_TIMEOUT,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_LOGGER = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_REQUEST_METHOD: Final = "token"
|
|
42
|
+
_REPLY_METHOD: Final = "Token"
|
|
43
|
+
# The device greets a new subscriber and expects this answer before requests.
|
|
44
|
+
_HANDSHAKE_METHOD: Final = "BLESPP"
|
|
45
|
+
_HANDSHAKE_ACK: Final = b'{"method":"BLESPP_OK"}'
|
|
46
|
+
# Identifies Home Assistant as the provisioning client.
|
|
47
|
+
_SOURCE_HOME_ASSISTANT: Final = 2
|
|
48
|
+
# Device reply: 0 = joined the network, anything else = failed.
|
|
49
|
+
_RESULT_SUCCESS: Final = 0
|
|
50
|
+
_MAX_SSID_BYTES: Final = 32
|
|
51
|
+
_MIN_PASSWORD_BYTES: Final = 8
|
|
52
|
+
_MAX_PASSWORD_BYTES: Final = 64
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class ProvisionResult:
|
|
57
|
+
"""Outcome of a successful provisioning.
|
|
58
|
+
|
|
59
|
+
``host`` is the address the device reported after joining the network, or
|
|
60
|
+
``None`` when it reported none; callers then fall back to discovery.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
serial_number: str
|
|
64
|
+
host: str | None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_provision_request(ssid: str, password: str) -> bytes:
|
|
68
|
+
"""Serialize the provisioning request.
|
|
69
|
+
|
|
70
|
+
UTF-8 is sent verbatim (``ensure_ascii=False``) so non-ASCII network names
|
|
71
|
+
are not turned into ``\\u`` escapes.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValidationError: SSID or password length is invalid for Wi-Fi, or the
|
|
75
|
+
request exceeds the device's packet limit.
|
|
76
|
+
"""
|
|
77
|
+
ssid_len = len(ssid.encode())
|
|
78
|
+
if not 1 <= ssid_len <= _MAX_SSID_BYTES:
|
|
79
|
+
raise ValidationError(f"SSID must be 1-{_MAX_SSID_BYTES} bytes")
|
|
80
|
+
password_len = len(password.encode())
|
|
81
|
+
if password_len and not _MIN_PASSWORD_BYTES <= password_len <= _MAX_PASSWORD_BYTES:
|
|
82
|
+
raise ValidationError(
|
|
83
|
+
f"password must be empty or {_MIN_PASSWORD_BYTES}-"
|
|
84
|
+
f"{_MAX_PASSWORD_BYTES} bytes"
|
|
85
|
+
)
|
|
86
|
+
payload = {
|
|
87
|
+
"method": _REQUEST_METHOD,
|
|
88
|
+
"source": _SOURCE_HOME_ASSISTANT,
|
|
89
|
+
"ssid": ssid,
|
|
90
|
+
"password": password,
|
|
91
|
+
"AM": 0,
|
|
92
|
+
}
|
|
93
|
+
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
|
|
94
|
+
if len(body) > MAX_PACKET_BYTES:
|
|
95
|
+
raise ValidationError(
|
|
96
|
+
f"provisioning request is {len(body)} bytes; "
|
|
97
|
+
f"device limit is {MAX_PACKET_BYTES}"
|
|
98
|
+
)
|
|
99
|
+
return body
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_provision_reply(message: dict[str, Any]) -> ProvisionResult:
|
|
103
|
+
"""Interpret the device's ``Token`` reply.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ProvisioningFailedError: the device reports it could not join.
|
|
107
|
+
MalformedResponseError: ``result`` or ``deviceId`` is missing or wrong.
|
|
108
|
+
"""
|
|
109
|
+
result = message.get("result")
|
|
110
|
+
if not isinstance(result, int) or isinstance(result, bool):
|
|
111
|
+
raise MalformedResponseError("provisioning reply has no integer result")
|
|
112
|
+
if result != _RESULT_SUCCESS:
|
|
113
|
+
raise ProvisioningFailedError(
|
|
114
|
+
f"device could not join the Wi-Fi network (result={result})"
|
|
115
|
+
)
|
|
116
|
+
serial_number = message.get("deviceId")
|
|
117
|
+
if not isinstance(serial_number, str) or not serial_number:
|
|
118
|
+
raise MalformedResponseError("provisioning reply has no deviceId")
|
|
119
|
+
return ProvisionResult(serial_number=serial_number, host=_host(message.get("ip")))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _host(value: object) -> str | None:
|
|
123
|
+
"""Return a usable IPv4 address, or ``None`` if absent or a placeholder."""
|
|
124
|
+
if not isinstance(value, str):
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
address = IPv4Address(value)
|
|
128
|
+
except ValueError:
|
|
129
|
+
return None
|
|
130
|
+
if address.is_unspecified or address.is_loopback or address.is_multicast:
|
|
131
|
+
return None
|
|
132
|
+
return str(address)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _loggable_request(body: bytes) -> str:
|
|
136
|
+
"""Return the request exactly as sent, except the password value."""
|
|
137
|
+
payload = json.loads(body)
|
|
138
|
+
if payload.get("password"):
|
|
139
|
+
payload["password"] = "***"
|
|
140
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _ReplyCollector:
|
|
144
|
+
"""Reassemble notifications into the ``BLESPP`` greeting and ``Token`` reply.
|
|
145
|
+
|
|
146
|
+
Messages may arrive split across notifications or with a trailing NUL;
|
|
147
|
+
other messages on the same characteristic are logged and ignored.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self) -> None:
|
|
151
|
+
self._buffer = bytearray()
|
|
152
|
+
self.notifications = 0
|
|
153
|
+
loop = asyncio.get_running_loop()
|
|
154
|
+
self.greeting: asyncio.Future[dict[str, Any]] = loop.create_future()
|
|
155
|
+
self.reply: asyncio.Future[dict[str, Any]] = loop.create_future()
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def buffered(self) -> int:
|
|
159
|
+
"""Bytes received that do not yet form a complete message."""
|
|
160
|
+
return len(self._buffer)
|
|
161
|
+
|
|
162
|
+
def __call__(self, _char: BleakGATTCharacteristic, data: bytearray) -> None:
|
|
163
|
+
self.notifications += 1
|
|
164
|
+
_LOGGER.debug("BLE RX %d bytes: %r", len(data), bytes(data))
|
|
165
|
+
if self.reply.done():
|
|
166
|
+
_LOGGER.debug("BLE notification after reply (%d bytes), ignored", len(data))
|
|
167
|
+
return
|
|
168
|
+
self._buffer.extend(data)
|
|
169
|
+
try:
|
|
170
|
+
message = json.loads(self._buffer.rstrip(b"\x00"))
|
|
171
|
+
except json.JSONDecodeError, UnicodeDecodeError:
|
|
172
|
+
_LOGGER.debug(
|
|
173
|
+
"BLE notification %d bytes, %d buffered, waiting for the rest",
|
|
174
|
+
len(data),
|
|
175
|
+
len(self._buffer),
|
|
176
|
+
)
|
|
177
|
+
if len(self._buffer) > MAX_PACKET_BYTES:
|
|
178
|
+
_LOGGER.debug("Dropping %d undecodable bytes", len(self._buffer))
|
|
179
|
+
self._buffer.clear()
|
|
180
|
+
return
|
|
181
|
+
self._buffer.clear()
|
|
182
|
+
method = message.get("method") if isinstance(message, dict) else None
|
|
183
|
+
if method == _HANDSHAKE_METHOD and not self.greeting.done():
|
|
184
|
+
_LOGGER.debug("BLE handshake greeting: %s", message)
|
|
185
|
+
self.greeting.set_result(message)
|
|
186
|
+
elif method == _REPLY_METHOD:
|
|
187
|
+
_LOGGER.debug("BLE provisioning reply: %s", message)
|
|
188
|
+
self.reply.set_result(message)
|
|
189
|
+
else:
|
|
190
|
+
_LOGGER.debug("Ignoring unrelated BLE message: %s", message)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _async_write(
|
|
194
|
+
client: BleakClient,
|
|
195
|
+
command: BleakGATTCharacteristic,
|
|
196
|
+
data: bytes,
|
|
197
|
+
logged: str,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Write ``data`` with response; ``logged`` is what the TX log shows."""
|
|
200
|
+
_LOGGER.debug("BLE TX %d bytes to %s: %s", len(data), COMMAND_CHAR_UUID, logged)
|
|
201
|
+
try:
|
|
202
|
+
async with asyncio.timeout(WRITE_TIMEOUT):
|
|
203
|
+
await client.write_gatt_char(command, data, response=True)
|
|
204
|
+
except TimeoutError as err:
|
|
205
|
+
raise ZendureConnectionError(
|
|
206
|
+
"provisioning write was not acknowledged (outcome unknown)"
|
|
207
|
+
) from err
|
|
208
|
+
except BleakError as err:
|
|
209
|
+
raise ZendureConnectionError(f"provisioning write failed: {err}") from err
|
|
210
|
+
_LOGGER.debug("BLE TX acknowledged")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def _async_stop_notify(
|
|
214
|
+
client: BleakClient, notify: BleakGATTCharacteristic
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Disable notifications without letting cleanup fail or hang."""
|
|
217
|
+
try:
|
|
218
|
+
async with asyncio.timeout(UNSUBSCRIBE_TIMEOUT):
|
|
219
|
+
await client.stop_notify(notify)
|
|
220
|
+
except TimeoutError:
|
|
221
|
+
_LOGGER.debug(
|
|
222
|
+
"Stopping notifications timed out after %g s", UNSUBSCRIBE_TIMEOUT
|
|
223
|
+
)
|
|
224
|
+
except BleakError:
|
|
225
|
+
# The device may drop the link right after replying.
|
|
226
|
+
_LOGGER.debug("Could not stop notifications", exc_info=True)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _characteristic(client: BleakClient, uuid: str) -> BleakGATTCharacteristic:
|
|
230
|
+
char = client.services.get_characteristic(uuid)
|
|
231
|
+
if char is None:
|
|
232
|
+
raise ZendureProtocolError(
|
|
233
|
+
f"device does not expose the provisioning characteristic {uuid}"
|
|
234
|
+
)
|
|
235
|
+
return char
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def async_provision_wifi(
|
|
239
|
+
client: BleakClient,
|
|
240
|
+
ssid: str,
|
|
241
|
+
password: str,
|
|
242
|
+
*,
|
|
243
|
+
result_timeout: float = DEFAULT_PROVISION_TIMEOUT,
|
|
244
|
+
) -> ProvisionResult:
|
|
245
|
+
"""Send Wi-Fi credentials and wait until the device reports the result.
|
|
246
|
+
|
|
247
|
+
``client`` must already be connected; it is left connected on return.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
ValidationError: invalid credentials or request too large for the link.
|
|
251
|
+
ZendureProtocolError: the device lacks the provisioning service.
|
|
252
|
+
ZendureConnectionError: the BLE link failed, enabling notifications
|
|
253
|
+
timed out, the device did not send its ``BLESPP`` greeting, or a
|
|
254
|
+
write timed out (outcome unknown).
|
|
255
|
+
ProvisioningFailedError: the device could not join the network.
|
|
256
|
+
ProvisioningTimeoutError: no result within ``result_timeout`` seconds.
|
|
257
|
+
MalformedResponseError: the reply could not be understood.
|
|
258
|
+
"""
|
|
259
|
+
body = build_provision_request(ssid, password)
|
|
260
|
+
command = _characteristic(client, COMMAND_CHAR_UUID)
|
|
261
|
+
notify = _characteristic(client, NOTIFY_CHAR_UUID)
|
|
262
|
+
if len(body) > command.max_write_without_response_size:
|
|
263
|
+
raise ValidationError(
|
|
264
|
+
f"provisioning request is {len(body)} bytes; the BLE link allows "
|
|
265
|
+
f"{command.max_write_without_response_size}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
collector = _ReplyCollector()
|
|
269
|
+
_LOGGER.debug(
|
|
270
|
+
"BLE subscribing to %s (timeout %g s)", NOTIFY_CHAR_UUID, SUBSCRIBE_TIMEOUT
|
|
271
|
+
)
|
|
272
|
+
try:
|
|
273
|
+
async with asyncio.timeout(SUBSCRIBE_TIMEOUT):
|
|
274
|
+
await client.start_notify(notify, collector)
|
|
275
|
+
except TimeoutError as err:
|
|
276
|
+
# BlueZ may have half-enabled notifications; undo them best effort.
|
|
277
|
+
await _async_stop_notify(client, notify)
|
|
278
|
+
raise ZendureConnectionError(
|
|
279
|
+
f"subscribing to replies timed out after {SUBSCRIBE_TIMEOUT:g} s"
|
|
280
|
+
) from err
|
|
281
|
+
except BleakError as err:
|
|
282
|
+
raise ZendureConnectionError(f"could not subscribe to replies: {err}") from err
|
|
283
|
+
_LOGGER.debug(
|
|
284
|
+
"BLE subscribed to %s (props=%s), write char %s (props=%s, max write %d)",
|
|
285
|
+
NOTIFY_CHAR_UUID,
|
|
286
|
+
notify.properties,
|
|
287
|
+
COMMAND_CHAR_UUID,
|
|
288
|
+
command.properties,
|
|
289
|
+
command.max_write_without_response_size,
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
try:
|
|
293
|
+
async with asyncio.timeout(HANDSHAKE_TIMEOUT):
|
|
294
|
+
await collector.greeting
|
|
295
|
+
except TimeoutError as err:
|
|
296
|
+
_LOGGER.debug(
|
|
297
|
+
"No %s greeting within %g s: %d notifications",
|
|
298
|
+
_HANDSHAKE_METHOD,
|
|
299
|
+
HANDSHAKE_TIMEOUT,
|
|
300
|
+
collector.notifications,
|
|
301
|
+
)
|
|
302
|
+
raise ZendureConnectionError(
|
|
303
|
+
f"device did not send {_HANDSHAKE_METHOD} within "
|
|
304
|
+
f"{HANDSHAKE_TIMEOUT:g} s; BLE link not established"
|
|
305
|
+
) from err
|
|
306
|
+
await _async_write(client, command, _HANDSHAKE_ACK, _HANDSHAKE_ACK.decode())
|
|
307
|
+
# The password value is never logged, only its length.
|
|
308
|
+
await _async_write(
|
|
309
|
+
client,
|
|
310
|
+
command,
|
|
311
|
+
body,
|
|
312
|
+
f"{_loggable_request(body)} (password_len={len(password.encode())})",
|
|
313
|
+
)
|
|
314
|
+
_LOGGER.debug("Waiting up to %g s for the Token reply", result_timeout)
|
|
315
|
+
try:
|
|
316
|
+
async with asyncio.timeout(result_timeout):
|
|
317
|
+
message = await collector.reply
|
|
318
|
+
except TimeoutError as err:
|
|
319
|
+
_LOGGER.debug(
|
|
320
|
+
"No provisioning reply after %g s: %d notifications, %d bytes buffered",
|
|
321
|
+
result_timeout,
|
|
322
|
+
collector.notifications,
|
|
323
|
+
collector.buffered,
|
|
324
|
+
)
|
|
325
|
+
raise ProvisioningTimeoutError(
|
|
326
|
+
f"no provisioning result within {result_timeout:g} s"
|
|
327
|
+
) from err
|
|
328
|
+
finally:
|
|
329
|
+
await _async_stop_notify(client, notify)
|
|
330
|
+
|
|
331
|
+
result = parse_provision_reply(message)
|
|
332
|
+
_LOGGER.debug(
|
|
333
|
+
"Provisioned deviceId=%s, reported host=%s",
|
|
334
|
+
result.serial_number,
|
|
335
|
+
result.host,
|
|
336
|
+
)
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
__all__ = [
|
|
341
|
+
"ProvisionResult",
|
|
342
|
+
"async_provision_wifi",
|
|
343
|
+
"build_provision_request",
|
|
344
|
+
"parse_provision_reply",
|
|
345
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Static capability definitions: protocol-agnostic, no runtime state.
|
|
2
|
+
|
|
3
|
+
Everything here is declared data (device models, property descriptions, battery
|
|
4
|
+
rules) plus the pure functions that look it up. These tables replace the
|
|
5
|
+
inheritance tree and runtime entity-building of the old integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .controls import CONTROL_DESCRIPTIONS, control, validate_control
|
|
9
|
+
from .devices import DeviceCapability, lookup_device, normalize_product
|
|
10
|
+
from .faults import FaultDescription, FaultLevel, fault_codes, lookup_fault
|
|
11
|
+
from .sensors import (
|
|
12
|
+
BATTERY_SENSOR_DESCRIPTIONS,
|
|
13
|
+
DEVICE_SENSOR_DESCRIPTIONS,
|
|
14
|
+
battery_sensor,
|
|
15
|
+
device_sensor,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BATTERY_SENSOR_DESCRIPTIONS",
|
|
20
|
+
"CONTROL_DESCRIPTIONS",
|
|
21
|
+
"DEVICE_SENSOR_DESCRIPTIONS",
|
|
22
|
+
"DeviceCapability",
|
|
23
|
+
"FaultDescription",
|
|
24
|
+
"FaultLevel",
|
|
25
|
+
"battery_sensor",
|
|
26
|
+
"control",
|
|
27
|
+
"device_sensor",
|
|
28
|
+
"fault_codes",
|
|
29
|
+
"lookup_device",
|
|
30
|
+
"lookup_fault",
|
|
31
|
+
"normalize_product",
|
|
32
|
+
"validate_control",
|
|
33
|
+
]
|