diff --git a/app/radio.py b/app/radio.py index 1165dd7..7c1ac1a 100644 --- a/app/radio.py +++ b/app/radio.py @@ -247,23 +247,24 @@ class RadioManager: if not self._meshcore: return self._setup_in_progress = True + mc = self._meshcore try: - register_event_handlers(self._meshcore) - await export_and_store_private_key(self._meshcore) + register_event_handlers(mc) + await export_and_store_private_key(mc) # Sync radio clock with system time - await sync_radio_time() + await sync_radio_time(mc) # Sync contacts/channels from radio to DB and clear radio logger.info("Syncing and offloading radio data...") - result = await sync_and_offload_all() + result = await sync_and_offload_all(mc) logger.info("Sync complete: %s", result) # Start periodic sync (idempotent) start_periodic_sync() # Send advertisement to announce our presence (if enabled and not throttled) - if await send_advertisement(): + if await send_advertisement(mc): logger.info("Advertisement sent") else: logger.debug("Advertisement skipped (disabled or throttled)") @@ -274,11 +275,11 @@ class RadioManager: # Drain any messages that were queued before we connected. # This must happen BEFORE starting auto-fetch, otherwise both # compete on get_msg() with interleaved radio I/O. - drained = await drain_pending_messages() + drained = await drain_pending_messages(mc) if drained > 0: logger.info("Drained %d pending message(s)", drained) - await self._meshcore.start_auto_message_fetching() + await mc.start_auto_message_fetching() logger.info("Auto message fetching started") # Start periodic message polling as fallback (idempotent) diff --git a/app/radio_sync.py b/app/radio_sync.py index 949e304..b2dab77 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -14,7 +14,7 @@ import logging import time from contextlib import asynccontextmanager -from meshcore import EventType +from meshcore import EventType, MeshCore from app.event_handlers import cleanup_expired_acks from app.models import Contact @@ -77,16 +77,11 @@ _sync_task: asyncio.Task | None = None SYNC_INTERVAL = 300 -async def sync_and_offload_contacts() -> dict: +async def sync_and_offload_contacts(mc: MeshCore) -> dict: """ Sync contacts from radio to database, then remove them from radio. Returns counts of synced and removed contacts. """ - if not radio_manager.is_connected or radio_manager.meshcore is None: - logger.warning("Cannot sync contacts: radio not connected") - return {"synced": 0, "removed": 0, "error": "Radio not connected"} - - mc = radio_manager.meshcore synced = 0 removed = 0 @@ -137,16 +132,11 @@ async def sync_and_offload_contacts() -> dict: return {"synced": synced, "removed": removed} -async def sync_and_offload_channels() -> dict: +async def sync_and_offload_channels(mc: MeshCore) -> dict: """ Sync channels from radio to database, then clear them from radio. Returns counts of synced and cleared channels. """ - if not radio_manager.is_connected or radio_manager.meshcore is None: - logger.warning("Cannot sync channels: radio not connected") - return {"synced": 0, "cleared": 0, "error": "Radio not connected"} - - mc = radio_manager.meshcore synced = 0 cleared = 0 @@ -227,12 +217,12 @@ async def ensure_default_channels() -> None: ) -async def sync_and_offload_all() -> dict: +async def sync_and_offload_all(mc: MeshCore) -> dict: """Sync and offload both contacts and channels, then ensure defaults exist.""" logger.info("Starting full radio sync and offload") - contacts_result = await sync_and_offload_contacts() - channels_result = await sync_and_offload_channels() + contacts_result = await sync_and_offload_contacts(mc) + channels_result = await sync_and_offload_channels(mc) # Ensure default channels exist await ensure_default_channels() @@ -243,17 +233,13 @@ async def sync_and_offload_all() -> dict: } -async def drain_pending_messages() -> int: +async def drain_pending_messages(mc: MeshCore) -> int: """ Drain all pending messages from the radio. Calls get_msg() repeatedly until NO_MORE_MSGS is received. Returns the count of messages retrieved. """ - if not radio_manager.is_connected or radio_manager.meshcore is None: - return 0 - - mc = radio_manager.meshcore count = 0 max_iterations = 100 # Safety limit @@ -281,7 +267,7 @@ async def drain_pending_messages() -> int: return count -async def poll_for_messages() -> int: +async def poll_for_messages(mc: MeshCore) -> int: """ Poll the radio for any pending messages (single pass). @@ -290,10 +276,6 @@ async def poll_for_messages() -> int: Returns the count of messages retrieved. """ - if not radio_manager.is_connected or radio_manager.meshcore is None: - return 0 - - mc = radio_manager.meshcore count = 0 try: @@ -308,7 +290,7 @@ async def poll_for_messages() -> int: elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV): count += 1 # If we got a message, there might be more - drain them - count += await drain_pending_messages() + count += await drain_pending_messages(mc) except asyncio.TimeoutError: pass @@ -329,16 +311,14 @@ async def _message_poll_loop(): cleanup_expired_acks() if radio_manager.is_connected and not is_polling_paused(): - mc = radio_manager.meshcore - if mc is not None: - try: - async with radio_manager.radio_operation( - "message_poll_loop", - blocking=False, - ): - await poll_for_messages() - except RadioOperationBusyError: - logger.debug("Skipping message poll: radio busy") + try: + async with radio_manager.radio_operation( + "message_poll_loop", + blocking=False, + ) as mc: + await poll_for_messages(mc) + except RadioOperationBusyError: + logger.debug("Skipping message poll: radio busy") except asyncio.CancelledError: break @@ -367,21 +347,18 @@ async def stop_message_polling(): logger.info("Stopped periodic message polling") -async def send_advertisement(force: bool = False) -> bool: +async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool: """Send an advertisement to announce presence on the mesh. Respects the configured advert_interval - won't send if not enough time has elapsed since the last advertisement, unless force=True. Args: + mc: The MeshCore instance to use for the advertisement. force: If True, send immediately regardless of interval. Returns True if successful, False otherwise (including if throttled). """ - if not radio_manager.is_connected or radio_manager.meshcore is None: - logger.debug("Cannot send advertisement: radio not connected") - return False - # Check if enough time has elapsed (unless forced) if not force: settings = await AppSettingsRepository.get() @@ -410,7 +387,7 @@ async def send_advertisement(force: bool = False) -> bool: return False try: - result = await radio_manager.meshcore.commands.send_advert(flood=True) + result = await mc.commands.send_advert(flood=True) if result.type == EventType.OK: # Update last_advert_time in database now = int(time.time()) @@ -437,16 +414,14 @@ async def _periodic_advert_loop(): # Try to send - send_advertisement() handles all checks # (disabled, throttled, not connected) if radio_manager.is_connected: - mc = radio_manager.meshcore - if mc is not None: - try: - async with radio_manager.radio_operation( - "periodic_advertisement", - blocking=False, - ): - await send_advertisement() - except RadioOperationBusyError: - logger.debug("Skipping periodic advertisement: radio busy") + try: + async with radio_manager.radio_operation( + "periodic_advertisement", + blocking=False, + ) as mc: + await send_advertisement(mc) + except RadioOperationBusyError: + logger.debug("Skipping periodic advertisement: radio busy") # Sleep before next check await asyncio.sleep(ADVERT_CHECK_INTERVAL) @@ -484,16 +459,11 @@ async def stop_periodic_advert(): logger.info("Stopped periodic advertisement") -async def sync_radio_time() -> bool: +async def sync_radio_time(mc: MeshCore) -> bool: """Sync the radio's clock with the system time. Returns True if successful, False otherwise. """ - mc = radio_manager.meshcore - if not mc: - logger.debug("Cannot sync time: radio not connected") - return False - try: now = int(time.time()) await mc.commands.set_time(now) @@ -509,18 +479,17 @@ async def _periodic_sync_loop(): while True: try: await asyncio.sleep(SYNC_INTERVAL) - mc = radio_manager.meshcore - if mc is None: + if not radio_manager.is_connected: continue try: async with radio_manager.radio_operation( "periodic_sync", blocking=False, - ): + ) as mc: logger.debug("Running periodic radio sync") - await sync_and_offload_all() - await sync_radio_time() + await sync_and_offload_all(mc) + await sync_radio_time(mc) except RadioOperationBusyError: logger.debug("Skipping periodic sync: radio busy") except asyncio.CancelledError: diff --git a/app/routers/radio.py b/app/routers/radio.py index 34d9fea..ffc57e4 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -104,7 +104,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse: ) # Sync time with system clock - await sync_radio_time() + await sync_radio_time(mc) # Re-fetch self_info so the response reflects the changes we just made. # Commands like set_name() write to flash but don't update the cached @@ -168,8 +168,8 @@ async def send_advertisement() -> dict: require_connected() logger.info("Sending flood advertisement") - async with radio_manager.radio_operation("manual_advertisement"): - success = await do_send_advertisement(force=True) + async with radio_manager.radio_operation("manual_advertisement") as mc: + success = await do_send_advertisement(mc, force=True) if not success: raise HTTPException(status_code=500, detail="Failed to send advertisement") diff --git a/tests/test_radio.py b/tests/test_radio.py index 18997a2..25af620 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -452,7 +452,7 @@ class TestPostConnectSetupOrdering: call_order = [] - async def mock_drain(): + async def mock_drain(mc): call_order.append("drain") return 0 @@ -494,7 +494,7 @@ class TestPostConnectSetupOrdering: observed_during = None - async def mock_drain(): + async def mock_drain(mc): nonlocal observed_during observed_during = rm.is_setup_in_progress return 0 diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index ee5bc5d..b3be54f 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -237,7 +237,7 @@ class TestAdvertise: active = 0 max_active = 0 - async def fake_send(*, force: bool): + async def fake_send(mc, *, force: bool): nonlocal active, max_active assert force is True active += 1 diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index 1752c5b..d63fcd4 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -188,31 +188,21 @@ class TestPollingPause: class TestSyncRadioTime: """Test the radio time sync function.""" - @pytest.mark.asyncio - async def test_returns_false_when_not_connected(self): - """sync_radio_time returns False when radio is not connected.""" - with patch("app.radio_sync.radio_manager") as mock_manager: - mock_manager.meshcore = None - result = await sync_radio_time() - assert result is False - @pytest.mark.asyncio async def test_returns_true_on_success(self): """sync_radio_time returns True when time is set successfully.""" mock_mc = MagicMock() mock_mc.commands.set_time = AsyncMock() - with patch("app.radio_sync.radio_manager") as mock_manager: - mock_manager.meshcore = mock_mc - result = await sync_radio_time() + result = await sync_radio_time(mock_mc) - assert result is True - mock_mc.commands.set_time.assert_called_once() - # Verify timestamp is reasonable (within last few seconds) - call_args = mock_mc.commands.set_time.call_args[0][0] - import time + assert result is True + mock_mc.commands.set_time.assert_called_once() + # Verify timestamp is reasonable (within last few seconds) + call_args = mock_mc.commands.set_time.call_args[0][0] + import time - assert abs(call_args - int(time.time())) < 5 + assert abs(call_args - int(time.time())) < 5 @pytest.mark.asyncio async def test_returns_false_on_exception(self): @@ -220,11 +210,9 @@ class TestSyncRadioTime: mock_mc = MagicMock() mock_mc.commands.set_time = AsyncMock(side_effect=Exception("Radio error")) - with patch("app.radio_sync.radio_manager") as mock_manager: - mock_manager.meshcore = mock_mc - result = await sync_radio_time() + result = await sync_radio_time(mock_mc) - assert result is False + assert result is False class TestSyncRecentContactsToRadio: @@ -429,21 +417,6 @@ class TestSyncRecentContactsToRadio: class TestSyncAndOffloadContacts: """Test sync_and_offload_contacts: pull contacts from radio, save to DB, remove from radio.""" - @pytest.mark.asyncio - async def test_returns_error_when_not_connected(self): - """Returns error dict when radio is not connected.""" - from app.radio_sync import sync_and_offload_contacts - - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = False - mock_rm.meshcore = None - - result = await sync_and_offload_contacts() - - assert result["synced"] == 0 - assert result["removed"] == 0 - assert "error" in result - @pytest.mark.asyncio async def test_syncs_and_removes_contacts(self, test_db): """Contacts are upserted to DB and removed from radio.""" @@ -465,11 +438,7 @@ class TestSyncAndOffloadContacts: mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result) mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - result = await sync_and_offload_contacts() + result = await sync_and_offload_contacts(mock_mc) assert result["synced"] == 2 assert result["removed"] == 2 @@ -509,11 +478,7 @@ class TestSyncAndOffloadContacts: mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result) mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - await sync_and_offload_contacts() + await sync_and_offload_contacts(mock_mc) # Verify the prefix message was claimed (promoted to full key) messages = await MessageRepository.get_all(conversation_key=KEY_A) @@ -546,11 +511,7 @@ class TestSyncAndOffloadContacts: # First remove fails, second succeeds mock_mc.commands.remove_contact = AsyncMock(side_effect=[mock_fail_result, mock_ok_result]) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - result = await sync_and_offload_contacts() + result = await sync_and_offload_contacts(mock_mc) # Both contacts synced, but only one removed successfully assert result["synced"] == 2 @@ -571,11 +532,7 @@ class TestSyncAndOffloadContacts: mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result) mock_mc.commands.remove_contact = AsyncMock(side_effect=Exception("Timeout")) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - result = await sync_and_offload_contacts() + result = await sync_and_offload_contacts(mock_mc) assert result["synced"] == 1 assert result["removed"] == 0 @@ -592,11 +549,7 @@ class TestSyncAndOffloadContacts: mock_mc = MagicMock() mock_mc.commands.get_contacts = AsyncMock(return_value=mock_error_result) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - result = await sync_and_offload_contacts() + result = await sync_and_offload_contacts(mock_mc) assert result["synced"] == 0 assert result["removed"] == 0 @@ -620,11 +573,7 @@ class TestSyncAndOffloadContacts: mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result) mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - await sync_and_offload_contacts() + await sync_and_offload_contacts(mock_mc) contact = await ContactRepository.get_by_key(KEY_A) assert contact is not None @@ -634,21 +583,6 @@ class TestSyncAndOffloadContacts: class TestSyncAndOffloadChannels: """Test sync_and_offload_channels: pull channels from radio, save to DB, clear from radio.""" - @pytest.mark.asyncio - async def test_returns_error_when_not_connected(self): - """Returns error dict when radio is not connected.""" - from app.radio_sync import sync_and_offload_channels - - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = False - mock_rm.meshcore = None - - result = await sync_and_offload_channels() - - assert result["synced"] == 0 - assert result["cleared"] == 0 - assert "error" in result - @pytest.mark.asyncio async def test_syncs_valid_channel_and_clears(self, test_db): """Valid channel is upserted to DB and cleared from radio.""" @@ -672,11 +606,7 @@ class TestSyncAndOffloadChannels: clear_result.type = EventType.OK mock_mc.commands.set_channel = AsyncMock(return_value=clear_result) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - result = await sync_and_offload_channels() + result = await sync_and_offload_channels(mock_mc) assert result["synced"] == 1 assert result["cleared"] == 1 @@ -708,11 +638,7 @@ class TestSyncAndOffloadChannels: side_effect=[empty_name_result] + [other_result] * 39 ) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - result = await sync_and_offload_channels() + result = await sync_and_offload_channels(mock_mc) assert result["synced"] == 0 assert result["cleared"] == 0 @@ -737,11 +663,7 @@ class TestSyncAndOffloadChannels: side_effect=[zero_key_result] + [other_result] * 39 ) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - result = await sync_and_offload_channels() + result = await sync_and_offload_channels(mock_mc) assert result["synced"] == 0 @@ -767,11 +689,7 @@ class TestSyncAndOffloadChannels: clear_result.type = EventType.OK mock_mc.commands.set_channel = AsyncMock(return_value=clear_result) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - await sync_and_offload_channels() + await sync_and_offload_channels(mock_mc) channel = await ChannelRepository.get_by_key("8B3387E9C5CDEA6AC9E5EDBAA115CD72") assert channel is not None @@ -799,11 +717,7 @@ class TestSyncAndOffloadChannels: clear_result.type = EventType.OK mock_mc.commands.set_channel = AsyncMock(return_value=clear_result) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - await sync_and_offload_channels() + await sync_and_offload_channels(mock_mc) mock_mc.commands.set_channel.assert_called_once_with( channel_idx=0, @@ -841,11 +755,7 @@ class TestSyncAndOffloadChannels: mock_mc.commands.set_channel = AsyncMock(side_effect=[fail_result, ok_result]) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - result = await sync_and_offload_channels() + result = await sync_and_offload_channels(mock_mc) assert result["synced"] == 2 assert result["cleared"] == 1 @@ -861,11 +771,7 @@ class TestSyncAndOffloadChannels: mock_mc = MagicMock() mock_mc.commands.get_channel = AsyncMock(return_value=empty_result) - with patch("app.radio_sync.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - - result = await sync_and_offload_channels() + result = await sync_and_offload_channels(mock_mc) assert mock_mc.commands.get_channel.call_count == 40 assert result["synced"] == 0