diff --git a/app/main.py b/app/main.py index a2fbfeb..c379ed9 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,7 @@ from app.radio_sync import ( stop_message_polling, stop_periodic_sync, sync_and_offload_all, + sync_radio_time, ) from app.routers import channels, contacts, health, messages, packets, radio, read_state, settings, ws @@ -37,6 +38,9 @@ async def lifespan(app: FastAPI): if radio_manager.meshcore: register_event_handlers(radio_manager.meshcore) + # Sync radio clock with system time + await sync_radio_time() + # Sync contacts/channels from radio to DB and clear radio logger.info("Syncing and offloading radio data...") result = await sync_and_offload_all() diff --git a/app/radio_sync.py b/app/radio_sync.py index 6903fbb..d41ad70 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -332,6 +332,26 @@ async def stop_message_polling(): logger.info("Stopped periodic message polling") +async def sync_radio_time() -> 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) + logger.debug("Synced radio time to %d", now) + return True + except Exception as e: + logger.warning("Failed to sync radio time: %s", e) + return False + + async def _periodic_sync_loop(): """Background task that periodically syncs and offloads.""" while True: @@ -339,6 +359,7 @@ async def _periodic_sync_loop(): await asyncio.sleep(SYNC_INTERVAL) logger.debug("Running periodic radio sync") await sync_and_offload_all() + await sync_radio_time() except asyncio.CancelledError: logger.info("Periodic sync task cancelled") break diff --git a/app/routers/radio.py b/app/routers/radio.py index 3306f38..20655ea 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -1,11 +1,11 @@ import logging -import time from fastapi import APIRouter, HTTPException from meshcore import EventType from pydantic import BaseModel, Field from app.dependencies import require_connected +from app.radio_sync import sync_radio_time logger = logging.getLogger(__name__) router = APIRouter(prefix="/radio", tags=["radio"]) @@ -101,9 +101,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse: ) # Sync time with system clock - now = int(time.time()) - logger.debug("Syncing radio time to %d", now) - await mc.commands.set_time(now) + await sync_radio_time() return await get_radio_config() diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index 9a6cc2e..ab70730 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -5,11 +5,13 @@ message polling from interfering with repeater CLI operations. """ import pytest +from unittest.mock import AsyncMock, MagicMock, patch from app.radio_sync import ( _polling_pause_count, is_polling_paused, pause_polling, + sync_radio_time, ) @@ -113,3 +115,44 @@ class TestPollingPause: assert radio_sync._polling_pause_count == 1 assert radio_sync._polling_pause_count == 0 + + +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() + + 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 + + @pytest.mark.asyncio + async def test_returns_false_on_exception(self): + """sync_radio_time returns False and doesn't raise on error.""" + 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() + + assert result is False