zencontrol-python 0.1.2__tar.gz → 0.1.4__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.4}/PKG-INFO +28 -16
  2. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/README.md +25 -11
  3. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/dump_simulator_config.py +7 -22
  4. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/interface_events.py +2 -3
  5. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/mqtt_bridge.py +7 -7
  6. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/pyproject.toml +11 -6
  7. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_lifecycle.py +28 -1
  8. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/__init__.py +1 -1
  9. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/api/models.py +118 -93
  10. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/api/protocol.py +146 -130
  11. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/api/types.py +3 -3
  12. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/exceptions.py +2 -3
  13. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/interface/interface.py +232 -175
  14. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/io/command.py +23 -23
  15. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/io/event.py +13 -14
  16. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/utils.py +4 -2
  17. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/PKG-INFO +28 -16
  18. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/LICENSE +0 -0
  19. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/__init__.py +0 -0
  20. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/controller.py +0 -0
  21. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/devices.py +0 -0
  22. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/events.py +0 -0
  23. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/gear.py +0 -0
  24. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/groups.py +0 -0
  25. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/instances.py +0 -0
  26. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/interface.py +0 -0
  27. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/leds.py +0 -0
  28. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/level_change_v2.py +0 -0
  29. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/multicast.py +0 -0
  30. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/multicast_raw.py +0 -0
  31. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/profiles.py +0 -0
  32. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/tpi_event_listener.py +0 -0
  33. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/examples/live/variables.py +0 -0
  34. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/setup.cfg +0 -0
  35. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_discovery.py +0 -0
  36. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_entity_scope.py +0 -0
  37. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_event_listener_disconnect.py +0 -0
  38. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_reconnect.py +0 -0
  39. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_sim_events.py +0 -0
  40. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_sim_interface.py +0 -0
  41. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_sim_protocol.py +0 -0
  42. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_sub_labels.py +0 -0
  43. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/tests/test_wire_resilience.py +0 -0
  44. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/api/__init__.py +0 -0
  45. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/interface/__init__.py +0 -0
  46. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/io/__init__.py +0 -0
  47. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol/py.typed +0 -0
  48. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/SOURCES.txt +0 -0
  49. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/dependency_links.txt +0 -0
  50. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/entry_points.txt +0 -0
  51. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/zencontrol_python.egg-info/requires.txt +0 -0
  52. {zencontrol_python-0.1.2 → zencontrol_python-0.1.4}/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.4
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
@@ -46,9 +44,33 @@ This library has now undergone validation in multiple environments. There is an
46
44
  [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
47
45
  A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
48
46
 
47
+ ## Features
48
+
49
+ Beyond basic lighting control, this library supports:
50
+
51
+ * **Broad command surface** — inhibit, custom fade, step/up/down helpers, colour scene membership queries, EAN/serial, and most related TPI Advanced commands
52
+ * **Object-based entity model** — Optional. Expresses lights, groups, profiles, buttons, motion sensors, and system variables as rich objects with interview/discovery helpers
53
+ * **UDP transport resilience** — request retries and queue-failure backoff
54
+ * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
55
+ * **Button events** — discovery of control-device button instances, plus press and long-press event callbacks
56
+ * **Event filtering** — configure which TPI events the controller emits
57
+ * **System variables** — labelled SV discovery, read/write, and change events
58
+ * **Profiles** — query, change, and return to the scheduled profile
59
+ * **Simulator-backed tests** — protocol path exercised against [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator)
60
+
61
+ ## Known limitations
62
+
63
+ * RGB+ and XY colour commands have not been tested with hardware
64
+ * Numerical (absolute) instances have not been tested with hardware
65
+
66
+ ## Out of scope
67
+
68
+ * 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
+ * Any commands described in the documentation as "legacy" (they aren't useful)
70
+
49
71
  ## Requirements
50
72
 
51
- * Python 3.11 (or later)
73
+ * Python 3.14 (or later)
52
74
  * Controller firmware 2.2.130 or later is strongly recommended (minimum 2.2.11 required)
53
75
 
54
76
  ## Install
@@ -70,16 +92,6 @@ pytest -m "not simulator"
70
92
  pytest
71
93
  ```
72
94
 
73
- ## Limitations
74
-
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)
77
-
78
- ## Out of scope
79
-
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)
81
- * Any commands described in the documentation as "legacy" (they aren't useful)
82
-
83
95
  ## TPI Advanced wishlist
84
96
 
85
97
  * 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.)_
@@ -10,9 +10,33 @@ This library has now undergone validation in multiple environments. There is an
10
10
  [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator), a nearly feature-complete simulator of zencontrol hardware.
11
11
  A practical demonstration is [zencontrol-homeassistant](https://github.com/sjwright/zencontrol-homeassistant), a comprehensive Home Assistant integration.
12
12
 
13
+ ## Features
14
+
15
+ Beyond basic lighting control, this library supports:
16
+
17
+ * **Broad command surface** — inhibit, custom fade, step/up/down helpers, colour scene membership queries, EAN/serial, and most related TPI Advanced commands
18
+ * **Object-based entity model** — Optional. Expresses lights, groups, profiles, buttons, motion sensors, and system variables as rich objects with interview/discovery helpers
19
+ * **UDP transport resilience** — request retries and queue-failure backoff
20
+ * **Multicast controller discovery** — find controllers on the LAN without a preconfigured host
21
+ * **Button events** — discovery of control-device button instances, plus press and long-press event callbacks
22
+ * **Event filtering** — configure which TPI events the controller emits
23
+ * **System variables** — labelled SV discovery, read/write, and change events
24
+ * **Profiles** — query, change, and return to the scheduled profile
25
+ * **Simulator-backed tests** — protocol path exercised against [zencontrol-simulator](https://github.com/sjwright/zencontrol-simulator)
26
+
27
+ ## Known limitations
28
+
29
+ * RGB+ and XY colour commands have not been tested with hardware
30
+ * Numerical (absolute) instances have not been tested with hardware
31
+
32
+ ## Out of scope
33
+
34
+ * 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)
35
+ * Any commands described in the documentation as "legacy" (they aren't useful)
36
+
13
37
  ## Requirements
14
38
 
15
- * Python 3.11 (or later)
39
+ * Python 3.14 (or later)
16
40
  * Controller firmware 2.2.130 or later is strongly recommended (minimum 2.2.11 required)
17
41
 
18
42
  ## Install
@@ -34,16 +58,6 @@ pytest -m "not simulator"
34
58
  pytest
35
59
  ```
36
60
 
37
- ## Limitations
38
-
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)
41
-
42
- ## Out of scope
43
-
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)
45
- * Any commands described in the documentation as "legacy" (they aren't useful)
46
-
47
61
  ## TPI Advanced wishlist
48
62
 
49
63
  * 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.)_
