Add node GPS enablement + sourcing. Closes #53.

This commit is contained in:
Jack Kingsman
2026-03-11 22:42:41 -07:00
parent f8e88b3737
commit 6466a5c355
7 changed files with 159 additions and 0 deletions
+19
View File
@@ -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,
)
+16
View File
@@ -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)
@@ -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({
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="advert-location-source">Advert Location Source</Label>
<select
id="advert-location-source"
value={advertLocationSource}
onChange={(e) =>
setAdvertLocationSource(e.target.value as 'off' | 'node_gps' | 'saved_coords')
}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="off">Off</option>
<option value="node_gps">Use Node GPS</option>
<option value="saved_coords">Use Saved Coordinates</option>
</select>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
</div>
{config.path_hash_mode_supported && (
+17
View File
@@ -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();
+2
View File
@@ -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 {
+34
View File
@@ -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()
+42
View File
@@ -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)