zencontrol-python 0.1.2__tar.gz → 0.1.3__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.
Files changed (52) hide show
  1. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/PKG-INFO +4 -6
  2. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/README.md +1 -1
  3. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/interface_events.py +2 -3
  4. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/mqtt_bridge.py +7 -7
  5. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/pyproject.toml +11 -6
  6. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/__init__.py +1 -1
  7. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/api/models.py +118 -93
  8. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/api/protocol.py +127 -130
  9. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/api/types.py +3 -3
  10. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/exceptions.py +2 -3
  11. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/interface/interface.py +160 -157
  12. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/io/command.py +23 -23
  13. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/io/event.py +13 -14
  14. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/utils.py +4 -2
  15. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol_python.egg-info/PKG-INFO +4 -6
  16. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol_python.egg-info/SOURCES.txt +0 -1
  17. zencontrol_python-0.1.2/examples/dump_simulator_config.py +0 -489
  18. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/LICENSE +0 -0
  19. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/__init__.py +0 -0
  20. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/controller.py +0 -0
  21. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/devices.py +0 -0
  22. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/events.py +0 -0
  23. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/gear.py +0 -0
  24. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/groups.py +0 -0
  25. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/instances.py +0 -0
  26. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/interface.py +0 -0
  27. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/leds.py +0 -0
  28. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/level_change_v2.py +0 -0
  29. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/multicast.py +0 -0
  30. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/multicast_raw.py +0 -0
  31. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/profiles.py +0 -0
  32. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/tpi_event_listener.py +0 -0
  33. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/examples/live/variables.py +0 -0
  34. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/setup.cfg +0 -0
  35. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_discovery.py +0 -0
  36. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_entity_scope.py +0 -0
  37. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_event_listener_disconnect.py +0 -0
  38. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_lifecycle.py +0 -0
  39. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_reconnect.py +0 -0
  40. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_sim_events.py +0 -0
  41. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_sim_interface.py +0 -0
  42. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_sim_protocol.py +0 -0
  43. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_sub_labels.py +0 -0
  44. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/tests/test_wire_resilience.py +0 -0
  45. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/api/__init__.py +0 -0
  46. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/interface/__init__.py +0 -0
  47. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/io/__init__.py +0 -0
  48. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol/py.typed +0 -0
  49. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol_python.egg-info/dependency_links.txt +0 -0
  50. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol_python.egg-info/entry_points.txt +0 -0
  51. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/zencontrol_python.egg-info/requires.txt +0 -0
  52. {zencontrol_python-0.1.2 → zencontrol_python-0.1.3}/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.2
3
+ Version: 0.1.3
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
@@ -12,13 +12,11 @@ Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.11
16
- Classifier: Programming Language :: Python :: 3.12
17
- Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
18
16
  Classifier: Topic :: Home Automation
19
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
18
  Classifier: Typing :: Typed
21
- Requires-Python: >=3.11
19
+ Requires-Python: >=3.14
22
20
  Description-Content-Type: text/markdown
23
21
  License-File: LICENSE
24
22
  Requires-Dist: colorama>=0.4.6
@@ -48,7 +46,7 @@ A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwri
48
46
 
49
47
  ## Requirements
50
48
 
51
- * Python 3.11 (or later)
49
+ * Python 3.14 (or later)
52
50
  * Controller firmware 2.2.130 or later is strongly recommended (minimum 2.2.11 required)
53
51
 
54
52
  ## Install
@@ -12,7 +12,7 @@ A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwri
12
12
 
13
13
  ## Requirements
14
14
 
15
- * Python 3.11 (or later)
15
+ * Python 3.14 (or later)
16
16
  * Controller firmware 2.2.130 or later is strongly recommended (minimum 2.2.11 required)
17
17
 
18
18
  ## Install
@@ -3,7 +3,6 @@ from zencontrol import ZenControl, ZenProfile, ZenGroup, ZenLight, ZenButton, Ze
3
3
  import yaml
4
4
  from pathlib import Path
5
5
  import time
6
- from typing import Optional
7
6
 
8
7
  async def main():
9
8
  config = yaml.safe_load(open(Path(__file__).resolve().parents[2] / "tests" / "config.yaml"))
