zencontrol-python 0.1.4__tar.gz → 0.1.7__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-0.1.7}/PKG-INFO +10 -5
  2. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/README.md +3 -1
  3. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/level_change_v2.py +1 -1
  4. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/mqtt_bridge.py +7 -1
  5. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/pyproject.toml +15 -24
  6. zencontrol_python-0.1.7/tests/test_absolute_input.py +96 -0
  7. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_discovery.py +5 -4
  8. zencontrol_python-0.1.7/tests/test_event_keepalive.py +174 -0
  9. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_lifecycle.py +1 -1
  10. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_sim_events.py +26 -5
  11. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_sim_interface.py +127 -31
  12. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_sim_protocol.py +138 -25
  13. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_wire_resilience.py +17 -8
  14. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/__init__.py +35 -13
  15. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/api/__init__.py +15 -2
  16. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/api/models.py +27 -12
  17. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/api/protocol.py +77 -48
  18. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/api/types.py +6 -2
  19. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/exceptions.py +1 -0
  20. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/interface/__init__.py +5 -3
  21. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/interface/interface.py +379 -90
  22. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/io/__init__.py +9 -2
  23. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/io/command.py +20 -16
  24. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/io/event.py +24 -22
  25. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/utils.py +2 -2
  26. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/PKG-INFO +10 -5
  27. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/SOURCES.txt +2 -1
  28. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/requires.txt +6 -2
  29. zencontrol_python-0.1.4/examples/dump_simulator_config.py +0 -474
  30. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/LICENSE +0 -0
  31. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/__init__.py +0 -0
  32. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/controller.py +0 -0
  33. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/devices.py +0 -0
  34. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/events.py +0 -0
  35. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/gear.py +0 -0
  36. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/groups.py +0 -0
  37. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/instances.py +0 -0
  38. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/interface.py +0 -0
  39. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/interface_events.py +0 -0
  40. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/leds.py +0 -0
  41. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/multicast.py +0 -0
  42. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/multicast_raw.py +0 -0
  43. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/profiles.py +0 -0
  44. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/tpi_event_listener.py +0 -0
  45. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/variables.py +0 -0
  46. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/setup.cfg +0 -0
  47. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_entity_scope.py +0 -0
  48. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_event_listener_disconnect.py +0 -0
  49. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_reconnect.py +0 -0
  50. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_sub_labels.py +0 -0
  51. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/py.typed +0 -0
  52. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/dependency_links.txt +0 -0
  53. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/entry_points.txt +0 -0
  54. {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/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.7
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
@@ -19,17 +19,20 @@ Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.14
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: colorama>=0.4.6
22
+ Provides-Extra: colorama
23
+ Requires-Dist: colorama>=0.4.6; extra == "colorama"
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: pytest>=6.0; extra == "dev"
25
26
  Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
26
- Requires-Dist: black>=22.0; extra == "dev"
27
- Requires-Dist: flake8>=4.0; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
28
  Requires-Dist: mypy>=0.950; extra == "dev"
29
+ Requires-Dist: types-colorama; extra == "dev"
29
30
  Requires-Dist: build>=1.0; extra == "dev"
31
+ Requires-Dist: colorama>=0.4.6; extra == "dev"
30
32
  Provides-Extra: mqtt
31
33
  Requires-Dist: aiomqtt>=2.1.0; extra == "mqtt"
32
34
  Requires-Dist: PyYAML>=6.0; extra == "mqtt"
35
+ Requires-Dist: colorama>=0.4.6; extra == "mqtt"
33
36
  Dynamic: license-file
34
37
 
35
38
  # zencontrol-python
@@ -49,10 +52,12 @@ A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwri
49
52
  Beyond basic lighting control, this library supports:
50
53
 
51
54
  * **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
55
+ * **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
56
  * **UDP transport resilience** — request retries and queue-failure backoff
57
+ * **Event keepalive** — periodic emit-state ping; re-enables TPI events (and unicast target) if a controller reboots while the listener stays up
54
58
  * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
55
59
  * **Button events** — discovery of control-device button instances, plus press and long-press event callbacks
60
+ * **Absolute inputs** — discovery of numerical ECD instances (dials/sliders) with 16-bit value-change event callbacks
56
61
  * **Event filtering** — configure which TPI events the controller emits
57
62
  * **System variables** — labelled SV discovery, read/write, and change events
58
63
  * **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
@@ -9,7 +9,7 @@ if str(ROOT) not in sys.path:
9
9
 
10
10
  import yaml
11
11
 
12
- from zencontrol.api.models import ZenAddress, ZenController
12
+ from zencontrol import ZenAddress, ZenController
13
13
  from zencontrol.api.protocol import ZenProtocol
14
14
  from zencontrol.api.types import ZenAddressType, ZenEventCode
15
15
  from zencontrol.utils import run_with_keyboard_interrupt
@@ -10,12 +10,18 @@ import zencontrol
10
10
  from zencontrol import ZenController, ZenProtocol, ZenClient, ZenColour, ZenColourType, ZenProfile, ZenLight, ZenGroup, ZenButton, ZenMotionSensor, ZenSystemVariable, ZenTimeoutError, ZenAddressType
11
11
  from zencontrol.api.types import Const as ApiConst
12
12
  import aiomqtt
13
- from colorama import Fore, Back, Style
14
13
  import logging
15
14
  from logging.handlers import RotatingFileHandler
16
15
  import math
17
16
  import pickle
18
17
  import traceback
18
+ try:
19
+ from colorama import Fore, Style
20
+ except ImportError:
21
+ class _NoColor:
22
+ def __getattr__(self, _name: str) -> str:
23
+ return ""
24
+ Fore = Style = _NoColor() # type: ignore[assignment]
19
25
 
20
26
  class RateLimiter:
21
27
  """Rate limiter to control concurrent coroutine execution"""
@@ -24,22 +24,25 @@ classifiers = [
24
24
  "Topic :: Software Development :: Libraries :: Python Modules",
25
25
  "Typing :: Typed",
26
26
  ]
27
- dependencies = [
28
- "colorama>=0.4.6",
29
- ]
27
+ dependencies = []
30
28
 
31
29
  [project.optional-dependencies]
30
+ colorama = [
31
+ "colorama>=0.4.6",
32
+ ]
32
33
  dev = [
33
34
  "pytest>=6.0",
34
35
  "pytest-asyncio>=0.18.0",
35
- "black>=22.0",
36
- "flake8>=4.0",
36
+ "ruff",
37
37
  "mypy>=0.950",
38
+ "types-colorama",
38
39
  "build>=1.0",
40
+ "colorama>=0.4.6",
39
41
  ]
40
42
  mqtt = [
41
43
  "aiomqtt>=2.1.0",
42
44
  "PyYAML>=6.0",
45
+ "colorama>=0.4.6",
43
46
  ]
44
47
  # Simulator integration tests (tests/test_sim_*.py) need zencontrol-simulator
45
48
  # (pip install -e ../zencontrol-simulator, or a sibling ../zencontrol-simulator
@@ -63,24 +66,6 @@ include = ["zencontrol*", "examples*"]
63
66
  [tool.setuptools.package-data]
64
67
  zencontrol = ["py.typed"]
65
68
 
66
- [tool.black]
67
- line-length = 88
68
- target-version = ['py314']
69
- include = '\.pyi?$'
70
- extend-exclude = '''
71
- /(
72
- # directories
73
- \.eggs
74
- | \.git
75
- | \.hg
76
- | \.mypy_cache
77
- | \.tox
78
- | \.venv
79
- | build
80
- | dist
81
- )/
82
- '''
83
-
84
69
  [tool.mypy]
85
70
  python_version = "3.14"
86
71
  warn_return_any = true
@@ -109,7 +94,13 @@ markers = [
109
94
 
110
95
  [tool.ruff]
111
96
  target-version = "py314"
112
- line-length = 88
97
+ line-length = 130
98
+ exclude = ["examples"]
113
99
 
114
100
  [tool.ruff.lint]
115
101
  select = ["ASYNC", "B", "E", "F", "I", "UP"]
102
+ ignore = [
103
+ "ASYNC109", # Timeout parameters are part of the public async API.
104
+ "E501", # Preserve existing protocol logging and payload formatting.
105
+ "E701", # Allow readable single-line conditionals.
106
+ ]
@@ -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
@@ -6,7 +6,8 @@ from unittest.mock import AsyncMock, patch
6
6
 
7
7
  import pytest
8
8
 
9
- from zencontrol.api.models import DiscoveredController, ZenController
9
+ from zencontrol import ZenController
10
+ from zencontrol.api.models import DiscoveredController
10
11
  from zencontrol.api.protocol import ZenProtocol
11
12
  from zencontrol.io.event import ZenEvent
12
13
 
@@ -76,7 +77,7 @@ async def test_second_packet_from_same_mac_or_ip_is_ignored() -> None:
76
77
  async def test_registered_controller_is_not_discovered() -> None:
77
78
  protocol = ZenProtocol()
78
79
  ctrl = ZenController(
79
- id="1",
80
+ id=1,
80
81
  name="known",
81
82
  label="Known",
82
83
  host="192.168.1.50",
@@ -105,7 +106,7 @@ async def test_registering_controller_forgets_identified() -> None:
105
106
  assert len(protocol.identified_controllers) == 1
106
107
 
107
108
  ctrl = ZenController(
108
- id="1",
109
+ id=1,
109
110
  name="kitchen",
110
111
  label="Kitchen",
111
112
  host="192.168.1.50",
@@ -136,7 +137,7 @@ async def test_label_query_failure_still_remembers_controller() -> None:
136
137
  async def test_new_controller_discovered_while_one_is_registered() -> None:
137
138
  protocol = ZenProtocol()
138
139
  known = ZenController(
139
- id="1",
140
+ id=1,
140
141
  name="known",
141
142
  label="Known",
142
143
  host="192.168.1.10",
@@ -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()
@@ -67,7 +67,7 @@ async def test_aclose_closes_clients_and_clears_instances() -> None:
67
67
  ctrl.client = fake_client
68
68
 
69
69
  address = ZenAddress(controller=ctrl, type=ZenAddressType.ECG, number=1)
70
- light = ZenLight(protocol=zen.protocol, address=address)
70
+ ZenLight(protocol=zen.protocol, address=address)
71
71
  assert "ctrl-a 1" in zen.protocol.entity_registry.lights
72
72
  assert "ctrl-a" in zen.protocol.entity_registry.controllers
73
73
 
@@ -3,11 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import pytest
6
+ from helpers import LEGACY_ACK, wait_until
6
7
 
7
8
  from zencontrol import ZenColour, ZenColourType
8
9
 
9
- from helpers import wait_until
10
-
11
10
  pytestmark = pytest.mark.simulator
12
11
 
13
12
 
@@ -26,7 +25,7 @@ async def test_scene_and_colour_events_via_protocol(live_sim):
26
25
  p.set_callbacks(scene_change_callback=on_scene, colour_change_callback=on_colour)
27
26
  await p.start_event_monitoring()
28
27
 
29
- assert await p.dali_scene(live_sim.ecg(0), 1) is True
28
+ assert await p.dali_scene(live_sim.ecg(0), 1) is LEGACY_ACK
30
29
  await wait_until(
31
30
  lambda: any(t == "ECG" and n == 0 and s == 1 for t, n, s in scenes),
32
31
  message="expected scene-change event for ECG 0 → scene 1",
@@ -61,7 +60,7 @@ async def test_group_level_event_via_protocol(live_sim):
61
60
  )
62
61
  await p.start_event_monitoring()
63
62
 
64
- assert await p.dali_arc_level(live_sim.group(0), 44) is True
63
+ assert await p.dali_arc_level(live_sim.group(0), 44) is LEGACY_ACK
65
64
  await wait_until(
66
65
  lambda: any(n == 0 and level == 44 for n, level in group_events),
67
66
  message="expected group level-change event for group 0 → 44",
@@ -83,7 +82,7 @@ async def test_member_events_on_group_scene(live_sim):
83
82
  p.set_callbacks(scene_change_callback=on_scene, level_change_callback=on_level)
84
83
  await p.start_event_monitoring()
85
84
 
86
- assert await p.dali_scene(live_sim.group(0), 1) is True
85
+ assert await p.dali_scene(live_sim.group(0), 1) is LEGACY_ACK
87
86
  await wait_until(
88
87
  lambda: (
89
88
  any(t == "GROUP" and n == 0 and s == 1 for t, n, s in scenes)
@@ -158,6 +157,28 @@ async def test_button_hold_and_occupancy_inject(live_sim):
158
157
  )
159
158
 
160
159
 
160
+ @pytest.mark.asyncio
161
+ async def test_absolute_input_inject(live_sim):
162
+ p = live_sim.protocol
163
+ events: list[tuple[int, int, bytes]] = []
164
+
165
+ async def on_absolute(*, instance, payload):
166
+ events.append((instance.address.number, instance.number, bytes(payload)))
167
+
168
+ p.set_callbacks(absolute_input_callback=on_absolute)
169
+ await p.start_event_monitoring()
170
+
171
+ live_sim.sim.inject_absolute_input(13, 0, 0xABCD)
172
+ await wait_until(
173
+ lambda: any(
174
+ ecd == 13 and inst == 0 and payload == bytes([0, 0xAB, 0xCD])
175
+ for ecd, inst, payload in events
176
+ ),
177
+ message="expected absolute-input inject event for ECD 13",
178
+ )
179
+ assert live_sim.world.instance(13, 0).value == 0xABCD
180
+
181
+
161
182
  @pytest.mark.asyncio
162
183
  async def test_inject_level_scene_colour_profile_events(live_sim):
163
184
  from zencontrol_simulator.world import Colour