@@ -11,14 +11,12 @@ Example:
11
11
  -o ../zencontrol-simulator/config.from-live.yaml
12
12
  """
13
13
 
14
- from __future__ import annotations
15
-
16
14
  import argparse
17
15
  import asyncio
18
16
  import logging
19
17
  import sys
20
18
  from pathlib import Path
21
- from typing import Any, Optional
19
+ from typing import Any
22
20
 
23
21
  import yaml
24
22
 
@@ -42,12 +40,10 @@ INSTANCE_TYPE_NAMES = {
42
40
  ZenInstanceType.GENERAL_SENSOR: "general_sensor",
43
41
  }
44
42
 
45
-
46
43
  def _hex_int(value: int) -> str:
47
44
  return f"0x{value:X}"
48
45
 
49
-
50
- def _colour_dict(colour: Optional[ZenColour]) -> Optional[dict[str, Any]]:
46
+ def _colour_dict(colour: ZenColour | None) -> dict[str, Any] | None:
51
47
  if colour is None or colour.type is None:
52
48
  return None
53
49
  if colour.type == ZenColourType.TC:
@@ -66,32 +62,28 @@ def _colour_dict(colour: Optional[ZenColour]) -> Optional[dict[str, Any]]:
66
62
  return {"type": "xy", "x": colour.x, "y": colour.y}
67
63
  return None
68
64
 
69
-
70
- def _scene_levels(levels: list[Optional[int]] | None) -> list[Optional[int]]:
71
- out: list[Optional[int]] = [None] * 12
65
+ def _scene_levels(levels: list[int | None] | None) -> list[int | None]:
66
+ out: list[int | None] = [None] * 12
72
67
  if not levels:
73
68
  return out
74
69
  for i, level in enumerate(levels[:12]):
75
70
  out[i] = None if level is None else int(level)
76
71
  return out
77
72
 
78
-
79
- def _scene_colours(colours: list[Optional[ZenColour]] | None) -> list[Optional[dict[str, Any]]]:
80
- out: list[Optional[dict[str, Any]]] = [None] * 12
73
+ def _scene_colours(colours: list[ZenColour | None] | None) -> list[dict[str, Any] | None]:
74
+ out: list[dict[str, Any] | None] = [None] * 12
81
75
  if not colours:
82
76
  return out
83
77
  for i, colour in enumerate(colours[:12]):
84
78
  out[i] = _colour_dict(colour)
85
79
  return out
86
80
 
87
-
88
- async def _raw_byte(tpi: ZenProtocol, controller: ZenController, command: int, address: int = 0) -> Optional[int]:
81
+ async def _raw_byte(tpi: ZenProtocol, controller: ZenController, command: int, address: int = 0) -> int | None:
89
82
  response = await tpi._send_basic(controller, command, address)
90
83
  if response and len(response) >= 1:
91
84
  return int(response[0])
92
85
  return None
93
86
 
94
-
95
87
  async def dump_controller(tpi: ZenProtocol, controller: ZenController) -> dict[str, Any]:
96
88
  LOGGER.info("Querying controller %s (%s:%s)", controller.mac, controller.host, controller.port)
97
89
 
@@ -343,15 +335,12 @@ async def dump_controller(tpi: ZenProtocol, controller: ZenController) -> dict[s
343
335
 
344
336
  return world
345
337
 
346
-
347
338
  class _HexInt(int):
348
339
  """YAML representable int that dumps as 0x…."""
349
340
 
350
-
351
341
  def _represent_hex_int(dumper: yaml.Dumper, data: _HexInt) -> Any:
352
342
  return dumper.represent_scalar("tag:yaml.org,2002:int", f"0x{int(data):X}")
353
343
 
354
-
355
344
  def _prepare_for_yaml(obj: Any) -> Any:
356
345
  """Convert serial hex strings back to ints tagged for hex dump where useful."""
357
346
  if isinstance(obj, dict):
@@ -370,7 +359,6 @@ def _prepare_for_yaml(obj: Any) -> Any:
370
359
  return [_prepare_for_yaml(x) for x in obj]
371
360
  return obj
372
361
 
373
-
374
362
  def write_yaml(path: Path, world: dict[str, Any]) -> None:
375
363
  class Dumper(yaml.SafeDumper):
376
364
  pass
@@ -395,7 +383,6 @@ def write_yaml(path: Path, world: dict[str, Any]) -> None:
395
383
  width=100,
396
384
  )
397
385
 
398
-
399
386
  def build_parser() -> argparse.ArgumentParser:
400
387
  example_dir = Path(__file__).resolve().parent
401
388
  default_config = example_dir / "config.yaml"
@@ -431,7 +418,6 @@ def build_parser() -> argparse.ArgumentParser:
431
418
  )
432
419
  return parser
433
420
 
434
-
435
421
  async def main(argv: list[str] | None = None) -> None:
436
422
  args = build_parser().parse_args(argv)
437
423
  logging.basicConfig(
@@ -484,6 +470,5 @@ async def main(argv: list[str] | None = None) -> None:
484
470
  len(world["system_variables"]),
485
471
  )
486
472
 
487
-
488
473
  if __name__ == "__main__":
489
474
  run_with_keyboard_interrupt(main)
@@ -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"]
@@ -11,7 +11,6 @@ from zencontrol.api.models import ZenAddress
11
11
  from zencontrol.api.types import ZenAddressType
12
12
  from zencontrol.interface.interface import (
13
13
  ZenControl,
14
- ZenController,
15
14
  ZenLight,
16
15
  )
17
16
 
@@ -24,6 +23,34 @@ async def test_zencontrol_async_context_manager_calls_aclose() -> None:
24
23
  aclose.assert_awaited_once()
25
24
 
26
25
 
26
+ @pytest.mark.asyncio
27
+ async def test_remove_controller_closes_client_and_purges_cache() -> None:
28
+ zen = ZenControl()
29
+ ctrl_a = zen.add_controller(
30
+ id=1, name="ctrl-a", label="A", host="127.0.0.1", port=5108
31
+ )
32
+ ctrl_b = zen.add_controller(
33
+ id=2, name="ctrl-b", label="B", host="127.0.0.2", port=5108
34
+ )
35
+ fake_client = MagicMock()
36
+ fake_client.close = AsyncMock()
37
+ ctrl_a.client = fake_client
38
+ address = ZenAddress(controller=ctrl_a, type=ZenAddressType.ECG, number=1)
39
+ ZenLight(protocol=zen.protocol, address=address)
40
+ assert "ctrl-a 1" in zen.protocol.entity_registry.lights
41
+
42
+ await zen.remove_controller(ctrl_a)
43
+
44
+ fake_client.close.assert_awaited()
45
+ assert ctrl_a.client is None
46
+ assert zen.controllers == [ctrl_b]
47
+ assert "ctrl-a" not in zen.protocol.entity_registry.controllers
48
+ assert "ctrl-a 1" not in zen.protocol.entity_registry.lights
49
+ assert "ctrl-b" in zen.protocol.entity_registry.controllers
50
+
51
+ await zen.aclose()
52
+
53
+
27
54
  @pytest.mark.asyncio
28
55
  async def test_aclose_closes_clients_and_clears_instances() -> None:
29
56
  zen = ZenControl()
@@ -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.4"
62
62
  __author__ = "Simon Wright"
63
63
 
64
64
  # Public API - these are the main classes users should import