mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Phase 3
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user