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.
- zencontrol_python-0.1.1/LICENSE +21 -0
- {zencontrol_python-0.1.0/zencontrol_python.egg-info → zencontrol_python-0.1.1}/PKG-INFO +6 -2
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/README.md +4 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/pyproject.toml +1 -1
- zencontrol_python-0.1.1/tests/test_discovery.py +156 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_reconnect.py +42 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_wire_resilience.py +61 -1
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/__init__.py +3 -2
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/api/__init__.py +2 -1
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/api/models.py +13 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/api/protocol.py +145 -3
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/interface/interface.py +43 -3
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/io/event.py +52 -17
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1/zencontrol_python.egg-info}/PKG-INFO +6 -2
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol_python.egg-info/SOURCES.txt +1 -0
- zencontrol_python-0.1.0/LICENSE +0 -504
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/examples/__init__.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/examples/mqtt_bridge.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/setup.cfg +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_controller.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_devices.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_entity_scope.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_event_listener_disconnect.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_events.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_gear.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_groups.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_instances.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_interface.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_interface_events.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_leds.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_level_change_v2.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_lifecycle.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_multicast.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_multicast_raw.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_profiles.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_sub_labels.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_tpi_event_listener.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/tests/test_variables.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/api/types.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/exceptions.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/interface/__init__.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/io/__init__.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/io/command.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/py.typed +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol/utils.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol_python.egg-info/dependency_links.txt +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol_python.egg-info/entry_points.txt +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.1}/zencontrol_python.egg-info/requires.txt +0 -0
- {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.
|
|
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:
|
|
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 = "
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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]]
|