python-rako-2025 0.3.7__tar.gz → 0.3.9__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 (23) hide show
  1. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/PKG-INFO +1 -1
  2. python_rako_2025-0.3.9/python_rako/__version__.py +1 -0
  3. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako/bridge.py +56 -36
  4. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako_2025.egg-info/PKG-INFO +1 -1
  5. python_rako_2025-0.3.7/python_rako/__version__.py +0 -1
  6. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/LICENSE +0 -0
  7. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/README.md +0 -0
  8. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/pyproject.toml +0 -0
  9. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako/__init__.py +0 -0
  10. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako/const.py +0 -0
  11. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako/exceptions.py +0 -0
  12. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako/helpers.py +0 -0
  13. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako/model.py +0 -0
  14. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako/py.typed +0 -0
  15. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako_2025.egg-info/SOURCES.txt +0 -0
  16. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako_2025.egg-info/dependency_links.txt +0 -0
  17. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako_2025.egg-info/requires.txt +0 -0
  18. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/python_rako_2025.egg-info/top_level.txt +0 -0
  19. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/setup.cfg +0 -0
  20. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/setup.py +0 -0
  21. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/tests/test_bridge.py +0 -0
  22. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/tests/test_helpers.py +0 -0
  23. {python_rako_2025-0.3.7 → python_rako_2025-0.3.9}/tests/test_model.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-rako-2025
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: Asynchronous Python client for Rako Controls Lighting
5
5
  Author-email: Simon Leigh <simonleigh@users.noreply.github.com>
6
6
  Project-URL: Homepage, https://github.com/simonleigh/python-rako
@@ -0,0 +1 @@
1
+ 0.3.9
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import threading
5
6
  from collections.abc import AsyncGenerator, Generator
6
7
  from typing import Any
7
8
 
@@ -32,15 +33,16 @@ from python_rako.model import (
32
33
  CommandUDP,
33
34
  EOFResponse,
34
35
  LevelCache,
35
- Light,
36
36
  RoomLight,
37
37
  RoomVentilation,
38
38
  SceneCache,
39
- Ventilation,
40
39
  )
41
40
 
42
41
  _LOGGER = logging.getLogger(__name__)
43
42
 
43
+ # Thread lock for XML parsing to ensure concurrency safety
44
+ _XML_PARSE_LOCK = threading.Lock()
45
+
44
46
 
45
47
  class _BridgeCommander:
46
48
  def __init__(self, host: str, port: int):
@@ -150,64 +152,76 @@ class Bridge:
150
152
  )
151
153
  self.level_cache: LevelCache = LevelCache()
152
154
  self.scene_cache: SceneCache = SceneCache()
155
+ self._cached_xml: str | None = None
153
156
 
154
157
  @property
155
158
  def _discovery_url(self) -> str:
156
159
  return f"http://{self.host}/rako.xml"
157
160
 
158
- async def get_rako_xml(self, session: aiohttp.ClientSession) -> str:
159
- async with session.get(self._discovery_url) as response:
160
- rako_xml: str = await response.text()
161
- return rako_xml
161
+ async def get_rako_xml(
162
+ self, session: aiohttp.ClientSession, force_refresh: bool = False
163
+ ) -> str:
164
+ if self._cached_xml is None or force_refresh:
165
+ async with session.get(self._discovery_url) as response:
166
+ self._cached_xml = await response.text()
167
+ assert self._cached_xml is not None
168
+ return self._cached_xml
162
169
 
163
170
  async def discover_devices(
164
- self, session: aiohttp.ClientSession
165
- ) -> tuple[list[Light], list[Ventilation]]:
171
+ self, session: aiohttp.ClientSession, force_refresh: bool = False
172
+ ) -> tuple[
173
+ list[RoomLight | ChannelLight], list[RoomVentilation | ChannelVentilation]
174
+ ]:
166
175
  """Discover all devices by fetching XML once and parsing all device types.
167
176
 
168
177
  Returns a tuple of (lights, ventilation) to avoid race conditions.
169
178
  """
170
- rako_xml = await self.get_rako_xml(session)
179
+ rako_xml = await self.get_rako_xml(session, force_refresh)
171
180
 
172
- lights: list[Light] = []
173
- ventilation: list[Ventilation] = []
181
+ lights: list[RoomLight | ChannelLight] = []
182
+ ventilation: list[RoomVentilation | ChannelVentilation] = []
174
183
 
175
184
  for device in self.get_devices_from_discovery_xml(rako_xml):
176
185
  if isinstance(device, (RoomLight, ChannelLight)):
177
- lights.append(device) # type: ignore[arg-type]
186
+ lights.append(device)
178
187
  elif isinstance(device, (RoomVentilation, ChannelVentilation)):
179
- ventilation.append(device) # type: ignore[arg-type]
188
+ ventilation.append(device)
180
189
 
181
190
  return lights, ventilation
182
191
 
183
192
  async def discover_lights(
184
- self, session: aiohttp.ClientSession
185
- ) -> AsyncGenerator[RoomLight | ChannelLight, None]:
193
+ self, session: aiohttp.ClientSession, force_refresh: bool = False
194
+ ) -> AsyncGenerator[RoomLight | ChannelLight]:
186
195
  """Discover lights by fetching XML once and filtering for lights."""
187
- rako_xml = await self.get_rako_xml(session)
188
- for light in self.get_lights_from_discovery_xml(rako_xml):
196
+ lights, _ = await self.discover_devices(session, force_refresh)
197
+ for light in lights:
189
198
  yield light
190
199
 
