mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
extract radio command service
This commit is contained in:
+26
-73
@@ -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"}
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user