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.
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/PKG-INFO +10 -5
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/README.md +3 -1
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/level_change_v2.py +1 -1
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/mqtt_bridge.py +7 -1
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/pyproject.toml +15 -24
- zencontrol_python-0.1.7/tests/test_absolute_input.py +96 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_discovery.py +5 -4
- zencontrol_python-0.1.7/tests/test_event_keepalive.py +174 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_lifecycle.py +1 -1
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_sim_events.py +26 -5
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_sim_interface.py +127 -31
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_sim_protocol.py +138 -25
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_wire_resilience.py +17 -8
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/__init__.py +35 -13
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/api/__init__.py +15 -2
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/api/models.py +27 -12
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/api/protocol.py +77 -48
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/api/types.py +6 -2
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/exceptions.py +1 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/interface/__init__.py +5 -3
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/interface/interface.py +379 -90
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/io/__init__.py +9 -2
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/io/command.py +20 -16
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/io/event.py +24 -22
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/utils.py +2 -2
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/PKG-INFO +10 -5
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/SOURCES.txt +2 -1
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/requires.txt +6 -2
- zencontrol_python-0.1.4/examples/dump_simulator_config.py +0 -474
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/LICENSE +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/__init__.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/controller.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/devices.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/events.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/gear.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/groups.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/instances.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/interface.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/interface_events.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/leds.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/multicast.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/multicast_raw.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/profiles.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/tpi_event_listener.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/examples/live/variables.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/setup.cfg +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_entity_scope.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_event_listener_disconnect.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_reconnect.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/tests/test_sub_labels.py +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol/py.typed +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/dependency_links.txt +0 -0
- {zencontrol_python-0.1.4 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/entry_points.txt +0 -0
- {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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
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 =
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|