python-rako-2025 0.4.1__tar.gz → 0.4.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 (48) hide show
  1. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.claude/settings.local.json +2 -1
  2. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/PKG-INFO +1 -1
  3. python_rako_2025-0.4.4/python_rako/__version__.py +1 -0
  4. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/bridge.py +42 -8
  5. python_rako_2025-0.4.4/python_rako/exceptions.py +12 -0
  6. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/helpers.py +45 -1
  7. python_rako_2025-0.4.1/python_rako/__version__.py +0 -1
  8. python_rako_2025-0.4.1/python_rako/exceptions.py +0 -2
  9. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.coveragerc +0 -0
  10. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.editorconfig +0 -0
  11. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.flake8 +0 -0
  12. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.gitattributes +0 -0
  13. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.github/dependabot.yml +0 -0
  14. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.github/workflows/ci.yml +0 -0
  15. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.github/workflows/release.yml +0 -0
  16. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.gitignore +0 -0
  17. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.isort.cfg +0 -0
  18. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.pre-commit-config.yaml +0 -0
  19. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.yamllint +0 -0
  20. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/LICENSE +0 -0
  21. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/Makefile +0 -0
  22. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/README.md +0 -0
  23. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/catalog-info.yaml +0 -0
  24. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/conftest.py +0 -0
  25. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/doc/accessing-the-rako-bridge.pdf +0 -0
  26. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/examples/discover_all_devices.py +0 -0
  27. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/examples/example.py +0 -0
  28. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/examples/ventilation_example.py +0 -0
  29. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/examples/view_updates.py +0 -0
  30. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/pyproject.toml +0 -0
  31. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/__init__.py +0 -0
  32. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/const.py +0 -0
  33. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/model.py +0 -0
  34. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/py.typed +0 -0
  35. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/README_TESTING.md +0 -0
  36. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/resources/rako.xml +0 -0
  37. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/resources/rako2.xml +0 -0
  38. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/resources/rako3.xml +0 -0
  39. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_async_lock.py +0 -0
  40. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_bridge.py +0 -0
  41. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_edge_cases.py +0 -0
  42. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_helpers.py +0 -0
  43. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_model.py +0 -0
  44. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_xml_parsing.py +0 -0
  45. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests_integration/__init__.py +0 -0
  46. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests_integration/conftest.py +0 -0
  47. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests_integration/test_command_live.py +0 -0
  48. {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests_integration/test_general_live.py +0 -0
@@ -6,7 +6,8 @@
6
6
  "mcp__ide__getDiagnostics",
7
7
  "WebFetch(domain:docs.python.org)",
8
8
  "Bash(python -m pytest tests/ -v)",
9
- "Bash(pytest:*)"
9
+ "Bash(pytest:*)",
10
+ "Bash(git fetch:*)"
10
11
  ],
11
12
  "deny": []
12
13
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-rako-2025
3
- Version: 0.4.1
3
+ Version: 0.4.4
4
4
  Summary: Asynchronous Python client for Rako Controls Lighting
5
5
  Project-URL: Homepage, https://github.com/simonleigh/python-rako
6
6
  Project-URL: Repository, https://github.com/simonleigh/python-rako
@@ -0,0 +1 @@
1
+ __version__ = "0.4.4"
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import logging
5
5
  import threading
6
+ import time
6
7
  from typing import TYPE_CHECKING, Any, cast
7
8
 
8
9
  import aiohttp
@@ -15,7 +16,7 @@ from python_rako.const import (
15
16
  MessageType,
16
17
  RequestType,
17
18
  )
