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.
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/PKG-INFO +25 -11
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/README.md +24 -10
- zencontrol_python-0.1.4/examples/dump_simulator_config.py +474 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_lifecycle.py +28 -1
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/__init__.py +1 -1
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/api/protocol.py +19 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/interface/interface.py +77 -23
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/PKG-INFO +25 -11
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/SOURCES.txt +1 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/LICENSE +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/__init__.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/controller.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/devices.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/events.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/gear.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/groups.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/instances.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/interface.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/interface_events.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/leds.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/level_change_v2.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/multicast.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/multicast_raw.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/profiles.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/tpi_event_listener.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/live/variables.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/examples/mqtt_bridge.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/pyproject.toml +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/setup.cfg +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_discovery.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_entity_scope.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_event_listener_disconnect.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_reconnect.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_sim_events.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_sim_interface.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_sim_protocol.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_sub_labels.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/tests/test_wire_resilience.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/api/__init__.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/api/models.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/api/types.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/exceptions.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/interface/__init__.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/io/__init__.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/io/command.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/io/event.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/py.typed +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol/utils.py +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/dependency_links.txt +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/entry_points.txt +0 -0
- {zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/requires.txt +0 -0
- {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
|
+
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.
|
|
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
|
-
|
|
507
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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(
|
|
572
|
-
|
|
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
|
-
|
|
575
|
-
for
|
|
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=
|
|
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=
|
|
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
|
+
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.)_
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{zencontrol_python-0.1.3 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/top_level.txt
RENAMED
|
File without changes
|