diff --git a/app/routers/radio.py b/app/routers/radio.py index 9c53e77..c8a8bfb 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -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", diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 33b8e48..06fb42f 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -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({ +
+ + +

+ {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.'} +

+
+
diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 6bc2630..e9f1ec5 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -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 }, }; diff --git a/frontend/src/test/appSearchJump.test.tsx b/frontend/src/test/appSearchJump.test.tsx index dd4075f..5affd57 100644 --- a/frontend/src/test/appSearchJump.test.tsx +++ b/frontend/src/test/appSearchJump.test.tsx @@ -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({ diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index c738a68..cd6b22a 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -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({ diff --git a/frontend/src/test/pathUtils.test.ts b/frontend/src/test/pathUtils.test.ts index 3f03643..e158623 100644 --- a/frontend/src/test/pathUtils.test.ts +++ b/frontend/src/test/pathUtils.test.ts @@ -40,6 +40,8 @@ function createConfig(overrides: Partial = {}): 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, }; diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 96fa50f..1917fe6 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -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; @@ -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, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c9e1323..c0aacf8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; } diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index b3be54f..bdb6e0c 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -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