pybls21 5.0.0__tar.gz → 5.2.0__tar.gz
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.
- {pybls21-5.0.0/pybls21.egg-info → pybls21-5.2.0}/PKG-INFO +80 -2
- {pybls21-5.0.0 → pybls21-5.2.0}/README.md +79 -1
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21/__init__.py +5 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21/_decoder.py +3 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21/client.py +10 -5
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21/constants.py +4 -0
- pybls21-5.2.0/pybls21/discovery.py +133 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21/exceptions.py +4 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21/models.py +8 -0
- {pybls21-5.0.0 → pybls21-5.2.0/pybls21.egg-info}/PKG-INFO +80 -2
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21.egg-info/SOURCES.txt +2 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/pyproject.toml +1 -1
- {pybls21-5.0.0 → pybls21-5.2.0}/tests/test_client.py +90 -3
- {pybls21-5.0.0 → pybls21-5.2.0}/tests/test_decoder.py +1 -0
- pybls21-5.2.0/tests/test_discovery.py +233 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/LICENSE +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/MANIFEST.in +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/MIGRATION.md +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/THIRD_PARTY_NOTICES +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21/py.typed +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21.egg-info/dependency_links.txt +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21.egg-info/requires.txt +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/pybls21.egg-info/top_level.txt +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/setup.cfg +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/tests/test_lifecycle.py +0 -0
- {pybls21-5.0.0 → pybls21-5.2.0}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pybls21
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.2.0
|
|
4
4
|
Summary: Async Modbus TCP client for Blauberg S21 ventilation devices
|
|
5
5
|
Author-email: Julius Vitkauskas <zadintuvas@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -94,9 +94,55 @@ identity, exceptions, and Home Assistant component migration.
|
|
|
94
94
|
|
|
95
95
|
Booleans are not accepted as numeric control values. Bad arguments raise
|
|
96
96
|
`ValueError` before network I/O. `S21Error` is the base for
|
|
97
|
-
`UnsupportedDeviceException` and `ModbusCommunicationException`; the latter
|
|
97
|
+
`UnsupportedDeviceException`, `DiscoveryError`, and `ModbusCommunicationException`; the latter
|
|
98
98
|
includes transport errors and timeouts, retaining the original cause.
|
|
99
99
|
|
|
100
|
+
## Discovering devices
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from pybls21 import discover, S21Client
|
|
104
|
+
|
|
105
|
+
# Broadcast one read-only UDP request and collect replies for three seconds.
|
|
106
|
+
devices = await discover()
|
|
107
|
+
for device in devices:
|
|
108
|
+
print(device.device_id, device.host)
|
|
109
|
+
snapshot = await S21Client(device.host).poll()
|
|
110
|
+
|
|
111
|
+
# Optional: target a subnet, choose a local interface, or query a known host.
|
|
112
|
+
devices = await discover(address="192.168.1.255", local_address="192.168.1.10")
|
|
113
|
+
devices = await discover(address="192.168.1.149", timeout=1.0)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`discover()` returns a tuple of immutable `DiscoveredDevice` objects with `host`
|
|
117
|
+
and `device_id`. It collects replies for the whole timeout, deduplicates by
|
|
118
|
+
controller ID (keeping the first address received), and sorts results by ID.
|
|
119
|
+
No replies means an empty tuple. Invalid arguments raise `ValueError`; socket
|
|
120
|
+
failures raise `DiscoveryError`, a subclass of `S21Error`, retaining the cause.
|
|
121
|
+
Cancellation propagates and closes the socket. No background tasks are left running.
|
|
122
|
+
|
|
123
|
+
Discovery uses IPv4 UDP port **4000**, independently of Modbus TCP port **502**.
|
|
124
|
+
It sends a parameter-read request for controller ID and device type using an
|
|
125
|
+
empty password. Replies must have a valid checksum, matching controller IDs,
|
|
126
|
+
and S21 device type 1. This was verified by unicast and broadcast against an S21
|
|
127
|
+
running firmware `0.36 (2019-05-08)`; availability on other firmware is not yet
|
|
128
|
+
verified. The packet format follows the manufacturer's
|
|
129
|
+
[UDP protocol documentation](https://device.report/m/4ad7c8426973ad1157494cc5715f286e69d244ea3fa438d4b66851938bebf485);
|
|
130
|
+
the [S21 manual](https://blaubergdata.de/Daten/Manual_S21.pdf) describes the app's
|
|
131
|
+
network-search feature.
|
|
132
|
+
|
|
133
|
+
The default broadcast address is `255.255.255.255`; it normally reaches only the
|
|
134
|
+
local subnet. On a host with multiple network interfaces, call discovery for
|
|
135
|
+
each desired interface using `local_address` and, if necessary, its subnet's
|
|
136
|
+
broadcast address. Arguments accept IPv4 addresses, not hostnames or IPv6.
|
|
137
|
+
Firewalls and network isolation can prevent replies; manual host configuration
|
|
138
|
+
remains supported. Discovery does not automatically run when creating a client.
|
|
139
|
+
|
|
140
|
+
The controller ID is a device-provided identifier that applications can use
|
|
141
|
+
across IP changes. It is separate from `ClimateDevice` and does not change
|
|
142
|
+
existing polling or entity IDs. UDP replies are not authenticated; validate a
|
|
143
|
+
candidate with `S21Client.poll()` before setting up the device. No device settings
|
|
144
|
+
are written and no password is needed for discovery on the tested unit.
|
|
145
|
+
|
|
100
146
|
## Additional readings
|
|
101
147
|
|
|
102
148
|
`await client.poll()` also returns extract and exhaust air temperatures
|
|
@@ -113,6 +159,38 @@ a successful poll. Connection and communication failures mark
|
|
|
113
159
|
successful subsequent poll restores it. Previously returned models are immutable
|
|
114
160
|
snapshots, so read `client.device` for the updated availability.
|
|
115
161
|
|
|
162
|
+
## Heater and cooler activity
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
snapshot = await client.poll()
|
|
166
|
+
print(snapshot.is_heating, snapshot.is_cooling)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`is_heating` and `is_cooling` expose the controller's operation indications at
|
|
170
|
+
**DI7 (`DI_StatusHEATER`)** and **DI8 (`DI_StatusCOOLER`)**, respectively. Every poll
|
|
171
|
+
reads both in one additional Modbus request, regardless of mode or alarm state.
|
|
172
|
+
These booleans come directly from the controller; temperature differences,
|
|
173
|
+
selected HVAC mode, and unit power do not override them. If both bits are set,
|
|
174
|
+
both fields remain true rather than arbitrarily selecting one action.
|
|
175
|
+
They indicate controller-reported operation, not independently measured power
|
|
176
|
+
consumption or proof that the attached heater/cooler is functioning.
|
|
177
|
+
|
|
178
|
+
A successful poll populates both fields. Failed, erroneous, or short responses
|
|
179
|
+
fail the poll and mark the cached snapshot unavailable, like other required
|
|
180
|
+
reads; they never silently report inactive equipment. The new fields default to
|
|
181
|
+
`None` only for manually constructed snapshots that omit them, preserving
|
|
182
|
+
compatibility with existing callers constructing `ClimateDevice`.
|
|
183
|
+
|
|
184
|
+
The reads were verified on a physical S21 running firmware `0.36 (2019-05-08)`:
|
|
185
|
+
both flags were false in fan-only mode while the fans ran. In a controlled test,
|
|
186
|
+
heating mode with a 15 °C target left DI7 false; raising the target to 25 °C
|
|
187
|
+
made DI7 true after about six seconds. Restoring fan-only mode and 15 °C made
|
|
188
|
+
DI7 false again, with fan level and alarm codes unchanged. No cooler was
|
|
189
|
+
configured, so active cooling is covered by the Modbus test server rather than
|
|
190
|
+
a physical cooling test. The legacy `hvac_action` field remains inferred for compatibility. Use the
|
|
191
|
+
new activity flags when actual heater/cooler status is needed; downstream
|
|
192
|
+
integrations must not treat the inferred field as measured activity.
|
|
193
|
+
|
|
116
194
|
## Bypass control
|
|
117
195
|
|
|
118
196
|
```python
|
|
@@ -70,9 +70,55 @@ identity, exceptions, and Home Assistant component migration.
|
|
|
70
70
|
|
|
71
71
|
Booleans are not accepted as numeric control values. Bad arguments raise
|
|
72
72
|
`ValueError` before network I/O. `S21Error` is the base for
|
|
73
|
-
`UnsupportedDeviceException` and `ModbusCommunicationException`; the latter
|
|
73
|
+
`UnsupportedDeviceException`, `DiscoveryError`, and `ModbusCommunicationException`; the latter
|
|
74
74
|
includes transport errors and timeouts, retaining the original cause.
|
|
75
75
|
|
|
76
|
+
## Discovering devices
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from pybls21 import discover, S21Client
|
|
80
|
+
|
|
81
|
+
# Broadcast one read-only UDP request and collect replies for three seconds.
|
|
82
|
+
devices = await discover()
|
|
83
|
+
for device in devices:
|
|
84
|
+
print(device.device_id, device.host)
|
|
85
|
+
snapshot = await S21Client(device.host).poll()
|
|
86
|
+
|
|
87
|
+
# Optional: target a subnet, choose a local interface, or query a known host.
|
|
88
|
+
devices = await discover(address="192.168.1.255", local_address="192.168.1.10")
|
|
89
|
+
devices = await discover(address="192.168.1.149", timeout=1.0)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`discover()` returns a tuple of immutable `DiscoveredDevice` objects with `host`
|
|
93
|
+
and `device_id`. It collects replies for the whole timeout, deduplicates by
|
|
94
|
+
controller ID (keeping the first address received), and sorts results by ID.
|
|
95
|
+
No replies means an empty tuple. Invalid arguments raise `ValueError`; socket
|
|
96
|
+
failures raise `DiscoveryError`, a subclass of `S21Error`, retaining the cause.
|
|
97
|
+
Cancellation propagates and closes the socket. No background tasks are left running.
|
|
98
|
+
|
|
99
|
+
Discovery uses IPv4 UDP port **4000**, independently of Modbus TCP port **502**.
|
|
100
|
+
It sends a parameter-read request for controller ID and device type using an
|
|
101
|
+
empty password. Replies must have a valid checksum, matching controller IDs,
|
|
102
|
+
and S21 device type 1. This was verified by unicast and broadcast against an S21
|
|
103
|
+
running firmware `0.36 (2019-05-08)`; availability on other firmware is not yet
|
|
104
|
+
verified. The packet format follows the manufacturer's
|
|
105
|
+
[UDP protocol documentation](https://device.report/m/4ad7c8426973ad1157494cc5715f286e69d244ea3fa438d4b66851938bebf485);
|
|
106
|
+
the [S21 manual](https://blaubergdata.de/Daten/Manual_S21.pdf) describes the app's
|
|
107
|
+
network-search feature.
|
|
108
|
+
|
|
109
|
+
The default broadcast address is `255.255.255.255`; it normally reaches only the
|
|
110
|
+
local subnet. On a host with multiple network interfaces, call discovery for
|
|
111
|
+
each desired interface using `local_address` and, if necessary, its subnet's
|
|
112
|
+
broadcast address. Arguments accept IPv4 addresses, not hostnames or IPv6.
|
|
113
|
+
Firewalls and network isolation can prevent replies; manual host configuration
|
|
114
|
+
remains supported. Discovery does not automatically run when creating a client.
|
|
115
|
+
|
|
116
|
+
The controller ID is a device-provided identifier that applications can use
|
|
117
|
+
across IP changes. It is separate from `ClimateDevice` and does not change
|
|
118
|
+
existing polling or entity IDs. UDP replies are not authenticated; validate a
|
|
119
|
+
candidate with `S21Client.poll()` before setting up the device. No device settings
|
|
120
|
+
are written and no password is needed for discovery on the tested unit.
|
|
121
|
+
|
|
76
122
|
## Additional readings
|
|
77
123
|
|
|
78
124
|
`await client.poll()` also returns extract and exhaust air temperatures
|
|
@@ -89,6 +135,38 @@ a successful poll. Connection and communication failures mark
|
|
|
89
135
|
successful subsequent poll restores it. Previously returned models are immutable
|
|
90
136
|
snapshots, so read `client.device` for the updated availability.
|
|
91
137
|
|
|
138
|
+
## Heater and cooler activity
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
snapshot = await client.poll()
|
|
142
|
+
print(snapshot.is_heating, snapshot.is_cooling)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`is_heating` and `is_cooling` expose the controller's operation indications at
|
|
146
|
+
**DI7 (`DI_StatusHEATER`)** and **DI8 (`DI_StatusCOOLER`)**, respectively. Every poll
|
|
147
|
+
reads both in one additional Modbus request, regardless of mode or alarm state.
|
|
148
|
+
These booleans come directly from the controller; temperature differences,
|
|
149
|
+
selected HVAC mode, and unit power do not override them. If both bits are set,
|
|
150
|
+
both fields remain true rather than arbitrarily selecting one action.
|
|
151
|
+
They indicate controller-reported operation, not independently measured power
|
|
152
|
+
consumption or proof that the attached heater/cooler is functioning.
|
|
153
|
+
|
|
154
|
+
A successful poll populates both fields. Failed, erroneous, or short responses
|
|
155
|
+
fail the poll and mark the cached snapshot unavailable, like other required
|
|
156
|
+
reads; they never silently report inactive equipment. The new fields default to
|
|
157
|
+
`None` only for manually constructed snapshots that omit them, preserving
|
|
158
|
+
compatibility with existing callers constructing `ClimateDevice`.
|
|
159
|
+
|
|
160
|
+
The reads were verified on a physical S21 running firmware `0.36 (2019-05-08)`:
|
|
161
|
+
both flags were false in fan-only mode while the fans ran. In a controlled test,
|
|
162
|
+
heating mode with a 15 °C target left DI7 false; raising the target to 25 °C
|
|
163
|
+
made DI7 true after about six seconds. Restoring fan-only mode and 15 °C made
|
|
164
|
+
DI7 false again, with fan level and alarm codes unchanged. No cooler was
|
|
165
|
+
configured, so active cooling is covered by the Modbus test server rather than
|
|
166
|
+
a physical cooling test. The legacy `hvac_action` field remains inferred for compatibility. Use the
|
|
167
|
+
new activity flags when actual heater/cooler status is needed; downstream
|
|
168
|
+
integrations must not treat the inferred field as measured activity.
|
|
169
|
+
|
|
92
170
|
## Bypass control
|
|
93
171
|
|
|
94
172
|
```python
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Public API for the asynchronous Blauberg S21 client."""
|
|
2
2
|
|
|
3
3
|
from .client import S21Client
|
|
4
|
+
from .discovery import DiscoveredDevice, discover
|
|
4
5
|
from .exceptions import (
|
|
6
|
+
DiscoveryError,
|
|
5
7
|
ModbusCommunicationException,
|
|
6
8
|
S21Error,
|
|
7
9
|
UnsupportedDeviceException,
|
|
@@ -10,6 +12,9 @@ from .models import BypassMode, BypassType, ClimateDevice, HVACAction, HVACMode
|
|
|
10
12
|
|
|
11
13
|
__all__ = [
|
|
12
14
|
"S21Client",
|
|
15
|
+
"discover",
|
|
16
|
+
"DiscoveredDevice",
|
|
17
|
+
"DiscoveryError",
|
|
13
18
|
"S21Error",
|
|
14
19
|
"ModbusCommunicationException",
|
|
15
20
|
"UnsupportedDeviceException",
|
|
@@ -78,6 +78,7 @@ def _decode_hvac_action(
|
|
|
78
78
|
def decode_device(
|
|
79
79
|
*,
|
|
80
80
|
coils: list[bool],
|
|
81
|
+
activity: list[bool],
|
|
81
82
|
holding_registers: list[int],
|
|
82
83
|
input_registers: list[int],
|
|
83
84
|
alarm_codes: list[int],
|
|
@@ -126,6 +127,8 @@ def decode_device(
|
|
|
126
127
|
|
|
127
128
|
return ClimateDevice(
|
|
128
129
|
available=True,
|
|
130
|
+
is_heating=activity[0],
|
|
131
|
+
is_cooling=activity[reg.DI_StatusCOOLER - reg.DI_StatusHEATER],
|
|
129
132
|
name="Blauberg S21",
|
|
130
133
|
temperature_unit=TEMP_CELSIUS,
|
|
131
134
|
precision=1,
|
|
@@ -176,7 +176,7 @@ class S21Client:
|
|
|
176
176
|
bits = self._validate_modbus_response(response, operation).bits
|
|
177
177
|
if not isinstance(bits, list) or len(bits) < count:
|
|
178
178
|
raise ModbusCommunicationException(
|
|
179
|
-
f"Modbus {operation} failed: expected {count}
|
|
179
|
+
f"Modbus {operation} failed: expected {count} bits"
|
|
180
180
|
)
|
|
181
181
|
return bits
|
|
182
182
|
|
|
@@ -218,11 +218,12 @@ class S21Client:
|
|
|
218
218
|
response, count, f"read input registers at {address}"
|
|
219
219
|
)
|
|
220
220
|
|
|
221
|
+
async def _read_discrete_inputs(self, address: int, count: int) -> list[bool]:
|
|
222
|
+
response = await self._client.read_discrete_inputs(address, count=count)
|
|
223
|
+
return self._get_bits(response, count, f"read discrete inputs at {address}")
|
|
224
|
+
|
|
221
225
|
async def _read_alarm_codes(self) -> list[int]:
|
|
222
|
-
|
|
223
|
-
reg.DI_ALARM_START, count=reg.DI_ALARM_COUNT
|
|
224
|
-
)
|
|
225
|
-
bits = self._get_bits(response, reg.DI_ALARM_COUNT, "read alarm codes")
|
|
226
|
+
bits = await self._read_discrete_inputs(reg.DI_ALARM_START, reg.DI_ALARM_COUNT)
|
|
226
227
|
# Modbus pads bit responses to whole bytes; ignore bits beyond code 52.
|
|
227
228
|
return [code for code in range(reg.DI_ALARM_COUNT) if bits[code]]
|
|
228
229
|
|
|
@@ -274,6 +275,9 @@ class S21Client:
|
|
|
274
275
|
coils = await self._read_coils(0, count=4)
|
|
275
276
|
holding_registers = await self._read_holding_registers(0, count=76)
|
|
276
277
|
input_registers = await self._read_input_registers(0, count=39)
|
|
278
|
+
activity = await self._read_discrete_inputs(
|
|
279
|
+
reg.DI_StatusHEATER, count=reg.DI_StatusCOOLER - reg.DI_StatusHEATER + 1
|
|
280
|
+
)
|
|
277
281
|
alarm_codes = (
|
|
278
282
|
await self._read_alarm_codes() if input_registers[reg.IR_ALARM] else []
|
|
279
283
|
)
|
|
@@ -288,6 +292,7 @@ class S21Client:
|
|
|
288
292
|
try:
|
|
289
293
|
return decode_device(
|
|
290
294
|
coils=coils,
|
|
295
|
+
activity=activity,
|
|
291
296
|
holding_registers=holding_registers,
|
|
292
297
|
input_registers=input_registers,
|
|
293
298
|
alarm_codes=alarm_codes,
|
|
@@ -47,6 +47,10 @@ IR_StatusBpsRotor = 51
|
|
|
47
47
|
IR_CurSuFanSpeed = 52 # Actual supply fan performance, percent
|
|
48
48
|
IR_CurExFanSpeed = 53 # Actual extract fan performance, percent
|
|
49
49
|
|
|
50
|
+
# Discrete inputs: controller-reported heating/cooling activity
|
|
51
|
+
DI_StatusHEATER = 7
|
|
52
|
+
DI_StatusCOOLER = 8
|
|
53
|
+
|
|
50
54
|
# Discrete inputs: alarm codes 0 through 52
|
|
51
55
|
DI_ALARM_START = 19
|
|
52
56
|
DI_ALARM_COUNT = 53
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Read-only IPv4 discovery using the controller's UDP protocol."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
import socket
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from ipaddress import IPv4Address
|
|
8
|
+
|
|
9
|
+
from .exceptions import DiscoveryError
|
|
10
|
+
|
|
11
|
+
_DISCOVERY_PORT = 4000
|
|
12
|
+
_MAX_PACKET_SIZE = 256
|
|
13
|
+
# Read device ID (0x007c) and device type (0x00b9), with an empty password.
|
|
14
|
+
_BODY = b"\x02\x10DEFAULT_DEVICEID\x00\x01\x7c\xb9"
|
|
15
|
+
_REQUEST = b"\xfd\xfd" + _BODY + sum(_BODY).to_bytes(2, "little")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
19
|
+
class DiscoveredDevice:
|
|
20
|
+
"""An S21 discovery reply; use host with S21Client's Modbus TCP port."""
|
|
21
|
+
|
|
22
|
+
host: str = field(doc="IPv4 address from which the reply was received.")
|
|
23
|
+
device_id: str = field(doc="16-character controller ID, independent of its IP.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_response(data: bytes, host: str) -> DiscoveredDevice | None:
|
|
27
|
+
"""Ignore malformed packets and other products sharing the UDP protocol."""
|
|
28
|
+
if (
|
|
29
|
+
not 25 <= len(data) <= _MAX_PACKET_SIZE
|
|
30
|
+
or data[:4] != b"\xfd\xfd\x02\x10"
|
|
31
|
+
or sum(data[2:-2]) != int.from_bytes(data[-2:], "little")
|
|
32
|
+
):
|
|
33
|
+
return None
|
|
34
|
+
identity = data[4:20]
|
|
35
|
+
if not identity.isalnum():
|
|
36
|
+
return None
|
|
37
|
+
password_size = data[20]
|
|
38
|
+
position = 21 + password_size
|
|
39
|
+
end = len(data) - 2
|
|
40
|
+
if password_size > 8 or position >= end or data[position] != 6:
|
|
41
|
+
return None
|
|
42
|
+
position += 1
|
|
43
|
+
parameters: dict[int, bytes] = {}
|
|
44
|
+
high_byte = 0
|
|
45
|
+
while position < end:
|
|
46
|
+
parameter = data[position]
|
|
47
|
+
position += 1
|
|
48
|
+
size = 1
|
|
49
|
+
if parameter == 0xFF: # Change the high byte of subsequent parameter IDs.
|
|
50
|
+
if position >= end:
|
|
51
|
+
return None
|
|
52
|
+
high_byte = data[position] << 8
|
|
53
|
+
position += 1
|
|
54
|
+
continue
|
|
55
|
+
if parameter == 0xFE: # Explicit length for the following parameter.
|
|
56
|
+
if position + 2 > end:
|
|
57
|
+
return None
|
|
58
|
+
size, parameter = data[position : position + 2]
|
|
59
|
+
position += 2
|
|
60
|
+
if parameter >= 0xFC or size == 0 or position + size > end:
|
|
61
|
+
return None
|
|
62
|
+
key = high_byte | parameter
|
|
63
|
+
if key in parameters:
|
|
64
|
+
return None
|
|
65
|
+
parameters[key] = data[position : position + size]
|
|
66
|
+
position += size
|
|
67
|
+
# S21 is type 1. Do not mistake VENTO and related products for an S21.
|
|
68
|
+
if parameters.get(0xB9) != b"\x01\x00" or parameters.get(0x7C) != identity:
|
|
69
|
+
return None
|
|
70
|
+
return DiscoveredDevice(host=host, device_id=identity.decode("ascii"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def discover(
|
|
74
|
+
*,
|
|
75
|
+
timeout: float = 3.0,
|
|
76
|
+
address: str = "255.255.255.255",
|
|
77
|
+
local_address: str = "0.0.0.0",
|
|
78
|
+
) -> tuple[DiscoveredDevice, ...]:
|
|
79
|
+
"""Find S21 controllers without changing device settings.
|
|
80
|
+
|
|
81
|
+
Send one UDP broadcast and collect replies for ``timeout`` seconds. ``address``
|
|
82
|
+
may be a subnet broadcast or a known device's IPv4 address for unicast lookup.
|
|
83
|
+
Bind ``local_address`` to a local IPv4 address to select a network interface.
|
|
84
|
+
Hostnames and IPv6 are not supported. Broadcasts normally stay on the local
|
|
85
|
+
subnet; call separately for each desired interface on a multihomed host.
|
|
86
|
+
|
|
87
|
+
Return devices sorted by ID, retaining the first address seen for each ID.
|
|
88
|
+
Silence returns an empty tuple. Invalid arguments raise ValueError; socket
|
|
89
|
+
failures raise DiscoveryError. Cancellation propagates and closes the socket.
|
|
90
|
+
The timeout bounds sending and receiving, not just the first reply.
|
|
91
|
+
"""
|
|
92
|
+
if (
|
|
93
|
+
isinstance(timeout, bool)
|
|
94
|
+
or not isinstance(timeout, (int, float))
|
|
95
|
+
or not math.isfinite(timeout)
|
|
96
|
+
or timeout <= 0
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("timeout must be a finite positive number")
|
|
99
|
+
if not isinstance(address, str) or not isinstance(local_address, str):
|
|
100
|
+
raise ValueError("address and local_address must be IPv4 address strings")
|
|
101
|
+
destination = str(IPv4Address(address))
|
|
102
|
+
interface = str(IPv4Address(local_address))
|
|
103
|
+
loop = asyncio.get_running_loop()
|
|
104
|
+
devices: dict[str, DiscoveredDevice] = {}
|
|
105
|
+
try:
|
|
106
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
107
|
+
sock.setblocking(False)
|
|
108
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
109
|
+
sock.bind((interface, 0))
|
|
110
|
+
deadline = asyncio.timeout(timeout)
|
|
111
|
+
try:
|
|
112
|
+
async with deadline:
|
|
113
|
+
await loop.sock_sendto(
|
|
114
|
+
sock, _REQUEST, (destination, _DISCOVERY_PORT)
|
|
115
|
+
)
|
|
116
|
+
while True:
|
|
117
|
+
data, source = await loop.sock_recvfrom(
|
|
118
|
+
sock, _MAX_PACKET_SIZE + 1
|
|
119
|
+
)
|
|
120
|
+
# Yield even if a busy socket has another packet ready,
|
|
121
|
+
# so deadlines and cancellation cannot be starved.
|
|
122
|
+
await asyncio.sleep(0)
|
|
123
|
+
if source[1] != _DISCOVERY_PORT:
|
|
124
|
+
continue
|
|
125
|
+
device = _parse_response(data, source[0])
|
|
126
|
+
if device is not None:
|
|
127
|
+
devices.setdefault(device.device_id, device)
|
|
128
|
+
except TimeoutError:
|
|
129
|
+
if not deadline.expired():
|
|
130
|
+
raise
|
|
131
|
+
except OSError as error:
|
|
132
|
+
raise DiscoveryError("UDP discovery failed") from error
|
|
133
|
+
return tuple(devices[key] for key in sorted(devices))
|
|
@@ -178,3 +178,11 @@ class ClimateDevice:
|
|
|
178
178
|
default=None,
|
|
179
179
|
doc="Actual extract fan performance in percent (0–100); None for unsupported or invalid readings.",
|
|
180
180
|
)
|
|
181
|
+
is_heating: bool | None = field(
|
|
182
|
+
default=None,
|
|
183
|
+
doc="Controller heater-operation indication (DI7), independent of the selected mode; None if not populated.",
|
|
184
|
+
)
|
|
185
|
+
is_cooling: bool | None = field(
|
|
186
|
+
default=None,
|
|
187
|
+
doc="Controller cooler-operation indication (DI8), independent of the selected mode; None if not populated.",
|
|
188
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pybls21
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.2.0
|
|
4
4
|
Summary: Async Modbus TCP client for Blauberg S21 ventilation devices
|
|
5
5
|
Author-email: Julius Vitkauskas <zadintuvas@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -94,9 +94,55 @@ identity, exceptions, and Home Assistant component migration.
|
|
|
94
94
|
|
|
95
95
|
Booleans are not accepted as numeric control values. Bad arguments raise
|
|
96
96
|
`ValueError` before network I/O. `S21Error` is the base for
|
|
97
|
-
`UnsupportedDeviceException` and `ModbusCommunicationException`; the latter
|
|
97
|
+
`UnsupportedDeviceException`, `DiscoveryError`, and `ModbusCommunicationException`; the latter
|
|
98
98
|
includes transport errors and timeouts, retaining the original cause.
|
|
99
99
|
|
|
100
|
+
## Discovering devices
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from pybls21 import discover, S21Client
|
|
104
|
+
|
|
105
|
+
# Broadcast one read-only UDP request and collect replies for three seconds.
|
|
106
|
+
devices = await discover()
|
|
107
|
+
for device in devices:
|
|
108
|
+
print(device.device_id, device.host)
|
|
109
|
+
snapshot = await S21Client(device.host).poll()
|
|
110
|
+
|
|
111
|
+
# Optional: target a subnet, choose a local interface, or query a known host.
|
|
112
|
+
devices = await discover(address="192.168.1.255", local_address="192.168.1.10")
|
|
113
|
+
devices = await discover(address="192.168.1.149", timeout=1.0)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`discover()` returns a tuple of immutable `DiscoveredDevice` objects with `host`
|
|
117
|
+
and `device_id`. It collects replies for the whole timeout, deduplicates by
|
|
118
|
+
controller ID (keeping the first address received), and sorts results by ID.
|
|
119
|
+
No replies means an empty tuple. Invalid arguments raise `ValueError`; socket
|
|
120
|
+
failures raise `DiscoveryError`, a subclass of `S21Error`, retaining the cause.
|
|
121
|
+
Cancellation propagates and closes the socket. No background tasks are left running.
|
|
122
|
+
|
|
123
|
+
Discovery uses IPv4 UDP port **4000**, independently of Modbus TCP port **502**.
|
|
124
|
+
It sends a parameter-read request for controller ID and device type using an
|
|
125
|
+
empty password. Replies must have a valid checksum, matching controller IDs,
|
|
126
|
+
and S21 device type 1. This was verified by unicast and broadcast against an S21
|
|
127
|
+
running firmware `0.36 (2019-05-08)`; availability on other firmware is not yet
|
|
128
|
+
verified. The packet format follows the manufacturer's
|
|
129
|
+
[UDP protocol documentation](https://device.report/m/4ad7c8426973ad1157494cc5715f286e69d244ea3fa438d4b66851938bebf485);
|
|
130
|
+
the [S21 manual](https://blaubergdata.de/Daten/Manual_S21.pdf) describes the app's
|
|
131
|
+
network-search feature.
|
|
132
|
+
|
|
133
|
+
The default broadcast address is `255.255.255.255`; it normally reaches only the
|
|
134
|
+
local subnet. On a host with multiple network interfaces, call discovery for
|
|
135
|
+
each desired interface using `local_address` and, if necessary, its subnet's
|
|
136
|
+
broadcast address. Arguments accept IPv4 addresses, not hostnames or IPv6.
|
|
137
|
+
Firewalls and network isolation can prevent replies; manual host configuration
|
|
138
|
+
remains supported. Discovery does not automatically run when creating a client.
|
|
139
|
+
|
|
140
|
+
The controller ID is a device-provided identifier that applications can use
|
|
141
|
+
across IP changes. It is separate from `ClimateDevice` and does not change
|
|
142
|
+
existing polling or entity IDs. UDP replies are not authenticated; validate a
|
|
143
|
+
candidate with `S21Client.poll()` before setting up the device. No device settings
|
|
144
|
+
are written and no password is needed for discovery on the tested unit.
|
|
145
|
+
|
|
100
146
|
## Additional readings
|
|
101
147
|
|
|
102
148
|
`await client.poll()` also returns extract and exhaust air temperatures
|
|
@@ -113,6 +159,38 @@ a successful poll. Connection and communication failures mark
|
|
|
113
159
|
successful subsequent poll restores it. Previously returned models are immutable
|
|
114
160
|
snapshots, so read `client.device` for the updated availability.
|
|
115
161
|
|
|
162
|
+
## Heater and cooler activity
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
snapshot = await client.poll()
|
|
166
|
+
print(snapshot.is_heating, snapshot.is_cooling)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`is_heating` and `is_cooling` expose the controller's operation indications at
|
|
170
|
+
**DI7 (`DI_StatusHEATER`)** and **DI8 (`DI_StatusCOOLER`)**, respectively. Every poll
|
|
171
|
+
reads both in one additional Modbus request, regardless of mode or alarm state.
|
|
172
|
+
These booleans come directly from the controller; temperature differences,
|
|
173
|
+
selected HVAC mode, and unit power do not override them. If both bits are set,
|
|
174
|
+
both fields remain true rather than arbitrarily selecting one action.
|
|
175
|
+
They indicate controller-reported operation, not independently measured power
|
|
176
|
+
consumption or proof that the attached heater/cooler is functioning.
|
|
177
|
+
|
|
178
|
+
A successful poll populates both fields. Failed, erroneous, or short responses
|
|
179
|
+
fail the poll and mark the cached snapshot unavailable, like other required
|
|
180
|
+
reads; they never silently report inactive equipment. The new fields default to
|
|
181
|
+
`None` only for manually constructed snapshots that omit them, preserving
|
|
182
|
+
compatibility with existing callers constructing `ClimateDevice`.
|
|
183
|
+
|
|
184
|
+
The reads were verified on a physical S21 running firmware `0.36 (2019-05-08)`:
|
|
185
|
+
both flags were false in fan-only mode while the fans ran. In a controlled test,
|
|
186
|
+
heating mode with a 15 °C target left DI7 false; raising the target to 25 °C
|
|
187
|
+
made DI7 true after about six seconds. Restoring fan-only mode and 15 °C made
|
|
188
|
+
DI7 false again, with fan level and alarm codes unchanged. No cooler was
|
|
189
|
+
configured, so active cooling is covered by the Modbus test server rather than
|
|
190
|
+
a physical cooling test. The legacy `hvac_action` field remains inferred for compatibility. Use the
|
|
191
|
+
new activity flags when actual heater/cooler status is needed; downstream
|
|
192
|
+
integrations must not treat the inferred field as measured activity.
|
|
193
|
+
|
|
116
194
|
## Bypass control
|
|
117
195
|
|
|
118
196
|
```python
|
|
@@ -8,6 +8,7 @@ pybls21/__init__.py
|
|
|
8
8
|
pybls21/_decoder.py
|
|
9
9
|
pybls21/client.py
|
|
10
10
|
pybls21/constants.py
|
|
11
|
+
pybls21/discovery.py
|
|
11
12
|
pybls21/exceptions.py
|
|
12
13
|
pybls21/models.py
|
|
13
14
|
pybls21/py.typed
|
|
@@ -18,5 +19,6 @@ pybls21.egg-info/requires.txt
|
|
|
18
19
|
pybls21.egg-info/top_level.txt
|
|
19
20
|
tests/test_client.py
|
|
20
21
|
tests/test_decoder.py
|
|
22
|
+
tests/test_discovery.py
|
|
21
23
|
tests/test_lifecycle.py
|
|
22
24
|
tests/test_models.py
|
|
@@ -228,6 +228,8 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
|
|
|
228
228
|
model="S21",
|
|
229
229
|
sw_version="0.36 (2019-05-08)",
|
|
230
230
|
is_boosting=False,
|
|
231
|
+
is_heating=False,
|
|
232
|
+
is_cooling=False,
|
|
231
233
|
current_intake_temperature=10.8,
|
|
232
234
|
manual_fan_speed_percent=100,
|
|
233
235
|
max_fan_level=3,
|
|
@@ -333,6 +335,79 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
|
|
|
333
335
|
self.assertEqual(device.fan_level_timer_mode, 1)
|
|
334
336
|
self.assertEqual(device.fan_level_schedule_mode, 0)
|
|
335
337
|
|
|
338
|
+
async def test_activity_bits_are_independent_of_mode_and_temperature(self):
|
|
339
|
+
client = S21Client(self.server.host, self.server.port)
|
|
340
|
+
bank = self.server.data_bank
|
|
341
|
+
bank.set_input_registers(reg.IR_ALARM, [0])
|
|
342
|
+
bank.set_input_registers(reg.IR_CurTEMP_SuAirIn, [100, 300])
|
|
343
|
+
for powered in (False, True):
|
|
344
|
+
bank.set_coils(reg.CL_POWER, [powered])
|
|
345
|
+
for mode in range(4):
|
|
346
|
+
bank.set_holding_registers(reg.HR_OPERATION_MODE, [mode])
|
|
347
|
+
for heating, cooling in (
|
|
348
|
+
(False, False),
|
|
349
|
+
(True, False),
|
|
350
|
+
(False, True),
|
|
351
|
+
(True, True),
|
|
352
|
+
):
|
|
353
|
+
with self.subTest(
|
|
354
|
+
powered=powered, mode=mode, heating=heating, cooling=cooling
|
|
355
|
+
):
|
|
356
|
+
bank.set_discrete_inputs(
|
|
357
|
+
reg.DI_StatusHEATER, [heating, cooling]
|
|
358
|
+
)
|
|
359
|
+
snapshot = await client.poll()
|
|
360
|
+
self.assertIs(snapshot.is_heating, heating)
|
|
361
|
+
self.assertIs(snapshot.is_cooling, cooling)
|
|
362
|
+
# An unsupported temperature sensor does not invalidate operation bits.
|
|
363
|
+
bank.set_input_registers(reg.IR_CurTEMP_SuAirIn, [0x8000, 0x7FFF])
|
|
364
|
+
self.assertTrue((await client.poll()).is_heating)
|
|
365
|
+
|
|
366
|
+
async def test_activity_fields_default_to_unknown_for_older_constructors(self):
|
|
367
|
+
from dataclasses import fields
|
|
368
|
+
|
|
369
|
+
snapshot = await S21Client(self.server.host, self.server.port).poll()
|
|
370
|
+
old_arguments = {
|
|
371
|
+
field.name: getattr(snapshot, field.name)
|
|
372
|
+
for field in fields(snapshot)
|
|
373
|
+
if field.name not in {"is_heating", "is_cooling"}
|
|
374
|
+
}
|
|
375
|
+
constructed = ClimateDevice(**old_arguments)
|
|
376
|
+
self.assertIsNone(constructed.is_heating)
|
|
377
|
+
self.assertIsNone(constructed.is_cooling)
|
|
378
|
+
|
|
379
|
+
async def test_activity_byte_padding_does_not_report_cooling(self):
|
|
380
|
+
self.server.data_bank.set_input_registers(reg.IR_ALARM, [0])
|
|
381
|
+
client = S21Client(self.server.host, self.server.port)
|
|
382
|
+
client._client.read_discrete_inputs = AsyncMock(
|
|
383
|
+
return_value=SuccessResponse(bits=[True, False] + [True] * 6)
|
|
384
|
+
)
|
|
385
|
+
snapshot = await client.poll()
|
|
386
|
+
self.assertTrue(snapshot.is_heating)
|
|
387
|
+
self.assertFalse(snapshot.is_cooling)
|
|
388
|
+
client._client.read_discrete_inputs.assert_awaited_once_with(7, count=2)
|
|
389
|
+
|
|
390
|
+
async def test_activity_read_failures_invalidate_cache_and_recover(self):
|
|
391
|
+
client = S21Client(self.server.host, self.server.port)
|
|
392
|
+
read = client._client.read_discrete_inputs
|
|
393
|
+
for response in (
|
|
394
|
+
None,
|
|
395
|
+
ErrorResponse(),
|
|
396
|
+
SuccessResponse(bits=[]),
|
|
397
|
+
SuccessResponse(bits=[True]),
|
|
398
|
+
):
|
|
399
|
+
with self.subTest(response=response):
|
|
400
|
+
client._client.read_discrete_inputs = read
|
|
401
|
+
snapshot = await client.poll()
|
|
402
|
+
client._client.read_discrete_inputs = AsyncMock(return_value=response)
|
|
403
|
+
with self.assertRaises(ModbusCommunicationException):
|
|
404
|
+
await client.poll()
|
|
405
|
+
self.assertFalse(client.device.available)
|
|
406
|
+
self.assertTrue(snapshot.available)
|
|
407
|
+
self.assertFalse(client._client.connected)
|
|
408
|
+
client._client.read_discrete_inputs = read
|
|
409
|
+
self.assertTrue((await client.poll()).available)
|
|
410
|
+
|
|
336
411
|
async def test_alarm_codes_are_read_only_for_active_alarms_or_warnings(self):
|
|
337
412
|
client = S21Client(self.server.host, self.server.port)
|
|
338
413
|
client._client.read_discrete_inputs = Mock(
|
|
@@ -345,8 +420,14 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
|
|
|
345
420
|
with self.subTest(state=state):
|
|
346
421
|
bank.set_input_registers(reg.IR_ALARM, [state])
|
|
347
422
|
self.assertEqual((await client.poll()).alarm_codes, tuple(expected))
|
|
348
|
-
self.assertEqual(client._client.read_discrete_inputs.call_count,
|
|
349
|
-
|
|
423
|
+
self.assertEqual(client._client.read_discrete_inputs.call_count, 6)
|
|
424
|
+
self.assertEqual(
|
|
425
|
+
[
|
|
426
|
+
(call.args[0], call.kwargs["count"])
|
|
427
|
+
for call in client._client.read_discrete_inputs.call_args_list
|
|
428
|
+
],
|
|
429
|
+
[(7, 2), (7, 2), (19, 53), (7, 2), (19, 53), (7, 2)],
|
|
430
|
+
)
|
|
350
431
|
|
|
351
432
|
async def test_alarm_code_byte_padding_is_ignored(self):
|
|
352
433
|
self.server.data_bank.set_input_registers(reg.IR_ALARM, [1])
|
|
@@ -363,7 +444,13 @@ class TestClient(unittest.IsolatedAsyncioTestCase):
|
|
|
363
444
|
client = S21Client(self.server.host, self.server.port)
|
|
364
445
|
await client.poll()
|
|
365
446
|
self.server.data_bank.set_input_registers(reg.IR_ALARM, [1])
|
|
366
|
-
client._client.read_discrete_inputs = AsyncMock(
|
|
447
|
+
client._client.read_discrete_inputs = AsyncMock(
|
|
448
|
+
side_effect=lambda address, *, count: (
|
|
449
|
+
response
|
|
450
|
+
if address == reg.DI_ALARM_START
|
|
451
|
+
else SuccessResponse(bits=[False] * 8)
|
|
452
|
+
)
|
|
453
|
+
)
|
|
367
454
|
with self.assertRaises(ModbusCommunicationException):
|
|
368
455
|
await client.poll()
|
|
369
456
|
self.assertFalse(client.device.available)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""UDP discovery framing, real loopback I/O, and resource lifecycle tests."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
import unittest
|
|
6
|
+
from dataclasses import FrozenInstanceError
|
|
7
|
+
from unittest.mock import AsyncMock, Mock, patch
|
|
8
|
+
|
|
9
|
+
from pybls21 import DiscoveredDevice, DiscoveryError, S21Error, discover
|
|
10
|
+
from pybls21.discovery import _parse_response
|
|
11
|
+
|
|
12
|
+
# Captured from the physical S21, firmware 0.36 (2019-05-08).
|
|
13
|
+
CAPTURED = bytes.fromhex(
|
|
14
|
+
"fdfd0210303032343030353433333337353130370006"
|
|
15
|
+
"fe02b90100fe107c30303234303035343333333735313037b409"
|
|
16
|
+
)
|
|
17
|
+
IDENTITY = b"0024005433375107"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def frame(body):
|
|
21
|
+
return b"\xfd\xfd" + body + sum(body).to_bytes(2, "little")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def response(parameters=None, identity=IDENTITY, password=b"", function=6):
|
|
25
|
+
if parameters is None:
|
|
26
|
+
parameters = b"\xfe\x02\xb9\x01\x00\xfe\x10\x7c" + identity
|
|
27
|
+
return frame(
|
|
28
|
+
b"\x02\x10"
|
|
29
|
+
+ identity
|
|
30
|
+
+ bytes([len(password)])
|
|
31
|
+
+ password
|
|
32
|
+
+ bytes([function])
|
|
33
|
+
+ parameters
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestDiscoveryParser(unittest.TestCase):
|
|
38
|
+
def test_hardware_capture(self):
|
|
39
|
+
self.assertEqual(response(), CAPTURED)
|
|
40
|
+
device = _parse_response(CAPTURED, "192.168.1.149")
|
|
41
|
+
self.assertEqual(
|
|
42
|
+
device,
|
|
43
|
+
DiscoveredDevice(host="192.168.1.149", device_id=IDENTITY.decode()),
|
|
44
|
+
)
|
|
45
|
+
with self.assertRaises(FrozenInstanceError):
|
|
46
|
+
device.host = "192.168.1.1"
|
|
47
|
+
|
|
48
|
+
def test_parameter_order_lengths_and_unknown_parameters(self):
|
|
49
|
+
parameters = (
|
|
50
|
+
b"\xfe\x10\x7c"
|
|
51
|
+
+ IDENTITY
|
|
52
|
+
+ b"\x42\x09" # Unknown, default one-byte value.
|
|
53
|
+
+ b"\xff\x01\xb9\x42" # Different page, not device type.
|
|
54
|
+
+ b"\xff\x00\xfe\x02\xb9\x01\x00"
|
|
55
|
+
)
|
|
56
|
+
self.assertIsNotNone(
|
|
57
|
+
_parse_response(response(parameters, password=b"1111"), "127.0.0.1")
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def test_rejects_bad_header_checksum_identity_and_other_devices(self):
|
|
61
|
+
good_params = b"\xfe\x02\xb9\x01\x00\xfe\x10\x7c" + IDENTITY
|
|
62
|
+
bad_packets = [
|
|
63
|
+
b"",
|
|
64
|
+
CAPTURED[:-1],
|
|
65
|
+
CAPTURED + b"\x00",
|
|
66
|
+
b"\x00" * 257,
|
|
67
|
+
frame(b"\x03" + CAPTURED[3:-2]),
|
|
68
|
+
frame(b"\x02\x0f" + CAPTURED[4:-2]),
|
|
69
|
+
response(identity=b"DEFAULT_DEVICEID"),
|
|
70
|
+
response(identity=b"\xff" * 16),
|
|
71
|
+
response(password=b"123456789"),
|
|
72
|
+
frame(b"\x02\x10" + IDENTITY + b"\x08\x00\x00"),
|
|
73
|
+
response(function=1),
|
|
74
|
+
response(b"\xfe\x10\x7c" + IDENTITY),
|
|
75
|
+
response(b"\xfe\x02\xb9\x03\x00\xfe\x10\x7c" + IDENTITY),
|
|
76
|
+
response(b"\xb9\x01\xfe\x10\x7c" + IDENTITY),
|
|
77
|
+
response(good_params[:-1] + b"8"),
|
|
78
|
+
response(good_params + b"\xfe\x02\xb9\x01\x00"),
|
|
79
|
+
]
|
|
80
|
+
for packet in bad_packets:
|
|
81
|
+
with self.subTest(packet=packet):
|
|
82
|
+
self.assertIsNone(_parse_response(packet, "127.0.0.1"))
|
|
83
|
+
|
|
84
|
+
def test_rejects_truncated_or_invalid_parameter_blocks(self):
|
|
85
|
+
for suffix in (
|
|
86
|
+
b"\xff",
|
|
87
|
+
b"\xfe",
|
|
88
|
+
b"\xfe\x10",
|
|
89
|
+
b"\xfe\x10\x7cshort",
|
|
90
|
+
b"\xfe\x00\x01",
|
|
91
|
+
b"\xfc",
|
|
92
|
+
b"\xfd\x7c",
|
|
93
|
+
b"\xfe\x01\xff\x00",
|
|
94
|
+
):
|
|
95
|
+
with self.subTest(suffix=suffix):
|
|
96
|
+
self.assertIsNone(_parse_response(response(suffix), "127.0.0.1"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestDiscovery(unittest.IsolatedAsyncioTestCase):
|
|
100
|
+
async def asyncSetUp(self):
|
|
101
|
+
self.server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
102
|
+
self.server.setblocking(False)
|
|
103
|
+
self.server.bind(("127.0.0.1", 0))
|
|
104
|
+
self.addCleanup(self.server.close)
|
|
105
|
+
patcher = patch(
|
|
106
|
+
"pybls21.discovery._DISCOVERY_PORT", self.server.getsockname()[1]
|
|
107
|
+
)
|
|
108
|
+
patcher.start()
|
|
109
|
+
self.addCleanup(patcher.stop)
|
|
110
|
+
self.loop = asyncio.get_running_loop()
|
|
111
|
+
|
|
112
|
+
async def test_collects_multiple_devices_deduplicates_and_ignores_noise(self):
|
|
113
|
+
async def answer():
|
|
114
|
+
request, sender = await self.loop.sock_recvfrom(self.server, 1024)
|
|
115
|
+
# Exact wire contract: read only, empty password, ID and type queries.
|
|
116
|
+
self.assertEqual(
|
|
117
|
+
request,
|
|
118
|
+
bytes.fromhex("fdfd021044454641554c545f444556494345494400017cb9e905"),
|
|
119
|
+
)
|
|
120
|
+
for packet in (
|
|
121
|
+
b"garbage",
|
|
122
|
+
response(identity=b"0024005433379999"),
|
|
123
|
+
CAPTURED,
|
|
124
|
+
CAPTURED,
|
|
125
|
+
):
|
|
126
|
+
await self.loop.sock_sendto(self.server, packet, sender)
|
|
127
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as wrong_port:
|
|
128
|
+
wrong_port.sendto(response(identity=b"0024005433370000"), sender)
|
|
129
|
+
|
|
130
|
+
responder = asyncio.create_task(answer())
|
|
131
|
+
try:
|
|
132
|
+
devices = await discover(
|
|
133
|
+
address="127.0.0.1", local_address="127.0.0.1", timeout=0.1
|
|
134
|
+
)
|
|
135
|
+
await asyncio.wait_for(responder, 1)
|
|
136
|
+
finally:
|
|
137
|
+
responder.cancel()
|
|
138
|
+
await asyncio.gather(responder, return_exceptions=True)
|
|
139
|
+
self.assertEqual(
|
|
140
|
+
[d.device_id for d in devices], [IDENTITY.decode(), "0024005433379999"]
|
|
141
|
+
)
|
|
142
|
+
self.assertTrue(all(d.host == "127.0.0.1" for d in devices))
|
|
143
|
+
|
|
144
|
+
async def test_silence_returns_empty_tuple(self):
|
|
145
|
+
self.assertEqual(await discover(address="127.0.0.1", timeout=0.02), ())
|
|
146
|
+
|
|
147
|
+
async def test_cancellation_closes_socket(self):
|
|
148
|
+
original = socket.socket
|
|
149
|
+
sockets = []
|
|
150
|
+
|
|
151
|
+
def factory(*args, **kwargs):
|
|
152
|
+
sock = original(*args, **kwargs)
|
|
153
|
+
sockets.append(sock)
|
|
154
|
+
return sock
|
|
155
|
+
|
|
156
|
+
with patch("pybls21.discovery.socket.socket", side_effect=factory):
|
|
157
|
+
task = asyncio.create_task(discover(address="127.0.0.1", timeout=30))
|
|
158
|
+
try:
|
|
159
|
+
await asyncio.wait_for(self.loop.sock_recvfrom(self.server, 1024), 1)
|
|
160
|
+
finally:
|
|
161
|
+
task.cancel()
|
|
162
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
163
|
+
await task
|
|
164
|
+
self.assertEqual(sockets[0].fileno(), -1)
|
|
165
|
+
|
|
166
|
+
async def test_invalid_arguments_do_not_open_socket(self):
|
|
167
|
+
for kwargs in (
|
|
168
|
+
*(
|
|
169
|
+
{"timeout": value}
|
|
170
|
+
for value in (0, -1, True, float("nan"), float("inf"), "3", None)
|
|
171
|
+
),
|
|
172
|
+
{"address": "example.org"},
|
|
173
|
+
{"address": "::1"},
|
|
174
|
+
{"local_address": "not-an-ip"},
|
|
175
|
+
{"address": 123},
|
|
176
|
+
{"local_address": None},
|
|
177
|
+
):
|
|
178
|
+
with (
|
|
179
|
+
self.subTest(kwargs=kwargs),
|
|
180
|
+
patch("pybls21.discovery.socket.socket") as factory,
|
|
181
|
+
):
|
|
182
|
+
with self.assertRaises(ValueError):
|
|
183
|
+
await discover(**kwargs)
|
|
184
|
+
factory.assert_not_called()
|
|
185
|
+
|
|
186
|
+
async def test_socket_errors_are_wrapped_and_close_socket(self):
|
|
187
|
+
for stage in ("create", "bind", "send", "receive", "socket_timeout"):
|
|
188
|
+
error = (
|
|
189
|
+
TimeoutError("socket timeout")
|
|
190
|
+
if stage == "socket_timeout"
|
|
191
|
+
else OSError("network unavailable")
|
|
192
|
+
)
|
|
193
|
+
sock = Mock()
|
|
194
|
+
sock.__enter__ = Mock(return_value=sock)
|
|
195
|
+
sock.__exit__ = Mock(return_value=False)
|
|
196
|
+
factory = Mock(return_value=sock)
|
|
197
|
+
send = AsyncMock()
|
|
198
|
+
receive = AsyncMock()
|
|
199
|
+
if stage == "create":
|
|
200
|
+
factory.side_effect = error
|
|
201
|
+
elif stage == "bind":
|
|
202
|
+
sock.bind.side_effect = error
|
|
203
|
+
elif stage == "send":
|
|
204
|
+
send.side_effect = error
|
|
205
|
+
else:
|
|
206
|
+
receive.side_effect = error
|
|
207
|
+
with (
|
|
208
|
+
self.subTest(stage=stage),
|
|
209
|
+
patch("pybls21.discovery.socket.socket", factory),
|
|
210
|
+
patch.object(self.loop, "sock_sendto", send),
|
|
211
|
+
patch.object(self.loop, "sock_recvfrom", receive),
|
|
212
|
+
):
|
|
213
|
+
with self.assertRaises(DiscoveryError) as caught:
|
|
214
|
+
await discover()
|
|
215
|
+
self.assertIsInstance(caught.exception, S21Error)
|
|
216
|
+
self.assertIs(caught.exception.__cause__, error)
|
|
217
|
+
if stage != "create":
|
|
218
|
+
sock.__exit__.assert_called_once()
|
|
219
|
+
|
|
220
|
+
async def test_deadline_includes_send_and_closes_socket(self):
|
|
221
|
+
sock = Mock()
|
|
222
|
+
sock.__enter__ = Mock(return_value=sock)
|
|
223
|
+
sock.__exit__ = Mock(return_value=False)
|
|
224
|
+
|
|
225
|
+
async def blocked_send(*args):
|
|
226
|
+
await asyncio.Event().wait()
|
|
227
|
+
|
|
228
|
+
with (
|
|
229
|
+
patch("pybls21.discovery.socket.socket", return_value=sock),
|
|
230
|
+
patch.object(self.loop, "sock_sendto", side_effect=blocked_send),
|
|
231
|
+
):
|
|
232
|
+
self.assertEqual(await discover(timeout=0.01), ())
|
|
233
|
+
sock.__exit__.assert_called_once()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|