From 6466a5c3550222338ac3871ef85d355d1a52888a Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 11 Mar 2026 22:42:41 -0700 Subject: [PATCH] Add node GPS enablement + sourcing. Closes #53. --- app/routers/radio.py | 19 +++++++++ app/services/radio_commands.py | 16 +++++++ .../settings/SettingsRadioSection.tsx | 29 +++++++++++++ frontend/src/test/settingsModal.test.tsx | 17 ++++++++ frontend/src/types.ts | 2 + tests/test_radio_commands_service.py | 34 +++++++++++++++ tests/test_radio_router.py | 42 +++++++++++++++++++ 7 files changed, 159 insertions(+) diff --git a/app/routers/radio.py b/app/routers/radio.py index 107be52..bb68086 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -1,4 +1,5 @@ import logging +from typing import Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field @@ -51,6 +52,10 @@ class RadioConfigResponse(BaseModel): path_hash_mode_supported: bool = Field( default=False, description="Whether firmware supports path hash mode setting" ) + advert_location_source: Literal["off", "node_gps", "saved_coords"] = Field( + default="saved_coords", + description="Source used for location included in adverts", + ) class RadioConfigUpdate(BaseModel): @@ -65,6 +70,10 @@ class RadioConfigUpdate(BaseModel): le=2, description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)", ) + advert_location_source: Literal["off", "node_gps", "saved_coords"] | None = Field( + default=None, + description="Source used for location included in adverts", + ) class PrivateKeyUpdate(BaseModel): @@ -80,6 +89,15 @@ async def get_radio_config() -> RadioConfigResponse: if not info: raise HTTPException(status_code=503, detail="Radio info not available") + adv_loc_policy = info.get("adv_loc_policy", 2) + advert_location_source: Literal["off", "node_gps", "saved_coords"] + if adv_loc_policy == 0: + advert_location_source = "off" + elif adv_loc_policy == 1: + advert_location_source = "node_gps" + else: + advert_location_source = "saved_coords" + return RadioConfigResponse( public_key=info.get("public_key", ""), name=info.get("name", ""), @@ -95,6 +113,7 @@ async def get_radio_config() -> RadioConfigResponse: ), path_hash_mode=radio_manager.path_hash_mode, path_hash_mode_supported=radio_manager.path_hash_mode_supported, + advert_location_source=advert_location_source, ) diff --git a/app/services/radio_commands.py b/app/services/radio_commands.py index 81fbeb9..53028c6 100644 --- a/app/services/radio_commands.py +++ b/app/services/radio_commands.py @@ -32,6 +32,22 @@ async def apply_radio_config_update( sync_radio_time_fn: Callable[[Any], Awaitable[Any]], ) -> None: """Apply a validated radio-config update to the connected radio.""" + if update.advert_location_source is not None: + advert_loc_policy = { + "off": 0, + "node_gps": 1, + "saved_coords": 2, + }[update.advert_location_source] + logger.info( + "Setting advert location policy to %s", + update.advert_location_source, + ) + result = await mc.commands.set_advert_loc_policy(advert_loc_policy) + if result is not None and result.type == EventType.ERROR: + raise RadioCommandRejectedError( + f"Failed to set advert location policy: {result.payload}" + ) + if update.name is not None: logger.info("Setting radio name to %s", update.name) await mc.commands.set_name(update.name) diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 876d90b..8c091d7 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -54,6 +54,9 @@ export function SettingsRadioSection({ const [sf, setSf] = useState(''); const [cr, setCr] = useState(''); const [pathHashMode, setPathHashMode] = useState('0'); + const [advertLocationSource, setAdvertLocationSource] = useState< + 'off' | 'node_gps' | 'saved_coords' + >('saved_coords'); const [gettingLocation, setGettingLocation] = useState(false); const [busy, setBusy] = useState(false); const [rebooting, setRebooting] = useState(false); @@ -86,6 +89,7 @@ export function SettingsRadioSection({ setSf(String(config.radio.sf)); setCr(String(config.radio.cr)); setPathHashMode(String(config.path_hash_mode)); + setAdvertLocationSource(config.advert_location_source ?? 'saved_coords'); }, [config]); useEffect(() => { @@ -175,6 +179,9 @@ export function SettingsRadioSection({ lat: parsedLat, lon: parsedLon, tx_power: parsedTxPower, + ...(advertLocationSource !== (config.advert_location_source ?? 'saved_coords') + ? { advert_location_source: advertLocationSource } + : {}), radio: { freq: parsedFreq, bw: parsedBw, @@ -506,6 +513,28 @@ export function SettingsRadioSection({ /> +
+ + +

+ This only controls which location source the radio puts into adverts. If you choose Use + Node GPS, GPS still has to be enabled manually on the node itself for live coordinates + to take effect; RemoteTerm cannot turn it on through the interface library. RemoteTerm + still uses the saved coordinates above for local distance math and similar UI + calculations. +

