zencontrol-python 0.1.4__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 (54) hide show
  1. {zencontrol_python-0.1.4/zencontrol_python.egg-info → zencontrol_python-0.1.6}/PKG-INFO +4 -2
  2. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/README.md +3 -1
  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.4 → zencontrol_python-0.1.6}/tests/test_sim_events.py +22 -0
  6. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/tests/test_sim_interface.py +24 -13
  7. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/tests/test_sim_protocol.py +35 -0
  8. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/__init__.py +3 -1
  9. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/api/protocol.py +7 -3
  10. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/api/types.py +4 -0
  11. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/interface/__init__.py +2 -0
  12. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/interface/interface.py +306 -24
  13. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6/zencontrol_python.egg-info}/PKG-INFO +4 -2
  14. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/SOURCES.txt +2 -1
  15. zencontrol_python-0.1.4/examples/dump_simulator_config.py +0 -474
  16. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/LICENSE +0 -0
  17. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/__init__.py +0 -0
  18. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/controller.py +0 -0
  19. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/devices.py +0 -0
  20. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/events.py +0 -0
  21. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/gear.py +0 -0
  22. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/groups.py +0 -0
  23. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/instances.py +0 -0
  24. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/interface.py +0 -0
  25. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/interface_events.py +0 -0
  26. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/leds.py +0 -0
  27. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/level_change_v2.py +0 -0
  28. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/multicast.py +0 -0
  29. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/multicast_raw.py +0 -0
  30. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/profiles.py +0 -0
  31. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/tpi_event_listener.py +0 -0
  32. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/live/variables.py +0 -0
  33. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/examples/mqtt_bridge.py +0 -0
  34. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/pyproject.toml +0 -0
  35. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/setup.cfg +0 -0
  36. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/tests/test_discovery.py +0 -0
  37. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/tests/test_entity_scope.py +0 -0
  38. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/tests/test_event_listener_disconnect.py +0 -0
  39. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/tests/test_lifecycle.py +0 -0
  40. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/tests/test_reconnect.py +0 -0
  41. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/tests/test_sub_labels.py +0 -0
  42. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/tests/test_wire_resilience.py +0 -0
  43. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/api/__init__.py +0 -0
  44. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/api/models.py +0 -0
  45. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/exceptions.py +0 -0
  46. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/io/__init__.py +0 -0
  47. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/io/command.py +0 -0
  48. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/io/event.py +0 -0
  49. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/py.typed +0 -0
  50. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol/utils.py +0 -0
  51. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/dependency_links.txt +0 -0
  52. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/entry_points.txt +0 -0
  53. {zencontrol_python-0.1.4 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/requires.txt +0 -0
  54. {zencontrol_python-0.1.4 → 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.4
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
@@ -49,10 +49,12 @@ A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwri
49
49
  Beyond basic lighting control, this library supports:
50
50
 
51
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, and system variables as rich objects with interview/discovery helpers
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
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
54
55
  * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
55
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
56
58
  * **Event filtering** — configure which TPI events the controller emits
57
59
  * **System variables** — labelled SV discovery, read/write, and change events
58
60
  * **Profiles** — query, change, and return to the scheduled profile
@@ -15,10 +15,12 @@ A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwri
15
15
  Beyond basic lighting control, this library supports:
16
16
 
17
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, and system variables as rich objects with interview/discovery helpers
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
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
20
21
  * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
21
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
22
24
  * **Event filtering** — configure which TPI events the controller emits
23
25
  * **System variables** — labelled SV discovery, read/write, and change events
24
26
  * **Profiles** — query, change, and return to the scheduled profile
@@ -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()
@@ -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.4"
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
 
@@ -57,6 +57,7 @@ class ZenCallbacks:
57
57
  self.light_change: Callable[..., Awaitable[None]] | None = None
58
58
  self.button_press: Callable[..., Awaitable[None]] | None = None
59
59
  self.button_long_press: Callable[..., Awaitable[None]] | None = None
60
+ self.absolute_input_change: Callable[..., Awaitable[None]] | None = None
60
61
  self.motion_event: Callable[..., Awaitable[None]] | None = None
61
62
  self.system_variable_change: Callable[..., Awaitable[None]] | None = None
62
63
  self.controller_discovered: Callable[[DiscoveredController], Awaitable[None]] | None = None
@@ -75,6 +76,7 @@ class EntityRegistry:
75
76
  self.lights: dict[str, Any] = {}
76
77
  self.groups: dict[str, Any] = {}
77
78
  self.buttons: dict[str, Any] = {}
79
+ self.absolute_inputs: dict[str, Any] = {}
78
80
  self.motion_sensors: dict[str, Any] = {}
79
81
  self.system_variables: dict[str, Any] = {}
80
82
 
@@ -84,6 +86,7 @@ class EntityRegistry:
84
86
  self.lights.clear()
85
87
  self.groups.clear()
86
88
  self.buttons.clear()
89
+ self.absolute_inputs.clear()
87
90
  self.motion_sensors.clear()
88
91
  self.system_variables.clear()
89
92
 
@@ -96,6 +99,7 @@ class EntityRegistry:
96
99
  self.lights,
97
100
  self.groups,
98
101
  self.buttons,
102
+ self.absolute_inputs,
99
103
  self.motion_sensors,
100
104
  self.system_variables,
101
105
  ):
@@ -873,7 +877,7 @@ class ZenProtocol:
873
877
  min_payload = {
874
878
  ZenEventCode.BUTTON_PRESS: 1,
875
879
  ZenEventCode.BUTTON_HOLD: 1,
876
- ZenEventCode.ABSOLUTE_INPUT: 1,
880
+ ZenEventCode.ABSOLUTE_INPUT: 3,
877
881
  ZenEventCode.LEVEL_CHANGE: 1,
878
882
  ZenEventCode.LEVEL_CHANGE_V2: 2,
879
883
  ZenEventCode.GROUP_LEVEL_CHANGE: 1,
@@ -921,10 +925,10 @@ class ZenProtocol:
921
925
  await self.button_hold_callback(instance=instance, payload=payload)
922
926
 
923
927
  case ZenEventCode.ABSOLUTE_INPUT:
928
+ value = (payload[1] << 8) | payload[2]
924
929
  if self.print_traffic:
925
930
  print(Fore.MAGENTA + f"{typecast.upper()} {ip_address}:" +
926
- Fore.CYAN + f" Absolute {target-64}" +
927
- Style.DIM + f" [{' '.join(f'0x{b:02X}' for b in payload)}]" +
931
+ Fore.CYAN + f" Absolute {target-64}.{payload[0]} = {value}" +
928
932
  Style.RESET_ALL)
929
933
  if self.absolute_input_callback:
930
934
  address = self._ecd_address_from_target(controller, target)
@@ -203,3 +203,7 @@ class Const:
203
203
  RECONNECT_MIN_DELAY = 1.0
204
204
  RECONNECT_MAX_DELAY = 30.0
205
205
  RECONNECT_HEALTHY_SECONDS = 60.0
206
+
207
+ # Periodic emit-state check — controllers that reboot while our listener
208
+ # stays up lose TPI event config until we re-assert it.
209
+ EVENT_KEEPALIVE_INTERVAL = 30.0
@@ -14,6 +14,7 @@ from .interface import (
14
14
  ZenLight,
15
15
  ZenGroup,
16
16
  ZenButton,
17
+ ZenAbsoluteInput,
17
18
  ZenMotionSensor,
18
19
  ZenSystemVariable,
19
20
  )
@@ -28,6 +29,7 @@ __all__ = [
28
29
  "ZenLight",
29
30
  "ZenGroup",
30
31
  "ZenButton",
32
+ "ZenAbsoluteInput",
31
33
  "ZenMotionSensor",
32
34
  "ZenSystemVariable",
33
35
  ]