Be clearer about reality of location inclusion. Closes #53

This commit is contained in:
Jack Kingsman
2026-03-12 10:00:00 -07:00
parent fb535298be
commit 9e8cf56b31
10 changed files with 35 additions and 47 deletions

View File

@@ -290,8 +290,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode` and `path_hash_mode_supported` |
| PATCH | `/api/radio/config` | Update name, location, radio params, and `path_hash_mode` when supported |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |

View File

@@ -146,7 +146,7 @@ app/
- `GET /health`
### Radio
- `GET /radio/config` — includes `path_hash_mode` and `path_hash_mode_supported`
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
- `PUT /radio/private-key`
- `POST /radio/advertise`

View File

@@ -20,6 +20,8 @@ from app.websocket import broadcast_health
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
AdvertLocationSource = Literal["off", "current"]
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
return await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
@@ -52,9 +54,9 @@ 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",
advert_location_source: AdvertLocationSource = Field(
default="current",
description="Whether adverts include the node's current location state",
)
@@ -70,9 +72,9 @@ 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(
advert_location_source: AdvertLocationSource | None = Field(
default=None,
description="Source used for location included in adverts",
description="Whether adverts include the node's current location state",
)
@@ -89,14 +91,8 @@ 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"
adv_loc_policy = info.get("adv_loc_policy", 1)
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
return RadioConfigResponse(
public_key=info.get("public_key", ""),

View File

@@ -33,11 +33,7 @@ async def apply_radio_config_update(
) -> 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]
advert_loc_policy = 0 if update.advert_location_source == "off" else 1
logger.info(
"Setting advert location policy to %s",
update.advert_location_source,

View File

@@ -248,6 +248,7 @@ High-level state is delegated to hooks:
### Radio settings behavior
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
## WebSocket (`useWebSocket.ts`)

View File

@@ -54,9 +54,7 @@ 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 [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
const [gettingLocation, setGettingLocation] = useState(false);
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
@@ -89,7 +87,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');
setAdvertLocationSource(config.advert_location_source ?? 'current');
}, [config]);
useEffect(() => {
@@ -179,7 +177,7 @@ export function SettingsRadioSection({
lat: parsedLat,
lon: parsedLon,
tx_power: parsedTxPower,
...(advertLocationSource !== (config.advert_location_source ?? 'saved_coords')
...(advertLocationSource !== (config.advert_location_source ?? 'current')
? { advert_location_source: advertLocationSource }
: {}),
radio: {
@@ -518,21 +516,18 @@ export function SettingsRadioSection({
<select
id="advert-location-source"
value={advertLocationSource}
onChange={(e) =>
setAdvertLocationSource(e.target.value as 'off' | 'node_gps' | 'saved_coords')
}
onChange={(e) => setAdvertLocationSource(e.target.value as 'off' | 'current')}
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>
<option value="current">Include Node Location</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.
Companion-radio firmware does not distinguish between saved coordinates and live GPS
here. When enabled, adverts include the node&apos;s current location state. That may be
the last coordinates you set from RemoteTerm or live GPS coordinates if the node itself
is already updating them. RemoteTerm cannot enable GPS on the node through the interface
library.
</p>
</div>
</div>

View File

@@ -32,7 +32,7 @@ const baseConfig: RadioConfig = {
},
path_hash_mode: 0,
path_hash_mode_supported: false,
advert_location_source: 'saved_coords',
advert_location_source: 'current',
};
const baseHealth: HealthStatus = {
@@ -209,13 +209,13 @@ describe('SettingsModal', () => {
openRadioSection();
fireEvent.change(screen.getByLabelText('Advert Location Source'), {
target: { value: 'node_gps' },
target: { value: 'off' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ advert_location_source: 'node_gps' })
expect.objectContaining({ advert_location_source: 'off' })
);
});
});

View File

@@ -15,7 +15,7 @@ export interface RadioConfig {
radio: RadioSettings;
path_hash_mode: number;
path_hash_mode_supported: boolean;
advert_location_source?: 'off' | 'node_gps' | 'saved_coords';
advert_location_source?: 'off' | 'current';
}
export interface RadioConfigUpdate {
@@ -25,7 +25,7 @@ export interface RadioConfigUpdate {
tx_power?: number;
radio?: RadioSettings;
path_hash_mode?: number;
advert_location_source?: 'off' | 'node_gps' | 'saved_coords';
advert_location_source?: 'off' | 'current';
}
export interface FanoutStatusEntry {

View File

@@ -75,7 +75,7 @@ class TestApplyRadioConfigUpdate:
await apply_radio_config_update(
mc,
RadioConfigUpdate(advert_location_source="node_gps"),
RadioConfigUpdate(advert_location_source="current"),
path_hash_mode_supported=True,
set_path_hash_mode=MagicMock(),
sync_radio_time_fn=AsyncMock(),

View File

@@ -96,17 +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"
assert response.advert_location_source == "current"
@pytest.mark.asyncio
async def test_maps_node_gps_advert_location_source(self):
async def test_maps_any_nonzero_advert_location_policy_to_current(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"
assert response.advert_location_source == "current"
@pytest.mark.asyncio
async def test_returns_503_when_self_info_missing(self):
@@ -164,7 +164,7 @@ class TestUpdateRadioConfig:
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",
advert_location_source="current",
)
with (
@@ -175,7 +175,7 @@ class TestUpdateRadioConfig:
"app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected
),
):
result = await update_radio_config(RadioConfigUpdate(advert_location_source="node_gps"))
result = await update_radio_config(RadioConfigUpdate(advert_location_source="current"))
mc.commands.set_advert_loc_policy.assert_awaited_once_with(1)
assert result == expected