@@ -21,11 +20,11 @@ async def main():
21
20
  ms()
22
21
  print(f"Profile Change Event - {profile}")
23
22
 
24
- async def _zen_group_change(group: ZenGroup, level: Optional[int] = None, colour: Optional[ZenColour] = None, scene: Optional[int] = None, discoordinated: bool = False) -> None:
23
+ async def _zen_group_change(group: ZenGroup, level: int | None = None, colour: ZenColour | None = None, scene: int | None = None, discoordinated: bool = False) -> None:
25
24
  ms()
26
25
  print(f"Group Change Event - {group} level {level} colour {colour} scene {scene} {'discoordinated' if discoordinated else ''}")
27
26
 
28
- async def _zen_light_change(light: ZenLight, level: Optional[int] = None, colour: Optional[ZenColour] = None, scene: Optional[int] = None) -> None:
27
+ async def _zen_light_change(light: ZenLight, level: int | None = None, colour: ZenColour | None = None, scene: int | None = None) -> None:
29
28
  ms()
30
29
  print(f"Light Change Event - {light} level {level} colour {colour} scene {scene}")
31
30
 
@@ -5,7 +5,7 @@ import json
5
5
  import yaml
6
6
  import re
7
7
  from pathlib import Path
8
- from typing import Optional, Any
8
+ from typing import Any
9
9
  import zencontrol
10
10
  from zencontrol import ZenController, ZenProtocol, ZenClient, ZenColour, ZenColourType, ZenProfile, ZenLight, ZenGroup, ZenButton, ZenMotionSensor, ZenSystemVariable, ZenTimeoutError, ZenAddressType
11
11
  from zencontrol.api.types import Const as ApiConst
@@ -814,9 +814,9 @@ class ZenMQTTBridge:
814
814
  async def _mqtt_light_change(self, light: ZenLight|ZenGroup, payload: dict[str, Any]) -> None:
815
815
  addr = light.address
816
816
  ctrl = addr.controller
817
- state: Optional[str] = payload.get("state", None)
818
- brightness: Optional[int] = payload.get("brightness", None)
819
- mireds: Optional[int] = payload.get("color_temp", None)
817
+ state: str | None = payload.get("state", None)
818
+ brightness: int | None = payload.get("brightness", None)
819
+ mireds: int | None = payload.get("color_temp", None)
820
820
 
821
821
  # If brightness or temperature is set
822
822
  if brightness is not None or mireds is not None:
@@ -835,7 +835,7 @@ class ZenMQTTBridge:
835
835
  self.logger.info(f"♥️💡 Command from HA: {ctrl.name} turning gear {addr.number} ON")
836
836
  await light.on()
837
837
 
838
- async def _zen_light_change(self, light: ZenLight, level: Optional[int] = None, colour: Optional[ZenColour] = None, scene: Optional[int] = None) -> None:
838
+ async def _zen_light_change(self, light: ZenLight, level: int | None = None, colour: ZenColour | None = None, scene: int | None = None) -> None:
839
839
  typestr = "group" if light.address.type == ZenAddressType.GROUP else "light"
840
840
  emoji = "👥" if light.address.type == ZenAddressType.GROUP else "💡"
841
841
  self.logger.info(f"🩵{emoji} Event from Zen: {typestr} {light.address.number} level {level if level is not None else '--'} colour {colour if colour is not None else '--'} scene {scene if scene is not None else '--'}")
@@ -930,7 +930,7 @@ class ZenMQTTBridge:
930
930
 
931
931
  # mqtt group light change calls _mqtt_light_change
932
932
 
933
- async def _zen_group_change(self, group: ZenGroup, level: Optional[int] = None, colour: Optional[ZenColour] = None, scene: Optional[int] = None, discoordinated: Optional[bool] = None) -> None:
933
+ async def _zen_group_change(self, group: ZenGroup, level: int | None = None, colour: ZenColour | None = None, scene: int | None = None, discoordinated: bool | None = None) -> None:
934
934
  select_mqtt_topic = group.client_data.get("select", {}).get('mqtt_topic', None)
935
935
 
936
936
  # Get the scene label for the ID from the group
@@ -1109,4 +1109,4 @@ async def main():
1109
1109
  await bridge.stop()
1110
1110
 
