zencontrol-python 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl

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.
@@ -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)
@@ -0,0 +1,48 @@
1
+ import asyncio
2
+ import yaml
3
+ from pathlib import Path
4
+ from zencontrol import ZenProtocol, ZenController, run_with_keyboard_interrupt
5
+
6
+ async def main():
7
+ """Test the async ZenProtocol with controller queries"""
8
+ # Load configuration
9
+ config = yaml.safe_load(open(Path(__file__).resolve().parents[2] / "tests" / "config.yaml"))
10
+
11
+ # Create protocol and controller
12
+ async with ZenProtocol(print_traffic=True) as tpi:
13
+ ctrl = ZenController(protocol=tpi, **config.get('zencontrol')[0])
14
+ tpi.set_controllers([ctrl])
15
+
16
+ print("Testing ZenController queries...")
17
+ print("=" * 50)
18
+
19
+ try:
20
+ # Query controller version
21
+ version = await tpi.query_controller_version_number(ctrl)
22
+ print(f"ZenController version: {version}")
23
+
24
+ # Query controller label
25
+ controller_label = await tpi.query_controller_label(ctrl)
26
+ print(f"ZenController label: {controller_label}")
27
+
28
+ # Query controller fitting number
29
+ controller_fitting_number = await tpi.query_controller_fitting_number(ctrl)
30
+ print(f"ZenController fitting number: {controller_fitting_number}")
31
+
32
+ # Query startup status
33
+ startup_complete = await tpi.query_controller_startup_complete(ctrl)
34
+ print(f"ZenController startup complete: {startup_complete}")
35
+
36
+ # Query DALI bus status
37
+ dali_ready = await tpi.query_is_dali_ready(ctrl)
38
+ print(f"DALI bus is ready: {dali_ready}")
39
+
40
+ except Exception as e:
41
+ print(f"Error during testing: {e}")
42
+
43
+ print("=" * 50)
44
+ print("Test completed!")
45
+
46
+ if __name__ == "__main__":
47
+ run_with_keyboard_interrupt(main)
48
+
@@ -0,0 +1,48 @@
1
+ import asyncio
2
+ import yaml
3
+ from pathlib import Path
4
+ from zencontrol import ZenProtocol, ZenController, run_with_keyboard_interrupt
5
+
6
+ async def main():
7
+ """Test DALI device queries"""
8
+ # Load configuration
9
+ config = yaml.safe_load(open(Path(__file__).resolve().parents[2] / "tests" / "config.yaml"))
10
+
11
+ # Create protocol and controller
12
+ async with ZenProtocol(print_traffic=False) as tpi:
13
+ ctrl = ZenController(protocol=tpi, **config.get('zencontrol')[0])
14
+ tpi.set_controllers([ctrl])
15
+
16
+ print("Testing DALI device queries...")
17
+ print("=" * 50)
18
+
19
+ try:
20
+ addresses = await tpi.query_dali_addresses_with_instances(ctrl, 0)
21
+ print(f"Addresses with instances:")
22
+
23
+ for address in addresses:
24
+ print(f" {address.number}")
25
+
26
+ label = await tpi.query_dali_device_label(address, generic_if_none=True)
27
+ print(f" label: {label}")
28
+
29
+ operating_mode = await tpi.query_operating_mode_by_address(address)
30
+ print(f" operating mode: {operating_mode}")
31
+
32
+ serial = await tpi.query_dali_serial(address)
33
+ print(f" serial: {serial}")
34
+
35
+ ean = await tpi.query_dali_ean(address)
36
+ print(f" ean: {ean}")
37
+
38
+ fitting = await tpi.query_dali_fitting_number(address)
39
+ print(f" fitting: {fitting}")
40
+
41
+ except Exception as e:
42
+ print(f"Error during testing: {e}")
43
+
44
+ print("=" * 50)
45
+ print("Test completed!")
46
+
47
+ if __name__ == "__main__":
48
+ run_with_keyboard_interrupt(main)