pybls21 5.0.0__tar.gz → 5.1.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.
Files changed (26) hide show
  1. {pybls21-5.0.0/pybls21.egg-info → pybls21-5.1.0}/PKG-INFO +48 -2
  2. {pybls21-5.0.0 → pybls21-5.1.0}/README.md +47 -1
  3. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21/__init__.py +5 -0
  4. pybls21-5.1.0/pybls21/discovery.py +133 -0
  5. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21/exceptions.py +4 -0
  6. {pybls21-5.0.0 → pybls21-5.1.0/pybls21.egg-info}/PKG-INFO +48 -2
  7. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21.egg-info/SOURCES.txt +2 -0
  8. {pybls21-5.0.0 → pybls21-5.1.0}/pyproject.toml +1 -1
  9. pybls21-5.1.0/tests/test_discovery.py +233 -0
  10. {pybls21-5.0.0 → pybls21-5.1.0}/LICENSE +0 -0
  11. {pybls21-5.0.0 → pybls21-5.1.0}/MANIFEST.in +0 -0
  12. {pybls21-5.0.0 → pybls21-5.1.0}/MIGRATION.md +0 -0
  13. {pybls21-5.0.0 → pybls21-5.1.0}/THIRD_PARTY_NOTICES +0 -0
  14. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21/_decoder.py +0 -0
  15. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21/client.py +0 -0
  16. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21/constants.py +0 -0
  17. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21/models.py +0 -0
  18. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21/py.typed +0 -0
  19. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21.egg-info/dependency_links.txt +0 -0
  20. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21.egg-info/requires.txt +0 -0
  21. {pybls21-5.0.0 → pybls21-5.1.0}/pybls21.egg-info/top_level.txt +0 -0
  22. {pybls21-5.0.0 → pybls21-5.1.0}/setup.cfg +0 -0
  23. {pybls21-5.0.0 → pybls21-5.1.0}/tests/test_client.py +0 -0
  24. {pybls21-5.0.0 → pybls21-5.1.0}/tests/test_decoder.py +0 -0
  25. {pybls21-5.0.0 → pybls21-5.1.0}/tests/test_lifecycle.py +0 -0
  26. {pybls21-5.0.0 → pybls21-5.1.0}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybls21
3
- Version: 5.0.0
3
+ Version: 5.1.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
@@ -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
@@ -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",
@@ -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))
@@ -14,3 +14,7 @@ class ModbusCommunicationException(S21Error):
14
14
 
15
15
  Transport exceptions are retained as ``__cause__`` when wrapped.
16
16
  """
17
+
18
+
19
+ class DiscoveryError(S21Error):
20
+ """UDP discovery failed due to a socket error; the cause is retained."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybls21
3
- Version: 5.0.0
3
+ Version: 5.1.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
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pybls21"
7
- version = "5.0.0"
7
+ version = "5.1.0"
8
8
  description = "Async Modbus TCP client for Blauberg S21 ventilation devices"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14.2"
@@ -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
File without changes
File without changes
File without changes