191
200
  async def discover_ventilation(
192
- self, session: aiohttp.ClientSession
193
- ) -> AsyncGenerator[RoomVentilation | ChannelVentilation, None]:
201
+ self, session: aiohttp.ClientSession, force_refresh: bool = False
202
+ ) -> AsyncGenerator[RoomVentilation | ChannelVentilation]:
194
203
  """Discover ventilation by fetching XML once and filtering for ventilation."""
195
- rako_xml = await self.get_rako_xml(session)
196
- for device in self.get_devices_from_discovery_xml(rako_xml, "Ventilation"):
197
- if isinstance(device, (RoomVentilation, ChannelVentilation)):
198
- yield device
204
+ _, ventilation = await self.discover_devices(session, force_refresh)
205
+ for vent in ventilation:
206
+ yield vent
199
207
 
200
208
  async def discover_all_devices(
201
- self, session: aiohttp.ClientSession
202
- ) -> AsyncGenerator[RoomLight | ChannelLight | RoomVentilation | ChannelVentilation, None]:
209
+ self, session: aiohttp.ClientSession, force_refresh: bool = False
210
+ ) -> AsyncGenerator[
211
+ RoomLight | ChannelLight | RoomVentilation | ChannelVentilation
212
+ ]:
203
213
  """Discover all devices by fetching XML once."""
204
- rako_xml = await self.get_rako_xml(session)
205
- for device in self.get_devices_from_discovery_xml(rako_xml):
206
- yield device
214
+ lights, ventilation = await self.discover_devices(session, force_refresh)
215
+ for light in lights:
216
+ yield light
217
+ for vent in ventilation:
218
+ yield vent
207
219
 
208
- async def get_info(self, session: aiohttp.ClientSession) -> BridgeInfo:
220
+ async def get_info(
221
+ self, session: aiohttp.ClientSession, force_refresh: bool = False
222
+ ) -> BridgeInfo:
209
223
  try:
210
- rako_xml = await self.get_rako_xml(session)
224
+ rako_xml = await self.get_rako_xml(session, force_refresh)
211
225
  info = self.get_bridge_info_from_discovery_xml(rako_xml)
212
226
  except (KeyError, ValueError) as ex:
213
227
  raise RakoBridgeError(f"unsupported bridge: {ex}")
@@ -217,7 +231,8 @@ class Bridge:
217
231
 
218
232
  @staticmethod
219
233
  def get_bridge_info_from_discovery_xml(xml: str) -> BridgeInfo:
220
- xml_dict = xmltodict.parse(xml)
234
+ with _XML_PARSE_LOCK:
235
+ xml_dict = xmltodict.parse(xml)
221
236
  info = xml_dict["rako"].get("info", dict())
222
237
  config = xml_dict["rako"].get("config", dict())
223
238
  return BridgeInfo(
@@ -234,20 +249,24 @@ class Bridge:
234
249
  )
235
250
 
236
251
  @staticmethod
237
- def get_lights_from_discovery_xml(xml: str) -> Generator[RoomLight | ChannelLight, None, None]:
252
+ def get_lights_from_discovery_xml(
253
+ xml: str,
254
+ ) -> Generator[RoomLight | ChannelLight]:
238
255
  for device in Bridge.get_devices_from_discovery_xml(xml, "Lights"):
239
256
  if isinstance(device, (RoomLight, ChannelLight)):
240
257
  yield device
241
258
 
242
259
  @staticmethod
243
- def get_all_devices_from_discovery_xml(xml: str) -> Generator[RoomLight | ChannelLight | RoomVentilation | ChannelVentilation, None, None]:
260
+ def get_all_devices_from_discovery_xml(
261
+ xml: str,
262
+ ) -> Generator[RoomLight | ChannelLight | RoomVentilation | ChannelVentilation]:
244
263
  """Get all devices (lights and ventilation) from discovery XML."""
245
264
  return Bridge.get_devices_from_discovery_xml(xml)
246
265
 
247
266
  @staticmethod
248
267
  def get_devices_from_discovery_xml(
249
268
  xml: str, device_types: str | list[str] | None = None
250
- ) -> Generator[RoomLight | ChannelLight | RoomVentilation | ChannelVentilation, None, None]:
269
+ ) -> Generator[RoomLight | ChannelLight | RoomVentilation | ChannelVentilation]:
251
270
  # Handle different input types for backward compatibility
252
271
  if device_types is None or device_types == "All":
253
272
  target_types = {"Lights", "Ventilation"}
@@ -256,7 +275,8 @@ class Bridge:
256
275
  else:
257
276
  target_types = set(device_types)
258
277
 
259
- xml_dict = xmltodict.parse(xml, force_list={"Room"})
278
+ with _XML_PARSE_LOCK:
279
+ xml_dict = xmltodict.parse(xml, force_list={"Room"})
260
280
  for room in xml_dict["rako"]["rooms"]["Room"]:
261
281
  room_id = int(room["@id"])
262
282
  room_type = room.get("Type", "Lights")
@@ -312,7 +332,7 @@ class Bridge:
312
332
  if remote_ip != self.host:
313
333
  return None
314
334
 
315
- byte_list = list(data) if isinstance(data, (bytes, bytearray)) else [int(data)]
335
+ byte_list = list(data)
316
336
  _LOGGER.debug("Received bytes: %s", byte_list)
317
337
  message = deserialise_byte_list(byte_list)
318
338
  _LOGGER.debug("Deserialised received message as: %s", message)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-rako-2025
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: Asynchronous Python client for Rako Controls Lighting
5
5
  Author-email: Simon Leigh <simonleigh@users.noreply.github.com>
6
6
  Project-URL: Homepage, https://github.com/simonleigh/python-rako
@@ -1 +0,0 @@
1
- 0.3.7