zencontrol-python 0.1.6__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 (53) hide show
  1. {zencontrol_python-0.1.6/zencontrol_python.egg-info → zencontrol_python-0.1.7}/PKG-INFO +7 -4
  2. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/level_change_v2.py +1 -1
  3. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/mqtt_bridge.py +7 -1
  4. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/pyproject.toml +15 -24
  5. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_discovery.py +5 -4
  6. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_lifecycle.py +1 -1
  7. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_sim_events.py +4 -5
  8. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_sim_interface.py +103 -18
  9. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_sim_protocol.py +103 -25
  10. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_wire_resilience.py +17 -8
  11. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/__init__.py +34 -14
  12. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/api/__init__.py +15 -2
  13. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/api/models.py +27 -12
  14. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/api/protocol.py +70 -45
  15. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/api/types.py +2 -2
  16. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/exceptions.py +1 -0
  17. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/interface/interface.py +78 -71
  18. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/io/__init__.py +9 -2
  19. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/io/command.py +20 -16
  20. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/io/event.py +24 -22
  21. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/utils.py +2 -2
  22. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7/zencontrol_python.egg-info}/PKG-INFO +7 -4
  23. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/requires.txt +6 -2
  24. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/LICENSE +0 -0
  25. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/README.md +0 -0
  26. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/__init__.py +0 -0
  27. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/controller.py +0 -0
  28. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/devices.py +0 -0
  29. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/events.py +0 -0
  30. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/gear.py +0 -0
  31. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/groups.py +0 -0
  32. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/instances.py +0 -0
  33. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/interface.py +0 -0
  34. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/interface_events.py +0 -0
  35. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/leds.py +0 -0
  36. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/multicast.py +0 -0
  37. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/multicast_raw.py +0 -0
  38. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/profiles.py +0 -0
  39. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/tpi_event_listener.py +0 -0
  40. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/examples/live/variables.py +0 -0
  41. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/setup.cfg +0 -0
  42. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_absolute_input.py +0 -0
  43. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_entity_scope.py +0 -0
  44. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_event_keepalive.py +0 -0
  45. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_event_listener_disconnect.py +0 -0
  46. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_reconnect.py +0 -0
  47. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/tests/test_sub_labels.py +0 -0
  48. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/interface/__init__.py +4 -4
  49. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol/py.typed +0 -0
  50. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/SOURCES.txt +0 -0
  51. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/dependency_links.txt +0 -0
  52. {zencontrol_python-0.1.6 → zencontrol_python-0.1.7}/zencontrol_python.egg-info/entry_points.txt +0 -0
  53. {zencontrol_python-0.1.6 → 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.6
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
@@ -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
+ ]
@@ -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",
@@ -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)
@@ -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
 
@@ -65,11 +64,11 @@ async def test_light_set_and_query(live_zen):
65
64
  lights = {lt.address.number: lt for lt in await zen.get_lights()}
66
65
  light = lights[1]
67
66
 
68
- assert await light.set(level=50, fade=True) is True
67
+ assert await light.set(level=50, fade=True) is LEGACY_ACK
69
68
  assert await zen.protocol.dali_query_level(light.address) == 50
70
69
  assert live_sim.world.lights[1].level == 50
71
70
 
72
- assert await light.off(fade=False) is True
71
+ assert await light.off(fade=False) is LEGACY_ACK
73
72
  assert await zen.protocol.dali_query_level(light.address) == 0
74
73
 
75
74
 
@@ -99,7 +98,7 @@ async def test_group_scene_and_profile_switch(live_zen):
99
98
 
100
99
  groups = await zen.get_groups()
101
100
  group = next(g for g in groups if g.address.number == 0)
102
- assert await group.set_scene(1) is True
101
+ assert await group.set_scene(1) is LEGACY_ACK
103
102
  assert live_sim.world.groups[0].last_scene == 1
104
103
  assert live_sim.world.lights[0].level == 80
105
104
  assert live_sim.world.lights[1].level == 100
@@ -176,7 +175,7 @@ async def test_start_receives_injected_and_control_events(live_zen):
176
175
  await zen.get_profiles()
177
176
 
