diff --git a/README.md b/README.md index cb87cc7..c9fea1b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Pyrisco can subscribe to Risco Cloud's Server-Sent Events stream and push state ```python import asyncio -from pyrisco import RiscoCloud +from pyrisco import RiscoCloud, MaxRetriesError r = RiscoCloud("", "", "") @@ -29,15 +29,28 @@ async def on_state(alarm): print(alarm.partitions[0].armed) print(alarm.zones[0].triggered) -async def on_error(error): - print(f"SSE error: {error}, reconnecting in 5s...") - await asyncio.sleep(5) +async def on_event(events): + for event in events: + print(event.text) + +async def _restart(): + await r.close() await r.login() await r.subscribe_states() +async def on_error(error): + if isinstance(error, MaxRetriesError): + # Schedule restart in a new task — calling close() from within the SSE + # task would deadlock because close() awaits the task itself + asyncio.create_task(_restart()) + else: + # Transient error — pyrisco will reconnect automatically with backoff + print(f"SSE error: {error}") + async def main(): await r.login() r.add_state_handler(on_state) + r.add_event_handler(on_event) r.add_error_handler(on_error) await r.subscribe_states() await asyncio.Future() # run forever @@ -45,9 +58,11 @@ async def main(): asyncio.run(main()) ``` -Both `add_state_handler` and `add_error_handler` return a callable that removes the handler when called. +`add_state_handler`, `add_event_handler`, and `add_error_handler` each return a callable that removes the handler when called. + +On connection errors pyrisco reconnects automatically with exponential backoff (1 s, 2 s, 4 s, 8 s…). After the maximum number of attempts the error handler is called with a `MaxRetriesError` wrapping the last exception — the recommended response is to re-login and re-subscribe. -After `subscribe_states()` is started, calls to `get_state()` return the latest cached state without making a network request. +After `subscribe_states()` is started, the state handler is called immediately with the current state, and subsequent calls to `get_state()` return the latest cached state without making a network request. #### Polling diff --git a/examples/cloud/cloud_sse_example.py b/examples/cloud/cloud_sse_example.py index 05258d4..10c51af 100644 --- a/examples/cloud/cloud_sse_example.py +++ b/examples/cloud/cloud_sse_example.py @@ -1,5 +1,5 @@ import asyncio -from pyrisco import RiscoCloud +from pyrisco import RiscoCloud, MaxRetriesError risco = RiscoCloud("user@example.com", "password", "1234") @@ -12,16 +12,35 @@ async def on_state(alarm): print(f" Zone {zone_id} ({zone.name}): triggered={zone.triggered}, bypassed={zone.bypassed}") -async def on_error(error): - print(f"SSE error: {error}, reconnecting in 5s...") - await asyncio.sleep(5) +async def on_event(events): + print(f"Received {len(events)} new event(s)") + for event in events: + print(f" [{event.time}] {event.text}") + + +async def _restart(): + """Re-login and re-subscribe after all reconnect attempts are exhausted.""" + await risco.close() await risco.login() await risco.subscribe_states() +async def on_error(error): + if isinstance(error, MaxRetriesError): + # All reconnect attempts exhausted — schedule restart in a new task; + # calling close() from within the SSE task would deadlock because + # close() awaits the task itself + print(f"Gave up reconnecting ({error.last_error}), restarting...") + asyncio.create_task(_restart()) + else: + # Transient error — pyrisco will reconnect automatically with backoff + print(f"SSE error: {error} (reconnecting automatically)") + + async def main(): await risco.login() risco.add_state_handler(on_state) + risco.add_event_handler(on_event) risco.add_error_handler(on_error) await risco.subscribe_states() await asyncio.Future() # run forever diff --git a/pyrisco/__init__.py b/pyrisco/__init__.py index 0587c40..6fe01ae 100644 --- a/pyrisco/__init__.py +++ b/pyrisco/__init__.py @@ -1,3 +1,3 @@ from pyrisco.local import RiscoLocal from pyrisco.cloud import RiscoCloud -from .common import CannotConnectError, OperationError, UnauthorizedError +from .common import CannotConnectError, OperationError, UnauthorizedError, MaxRetriesError diff --git a/pyrisco/cloud/risco_cloud.py b/pyrisco/cloud/risco_cloud.py index fd2138b..4fa8413 100644 --- a/pyrisco/cloud/risco_cloud.py +++ b/pyrisco/cloud/risco_cloud.py @@ -7,7 +7,12 @@ from .alarm import Alarm from .event import Event -from pyrisco.common import UnauthorizedError, CannotConnectError, OperationError, RetryableOperationError, GROUP_ID_TO_NAME +from pyrisco.common import UnauthorizedError, CannotConnectError, OperationError, RetryableOperationError, MaxRetriesError, GROUP_ID_TO_NAME + + +def _parse_timestamp(ts_str): + """Parse an ISO 8601 timestamp, accepting both 'Z' and '+00:00' UTC suffixes.""" + return datetime.fromisoformat(ts_str.replace("Z", "+00:00")) LOGIN_URL = "https://www.riscocloud.com/webapi/api/auth/login" @@ -23,6 +28,9 @@ NUM_RETRIES = 3 RETRYABLE_RESULT_CODE = 72 +RECONNECT_INITIAL_DELAY = 1 +RECONNECT_MAX_DELAY = 60 +RECONNECT_MAX_ATTEMPTS = 5 class RiscoCloud: @@ -43,7 +51,9 @@ def __init__(self, username, password, pin, language="en"): self._created_session = False self._state_handlers = [] self._error_handlers = [] + self._event_handlers = [] self._last_status_update = None + self._last_event_update = None self._latest_state = None self._subscription_task = None @@ -54,6 +64,14 @@ def _remove(): handlers.remove(handler) return _remove + @staticmethod + def _call_handlers(handlers, *params): + """Dispatch handlers asynchronously so a slow/buggy callback doesn't block the SSE loop.""" + if handlers: + async def _gather(): + await asyncio.gather(*[h(*params) for h in list(handlers)], return_exceptions=True) + asyncio.create_task(_gather()) + async def _authenticated_post(self, url, body): headers = { "Content-Type": "application/json", @@ -139,58 +157,80 @@ async def _send_control_command(self, body): return Alarm(self, resp, assumed_control_panel_state) async def _sse_loop(self): - try: - url = SSE_URL % self._site_id - headers = { - "authorization": f"Bearer {self._access_token}", - "User-Agent": "pyrisco/1.0", - "sessionToken": self._session_id, - } - params = {"sessionToken": self._session_id} - async with self._session.get(url, headers=headers, params=params) as resp: - event_type = None - data_line = None - async for line_bytes in resp.content: - line = line_bytes.decode("utf-8").rstrip("\r\n") - if line.startswith("event:"): - event_type = line[6:].strip() - elif line.startswith("data:"): - data_line = line[5:].strip() - elif line == "" and event_type == "runtimeUpdate" and data_line: - await self._handle_runtime_update(json.loads(data_line)) - event_type = None - data_line = None - except asyncio.CancelledError: - raise - except Exception as e: - for handler in list(self._error_handlers): - await handler(e) + attempt = 0 + while True: + try: + url = SSE_URL % self._site_id + headers = { + "authorization": f"Bearer {self._access_token}", + "User-Agent": "pyrisco/1.0", + "sessionToken": self._session_id, + } + params = {"sessionToken": self._session_id} + async with self._session.get(url, headers=headers, params=params) as resp: + resp.raise_for_status() + # SSE connection is now open — fetch initial state before consuming + # messages so no state changes in between can be missed. + initial_resp, assumed = await self._site_post(STATE_URL, {}) + alarm = Alarm(self, initial_resp["state"]["status"], assumed) + self._latest_state = alarm + RiscoCloud._call_handlers(self._state_handlers, alarm) + attempt = 0 # usable connection established — reset backoff counter + event_type = None + data_line = None + async for line_bytes in resp.content: + line = line_bytes.decode("utf-8").rstrip("\r\n") + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_line = line[5:].strip() + elif line == "" and event_type == "runtimeUpdate" and data_line: + await self._handle_runtime_update(json.loads(data_line)) + event_type = None + data_line = None + await asyncio.sleep(RECONNECT_INITIAL_DELAY) # small delay before reconnecting on clean EOF + except asyncio.CancelledError: + raise + except Exception as e: + attempt += 1 + if attempt >= RECONNECT_MAX_ATTEMPTS: + RiscoCloud._call_handlers(self._error_handlers, MaxRetriesError(e)) + return + RiscoCloud._call_handlers(self._error_handlers, e) + delay = min(RECONNECT_INITIAL_DELAY * (2 ** (attempt - 1)), RECONNECT_MAX_DELAY) + await asyncio.sleep(delay) async def _handle_runtime_update(self, data): - if data.get("isOffline"): - return - ts_str = data.get("lastStatusUpdate") - if not ts_str: - return - update_time = datetime.fromisoformat(ts_str) - if self._last_status_update is not None and update_time <= self._last_status_update: + if data.get("IsOffline"): return - resp, assumed = await self._site_post(STATE_URL, {}) - alarm = Alarm(self, resp["state"]["status"], assumed) - self._last_status_update = update_time - self._latest_state = alarm - for handler in list(self._state_handlers): - await handler(alarm) + ts_str = data.get("LastStatusUpdate") + if ts_str: + update_time = _parse_timestamp(ts_str) + if self._last_status_update is None or update_time > self._last_status_update: + resp, assumed = await self._site_post(STATE_URL, {}) + alarm = Alarm(self, resp["state"]["status"], assumed) + self._last_status_update = update_time + self._latest_state = alarm + RiscoCloud._call_handlers(self._state_handlers, alarm) + event_ts_str = data.get("LastEventUpdated") + if event_ts_str and self._event_handlers: + event_time = _parse_timestamp(event_ts_str) + last_event_time = _parse_timestamp(self._last_event_update) if self._last_event_update else None + if last_event_time is None or event_time > last_event_time: + events = await self.get_events(self._last_event_update) + self._last_event_update = event_ts_str + RiscoCloud._call_handlers(self._event_handlers, events) async def close(self): """Close the connection.""" self._session_id = None if self._subscription_task: self._subscription_task.cancel() - try: - await self._subscription_task - except (asyncio.CancelledError, Exception): - pass + if asyncio.current_task() != self._subscription_task: + try: + await self._subscription_task + except (asyncio.CancelledError, Exception): + pass self._subscription_task = None if self._created_session and self._session is not None: await self._session.close() @@ -215,6 +255,10 @@ def add_error_handler(self, handler): """Register an async callback for SSE errors. Returns a remover callable.""" return RiscoCloud._add_handler(self._error_handlers, handler) + def add_event_handler(self, handler): + """Register an async callback for event log updates via SSE. Returns a remover callable.""" + return RiscoCloud._add_handler(self._event_handlers, handler) + async def subscribe_states(self): """Start listening for push state updates via SSE.""" self._subscription_task = asyncio.create_task(self._sse_loop()) @@ -265,6 +309,8 @@ async def get_events(self, newer_than, count=10): "offset": 0, } response, assumed_control_panel_state = await self._site_post(EVENTS_URL, body) + if response is None: + return [] return [Event(e) for e in response["controlPanelEventsList"]] async def bypass_zone(self, zone, bypass): diff --git a/pyrisco/common.py b/pyrisco/common.py index 5fd0901..1627578 100644 --- a/pyrisco/common.py +++ b/pyrisco/common.py @@ -147,3 +147,11 @@ class OperationError(Exception): class RetryableOperationError(OperationError): """Exception to indicate an error in operation that can be retried and might succeed.""" + + +class MaxRetriesError(Exception): + """Raised when the SSE connection fails and all reconnect attempts are exhausted.""" + + def __init__(self, last_error): + self.last_error = last_error + super().__init__(f"SSE reconnect failed after maximum attempts: {last_error}") diff --git a/tests/test_risco_cloud.py b/tests/test_risco_cloud.py index b59319a..14d6a47 100644 --- a/tests/test_risco_cloud.py +++ b/tests/test_risco_cloud.py @@ -2,12 +2,42 @@ import unittest from datetime import datetime, timezone from unittest.mock import patch, AsyncMock, MagicMock -from pyrisco.cloud.risco_cloud import RiscoCloud, UnauthorizedError, OperationError, RetryableOperationError +from pyrisco.cloud.risco_cloud import RiscoCloud, UnauthorizedError, OperationError, RetryableOperationError, RECONNECT_INITIAL_DELAY, RECONNECT_MAX_ATTEMPTS +from pyrisco.common import MaxRetriesError from pyrisco.cloud.alarm import Alarm LOGIN_URL = "https://www.riscocloud.com/webapi/api/auth/login" +def _make_cancel_cm(): + """Return a mock async context manager that raises CancelledError on enter. + + Used to stop the SSE reconnect loop cleanly in tests: set the second + session.get() side_effect entry to _make_cancel_cm() so the loop exits + as soon as it tries to reconnect after the test stream ends. + """ + cm = MagicMock() + cm.__aenter__ = AsyncMock(side_effect=asyncio.CancelledError) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + +async def _drain_handler_tasks(): + """Wait for all handler tasks spawned by _call_handlers to finish.""" + pending = {t for t in asyncio.all_tasks() if t != asyncio.current_task()} + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + +async def _run_sse_task(risco_cloud): + """Await the SSE subscription task, then drain any handler tasks.""" + try: + await risco_cloud._subscription_task + except asyncio.CancelledError: + pass + await _drain_handler_tasks() + + def _make_sse_stream(*events): """Build an async iterator that yields SSE lines for the given events. @@ -29,6 +59,14 @@ async def _iter(): class TestRiscoCloud(unittest.IsolatedAsyncioTestCase): + def setUp(self): + # Mock asyncio.sleep globally for all tests so SSE reconnect delays don't slow the suite. + self._sleep_patcher = patch('pyrisco.cloud.risco_cloud.asyncio.sleep', new_callable=AsyncMock) + self.mock_sleep = self._sleep_patcher.start() + + def tearDown(self): + self._sleep_patcher.stop() + @patch('pyrisco.cloud.risco_cloud.RiscoCloud._authenticated_post', new_callable=AsyncMock) @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') async def test_login(self, MockClientSession, mock_authenticated_post): @@ -205,21 +243,55 @@ async def test_get_state_with_fallback_to_cloud(self, mock_authenticated_post): self.assertTrue(mock_authenticated_post.call_args_list[0][0][1]['fromControlPanel']) self.assertFalse(mock_authenticated_post.call_args_list[1][0][1]['fromControlPanel']) + @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) + @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') + async def test_subscribe_states_notifies_handler_immediately(self, MockClientSession, mock_site_post): + """Handler should be called right after SSE connects, before any SSE messages.""" + state_payload = {"partitions": [], "zones": []} + mock_site_post.return_value = ({"state": {"status": state_payload}}, False) + + mock_session = MockClientSession.return_value + mock_resp = MagicMock() + mock_resp.content = _make_sse_stream() # no SSE messages + mock_get_cm = MagicMock() + mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) + mock_get_cm.__aexit__ = AsyncMock(return_value=None) + mock_session.get.side_effect = [mock_get_cm, _make_cancel_cm()] + + received = [] + async def on_state(alarm): + received.append(alarm) + + risco_cloud = RiscoCloud("username", "password", "pin") + risco_cloud._access_token = "mock_access_token" + risco_cloud._site_id = "mock_site_id" + risco_cloud._session_id = "mock_session_id" + risco_cloud._session = mock_session + risco_cloud.add_state_handler(on_state) + + await risco_cloud.subscribe_states() + await _run_sse_task(risco_cloud) + + self.assertEqual(len(received), 1) + self.assertIsInstance(received[0], Alarm) + self.assertEqual(mock_site_post.call_count, 1) + @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') async def test_subscribe_states_calls_handler(self, MockClientSession, mock_site_post): state_payload = {"partitions": [], "zones": []} + # Two calls: initial state fetch + state fetch triggered by SSE message mock_site_post.return_value = ({"state": {"status": state_payload}}, False) mock_session = MockClientSession.return_value mock_resp = MagicMock() mock_resp.content = _make_sse_stream( - ("runtimeUpdate", {"lastStatusUpdate": "2024-01-01T12:00:00Z"}), + ("runtimeUpdate", {"LastStatusUpdate": "2024-01-01T12:00:00Z"}), ) mock_get_cm = MagicMock() mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) mock_get_cm.__aexit__ = AsyncMock(return_value=None) - mock_session.get.return_value = mock_get_cm + mock_session.get.side_effect = [mock_get_cm, _make_cancel_cm()] received = [] async def on_state(alarm): @@ -233,11 +305,11 @@ async def on_state(alarm): risco_cloud.add_state_handler(on_state) await risco_cloud.subscribe_states() - await risco_cloud._subscription_task + await _run_sse_task(risco_cloud) - self.assertEqual(len(received), 1) + self.assertEqual(len(received), 2) # initial fetch + SSE-triggered fetch self.assertIsInstance(received[0], Alarm) - self.assertEqual(mock_site_post.call_count, 1) + self.assertEqual(mock_site_post.call_count, 2) @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') @@ -248,13 +320,13 @@ async def test_subscribe_states_no_update_same_timestamp(self, MockClientSession mock_session = MockClientSession.return_value mock_resp = MagicMock() mock_resp.content = _make_sse_stream( - ("runtimeUpdate", {"lastStatusUpdate": "2024-01-01T12:00:00Z"}), - ("runtimeUpdate", {"lastStatusUpdate": "2024-01-01T12:00:00Z"}), + ("runtimeUpdate", {"LastStatusUpdate": "2024-01-01T12:00:00Z"}), + ("runtimeUpdate", {"LastStatusUpdate": "2024-01-01T12:00:00Z"}), ) mock_get_cm = MagicMock() mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) mock_get_cm.__aexit__ = AsyncMock(return_value=None) - mock_session.get.return_value = mock_get_cm + mock_session.get.side_effect = [mock_get_cm, _make_cancel_cm()] risco_cloud = RiscoCloud("username", "password", "pin") risco_cloud._access_token = "mock_access_token" @@ -263,9 +335,10 @@ async def test_subscribe_states_no_update_same_timestamp(self, MockClientSession risco_cloud._session = mock_session await risco_cloud.subscribe_states() - await risco_cloud._subscription_task + await _run_sse_task(risco_cloud) - self.assertEqual(mock_site_post.call_count, 1) + # initial fetch + first SSE message; second SSE message has same timestamp so skipped + self.assertEqual(mock_site_post.call_count, 2) @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) async def test_get_state_returns_cached(self, mock_site_post): @@ -285,15 +358,18 @@ async def test_get_state_returns_cached(self, mock_site_post): @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') async def test_subscribe_states_skips_fetch_when_offline(self, MockClientSession, mock_site_post): + state_payload = {"partitions": [], "zones": []} + mock_site_post.return_value = ({"state": {"status": state_payload}}, False) + mock_session = MockClientSession.return_value mock_resp = MagicMock() mock_resp.content = _make_sse_stream( - ("runtimeUpdate", {"lastStatusUpdate": "2024-01-01T12:00:00Z", "isOffline": True}), + ("runtimeUpdate", {"LastStatusUpdate": "2024-01-01T12:00:00Z", "IsOffline": True}), ) mock_get_cm = MagicMock() mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) mock_get_cm.__aexit__ = AsyncMock(return_value=None) - mock_session.get.return_value = mock_get_cm + mock_session.get.side_effect = [mock_get_cm, _make_cancel_cm()] risco_cloud = RiscoCloud("username", "password", "pin") risco_cloud._access_token = "mock_access_token" @@ -302,12 +378,57 @@ async def test_subscribe_states_skips_fetch_when_offline(self, MockClientSession risco_cloud._session = mock_session await risco_cloud.subscribe_states() - await risco_cloud._subscription_task + await _run_sse_task(risco_cloud) - mock_site_post.assert_not_called() + # Only the initial fetch; the SSE message is skipped because IsOffline=True + self.assertEqual(mock_site_post.call_count, 1) + + @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) + @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') + async def test_subscribe_states_event_handler_fires_without_status_update(self, MockClientSession, mock_site_post): + """LastEventUpdated should trigger the event handler even when LastStatusUpdate is absent.""" + state_payload = {"partitions": [], "zones": []} + event_payload = {"controlPanelEventsList": []} + mock_site_post.side_effect = [ + ({"state": {"status": state_payload}}, False), # initial state fetch + (event_payload, False), # event fetch from SSE message + ] + + mock_session = MockClientSession.return_value + mock_resp = MagicMock() + mock_resp.content = _make_sse_stream( + ("runtimeUpdate", {"LastEventUpdated": "2024-01-01T12:00:00Z"}), + ) + mock_get_cm = MagicMock() + mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) + mock_get_cm.__aexit__ = AsyncMock(return_value=None) + mock_session.get.side_effect = [mock_get_cm, _make_cancel_cm()] + received_events = [] + async def on_event(events): + received_events.append(events) + + risco_cloud = RiscoCloud("username", "password", "pin") + risco_cloud._access_token = "mock_access_token" + risco_cloud._site_id = "mock_site_id" + risco_cloud._session_id = "mock_session_id" + risco_cloud._session = mock_session + risco_cloud.add_event_handler(on_event) + + await risco_cloud.subscribe_states() + await _run_sse_task(risco_cloud) + + self.assertEqual(len(received_events), 1) + self.assertEqual(mock_site_post.call_count, 2) # initial state fetch + event fetch + + @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') - async def test_subscribe_states_calls_error_handler(self, MockClientSession): + async def test_subscribe_states_calls_error_handler(self, MockClientSession, mock_site_post): + state_payload = {"partitions": [], "zones": []} + mock_site_post.return_value = ({"state": {"status": state_payload}}, False) + # Make sleep raise CancelledError so the loop stops cleanly after the first retry delay + self.mock_sleep.side_effect = asyncio.CancelledError + mock_session = MockClientSession.return_value error = RuntimeError("stream broken") @@ -334,10 +455,165 @@ async def on_error(err): risco_cloud.add_error_handler(on_error) await risco_cloud.subscribe_states() - await risco_cloud._subscription_task + try: + await risco_cloud._subscription_task + except asyncio.CancelledError: + pass + await _drain_handler_tasks() self.assertEqual(len(received_errors), 1) self.assertIs(received_errors[0], error) + self.mock_sleep.assert_awaited_once_with(RECONNECT_INITIAL_DELAY) # first attempt: 1s + + + @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) + @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') + async def test_subscribe_states_calls_event_handler_with_null_response(self, MockClientSession, mock_site_post): + state_payload = {"partitions": [], "zones": []} + mock_site_post.side_effect = [ + ({"state": {"status": state_payload}}, False), # initial state fetch + ({"state": {"status": state_payload}}, False), # state fetch from SSE message + (None, False), # event fetch returns null + ] + + mock_session = MockClientSession.return_value + mock_resp = MagicMock() + mock_resp.content = _make_sse_stream( + ("runtimeUpdate", { + "LastStatusUpdate": "2024-01-01T12:00:00Z", + "LastEventUpdated": "2024-01-01T12:00:00Z", + }), + ) + mock_get_cm = MagicMock() + mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) + mock_get_cm.__aexit__ = AsyncMock(return_value=None) + mock_session.get.side_effect = [mock_get_cm, _make_cancel_cm()] + + received_events = [] + async def on_event(events): + received_events.append(events) + + risco_cloud = RiscoCloud("username", "password", "pin") + risco_cloud._access_token = "mock_access_token" + risco_cloud._site_id = "mock_site_id" + risco_cloud._session_id = "mock_session_id" + risco_cloud._session = mock_session + risco_cloud.add_event_handler(on_event) + + await risco_cloud.subscribe_states() + await _run_sse_task(risco_cloud) + + self.assertEqual(len(received_events), 1) + self.assertEqual(received_events[0], []) + + @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) + @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') + async def test_subscribe_states_calls_event_handler(self, MockClientSession, mock_site_post): + state_payload = {"partitions": [], "zones": []} + event_payload = {"controlPanelEventsList": []} + mock_site_post.side_effect = [ + ({"state": {"status": state_payload}}, False), # initial state fetch + ({"state": {"status": state_payload}}, False), # state fetch from SSE message + (event_payload, False), # event fetch + ] + + mock_session = MockClientSession.return_value + mock_resp = MagicMock() + mock_resp.content = _make_sse_stream( + ("runtimeUpdate", { + "LastStatusUpdate": "2024-01-01T12:00:00Z", + "LastEventUpdated": "2024-01-01T12:00:00Z", + }), + ) + mock_get_cm = MagicMock() + mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) + mock_get_cm.__aexit__ = AsyncMock(return_value=None) + mock_session.get.side_effect = [mock_get_cm, _make_cancel_cm()] + + received_events = [] + async def on_event(events): + received_events.append(events) + + risco_cloud = RiscoCloud("username", "password", "pin") + risco_cloud._access_token = "mock_access_token" + risco_cloud._site_id = "mock_site_id" + risco_cloud._session_id = "mock_session_id" + risco_cloud._session = mock_session + risco_cloud.add_event_handler(on_event) + + await risco_cloud.subscribe_states() + await _run_sse_task(risco_cloud) + + self.assertEqual(len(received_events), 1) + self.assertIsInstance(received_events[0], list) + + @patch('pyrisco.cloud.risco_cloud.RiscoCloud._site_post', new_callable=AsyncMock) + @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') + async def test_subscribe_states_no_event_handler_when_no_last_event_updated(self, MockClientSession, mock_site_post): + state_payload = {"partitions": [], "zones": []} + mock_site_post.return_value = ({"state": {"status": state_payload}}, False) + + mock_session = MockClientSession.return_value + mock_resp = MagicMock() + mock_resp.content = _make_sse_stream( + ("runtimeUpdate", {"LastStatusUpdate": "2024-01-01T12:00:00Z"}), + ) + mock_get_cm = MagicMock() + mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) + mock_get_cm.__aexit__ = AsyncMock(return_value=None) + mock_session.get.side_effect = [mock_get_cm, _make_cancel_cm()] + + received_events = [] + async def on_event(events): + received_events.append(events) + + risco_cloud = RiscoCloud("username", "password", "pin") + risco_cloud._access_token = "mock_access_token" + risco_cloud._site_id = "mock_site_id" + risco_cloud._session_id = "mock_session_id" + risco_cloud._session = mock_session + risco_cloud.add_event_handler(on_event) + + await risco_cloud.subscribe_states() + await _run_sse_task(risco_cloud) + + self.assertEqual(len(received_events), 0) + self.assertEqual(mock_site_post.call_count, 2) # initial state fetch + state fetch from SSE, no event fetch + + + @patch('pyrisco.cloud.risco_cloud.aiohttp.ClientSession') + async def test_subscribe_states_max_retries_exceeded(self, MockClientSession): + """After RECONNECT_MAX_ATTEMPTS failures the loop gives up and notifies via MaxRetriesError.""" + mock_session = MockClientSession.return_value + error = RuntimeError("connection failed") + mock_session.get.side_effect = error + + received_errors = [] + async def on_error(err): + received_errors.append(err) + + risco_cloud = RiscoCloud("username", "password", "pin") + risco_cloud._access_token = "mock_access_token" + risco_cloud._site_id = "mock_site_id" + risco_cloud._session_id = "mock_session_id" + risco_cloud._session = mock_session + risco_cloud.add_error_handler(on_error) + + await risco_cloud.subscribe_states() + await risco_cloud._subscription_task # completes normally after giving up + await _drain_handler_tasks() + + # Error handler called once per attempt: first N-1 with the raw error, last with MaxRetriesError + self.assertEqual(len(received_errors), RECONNECT_MAX_ATTEMPTS) + for i in range(RECONNECT_MAX_ATTEMPTS - 1): + self.assertIs(received_errors[i], error) + self.assertIsInstance(received_errors[-1], MaxRetriesError) + self.assertIs(received_errors[-1].last_error, error) + + # Sleep called with exponential backoff (no sleep on final attempt) + self.assertEqual(self.mock_sleep.await_count, RECONNECT_MAX_ATTEMPTS - 1) + for i, expected_delay in enumerate([1, 2, 4, 8]): + self.assertEqual(self.mock_sleep.await_args_list[i].args[0], expected_delay) if __name__ == '__main__':