zencontrol-python 0.1.3__tar.gz → 0.1.6__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 (53) hide show
  1. {zencontrol_python-0.1.3/zencontrol_python.egg-info → zencontrol_python-0.1.6}/PKG-INFO +27 -11
  2. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/README.md +26 -10
  3. zencontrol_python-0.1.6/tests/test_absolute_input.py +96 -0
  4. zencontrol_python-0.1.6/tests/test_event_keepalive.py +174 -0
  5. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_lifecycle.py +28 -1
  6. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_sim_events.py +22 -0
  7. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_sim_interface.py +24 -13
  8. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_sim_protocol.py +35 -0
  9. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/__init__.py +3 -1
  10. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/api/protocol.py +26 -3
  11. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/api/types.py +4 -0
  12. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/interface/__init__.py +2 -0
  13. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/interface/interface.py +372 -36
  14. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6/zencontrol_python.egg-info}/PKG-INFO +27 -11
  15. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/SOURCES.txt +2 -0
  16. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/LICENSE +0 -0
  17. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/__init__.py +0 -0
  18. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/controller.py +0 -0
  19. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/devices.py +0 -0
  20. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/events.py +0 -0
  21. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/gear.py +0 -0
  22. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/groups.py +0 -0
  23. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/instances.py +0 -0
  24. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/interface.py +0 -0
  25. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/interface_events.py +0 -0
  26. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/leds.py +0 -0
  27. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/level_change_v2.py +0 -0
  28. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/multicast.py +0 -0
  29. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/multicast_raw.py +0 -0
  30. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/profiles.py +0 -0
  31. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/tpi_event_listener.py +0 -0
  32. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/variables.py +0 -0
  33. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/mqtt_bridge.py +0 -0
  34. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/pyproject.toml +0 -0
  35. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/setup.cfg +0 -0
  36. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_discovery.py +0 -0
  37. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_entity_scope.py +0 -0
  38. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_event_listener_disconnect.py +0 -0
  39. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_reconnect.py +0 -0
  40. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_sub_labels.py +0 -0
  41. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_wire_resilience.py +0 -0
  42. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/api/__init__.py +0 -0
  43. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/api/models.py +0 -0
  44. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/exceptions.py +0 -0
  45. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/io/__init__.py +0 -0
  46. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/io/command.py +0 -0
  47. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/io/event.py +0 -0
  48. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/py.typed +0 -0
  49. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/utils.py +0 -0
  50. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/dependency_links.txt +0 -0
  51. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/entry_points.txt +0 -0
  52. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/requires.txt +0 -0
  53. {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zencontrol-python
3
- Version: 0.1.3
3
+ Version: 0.1.6
4
4
  Summary: Python implementation of the Zencontrol TPI Advanced protocol for DALI lighting controllers.
5
5
  Author: Simon Wright
6
6
  License-Expression: MIT
@@ -44,6 +44,32 @@ This library has now undergone validation in multiple environments. There is an
44
44
  [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
45
45
  A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
46
46
 
47
+ ## Features
48
+
49
+ Beyond basic lighting control, this library supports:
50
+
51
+ * **Broad command surface** — inhibit, custom fade, step/up/down helpers, colour scene membership queries, EAN/serial, and most related TPI Advanced commands
52
+ * **Object-based entity model** — Optional. Expresses lights, groups, profiles, buttons, motion sensors, absolute inputs, and system variables as rich objects with interview/discovery helpers
53
+ * **UDP transport resilience** — request retries and queue-failure backoff
54
+ * **Event keepalive** — periodic emit-state ping; re-enables TPI events (and unicast target) if a controller reboots while the listener stays up
55
+ * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
56
+ * **Button events** — discovery of control-device button instances, plus press and long-press event callbacks
57
+ * **Absolute inputs** — discovery of numerical ECD instances (dials/sliders) with 16-bit value-change event callbacks
58
+ * **Event filtering** — configure which TPI events the controller emits
59
+ * **System variables** — labelled SV discovery, read/write, and change events
60
+ * **Profiles** — query, change, and return to the scheduled profile
61
+ * **Simulator-backed tests** — protocol path exercised against [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator)
62
+
63
+ ## Known limitations
64
+
65
+ * RGB+ and XY colour commands have not been tested with hardware
66
+ * Numerical (absolute) instances have not been tested with hardware
67
+
68
+ ## Out of scope
69
+
70
+ * Any commands involving DMX, Control4, or virtual instances (I don't have licenses for any of these so I couldn't test them even if I wanted to, but the scaffolding is there if anyone wishes to add support)
71
+ * Any commands described in the documentation as "legacy" (they aren't useful)
72
+
47
73
  ## Requirements
48
74
 
49
75
  * Python 3.14 (or later)
@@ -68,16 +94,6 @@ pytest -m "not simulator"
68
94
  pytest
69
95
  ```
70
96
 
71
- ## Limitations
72
-
73
- * RGB+ and XY colour commands are not tested (I don't have any compatible lights)
74
- * Numerical (absolute) instances are not tested (I don't have any such ECDs)
75
-
76
- ## Out of scope
77
-
78
- * Any commands involving DMX, Control4, or virtual instances (I don't have licenses for any of these so I couldn't test them even if I wanted to, but the scaffolding is there if anyone wishes to add support)
79
- * Any commands described in the documentation as "legacy" (they aren't useful)
80
-
81
97
  ## TPI Advanced wishlist
82
98
 
83
99
  * Command to return a controller's MAC address used for multicast packets _(There are other ways to get or infer the MAC access, but they're unreliable.)_
@@ -10,6 +10,32 @@ This library has now undergone validation in multiple environments. There is an
10
10
  [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
11
11
  A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
12
12
 
13
+ ## Features
14
+
15
+ Beyond basic lighting control, this library supports:
16
+
17
+ * **Broad command surface** — inhibit, custom fade, step/up/down helpers, colour scene membership queries, EAN/serial, and most related TPI Advanced commands
18
+ * **Object-based entity model** — Optional. Expresses lights, groups, profiles, buttons, motion sensors, absolute inputs, and system variables as rich objects with interview/discovery helpers
19
+ * **UDP transport resilience** — request retries and queue-failure backoff
20
+ * **Event keepalive** — periodic emit-state ping; re-enables TPI events (and unicast target) if a controller reboots while the listener stays up
21
+ * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
22
+ * **Button events** — discovery of control-device button instances, plus press and long-press event callbacks
23
+ * **Absolute inputs** — discovery of numerical ECD instances (dials/sliders) with 16-bit value-change event callbacks
24
+ * **Event filtering** — configure which TPI events the controller emits
25
+ * **System variables** — labelled SV discovery, read/write, and change events
26
+ * **Profiles** — query, change, and return to the scheduled profile
27
+ * **Simulator-backed tests** — protocol path exercised against [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator)
28
+
29
+ ## Known limitations
30
+
31
+ * RGB+ and XY colour commands have not been tested with hardware
32
+ * Numerical (absolute) instances have not been tested with hardware
33
+
34
+ ## Out of scope
35
+
36
+ * Any commands involving DMX, Control4, or virtual instances (I don't have licenses for any of these so I couldn't test them even if I wanted to, but the scaffolding is there if anyone wishes to add support)
37
+ * Any commands described in the documentation as "legacy" (they aren't useful)
38
+
13
39
  ## Requirements
14
40
 
15
41
  * Python 3.14 (or later)
@@ -34,16 +60,6 @@ pytest -m "not simulator"
34
60
  pytest
35
61
  ```
36
62
 
37
- ## Limitations
38
-
39
- * RGB+ and XY colour commands are not tested (I don't have any compatible lights)
40
- * Numerical (absolute) instances are not tested (I don't have any such ECDs)
41
-
42
- ## Out of scope
43
-
44
- * Any commands involving DMX, Control4, or virtual instances (I don't have licenses for any of these so I couldn't test them even if I wanted to, but the scaffolding is there if anyone wishes to add support)
45
- * Any commands described in the documentation as "legacy" (they aren't useful)
46
-
47
63
  ## TPI Advanced wishlist
48
64
 
49
65
  * Command to return a controller's MAC address used for multicast packets _(There are other ways to get or infer the MAC access, but they're unreliable.)_
@@ -0,0 +1,96 @@
1
+ """Unit tests for ZenAbsoluteInput discovery and events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import AsyncMock
6
+
7
+ import pytest
8
+
9
+ from zencontrol.api.models import ZenAddress, ZenInstance
10
+ from zencontrol.api.types import ZenAddressType, ZenInstanceType
11
+ from zencontrol.interface.interface import ZenAbsoluteInput, ZenControl
12
+
13
+
14
+ def _ecd_instance(zen: ZenControl, *, number: int = 0, inst: int = 1) -> ZenInstance:
15
+ ctrl = zen.add_controller(
16
+ id=1, name="house", label="House", host="127.0.0.1", port=5108
17
+ )
18
+ addr = ZenAddress(controller=ctrl, type=ZenAddressType.ECD, number=number)
19
+ return ZenInstance(address=addr, type=ZenInstanceType.ABSOLUTE_INPUT, number=inst)
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_absolute_input_event_parses_16bit_value() -> None:
24
+ zen = ZenControl()
25
+ instance = _ecd_instance(zen)
26
+ changes: list[tuple[ZenAbsoluteInput, int]] = []
27
+
28
+ async def on_change(absolute_input: ZenAbsoluteInput, value: int) -> None:
29
+ changes.append((absolute_input, value))
30
+
31
+ zen.absolute_input_change = on_change
32
+ absolute = ZenAbsoluteInput(protocol=zen.protocol, instance=instance)
33
+ assert absolute.interview_hydrate(
34
+ {"serial": "1", "label": "Panel", "instance_label": "Dial"}
35
+ )
36
+
37
+ await zen.absolute_input_event(instance, bytes([1, 0x12, 0x34]))
38
+ assert absolute.value == 0x1234
39
+ assert changes == [(absolute, 0x1234)]
40
+
41
+ await zen.absolute_input_event(instance, bytes([1, 0x12, 0x34]))
42
+ assert changes == [(absolute, 0x1234)]
43
+
44
+ await zen.absolute_input_event(instance, bytes([1, 0x00, 0x01]))
45
+ assert absolute.value == 1
46
+ assert changes[-1] == (absolute, 1)
47
+
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_absolute_input_event_ignores_short_payload() -> None:
51
+ zen = ZenControl()
52
+ instance = _ecd_instance(zen)
53
+ absolute = ZenAbsoluteInput(protocol=zen.protocol, instance=instance)
54
+ await zen.absolute_input_event(instance, bytes([1]))
55
+ assert absolute.value is None
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_get_absolute_inputs_filters_instance_type() -> None:
60
+ zen = ZenControl()
61
+ ctrl = zen.add_controller(
62
+ id=1, name="house", label="House", host="127.0.0.1", port=5108
63
+ )
64
+ addr = ZenAddress(controller=ctrl, type=ZenAddressType.ECD, number=2)
65
+ abs_inst = ZenInstance(
66
+ address=addr, type=ZenInstanceType.ABSOLUTE_INPUT, number=0
67
+ )
68
+ btn_inst = ZenInstance(
69
+ address=addr, type=ZenInstanceType.PUSH_BUTTON, number=1
70
+ )
71
+
72
+ zen._get_addresses_with_instances = AsyncMock(return_value=[addr]) # noqa: SLF001
73
+ zen.protocol.query_instances_by_address = AsyncMock(
74
+ return_value=[abs_inst, btn_inst]
75
+ )
76
+ zen.protocol.query_dali_device_label = AsyncMock(return_value="Wall")
77
+ zen.protocol.query_dali_serial = AsyncMock(return_value="ABC")
78
+ zen.protocol.query_dali_instance_label = AsyncMock(return_value="Slider")
79
+
80
+ found = await zen.get_absolute_inputs(controller=ctrl)
81
+ assert len(found) == 1
82
+ item = next(iter(found))
83
+ assert item.instance.type == ZenInstanceType.ABSOLUTE_INPUT
84
+ assert item.label == "Wall"
85
+ assert item.instance_label == "Slider"
86
+ assert item in ctrl.absolute_inputs
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_absolute_input_singleton_per_protocol() -> None:
91
+ zen = ZenControl()
92
+ instance = _ecd_instance(zen)
93
+ a = ZenAbsoluteInput(protocol=zen.protocol, instance=instance)
94
+ b = ZenAbsoluteInput(protocol=zen.protocol, instance=instance)
95
+ assert a is b
96
+ assert "house 0 1" in zen.protocol.entity_registry.absolute_inputs
@@ -0,0 +1,174 @@
1
+ """Unit tests for TPI event emit keepalive / re-assert."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from types import SimpleNamespace
7
+ from unittest.mock import AsyncMock, patch
8
+
9
+ import pytest
10
+
11
+ from zencontrol.api.types import ZenEventMode
12
+ from zencontrol.interface.interface import ZenControl
13
+
14
+
15
+ class _BlockingListener:
16
+ def __init__(self) -> None:
17
+ self.listen_port = 6969
18
+
19
+ async def events(self):
20
+ try:
21
+ while True:
22
+ await asyncio.sleep(3600)
23
+ yield # pragma: no cover
24
+ except asyncio.CancelledError:
25
+ raise
26
+
27
+ async def close(self):
28
+ return None
29
+
30
+
31
+ def _controller(name: str = "ctrl") -> SimpleNamespace:
32
+ return SimpleNamespace(name=name, filtering=False, mac="AA:BB:CC:DD:EE:01")
33
+
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_assert_reconfigures_when_emit_disabled() -> None:
37
+ zen = ZenControl()
38
+ zen.protocol.event_listener = object() # pretend listener is up
39
+ ctrl = _controller()
40
+ ctrl.is_controller_ready = AsyncMock(return_value=True)
41
+ zen.controllers = [ctrl] # type: ignore[list-item]
42
+
43
+ zen.protocol.query_tpi_event_unicast_address = AsyncMock(return_value=None)
44
+ zen.protocol.query_tpi_event_emit_state = AsyncMock(return_value=False)
45
+ zen.configure_controller_events = AsyncMock(return_value=True)
46
+
47
+ assert await zen.assert_controller_events(ctrl) is True # type: ignore[arg-type]
48
+ zen.configure_controller_events.assert_awaited_once_with(ctrl)
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_assert_marks_unreachable_when_reassert_fails() -> None:
53
+ zen = ZenControl()
54
+ zen.protocol.event_listener = object()
55
+ ctrl = _controller()
56
+ ctrl.is_controller_ready = AsyncMock(return_value=True)
57
+ zen.protocol.query_tpi_event_unicast_address = AsyncMock(return_value=None)
58
+ zen.protocol.query_tpi_event_emit_state = AsyncMock(return_value=False)
59
+ zen.configure_controller_events = AsyncMock(return_value=False)
60
+ status_cb = AsyncMock()
61
+ zen.controller_status_change = status_cb
62
+
63
+ assert await zen.assert_controller_events(ctrl) is False # type: ignore[arg-type]
64
+ status_cb.assert_awaited_once_with(ctrl, "unreachable")
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_assert_defers_while_controller_not_ready() -> None:
69
+ zen = ZenControl()
70
+ zen.protocol.event_listener = object()
71
+ ctrl = _controller()
72
+ ctrl.is_controller_ready = AsyncMock(return_value=False)
73
+ zen.configure_controller_events = AsyncMock()
74
+ zen.protocol.query_tpi_event_unicast_address = AsyncMock()
75
+ status_cb = AsyncMock()
76
+ zen.controller_status_change = status_cb
77
+
78
+ assert await zen.assert_controller_events(ctrl) is True # type: ignore[arg-type]
79
+ zen.configure_controller_events.assert_not_awaited()
80
+ zen.protocol.query_tpi_event_unicast_address.assert_not_awaited()
81
+ status_cb.assert_awaited_once_with(ctrl, "starting")
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_assert_noop_when_emit_enabled() -> None:
86
+ zen = ZenControl()
87
+ zen.protocol.event_listener = object()
88
+ ctrl = _controller()
89
+ ctrl.is_controller_ready = AsyncMock(return_value=True)
90
+
91
+ zen.protocol.query_tpi_event_unicast_address = AsyncMock(
92
+ return_value={
93
+ "mode": ZenEventMode(enabled=True, unicast=False, multicast=True),
94
+ "port": 0,
95
+ "ip": "0.0.0.0",
96
+ }
97
+ )
98
+ zen.configure_controller_events = AsyncMock()
99
+
100
+ assert await zen.assert_controller_events(ctrl) is True # type: ignore[arg-type]
101
+ zen.configure_controller_events.assert_not_awaited()
102
+
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_assert_reconfigures_on_unicast_target_mismatch() -> None:
106
+ zen = ZenControl(unicast=True)
107
+ zen.protocol.event_listener = object()
108
+ zen.protocol.local_ip = "192.168.1.10"
109
+ zen.protocol.listen_port = 6970
110
+ ctrl = _controller()
111
+ ctrl.is_controller_ready = AsyncMock(return_value=True)
112
+
113
+ zen.protocol.query_tpi_event_unicast_address = AsyncMock(
114
+ return_value={
115
+ "mode": ZenEventMode(enabled=True, unicast=True, multicast=False),
116
+ "port": 6970,
117
+ "ip": "192.168.1.99", # stale target after HA IP change / reboot
118
+ }
119
+ )
120
+ zen.configure_controller_events = AsyncMock(return_value=True)
121
+
122
+ assert await zen.assert_controller_events(ctrl) is True # type: ignore[arg-type]
123
+ zen.configure_controller_events.assert_awaited_once_with(ctrl)
124
+
125
+
126
+ @pytest.mark.asyncio
127
+ async def test_assert_returns_false_when_ping_fails() -> None:
128
+ zen = ZenControl()
129
+ zen.protocol.event_listener = object()
130
+ ctrl = _controller()
131
+ ctrl.is_controller_ready = AsyncMock(return_value=None)
132
+ zen.configure_controller_events = AsyncMock()
133
+
134
+ assert await zen.assert_controller_events(ctrl) is False # type: ignore[arg-type]
135
+ zen.configure_controller_events.assert_not_awaited()
136
+
137
+
138
+ @pytest.mark.asyncio
139
+ async def test_keepalive_loop_reasserts_and_stops_cleanly() -> None:
140
+ zen = ZenControl()
141
+ zen.event_keepalive_interval = 0.05
142
+ zen.reconnect_min_delay = 0.01
143
+ ctrl = _controller()
144
+ zen.add_controller(
145
+ id=1,
146
+ name=ctrl.name,
147
+ label="Controller",
148
+ host="127.0.0.1",
149
+ mac=ctrl.mac,
150
+ )
151
+
152
+ configure = AsyncMock()
153
+ zen.configure_controller_events = configure
154
+ zen.protocol.query_tpi_event_unicast_address = AsyncMock(return_value=None)
155
+ zen.protocol.query_tpi_event_emit_state = AsyncMock(return_value=False)
156
+ zen.protocol.query_controller_startup_complete = AsyncMock(return_value=True)
157
+ # start_event_monitoring configures controllers itself; stub the UDP path.
158
+ zen.protocol.set_tpi_event_unicast_address = AsyncMock()
159
+ zen.protocol.tpi_event_emit = AsyncMock(return_value=True)
160
+
161
+ with patch(
162
+ "zencontrol.api.protocol.ZenListener.create",
163
+ new=AsyncMock(return_value=_BlockingListener()),
164
+ ):
165
+ await zen.start()
166
+ for _ in range(40):
167
+ if configure.await_count >= 1:
168
+ break
169
+ await asyncio.sleep(0.05)
170
+ else:
171
+ pytest.fail("keepalive did not re-assert events")
172
+
173
+ await zen.stop()
174
+ assert zen._keepalive_task is None or zen._keepalive_task.done()
@@ -11,7 +11,6 @@ from zencontrol.api.models import ZenAddress
11
11
  from zencontrol.api.types import ZenAddressType
12
12
  from zencontrol.interface.interface import (
13
13
  ZenControl,
14
- ZenController,
15
14
  ZenLight,
16
15
  )
17
16
 
@@ -24,6 +23,34 @@ async def test_zencontrol_async_context_manager_calls_aclose() -> None:
24
23
  aclose.assert_awaited_once()
25
24
 
26
25
 
26
+ @pytest.mark.asyncio
27
+ async def test_remove_controller_closes_client_and_purges_cache() -> None:
28
+ zen = ZenControl()
29
+ ctrl_a = zen.add_controller(
30
+ id=1, name="ctrl-a", label="A", host="127.0.0.1", port=5108
31
+ )
32
+ ctrl_b = zen.add_controller(
33
+ id=2, name="ctrl-b", label="B", host="127.0.0.2", port=5108
34
+ )
35
+ fake_client = MagicMock()
36
+ fake_client.close = AsyncMock()
37
+ ctrl_a.client = fake_client
38
+ address = ZenAddress(controller=ctrl_a, type=ZenAddressType.ECG, number=1)
39
+ ZenLight(protocol=zen.protocol, address=address)
40
+ assert "ctrl-a 1" in zen.protocol.entity_registry.lights
41
+
42
+ await zen.remove_controller(ctrl_a)
43
+
44
+ fake_client.close.assert_awaited()
45
+ assert ctrl_a.client is None
46
+ assert zen.controllers == [ctrl_b]
47
+ assert "ctrl-a" not in zen.protocol.entity_registry.controllers
48
+ assert "ctrl-a 1" not in zen.protocol.entity_registry.lights
49
+ assert "ctrl-b" in zen.protocol.entity_registry.controllers
50
+
51
+ await zen.aclose()
52
+
53
+
27
54
  @pytest.mark.asyncio
28
55
  async def test_aclose_closes_clients_and_clears_instances() -> None:
29
56
  zen = ZenControl()
@@ -158,6 +158,28 @@ async def test_button_hold_and_occupancy_inject(live_sim):
158
158
  )
159
159
 
160
160
 
161
+ @pytest.mark.asyncio
162
+ async def test_absolute_input_inject(live_sim):
163
+ p = live_sim.protocol
164
+ events: list[tuple[int, int, bytes]] = []
165
+
166
+ async def on_absolute(*, instance, payload):
167
+ events.append((instance.address.number, instance.number, bytes(payload)))
168
+
169
+ p.set_callbacks(absolute_input_callback=on_absolute)
170
+ await p.start_event_monitoring()
171
+
172
+ live_sim.sim.inject_absolute_input(13, 0, 0xABCD)
173
+ await wait_until(
174
+ lambda: any(
175
+ ecd == 13 and inst == 0 and payload == bytes([0, 0xAB, 0xCD])
176
+ for ecd, inst, payload in events
177
+ ),
178
+ message="expected absolute-input inject event for ECD 13",
179
+ )
180
+ assert live_sim.world.instance(13, 0).value == 0xABCD
181
+
182
+
161
183
  @pytest.mark.asyncio
162
184
  async def test_inject_level_scene_colour_profile_events(live_sim):
163
185
  from zencontrol_simulator.world import Colour
@@ -22,6 +22,7 @@ async def test_interview_discovers_entities(live_zen):
22
22
  lights = await zen.get_lights()
23
23
  groups = await zen.get_groups()
24
24
  buttons = await zen.get_buttons()
25
+ absolute_inputs = await zen.get_absolute_inputs()
25
26
  sensors = await zen.get_motion_sensors()
26
27
  profiles = await zen.get_profiles()
27
28
  sysvars = await zen.get_system_variables(give_up_after=5)
@@ -29,6 +30,7 @@ async def test_interview_discovers_entities(live_zen):
29
30
  assert len(lights) == 12
30
31
  assert len(groups) == 6
31
32
  assert len(buttons) >= 9
33
+ assert len(absolute_inputs) >= 1
32
34
  assert len(sensors) >= 2
33
35
  assert len(profiles) == 3
34
36
  assert len(sysvars) >= 2
@@ -42,6 +44,13 @@ async def test_interview_discovers_entities(live_zen):
42
44
  assert any(getattr(s, "instance_label", None) == "Motion" for s in sensors)
43
45
  assert any(getattr(b, "label", None) == "Living Room Switch" for b in buttons)
44
46
  assert any(getattr(s, "label", None) == "Porch Sensor" for s in sensors)
47
+ slider = next(
48
+ a
49
+ for a in absolute_inputs
50
+ if a.instance.address.number == 13 and a.instance.number == 0
51
+ )
52
+ assert slider.instance_label == "Slider"
53
+ assert slider.value is None
45
54
 
46
55
  # World state still matches what we interviewed
47
56
  assert live_sim.world.lights[0].label == "Living Room Ceiling"
@@ -121,6 +130,7 @@ async def test_start_receives_injected_and_control_events(live_zen):
121
130
  button_events: list = []
122
131
  hold_events: list = []
123
132
  motion_events: list = []
133
+ absolute_events: list = []
124
134
  profile_events: list = []
125
135
  sysvar_events: list = []
126
136
  colour_events: list = []
@@ -136,6 +146,9 @@ async def test_start_receives_injected_and_control_events(live_zen):
136
146
  async def on_motion(sensor, occupied):
137
147
  motion_events.append((sensor, occupied))
138
148
 
149
+ async def on_absolute(absolute_input, value):
150
+ absolute_events.append((absolute_input, value))
151
+
139
152
  async def on_profile(profile):
140
153
  profile_events.append(profile)
141
154
 
@@ -153,6 +166,7 @@ async def test_start_receives_injected_and_control_events(live_zen):
153
166
  zen.button_press = on_button
154
167
  zen.button_long_press = on_hold
155
168
  zen.motion_event = on_motion
169
+ zen.absolute_input_change = on_absolute
156
170
  zen.profile_change = on_profile
157
171
  zen.system_variable_change = on_sysvar
158
172
  zen.light_change = on_light
@@ -201,14 +215,18 @@ async def test_start_receives_injected_and_control_events(live_zen):
201
215
  live_sim.sim.inject_button_hold(0, 1)
202
216
  live_sim.sim.inject_button_hold(0, 1)
203
217
  live_sim.sim.inject_occupancy(0, 2, occupied=True)
218
+ live_sim.sim.inject_absolute_input(13, 0, 0x1234)
204
219
  await wait_until(
205
220
  lambda: (
206
221
  len(button_events) >= 1
207
222
  and len(hold_events) >= 1
208
223
  and len(motion_events) >= 1
224
+ and any(value == 0x1234 for _, value in absolute_events)
209
225
  ),
210
- message="expected injected button/hold/motion callbacks",
226
+ message="expected injected button/hold/motion/absolute-input callbacks",
211
227
  )
228
+ assert absolute_events[0][0].value == 0x1234
229
+ assert absolute_events[0][0].instance.address.number == 13
212
230
 
213
231
  await zen.stop()
214
232
 
@@ -226,19 +244,12 @@ async def test_rgb_and_xy_via_interface(live_zen):
226
244
  assert live_sim.world.lights[2].colour.r == 1
227
245
  assert live_sim.world.lights[2].level == 100
228
246
 
229
- assert lights[3].features.get("temperature") is False or lights[3].features.get(
230
- "RGB"
231
- ) is False
247
+ assert lights[3].features.get("XY") is True
232
248
  xy = ZenColour(type=ZenColourType.XY, x=15000, y=16000)
233
- # XY spotlight may expose via colour support rather than RGB feature flag
234
- if lights[3].supports_colour(xy):
235
- assert await lights[3].set(colour=xy, level=90) is True
236
- assert live_sim.world.lights[3].colour.x == 15000
237
- assert live_sim.world.lights[3].level == 90
238
- else:
239
- # Fall back to protocol path still reachable through interface protocol
240
- assert await zen.protocol.dali_colour(lights[3].address, xy, level=90) is True
241
- assert live_sim.world.lights[3].colour.x == 15000
249
+ assert lights[3].supports_colour(xy) is True
250
+ assert await lights[3].set(colour=xy, level=90) is True
251
+ assert live_sim.world.lights[3].colour.x == 15000
252
+ assert live_sim.world.lights[3].level == 90
242
253
 
243
254
 
244
255
  @pytest.mark.asyncio
@@ -230,6 +230,41 @@ async def test_injected_button_and_occupancy_events(live_sim):
230
230
  )
231
231
 
232
232
 
233
+ @pytest.mark.asyncio
234
+ async def test_injected_absolute_input_event(live_sim):
235
+ p = live_sim.protocol
236
+ events: list[tuple[int, int, bytes]] = []
237
+
238
+ async def on_absolute(*, instance, payload):
239
+ events.append((instance.address.number, instance.number, bytes(payload)))
240
+
241
+ p.set_callbacks(absolute_input_callback=on_absolute)
242
+ await p.start_event_monitoring()
243
+ live_sim.sim.inject_absolute_input(13, 0, 4660)
244
+ await wait_until(
245
+ lambda: any(
246
+ ecd == 13 and inst == 0 and payload == bytes([0, 0x12, 0x34])
247
+ for ecd, inst, payload in events
248
+ ),
249
+ message="expected injected absolute-input event",
250
+ )
251
+
252
+
253
+ @pytest.mark.asyncio
254
+ async def test_query_absolute_input_instance(live_sim):
255
+ """Demo ECD 13 exposes a discoverable absolute_input instance."""
256
+ from zencontrol import ZenAddress, ZenAddressType, ZenInstanceType
257
+
258
+ p = live_sim.protocol
259
+ c = live_sim.controller
260
+ addr = ZenAddress(controller=c, type=ZenAddressType.ECD, number=13)
261
+ instances = await p.query_instances_by_address(address=addr)
262
+ assert any(
263
+ inst.number == 0 and inst.type == ZenInstanceType.ABSOLUTE_INPUT
264
+ for inst in instances
265
+ )
266
+
267
+
233
268
  @pytest.mark.asyncio
234
269
  async def test_timeout_when_simulator_stopped(live_sim):
235
270
  from zencontrol import ZenTimeoutError
@@ -40,6 +40,7 @@ from .interface import (
40
40
  ZenLight,
41
41
  ZenGroup,
42
42
  ZenButton,
43
+ ZenAbsoluteInput,
43
44
  ZenMotionSensor,
44
45
  ZenSystemVariable,
45
46
  )
@@ -58,7 +59,7 @@ from .exceptions import ZenError, ZenTimeoutError, ZenResponseError, ZenConnecti
58
59
  # Utilities
59
60
  from .utils import run_with_keyboard_interrupt
60
61
 
61
- __version__ = "0.1.3"
62
+ __version__ = "0.1.6"
62
63
  __author__ = "Simon Wright"
63
64
 
64
65
  # Public API - these are the main classes users should import
@@ -72,6 +73,7 @@ __all__ = [
72
73
  "ZenLight",
73
74
  "ZenGroup",
74
75
  "ZenButton",
76
+ "ZenAbsoluteInput",
75
77
  "ZenMotionSensor",
76
78
  "ZenSystemVariable",
77
79