From 946006bd7f4f51c73879cf88353265b1d335450c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 18:13:18 -0700 Subject: [PATCH] extract radio command service --- app/routers/radio.py | 99 +++++------------ app/services/radio_commands.py | 102 ++++++++++++++++++ tests/test_radio_commands_service.py | 154 +++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 73 deletions(-) create mode 100644 app/services/radio_commands.py create mode 100644 tests/test_radio_commands_service.py diff --git a/app/routers/radio.py b/app/routers/radio.py index 934372d..c58ea9c 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -1,13 +1,19 @@ import logging from fastapi import APIRouter, HTTPException -from meshcore import EventType from pydantic import BaseModel, Field from app.dependencies import require_connected from app.radio import radio_manager from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import sync_radio_time +from app.services.radio_commands import ( + KeystoreRefreshError, + PathHashModeUnsupportedError, + RadioCommandRejectedError, + apply_radio_config_update, + import_private_key_and_refresh_keystore, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/radio", tags=["radio"]) @@ -87,57 +93,18 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse: require_connected() async with radio_manager.radio_operation("update_radio_config") as mc: - if update.name is not None: - logger.info("Setting radio name to %s", update.name) - await mc.commands.set_name(update.name) - - if update.lat is not None or update.lon is not None: - current_info = mc.self_info - lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0) - lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0) - logger.info("Setting radio coordinates to %f, %f", lat, lon) - await mc.commands.set_coords(lat=lat, lon=lon) - - if update.tx_power is not None: - logger.info("Setting TX power to %d dBm", update.tx_power) - await mc.commands.set_tx_power(val=update.tx_power) - - if update.radio is not None: - logger.info( - "Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d", - update.radio.freq, - update.radio.bw, - update.radio.sf, - update.radio.cr, + try: + await apply_radio_config_update( + mc, + update, + path_hash_mode_supported=radio_manager.path_hash_mode_supported, + set_path_hash_mode=lambda mode: setattr(radio_manager, "path_hash_mode", mode), + sync_radio_time_fn=sync_radio_time, ) - await mc.commands.set_radio( - freq=update.radio.freq, - bw=update.radio.bw, - sf=update.radio.sf, - cr=update.radio.cr, - ) - - if update.path_hash_mode is not None: - if not radio_manager.path_hash_mode_supported: - raise HTTPException( - status_code=400, detail="Firmware does not support path hash mode setting" - ) - logger.info("Setting path hash mode to %d", update.path_hash_mode) - result = await mc.commands.set_path_hash_mode(update.path_hash_mode) - if result is not None and result.type == EventType.ERROR: - raise HTTPException( - status_code=500, - detail=f"Failed to set path hash mode: {result.payload}", - ) - radio_manager.path_hash_mode = update.path_hash_mode - - # Sync time with system clock - 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 - # self_info — send_appstart() triggers a fresh SELF_INFO from the radio. - await mc.commands.send_appstart() + except PathHashModeUnsupportedError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RadioCommandRejectedError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc return await get_radio_config() @@ -154,30 +121,16 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict: logger.info("Importing private key") async with radio_manager.radio_operation("import_private_key") as mc: - result = await mc.commands.import_private_key(key_bytes) - - if result.type == EventType.ERROR: - raise HTTPException( - status_code=500, detail=f"Failed to import private key: {result.payload}" - ) - - # Re-export from radio so the server-side keystore uses the new key - # for DM decryption immediately, rather than waiting for reconnect. from app.keystore import export_and_store_private_key - keystore_refreshed = await export_and_store_private_key(mc) - if not keystore_refreshed: - logger.warning("Keystore refresh failed after import, retrying once") - keystore_refreshed = await export_and_store_private_key(mc) - - if not keystore_refreshed: - raise HTTPException( - status_code=500, - detail=( - "Private key imported on radio, but server-side keystore " - "refresh failed. Reconnect to apply the new key for DM decryption." - ), - ) + try: + await import_private_key_and_refresh_keystore( + mc, + key_bytes, + export_and_store_private_key_fn=export_and_store_private_key, + ) + except (RadioCommandRejectedError, KeystoreRefreshError) as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc return {"status": "ok"} diff --git a/app/services/radio_commands.py b/app/services/radio_commands.py new file mode 100644 index 0000000..81fbeb9 --- /dev/null +++ b/app/services/radio_commands.py @@ -0,0 +1,102 @@ +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from meshcore import EventType + +logger = logging.getLogger(__name__) + + +class RadioCommandServiceError(RuntimeError): + """Base error for reusable radio command workflows.""" + + +class PathHashModeUnsupportedError(RadioCommandServiceError): + """Raised when firmware does not support path hash mode updates.""" + + +class RadioCommandRejectedError(RadioCommandServiceError): + """Raised when the radio reports an error for a command.""" + + +class KeystoreRefreshError(RadioCommandServiceError): + """Raised when server-side keystore refresh fails after import.""" + + +async def apply_radio_config_update( + mc, + update, + *, + path_hash_mode_supported: bool, + set_path_hash_mode: Callable[[int], None], + sync_radio_time_fn: Callable[[Any], Awaitable[Any]], +) -> None: + """Apply a validated radio-config update to the connected radio.""" + if update.name is not None: + logger.info("Setting radio name to %s", update.name) + await mc.commands.set_name(update.name) + + if update.lat is not None or update.lon is not None: + current_info = mc.self_info + lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0) + lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0) + logger.info("Setting radio coordinates to %f, %f", lat, lon) + await mc.commands.set_coords(lat=lat, lon=lon) + + if update.tx_power is not None: + logger.info("Setting TX power to %d dBm", update.tx_power) + await mc.commands.set_tx_power(val=update.tx_power) + + if update.radio is not None: + logger.info( + "Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d", + update.radio.freq, + update.radio.bw, + update.radio.sf, + update.radio.cr, + ) + await mc.commands.set_radio( + freq=update.radio.freq, + bw=update.radio.bw, + sf=update.radio.sf, + cr=update.radio.cr, + ) + + if update.path_hash_mode is not None: + if not path_hash_mode_supported: + raise PathHashModeUnsupportedError("Firmware does not support path hash mode setting") + + logger.info("Setting path hash mode to %d", update.path_hash_mode) + result = await mc.commands.set_path_hash_mode(update.path_hash_mode) + if result is not None and result.type == EventType.ERROR: + raise RadioCommandRejectedError(f"Failed to set path hash mode: {result.payload}") + set_path_hash_mode(update.path_hash_mode) + + await sync_radio_time_fn(mc) + + # Commands like set_name() write to flash but don't update cached self_info. + # send_appstart() forces a fresh SELF_INFO so the response reflects changes. + await mc.commands.send_appstart() + + +async def import_private_key_and_refresh_keystore( + mc, + key_bytes: bytes, + *, + export_and_store_private_key_fn: Callable[[Any], Awaitable[bool]], +) -> None: + """Import a private key and refresh the in-memory keystore immediately.""" + result = await mc.commands.import_private_key(key_bytes) + if result.type == EventType.ERROR: + raise RadioCommandRejectedError(f"Failed to import private key: {result.payload}") + + keystore_refreshed = await export_and_store_private_key_fn(mc) + if not keystore_refreshed: + logger.warning("Keystore refresh failed after import, retrying once") + keystore_refreshed = await export_and_store_private_key_fn(mc) + + if not keystore_refreshed: + raise KeystoreRefreshError( + "Private key imported on radio, but server-side keystore refresh failed. " + "Reconnect to apply the new key for DM decryption." + ) diff --git a/tests/test_radio_commands_service.py b/tests/test_radio_commands_service.py new file mode 100644 index 0000000..04f5b9d --- /dev/null +++ b/tests/test_radio_commands_service.py @@ -0,0 +1,154 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from meshcore import EventType + +from app.routers.radio import RadioConfigUpdate, RadioSettings +from app.services.radio_commands import ( + KeystoreRefreshError, + PathHashModeUnsupportedError, + RadioCommandRejectedError, + apply_radio_config_update, + import_private_key_and_refresh_keystore, +) + + +def _radio_result(event_type=EventType.OK, payload=None): + result = MagicMock() + result.type = event_type + result.payload = payload or {} + return result + + +def _mock_meshcore_with_info(): + mc = MagicMock() + mc.self_info = { + "adv_lat": 10.0, + "adv_lon": 20.0, + } + mc.commands = MagicMock() + mc.commands.set_name = AsyncMock() + mc.commands.set_coords = AsyncMock() + mc.commands.set_tx_power = AsyncMock() + mc.commands.set_radio = AsyncMock() + mc.commands.set_path_hash_mode = AsyncMock(return_value=_radio_result()) + mc.commands.send_appstart = AsyncMock() + mc.commands.import_private_key = AsyncMock(return_value=_radio_result()) + return mc + + +class TestApplyRadioConfigUpdate: + @pytest.mark.asyncio + async def test_updates_requested_fields_and_refreshes_info(self): + mc = _mock_meshcore_with_info() + sync_radio_time_fn = AsyncMock() + set_path_hash_mode = MagicMock() + update = RadioConfigUpdate( + name="NodeUpdated", + lat=1.23, + tx_power=17, + radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5), + path_hash_mode=1, + ) + + await apply_radio_config_update( + mc, + update, + path_hash_mode_supported=True, + set_path_hash_mode=set_path_hash_mode, + sync_radio_time_fn=sync_radio_time_fn, + ) + + mc.commands.set_name.assert_awaited_once_with("NodeUpdated") + mc.commands.set_coords.assert_awaited_once_with(lat=1.23, lon=20.0) + mc.commands.set_tx_power.assert_awaited_once_with(val=17) + mc.commands.set_radio.assert_awaited_once_with(freq=910.525, bw=62.5, sf=7, cr=5) + mc.commands.set_path_hash_mode.assert_awaited_once_with(1) + set_path_hash_mode.assert_called_once_with(1) + sync_radio_time_fn.assert_awaited_once_with(mc) + mc.commands.send_appstart.assert_awaited_once() + + @pytest.mark.asyncio + async def test_rejects_unsupported_path_hash_mode(self): + mc = _mock_meshcore_with_info() + update = RadioConfigUpdate(path_hash_mode=1) + + with pytest.raises(PathHashModeUnsupportedError): + await apply_radio_config_update( + mc, + update, + path_hash_mode_supported=False, + set_path_hash_mode=MagicMock(), + sync_radio_time_fn=AsyncMock(), + ) + + mc.commands.set_path_hash_mode.assert_not_awaited() + mc.commands.send_appstart.assert_not_awaited() + + @pytest.mark.asyncio + async def test_raises_when_radio_rejects_path_hash_mode(self): + mc = _mock_meshcore_with_info() + mc.commands.set_path_hash_mode = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"error": "nope"}) + ) + update = RadioConfigUpdate(path_hash_mode=1) + set_path_hash_mode = MagicMock() + + with pytest.raises(RadioCommandRejectedError): + await apply_radio_config_update( + mc, + update, + path_hash_mode_supported=True, + set_path_hash_mode=set_path_hash_mode, + sync_radio_time_fn=AsyncMock(), + ) + + set_path_hash_mode.assert_not_called() + mc.commands.send_appstart.assert_not_awaited() + + +class TestImportPrivateKeyAndRefreshKeystore: + @pytest.mark.asyncio + async def test_rejects_radio_error(self): + mc = _mock_meshcore_with_info() + mc.commands.import_private_key = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"error": "failed"}) + ) + export_fn = AsyncMock(return_value=True) + + with pytest.raises(RadioCommandRejectedError): + await import_private_key_and_refresh_keystore( + mc, + b"\xaa" * 64, + export_and_store_private_key_fn=export_fn, + ) + + export_fn.assert_not_awaited() + + @pytest.mark.asyncio + async def test_retries_keystore_refresh_once(self): + mc = _mock_meshcore_with_info() + export_fn = AsyncMock(side_effect=[False, True]) + + await import_private_key_and_refresh_keystore( + mc, + b"\xaa" * 64, + export_and_store_private_key_fn=export_fn, + ) + + mc.commands.import_private_key.assert_awaited_once_with(b"\xaa" * 64) + assert export_fn.await_count == 2 + + @pytest.mark.asyncio + async def test_raises_when_keystore_refresh_fails_twice(self): + mc = _mock_meshcore_with_info() + export_fn = AsyncMock(return_value=False) + + with pytest.raises(KeystoreRefreshError): + await import_private_key_and_refresh_keystore( + mc, + b"\xaa" * 64, + export_and_store_private_key_fn=export_fn, + ) + + assert export_fn.await_count == 2