18
- from python_rako.exceptions import RakoBridgeError
19
+ from python_rako.exceptions import RakoBridgeError, RakoConnectionError, RakoCommandError
19
20
  from python_rako.helpers import (
20
21
  command_to_byte_list,
21
22
  deserialise_byte_list,
@@ -87,15 +88,36 @@ class BridgeCommanderUDP(_BridgeCommander):
87
88
  await self._send_command(command)
88
89
 
89
90
  async def _send_command(self, command: CommandUDP) -> None:
91
+ """Send command with retry logic."""
92
+ await self._send_command_with_retry(command, max_retries=2)
93
+
94
+ async def _send_command_with_retry(self, command: CommandUDP, max_retries: int = 2) -> bool:
95
+ """Send command with retry logic."""
90
96
  _LOGGER.debug("Sending command: %s", command)
91
97
  byte_list = command_to_byte_list(command)
92
- async with get_dg_commander(self.host, self.port) as dg_client:
93
- _LOGGER.debug("Sending command bytes: %s", byte_list)
94
- await dg_client.send(bytes(byte_list))
95
- data, _ = await dg_client.recv()
96
-
97
- if data.decode("utf8").strip() != COMMAND_SUCCESS_RESPONSE:
98
- _LOGGER.warning("Bad response after command %s %s", command, data)
98
+
99
+ for attempt in range(max_retries + 1):
100
+ try:
101
+ async with get_dg_commander(self.host, self.port) as dg_client:
102
+ _LOGGER.debug("Sending command bytes: %s (attempt %d)", byte_list, attempt + 1)
103
+ await dg_client.send(bytes(byte_list))
104
+ try:
105
+ # Add timeout to prevent indefinite blocking
106
+ data, _ = await asyncio.wait_for(dg_client.recv(), timeout=3.0)
107
+ if data.decode("utf8").strip() != COMMAND_SUCCESS_RESPONSE:
108
+ _LOGGER.warning("Bad response after command %s: %s", command, data)
109
+ return True
110
+ except TimeoutError:
111
+ # Many bridges don't send consistent responses, just log and continue
112
+ _LOGGER.debug("No response received for command %s (timeout after 3s)", command)
113
+ return True # Consider timeout as success for UDP commands
114
+ except (ConnectionError, OSError) as e:
115
+ if attempt == max_retries:
116
+ _LOGGER.error("Command failed after %d attempts: %s", max_retries + 1, e)
117
+ raise RakoConnectionError(f"Failed to send command after {max_retries + 1} attempts: {e}") from e
118
+ _LOGGER.warning("Command attempt %d failed, retrying: %s", attempt + 1, e)
119
+ await asyncio.sleep(0.5 * (2 ** attempt)) # Exponential backoff
120
+ return False
99
121
 
100
122
 
101
123
  class BridgeCommanderHTTP(_BridgeCommander):
@@ -151,6 +173,7 @@ class Bridge:
151
173
  self.scene_cache: SceneCache = SceneCache()
152
174
  self._cached_xml: str | None = None
153
175
  self._xml_fetch_lock = asyncio.Lock()
176
+ self._last_cache_refresh: float = 0
154
177
 
155
178
  @property
156
179
  def _discovery_url(self) -> str:
@@ -335,6 +358,17 @@ class Bridge:
335
358
 
336
359
  return level_cache, scene_cache
337
360
 
361
+ async def refresh_cache_if_stale(self, max_age_seconds: int = 300) -> None:
362
+ """Refresh level and scene cache if older than max_age_seconds."""
363
+ current_time = time.time()
364
+ if current_time - self._last_cache_refresh > max_age_seconds:
365
+ try:
366
+ self.level_cache, self.scene_cache = await self.get_cache_state()
367
+ self._last_cache_refresh = current_time
368
+ _LOGGER.debug("📚 Cache refreshed for bridge %s", self.mac)
369
+ except Exception as e:
370
+ _LOGGER.warning("Failed to refresh cache for bridge %s: %s", self.mac, e)
371
+
338
372
  async def set_room_scene(self, room_id: int, scene: int) -> None:
339
373
  """Set the scene of a room."""
340
374
  await self._bridge_commander.set_room_scene(room_id, scene)
@@ -0,0 +1,12 @@
1
+ class RakoBridgeError(Exception):
2
+ pass
3
+
4
+
5
+ class RakoConnectionError(RakoBridgeError):
6
+ """Raised when bridge connection fails."""
7
+ pass
8
+
9
+
10
+ class RakoCommandError(RakoBridgeError):
11
+ """Raised when command execution fails."""
12
+ pass
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import logging
5
+ import socket
6
+ import time
4
7
  from contextlib import asynccontextmanager
5
8
  from typing import TYPE_CHECKING
6
9
 
@@ -38,7 +41,13 @@ _LOGGER = logging.getLogger(__name__)
38
41
  async def get_dg_listener(port: int, listen_host: str = "0.0.0.0") -> AsyncIterator[DatagramServer]:
39
42
  server: DatagramServer | None = None
40
43
  try:
41
- server = await asyncio_dgram.bind((listen_host, port))
44
+ # Create socket with broadcast capability for receiving status messages
45
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
46
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
47
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
48
+ sock.bind((listen_host, port))
49
+
50
+ server = await asyncio_dgram.from_socket(sock)
42
51
  yield server
43
52
  finally:
44
53
  if server:
@@ -194,3 +203,38 @@ def convert_to_scene(brightness: int) -> int:
194
203
 
195
204
  scene = next(k for k, v in _scene_windows.items() if v["low"] <= brightness < v["high"])
196
205
  return scene
206
+
207
+
208
+ def get_predicted_channel_brightness(level_cache: LevelCache, room: int, channel: int, scene: int) -> int:
209
+ """Predict channel brightness from scene using cache, fallback to conversion."""
210
+ # Try cache first
211
+ cached_brightness = level_cache.get_channel_level(
212
+ RoomChannel(room, channel), scene
213
+ )
214
+ if cached_brightness > 0:
215
+ return cached_brightness
216
+
217
+ # Fallback to scene-to-brightness conversion
218
+ return convert_to_brightness(scene)
219
+
220
+
221
+ class UDPMessageRateLimit:
222
+ """Rate limiter for UDP message processing to prevent flooding."""
223
+
224
+ def __init__(self, max_messages_per_second: int = 50):
225
+ self.max_messages = max_messages_per_second
226
+ self.message_times: list[float] = []
227
+ self.lock = asyncio.Lock()
228
+
229
+ async def should_process_message(self) -> bool:
230
+ """Rate limit UDP message processing."""
231
+ async with self.lock:
232
+ now = time.time()
233
+ # Remove messages older than 1 second
234
+ self.message_times = [t for t in self.message_times if now - t < 1.0]
235
+
236
+ if len(self.message_times) >= self.max_messages:
237
+ return False
238
+
239
+ self.message_times.append(now)
240
+ return True
@@ -1 +0,0 @@
1
- __version__ = "0.4.1"
@@ -1,2 +0,0 @@
1
- class RakoBridgeError(Exception):
2
- pass