zencontrol-python 0.1.0__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.
zencontrol/__init__.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ ZenControl Python Library
3
+
4
+ A Python library for interfacing with ZenControl DALI lighting controllers.
5
+
6
+ This library provides three distinct layers of abstraction:
7
+
8
+ 1. **zen_io**: Wire-level protocol implementation (UDP, message framing)
9
+ 2. **zen_api**: Zen API calls using zen_io (DALI commands, TPI protocol)
10
+ 3. **zen_interface**: Pythonic interface to Zen entities using zen_api (high-level objects)
11
+
12
+ Example usage:
13
+ import zencontrol
14
+
15
+ # High-level interface (recommended for most users)
16
+ async with zencontrol.ZenControl() as zen:
17
+ zen.add_controller(
18
+ id=1,
19
+ name="living",
20
+ label="Living Room",
21
+ host="192.168.1.100",
22
+ port=5108,
23
+ )
24
+ await zen.start()
25
+ lights = await zen.get_lights()
26
+ for light in lights:
27
+ await light.set(level=50)
28
+
29
+ # Low-level API access (for advanced users)
30
+ async with zencontrol.ZenProtocol() as protocol:
31
+ controller = zencontrol.ZenController(protocol=protocol, ...)
32
+ await protocol.dali_arc_level(address, 50)
33
+ """
34
+
35
+ # High-level interface (recommended for most users)
36
+ from .interface import (
37
+ ZenControl,
38
+ ZenController,
39
+ ZenProfile,
40
+ ZenLight,
41
+ ZenGroup,
42
+ ZenButton,
43
+ ZenMotionSensor,
44
+ ZenSystemVariable,
45
+ )
46
+
47
+ # API-level models (used by zen_api)
48
+ from .api.models import ZenAddress, ZenInstance, ZenColour
49
+ from .api.protocol import ZenProtocol
50
+
51
+ # Low-level models (used by zen_io)
52
+ from .io import ZenClient, ZenListener, ZenEvent, Request, Response, ResponseType, RequestType
53
+
54
+ # Shared types and exceptions
55
+ from .api.types import ZenAddressType, ZenInstanceType, ZenColourType, ZenEventCode, ZenEventMask, ZenEventMode
56
+ from .exceptions import ZenError, ZenTimeoutError, ZenResponseError
57
+
58
+ # Utilities
59
+ from .utils import run_with_keyboard_interrupt
60
+
61
+ __version__ = "0.1.0"
62
+ __author__ = "Simon Wright"
63
+
64
+ # Public API - these are the main classes users should import
65
+ __all__ = [
66
+ # High-level interface (recommended)
67
+ "ZenControl",
68
+
69
+ # High-level models (for most users)
70
+ "ZenController",
71
+ "ZenProfile",
72
+ "ZenLight",
73
+ "ZenGroup",
74
+ "ZenButton",
75
+ "ZenMotionSensor",
76
+ "ZenSystemVariable",
77
+
78
+ # API-level models (for advanced users)
79
+ "ZenAddress",
80
+ "ZenInstance",
81
+ "ZenProtocol",
82
+ "ZenColour",
83
+
84
+ # Low-level models (for advanced users)
85
+ "ZenClient",
86
+ "ZenListener",
87
+ "ZenEvent",
88
+ "Request",
89
+ "RequestType",
90
+ "Response",
91
+ "ResponseType",
92
+
93
+ # Exceptions
94
+ "ZenError",
95
+ "ZenTimeoutError",
96
+ "ZenResponseError",
97
+
98
+ # Types and enums
99
+ "ZenAddressType",
100
+ "ZenInstanceType",
101
+ "ZenColourType",
102
+ "ZenEventCode",
103
+ "ZenEventMask",
104
+ "ZenEventMode",
105
+
106
+ # Utilities
107
+ "run_with_keyboard_interrupt",
108
+ ]
@@ -0,0 +1,30 @@
1
+ """
2
+ API-level models and protocol implementation.
3
+
4
+ This module contains models and types that belong to the API layer:
5
+ - ZenController, ZenAddress, ZenInstance (API-level concepts)
6
+ - ZenProtocol (implements TPI commands)
7
+ - ZenColour, ZenProfile (API-level concepts used by TPI protocol)
8
+ - Types and enums used by the API layer
9
+ """
10
+
11
+ from .models import ZenController, ZenAddress, ZenInstance, ZenColour, ZenProfile
12
+ from .protocol import ZenProtocol
13
+ from .types import ZenAddressType, ZenInstanceType, ZenColourType, ZenEventMask, ZenEventMode
14
+
15
+ __all__ = [
16
+ # API-level models
17
+ "ZenController",
18
+ "ZenAddress",
19
+ "ZenInstance",
20
+ "ZenColour",
21
+ "ZenProfile",
22
+ "ZenProtocol",
23
+
24
+ # API-level types
25
+ "ZenAddressType",
26
+ "ZenInstanceType",
27
+ "ZenColourType",
28
+ "ZenEventMask",
29
+ "ZenEventMode",
30
+ ]
@@ -0,0 +1,273 @@
1
+ """
2
+ ZenControl API-level models.
3
+
4
+ This module contains models that belong to the zen_api layer:
5
+ - ZenController, ZenAddress, ZenInstance (API-level concepts)
6
+ - ZenColour, ZenProfile (API-level concepts used by TPI protocol)
7
+ - These are the core objects used by the TPI protocol
8
+ """
9
+
10
+ import logging
11
+ import socket
12
+ import struct
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Optional, Self
16
+
17
+ from ..io import ZenClient
18
+ from .types import ZenAddressType, ZenInstanceType, ZenColourType, Const
19
+
20
+
21
+ @dataclass
22
+ class ZenController:
23
+ """Represents a ZenControl controller
24
+
25
+ The 'host' field can be any resolvable hostname or IP address.
26
+ The 'ip' property will resolve the hostname to an IP address and cache it.
27
+ """
28
+ id: str
29
+ name: str
30
+ label: str
31
+ host: str
32
+ port: int
33
+ mac: Optional[str] = None
34
+ mac_bytes: Optional[bytes] = field(init=False, default=None)
35
+ # Any avoids an import cycle with protocol.py (which imports this module).
36
+ protocol: Optional[Any] = None
37
+ version: Optional[str] = None
38
+ startup_complete: bool = False
39
+ dali_ready: bool = False
40
+ filtering: bool = False
41
+ last_seen: float = field(default_factory=time.time)
42
+ client: Optional[ZenClient] = None
43
+ _ip: Optional[str] = field(init=False, repr=False, default=None)
44
+
45
+ def __post_init__(self):
46
+ self._update_mac_bytes(self.mac)
47
+
48
+ def __setattr__(self, name: str, value: object) -> None:
49
+ object.__setattr__(self, name, value)
50
+ # After init, keep mac_bytes in sync when mac is assigned.
51
+ if name == "mac" and "mac_bytes" in self.__dict__:
52
+ self._update_mac_bytes(value if isinstance(value, str) or value is None else None)
53
+
54
+ def _update_mac_bytes(self, value: Optional[str]) -> None:
55
+ """Update mac_bytes from a MAC string (or clear it)."""
56
+ if value is not None:
57
+ object.__setattr__(
58
+ self,
59
+ "mac_bytes",
60
+ bytes.fromhex(value.replace(":", "").replace("-", "")),
61
+ )
62
+ else:
63
+ object.__setattr__(self, "mac_bytes", None)
64
+
65
+ @property
66
+ def ip(self) -> str:
67
+ """Get the resolved IP address from the host field.
68
+
69
+ Resolves DNS names to IP addresses and caches the result.
70
+ If resolution fails, returns the original host value.
71
+ """
72
+ if self._ip is None:
73
+ try:
74
+ # Try to resolve the hostname to an IP address
75
+ self._ip = socket.gethostbyname(self.host)
76
+ except (socket.gaierror, socket.herror):
77
+ # If resolution fails, use the host value as-is (might already be an IP)
78
+ self._ip = self.host
79
+ return self._ip
80
+
81
+ def refresh_ip(self) -> str:
82
+ """Force a fresh DNS lookup and return the resolved IP address."""
83
+ self._ip = None
84
+ return self.ip
85
+
86
+
87
+ @dataclass
88
+ class ZenAddress:
89
+ """Represents a DALI address"""
90
+ controller: ZenController
91
+ type: ZenAddressType
92
+ number: int
93
+ label: Optional[str] = field(default=None, init=False)
94
+ serial: Optional[str] = field(default=None, init=False)
95
+
96
+ @classmethod
97
+ def broadcast(cls, controller: ZenController) -> Self:
98
+ return cls(controller=controller, type=ZenAddressType.BROADCAST, number=255)
99
+
100
+ def ecg(self) -> int:
101
+ if self.type == ZenAddressType.ECG: return self.number
102
+ raise ValueError("Address is not a Control Gear")
103
+
104
+ def ecg_or_group(self) -> int:
105
+ if self.type == ZenAddressType.ECG: return self.number
106
+ if self.type == ZenAddressType.GROUP: return self.number+64
107
+ raise ValueError("Address is not a Control Gear or Group")
108
+
109
+ def ecg_or_group_or_broadcast(self) -> int:
110
+ if self.type == ZenAddressType.ECG: return self.number
111
+ if self.type == ZenAddressType.GROUP: return self.number+64
112
+ if self.type == ZenAddressType.BROADCAST: return 255
113
+ raise ValueError("Address is not a Control Gear, Group or Broadcast")
114
+
115
+ def ecg_or_ecd(self) -> int:
116
+ if self.type == ZenAddressType.ECG: return self.number
117
+ if self.type == ZenAddressType.ECD: return self.number+64
118
+ raise ValueError("Address is not a Control Gear or Control Device")
119
+
120
+ def ecg_or_ecd_or_broadcast(self) -> int:
121
+ if self.type == ZenAddressType.ECG: return self.number
122
+ if self.type == ZenAddressType.ECD: return self.number+64
123
+ if self.type == ZenAddressType.BROADCAST: return 255
124
+ raise ValueError("Address is not a Control Gear, Control Device or Broadcast")
125
+
126
+ def ecd(self) -> int:
127
+ if self.type == ZenAddressType.ECD: return self.number+64
128
+ raise ValueError("Address is not a Control Device")
129
+
130
+ def group(self) -> int:
131
+ if self.type == ZenAddressType.GROUP: return self.number
132
+ raise ValueError("Address is not a Group")
133
+
134
+ def entity_id_string(self) -> str:
135
+ """Return a stable HA-friendly identifier for this address."""
136
+ return f"{self.type.name.casefold()}{self.number}"
137
+
138
+ def __post_init__(self):
139
+ match self.type:
140
+ case ZenAddressType.BROADCAST:
141
+ if self.number != 255:
142
+ raise ValueError("Broadcast address must be 255")
143
+ case ZenAddressType.ECG:
144
+ if not (0 <= self.number <= 63):
145
+ raise ValueError(f"ECG address must be 0-63, got {self.number}")
146
+ case ZenAddressType.ECD:
147
+ if not (0 <= self.number <= 63):
148
+ raise ValueError(f"ECD address must be 0-63, got {self.number}")
149
+ case ZenAddressType.GROUP:
150
+ if not (0 <= self.number <= 15):
151
+ raise ValueError(f"Group address must be 0-15, got {self.number}")
152
+
153
+
154
+ @dataclass
155
+ class ZenInstance:
156
+ """Represents a DALI ECD instance"""
157
+ address: ZenAddress
158
+ type: ZenInstanceType
159
+ number: int
160
+ active: Optional[bool] = None
161
+ error: Optional[bool] = None
162
+ def __post_init__(self):
163
+ if not 0 <= self.number < Const.MAX_INSTANCE:
164
+ raise ValueError(f"Instance number must be between 0 and {Const.MAX_INSTANCE-1}, received {self.number}")
165
+
166
+ def entity_id_string(self) -> str:
167
+ """Return a stable HA-friendly identifier for this instance."""
168
+ return f"{self.address.entity_id_string()}_{self.number}"
169
+
170
+
171
+ @dataclass
172
+ class ZenColour:
173
+ """Represents a DALI color"""
174
+ type: Optional[ZenColourType] = None
175
+ kelvin: Optional[int] = None
176
+ r: Optional[int] = None
177
+ g: Optional[int] = None
178
+ b: Optional[int] = None
179
+ w: Optional[int] = None
180
+ a: Optional[int] = None
181
+ f: Optional[int] = None
182
+ x: Optional[int] = None
183
+ y: Optional[int] = None
184
+
185
+ @classmethod
186
+ def from_bytes(cls, bytes: bytes) -> Optional[Self]:
187
+ if not bytes: # If bytes is empty, return None
188
+ return None
189
+ if bytes[0] == ZenColourType.RGBWAF.value and len(bytes) == 7:
190
+ return cls(type=ZenColourType.RGBWAF, r=bytes[1], g=bytes[2], b=bytes[3], w=bytes[4], a=bytes[5], f=bytes[6])
191
+ if bytes[0] == ZenColourType.TC.value and (len(bytes) == 3 or len(bytes) == 7):
192
+ kelvin = (bytes[1] << 8) | bytes[2]
193
+ return cls(type=ZenColourType.TC, kelvin=kelvin)
194
+ if bytes[0] == ZenColourType.XY.value and (len(bytes) == 5 or len(bytes) == 7):
195
+ x = (bytes[1] << 8) | bytes[2]
196
+ y = (bytes[3] << 8) | bytes[4]
197
+ return cls(type=ZenColourType.XY, x=x, y=y)
198
+ return None
199
+
200
+ def __post_init__(self):
201
+ if self.type == ZenColourType.TC:
202
+ kelvin = self.kelvin
203
+ if kelvin is None:
204
+ raise ValueError("Kelvin is required for TC colour type")
205
+ if not Const.MIN_KELVIN <= kelvin <= Const.MAX_KELVIN:
206
+ #raise ValueError(f"Kelvin must be between {Const.MIN_KELVIN} and {Const.MAX_KELVIN}, received {self.kelvin}")
207
+ logging.getLogger(__name__).warning(
208
+ "Kelvin %s out of range [%s, %s]; clamping",
209
+ kelvin, Const.MIN_KELVIN, Const.MAX_KELVIN,
210
+ )
211
+ # set to the nearest valid value
212
+ self.kelvin = max(Const.MIN_KELVIN, min(Const.MAX_KELVIN, kelvin))
213
+ if self.type == ZenColourType.RGBWAF:
214
+ r, g, b = self.r, self.g, self.b
215
+ if r is None or not 0 <= r <= 255:
216
+ raise ValueError(f"R must be between 0 and 255, received {self.r}")
217
+ if g is None or not 0 <= g <= 255:
218
+ raise ValueError(f"G must be between 0 and 255, received {self.g}")
219
+ if b is None or not 0 <= b <= 255:
220
+ raise ValueError(f"B must be between 0 and 255, received {self.b}")
221
+ if self.w is not None and not 0 <= self.w <= 255:
222
+ raise ValueError(f"W must be between 0 and 255, received {self.w}")
223
+ if self.a is not None and not 0 <= self.a <= 255:
224
+ raise ValueError(f"A must be between 0 and 255, received {self.a}")
225
+ if self.f is not None and not 0 <= self.f <= 255:
226
+ raise ValueError(f"F must be between 0 and 255, received {self.f}")
227
+ if self.type == ZenColourType.XY:
228
+ x, y = self.x, self.y
229
+ if x is None or not 0 <= x <= 65535:
230
+ raise ValueError(f"X must be between 0 and 65535, received {self.x}")
231
+ if y is None or not 0 <= y <= 65535:
232
+ raise ValueError(f"Y must be between 0 and 65535, received {self.y}")
233
+
234
+ def __repr__(self) -> str:
235
+ if self.type == ZenColourType.TC:
236
+ return f"ZenColour(kelvin={self.kelvin})"
237
+ if self.type == ZenColourType.RGBWAF:
238
+ return f"ZenColour(r={self.r}, g={self.g}, b={self.b}, w={self.w}, a={self.a}, f={self.f})"
239
+ if self.type == ZenColourType.XY:
240
+ return f"ZenColour(x={self.x}, y={self.y})"
241
+ return f"ZenColour(type={self.type})"
242
+
243
+ def __eq__(self, other):
244
+ if isinstance(other, self.__class__):
245
+ return self.__dict__ == other.__dict__
246
+ else:
247
+ return False
248
+
249
+ def to_bytes(self, level: int = 255) -> bytes:
250
+ """Encode colour data as returned by QUERY_DALI_COLOUR (no address or arc level)."""
251
+ if self.type == ZenColourType.TC:
252
+ return struct.pack('>BH', 0x20, self.kelvin)
253
+ if self.type == ZenColourType.RGBWAF:
254
+ return struct.pack('BBBBBBB', 0x80, self.r, self.g, self.b, self.w if self.w is not None else 0, self.a if self.a is not None else 0, self.f if self.f is not None else 0)
255
+ if self.type == ZenColourType.XY:
256
+ return struct.pack('>BHH', 0x10, self.x, self.y)
257
+ return b''
258
+
259
+ def command_payload(self) -> bytes:
260
+ """Colour type and channel bytes for DALI_COLOUR (follows address and arc level)."""
261
+ return self.to_bytes()
262
+
263
+
264
+ @dataclass
265
+ class ZenProfile:
266
+ """Represents a DALI profile"""
267
+ controller: ZenController
268
+ address: ZenAddress
269
+ profile: int
270
+
271
+ def __post_init__(self):
272
+ if not (0 <= self.profile <= 255):
273
+ raise ValueError(f"Profile must be 0-255, got {self.profile}")