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.
- examples/__init__.py +0 -0
- examples/mqtt_bridge.py +1142 -0
- zencontrol/__init__.py +108 -0
- zencontrol/api/__init__.py +30 -0
- zencontrol/api/models.py +273 -0
- zencontrol/api/protocol.py +1700 -0
- zencontrol/api/types.py +203 -0
- zencontrol/exceptions.py +30 -0
- zencontrol/interface/__init__.py +33 -0
- zencontrol/interface/interface.py +1579 -0
- zencontrol/io/__init__.py +24 -0
- zencontrol/io/command.py +387 -0
- zencontrol/io/event.py +316 -0
- zencontrol/py.typed +0 -0
- zencontrol/utils.py +67 -0
- zencontrol_python-0.1.0.dist-info/METADATA +79 -0
- zencontrol_python-0.1.0.dist-info/RECORD +21 -0
- zencontrol_python-0.1.0.dist-info/WHEEL +5 -0
- zencontrol_python-0.1.0.dist-info/entry_points.txt +2 -0
- zencontrol_python-0.1.0.dist-info/licenses/LICENSE +504 -0
- zencontrol_python-0.1.0.dist-info/top_level.txt +2 -0
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
|
+
]
|
zencontrol/api/models.py
ADDED
|
@@ -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}")
|