zencontrol-python 0.1.0__tar.gz → 0.1.1__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 (49) hide show
  1. zencontrol_python-0.1.1/LICENSE +21 -0
  2. {zencontrol_python-0.1.0/zencontrol_python.egg-info → zencontrol_python-0.1.1}/PKG-INFO +6 -2
  3. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/README.md +4 -0
  4. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/pyproject.toml +1 -1
  5. zencontrol_python-0.1.1/tests/test_discovery.py +156 -0
  6. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_reconnect.py +42 -0
  7. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_wire_resilience.py +61 -1
  8. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/__init__.py +3 -2
  9. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/api/__init__.py +2 -1
  10. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/api/models.py +13 -0
  11. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/api/protocol.py +145 -3
  12. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/interface/interface.py +43 -3
  13. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/io/event.py +52 -17
  14. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1/zencontrol_python.egg-info}/PKG-INFO +6 -2
  15. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol_python.egg-info/SOURCES.txt +1 -0
  16. zencontrol_python-0.1.0/LICENSE +0 -504
  17. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/examples/__init__.py +0 -0
  18. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/examples/mqtt_bridge.py +0 -0
  19. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/setup.cfg +0 -0
  20. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_controller.py +0 -0
  21. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_devices.py +0 -0
  22. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_entity_scope.py +0 -0
  23. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_event_listener_disconnect.py +0 -0
  24. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_events.py +0 -0
  25. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_gear.py +0 -0
  26. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_groups.py +0 -0
  27. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_instances.py +0 -0
  28. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_interface.py +0 -0
  29. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_interface_events.py +0 -0
  30. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_leds.py +0 -0
  31. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_level_change_v2.py +0 -0
  32. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_lifecycle.py +0 -0
  33. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_multicast.py +0 -0
  34. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_multicast_raw.py +0 -0
  35. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_profiles.py +0 -0
  36. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_sub_labels.py +0 -0
  37. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_tpi_event_listener.py +0 -0
  38. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_variables.py +0 -0
  39. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/api/types.py +0 -0
  40. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/exceptions.py +0 -0
  41. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/interface/__init__.py +0 -0
  42. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/io/__init__.py +0 -0
  43. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/io/command.py +0 -0
  44. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/py.typed +0 -0
  45. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/utils.py +0 -0
  46. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol_python.egg-info/dependency_links.txt +0 -0
  47. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol_python.egg-info/entry_points.txt +0 -0
  48. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol_python.egg-info/requires.txt +0 -0
  49. {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol_python.egg-info/top_level.txt +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simon Wright
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zencontrol-python
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Python implementation of the Zencontrol TPI Advanced protocol for DALI lighting controllers.
5
5
  Author: Simon Wright
6
- License-Expression: LGPL-2.1-only
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/sjwright/zencontrol-python
8
8
  Project-URL: Repository, https://github.com/sjwright/zencontrol-python
9
9
  Project-URL: Issues, https://github.com/sjwright/zencontrol-python/issues
@@ -74,6 +74,10 @@ Not implemented:
74
74
  * Command to read an ambient light sensor's lux value. _(As a workaround, you can target a light sensor to a system variable. Not elegant but it works.)_
75
75
  * Event notification for ambient light sensor lux values. _(Same workaround as above.)_
76
76
 
77
+ ## License
78
+
79
+ [MIT](LICENSE)
80
+
77
81
  ## Links
78
82
 
79
83
  * [About TPI Advanced](https://support.zencontrol.com/hc/en-us/articles/360000337175-What-is-the-Third-Party-Interface-TPI)
@@ -38,6 +38,10 @@ Not implemented:
38
38
  * Command to read an ambient light sensor's lux value. _(As a workaround, you can target a light sensor to a system variable. Not elegant but it works.)_
39
39
  * Event notification for ambient light sensor lux values. _(Same workaround as above.)_
40
40
 
41
+ ## License
42
+
43
+ [MIT](LICENSE)
44
+
41
45
  ## Links
42
46
 
43
47
  * [About TPI Advanced](https://support.zencontrol.com/hc/en-us/articles/360000337175-What-is-the-Third-Party-Interface-TPI)
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  description = "Python implementation of the Zencontrol TPI Advanced protocol for DALI lighting controllers."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
- license = "LGPL-2.1-only"
11
+ license = "MIT"
12
12
  license-files = ["LICENSE"]
13
13
  authors = [
14
14
  {name = "Simon Wright"},
@@ -0,0 +1,156 @@
1
+ """Unit tests for multicast controller discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock, patch
6
+
7
+ import pytest
8
+
9
+ from zencontrol.api.models import DiscoveredController, ZenController
10
+ from zencontrol.api.protocol import ZenProtocol
11
+ from zencontrol.io.event import ZenEvent
12
+
13
+
14
+ def _event(
15
+ *,
16
+ ip: str = "192.168.1.50",
17
+ mac: bytes = b"\x02\x00\x00\x00\x00\x01",
18
+ code: int = 1,
19
+ ) -> ZenEvent:
20
+ return ZenEvent(
21
+ raw_data=b"ZC" + mac + bytes([0, code, 0]),
22
+ event_code=code,
23
+ target=0,
24
+ payload=b"\x00",
25
+ mac_address=mac,
26
+ ip_address=ip,
27
+ ip_port=6969,
28
+ )
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_unknown_multicast_queries_label_and_remembers() -> None:
33
+ protocol = ZenProtocol()
34
+ seen: list[DiscoveredController] = []
35
+
36
+ async def on_discovered(discovered: DiscoveredController) -> None:
37
+ seen.append(discovered)
38
+
39
+ protocol.callbacks.controller_discovered = on_discovered
40
+
41
+ with patch.object(
42
+ protocol, "query_controller_label", new_callable=AsyncMock, return_value="Kitchen"
43
+ ) as query:
44
+ await protocol._process_zen_event(_event())
45
+
46
+ query.assert_awaited_once()
47
+ assert len(protocol.identified_controllers) == 1
48
+ discovered = protocol.identified_controllers[0]
49
+ assert discovered.host == "192.168.1.50"
50
+ assert discovered.mac == "02:00:00:00:00:01"
51
+ assert discovered.label == "Kitchen"
52
+ assert discovered.port == 5108
53
+ assert seen == [discovered]
54
+
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_second_packet_from_same_mac_or_ip_is_ignored() -> None:
58
+ protocol = ZenProtocol()
59
+ with patch.object(
60
+ protocol, "query_controller_label", new_callable=AsyncMock, return_value="Kitchen"
61
+ ) as query:
62
+ await protocol._process_zen_event(_event())
63
+ await protocol._process_zen_event(_event())
64
+ await protocol._process_zen_event(
65
+ _event(ip="192.168.1.99", mac=b"\x02\x00\x00\x00\x00\x01")
66
+ )
67
+ await protocol._process_zen_event(
68
+ _event(ip="192.168.1.50", mac=b"\xaa\xbb\xcc\xdd\xee\xff")
69
+ )
70
+
71
+ assert query.await_count == 1
72
+ assert len(protocol.identified_controllers) == 1
73
+
74
+
75
+ @pytest.mark.asyncio
76
+ async def test_registered_controller_is_not_discovered() -> None:
77
+ protocol = ZenProtocol()
78
+ ctrl = ZenController(
79
+ id="1",
80
+ name="known",
81
+ label="Known",
82
+ host="192.168.1.50",
83
+ port=5108,
84
+ mac="02:00:00:00:00:01",
85
+ protocol=protocol,
86
+ )
87
+ protocol.set_controllers([ctrl])
88
+
89
+ with patch.object(
90
+ protocol, "query_controller_label", new_callable=AsyncMock, return_value="Nope"
91
+ ) as query:
92
+ await protocol._process_zen_event(_event())
93
+
94
+ query.assert_not_awaited()
95
+ assert protocol.identified_controllers == []
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_registering_controller_forgets_identified() -> None:
100
+ protocol = ZenProtocol()
101
+ with patch.object(
102
+ protocol, "query_controller_label", new_callable=AsyncMock, return_value="Kitchen"
103
+ ):
104
+ await protocol._process_zen_event(_event())
105
+ assert len(protocol.identified_controllers) == 1
106
+
107
+ ctrl = ZenController(
108
+ id="1",
109
+ name="kitchen",
110
+ label="Kitchen",
111
+ host="192.168.1.50",
112
+ port=5108,
113
+ mac="02:00:00:00:00:01",
114
+ protocol=protocol,
115
+ )
116
+ protocol.set_controllers([ctrl])
117
+ assert protocol.identified_controllers == []
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_label_query_failure_still_remembers_controller() -> None:
122
+ protocol = ZenProtocol()
123
+ with patch.object(
124
+ protocol,
125
+ "query_controller_label",
126
+ new_callable=AsyncMock,
127
+ side_effect=TimeoutError("offline"),
128
+ ):
129
+ await protocol._process_zen_event(_event())
130
+
131
+ assert len(protocol.identified_controllers) == 1
132
+ assert protocol.identified_controllers[0].label is None
133
+
134
+
135
+ @pytest.mark.asyncio
136
+ async def test_new_controller_discovered_while_one_is_registered() -> None:
137
+ protocol = ZenProtocol()
138
+ known = ZenController(
139
+ id="1",
140
+ name="known",
141
+ label="Known",
142
+ host="192.168.1.10",
143
+ port=5108,
144
+ mac="11:22:33:44:55:66",
145
+ protocol=protocol,
146
+ )
147
+ protocol.set_controllers([known])
148
+
149
+ with patch.object(
150
+ protocol, "query_controller_label", new_callable=AsyncMock, return_value="Annex"
151
+ ) as query:
152
+ await protocol._process_zen_event(_event())
153
+
154
+ query.assert_awaited_once()
155
+ assert len(protocol.identified_controllers) == 1
156
+ assert protocol.identified_controllers[0].label == "Annex"
@@ -122,3 +122,45 @@ async def test_stop_does_not_reconnect() -> None:
122
122
  await zen.stop()
123
123
  await asyncio.sleep(0.1)
124
124
  assert create_count == 1
125
+
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_supervisor_cancel_does_not_reconnect() -> None:
129
+ """HA cancels tasks on shutdown before unload sets _stopping — no reconnect."""
130
+ zen = ZenControl()
131
+ zen.reconnect_min_delay = 0.01
132
+ started = asyncio.Event()
133
+ create_count = 0
134
+
135
+ class _BlockingListener(_ControllableListener):
136
+ async def events(self):
137
+ started.set()
138
+ try:
139
+ while True:
140
+ await asyncio.sleep(3600)
141
+ yield # pragma: no cover
142
+ except asyncio.CancelledError:
143
+ raise
144
+
145
+ async def fake_create(*args, **kwargs):
146
+ nonlocal create_count
147
+ create_count += 1
148
+ return _BlockingListener()
149
+
150
+ with patch(
151
+ "zencontrol.api.protocol.ZenListener.create",
152
+ new=AsyncMock(side_effect=fake_create),
153
+ ):
154
+ await zen.start()
155
+ await asyncio.wait_for(started.wait(), timeout=1.0)
156
+ assert create_count == 1
157
+ assert zen._supervisor_task is not None
158
+
159
+ zen._supervisor_task.cancel()
160
+ with pytest.raises(asyncio.CancelledError):
161
+ await zen._supervisor_task
162
+
163
+ await asyncio.sleep(0.1)
164
+ assert create_count == 1
165
+ # Mimic unload after task cancel
166
+ await zen.aclose()
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from unittest.mock import AsyncMock, MagicMock, patch
6
+ import socket
7
+ from unittest.mock import AsyncMock, MagicMock, call, patch
7
8
 
8
9
  import pytest
9
10
 
@@ -44,6 +45,65 @@ async def test_event_queue_drop_oldest_under_backpressure() -> None:
44
45
  assert second.event_code == 3
45
46
 
46
47
 
48
+ @pytest.mark.asyncio
49
+ async def test_multicast_listener_joins_before_endpoint_and_drops_on_stop() -> None:
50
+ """Reuse/join must happen before asyncio owns the socket; DROP before close."""
51
+ fake_sock = MagicMock()
52
+ fake_transport = MagicMock()
53
+ fake_transport.is_closing.return_value = False
54
+ fake_transport.get_extra_info.return_value = fake_sock
55
+
56
+ with patch("zencontrol.io.event.socket.socket", return_value=fake_sock):
57
+ loop = asyncio.get_running_loop()
58
+ with patch.object(
59
+ loop,
60
+ "create_datagram_endpoint",
61
+ new_callable=AsyncMock,
62
+ return_value=(fake_transport, MagicMock()),
63
+ ) as create_endpoint:
64
+ listener = await ZenListener.create(unicast=False)
65
+
66
+ create_endpoint.assert_awaited_once()
67
+ assert create_endpoint.await_args.kwargs["sock"] is fake_sock
68
+ assert listener._mreq is not None
69
+
70
+ # First setsockopt should be SO_REUSEADDR (before bind); ADD_MEMBERSHIP after bind
71
+ first_opt = fake_sock.setsockopt.call_args_list[0].args[:2]
72
+ assert first_opt == (socket.SOL_SOCKET, socket.SO_REUSEADDR)
73
+ assert fake_sock.bind.call_args_list[0] == call(
74
+ ("0.0.0.0", EventConst.MULTICAST_PORT)
75
+ )
76
+ add_call = next(
77
+ c
78
+ for c in fake_sock.setsockopt.call_args_list
79
+ if c.args[:2] == (socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP)
80
+ )
81
+ assert add_call.args[2] == listener._mreq
82
+ bind_pos = next(
83
+ i for i, (name, *_) in enumerate(fake_sock.method_calls) if name == "bind"
84
+ )
85
+ add_pos = next(
86
+ i
87
+ for i, (name, args, _) in enumerate(fake_sock.method_calls)
88
+ if name == "setsockopt"
89
+ and args[:2] == (socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP)
90
+ )
91
+ assert bind_pos < add_pos
92
+
93
+ joined_mreq = add_call.args[2]
94
+ await listener.stop_listening()
95
+
96
+ drop_calls = [
97
+ c
98
+ for c in fake_sock.setsockopt.call_args_list
99
+ if c.args[:2] == (socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP)
100
+ ]
101
+ assert len(drop_calls) == 1
102
+ assert drop_calls[0].args[2] == joined_mreq
103
+ fake_transport.close.assert_called_once()
104
+ assert listener._mreq is None
105
+
106
+
47
107
  def test_local_ip_for_remote_returns_ipv4() -> None:
48
108
  ip = local_ip_for_remote("127.0.0.1")
49
109
  assert ip.count(".") == 3
@@ -45,7 +45,7 @@ from .interface import (
45
45
  )
46
46
 
47
47
  # API-level models (used by zen_api)
48
- from .api.models import ZenAddress, ZenInstance, ZenColour
48
+ from .api.models import ZenAddress, ZenInstance, ZenColour, DiscoveredController
49
49
  from .api.protocol import ZenProtocol
50
50
 
51
51
  # Low-level models (used by zen_io)
@@ -58,7 +58,7 @@ from .exceptions import ZenError, ZenTimeoutError, ZenResponseError
58
58
  # Utilities
59
59
  from .utils import run_with_keyboard_interrupt
60
60
 
61
- __version__ = "0.1.0"
61
+ __version__ = "0.1.1"
62
62
  __author__ = "Simon Wright"
63
63
 
64
64
  # Public API - these are the main classes users should import
@@ -80,6 +80,7 @@ __all__ = [
80
80
  "ZenInstance",
81
81
  "ZenProtocol",
82
82
  "ZenColour",
83
+ "DiscoveredController",
83
84
 
84
85
  # Low-level models (for advanced users)
85
86
  "ZenClient",
@@ -8,7 +8,7 @@ This module contains models and types that belong to the API layer:
8
8
  - Types and enums used by the API layer
9
9
  """
10
10
 
11
- from .models import ZenController, ZenAddress, ZenInstance, ZenColour, ZenProfile
11
+ from .models import ZenController, ZenAddress, ZenInstance, ZenColour, ZenProfile, DiscoveredController
12
12
  from .protocol import ZenProtocol
13
13
  from .types import ZenAddressType, ZenInstanceType, ZenColourType, ZenEventMask, ZenEventMode
14
14
 
@@ -19,6 +19,7 @@ __all__ = [
19
19
  "ZenInstance",
20
20
  "ZenColour",
21
21
  "ZenProfile",
22
+ "DiscoveredController",
22
23
  "ZenProtocol",
23
24
 
24
25
  # API-level types
@@ -18,6 +18,19 @@ from ..io import ZenClient
18
18
  from .types import ZenAddressType, ZenInstanceType, ZenColourType, Const
19
19
 
20
20
 
21
+ DEFAULT_CONTROLLER_PORT = 5108
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class DiscoveredController:
26
+ """A controller identified from multicast events (not yet registered)."""
27
+
28
+ host: str
29
+ mac: str
30
+ label: Optional[str] = None
31
+ port: int = DEFAULT_CONTROLLER_PORT
32
+
33
+
21
34
  @dataclass
22
35
  class ZenController:
23
36
  """Represents a ZenControl controller
@@ -11,11 +11,29 @@ from colorama import Fore, Back, Style
11
11
  from dataclasses import dataclass, field
12
12
 
13
13
  from ..io import ZenClient, ZenListener, ZenEvent, Request, Response, ResponseType, RequestType, EventConst, ClientConst
14
- from .models import ZenController, ZenAddress, ZenInstance, ZenColour, ZenProfile
14
+ from .models import (
15
+ DEFAULT_CONTROLLER_PORT,
16
+ DiscoveredController,
17
+ ZenController,
18
+ ZenAddress,
19
+ ZenInstance,
20
+ ZenColour,
21
+ ZenProfile,
22
+ )
15
23
  from .types import ZenAddressType, ZenInstanceType, ZenColourType, ZenEventCode, ZenEventMask, ZenEventMode, ZenErrorCode, Const
16
24
  from ..exceptions import ZenError, ZenTimeoutError, ZenResponseError
17
25
  from ..utils import local_ip_for_remote
18
26
 
27
+
28
+ def _mac_bytes_to_str(mac: bytes) -> str:
29
+ """Format MAC bytes as uppercase colon-separated hex."""
30
+ return ":".join(f"{b:02X}" for b in mac)
31
+
32
+
33
+ def _mac_str_to_key(mac: str) -> str:
34
+ """Normalize a MAC string to uppercase hex without separators."""
35
+ return mac.upper().replace(":", "").replace("-", "")
36
+
19
37
  """
20
38
  ===================================================================================
21
39
  This module implements the ZenControl TPI Advanced API using zen_io.
@@ -40,6 +58,9 @@ class ZenCallbacks:
40
58
  self.button_long_press: Optional[Callable[..., Awaitable[None]]] = None
41
59
  self.motion_event: Optional[Callable[..., Awaitable[None]]] = None
42
60
  self.system_variable_change: Optional[Callable[..., Awaitable[None]]] = None
61
+ self.controller_discovered: Optional[
62
+ Callable[[DiscoveredController], Awaitable[None]]
63
+ ] = None
43
64
 
44
65
 
45
66
  class EntityRegistry:
@@ -216,6 +237,13 @@ class ZenProtocol:
216
237
 
217
238
  # Controllers will be assigned later
218
239
  self.controllers = []
240
+ # Controllers identified via multicast but not yet registered
241
+ self.identified_controllers: list[DiscoveredController] = []
242
+ self._discovery_pending_macs: set[str] = set()
243
+ self._discovery_pending_ips: set[str] = set()
244
+ self.controller_discovered_callback: Optional[
245
+ Callable[[DiscoveredController], Awaitable[None]]
246
+ ] = None
219
247
  # High-level callback registry (replaced per ZenControl instance)
220
248
  self.callbacks = ZenCallbacks()
221
249
  # Interface-layer entity identity (scoped to this protocol)
@@ -275,6 +303,39 @@ class ZenProtocol:
275
303
 
276
304
  def set_controllers(self, controllers: list[ZenController]):
277
305
  self.controllers = controllers # Used to match events to controllers, and include controller objects in callbacks
306
+ # Drop identified entries that are now registered
307
+ for ctrl in controllers:
308
+ self.forget_identified(host=ctrl.host, mac=ctrl.mac)
309
+
310
+ def get_identified_by_ip_mac(
311
+ self, ip: Optional[str] = None, mac: Optional[bytes | str] = None
312
+ ) -> Optional[DiscoveredController]:
313
+ """Find an already-identified (discovered) controller by IP or MAC."""
314
+ mac_key: Optional[str] = None
315
+ if isinstance(mac, bytes):
316
+ mac_key = _mac_str_to_key(_mac_bytes_to_str(mac))
317
+ elif isinstance(mac, str):
318
+ mac_key = _mac_str_to_key(mac)
319
+ for discovered in self.identified_controllers:
320
+ if ip is not None and discovered.host == ip:
321
+ return discovered
322
+ if mac_key is not None and _mac_str_to_key(discovered.mac) == mac_key:
323
+ return discovered
324
+ return None
325
+
326
+ def forget_identified(
327
+ self, *, host: Optional[str] = None, mac: Optional[str] = None
328
+ ) -> None:
329
+ """Remove matching entries from the identified-controller cache."""
330
+ mac_key = _mac_str_to_key(mac) if mac else None
331
+ self.identified_controllers = [
332
+ d
333
+ for d in self.identified_controllers
334
+ if not (
335
+ (host is not None and d.host == host)
336
+ or (mac_key is not None and _mac_str_to_key(d.mac) == mac_key)
337
+ )
338
+ ]
278
339
 
279
340
  # ============================
280
341
  # PACKET SENDING
@@ -657,7 +718,88 @@ class ZenProtocol:
657
718
  if ctrl.mac_bytes == mac:
658
719
  return ctrl
659
720
  return None
660
-
721
+
722
+ async def _handle_unknown_controller(self, event: ZenEvent) -> None:
723
+ """Identify a new controller from multicast, query its label, and remember it."""
724
+ mac_str = _mac_bytes_to_str(event.mac_address)
725
+ mac_key = _mac_str_to_key(mac_str)
726
+ host = event.ip_address
727
+
728
+ if self.get_identified_by_ip_mac(host, event.mac_address) is not None:
729
+ return
730
+ if mac_key in self._discovery_pending_macs or host in self._discovery_pending_ips:
731
+ return
732
+
733
+ self._discovery_pending_macs.add(mac_key)
734
+ self._discovery_pending_ips.add(host)
735
+ label: Optional[str] = None
736
+ temp: Optional[ZenController] = None
737
+ try:
738
+ temp = ZenController(
739
+ id=f"discover-{mac_key}",
740
+ name=f"_discover_{mac_key}",
741
+ label="",
742
+ host=host,
743
+ port=DEFAULT_CONTROLLER_PORT,
744
+ mac=mac_str,
745
+ protocol=self,
746
+ )
747
+ try:
748
+ label = await self.query_controller_label(temp)
749
+ except ZenTimeoutError:
750
+ self.logger.info(
751
+ "Discovered controller %s (%s) but label query timed out",
752
+ host,
753
+ mac_str,
754
+ )
755
+ except Exception as err:
756
+ self.logger.warning(
757
+ "Discovered controller %s (%s) but label query failed: %s",
758
+ host,
759
+ mac_str,
760
+ err,
761
+ )
762
+
763
+ # Re-check in case a race registered the controller meanwhile
764
+ if self.get_controller_by_ip_mac(host, event.mac_address) is not None:
765
+ return
766
+ if self.get_identified_by_ip_mac(host, event.mac_address) is not None:
767
+ return
768
+
769
+ discovered = DiscoveredController(
770
+ host=host,
771
+ mac=mac_str,
772
+ label=label,
773
+ port=DEFAULT_CONTROLLER_PORT,
774
+ )
775
+ self.identified_controllers.append(discovered)
776
+ self.logger.info(
777
+ "Identified controller %s mac=%s label=%r",
778
+ host,
779
+ mac_str,
780
+ label,
781
+ )
782
+
783
+ callback = self.controller_discovered_callback
784
+ if callback is None and callable(self.callbacks.controller_discovered):
785
+ callback = self.callbacks.controller_discovered
786
+ if callable(callback):
787
+ try:
788
+ await callback(discovered)
789
+ except Exception as err:
790
+ self.logger.error(
791
+ "controller_discovered callback error: %s", err, exc_info=err
792
+ )
793
+ finally:
794
+ self._discovery_pending_macs.discard(mac_key)
795
+ self._discovery_pending_ips.discard(host)
796
+ if temp is not None and temp.client is not None:
797
+ try:
798
+ await temp.client.close()
799
+ except Exception:
800
+ pass
801
+ temp.client = None
802
+
661
803
  async def _process_zen_event(self, event: ZenEvent):
662
804
  """Process received ZenEvent from ZenListener"""
663
805
  typecast = "unicast" if self.unicast else "multicast"
@@ -665,7 +807,7 @@ class ZenProtocol:
665
807
  # Find the controller that sent this event
666
808
  controller = self.get_controller_by_ip_mac(event.ip_address, event.mac_address)
667
809
  if not controller:
668
- self.logger.warning(f"Received {typecast} packet from unknown controller: {event.ip_address}: [{' '.join(f'0x{b:02X}' for b in event.raw_data)}]")
810
+ await self._handle_unknown_controller(event)
669
811
  return
670
812
 
671
813
  ip_address = event.ip_address
@@ -8,6 +8,7 @@ from typing import Any, Optional, Callable, Awaitable, cast, Self
8
8
  from collections.abc import Coroutine
9
9
 
10
10
  from ..api import ZenProtocol, ZenController as SuperZenController, ZenAddress, ZenInstance, ZenAddressType, ZenColour, ZenColourType, ZenInstanceType
11
+ from ..api.models import DiscoveredController
11
12
  from ..api.protocol import ZenCallbacks
12
13
  from ..api.types import Const
13
14
 
@@ -217,6 +218,18 @@ class ZenControl:
217
218
  def system_variable_change(self, func: "CallbackSystemVariableChange | None") -> None:
218
219
  self.protocol.callbacks.system_variable_change = func
219
220
 
221
+ @property
222
+ def controller_discovered(self) -> "CallbackControllerDiscovered | None":
223
+ return self.protocol.callbacks.controller_discovered
224
+ @controller_discovered.setter
225
+ def controller_discovered(self, func: "CallbackControllerDiscovered | None") -> None:
226
+ self.protocol.callbacks.controller_discovered = func
227
+
228
+ @property
229
+ def discovered_controllers(self) -> list[DiscoveredController]:
230
+ """Controllers identified from multicast but not yet registered."""
231
+ return list(self.protocol.identified_controllers)
232
+
220
233
  # ============================
221
234
  # Setup / Start / Stop
222
235
  # ============================
@@ -228,6 +241,31 @@ class ZenControl:
228
241
  self.protocol.set_controllers(cast(list[SuperZenController], self.controllers))
229
242
  return controller
230
243
 
244
+ async def discover(self, timeout: float = 5.0) -> list[DiscoveredController]:
245
+ """Listen for multicast and return controllers identified within ``timeout`` seconds.
246
+
247
+ Starts event monitoring if needed. Works with zero registered controllers
248
+ and also reports controllers that are not already registered while running.
249
+ """
250
+ before = {
251
+ (d.mac.upper().replace("-", ":"), d.host)
252
+ for d in self.protocol.identified_controllers
253
+ }
254
+ started_here = False
255
+ if self._supervisor_task is None or self._supervisor_task.done():
256
+ await self.start()
257
+ started_here = True
258
+ try:
259
+ await asyncio.sleep(timeout)
260
+ finally:
261
+ if started_here:
262
+ await self.stop()
263
+ return [
264
+ d
265
+ for d in self.protocol.identified_controllers
266
+ if (d.mac.upper().replace("-", ":"), d.host) not in before
267
+ ]
268
+
231
269
  async def start(self) -> None:
232
270
  """Start event monitoring with automatic reconnect on unexpected loss."""
233
271
  self._stopping = False
@@ -315,8 +353,9 @@ class ZenControl:
315
353
  try:
316
354
  await event_task
317
355
  except asyncio.CancelledError:
318
- if self._stopping:
319
- return
356
+ # HA (and asyncio) cancel tasks on shutdown before unload can set
357
+ # _stopping. Never reconnect on cancel — always exit.
358
+ raise
320
359
  except Exception as err:
321
360
  self.logger.error(f"Event monitor task error: {err}")
322
361
 
@@ -336,7 +375,7 @@ class ZenControl:
336
375
  try:
337
376
  await asyncio.sleep(delay)
338
377
  except asyncio.CancelledError:
339
- return
378
+ raise
340
379
 
341
380
  async def _prepare_for_reconnect(self) -> None:
342
381
  """Drop stale clients and refresh DNS before binding the listener again."""
@@ -1577,3 +1616,4 @@ CallbackButtonPress = Callable[[ZenButton], Awaitable[None]]
1577
1616
  CallbackButtonLongPress = Callable[[ZenButton], Awaitable[None]]
1578
1617
  CallbackMotionEvent = Callable[[ZenMotionSensor, bool], Awaitable[None]]
1579
1618
  CallbackSystemVariableChange = Callable[[ZenSystemVariable, int, bool, bool], Awaitable[None]]
1619
+ CallbackControllerDiscovered = Callable[[DiscoveredController], Awaitable[None]]