178
177
  lights = {lt.address.number: lt for lt in await zen.get_lights()}
179
- assert await lights[1].set(level=66) is True
178
+ assert await lights[1].set(level=66) is LEGACY_ACK
180
179
  await wait_until(
181
180
  lambda: any(n == 1 and level == 66 for n, level, _, _ in light_events),
182
181
  message="expected light_change for ECG 1 → 66",
@@ -190,7 +189,7 @@ async def test_start_receives_injected_and_control_events(live_zen):
190
189
  )
191
190
 
192
191
  groups = {g.address.number: g for g in await zen.get_groups()}
193
- assert await groups[0].set(level=55) is True
192
+ assert await groups[0].set(level=55) is LEGACY_ACK
194
193
  await wait_until(
195
194
  lambda: any(n == 0 and level == 55 for n, level, _, _ in group_events),
196
195
  message="expected group_change for group 0 → 55",
@@ -260,14 +259,14 @@ async def test_light_scene_and_on_off_via_interface(live_zen):
260
259
  lights = {lt.address.number: lt for lt in await zen.get_lights()}
261
260
  light = lights[0]
262
261
 
263
- assert await light.set_scene(1) is True
262
+ assert await light.set_scene(1) is LEGACY_ACK
264
263
  assert live_sim.world.lights[0].level == 80
265
264
  assert await zen.protocol.dali_query_last_scene(light.address) == 1
266
265
 
267
- assert await light.set(level=120) is True
268
- assert await light.off(fade=False) is True
266
+ assert await light.set(level=120) is LEGACY_ACK
267
+ assert await light.off(fade=False) is LEGACY_ACK
269
268
  assert live_sim.world.lights[0].level == 0
270
- assert await light.on(fade=False) is True
269
+ assert await light.on(fade=False) is LEGACY_ACK
271
270
  assert live_sim.world.lights[0].level == 120
272
271
 
273
272
 
@@ -280,11 +279,11 @@ async def test_group_scene_by_label_and_level(live_zen):
280
279
  group = groups[0]
281
280
 
282
281
  assert group.get_scene_number_from_label("Relax") == 1
283
- assert await group.set_scene("Relax") is True
282
+ assert await group.set_scene("Relax") is LEGACY_ACK
284
283
  assert live_sim.world.groups[0].last_scene == 1
285
284
  assert live_sim.world.lights[0].level == 80
286
285
 
287
- assert await group.set(level=40) is True
286
+ assert await group.set(level=40) is LEGACY_ACK
288
287
  assert live_sim.world.lights[0].level == 40
289
288
  assert live_sim.world.lights[1].level == 40
290
289
 
@@ -313,15 +312,15 @@ async def test_light_steps_recall_and_inhibit_via_interface(live_zen):
313
312
  live_sim.world.lights[1].max_level = 200
314
313
  live_sim.world.lights[1].min_level = 5
315
314
 
316
- assert await light.set(level=10) is True
317
- assert await light.dali_up() is True
315
+ assert await light.set(level=10) is LEGACY_ACK
316
+ assert await light.dali_up() is LEGACY_ACK
318
317
  assert live_sim.world.lights[1].level == 11
319
- assert await light.dali_down() is True
318
+ assert await light.dali_down() is LEGACY_ACK
320
319
  assert live_sim.world.lights[1].level == 10
321
320
 
322
- assert await light.dali_recall_max() is True
321
+ assert await light.dali_recall_max() is LEGACY_ACK
323
322
  assert live_sim.world.lights[1].level == 200
324
- assert await light.dali_recall_min() is True
323
+ assert await light.dali_recall_min() is LEGACY_ACK
325
324
  assert live_sim.world.lights[1].level == 5
326
325
 
327
326
  assert await light.dali_inhibit(True) is True
@@ -350,3 +349,89 @@ async def test_button_and_sensor_interview_fields(live_zen):
350
349
  # Entrance 6-button pad should contribute multiple button instances
351
350
  entrance = [b for b in buttons if b.label == "Entrance 6-Button"]
352
351
  assert len(entrance) == 6
352
+
353
+
354
+ @pytest.mark.asyncio
355
+ async def test_light_fade_step_and_refresh_via_interface(live_zen):
356
+ zen, live_sim = live_zen
357
+ ctrl = zen.controllers[0]
358
+ await ctrl.interview()
359
+ lights = {lt.address.number: lt for lt in await zen.get_lights()}
360
+ light = lights[1]
361
+
362
+ assert await light.set(level=0, fade=False) is True
363
+ assert await light.dali_on_step_up() is LEGACY_ACK
364
+ assert live_sim.world.lights[1].level >= 1
365
+
366
+ assert await light.set(level=50, fade=False) is True
367
+ assert await light.dali_custom_fade(100, 5) is True
368
+ assert live_sim.world.lights[1].status & 0x10
369
+ assert await light.dali_stop_fade() is True
370
+ assert not (live_sim.world.lights[1].status & 0x10)
371
+
372
+ live_sim.world.lights[1].last_active_level = 88
373
+ assert await light.dali_go_to_last_active_level() is LEGACY_ACK
374
+ assert live_sim.world.lights[1].level == 88
375
+
376
+ assert await light.set(level=40, fade=False) is True
377
+ assert await light.dali_step_down_off() is LEGACY_ACK
378
+ assert live_sim.world.lights[1].level == 39
379
+
380
+ assert await light.dali_enable_dapc_sequence() is None
381
+ assert await light.dali_off() is LEGACY_ACK
382
+ assert live_sim.world.lights[1].level == 0
383
+
384
+ # Mutate controller under the entity, then refresh entity state.
385
+ assert await zen.protocol.dali_arc_level(light.address, 123) is LEGACY_ACK
386
+ light.level = None
387
+ await light.refresh_state_from_controller()
388
+ assert light.level == 123
389
+
390
+
391
+ @pytest.mark.asyncio
392
+ async def test_sysvar_get_value_and_refresh(live_zen):
393
+ zen, live_sim = live_zen
394
+ ctrl = zen.controllers[0]
395
+ await ctrl.interview()
396
+ assert await ctrl.is_dali_ready() is True
397
+
398
+ sysvars = await zen.get_system_variables(give_up_after=5)
399
+ svar = next(v for v in sysvars if v.id == 0)
400
+ svar._value = None
401
+ live_sim.world.system_variables[0].value = 55
402
+ assert await svar.get_value() == 55
403
+
404
+ live_sim.world.system_variables[0].value = 66
405
+ await svar.refresh_state_from_controller()
406
+ assert svar.value == 66
407
+
408
+
409
+ @pytest.mark.asyncio
410
+ async def test_motion_refresh_and_group_discoordination(live_zen):
411
+ zen, live_sim = live_zen
412
+ ctrl = zen.controllers[0]
413
+ await ctrl.interview()
414
+
415
+ sensors = await zen.get_motion_sensors()
416
+ porch = next(s for s in sensors if s.label == "Porch Sensor")
417
+ world_inst = live_sim.world.instance(10, 0)
418
+ assert world_inst is not None and world_inst.timers is not None
419
+ world_inst.timers.last_motion_at = __import__("time").time() - 5
420
+ assert await porch.refresh_state_from_controller() is True
421
+ assert porch.last_detect is not None
422
+ assert porch.hold_time == 60
423
+
424
+ groups = {g.address.number: g for g in await zen.get_groups()}
425
+ group = groups[0]
426
+ group.level = 40
427
+ group.scene = 1
428
+ disco: list = []
429
+
430
+ async def on_group(*, group, discoordinated=False, **kwargs):
431
+ if discoordinated:
432
+ disco.append(group.address.number)
433
+
434
+ zen.group_change = on_group
435
+ await group.declare_discoordination()
436
+ assert group.level is None and group.scene is None
437
+ assert 0 in disco
@@ -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, ZenEventMask, ZenEventMode
8
9
 
9
- from helpers import wait_until
10
-
11
10
  pytestmark = pytest.mark.simulator
12
11
 
13
12
 
@@ -73,13 +72,13 @@ async def test_arc_level_off_and_on(live_sim):
73
72
  p = live_sim.protocol
74
73
  addr = live_sim.ecg(1)
75
74
 
76
- assert await p.dali_arc_level(addr, 77) is True
75
+ assert await p.dali_arc_level(addr, 77) is LEGACY_ACK
77
76
  assert await p.dali_query_level(addr) == 77
78
77
  assert live_sim.world.lights[1].level == 77
79
78
 
80
- assert await p.dali_off(addr) is True
79
+ assert await p.dali_off(addr) is LEGACY_ACK
81
80
  assert await p.dali_query_level(addr) == 0
82
- assert await p.dali_on_step_up(addr) is True
81
+ assert await p.dali_on_step_up(addr) is LEGACY_ACK
83
82
  assert await p.dali_query_level(addr) == live_sim.world.lights[1].min_level
84
83
 
85
84
 
@@ -88,7 +87,7 @@ async def test_group_arc_and_mixed_level_query(live_sim):
88
87
  p = live_sim.protocol
89
88
  g0 = live_sim.group(0)
90
89
 
91
- assert await p.dali_arc_level(g0, 40) is True
90
+ assert await p.dali_arc_level(g0, 40) is LEGACY_ACK
92
91
  assert await p.dali_query_level(g0) == 40
93
92
  assert live_sim.world.lights[0].level == 40
94
93
  assert live_sim.world.lights[1].level == 40
@@ -103,7 +102,7 @@ async def test_scene_recall_and_queries(live_sim):
103
102
  p = live_sim.protocol
104
103
  addr = live_sim.ecg(0)
105
104
 
106
- assert await p.dali_scene(addr, 1) is True
105
+ assert await p.dali_scene(addr, 1) is LEGACY_ACK
107
106
  assert await p.dali_query_last_scene(addr) == 1
108
107
  assert await p.dali_query_last_scene_is_current(addr) is True
109
108
  assert await p.dali_query_level(addr) == 80
@@ -123,7 +122,7 @@ async def test_group_scene_recall(live_sim):
123
122
  assert 0 in scenes and 1 in scenes
124
123
  assert await p.query_scene_label_for_group(g0, 1) == "Relax"
125
124
 
126
- assert await p.dali_scene(g0, 1) is True
125
+ assert await p.dali_scene(g0, 1) is LEGACY_ACK
127
126
  assert live_sim.world.groups[0].last_scene == 1
128
127
  assert live_sim.world.lights[0].level == 80
129
128
  assert live_sim.world.lights[1].level == 100
@@ -197,7 +196,7 @@ async def test_level_change_event_via_protocol(live_sim):
197
196
 
198
197
  p.set_callbacks(level_change_callback=on_level)
199
198
  await p.start_event_monitoring()
200
- assert await p.dali_arc_level(live_sim.ecg(1), 55) is True
199
+ assert await p.dali_arc_level(live_sim.ecg(1), 55) is LEGACY_ACK
201
200
  await wait_until(
202
201
  lambda: any(n == 1 and level == 55 for n, level in events),
203
202
  message="expected level-change event for ECG 1 → 55",
@@ -288,19 +287,19 @@ async def test_step_up_down_and_step_down_off(live_sim):
288
287
  addr = live_sim.ecg(1)
289
288
 
290
289
  await p.dali_arc_level(addr, 10)
291
- assert await p.dali_up(addr) is True
290
+ assert await p.dali_up(addr) is LEGACY_ACK
292
291
  assert await p.dali_query_level(addr) == 11
293
- assert await p.dali_down(addr) is True
292
+ assert await p.dali_down(addr) is LEGACY_ACK
294
293
  assert await p.dali_query_level(addr) == 10
295
294
 
296
295
  await p.dali_arc_level(addr, 1)
297
- assert await p.dali_down(addr) is True # stay at min
296
+ assert await p.dali_down(addr) is LEGACY_ACK # stay at min
298
297
  assert await p.dali_query_level(addr) == 1
299
- assert await p.dali_step_down_off(addr) is True
298
+ assert await p.dali_step_down_off(addr) is LEGACY_ACK
300
299
  assert await p.dali_query_level(addr) == 0
301
300
 
302
301
  # UP must not ignite from off
303
- assert await p.dali_up(addr) is True
302
+ assert await p.dali_up(addr) is LEGACY_ACK
304
303
  assert await p.dali_query_level(addr) == 0
305
304
 
306
305
 
@@ -311,14 +310,14 @@ async def test_recall_max_min_and_last_active(live_sim):
311
310
  live_sim.world.lights[1].max_level = 200
312
311
  live_sim.world.lights[1].min_level = 5
313
312
 
314
- assert await p.dali_recall_max(addr) is True
313
+ assert await p.dali_recall_max(addr) is LEGACY_ACK
315
314
  assert await p.dali_query_level(addr) == 200
316
- assert await p.dali_recall_min(addr) is True
315
+ assert await p.dali_recall_min(addr) is LEGACY_ACK
317
316
  assert await p.dali_query_level(addr) == 5
318
317
 
319
318
  await p.dali_arc_level(addr, 88)
320
319
  await p.dali_off(addr)
321
- assert await p.dali_go_to_last_active_level(addr) is True
320
+ assert await p.dali_go_to_last_active_level(addr) is LEGACY_ACK
322
321
  assert await p.dali_query_level(addr) == 88
323
322
 
324
323
 
@@ -425,7 +424,7 @@ async def test_colour_scenes_include_8_11(live_sim):
425
424
  assert levels[8] == 160
426
425
  assert levels[9] == 40
427
426
 
428
- assert await p.dali_scene(addr, 8) is True
427
+ assert await p.dali_scene(addr, 8) is LEGACY_ACK
429
428
  assert await p.dali_query_level(addr) == 160
430
429
  queried = await p.query_dali_colour(addr)
431
430
  assert queried is not None and queried.kelvin == 4500
@@ -448,13 +447,13 @@ async def test_broadcast_arc_off_scene_and_colour(live_sim):
448
447
  p = live_sim.protocol
449
448
  bcast = live_sim.broadcast()
450
449
 
451
- assert await p.dali_arc_level(bcast, 33) is True
450
+ assert await p.dali_arc_level(bcast, 33) is LEGACY_ACK
452
451
  assert all(lt.level == 33 for lt in live_sim.world.lights.values())
453
452
 
454
- assert await p.dali_off(bcast) is True
453
+ assert await p.dali_off(bcast) is LEGACY_ACK
455
454
  assert all(lt.level == 0 for lt in live_sim.world.lights.values())
456
455
 
457
- assert await p.dali_scene(bcast, 0) is True
456
+ assert await p.dali_scene(bcast, 0) is LEGACY_ACK
458
457
  assert live_sim.world.lights[0].level == 180
459
458
  assert live_sim.world.groups[0].last_scene == 0
460
459
 
@@ -469,7 +468,7 @@ async def test_broadcast_arc_off_scene_and_colour(live_sim):
469
468
  async def test_group_last_scene_and_status(live_sim):
470
469
  p = live_sim.protocol
471
470
  g0 = live_sim.group(0)
472
- assert await p.dali_scene(g0, 1) is True
471
+ assert await p.dali_scene(g0, 1) is LEGACY_ACK
473
472
  assert await p.dali_query_last_scene(g0) == 1
474
473
  assert await p.dali_query_last_scene_is_current(g0) is True
475
474
 
@@ -540,9 +539,7 @@ async def test_readiness_flags_and_unknown_sysvars(live_sim):
540
539
  live_sim.world.startup_complete = False
541
540
  assert await p.query_controller_startup_complete(c) is not True
542
541
  live_sim.world.startup_complete = True
543
- live_sim.world.dali_ready = False
544
- assert await p.query_is_dali_ready(c) is not True
545
- live_sim.world.dali_ready = True
542
+ live_sim.world.dali_ready = False # simulator ignores — DALI always ready
546
543
  assert await p.query_is_dali_ready(c) is True
547
544
 
548
545
  assert await p.query_system_variable_name(c, 99) is None
@@ -557,3 +554,84 @@ async def test_return_to_scheduled_profile(live_sim):
557
554
  assert await p.change_profile_number(c, 3) is True
558
555
  assert await p.return_to_scheduled_profile(c) is True
559
556
  assert await p.query_current_profile_number(c) == 1
557
+
558
+
559
+ @pytest.mark.asyncio
560
+ async def test_dali_query_fade_running(live_sim):
561
+ p = live_sim.protocol
562
+ addr = live_sim.ecg(1)
563
+ await p.dali_arc_level(addr, 0)
564
+ assert await p.dali_query_fade_running(addr) is False
565
+ assert await p.dali_custom_fade(addr, 100, 5) is True
566
+ p.cache.clear()
567
+ assert await p.dali_query_fade_running(addr) is True
568
+ assert await p.dali_stop_fade(addr) is True
569
+ p.cache.clear()
570
+ assert await p.dali_query_fade_running(addr) is False
571
+
572
+
573
+ @pytest.mark.asyncio
574
+ async def test_operating_mode_and_button_led_stubs(live_sim):
575
+ p = live_sim.protocol
576
+ assert await p.query_operating_mode_by_address(live_sim.ecg(0)) == 0
577
+ assert await p.query_operating_mode_by_address(live_sim.ecd(0)) == 0
578
+
579
+ button = live_sim.instance(0, 0)
580
+ assert await p.override_dali_button_led_state(button, True) is True
581
+ # Sim stub always reports last-known LED off.
582
+ assert await p.query_last_known_dali_button_led_state(button) is False
583
+
584
+
585
+ @pytest.mark.asyncio
586
+ async def test_query_instance_groups(live_sim):
587
+ p = live_sim.protocol
588
+ groups = await p.query_instance_groups(live_sim.instance(0, 0))
589
+ assert groups == (0, 1, None)
590
+ motion = await p.query_instance_groups(live_sim.instance(0, 2, type_code=3))
591
+ assert motion == (0, None, None)
592
+ # Unconfigured instance → all None
593
+ unset = await p.query_instance_groups(live_sim.instance(1, 0))
594
+ assert unset == (None, None, None)
595
+
596
+
597
+ @pytest.mark.asyncio
598
+ async def test_query_profile_information(live_sim):
599
+ """PDF: header + records; bit0=disabled, bits1–2=priority (0 scheduled, 1+, …)."""
600
+ p, c = live_sim.protocol, live_sim.controller
601
+ result = await p.query_profile_information(c)
602
+ assert result is not None
603
+ state, profiles = result
604
+ assert state["current_active_profile"] == 1
605
+ assert state["last_scheduled_profile"] == 1
606
+ assert state["last_overridden_profile_utc"].timestamp() == 0x22334455
607
+ assert state["last_scheduled_profile_utc"].timestamp() == 0x44556677
608
+ assert set(profiles) == {1, 2, 3}
609
+ # Assert protocol fields (enabled/priority); labels are library niceties.
610
+ assert profiles[1]["enabled"] is True and profiles[1]["priority"] == 0
611
+ assert profiles[2]["enabled"] is True and profiles[2]["priority"] == 1
612
+ assert profiles[3]["enabled"] is False and profiles[3]["priority"] == 0
613
+
614
+
615
+ @pytest.mark.asyncio
616
+ async def test_colour_only_preserves_level(live_sim):
617
+ p = live_sim.protocol
618
+ addr = live_sim.ecg(0)
619
+ assert await p.dali_arc_level(addr, 77) is LEGACY_ACK
620
+ colour = ZenColour(type=ZenColourType.TC, kelvin=4200)
621
+ assert await p.dali_colour(addr, colour, level=255) is True
622
+ assert await p.dali_query_level(addr) == 77
623
+ assert live_sim.world.lights[0].level == 77
624
+ queried = await p.query_dali_colour(addr)
625
+ assert queried is not None and queried.kelvin == 4200
626
+
627
+
628
+ @pytest.mark.asyncio
629
+ async def test_clear_tpi_event_unicast_address(live_sim):
630
+ """Sim extension: omit IP/port (zeros) clears unicast targeting."""
631
+ p, c = live_sim.protocol, live_sim.controller
632
+ await p.set_tpi_event_unicast_address(c, ipaddr="127.0.0.1", port=6970)
633
+ await p.set_tpi_event_unicast_address(c)
634
+ info = await p.query_tpi_event_unicast_address(c)
635
+ assert info is not None
636
+ assert info["port"] == 0
637
+ assert info["ip"] == "0.0.0.0"