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.
- {zencontrol_python-0.1.3/zencontrol_python.egg-info → zencontrol_python-0.1.6}/PKG-INFO +27 -11
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/README.md +26 -10
- zencontrol_python-0.1.6/tests/test_absolute_input.py +96 -0
- zencontrol_python-0.1.6/tests/test_event_keepalive.py +174 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_lifecycle.py +28 -1
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_sim_events.py +22 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_sim_interface.py +24 -13
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_sim_protocol.py +35 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/__init__.py +3 -1
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/api/protocol.py +26 -3
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/api/types.py +4 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/interface/__init__.py +2 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/interface/interface.py +372 -36
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6/zencontrol_python.egg-info}/PKG-INFO +27 -11
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/SOURCES.txt +2 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/LICENSE +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/__init__.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/controller.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/devices.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/events.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/gear.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/groups.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/instances.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/interface.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/interface_events.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/leds.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/level_change_v2.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/multicast.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/multicast_raw.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/profiles.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/tpi_event_listener.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/live/variables.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/examples/mqtt_bridge.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/pyproject.toml +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/setup.cfg +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_discovery.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_entity_scope.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_event_listener_disconnect.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_reconnect.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_sub_labels.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/tests/test_wire_resilience.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/api/__init__.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/api/models.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/exceptions.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/io/__init__.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/io/command.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/io/event.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/py.typed +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol/utils.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/dependency_links.txt +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/entry_points.txt +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.6}/zencontrol_python.egg-info/requires.txt +0 -0
- {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
|
+
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("
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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
|
|