zencontrol-python 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. zencontrol_python-0.1.2/LICENSE +21 -0
  2. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/PKG-INFO +31 -14
  3. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/README.md +29 -12
  4. zencontrol_python-0.1.2/examples/dump_simulator_config.py +489 -0
  5. zencontrol_python-0.1.0/tests/test_controller.py → zencontrol_python-0.1.2/examples/live/controller.py +2 -1
  6. zencontrol_python-0.1.0/tests/test_devices.py → zencontrol_python-0.1.2/examples/live/devices.py +2 -1
  7. zencontrol_python-0.1.0/tests/test_events.py → zencontrol_python-0.1.2/examples/live/events.py +1 -1
  8. zencontrol_python-0.1.0/tests/test_gear.py → zencontrol_python-0.1.2/examples/live/gear.py +2 -1
  9. zencontrol_python-0.1.0/tests/test_groups.py → zencontrol_python-0.1.2/examples/live/groups.py +2 -1
  10. zencontrol_python-0.1.0/tests/test_instances.py → zencontrol_python-0.1.2/examples/live/instances.py +2 -1
  11. zencontrol_python-0.1.0/tests/test_interface.py → zencontrol_python-0.1.2/examples/live/interface.py +2 -1
  12. zencontrol_python-0.1.0/tests/test_interface_events.py → zencontrol_python-0.1.2/examples/live/interface_events.py +2 -1
  13. zencontrol_python-0.1.0/tests/test_leds.py → zencontrol_python-0.1.2/examples/live/leds.py +2 -1
  14. zencontrol_python-0.1.0/tests/test_level_change_v2.py → zencontrol_python-0.1.2/examples/live/level_change_v2.py +1 -1
  15. zencontrol_python-0.1.0/tests/test_multicast.py → zencontrol_python-0.1.2/examples/live/multicast.py +2 -1
  16. zencontrol_python-0.1.0/tests/test_profiles.py → zencontrol_python-0.1.2/examples/live/profiles.py +2 -1
  17. zencontrol_python-0.1.0/tests/test_variables.py → zencontrol_python-0.1.2/examples/live/variables.py +2 -1
  18. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/examples/mqtt_bridge.py +0 -30
  19. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/pyproject.toml +7 -1
  20. zencontrol_python-0.1.2/tests/test_discovery.py +156 -0
  21. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_entity_scope.py +7 -0
  22. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_reconnect.py +42 -0
  23. zencontrol_python-0.1.2/tests/test_sim_events.py +206 -0
  24. zencontrol_python-0.1.2/tests/test_sim_interface.py +341 -0
  25. zencontrol_python-0.1.2/tests/test_sim_protocol.py +524 -0
  26. zencontrol_python-0.1.2/tests/test_wire_resilience.py +356 -0
  27. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/__init__.py +6 -3
  28. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/api/__init__.py +2 -1
  29. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/api/models.py +22 -5
  30. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/api/protocol.py +220 -26
  31. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/api/types.py +5 -3
  32. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/exceptions.py +14 -2
  33. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/interface/interface.py +60 -5
  34. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/io/command.py +59 -35
  35. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/io/event.py +52 -17
  36. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/PKG-INFO +31 -14
  37. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/SOURCES.txt +20 -15
  38. zencontrol_python-0.1.0/LICENSE +0 -504
  39. zencontrol_python-0.1.0/tests/test_wire_resilience.py +0 -178
  40. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/examples/__init__.py +0 -0
  41. /zencontrol_python-0.1.0/tests/test_multicast_raw.py → /zencontrol_python-0.1.2/examples/live/multicast_raw.py +0 -0
  42. /zencontrol_python-0.1.0/tests/test_tpi_event_listener.py → /zencontrol_python-0.1.2/examples/live/tpi_event_listener.py +0 -0
  43. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/setup.cfg +0 -0
  44. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_event_listener_disconnect.py +0 -0
  45. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_lifecycle.py +0 -0
  46. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_sub_labels.py +0 -0
  47. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/interface/__init__.py +0 -0
  48. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/io/__init__.py +0 -0
  49. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/py.typed +0 -0
  50. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/utils.py +0 -0
  51. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/dependency_links.txt +0 -0
  52. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/entry_points.txt +0 -0
  53. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/requires.txt +0 -0
  54. {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/top_level.txt +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simon Wright
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zencontrol-python
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python implementation of the Zencontrol TPI Advanced protocol for DALI lighting controllers.
5
5
  Author: Simon Wright
6
- License-Expression: LGPL-2.1-only
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/sjwright/zencontrol-python
8
8
  Project-URL: Repository, https://github.com/sjwright/zencontrol-python
9
9
  Project-URL: Issues, https://github.com/sjwright/zencontrol-python/issues
@@ -40,33 +40,46 @@ This is an implementation of the **Zencontrol TPI Advanced** protocol, written i
40
40
 
41
41
  - zencontrol.io: Implementation of the raw TPI Advanced UDP packet specification;
42
42
  - zencontrol.api: Implementation of most TPI Advanced API commands and events;
43
- - zencontrol.interface: An opinionated abstraction layer suitable for integration into smart building control software. It provides methods, objects, and callbacks for managing lights, groups, profiles, buttons, motion sensors, and system variables. This code is still undergoing significant refinement.
43
+ - zencontrol.interface: An opinionated abstraction layer suitable for integration into smart building control software. It provides methods, objects, and callbacks for managing lights, groups, profiles, buttons, motion sensors, and system variables.
44
44
 
45
- Built on top of this is an example MQTT bridge for Home Assistant. See [examples/mqtt_bridge.md](examples/mqtt_bridge.md).
45
+ This library has now undergone validation in multiple environments. There is an extensive test suite, some of which uses
46
+ [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
47
+ A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
46
48
 
47
49
  ## Requirements
48
50
 
49
51
  * Python 3.11 (or later)
50
- * Controller firmware 2.2.11 (or later)
52
+ * Controller firmware 2.2.130 or later is strongly recommended (minimum 2.2.11 required)
51
53
 
52
54
  ## Install
53
55
 
54
- Refer to zencontrol-tpi project for now.
55
- For integrators, the library is also published on PyPI.
56
+ This library is available on PyPI.
57
+
58
+ ## Testing
59
+
60
+ Integration tests start [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator) on an ephemeral local port and exercise a real UDP TPI protocol path. Either install the simulator, or check it out as a sibling directory (`../zencontrol-simulator`); tests will pick it up automatically. Note that PyYAML is a simulator dependency.
61
+
62
+ ```bash
63
+ pip install -e ".[dev]"
64
+ pip install PyYAML
65
+ # optional if not using a sibling checkout:
66
+ # pip install -e ../zencontrol-simulator
67
+ pytest -m simulator
68
+ pytest -m "not simulator"
69
+ # or run everything:
70
+ pytest
71
+ ```
56
72
 
57
73
  ## Limitations
58
74
 
59
- Implemented but untested:
60
-
61
- * Dealing with multiple controllers (I only have one controller)
62
- * RGB+ and XY colour commands (I don't have any compatible lights)
63
- * Numerical (absolute) instances (I don't have any such ECDs)
64
- * Event filtering (I haven't tested it)
75
+ * RGB+ and XY colour commands are not tested (I don't have any compatible lights)
76
+ * Numerical (absolute) instances are not tested (I don't have any such ECDs)
65
77
 
66
- Not implemented:
78
+ ## Out of scope
67
79
 
68
80
  * 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)
69
81
  * Any commands described in the documentation as "legacy" (they aren't useful)
82
+
70
83
  ## TPI Advanced wishlist
71
84
 
72
85
  * 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.)_
@@ -74,6 +87,10 @@ Not implemented:
74
87
  * Command to read an ambient light sensor's lux value. _(As a workaround, you can target a light sensor to a system variable. Not elegant but it works.)_
75
88
  * Event notification for ambient light sensor lux values. _(Same workaround as above.)_
76
89
 
90
+ ## License
91
+
92
+ [MIT](LICENSE)
93
+
77
94
  ## Links
78
95
 
79
96
  * [About TPI Advanced](https://support.zencontrol.com/hc/en-us/articles/360000337175-What-is-the-Third-Party-Interface-TPI)
@@ -4,33 +4,46 @@ This is an implementation of the **Zencontrol TPI Advanced** protocol, written i
4
4
 
5
5
  - zencontrol.io: Implementation of the raw TPI Advanced UDP packet specification;
6
6
  - zencontrol.api: Implementation of most TPI Advanced API commands and events;
7
- - zencontrol.interface: An opinionated abstraction layer suitable for integration into smart building control software. It provides methods, objects, and callbacks for managing lights, groups, profiles, buttons, motion sensors, and system variables. This code is still undergoing significant refinement.
7
+ - zencontrol.interface: An opinionated abstraction layer suitable for integration into smart building control software. It provides methods, objects, and callbacks for managing lights, groups, profiles, buttons, motion sensors, and system variables.
8
8
 
9
- Built on top of this is an example MQTT bridge for Home Assistant. See [examples/mqtt_bridge.md](examples/mqtt_bridge.md).
9
+ This library has now undergone validation in multiple environments. There is an extensive test suite, some of which uses
10
+ [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
11
+ A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
10
12
 
11
13
  ## Requirements
12
14
 
13
15
  * Python 3.11 (or later)
14
- * Controller firmware 2.2.11 (or later)
16
+ * Controller firmware 2.2.130 or later is strongly recommended (minimum 2.2.11 required)
15
17
 
16
18
  ## Install
17
19
 
18
- Refer to zencontrol-tpi project for now.
19
- For integrators, the library is also published on PyPI.
20
+ This library is available on PyPI.
21
+
22
+ ## Testing
23
+
24
+ Integration tests start [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator) on an ephemeral local port and exercise a real UDP TPI protocol path. Either install the simulator, or check it out as a sibling directory (`../zencontrol-simulator`); tests will pick it up automatically. Note that PyYAML is a simulator dependency.
25
+
26
+ ```bash
27
+ pip install -e ".[dev]"
28
+ pip install PyYAML
29
+ # optional if not using a sibling checkout:
30
+ # pip install -e ../zencontrol-simulator
31
+ pytest -m simulator
32
+ pytest -m "not simulator"
33
+ # or run everything:
34
+ pytest
35
+ ```
20
36
 
21
37
  ## Limitations
22
38
 
23
- Implemented but untested:
24
-
25
- * Dealing with multiple controllers (I only have one controller)
26
- * RGB+ and XY colour commands (I don't have any compatible lights)
27
- * Numerical (absolute) instances (I don't have any such ECDs)
28
- * Event filtering (I haven't tested it)
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)
29
41
 
30
- Not implemented:
42
+ ## Out of scope
31
43
 
32
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)
33
45
  * Any commands described in the documentation as "legacy" (they aren't useful)
46
+
34
47
  ## TPI Advanced wishlist
35
48
 
36
49
  * 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.)_
@@ -38,6 +51,10 @@ Not implemented:
38
51
  * Command to read an ambient light sensor's lux value. _(As a workaround, you can target a light sensor to a system variable. Not elegant but it works.)_
39
52
  * Event notification for ambient light sensor lux values. _(Same workaround as above.)_
40
53
 
54
+ ## License
55
+
56
+ [MIT](LICENSE)
57
+
41
58
  ## Links
42
59
 
43
60
  * [About TPI Advanced](https://support.zencontrol.com/hc/en-us/articles/360000337175-What-is-the-Third-Party-Interface-TPI)
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env python3
2
+ """Dump a live Zencontrol controller into a zencontrol-simulator config.yaml.
3
+
4
+ Read-only: queries labels, levels, colour, scenes, groups, ECDs/instances,
5
+ profiles, and system variables. Does not send control commands.
6
+
7
+ Example:
8
+ cd zencontrol-python
9
+ python examples/dump_simulator_config.py \\
10
+ -c examples/config.yaml \\
11
+ -o ../zencontrol-simulator/config.from-live.yaml
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import logging
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ import yaml
24
+
25
+ from zencontrol import (
26
+ ZenColour,
27
+ ZenColourType,
28
+ ZenController,
29
+ ZenInstanceType,
30
+ ZenProtocol,
31
+ run_with_keyboard_interrupt,
32
+ )
33
+ from zencontrol.api.types import Const
34
+
35
+ LOGGER = logging.getLogger("dump_simulator_config")
36
+
37
+ INSTANCE_TYPE_NAMES = {
38
+ ZenInstanceType.PUSH_BUTTON: "push_button",
39
+ ZenInstanceType.ABSOLUTE_INPUT: "absolute_input",
40
+ ZenInstanceType.OCCUPANCY_SENSOR: "occupancy_sensor",
41
+ ZenInstanceType.LIGHT_SENSOR: "light_sensor",
42
+ ZenInstanceType.GENERAL_SENSOR: "general_sensor",
43
+ }
44
+
45
+
46
+ def _hex_int(value: int) -> str:
47
+ return f"0x{value:X}"
48
+
49
+
50
+ def _colour_dict(colour: Optional[ZenColour]) -> Optional[dict[str, Any]]:
51
+ if colour is None or colour.type is None:
52
+ return None
53
+ if colour.type == ZenColourType.TC:
54
+ return {"type": "tc", "kelvin": colour.kelvin}
55
+ if colour.type == ZenColourType.RGBWAF:
56
+ return {
57
+ "type": "rgbwaf",
58
+ "r": colour.r,
59
+ "g": colour.g,
60
+ "b": colour.b,
61
+ "w": colour.w if colour.w is not None else 0,
62
+ "a": colour.a if colour.a is not None else 0,
63
+ "f": colour.f if colour.f is not None else 0,
64
+ }
65
+ if colour.type == ZenColourType.XY:
66
+ return {"type": "xy", "x": colour.x, "y": colour.y}
67
+ return None
68
+
69
+
70
+ def _scene_levels(levels: list[Optional[int]] | None) -> list[Optional[int]]:
71
+ out: list[Optional[int]] = [None] * 12
72
+ if not levels:
73
+ return out
74
+ for i, level in enumerate(levels[:12]):
75
+ out[i] = None if level is None else int(level)
76
+ return out
77
+
78
+
79
+ def _scene_colours(colours: list[Optional[ZenColour]] | None) -> list[Optional[dict[str, Any]]]:
80
+ out: list[Optional[dict[str, Any]]] = [None] * 12
81
+ if not colours:
82
+ return out
83
+ for i, colour in enumerate(colours[:12]):
84
+ out[i] = _colour_dict(colour)
85
+ return out
86
+
87
+
88
+ async def _raw_byte(tpi: ZenProtocol, controller: ZenController, command: int, address: int = 0) -> Optional[int]:
89
+ response = await tpi._send_basic(controller, command, address)
90
+ if response and len(response) >= 1:
91
+ return int(response[0])
92
+ return None
93
+
94
+
95
+ async def dump_controller(tpi: ZenProtocol, controller: ZenController) -> dict[str, Any]:
96
+ LOGGER.info("Querying controller %s (%s:%s)", controller.mac, controller.host, controller.port)
97
+
98
+ version = await tpi.query_controller_version_number(controller)
99
+ version_parts = [2, 2, 0]
100
+ if isinstance(version, str):
101
+ try:
102
+ version_parts = [int(x) for x in version.split(".")[:3]]
103
+ while len(version_parts) < 3:
104
+ version_parts.append(0)
105
+ except ValueError:
106
+ pass
107
+ label = await tpi.query_controller_label(controller) or controller.label or "Controller"
108
+ startup = await tpi.query_controller_startup_complete(controller)
109
+ dali_ready = await tpi.query_is_dali_ready(controller)
110
+ event_mode = await _raw_byte(tpi, controller, tpi.CMD["QUERY_TPI_EVENT_EMIT_STATE"])
111
+
112
+ current_profile = await tpi.query_current_profile_number(controller)
113
+ last_scheduled = current_profile or 0
114
+ profile_info = await tpi.query_profile_information(controller)
115
+ if profile_info:
116
+ state, _profiles_detail = profile_info
117
+ current_profile = int(state.get("current_active_profile", current_profile or 0))
118
+ last_scheduled = int(state.get("last_scheduled_profile", last_scheduled))
119
+
120
+ profile_numbers = await tpi.query_profile_numbers(controller) or []
121
+ if current_profile is not None and current_profile not in profile_numbers:
122
+ profile_numbers = sorted(set(profile_numbers) | {current_profile})
123
+ profile_items = []
124
+ for number in sorted(profile_numbers):
125
+ plabel = await tpi.query_profile_label(controller, number)
126
+ profile_items.append({
127
+ "number": int(number),
128
+ "label": plabel or f"Profile {number}",
129
+ })
130
+ LOGGER.info(" profile %s: %s", number, plabel)
131
+
132
+ mac = controller.mac.replace("-", ":").lower()
133
+ world: dict[str, Any] = {
134
+ "controller": {
135
+ "bind_host": "0.0.0.0",
136
+ "bind_port": 5108,
137
+ "mac": mac,
138
+ "label": label,
139
+ "version": version_parts,
140
+ "startup_complete": bool(startup) if startup is not None else True,
141
+ "dali_ready": bool(dali_ready) if dali_ready is not None else True,
142
+ "event_mode": event_mode if event_mode is not None else 0x01,
143
+ # Dump is a static snapshot — disable occupancy heartbeat by default.
144
+ "heartbeat_interval": 0,
145
+ },
146
+ "lights": [],
147
+ "groups": [],
148
+ "devices": [],
149
+ "profiles": {
150
+ "current": int(current_profile or 0),
151
+ "last_scheduled": int(last_scheduled or 0),
152
+ "items": profile_items,
153
+ },
154
+ "system_variables": [],
155
+ }
156
+
157
+ # --- Groups (labels / scenes first so light membership can reference them) ---
158
+ group_addrs = await tpi.query_group_numbers(controller) or []
159
+ LOGGER.info("Groups: %d", len(group_addrs))
160
+ for gaddr in sorted(group_addrs, key=lambda a: a.number):
161
+ glabel = await tpi.query_group_label(gaddr)
162
+ glevel = await tpi.dali_query_level(gaddr)
163
+ scene_nums = await tpi.query_scene_numbers_for_group(gaddr) or []
164
+ scenes: dict[int, str] = {}
165
+ for scene in sorted(scene_nums):
166
+ if not 0 <= scene <= 11:
167
+ continue
168
+ slabel = await tpi.query_scene_label_for_group(gaddr, scene)
169
+ if slabel:
170
+ scenes[int(scene)] = slabel
171
+ world["groups"].append({
172
+ "number": int(gaddr.number),
173
+ "label": glabel or f"Group {gaddr.number}",
174
+ "level": 0 if glevel is None else int(glevel),
175
+ "scenes": scenes,
176
+ })
177
+ LOGGER.info(" group %s: %s (%d scenes)", gaddr.number, glabel, len(scenes))
178
+
179
+ # --- Control gear / lights ---
180
+ gears = await tpi.query_control_gear_dali_addresses(controller) or []
181
+ LOGGER.info("Control gear: %d", len(gears))
182
+ for addr in sorted(gears, key=lambda a: a.number):
183
+ status_raw = await _raw_byte(
184
+ tpi, controller, tpi.CMD["DALI_QUERY_CONTROL_GEAR_STATUS"], addr.ecg()
185
+ )
186
+ # Skip gear that does not answer status (absent / failed)
187
+ if status_raw is None:
188
+ LOGGER.warning(" ECG %s: no status — skipping", addr.number)
189
+ continue
190
+
191
+ dlabel = await tpi.query_dali_device_label(addr)
192
+ serial = await tpi.query_dali_serial(addr)
193
+ level = await tpi.dali_query_level(addr)
194
+ min_level = await tpi.dali_query_min_level(addr)
195
+ max_level = await tpi.dali_query_max_level(addr)
196
+ last_scene = await tpi.dali_query_last_scene(addr)
197
+ last_current = await tpi.dali_query_last_scene_is_current(addr)
198
+ cg_types = await tpi.dali_query_cg_type(addr) or []
199
+ groups = await tpi.query_group_membership_by_address(addr) or []
200
+ group_nums = sorted({int(g.number) for g in groups})
201
+
202
+ colour_features = {
203
+ "supports_xy": False,
204
+ "supports_tunable": False,
205
+ "primary_count": 0,
206
+ "rgbwaf_channels": 0,
207
+ }
208
+ colour = None
209
+ colour_temp_limits = None
210
+ if 8 in cg_types:
211
+ features = await tpi.query_dali_colour_features(addr)
212
+ if features:
213
+ colour_features = {
214
+ "supports_xy": bool(features.get("supports_xy", False)),
215
+ "supports_tunable": bool(features.get("supports_tunable", False)),
216
+ "primary_count": int(features.get("primary_count", 0)),
217
+ "rgbwaf_channels": int(features.get("rgbwaf_channels", 0)),
218
+ }
219
+ colour = _colour_dict(await tpi.query_dali_colour(addr))
220
+ if colour_features["supports_tunable"]:
221
+ colour_temp_limits = await tpi.query_dali_colour_temp_limits(addr)
222
+
223
+ scene_levels = _scene_levels(await tpi.query_scene_levels_by_address(addr))
224
+ scene_colours = _scene_colours(await tpi.query_scene_colours_by_address(addr))
225
+
226
+ light: dict[str, Any] = {
227
+ "address": int(addr.number),
228
+ "label": dlabel or f"Light {addr.number}",
229
+ "serial": _hex_int(int(serial)) if serial else 0,
230
+ "level": 0 if level is None else int(level),
231
+ "min_level": 1 if min_level is None else int(min_level),
232
+ "max_level": 254 if max_level is None else int(max_level),
233
+ "last_scene": 0 if last_scene is None else int(last_scene),
234
+ "last_scene_current": bool(last_current) if last_current is not None else False,
235
+ "cg_types": [int(x) for x in cg_types],
236
+ "colour": colour,
237
+ "colour_features": colour_features,
238
+ "groups": group_nums,
239
+ "scene_levels": scene_levels,
240
+ "status": status_raw,
241
+ }
242
+ if colour_temp_limits:
243
+ light["colour_temp_limits"] = {
244
+ "physical_warmest": int(colour_temp_limits["physical_warmest"]),
245
+ "physical_coolest": int(colour_temp_limits["physical_coolest"]),
246
+ "soft_warmest": int(colour_temp_limits["soft_warmest"]),
247
+ "soft_coolest": int(colour_temp_limits["soft_coolest"]),
248
+ "step_value": int(colour_temp_limits["step_value"]),
249
+ }
250
+ if any(c is not None for c in scene_colours):
251
+ light["scene_colours"] = scene_colours
252
+
253
+ world["lights"].append(light)
254
+ LOGGER.info(
255
+ " ECG %s: %s level=%s groups=%s cg=%s",
256
+ addr.number,
257
+ dlabel,
258
+ light["level"],
259
+ group_nums,
260
+ cg_types,
261
+ )
262
+
263
+ # --- ECDs / instances ---
264
+ ecds = await tpi.query_dali_addresses_with_instances(controller, 0) or []
265
+ LOGGER.info("Devices with instances: %d", len(ecds))
266
+ for ecd in sorted(ecds, key=lambda a: a.number):
267
+ dlabel = await tpi.query_dali_device_label(ecd)
268
+ serial = await tpi.query_dali_serial(ecd)
269
+ instances = await tpi.query_instances_by_address(ecd) or []
270
+ inst_list: list[dict[str, Any]] = []
271
+ for inst in sorted(instances, key=lambda i: i.number):
272
+ if inst.type is None:
273
+ continue
274
+ type_name = INSTANCE_TYPE_NAMES.get(inst.type)
275
+ if type_name is None:
276
+ LOGGER.warning(
277
+ " ECD %s inst %s: unsupported type %s — skipped",
278
+ ecd.number,
279
+ inst.number,
280
+ inst.type,
281
+ )
282
+ continue
283
+ ilabel = await tpi.query_dali_instance_label(inst)
284
+ entry: dict[str, Any] = {
285
+ "number": int(inst.number),
286
+ "type": type_name,
287
+ "label": ilabel or f"Instance {inst.number}",
288
+ "active": True,
289
+ "error": False,
290
+ }
291
+ if inst.type == ZenInstanceType.OCCUPANCY_SENSOR:
292
+ timers = await tpi.query_occupancy_instance_timers(inst)
293
+ if timers:
294
+ entry["timers"] = {
295
+ "deadtime": int(timers["deadtime"]),
296
+ "hold": int(timers["hold"]),
297
+ "report": int(timers["report"]),
298
+ "last_detect": int(timers["last_detect"]),
299
+ }
300
+ else:
301
+ entry["timers"] = {
302
+ "deadtime": 1,
303
+ "hold": 60,
304
+ "report": 20,
305
+ "last_detect": 0,
306
+ }
307
+ inst_list.append(entry)
308
+ LOGGER.info(
309
+ " ECD %s.%s %s (%s)",
310
+ ecd.number,
311
+ inst.number,
312
+ type_name,
313
+ ilabel,
314
+ )
315
+ if not inst_list:
316
+ continue
317
+ world["devices"].append({
318
+ "address": int(ecd.number),
319
+ "label": dlabel or f"Device {ecd.number}",
320
+ "serial": _hex_int(int(serial)) if serial else 0,
321
+ "instances": inst_list,
322
+ })
323
+
324
+ # --- System variables (stop after consecutive unnamed IDs) ---
325
+ give_up_after = 10
326
+ failed = 0
327
+ LOGGER.info("System variables…")
328
+ for vid in range(Const.MAX_SYSVAR):
329
+ name = await tpi.query_system_variable_name(controller, vid)
330
+ if not name:
331
+ failed += 1
332
+ if failed >= give_up_after:
333
+ break
334
+ continue
335
+ failed = 0
336
+ value = await tpi.query_system_variable(controller, vid)
337
+ world["system_variables"].append({
338
+ "id": int(vid),
339
+ "name": name,
340
+ "value": 0 if value is None else int(value),
341
+ })
342
+ LOGGER.info(" sysvar %s: %s = %s", vid, name, value)
343
+
344
+ return world
345
+
346
+
347
+ class _HexInt(int):
348
+ """YAML representable int that dumps as 0x…."""
349
+
350
+
351
+ def _represent_hex_int(dumper: yaml.Dumper, data: _HexInt) -> Any:
352
+ return dumper.represent_scalar("tag:yaml.org,2002:int", f"0x{int(data):X}")
353
+
354
+
355
+ def _prepare_for_yaml(obj: Any) -> Any:
356
+ """Convert serial hex strings back to ints tagged for hex dump where useful."""
357
+ if isinstance(obj, dict):
358
+ out = {}
359
+ for key, value in obj.items():
360
+ if key == "serial" and isinstance(value, str) and value.startswith("0x"):
361
+ out[key] = _HexInt(int(value, 0))
362
+ elif key == "event_mode" and isinstance(value, int):
363
+ out[key] = _HexInt(value)
364
+ elif key == "status" and isinstance(value, int):
365
+ out[key] = _HexInt(value)
366
+ else:
367
+ out[key] = _prepare_for_yaml(value)
368
+ return out
369
+ if isinstance(obj, list):
370
+ return [_prepare_for_yaml(x) for x in obj]
371
+ return obj
372
+
373
+
374
+ def write_yaml(path: Path, world: dict[str, Any]) -> None:
375
+ class Dumper(yaml.SafeDumper):
376
+ pass
377
+
378
+ Dumper.add_representer(_HexInt, _represent_hex_int)
379
+ payload = _prepare_for_yaml(world)
380
+ header = (
381
+ "# Auto-generated by zencontrol-python/examples/dump_simulator_config.py\n"
382
+ "# Read-only snapshot of a live Zencontrol controller for zencontrol-simulator.\n"
383
+ "# Control commands mutate in-memory state only; nothing is written back to disk.\n\n"
384
+ )
385
+ path.parent.mkdir(parents=True, exist_ok=True)
386
+ with open(path, "w", encoding="utf-8") as fh:
387
+ fh.write(header)
388
+ yaml.dump(
389
+ payload,
390
+ fh,
391
+ Dumper=Dumper,
392
+ sort_keys=False,
393
+ default_flow_style=False,
394
+ allow_unicode=True,
395
+ width=100,
396
+ )
397
+
398
+
399
+ def build_parser() -> argparse.ArgumentParser:
400
+ example_dir = Path(__file__).resolve().parent
401
+ default_config = example_dir / "config.yaml"
402
+ default_out = example_dir.parents[1] / "zencontrol-simulator" / "config.from-live.yaml"
403
+ parser = argparse.ArgumentParser(
404
+ description="Dump a live Zencontrol controller to a zencontrol-simulator YAML world",
405
+ )
406
+ parser.add_argument(
407
+ "-c",
408
+ "--config",
409
+ type=Path,
410
+ default=default_config,
411
+ help=f"zencontrol-python config.yaml (default: {default_config})",
412
+ )
413
+ parser.add_argument(
414
+ "-o",
415
+ "--output",
416
+ type=Path,
417
+ default=default_out,
418
+ help=f"Output simulator config path (default: {default_out})",
419
+ )
420
+ parser.add_argument(
421
+ "--controller",
422
+ type=int,
423
+ default=0,
424
+ help="Index into zencontrol: list in config (default: 0)",
425
+ )
426
+ parser.add_argument(
427
+ "-v",
428
+ "--verbose",
429
+ action="store_true",
430
+ help="Debug logging",
431
+ )
432
+ return parser
433
+
434
+
435
+ async def main(argv: list[str] | None = None) -> None:
436
+ args = build_parser().parse_args(argv)
437
+ logging.basicConfig(
438
+ level=logging.DEBUG if args.verbose else logging.INFO,
439
+ format="%(asctime)s %(levelname)s %(message)s",
440
+ )
441
+
442
+ if not args.config.is_file():
443
+ LOGGER.error("Config not found: %s", args.config)
444
+ sys.exit(1)
445
+
446
+ with open(args.config, encoding="utf-8") as fh:
447
+ config = yaml.safe_load(fh) or {}
448
+ controllers = config.get("zencontrol") or []
449
+ if not controllers:
450
+ LOGGER.error("No zencontrol: entries in %s", args.config)
451
+ sys.exit(1)
452
+ if not 0 <= args.controller < len(controllers):
453
+ LOGGER.error("Controller index %s out of range (have %d)", args.controller, len(controllers))
454
+ sys.exit(1)
455
+
456
+ entry = dict(controllers[args.controller])
457
+ # ZenController / ZenProtocol do not need mqtt extras
458
+ for key in ("system_variables", "buttons", "unicast_ip", "unicast_port", "filtering"):
459
+ entry.pop(key, None)
460
+
461
+ async with ZenProtocol(print_traffic=False) as tpi:
462
+ # Coerce YAML types to what ZenController expects
463
+ entry["id"] = str(entry.get("id", "1"))
464
+ ctrl = ZenController(protocol=tpi, **entry)
465
+ tpi.set_controllers([ctrl])
466
+
467
+ ready = await tpi.query_controller_startup_complete(ctrl)
468
+ if ready is False:
469
+ LOGGER.warning("Controller reports startup incomplete — continuing anyway")
470
+ dali = await tpi.query_is_dali_ready(ctrl)
471
+ if dali is False:
472
+ LOGGER.warning("DALI bus not ready — continuing anyway")
473
+
474
+ world = await dump_controller(tpi, ctrl)
475
+
476
+ write_yaml(args.output, world)
477
+ LOGGER.info(
478
+ "Wrote %s (%d lights, %d groups, %d devices, %d profiles, %d sysvars)",
479
+ args.output,
480
+ len(world["lights"]),
481
+ len(world["groups"]),
482
+ len(world["devices"]),
483
+ len(world["profiles"]["items"]),
484
+ len(world["system_variables"]),
485
+ )
486
+
487
+
488
+ if __name__ == "__main__":
489
+ run_with_keyboard_interrupt(main)
@@ -1,11 +1,12 @@
1
1
  import asyncio
2
2
  import yaml
3
+ from pathlib import Path
3
4
  from zencontrol import ZenProtocol, ZenController, run_with_keyboard_interrupt
4
5
 
5
6
  async def main():
6
7
  """Test the async ZenProtocol with controller queries"""
7
8
  # Load configuration
8
- config = yaml.safe_load(open("tests/config.yaml"))
9
+ config = yaml.safe_load(open(Path(__file__).resolve().parents[2] / "tests" / "config.yaml"))
9
10
 
10
11
  # Create protocol and controller
11
12
  async with ZenProtocol(print_traffic=True) as tpi: