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.
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.claude/settings.local.json +2 -1
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/PKG-INFO +1 -1
- python_rako_2025-0.4.4/python_rako/__version__.py +1 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/bridge.py +42 -8
- python_rako_2025-0.4.4/python_rako/exceptions.py +12 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/helpers.py +45 -1
- python_rako_2025-0.4.1/python_rako/__version__.py +0 -1
- python_rako_2025-0.4.1/python_rako/exceptions.py +0 -2
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.coveragerc +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.editorconfig +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.flake8 +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.gitattributes +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.github/dependabot.yml +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.github/workflows/ci.yml +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.github/workflows/release.yml +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.gitignore +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.isort.cfg +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.pre-commit-config.yaml +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/.yamllint +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/LICENSE +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/Makefile +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/README.md +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/catalog-info.yaml +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/conftest.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/doc/accessing-the-rako-bridge.pdf +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/examples/discover_all_devices.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/examples/example.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/examples/ventilation_example.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/examples/view_updates.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/pyproject.toml +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/__init__.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/const.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/model.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/python_rako/py.typed +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/README_TESTING.md +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/resources/rako.xml +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/resources/rako2.xml +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/resources/rako3.xml +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_async_lock.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_bridge.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_edge_cases.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_helpers.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_model.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests/test_xml_parsing.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests_integration/__init__.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests_integration/conftest.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests_integration/test_command_live.py +0 -0
- {python_rako_2025-0.4.1 → python_rako_2025-0.4.4}/tests_integration/test_general_live.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-rako-2025
|
|
3
|
-
Version: 0.4.
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|