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.
- zencontrol_python-0.1.2/LICENSE +21 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/PKG-INFO +31 -14
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/README.md +29 -12
- zencontrol_python-0.1.2/examples/dump_simulator_config.py +489 -0
- zencontrol_python-0.1.0/tests/test_controller.py → zencontrol_python-0.1.2/examples/live/controller.py +2 -1
- zencontrol_python-0.1.0/tests/test_devices.py → zencontrol_python-0.1.2/examples/live/devices.py +2 -1
- zencontrol_python-0.1.0/tests/test_events.py → zencontrol_python-0.1.2/examples/live/events.py +1 -1
- zencontrol_python-0.1.0/tests/test_gear.py → zencontrol_python-0.1.2/examples/live/gear.py +2 -1
- zencontrol_python-0.1.0/tests/test_groups.py → zencontrol_python-0.1.2/examples/live/groups.py +2 -1
- zencontrol_python-0.1.0/tests/test_instances.py → zencontrol_python-0.1.2/examples/live/instances.py +2 -1
- zencontrol_python-0.1.0/tests/test_interface.py → zencontrol_python-0.1.2/examples/live/interface.py +2 -1
- zencontrol_python-0.1.0/tests/test_interface_events.py → zencontrol_python-0.1.2/examples/live/interface_events.py +2 -1
- zencontrol_python-0.1.0/tests/test_leds.py → zencontrol_python-0.1.2/examples/live/leds.py +2 -1
- zencontrol_python-0.1.0/tests/test_level_change_v2.py → zencontrol_python-0.1.2/examples/live/level_change_v2.py +1 -1
- zencontrol_python-0.1.0/tests/test_multicast.py → zencontrol_python-0.1.2/examples/live/multicast.py +2 -1
- zencontrol_python-0.1.0/tests/test_profiles.py → zencontrol_python-0.1.2/examples/live/profiles.py +2 -1
- zencontrol_python-0.1.0/tests/test_variables.py → zencontrol_python-0.1.2/examples/live/variables.py +2 -1
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/examples/mqtt_bridge.py +0 -30
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/pyproject.toml +7 -1
- zencontrol_python-0.1.2/tests/test_discovery.py +156 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_entity_scope.py +7 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_reconnect.py +42 -0
- zencontrol_python-0.1.2/tests/test_sim_events.py +206 -0
- zencontrol_python-0.1.2/tests/test_sim_interface.py +341 -0
- zencontrol_python-0.1.2/tests/test_sim_protocol.py +524 -0
- zencontrol_python-0.1.2/tests/test_wire_resilience.py +356 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/__init__.py +6 -3
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/api/__init__.py +2 -1
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/api/models.py +22 -5
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/api/protocol.py +220 -26
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/api/types.py +5 -3
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/exceptions.py +14 -2
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/interface/interface.py +60 -5
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/io/command.py +59 -35
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/io/event.py +52 -17
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/PKG-INFO +31 -14
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/SOURCES.txt +20 -15
- zencontrol_python-0.1.0/LICENSE +0 -504
- zencontrol_python-0.1.0/tests/test_wire_resilience.py +0 -178
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/examples/__init__.py +0 -0
- /zencontrol_python-0.1.0/tests/test_multicast_raw.py → /zencontrol_python-0.1.2/examples/live/multicast_raw.py +0 -0
- /zencontrol_python-0.1.0/tests/test_tpi_event_listener.py → /zencontrol_python-0.1.2/examples/live/tpi_event_listener.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/setup.cfg +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_event_listener_disconnect.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_lifecycle.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/tests/test_sub_labels.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/interface/__init__.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/io/__init__.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/py.typed +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol/utils.py +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/dependency_links.txt +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/entry_points.txt +0 -0
- {zencontrol_python-0.1.0 → zencontrol_python-0.1.2}/zencontrol_python.egg-info/requires.txt +0 -0
- {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.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
52
|
+
* Controller firmware 2.2.130 or later is strongly recommended (minimum 2.2.11 required)
|
|
51
53
|
|
|
52
54
|
## Install
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
16
|
+
* Controller firmware 2.2.130 or later is strongly recommended (minimum 2.2.11 required)
|
|
15
17
|
|
|
16
18
|
## Install
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|