This commit is contained in:
Jack Kingsman
2026-03-06 23:48:24 -08:00
parent 3edc7d9bd1
commit f302cc04ae
9 changed files with 259 additions and 21 deletions

View File

@@ -1,4 +1,6 @@
import logging
from collections.abc import Awaitable, Callable
from typing import Any, cast
from fastapi import APIRouter, HTTPException
from meshcore import EventType
@@ -27,6 +29,12 @@ class RadioConfigResponse(BaseModel):
lon: float
tx_power: int = Field(description="Transmit power in dBm")
max_tx_power: int = Field(description="Maximum transmit power in dBm")
path_hash_mode: int = Field(
default=0, description="Default outbound path hash mode (0=1 byte, 1=2 bytes, 2=3 bytes)"
)
path_hash_mode_supported: bool = Field(
default=False, description="Whether the connected radio/firmware exposes path hash mode"
)
radio: RadioSettings
@@ -35,6 +43,9 @@ class RadioConfigUpdate(BaseModel):
lat: float | None = None
lon: float | None = None
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
path_hash_mode: int | None = Field(
default=None, ge=0, le=2, description="Default outbound path hash mode"
)
radio: RadioSettings | None = None
@@ -42,29 +53,91 @@ class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
async def _get_path_hash_mode_info(mc) -> tuple[int, bool]:
"""Return (mode, supported) using the best interface available."""
commands = getattr(mc, "commands", None)
send_device_query = cast(
Callable[[], Awaitable[Any]] | None, getattr(commands, "send_device_query", None)
)
if commands is None or not callable(send_device_query):
return 0, False
try:
result = await send_device_query()
except Exception as exc:
logger.debug("Failed to query device info for path hash mode: %s", exc)
return 0, False
if result is None or result.type == EventType.ERROR:
return 0, False
payload = result.payload if isinstance(result.payload, dict) else {}
mode = payload.get("path_hash_mode")
if isinstance(mode, int) and 0 <= mode <= 2:
return mode, True
return 0, False
async def _set_path_hash_mode(mc, mode: int):
"""Set path hash mode using either the new helper or raw command fallback."""
commands = getattr(mc, "commands", None)
if commands is None:
raise HTTPException(status_code=503, detail="Radio command interface unavailable")
set_path_hash_mode = cast(
Callable[[int], Awaitable[Any]] | None, getattr(commands, "set_path_hash_mode", None)
)
send_raw = cast(
Callable[[bytes, list[EventType]], Awaitable[Any]] | None,
getattr(commands, "send", None),
)
if callable(set_path_hash_mode):
result = await set_path_hash_mode(mode)
elif callable(send_raw):
data = b"\x3d\x00" + int(mode).to_bytes(1, "little")
result = await send_raw(data, [EventType.OK, EventType.ERROR])
else:
raise HTTPException(
status_code=400,
detail="Installed meshcore interface library cannot set path hash mode",
)
if result is not None and result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to set path hash mode on radio")
return result
@router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration."""
mc = require_connected()
require_connected()
info = mc.self_info
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
async with radio_manager.radio_operation("get_radio_config") as mc:
info = mc.self_info
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
return RadioConfigResponse(
public_key=info.get("public_key", ""),
name=info.get("name", ""),
lat=info.get("adv_lat", 0.0),
lon=info.get("adv_lon", 0.0),
tx_power=info.get("tx_power", 0),
max_tx_power=info.get("max_tx_power", 0),
radio=RadioSettings(
freq=info.get("radio_freq", 0.0),
bw=info.get("radio_bw", 0.0),
sf=info.get("radio_sf", 0),
cr=info.get("radio_cr", 0),
),
)
path_hash_mode, path_hash_mode_supported = await _get_path_hash_mode_info(mc)
return RadioConfigResponse(
public_key=info.get("public_key", ""),
name=info.get("name", ""),
lat=info.get("adv_lat", 0.0),
lon=info.get("adv_lon", 0.0),
tx_power=info.get("tx_power", 0),
max_tx_power=info.get("max_tx_power", 0),
path_hash_mode=path_hash_mode,
path_hash_mode_supported=path_hash_mode_supported,
radio=RadioSettings(
freq=info.get("radio_freq", 0.0),
bw=info.get("radio_bw", 0.0),
sf=info.get("radio_sf", 0),
cr=info.get("radio_cr", 0),
),
)
@router.patch("/config", response_model=RadioConfigResponse)
@@ -88,6 +161,17 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
logger.info("Setting TX power to %d dBm", update.tx_power)
await mc.commands.set_tx_power(val=update.tx_power)
if update.path_hash_mode is not None:
current_mode, supported = await _get_path_hash_mode_info(mc)
if not supported:
raise HTTPException(
status_code=400,
detail="Connected radio/firmware does not expose path hash mode",
)
if current_mode != update.path_hash_mode:
logger.info("Setting path hash mode to %d", update.path_hash_mode)
await _set_path_hash_mode(mc, update.path_hash_mode)
if update.radio is not None:
logger.info(
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",

View File

@@ -43,6 +43,7 @@ export function SettingsRadioSection({
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [pathHashMode, setPathHashMode] = useState('0');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
@@ -73,6 +74,7 @@ export function SettingsRadioSection({
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setPathHashMode(String(config.path_hash_mode));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
@@ -145,6 +147,7 @@ export function SettingsRadioSection({
const parsedLat = parseFloat(lat);
const parsedLon = parseFloat(lon);
const parsedTxPower = parseInt(txPower, 10);
const parsedPathHashMode = parseInt(pathHashMode, 10);
const parsedFreq = parseFloat(freq);
const parsedBw = parseFloat(bw);
const parsedSf = parseInt(sf, 10);
@@ -159,11 +162,20 @@ export function SettingsRadioSection({
return null;
}
if (
config.path_hash_mode_supported &&
(isNaN(parsedPathHashMode) || parsedPathHashMode < 0 || parsedPathHashMode > 2)
) {
setError('Path hash mode must be between 0 and 2');
return null;
}
return {
name,
lat: parsedLat,
lon: parsedLon,
tx_power: parsedTxPower,
...(config.path_hash_mode_supported && { path_hash_mode: parsedPathHashMode }),
radio: {
freq: parsedFreq,
bw: parsedBw,
@@ -384,6 +396,26 @@ export function SettingsRadioSection({
</div>
</div>
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
disabled={!config.path_hash_mode_supported}
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 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="0">1 byte per hop</option>
<option value="1">2 bytes per hop</option>
<option value="2">3 bytes per hop</option>
</select>
<p className="text-xs text-muted-foreground">
{config.path_hash_mode_supported
? 'Controls the default hop hash width your radio uses for outbound routed paths.'
: 'Connected radio or firmware does not expose this setting.'}
</p>
</div>
<Separator />
<div className="space-y-2">

View File

@@ -173,6 +173,8 @@ const baseConfig = {
lon: 0,
tx_power: 17,
max_tx_power: 22,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
};

View File

@@ -201,6 +201,8 @@ describe('App search jump target handling', () => {
lon: 0,
tx_power: 17,
max_tx_power: 22,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
});
mocks.api.getSettings.mockResolvedValue({

View File

@@ -157,6 +157,8 @@ describe('App startup hash resolution', () => {
lon: 0,
tx_power: 17,
max_tx_power: 22,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
});
mocks.api.getSettings.mockResolvedValue({

View File

@@ -40,6 +40,8 @@ function createConfig(overrides: Partial<RadioConfig> = {}): RadioConfig {
lon: -74.006,
tx_power: 10,
max_tx_power: 20,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 915, bw: 250, sf: 10, cr: 8 },
...overrides,
};

View File

@@ -24,6 +24,8 @@ const baseConfig: RadioConfig = {
lon: 2,
tx_power: 17,
max_tx_power: 22,
path_hash_mode: 1,
path_hash_mode_supported: true,
radio: {
freq: 910.525,
bw: 62.5,
@@ -57,6 +59,7 @@ const baseSettings: AppSettings = {
};
function renderModal(overrides?: {
config?: RadioConfig;
appSettings?: AppSettings;
health?: HealthStatus;
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
@@ -83,7 +86,7 @@ function renderModal(overrides?: {
const commonProps = {
open: overrides?.open ?? true,
pageMode: overrides?.pageMode,
config: baseConfig,
config: overrides?.config ?? baseConfig,
health: overrides?.health ?? baseHealth,
appSettings: overrides?.appSettings ?? baseSettings,
onClose,
@@ -218,6 +221,36 @@ describe('SettingsModal', () => {
});
});
it('saves radio path hash mode through onSave', async () => {
const { onSave } = renderModal();
openRadioSection();
fireEvent.change(screen.getByLabelText('Path Hash Mode'), {
target: { value: '2' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
path_hash_mode: 2,
})
);
});
});
it('disables path hash mode when the connected radio does not expose it', () => {
renderModal({
config: { ...baseConfig, path_hash_mode_supported: false },
});
openRadioSection();
expect(screen.getByLabelText('Path Hash Mode')).toBeDisabled();
expect(
screen.getByText('Connected radio or firmware does not expose this setting.')
).toBeInTheDocument();
});
it('renders selected section from external sidebar nav on desktop mode', async () => {
renderModal({
externalSidebarNav: true,

View File

@@ -12,6 +12,8 @@ export interface RadioConfig {
lon: number;
tx_power: number;
max_tx_power: number;
path_hash_mode: number;
path_hash_mode_supported: boolean;
radio: RadioSettings;
}
@@ -20,6 +22,7 @@ export interface RadioConfigUpdate {
lat?: number;
lon?: number;
tx_power?: number;
path_hash_mode?: number;
radio?: RadioSettings;
}

View File

@@ -70,6 +70,10 @@ def _mock_meshcore_with_info():
mc.commands.set_tx_power = AsyncMock()
mc.commands.set_radio = AsyncMock()
mc.commands.send_appstart = AsyncMock()
mc.commands.send_device_query = AsyncMock(
return_value=_radio_result(payload={"path_hash_mode": 1})
)
mc.commands.send = AsyncMock(return_value=_radio_result())
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
return mc
@@ -78,7 +82,11 @@ class TestGetRadioConfig:
@pytest.mark.asyncio
async def test_maps_self_info_to_response(self):
mc = _mock_meshcore_with_info()
with patch("app.routers.radio.require_connected", return_value=mc):
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
):
response = await get_radio_config()
assert response.public_key == "aa" * 32
@@ -87,17 +95,38 @@ class TestGetRadioConfig:
assert response.lon == 20.0
assert response.radio.freq == 910.525
assert response.radio.cr == 5
assert response.path_hash_mode == 1
assert response.path_hash_mode_supported is True
@pytest.mark.asyncio
async def test_returns_503_when_self_info_missing(self):
mc = MagicMock()
mc.self_info = None
with patch("app.routers.radio.require_connected", return_value=mc):
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
):
with pytest.raises(HTTPException) as exc:
await get_radio_config()
assert exc.value.status_code == 503
@pytest.mark.asyncio
async def test_marks_path_hash_mode_unsupported_when_device_info_lacks_field(self):
mc = _mock_meshcore_with_info()
mc.commands.send_device_query = AsyncMock(return_value=_radio_result(payload={}))
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
):
response = await get_radio_config()
assert response.path_hash_mode == 0
assert response.path_hash_mode_supported is False
class TestUpdateRadioConfig:
@pytest.mark.asyncio
@@ -110,12 +139,15 @@ class TestUpdateRadioConfig:
lon=20.0,
tx_power=17,
max_tx_power=22,
path_hash_mode=1,
path_hash_mode_supported=True,
radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5),
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock) as mock_sync_time,
patch(
"app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected
@@ -131,6 +163,52 @@ class TestUpdateRadioConfig:
mock_sync_time.assert_awaited_once()
assert result == expected
@pytest.mark.asyncio
async def test_updates_path_hash_mode_via_raw_command_fallback(self):
mc = _mock_meshcore_with_info()
mc.commands.set_path_hash_mode = None
expected = RadioConfigResponse(
public_key="aa" * 32,
name="NodeA",
lat=10.0,
lon=20.0,
tx_power=17,
max_tx_power=22,
path_hash_mode=2,
path_hash_mode_supported=True,
radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5),
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(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(path_hash_mode=2))
mc.commands.send.assert_awaited_once_with(b"\x3d\x00\x02", [EventType.OK, EventType.ERROR])
assert result == expected
@pytest.mark.asyncio
async def test_rejects_path_hash_mode_update_when_radio_does_not_expose_it(self):
mc = _mock_meshcore_with_info()
mc.commands.send_device_query = AsyncMock(return_value=_radio_result(payload={}))
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
):
with pytest.raises(HTTPException) as exc:
await update_radio_config(RadioConfigUpdate(path_hash_mode=2))
assert exc.value.status_code == 400
assert "path hash mode" in exc.value.detail.lower()
class TestPrivateKeyImport:
@pytest.mark.asyncio