1111
1111
  if __name__ == "__main__":
1112
- asyncio.run(main())
1112
+ asyncio.run(main())
@@ -7,7 +7,7 @@ name = "zencontrol-python"
7
7
  dynamic = ["version"]
8
8
  description = "Python implementation of the Zencontrol TPI Advanced protocol for DALI lighting controllers."
9
9
  readme = "README.md"
10
- requires-python = ">=3.11"
10
+ requires-python = ">=3.14"
11
11
  license = "MIT"
12
12
  license-files = ["LICENSE"]
13
13
  authors = [
@@ -19,9 +19,7 @@ classifiers = [
19
19
  "Intended Audience :: Developers",
20
20
  "Operating System :: OS Independent",
21
21
  "Programming Language :: Python :: 3",
22
- "Programming Language :: Python :: 3.11",
23
- "Programming Language :: Python :: 3.12",
24
- "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
25
23
  "Topic :: Home Automation",
26
24
  "Topic :: Software Development :: Libraries :: Python Modules",
27
25
  "Typing :: Typed",
@@ -67,7 +65,7 @@ zencontrol = ["py.typed"]
67
65
 
68
66
  [tool.black]
69
67
  line-length = 88
70
- target-version = ['py311']
68
+ target-version = ['py314']
71
69
  include = '\.pyi?$'
72
70
  extend-exclude = '''
73
71
  /(
@@ -84,7 +82,7 @@ extend-exclude = '''
84
82
  '''
85
83
 
86
84
  [tool.mypy]
87
- python_version = "3.11"
85
+ python_version = "3.14"
88
86
  warn_return_any = true
89
87
  warn_unused_configs = true
90
88
  disallow_untyped_defs = true
@@ -108,3 +106,10 @@ asyncio_mode = "auto"
108
106
  markers = [
109
107
  "simulator: integration tests that start zencontrol-simulator (installed or sibling ../zencontrol-simulator checkout)",
110
108
  ]
109
+
110
+ [tool.ruff]
111
+ target-version = "py314"
112
+ line-length = 88
113
+
114
+ [tool.ruff.lint]
115
+ select = ["ASYNC", "B", "E", "F", "I", "UP"]
@@ -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.2"
61
+ __version__ = "0.1.3"
62
62
  __author__ = "Simon Wright"
63
63
 
64
64
  # Public API - these are the main classes users should import
@@ -12,7 +12,7 @@ import socket
12
12
  import struct
13
13
  import time
14
14
  from dataclasses import dataclass, field
15
- from typing import Any, Optional, Self
15
+ from typing import Any, Self
16
16
 
17
17
  from ..io import ZenClient
18
18
  from .types import ZenAddressType, ZenInstanceType, ZenColourType, Const
@@ -21,13 +21,13 @@ from .types import ZenAddressType, ZenInstanceType, ZenColourType, Const
21
21
  DEFAULT_CONTROLLER_PORT = 5108
22
22
 
23
23
 
24
- @dataclass(frozen=True)
24
+ @dataclass(frozen=True, slots=True)
25
25
  class DiscoveredController:
26
26
  """A controller identified from multicast events (not yet registered)."""
27
27
 
28
28
  host: str
29
29
  mac: str
30
- label: Optional[str] = None
30
+ label: str | None = None
31
31
  port: int = DEFAULT_CONTROLLER_PORT
32
32
 
33
33
 
@@ -43,17 +43,17 @@ class ZenController:
43
43
  label: str
44
44
  host: str
45
45
  port: int
46
- mac: Optional[str] = None
47
- mac_bytes: Optional[bytes] = field(init=False, default=None)
46
+ mac: str | None = None
47
+ mac_bytes: bytes | None = field(init=False, default=None)
48
48
  # Any avoids an import cycle with protocol.py (which imports this module).
49
- protocol: Optional[Any] = None
50
- version: Optional[str] = None
49
+ protocol: Any | None = None
50
+ version: str | None = None
51
51
  startup_complete: bool = False
52
52
  dali_ready: bool = False
53
53
  filtering: bool = False
54
54
  last_seen: float = field(default_factory=time.time)
55
- client: Optional[ZenClient] = None
56
- _ip: Optional[str] = field(init=False, repr=False, default=None)
55
+ client: ZenClient | None = None
56
+ _ip: str | None = field(init=False, repr=False, default=None)
57
57
 
58
58
  def __post_init__(self):
59
59
  self._update_mac_bytes(self.mac)
@@ -64,7 +64,7 @@ class ZenController:
64
64
  if name == "mac" and "mac_bytes" in self.__dict__:
65
65
  self._update_mac_bytes(value if isinstance(value, str) or value is None else None)
66
66
 
67
- def _update_mac_bytes(self, value: Optional[str]) -> None:
67
+ def _update_mac_bytes(self, value: str | None) -> None:
68
68
  """Update mac_bytes from a MAC string (or clear it)."""
69
69
  if value is not None:
70
70
  try:
@@ -101,14 +101,14 @@ class ZenController:
101
101
  return self.ip
102
102
 
103
103
 
104
- @dataclass
104
+ @dataclass(slots=True)
105
105
  class ZenAddress:
106
106
  """Represents a DALI address"""
107
107
  controller: ZenController
108
108
  type: ZenAddressType
109
109
  number: int
110
- label: Optional[str] = field(default=None, init=False)
111
- serial: Optional[str] = field(default=None, init=False)
110
+ label: str | None = field(default=None, init=False)
111
+ serial: str | None = field(default=None, init=False)
112
112
 
113
113
  @classmethod
114
114
  def broadcast(cls, controller: ZenController) -> Self:
@@ -168,14 +168,14 @@ class ZenAddress:
168
168
  raise ValueError(f"Group address must be 0-15, got {self.number}")
169
169
 
170
170
 
171
- @dataclass
171
+ @dataclass(slots=True)
172
172
  class ZenInstance:
173
173
  """Represents a DALI ECD instance"""
174
174
  address: ZenAddress
175
175
  type: ZenInstanceType
176
176
  number: int
177
- active: Optional[bool] = None
178
- error: Optional[bool] = None
177
+ active: bool | None = None
178
+ error: bool | None = None
179
179
  def __post_init__(self):
180
180
  if not 0 <= self.number < Const.MAX_INSTANCE:
181
181
  raise ValueError(f"Instance number must be between 0 and {Const.MAX_INSTANCE-1}, received {self.number}")
@@ -185,100 +185,125 @@ class ZenInstance:
185
185
  return f"{self.address.entity_id_string()}_{self.number}"
186
186
 
187
187
 
188
- @dataclass
188
+ @dataclass(slots=True)
189
189
  class ZenColour:
190
190
  """Represents a DALI color"""
191
- type: Optional[ZenColourType] = None
192
- kelvin: Optional[int] = None
193
- r: Optional[int] = None
194
- g: Optional[int] = None
195
- b: Optional[int] = None
196
- w: Optional[int] = None
197
- a: Optional[int] = None
198
- f: Optional[int] = None
199
- x: Optional[int] = None
200
- y: Optional[int] = None
191
+ type: ZenColourType | None = None
192
+ kelvin: int | None = None
193
+ r: int | None = None
194
+ g: int | None = None
195
+ b: int | None = None
196
+ w: int | None = None
197
+ a: int | None = None
198
+ f: int | None = None
199
+ x: int | None = None
200
+ y: int | None = None
201
201
 
202
202
  @classmethod
203
- def from_bytes(cls, bytes: bytes) -> Optional[Self]:
204
- if not bytes: # If bytes is empty, return None
205
- return None
206
- if bytes[0] == ZenColourType.RGBWAF.value and len(bytes) == 7:
207
- return cls(type=ZenColourType.RGBWAF, r=bytes[1], g=bytes[2], b=bytes[3], w=bytes[4], a=bytes[5], f=bytes[6])
208
- if bytes[0] == ZenColourType.TC.value and (len(bytes) == 3 or len(bytes) == 7):
209
- kelvin = (bytes[1] << 8) | bytes[2]
210
- return cls(type=ZenColourType.TC, kelvin=kelvin)
211
- if bytes[0] == ZenColourType.XY.value and (len(bytes) == 5 or len(bytes) == 7):
212
- x = (bytes[1] << 8) | bytes[2]
213
- y = (bytes[3] << 8) | bytes[4]
214
- return cls(type=ZenColourType.XY, x=x, y=y)
215
- return None
203
+ def from_bytes(cls, data: bytes) -> Self | None:
204
+ match list(data):
205
+ case [ZenColourType.RGBWAF.value, r, g, b, w, a, f]:
206
+ return cls(type=ZenColourType.RGBWAF, r=r, g=g, b=b, w=w, a=a, f=f)
207
+ case [ZenColourType.TC.value, hi, lo] | [ZenColourType.TC.value, hi, lo, *_]:
208
+ if len(data) not in (3, 7):
209
+ return None
210
+ return cls(type=ZenColourType.TC, kelvin=(hi << 8) | lo)
211
+ case [ZenColourType.XY.value, xh, xl, yh, yl] | [ZenColourType.XY.value, xh, xl, yh, yl, *_]:
212
+ if len(data) not in (5, 7):
213
+ return None
214
+ return cls(type=ZenColourType.XY, x=(xh << 8) | xl, y=(yh << 8) | yl)
215
+ case _:
216
+ return None
216
217
 
217
218
  def __post_init__(self):
218
- if self.type == ZenColourType.TC:
219
- kelvin = self.kelvin
220
- if kelvin is None:
221
- raise ValueError("Kelvin is required for TC colour type")
222
- if not Const.MIN_KELVIN <= kelvin <= Const.MAX_KELVIN:
223
- #raise ValueError(f"Kelvin must be between {Const.MIN_KELVIN} and {Const.MAX_KELVIN}, received {self.kelvin}")
224
- logging.getLogger(__name__).warning(
225
- "Kelvin %s out of range [%s, %s]; clamping",
226
- kelvin, Const.MIN_KELVIN, Const.MAX_KELVIN,
227
- )
228
- # set to the nearest valid value
229
- self.kelvin = max(Const.MIN_KELVIN, min(Const.MAX_KELVIN, kelvin))
230
- if self.type == ZenColourType.RGBWAF:
231
- r, g, b = self.r, self.g, self.b
232
- if r is None or not 0 <= r <= 255:
233
- raise ValueError(f"R must be between 0 and 255, received {self.r}")
234
- if g is None or not 0 <= g <= 255:
235
- raise ValueError(f"G must be between 0 and 255, received {self.g}")
236
- if b is None or not 0 <= b <= 255:
237
- raise ValueError(f"B must be between 0 and 255, received {self.b}")
238
- if self.w is not None and not 0 <= self.w <= 255:
239
- raise ValueError(f"W must be between 0 and 255, received {self.w}")
240
- if self.a is not None and not 0 <= self.a <= 255:
241
- raise ValueError(f"A must be between 0 and 255, received {self.a}")
242
- if self.f is not None and not 0 <= self.f <= 255:
243
- raise ValueError(f"F must be between 0 and 255, received {self.f}")
244
- if self.type == ZenColourType.XY:
245
- x, y = self.x, self.y
246
- if x is None or not 0 <= x <= 65535:
247
- raise ValueError(f"X must be between 0 and 65535, received {self.x}")
248
- if y is None or not 0 <= y <= 65535:
249
- raise ValueError(f"Y must be between 0 and 65535, received {self.y}")
219
+ match self.type:
220
+ case ZenColourType.TC:
221
+ kelvin = self.kelvin
222
+ if kelvin is None:
223
+ raise ValueError("Kelvin is required for TC colour type")
224
+ if not Const.MIN_KELVIN <= kelvin <= Const.MAX_KELVIN:
225
+ logging.getLogger(__name__).warning(
226
+ "Kelvin %s out of range [%s, %s]; clamping",
227
+ kelvin, Const.MIN_KELVIN, Const.MAX_KELVIN,
228
+ )
229
+ self.kelvin = max(Const.MIN_KELVIN, min(Const.MAX_KELVIN, kelvin))
230
+ case ZenColourType.RGBWAF:
231
+ r, g, b = self.r, self.g, self.b
232
+ if r is None or not 0 <= r <= 255:
233
+ raise ValueError(f"R must be between 0 and 255, received {self.r}")
234
+ if g is None or not 0 <= g <= 255:
235
+ raise ValueError(f"G must be between 0 and 255, received {self.g}")
236
+ if b is None or not 0 <= b <= 255:
237
+ raise ValueError(f"B must be between 0 and 255, received {self.b}")
238
+ if self.w is not None and not 0 <= self.w <= 255:
239
+ raise ValueError(f"W must be between 0 and 255, received {self.w}")
240
+ if self.a is not None and not 0 <= self.a <= 255:
241
+ raise ValueError(f"A must be between 0 and 255, received {self.a}")
242
+ if self.f is not None and not 0 <= self.f <= 255:
243
+ raise ValueError(f"F must be between 0 and 255, received {self.f}")
244
+ case ZenColourType.XY:
245
+ x, y = self.x, self.y
246
+ if x is None or not 0 <= x <= 65535:
247
+ raise ValueError(f"X must be between 0 and 65535, received {self.x}")
248
+ if y is None or not 0 <= y <= 65535:
249
+ raise ValueError(f"Y must be between 0 and 65535, received {self.y}")
250
+ case _:
251
+ pass
250
252
 
251
253
  def __repr__(self) -> str:
252
- if self.type == ZenColourType.TC:
253
- return f"ZenColour(kelvin={self.kelvin})"
254
- if self.type == ZenColourType.RGBWAF:
255
- return f"ZenColour(r={self.r}, g={self.g}, b={self.b}, w={self.w}, a={self.a}, f={self.f})"
256
- if self.type == ZenColourType.XY:
257
- return f"ZenColour(x={self.x}, y={self.y})"
258
- return f"ZenColour(type={self.type})"
259
-
260
- def __eq__(self, other):
261
- if isinstance(other, self.__class__):
262
- return self.__dict__ == other.__dict__
263
- else:
264
- return False
254
+ match self.type:
255
+ case ZenColourType.TC:
256
+ return f"ZenColour(kelvin={self.kelvin})"
257
+ case ZenColourType.RGBWAF:
258
+ return f"ZenColour(r={self.r}, g={self.g}, b={self.b}, w={self.w}, a={self.a}, f={self.f})"
259
+ case ZenColourType.XY:
260
+ return f"ZenColour(x={self.x}, y={self.y})"
261
+ case _:
262
+ return f"ZenColour(type={self.type})"
263
+
264
+ def __eq__(self, other: object) -> bool:
265
+ if not isinstance(other, ZenColour):
266
+ return NotImplemented
267
+ return (
268
+ self.type == other.type
269
+ and self.kelvin == other.kelvin
270
+ and self.r == other.r
271
+ and self.g == other.g
272
+ and self.b == other.b
273
+ and self.w == other.w
274
+ and self.a == other.a
275
+ and self.f == other.f
276
+ and self.x == other.x
277
+ and self.y == other.y
278
+ )
265
279
 
266
280
  def to_bytes(self, level: int = 255) -> bytes:
267
281
  """Encode colour data as returned by QUERY_DALI_COLOUR (no address or arc level)."""
268
- if self.type == ZenColourType.TC:
269
- return struct.pack('>BH', 0x20, self.kelvin)
270
- if self.type == ZenColourType.RGBWAF:
271
- 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)
272
- if self.type == ZenColourType.XY:
273
- return struct.pack('>BHH', 0x10, self.x, self.y)
274
- return b''
282
+ match self.type:
283
+ case ZenColourType.TC:
284
+ return struct.pack(">BH", 0x20, self.kelvin)
285
+ case ZenColourType.RGBWAF:
286
+ return struct.pack(
287
+ "BBBBBBB",
288
+ 0x80,
289
+ self.r,
290
+ self.g,
291
+ self.b,
292
+ self.w if self.w is not None else 0,
293
+ self.a if self.a is not None else 0,
294
+ self.f if self.f is not None else 0,
295
+ )
296
+ case ZenColourType.XY:
297
+ return struct.pack(">BHH", 0x10, self.x, self.y)
298
+ case _:
299
+ return b""
275
300
 
276
301
  def command_payload(self) -> bytes:
277
302
  """Colour type and channel bytes for DALI_COLOUR (follows address and arc level)."""
278
303
  return self.to_bytes()
279
304
 
280
305
 
281
- @dataclass
306
+ @dataclass(slots=True)
282
307
  class ZenProfile:
283
308
  """Represents a DALI profile"""
284
309
  controller: ZenController