zencontrol-python 0.1.3__tar.gz → 0.1.4__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 (52) hide show
  1. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/PKG-INFO +25 -11
  2. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/README.md +24 -10
  3. zencontrol_python-0.1.4/examples/dump_simulator_config.py +474 -0
  4. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_lifecycle.py +28 -1
  5. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/__init__.py +1 -1
  6. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/api/protocol.py +19 -0
  7. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/interface/interface.py +77 -23
  8. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/PKG-INFO +25 -11
  9. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/SOURCES.txt +1 -0
  10. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/LICENSE +0 -0
  11. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/__init__.py +0 -0
  12. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/controller.py +0 -0
  13. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/devices.py +0 -0
  14. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/events.py +0 -0
  15. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/gear.py +0 -0
  16. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/groups.py +0 -0
  17. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/instances.py +0 -0
  18. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/interface.py +0 -0
  19. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/interface_events.py +0 -0
  20. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/leds.py +0 -0
  21. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/level_change_v2.py +0 -0
  22. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/multicast.py +0 -0
  23. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/multicast_raw.py +0 -0
  24. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/profiles.py +0 -0
  25. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/tpi_event_listener.py +0 -0
  26. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/variables.py +0 -0
  27. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/mqtt_bridge.py +0 -0
  28. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/pyproject.toml +0 -0
  29. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/setup.cfg +0 -0
  30. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_discovery.py +0 -0
  31. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_entity_scope.py +0 -0
  32. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_event_listener_disconnect.py +0 -0
  33. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_reconnect.py +0 -0
  34. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_sim_events.py +0 -0
  35. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_sim_interface.py +0 -0
  36. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_sim_protocol.py +0 -0
  37. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_sub_labels.py +0 -0
  38. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_wire_resilience.py +0 -0
  39. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/api/__init__.py +0 -0
  40. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/api/models.py +0 -0
  41. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/api/types.py +0 -0
  42. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/exceptions.py +0 -0
  43. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/interface/__init__.py +0 -0
  44. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/io/__init__.py +0 -0
  45. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/io/command.py +0 -0
  46. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/io/event.py +0 -0
  47. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/py.typed +0 -0
  48. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/utils.py +0 -0
  49. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/dependency_links.txt +0 -0
  50. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/entry_points.txt +0 -0
  51. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/requires.txt +0 -0
  52. {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/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
3
+ Version: 0.1.4
4
4
  Summary: Python implementation of the Zencontrol TPI Advanced protocol for DALI lighting controllers.
5
5
  Author: Simon Wright
6
6
  License-Expression: MIT
@@ -44,6 +44,30 @@ This library has now undergone validation in multiple environments. There is an
44
44
  [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
45
45
  A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
46
46
 
47
+ ## Features
48
+
49
+ Beyond basic lighting control, this library supports:
50
+
51
+ * **Broad command surface** — inhibit, custom fade, step/up/down helpers, colour scene membership queries, EAN/serial, and most related TPI Advanced commands
52
+ * **Object-based entity model** — Optional. Expresses lights, groups, profiles, buttons, motion sensors, and system variables as rich objects with interview/discovery helpers
53
+ * **UDP transport resilience** — request retries and queue-failure backoff
54
+ * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
55
+ * **Button events** — discovery of control-device button instances, plus press and long-press event callbacks
56
+ * **Event filtering** — configure which TPI events the controller emits
57
+ * **System variables** — labelled SV discovery, read/write, and change events
58
+ * **Profiles** — query, change, and return to the scheduled profile
59
+ * **Simulator-backed tests** — protocol path exercised against [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator)
60
+
61
+ ## Known limitations
62
+
63
+ * RGB+ and XY colour commands have not been tested with hardware
64
+ * Numerical (absolute) instances have not been tested with hardware
65
+
66
+ ## Out of scope
67
+
68
+ * 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
+ * Any commands described in the documentation as "legacy" (they aren't useful)
70
+
47
71
  ## Requirements
48
72
 
49
73
  * Python 3.14 (or later)
@@ -68,16 +92,6 @@ pytest -m "not simulator"
68
92
  pytest
69
93
  ```
70
94
 
71
- ## Limitations
72
-
73
- * RGB+ and XY colour commands are not tested (I don't have any compatible lights)
74
- * Numerical (absolute) instances are not tested (I don't have any such ECDs)
75
-
76
- ## Out of scope
77
-
78
- * Any commands involving DMX, Control4, or virtual instances (I don't have licenses for any of these so I couldn't test them even if I wanted to, but the scaffolding is there if anyone wishes to add support)
79
- * Any commands described in the documentation as "legacy" (they aren't useful)
80
-
81
95
  ## TPI Advanced wishlist
82
96
 
83
97
  * Command to return a controller's MAC address used for multicast packets _(There are other ways to get or infer the MAC access, but they're unreliable.)_
@@ -10,6 +10,30 @@ This library has now undergone validation in multiple environments. There is an
10
10
  [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
11
11
  A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
12
12
 
13
+ ## Features
14
+
15
+ Beyond basic lighting control, this library supports:
16
+
17
+ * **Broad command surface** — inhibit, custom fade, step/up/down helpers, colour scene membership queries, EAN/serial, and most related TPI Advanced commands
18
+ * **Object-based entity model** — Optional. Expresses lights, groups, profiles, buttons, motion sensors, and system variables as rich objects with interview/discovery helpers
19
+ * **UDP transport resilience** — request retries and queue-failure backoff
20
+ * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
21
+ * **Button events** — discovery of control-device button instances, plus press and long-press event callbacks
22
+ * **Event filtering** — configure which TPI events the controller emits
23
+ * **System variables** — labelled SV discovery, read/write, and change events
24
+ * **Profiles** — query, change, and return to the scheduled profile
25
+ * **Simulator-backed tests** — protocol path exercised against [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator)
26
+
27
+ ## Known limitations
28
+
29
+ * RGB+ and XY colour commands have not been tested with hardware
30
+ * Numerical (absolute) instances have not been tested with hardware
31
+
32
+ ## Out of scope
33
+
34
+ * 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)
35
+ * Any commands described in the documentation as "legacy" (they aren't useful)
36
+
13
37
  ## Requirements
14
38
 
15
39
  * Python 3.14 (or later)
@@ -34,16 +58,6 @@ pytest -m "not simulator"
34
58
  pytest
35
59
  ```
36
60
 
37
- ## Limitations
38
-
39
- * RGB+ and XY colour commands are not tested (I don't have any compatible lights)
40
- * Numerical (absolute) instances are not tested (I don't have any such ECDs)
41
-
42
- ## Out of scope
43
-
44
- * Any commands involving DMX, Control4, or virtual instances (I don't have licenses for any of these so I couldn't test them even if I wanted to, but the scaffolding is there if anyone wishes to add support)
45
- * Any commands described in the documentation as "legacy" (they aren't useful)
46
-
47
61
  ## TPI Advanced wishlist
48
62
 
49
63
  * Command to return a controller's MAC address used for multicast packets _(There are other ways to get or infer the MAC access, but they're unreliable.)_
@@ -0,0 +1,474 @@
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
+ import argparse
15
+ import asyncio
16
+ import logging
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ from zencontrol import (
24
+ ZenColour,
25
+ ZenColourType,
26
+ ZenController,
27
+ ZenInstanceType,
28
+ ZenProtocol,
29
+ run_with_keyboard_interrupt,
30
+ )
31
+ from zencontrol.api.types import Const
32
+
33
+ LOGGER = logging.getLogger("dump_simulator_config")
34
+
35
+ INSTANCE_TYPE_NAMES = {
36
+ ZenInstanceType.PUSH_BUTTON: "push_button",
37
+ ZenInstanceType.ABSOLUTE_INPUT: "absolute_input",
38
+ ZenInstanceType.OCCUPANCY_SENSOR: "occupancy_sensor",
39
+ ZenInstanceType.LIGHT_SENSOR: "light_sensor",
40
+ ZenInstanceType.GENERAL_SENSOR: "general_sensor",
41
+ }
42
+
43
+ def _hex_int(value: int) -> str:
44
+ return f"0x{value:X}"
45
+
46
+ def _colour_dict(colour: ZenColour | None) -> dict[str, Any] | None:
47
+ if colour is None or colour.type is None:
48
+ return None
49
+ if colour.type == ZenColourType.TC:
50
+ return {"type": "tc", "kelvin": colour.kelvin}
51
+ if colour.type == ZenColourType.RGBWAF:
52
+ return {
53
+ "type": "rgbwaf",
54
+ "r": colour.r,
55
+ "g": colour.g,
56
+ "b": colour.b,
57
+ "w": colour.w if colour.w is not None else 0,
58
+ "a": colour.a if colour.a is not None else 0,
59
+ "f": colour.f if colour.f is not None else 0,
60
+ }
61
+ if colour.type == ZenColourType.XY:
62
+ return {"type": "xy", "x": colour.x, "y": colour.y}
63
+ return None
64
+
65
+ def _scene_levels(levels: list[int | None] | None) -> list[int | None]:
66
+ out: list[int | None] = [None] * 12
67
+ if not levels:
68
+ return out
69
+ for i, level in enumerate(levels[:12]):
70
+ out[i] = None if level is None else int(level)
71
+ return out
72
+
73
+ def _scene_colours(colours: list[ZenColour | None] | None) -> list[dict[str, Any] | None]:
74
+ out: list[dict[str, Any] | None] = [None] * 12
75
+ if not colours:
76
+ return out
77
+ for i, colour in enumerate(colours[:12]):
78
+ out[i] = _colour_dict(colour)
79
+ return out
80
+
81
+ async def _raw_byte(tpi: ZenProtocol, controller: ZenController, command: int, address: int = 0) -> int | None:
82
+ response = await tpi._send_basic(controller, command, address)
83
+ if response and len(response) >= 1:
84
+ return int(response[0])
85
+ return None
86
+
87
+ async def dump_controller(tpi: ZenProtocol, controller: ZenController) -> dict[str, Any]:
88
+ LOGGER.info("Querying controller %s (%s:%s)", controller.mac, controller.host, controller.port)
89
+
90
+ version = await tpi.query_controller_version_number(controller)
91
+ version_parts = [2, 2, 0]
92
+ if isinstance(version, str):
93
+ try:
94
+ version_parts = [int(x) for x in version.split(".")[:3]]
95
+ while len(version_parts) < 3:
96
+ version_parts.append(0)
97
+ except ValueError:
98
+ pass
99
+ label = await tpi.query_controller_label(controller) or controller.label or "Controller"
100
+ startup = await tpi.query_controller_startup_complete(controller)
101
+ dali_ready = await tpi.query_is_dali_ready(controller)
102
+ event_mode = await _raw_byte(tpi, controller, tpi.CMD["QUERY_TPI_EVENT_EMIT_STATE"])
103
+
104
+ current_profile = await tpi.query_current_profile_number(controller)
105
+ last_scheduled = current_profile or 0
106
+ profile_info = await tpi.query_profile_information(controller)
107
+ if profile_info:
108
+ state, _profiles_detail = profile_info
109
+ current_profile = int(state.get("current_active_profile", current_profile or 0))
110
+ last_scheduled = int(state.get("last_scheduled_profile", last_scheduled))
111
+
112
+ profile_numbers = await tpi.query_profile_numbers(controller) or []
113
+ if current_profile is not None and current_profile not in profile_numbers:
114
+ profile_numbers = sorted(set(profile_numbers) | {current_profile})
115
+ profile_items = []
116
+ for number in sorted(profile_numbers):
117
+ plabel = await tpi.query_profile_label(controller, number)
118
+ profile_items.append({
119
+ "number": int(number),
120
+ "label": plabel or f"Profile {number}",
121
+ })
122
+ LOGGER.info(" profile %s: %s", number, plabel)
123
+
124
+ mac = controller.mac.replace("-", ":").lower()
125
+ world: dict[str, Any] = {
126
+ "controller": {
127
+ "bind_host": "0.0.0.0",
128
+ "bind_port": 5108,
129
+ "mac": mac,
130
+ "label": label,
131
+ "version": version_parts,
132
+ "startup_complete": bool(startup) if startup is not None else True,
133
+ "dali_ready": bool(dali_ready) if dali_ready is not None else True,
134
+ "event_mode": event_mode if event_mode is not None else 0x01,
135
+ # Dump is a static snapshot — disable occupancy heartbeat by default.
136
+ "heartbeat_interval": 0,
137
+ },
138
+ "lights": [],
139
+ "groups": [],
140
+ "devices": [],
141
+ "profiles": {
142
+ "current": int(current_profile or 0),
143
+ "last_scheduled": int(last_scheduled or 0),
144
+ "items": profile_items,
145
+ },
146
+ "system_variables": [],
147
+ }
148
+
149
+ # --- Groups (labels / scenes first so light membership can reference them) ---
150
+ group_addrs = await tpi.query_group_numbers(controller) or []
151
+ LOGGER.info("Groups: %d", len(group_addrs))
152
+ for gaddr in sorted(group_addrs, key=lambda a: a.number):
153
+ glabel = await tpi.query_group_label(gaddr)
154
+ glevel = await tpi.dali_query_level(gaddr)
155
+ scene_nums = await tpi.query_scene_numbers_for_group(gaddr) or []
156
+ scenes: dict[int, str] = {}
157
+ for scene in sorted(scene_nums):
158
+ if not 0 <= scene <= 11:
159
+ continue
160
+ slabel = await tpi.query_scene_label_for_group(gaddr, scene)
161
+ if slabel:
162
+ scenes[int(scene)] = slabel
163
+ world["groups"].append({
164
+ "number": int(gaddr.number),
165
+ "label": glabel or f"Group {gaddr.number}",
166
+ "level": 0 if glevel is None else int(glevel),
167
+ "scenes": scenes,
168
+ })
169
+ LOGGER.info(" group %s: %s (%d scenes)", gaddr.number, glabel, len(scenes))
170
+
171
+ # --- Control gear / lights ---
172
+ gears = await tpi.query_control_gear_dali_addresses(controller) or []
173
+ LOGGER.info("Control gear: %d", len(gears))
174
+ for addr in sorted(gears, key=lambda a: a.number):
175
+ status_raw = await _raw_byte(
176
+ tpi, controller, tpi.CMD["DALI_QUERY_CONTROL_GEAR_STATUS"], addr.ecg()
177
+ )
178
+ # Skip gear that does not answer status (absent / failed)
179
+ if status_raw is None:
180
+ LOGGER.warning(" ECG %s: no status — skipping", addr.number)
181
+ continue
182
+
183
+ dlabel = await tpi.query_dali_device_label(addr)
184
+ serial = await tpi.query_dali_serial(addr)
185
+ level = await tpi.dali_query_level(addr)
186
+ min_level = await tpi.dali_query_min_level(addr)
187
+ max_level = await tpi.dali_query_max_level(addr)
188
+ last_scene = await tpi.dali_query_last_scene(addr)
189
+ last_current = await tpi.dali_query_last_scene_is_current(addr)
190
+ cg_types = await tpi.dali_query_cg_type(addr) or []
191
+ groups = await tpi.query_group_membership_by_address(addr) or []
192
+ group_nums = sorted({int(g.number) for g in groups})
193
+
194
+ colour_features = {
195
+ "supports_xy": False,
196
+ "supports_tunable": False,
197
+ "primary_count": 0,
198
+ "rgbwaf_channels": 0,
199
+ }
200
+ colour = None
201
+ colour_temp_limits = None
202
+ if 8 in cg_types:
203
+ features = await tpi.query_dali_colour_features(addr)
204
+ if features:
205
+ colour_features = {
206
+ "supports_xy": bool(features.get("supports_xy", False)),
207
+ "supports_tunable": bool(features.get("supports_tunable", False)),
208
+ "primary_count": int(features.get("primary_count", 0)),
209
+ "rgbwaf_channels": int(features.get("rgbwaf_channels", 0)),
210
+ }
211
+ colour = _colour_dict(await tpi.query_dali_colour(addr))
212
+ if colour_features["supports_tunable"]:
213
+ colour_temp_limits = await tpi.query_dali_colour_temp_limits(addr)
214
+
215
+ scene_levels = _scene_levels(await tpi.query_scene_levels_by_address(addr))
216
+ scene_colours = _scene_colours(await tpi.query_scene_colours_by_address(addr))
217
+
218
+ light: dict[str, Any] = {
219
+ "address": int(addr.number),
220
+ "label": dlabel or f"Light {addr.number}",
221
+ "serial": _hex_int(int(serial)) if serial else 0,
222
+ "level": 0 if level is None else int(level),
223
+ "min_level": 1 if min_level is None else int(min_level),
224
+ "max_level": 254 if max_level is None else int(max_level),
225
+ "last_scene": 0 if last_scene is None else int(last_scene),
226
+ "last_scene_current": bool(last_current) if last_current is not None else False,
227
+ "cg_types": [int(x) for x in cg_types],
228
+ "colour": colour,
229
+ "colour_features": colour_features,
230
+ "groups": group_nums,
231
+ "scene_levels": scene_levels,
232
+ "status": status_raw,
233
+ }
234
+ if colour_temp_limits:
235
+ light["colour_temp_limits"] = {
236
+ "physical_warmest": int(colour_temp_limits["physical_warmest"]),
237
+ "physical_coolest": int(colour_temp_limits["physical_coolest"]),
238
+ "soft_warmest": int(colour_temp_limits["soft_warmest"]),
239
+ "soft_coolest": int(colour_temp_limits["soft_coolest"]),
240
+ "step_value": int(colour_temp_limits["step_value"]),
241
+ }
242
+ if any(c is not None for c in scene_colours):
243
+ light["scene_colours"] = scene_colours
244
+
245
+ world["lights"].append(light)
246
+ LOGGER.info(
247
+ " ECG %s: %s level=%s groups=%s cg=%s",
248
+ addr.number,
249
+ dlabel,
250
+ light["level"],
251
+ group_nums,
252
+ cg_types,
253
+ )
254
+
255
+ # --- ECDs / instances ---
256
+ ecds = await tpi.query_dali_addresses_with_instances(controller, 0) or []
257
+ LOGGER.info("Devices with instances: %d", len(ecds))
258
+ for ecd in sorted(ecds, key=lambda a: a.number):
259
+ dlabel = await tpi.query_dali_device_label(ecd)
260
+ serial = await tpi.query_dali_serial(ecd)
261
+ instances = await tpi.query_instances_by_address(ecd) or []
262
+ inst_list: list[dict[str, Any]] = []
263
+ for inst in sorted(instances, key=lambda i: i.number):
264
+ if inst.type is None:
265
+ continue
266
+ type_name = INSTANCE_TYPE_NAMES.get(inst.type)
267
+ if type_name is None:
268
+ LOGGER.warning(
269
+ " ECD %s inst %s: unsupported type %s — skipped",
270
+ ecd.number,
271
+ inst.number,
272
+ inst.type,
273
+ )
274
+ continue
275
+ ilabel = await tpi.query_dali_instance_label(inst)
276
+ entry: dict[str, Any] = {
277
+ "number": int(inst.number),
278
+ "type": type_name,
279
+ "label": ilabel or f"Instance {inst.number}",
280
+ "active": True,
281
+ "error": False,
282
+ }
283
+ if inst.type == ZenInstanceType.OCCUPANCY_SENSOR:
284
+ timers = await tpi.query_occupancy_instance_timers(inst)
285
+ if timers:
286
+ entry["timers"] = {
287
+ "deadtime": int(timers["deadtime"]),
288
+ "hold": int(timers["hold"]),
289
+ "report": int(timers["report"]),
290
+ "last_detect": int(timers["last_detect"]),
291
+ }
292
+ else:
293
+ entry["timers"] = {
294
+ "deadtime": 1,
295
+ "hold": 60,
296
+ "report": 20,
297
+ "last_detect": 0,
298
+ }
299
+ inst_list.append(entry)
300
+ LOGGER.info(
301
+ " ECD %s.%s %s (%s)",
302
+ ecd.number,
303
+ inst.number,
304
+ type_name,
305
+ ilabel,
306
+ )
307
+ if not inst_list:
308
+ continue
309
+ world["devices"].append({
310
+ "address": int(ecd.number),
311
+ "label": dlabel or f"Device {ecd.number}",
312
+ "serial": _hex_int(int(serial)) if serial else 0,
313
+ "instances": inst_list,
314
+ })
315
+
316
+ # --- System variables (stop after consecutive unnamed IDs) ---
317
+ give_up_after = 10
318
+ failed = 0
319
+ LOGGER.info("System variables…")
320
+ for vid in range(Const.MAX_SYSVAR):
321
+ name = await tpi.query_system_variable_name(controller, vid)
322
+ if not name:
323
+ failed += 1
324
+ if failed >= give_up_after:
325
+ break
326
+ continue
327
+ failed = 0
328
+ value = await tpi.query_system_variable(controller, vid)
329
+ world["system_variables"].append({
330
+ "id": int(vid),
331
+ "name": name,
332
+ "value": 0 if value is None else int(value),
333
+ })
334
+ LOGGER.info(" sysvar %s: %s = %s", vid, name, value)
335
+
336
+ return world
337
+
338
+ class _HexInt(int):
339
+ """YAML representable int that dumps as 0x…."""
340
+
341
+ def _represent_hex_int(dumper: yaml.Dumper, data: _HexInt) -> Any:
342
+ return dumper.represent_scalar("tag:yaml.org,2002:int", f"0x{int(data):X}")
343
+
344
+ def _prepare_for_yaml(obj: Any) -> Any:
345
+ """Convert serial hex strings back to ints tagged for hex dump where useful."""
346
+ if isinstance(obj, dict):
347
+ out = {}
348
+ for key, value in obj.items():
349
+ if key == "serial" and isinstance(value, str) and value.startswith("0x"):
350
+ out[key] = _HexInt(int(value, 0))
351
+ elif key == "event_mode" and isinstance(value, int):
352
+ out[key] = _HexInt(value)
353
+ elif key == "status" and isinstance(value, int):
354
+ out[key] = _HexInt(value)
355
+ else:
356
+ out[key] = _prepare_for_yaml(value)
357
+ return out
358
+ if isinstance(obj, list):
359
+ return [_prepare_for_yaml(x) for x in obj]
360
+ return obj
361
+
362
+ def write_yaml(path: Path, world: dict[str, Any]) -> None:
363
+ class Dumper(yaml.SafeDumper):
364
+ pass
365
+
366
+ Dumper.add_representer(_HexInt, _represent_hex_int)
367
+ payload = _prepare_for_yaml(world)
368
+ header = (
369
+ "# Auto-generated by zencontrol-python/examples/dump_simulator_config.py\n"
370
+ "# Read-only snapshot of a live Zencontrol controller for zencontrol-simulator.\n"
371
+ "# Control commands mutate in-memory state only; nothing is written back to disk.\n\n"
372
+ )
373
+ path.parent.mkdir(parents=True, exist_ok=True)
374
+ with open(path, "w", encoding="utf-8") as fh:
375
+ fh.write(header)
376
+ yaml.dump(
377
+ payload,
378
+ fh,
379
+ Dumper=Dumper,
380
+ sort_keys=False,
381
+ default_flow_style=False,
382
+ allow_unicode=True,
383
+ width=100,
384
+ )
385
+
386
+ def build_parser() -> argparse.ArgumentParser:
387
+ example_dir = Path(__file__).resolve().parent
388
+ default_config = example_dir / "config.yaml"
389
+ default_out = example_dir.parents[1] / "zencontrol-simulator" / "config.from-live.yaml"
390
+ parser = argparse.ArgumentParser(
391
+ description="Dump a live Zencontrol controller to a zencontrol-simulator YAML world",
392
+ )
393
+ parser.add_argument(
394
+ "-c",
395
+ "--config",
396
+ type=Path,
397
+ default=default_config,
398
+ help=f"zencontrol-python config.yaml (default: {default_config})",
399
+ )
400
+ parser.add_argument(
401
+ "-o",
402
+ "--output",
403
+ type=Path,
404
+ default=default_out,
405
+ help=f"Output simulator config path (default: {default_out})",
406
+ )
407
+ parser.add_argument(
408
+ "--controller",
409
+ type=int,
410
+ default=0,
411
+ help="Index into zencontrol: list in config (default: 0)",
412
+ )
413
+ parser.add_argument(
414
+ "-v",
415
+ "--verbose",
416
+ action="store_true",
417
+ help="Debug logging",
418
+ )
419
+ return parser
420
+
421
+ async def main(argv: list[str] | None = None) -> None:
422
+ args = build_parser().parse_args(argv)
423
+ logging.basicConfig(
424
+ level=logging.DEBUG if args.verbose else logging.INFO,
425
+ format="%(asctime)s %(levelname)s %(message)s",
426
+ )
427
+
428
+ if not args.config.is_file():
429
+ LOGGER.error("Config not found: %s", args.config)
430
+ sys.exit(1)
431
+
432
+ with open(args.config, encoding="utf-8") as fh:
433
+ config = yaml.safe_load(fh) or {}
434
+ controllers = config.get("zencontrol") or []
435
+ if not controllers:
436
+ LOGGER.error("No zencontrol: entries in %s", args.config)
437
+ sys.exit(1)
438
+ if not 0 <= args.controller < len(controllers):
439
+ LOGGER.error("Controller index %s out of range (have %d)", args.controller, len(controllers))
440
+ sys.exit(1)
441
+
442
+ entry = dict(controllers[args.controller])
443
+ # ZenController / ZenProtocol do not need mqtt extras
444
+ for key in ("system_variables", "buttons", "unicast_ip", "unicast_port", "filtering"):
445
+ entry.pop(key, None)
446
+
447
+ async with ZenProtocol(print_traffic=False) as tpi:
448
+ # Coerce YAML types to what ZenController expects
449
+ entry["id"] = str(entry.get("id", "1"))
450
+ ctrl = ZenController(protocol=tpi, **entry)
451
+ tpi.set_controllers([ctrl])
452
+
453
+ ready = await tpi.query_controller_startup_complete(ctrl)
454
+ if ready is False:
455
+ LOGGER.warning("Controller reports startup incomplete — continuing anyway")
456
+ dali = await tpi.query_is_dali_ready(ctrl)
457
+ if dali is False:
458
+ LOGGER.warning("DALI bus not ready — continuing anyway")
459
+
460
+ world = await dump_controller(tpi, ctrl)
461
+
462
+ write_yaml(args.output, world)
463
+ LOGGER.info(
464
+ "Wrote %s (%d lights, %d groups, %d devices, %d profiles, %d sysvars)",
465
+ args.output,
466
+ len(world["lights"]),
467
+ len(world["groups"]),
468
+ len(world["devices"]),
469
+ len(world["profiles"]["items"]),
470
+ len(world["system_variables"]),
471
+ )
472
+
473
+ if __name__ == "__main__":
474
+ run_with_keyboard_interrupt(main)
@@ -11,7 +11,6 @@ from zencontrol.api.models import ZenAddress
11
11
  from zencontrol.api.types import ZenAddressType
12
12
  from zencontrol.interface.interface import (
13
13
  ZenControl,
14
- ZenController,
15
14
  ZenLight,
16
15
  )
17
16
 
@@ -24,6 +23,34 @@ async def test_zencontrol_async_context_manager_calls_aclose() -> None:
24
23
  aclose.assert_awaited_once()
25
24
 
26
25
 
26
+ @pytest.mark.asyncio
27
+ async def test_remove_controller_closes_client_and_purges_cache() -> None:
28
+ zen = ZenControl()
29
+ ctrl_a = zen.add_controller(
30
+ id=1, name="ctrl-a", label="A", host="127.0.0.1", port=5108
31
+ )
32
+ ctrl_b = zen.add_controller(
33
+ id=2, name="ctrl-b", label="B", host="127.0.0.2", port=5108
34
+ )
35
+ fake_client = MagicMock()
36
+ fake_client.close = AsyncMock()
37
+ ctrl_a.client = fake_client
38
+ address = ZenAddress(controller=ctrl_a, type=ZenAddressType.ECG, number=1)
39
+ ZenLight(protocol=zen.protocol, address=address)
40
+ assert "ctrl-a 1" in zen.protocol.entity_registry.lights
41
+
42
+ await zen.remove_controller(ctrl_a)
43
+
44
+ fake_client.close.assert_awaited()
45
+ assert ctrl_a.client is None
46
+ assert zen.controllers == [ctrl_b]
47
+ assert "ctrl-a" not in zen.protocol.entity_registry.controllers
48
+ assert "ctrl-a 1" not in zen.protocol.entity_registry.lights
49
+ assert "ctrl-b" in zen.protocol.entity_registry.controllers
50
+
51
+ await zen.aclose()
52
+
53
+
27
54
  @pytest.mark.asyncio
28
55
  async def test_aclose_closes_clients_and_clears_instances() -> None:
29
56
  zen = ZenControl()
@@ -58,7 +58,7 @@ from .exceptions import ZenError, ZenTimeoutError, ZenResponseError, ZenConnecti
58
58
  # Utilities
59
59
  from .utils import run_with_keyboard_interrupt
60
60
 
61
- __version__ = "0.1.3"
61
+ __version__ = "0.1.4"
62
62
  __author__ = "Simon Wright"
63
63
 
64
64
  # Public API - these are the main classes users should import
@@ -87,6 +87,21 @@ class EntityRegistry:
87
87
  self.motion_sensors.clear()
88
88
  self.system_variables.clear()
89
89
 
90
+ def purge_controller(self, controller_name: str) -> None:
91
+ """Drop cached entities that belong to ``controller_name``."""
92
+ self.controllers.pop(controller_name, None)
93
+ prefix = f"{controller_name} "
94
+ for store in (
95
+ self.profiles,
96
+ self.lights,
97
+ self.groups,
98
+ self.buttons,
99
+ self.motion_sensors,
100
+ self.system_variables,
101
+ ):
102
+ for key in [k for k in store if k == controller_name or k.startswith(prefix)]:
103
+ store.pop(key, None)
104
+
90
105
 
91
106
  class ZenProtocol:
92
107
 
@@ -260,6 +275,10 @@ class ZenProtocol:
260
275
  """Drop all interface entity singletons owned by this protocol."""
261
276
  self.entity_registry.clear()
262
277
 
278
+ def purge_controller_entities(self, controller_name: str) -> None:
279
+ """Drop interface-layer singletons for one controller."""
280
+ self.entity_registry.purge_controller(controller_name)
281
+
263
282
  def track_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
264
283
  """Schedule a background task and track it for cancellation on aclose."""
265
284
  task = asyncio.create_task(coro)
@@ -10,7 +10,7 @@ from collections.abc import Coroutine, Callable, Awaitable
10
10
  from ..api import ZenProtocol, ZenController as SuperZenController, ZenAddress, ZenInstance, ZenAddressType, ZenColour, ZenColourType, ZenInstanceType
11
11
  from ..api.models import DiscoveredController
12
12
  from ..api.protocol import ZenCallbacks
13
- from ..api.types import Const
13
+ from ..api.types import Const, ZenEventMode
14
14
  from ..exceptions import ZenConnectionError
15
15
 
16
16
  """
@@ -245,6 +245,51 @@ class ZenControl:
245
245
  self.protocol.set_controllers(cast(list[SuperZenController], self.controllers))
246
246
  return controller
247
247
 
248
+ async def remove_controller(self, controller: ZenController | str) -> None:
249
+ """Detach a controller and close its command client.
250
+
251
+ Safe to call while event monitoring is running. Does not stop the shared
252
+ listener; callers that own the last controller should ``aclose()``.
253
+ """
254
+ name = controller if isinstance(controller, str) else controller.name
255
+ removed = [c for c in self.controllers if c.name == name]
256
+ self.controllers = [c for c in self.controllers if c.name != name]
257
+ self.protocol.set_controllers(cast(list[SuperZenController], self.controllers))
258
+ self.protocol.purge_controller_entities(name)
259
+ for ctrl in removed:
260
+ client = ctrl.client
261
+ ctrl.client = None
262
+ if client is None:
263
+ continue
264
+ try:
265
+ await client.close()
266
+ except Exception:
267
+ pass
268
+
269
+ async def configure_controller_events(self, controller: ZenController) -> None:
270
+ """Enable TPI event emit for one controller using this client's listen mode.
271
+
272
+ Call after ``add_controller`` when event monitoring is already running so a
273
+ newly attached controller joins the shared listener.
274
+ """
275
+ if self.protocol.event_listener is None:
276
+ return
277
+ unicast = self.protocol.unicast
278
+ await self.protocol.set_tpi_event_unicast_address(
279
+ controller,
280
+ ipaddr=self.protocol.local_ip if unicast else None,
281
+ port=self.protocol.listen_port if unicast else None,
282
+ )
283
+ await self.protocol.tpi_event_emit(
284
+ controller,
285
+ ZenEventMode(
286
+ enabled=True,
287
+ filtering=controller.filtering,
288
+ unicast=unicast,
289
+ multicast=not unicast,
290
+ ),
291
+ )
292
+
248
293
  async def discover(self, timeout: float = 5.0) -> list[DiscoveredController]:
249
294
  """Listen for multicast and return controllers identified within ``timeout`` seconds.
250
295
 
@@ -500,21 +545,23 @@ class ZenControl:
500
545
  profiles.add(profile)
501
546
  return profiles
502
547
 
503
- async def get_groups(self) -> set[ZenGroup]:
504
- """Return a set of all groups."""
548
+ async def get_groups(self, controller: ZenController | None = None) -> set[ZenGroup]:
549
+ """Return a set of all groups (optionally for one controller)."""
505
550
  groups: set[ZenGroup] = set()
506
- for controller in self.controllers:
507
- addresses = await self.protocol.query_group_numbers(controller=controller)
551
+ controllers = [controller] if controller else self.controllers
552
+ for ctrl in controllers:
553
+ addresses = await self.protocol.query_group_numbers(controller=ctrl)
508
554
  for address in addresses:
509
555
  group = await ZenGroup.create(protocol=self.protocol, address=address)
510
556
  groups.add(group)
511
557
  return groups
512
558
 
513
- async def get_lights(self) -> set[ZenLight]:
514
- """Return a set of all lights available."""
559
+ async def get_lights(self, controller: ZenController | None = None) -> set[ZenLight]:
560
+ """Return a set of all lights available (optionally for one controller)."""
515
561
  lights: set[ZenLight] = set()
516
- for controller in self.controllers:
517
- addresses = await self.protocol.query_control_gear_dali_addresses(controller=controller)
562
+ controllers = [controller] if controller else self.controllers
563
+ for ctrl in controllers:
564
+ addresses = await self.protocol.query_control_gear_dali_addresses(controller=ctrl)
518
565
  for address in addresses:
519
566
  light = await ZenLight.create(protocol=self.protocol, address=address)
520
567
  lights.add(light)
@@ -542,11 +589,12 @@ class ZenControl:
542
589
  addresses.append(addr)
543
590
  return addresses
544
591
 
545
- async def get_buttons(self) -> set[ZenButton]:
546
- """Return a set of all buttons available."""
592
+ async def get_buttons(self, controller: ZenController | None = None) -> set[ZenButton]:
593
+ """Return a set of all buttons available (optionally for one controller)."""
547
594
  buttons: set[ZenButton] = set()
548
- for controller in self.controllers:
549
- addresses = await self._get_addresses_with_instances(controller)
595
+ controllers = [controller] if controller else self.controllers
596
+ for ctrl in controllers:
597
+ addresses = await self._get_addresses_with_instances(ctrl)
550
598
  for address in addresses:
551
599
  instances = await self.protocol.query_instances_by_address(address=address)
552
600
  for instance in instances:
@@ -555,11 +603,12 @@ class ZenControl:
555
603
  buttons.add(button)
556
604
  return buttons
557
605
 
558
- async def get_motion_sensors(self) -> set[ZenMotionSensor]:
559
- """Return a set of all motion sensors available."""
606
+ async def get_motion_sensors(self, controller: ZenController | None = None) -> set[ZenMotionSensor]:
607
+ """Return a set of all motion sensors available (optionally for one controller)."""
560
608
  motion_sensors: set[ZenMotionSensor] = set()
561
- for controller in self.controllers:
562
- addresses = await self._get_addresses_with_instances(controller)
609
+ controllers = [controller] if controller else self.controllers
610
+ for ctrl in controllers:
611
+ addresses = await self._get_addresses_with_instances(ctrl)
563
612
  for address in addresses:
564
613
  instances = await self.protocol.query_instances_by_address(address=address)
565
614
  for instance in instances:
@@ -568,16 +617,21 @@ class ZenControl:
568
617
  motion_sensors.add(motion_sensor)
569
618
  return motion_sensors
570
619
 
571
- async def get_system_variables(self, give_up_after: int = 10) -> set[ZenSystemVariable]:
572
- """Return a set of all system variables. Variables must have a label. Searching will give_up_after [x] sequential IDs without a label."""
620
+ async def get_system_variables(
621
+ self,
622
+ give_up_after: int = 10,
623
+ controller: ZenController | None = None,
624
+ ) -> set[ZenSystemVariable]:
625
+ """Return labelled system variables (optionally for one controller)."""
573
626
  sysvars: set[ZenSystemVariable] = set()
574
- failed_attempts = 0
575
- for controller in self.controllers:
627
+ controllers = [controller] if controller else self.controllers
628
+ for ctrl in controllers:
629
+ failed_attempts = 0
576
630
  for variable in range(Const.MAX_SYSVAR):
577
- label = await self.protocol.query_system_variable_name(controller=controller, variable=variable)
631
+ label = await self.protocol.query_system_variable_name(controller=ctrl, variable=variable)
578
632
  if label:
579
633
  failed_attempts = 0
580
- sysvar = await ZenSystemVariable.create(protocol=self.protocol, controller=controller, id=variable, label=label)
634
+ sysvar = await ZenSystemVariable.create(protocol=self.protocol, controller=ctrl, id=variable, label=label)
581
635
  sysvars.add(sysvar)
582
636
  else:
583
637
  failed_attempts += 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zencontrol-python
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Python implementation of the Zencontrol TPI Advanced protocol for DALI lighting controllers.
5
5
  Author: Simon Wright
6
6
  License-Expression: MIT
@@ -44,6 +44,30 @@ This library has now undergone validation in multiple environments. There is an
44
44
  [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
45
45
  A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
46
46
 
47
+ ## Features
48
+
49
+ Beyond basic lighting control, this library supports:
50
+
51
+ * **Broad command surface** — inhibit, custom fade, step/up/down helpers, colour scene membership queries, EAN/serial, and most related TPI Advanced commands
52
+ * **Object-based entity model** — Optional. Expresses lights, groups, profiles, buttons, motion sensors, and system variables as rich objects with interview/discovery helpers
53
+ * **UDP transport resilience** — request retries and queue-failure backoff
54
+ * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
55
+ * **Button events** — discovery of control-device button instances, plus press and long-press event callbacks
56
+ * **Event filtering** — configure which TPI events the controller emits
57
+ * **System variables** — labelled SV discovery, read/write, and change events
58
+ * **Profiles** — query, change, and return to the scheduled profile
59
+ * **Simulator-backed tests** — protocol path exercised against [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator)
60
+
61
+ ## Known limitations
62
+
63
+ * RGB+ and XY colour commands have not been tested with hardware
64
+ * Numerical (absolute) instances have not been tested with hardware
65
+
66
+ ## Out of scope
67
+
68
+ * 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
+ * Any commands described in the documentation as "legacy" (they aren't useful)
70
+
47
71
  ## Requirements
48
72
 
49
73
  * Python 3.14 (or later)
@@ -68,16 +92,6 @@ pytest -m "not simulator"
68
92
  pytest
69
93
  ```
70
94
 
71
- ## Limitations
72
-
73
- * RGB+ and XY colour commands are not tested (I don't have any compatible lights)
74
- * Numerical (absolute) instances are not tested (I don't have any such ECDs)
75
-
76
- ## Out of scope
77
-
78
- * Any commands involving DMX, Control4, or virtual instances (I don't have licenses for any of these so I couldn't test them even if I wanted to, but the scaffolding is there if anyone wishes to add support)
79
- * Any commands described in the documentation as "legacy" (they aren't useful)
80
-
81
95
  ## TPI Advanced wishlist
82
96
 
83
97
  * 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.)_
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  examples/__init__.py
5
+ examples/dump_simulator_config.py
5
6
  examples/mqtt_bridge.py
6
7
  examples/live/controller.py
7
8
  examples/live/devices.py