+
{config.path_hash_mode_supported && ( diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index a7bee85..64b4b30 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -32,6 +32,7 @@ const baseConfig: RadioConfig = { }, path_hash_mode: 0, path_hash_mode_supported: false, + advert_location_source: 'saved_coords', }; const baseHealth: HealthStatus = { @@ -203,6 +204,22 @@ describe('SettingsModal', () => { expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument(); }); + it('saves advert location source through radio config save', async () => { + const { onSave } = renderModal(); + openRadioSection(); + + fireEvent.change(screen.getByLabelText('Advert Location Source'), { + target: { value: 'node_gps' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ advert_location_source: 'node_gps' }) + ); + }); + }); + it('saves changed max contacts value through onSaveAppSettings', async () => { const { onSaveAppSettings } = renderModal(); openRadioSection(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c990df9..5bcb1a8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -15,6 +15,7 @@ export interface RadioConfig { radio: RadioSettings; path_hash_mode: number; path_hash_mode_supported: boolean; + advert_location_source?: 'off' | 'node_gps' | 'saved_coords'; } export interface RadioConfigUpdate { @@ -24,6 +25,7 @@ export interface RadioConfigUpdate { tx_power?: number; radio?: RadioSettings; path_hash_mode?: number; + advert_location_source?: 'off' | 'node_gps' | 'saved_coords'; } export interface FanoutStatusEntry { diff --git a/tests/test_radio_commands_service.py b/tests/test_radio_commands_service.py index 04f5b9d..4e10db7 100644 --- a/tests/test_radio_commands_service.py +++ b/tests/test_radio_commands_service.py @@ -32,6 +32,7 @@ def _mock_meshcore_with_info(): mc.commands.set_tx_power = AsyncMock() mc.commands.set_radio = AsyncMock() mc.commands.set_path_hash_mode = AsyncMock(return_value=_radio_result()) + mc.commands.set_advert_loc_policy = AsyncMock(return_value=_radio_result()) mc.commands.send_appstart = AsyncMock() mc.commands.import_private_key = AsyncMock(return_value=_radio_result()) return mc @@ -68,6 +69,39 @@ class TestApplyRadioConfigUpdate: sync_radio_time_fn.assert_awaited_once_with(mc) mc.commands.send_appstart.assert_awaited_once() + @pytest.mark.asyncio + async def test_updates_advert_location_source(self): + mc = _mock_meshcore_with_info() + + await apply_radio_config_update( + mc, + RadioConfigUpdate(advert_location_source="node_gps"), + path_hash_mode_supported=True, + set_path_hash_mode=MagicMock(), + sync_radio_time_fn=AsyncMock(), + ) + + mc.commands.set_advert_loc_policy.assert_awaited_once_with(1) + mc.commands.send_appstart.assert_awaited_once() + + @pytest.mark.asyncio + async def test_raises_when_radio_rejects_advert_location_source(self): + mc = _mock_meshcore_with_info() + mc.commands.set_advert_loc_policy = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"error": "nope"}) + ) + + with pytest.raises(RadioCommandRejectedError): + await apply_radio_config_update( + mc, + RadioConfigUpdate(advert_location_source="off"), + path_hash_mode_supported=True, + set_path_hash_mode=MagicMock(), + sync_radio_time_fn=AsyncMock(), + ) + + mc.commands.send_appstart.assert_not_awaited() + @pytest.mark.asyncio async def test_rejects_unsupported_path_hash_mode(self): mc = _mock_meshcore_with_info() diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index a795a9a..a09a7b6 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -70,12 +70,14 @@ def _mock_meshcore_with_info(): "radio_bw": 62.5, "radio_sf": 7, "radio_cr": 5, + "adv_loc_policy": 2, } 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_advert_loc_policy = AsyncMock(return_value=_radio_result()) mc.commands.send_appstart = AsyncMock() mc.commands.import_private_key = AsyncMock(return_value=_radio_result()) return mc @@ -94,6 +96,17 @@ class TestGetRadioConfig: assert response.lon == 20.0 assert response.radio.freq == 910.525 assert response.radio.cr == 5 + assert response.advert_location_source == "saved_coords" + + @pytest.mark.asyncio + async def test_maps_node_gps_advert_location_source(self): + mc = _mock_meshcore_with_info() + mc.self_info["adv_loc_policy"] = 1 + + with patch("app.routers.radio.require_connected", return_value=mc): + response = await get_radio_config() + + assert response.advert_location_source == "node_gps" @pytest.mark.asyncio async def test_returns_503_when_self_info_missing(self): @@ -138,6 +151,35 @@ class TestUpdateRadioConfig: mock_sync_time.assert_awaited_once() assert result == expected + @pytest.mark.asyncio + async def test_updates_advert_location_source(self): + mc = _mock_meshcore_with_info() + expected = RadioConfigResponse( + public_key="aa" * 32, + name="NodeA", + lat=10.0, + lon=20.0, + tx_power=17, + max_tx_power=22, + radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5), + path_hash_mode=0, + path_hash_mode_supported=False, + advert_location_source="node_gps", + ) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock), + patch( + "app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected + ), + ): + result = await update_radio_config(RadioConfigUpdate(advert_location_source="node_gps")) + + mc.commands.set_advert_loc_policy.assert_awaited_once_with(1) + assert result == expected + def test_model_rejects_negative_path_hash_mode(self): with pytest.raises(ValidationError): RadioConfigUpdate(path_hash_mode=-1)