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 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"]
@@ -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
+ ]
@@ -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
+ ]