Add local node discovery

This commit is contained in:
Jack Kingsman
2026-03-13 17:25:28 -07:00
parent bd19015693
commit 3a4ea8022b
14 changed files with 721 additions and 6 deletions
+1
View File
@@ -295,6 +295,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| 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/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
| POST | `/api/radio/reconnect` | Manual radio reconnection |
+1
View File
@@ -158,6 +158,7 @@ app/
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
- `PUT /radio/private-key`
- `POST /radio/advertise`
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
- `POST /radio/disconnect`
- `POST /radio/reboot`
- `POST /radio/reconnect`
+42
View File
@@ -540,6 +540,48 @@ class CommandResponse(BaseModel):
)
class RadioDiscoveryRequest(BaseModel):
"""Request to discover nearby mesh nodes from the local radio."""
target: Literal["repeaters", "sensors", "all"] = Field(
default="all",
description="Which node classes to discover over the mesh",
)
class RadioDiscoveryResult(BaseModel):
"""One mesh node heard during a discovery sweep."""
public_key: str = Field(description="Discovered node public key as hex")
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
heard_count: int = Field(default=1, description="How many responses were heard from this node")
local_snr: float | None = Field(
default=None,
description="SNR at which the local radio heard the response (dB)",
)
local_rssi: int | None = Field(
default=None,
description="RSSI at which the local radio heard the response (dBm)",
)
remote_snr: float | None = Field(
default=None,
description="SNR reported by the remote node while hearing our discovery request (dB)",
)
class RadioDiscoveryResponse(BaseModel):
"""Response payload for a mesh discovery sweep."""
target: Literal["repeaters", "sensors", "all"] = Field(
description="Which node classes were requested"
)
duration_seconds: float = Field(description="How long the sweep listened for responses")
results: list[RadioDiscoveryResult] = Field(
default_factory=list,
description="Deduplicated discovery responses heard during the sweep",
)
class Favorite(BaseModel):
"""A favorite conversation."""
+172 -2
View File
@@ -1,12 +1,23 @@
import asyncio
import logging
from typing import Literal
import random
import time
from typing import Literal, TypeAlias
from fastapi import APIRouter, HTTPException
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import (
ContactUpsert,
RadioDiscoveryRequest,
RadioDiscoveryResponse,
RadioDiscoveryResult,
)
from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time
from app.repository import ContactRepository
from app.services.radio_commands import (
KeystoreRefreshError,
PathHashModeUnsupportedError,
@@ -15,12 +26,23 @@ from app.services.radio_commands import (
import_private_key_and_refresh_keystore,
)
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_health
from app.websocket import broadcast_event, broadcast_health
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
AdvertLocationSource = Literal["off", "current"]
DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"]
DISCOVERY_WINDOW_SECONDS = 8.0
_DISCOVERY_TARGET_BITS = {
"repeaters": 1 << 2,
"sensors": 1 << 4,
"all": (1 << 2) | (1 << 4),
}
_DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
2: "repeater",
4: "sensor",
}
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
@@ -82,6 +104,88 @@ class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
def _monotonic() -> float:
return time.monotonic()
def _better_signal(first: float | None, second: float | None) -> float | None:
if first is None:
return second
if second is None:
return first
return second if second > first else first
def _coerce_float(value: object) -> float | None:
if isinstance(value, (int, float)):
return float(value)
return None
def _coerce_int(value: object) -> int | None:
if isinstance(value, int):
return value
return None
def _merge_discovery_result(
existing: RadioDiscoveryResult | None, event_payload: dict[str, object]
) -> RadioDiscoveryResult | None:
public_key = event_payload.get("pubkey")
node_type_code = event_payload.get("node_type")
if not isinstance(public_key, str) or not public_key:
return existing
if not isinstance(node_type_code, int):
return existing
node_type = _DISCOVERY_NODE_TYPES.get(node_type_code)
if node_type is None:
return existing
if existing is None:
return RadioDiscoveryResult(
public_key=public_key,
node_type=node_type,
heard_count=1,
local_snr=_coerce_float(event_payload.get("SNR")),
local_rssi=_coerce_int(event_payload.get("RSSI")),
remote_snr=_coerce_float(event_payload.get("SNR_in")),
)
existing.heard_count += 1
existing.local_snr = _better_signal(existing.local_snr, _coerce_float(event_payload.get("SNR")))
current_rssi = _coerce_int(event_payload.get("RSSI"))
if existing.local_rssi is None or (
current_rssi is not None and current_rssi > existing.local_rssi
):
existing.local_rssi = current_rssi
existing.remote_snr = _better_signal(
existing.remote_snr,
_coerce_float(event_payload.get("SNR_in")),
)
return existing
async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -> None:
now = int(time.time())
for result in results:
existing = await ContactRepository.get_by_key(result.public_key)
if existing is not None:
continue
contact = ContactUpsert(
public_key=result.public_key,
type=2 if result.node_type == "repeater" else 4,
last_seen=now,
first_seen=now,
on_radio=False,
)
await ContactRepository.upsert(contact)
created = await ContactRepository.get_by_key(result.public_key)
if created is not None:
broadcast_event("contact", created.model_dump())
@router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration."""
@@ -184,6 +288,72 @@ async def send_advertisement() -> dict:
return {"status": "ok"}
@router.post("/discover", response_model=RadioDiscoveryResponse)
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
"""Run a short node-discovery sweep from the local radio."""
require_connected()
target_bits = _DISCOVERY_TARGET_BITS[request.target]
tag = random.randint(1, 0xFFFFFFFF)
tag_hex = tag.to_bytes(4, "little", signed=False).hex()
events: asyncio.Queue = asyncio.Queue()
async with radio_manager.radio_operation(
"discover_mesh",
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
subscription = mc.subscribe(
EventType.DISCOVER_RESPONSE,
lambda event: events.put_nowait(event),
{"tag": tag_hex},
)
try:
send_result = await mc.commands.send_node_discover_req(
target_bits,
prefix_only=False,
tag=tag,
)
if send_result is None or send_result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to start mesh discovery")
deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS
results_by_key: dict[str, RadioDiscoveryResult] = {}
while True:
remaining = deadline - _monotonic()
if remaining <= 0:
break
try:
event = await asyncio.wait_for(events.get(), timeout=remaining)
except asyncio.TimeoutError:
break
merged = _merge_discovery_result(
results_by_key.get(event.payload.get("pubkey")),
event.payload,
)
if merged is not None:
results_by_key[merged.public_key] = merged
finally:
subscription.unsubscribe()
results = sorted(
results_by_key.values(),
key=lambda item: (
item.node_type,
-(item.local_snr if item.local_snr is not None else -999.0),
item.public_key,
),
)
await _persist_new_discovery_contacts(results)
return RadioDiscoveryResponse(
target=request.target,
duration_seconds=DISCOVERY_WINDOW_SECONDS,
results=results,
)
async def _attempt_reconnect() -> dict:
"""Shared reconnection logic for reboot and reconnect endpoints."""
radio_manager.resume_connection()
+3 -2
View File
@@ -51,7 +51,7 @@ frontend/src/
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
│ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker)
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
│ ├── useRadioControl.ts # Radio health/config state, reconnection
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── useConversationRouter.ts # URL hash → active conversation routing
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
@@ -110,7 +110,7 @@ frontend/src/
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
@@ -250,6 +250,7 @@ High-level state is delegated to hooks:
- `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.
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
- 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`)
+6
View File
@@ -122,6 +122,9 @@ export function App() {
handleDisconnect,
handleReconnect,
handleAdvertise,
meshDiscovery,
meshDiscoveryLoadingTarget,
handleDiscoverMesh,
handleHealthRefresh,
} = useRadioControl();
@@ -451,6 +454,9 @@ export function App() {
onDisconnect: handleDisconnect,
onReconnect: handleReconnect,
onAdvertise: handleAdvertise,
meshDiscovery,
meshDiscoveryLoadingTarget,
onDiscoverMesh: handleDiscoverMesh,
onHealthRefresh: handleHealthRefresh,
onRefreshAppSettings: fetchAppSettings,
blockedKeys: appSettings?.blocked_keys,
+7
View File
@@ -17,6 +17,8 @@ import type {
MigratePreferencesResponse,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
RepeaterLoginResponse,
@@ -95,6 +97,11 @@ export const api = {
fetchJson<{ status: string }>('/radio/advertise', {
method: 'POST',
}),
discoverMesh: (target: RadioDiscoveryTarget) =>
fetchJson<RadioDiscoveryResponse>('/radio/discover', {
method: 'POST',
body: JSON.stringify({ target }),
}),
rebootRadio: () =>
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',
+11
View File
@@ -5,6 +5,8 @@ import type {
HealthStatus,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
} from '../types';
import type { LocalLabel } from '../utils/localLabel';
import {
@@ -34,6 +36,9 @@ interface SettingsModalBaseProps {
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
onHealthRefresh: () => Promise<void>;
onRefreshAppSettings: () => Promise<void>;
onLocalLabelChange?: (label: LocalLabel) => void;
@@ -64,6 +69,9 @@ export function SettingsModal(props: SettingsModalProps) {
onDisconnect,
onReconnect,
onAdvertise,
meshDiscovery,
meshDiscoveryLoadingTarget,
onDiscoverMesh,
onHealthRefresh,
onRefreshAppSettings,
onLocalLabelChange,
@@ -189,6 +197,9 @@ export function SettingsModal(props: SettingsModalProps) {
onDisconnect={onDisconnect}
onReconnect={onReconnect}
onAdvertise={onAdvertise}
meshDiscovery={meshDiscovery}
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
onDiscoverMesh={onDiscoverMesh}
onClose={onClose}
className={sectionContentClass}
/>
@@ -13,6 +13,8 @@ import type {
HealthStatus,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
} from '../../types';
export function SettingsRadioSection({
@@ -27,6 +29,9 @@ export function SettingsRadioSection({
onDisconnect,
onReconnect,
onAdvertise,
meshDiscovery,
meshDiscoveryLoadingTarget,
onDiscoverMesh,
onClose,
className,
}: {
@@ -41,6 +46,9 @@ export function SettingsRadioSection({
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
onClose: () => void;
className?: string;
}) {
@@ -75,6 +83,7 @@ export function SettingsRadioSection({
// Advertise state
const [advertising, setAdvertising] = useState(false);
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [connectionBusy, setConnectionBusy] = useState(false);
useEffect(() => {
@@ -295,6 +304,15 @@ export function SettingsRadioSection({
}
};
const handleDiscover = async (target: RadioDiscoveryTarget) => {
setDiscoverError(null);
try {
await onDiscoverMesh(target);
} catch (err) {
setDiscoverError(err instanceof Error ? err.message : 'Failed to run mesh discovery');
}
};
const radioState =
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
const connectionActionLabel =
@@ -687,7 +705,10 @@ export function SettingsRadioSection({
<Separator />
{/* Send Advertisement */}
<div className="space-y-2">
<Label className="text-base">Hear &amp; Be Heard</Label>
</div>
<div className="space-y-2">
<Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
@@ -704,6 +725,81 @@ export function SettingsRadioSection({
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
<div className="space-y-3">
<Label>Mesh Discovery</Label>
<p className="text-xs text-muted-foreground">
Discover nearby node types that currently respond to mesh discovery requests: repeaters
and sensors.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ target: 'repeaters', label: 'Discover Repeaters' },
{ target: 'sensors', label: 'Discover Sensors' },
{ target: 'all', label: 'Discover Both' },
].map(({ target, label }) => (
<Button
key={target}
type="button"
variant="outline"
onClick={() => handleDiscover(target as RadioDiscoveryTarget)}
disabled={meshDiscoveryLoadingTarget !== null || !health?.radio_connected}
className="w-full"
>
{meshDiscoveryLoadingTarget === target ? 'Listening...' : label}
</Button>
))}
</div>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
{discoverError && (
<p className="text-sm text-destructive" role="alert">
{discoverError}
</p>
)}
{meshDiscovery && (
<div className="space-y-2 rounded-md border border-input bg-muted/20 p-3">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium">
Last sweep: {meshDiscovery.results.length} node
{meshDiscovery.results.length === 1 ? '' : 's'}
</p>
<p className="text-xs text-muted-foreground">
{meshDiscovery.duration_seconds.toFixed(0)}s listen window
</p>
</div>
{meshDiscovery.results.length === 0 ? (
<p className="text-sm text-muted-foreground">
No supported nodes responded during the last discovery sweep.
</p>
) : (
<div className="space-y-2">
{meshDiscovery.results.map((result) => (
<div
key={result.public_key}
className="rounded-md border border-input bg-background px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium capitalize">{result.node_type}</span>
<span className="text-xs text-muted-foreground">
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
</span>
</div>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{result.public_key}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Heard here: {result.local_snr ?? 'n/a'} dB SNR / {result.local_rssi ?? 'n/a'}{' '}
dBm RSSI. Remote heard us: {result.remote_snr ?? 'n/a'} dB SNR.
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+33 -1
View File
@@ -2,11 +2,20 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { api } from '../api';
import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import type { HealthStatus, RadioConfig, RadioConfigUpdate } from '../types';
import type {
HealthStatus,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
} from '../types';
export function useRadioControl() {
const [health, setHealth] = useState<HealthStatus | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [meshDiscovery, setMeshDiscovery] = useState<RadioDiscoveryResponse | null>(null);
const [meshDiscoveryLoadingTarget, setMeshDiscoveryLoadingTarget] =
useState<RadioDiscoveryTarget | null>(null);
const prevHealthRef = useRef<HealthStatus | null>(null);
const rebootPollTokenRef = useRef(0);
@@ -96,6 +105,26 @@ export function useRadioControl() {
}
}, []);
const handleDiscoverMesh = useCallback(async (target: RadioDiscoveryTarget) => {
setMeshDiscoveryLoadingTarget(target);
try {
const data = await api.discoverMesh(target);
setMeshDiscovery(data);
toast.success(
data.results.length === 0
? 'No nearby nodes responded'
: `Found ${data.results.length} nearby node${data.results.length === 1 ? '' : 's'}`
);
} catch (err) {
console.error('Failed to discover nearby nodes:', err);
toast.error('Failed to run mesh discovery', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
} finally {
setMeshDiscoveryLoadingTarget(null);
}
}, []);
const handleHealthRefresh = useCallback(async () => {
try {
const data = await api.getHealth();
@@ -118,6 +147,9 @@ export function useRadioControl() {
handleDisconnect,
handleReconnect,
handleAdvertise,
meshDiscovery,
meshDiscoveryLoadingTarget,
handleDiscoverMesh,
handleHealthRefresh,
};
}
+15
View File
@@ -257,6 +257,21 @@ describe('fetchJson (via api methods)', () => {
expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
});
it('sends POST with JSON body for mesh discovery', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ target: 'repeaters', duration_seconds: 8, results: [] }),
});
await api.discoverMesh('repeaters');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/discover');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
});
it('sends DELETE for deleteContact', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
+49
View File
@@ -8,6 +8,8 @@ import type {
HealthStatus,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
StatisticsResponse,
} from '../types';
import type { SettingsSection } from '../components/settings/settingsConstants';
@@ -71,6 +73,9 @@ function renderModal(overrides?: {
onReboot?: () => Promise<void>;
onDisconnect?: () => Promise<void>;
onReconnect?: () => Promise<void>;
meshDiscovery?: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
open?: boolean;
pageMode?: boolean;
externalSidebarNav?: boolean;
@@ -87,6 +92,7 @@ function renderModal(overrides?: {
const onReboot = overrides?.onReboot ?? vi.fn(async () => {});
const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {});
const onReconnect = overrides?.onReconnect ?? vi.fn(async () => {});
const onDiscoverMesh = overrides?.onDiscoverMesh ?? vi.fn(async () => {});
const commonProps = {
open: overrides?.open ?? true,
@@ -102,6 +108,9 @@ function renderModal(overrides?: {
onDisconnect,
onReconnect,
onAdvertise: vi.fn(async () => {}),
meshDiscovery: overrides?.meshDiscovery ?? null,
meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null,
onDiscoverMesh,
onHealthRefresh: vi.fn(async () => {}),
onRefreshAppSettings,
};
@@ -125,6 +134,7 @@ function renderModal(overrides?: {
onReboot,
onDisconnect,
onReconnect,
onDiscoverMesh,
view,
};
}
@@ -204,6 +214,42 @@ describe('SettingsModal', () => {
expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument();
});
it('runs repeater mesh discovery from the radio tab', async () => {
const { onDiscoverMesh } = renderModal();
openRadioSection();
fireEvent.click(screen.getByRole('button', { name: 'Discover Repeaters' }));
await waitFor(() => {
expect(onDiscoverMesh).toHaveBeenCalledWith('repeaters');
});
});
it('renders mesh discovery results in the radio tab', () => {
renderModal({
meshDiscovery: {
target: 'all',
duration_seconds: 8,
results: [
{
public_key: '11'.repeat(32),
node_type: 'repeater',
heard_count: 2,
local_snr: 7.5,
local_rssi: -101,
remote_snr: 4,
},
],
},
});
openRadioSection();
expect(screen.getByText('Last sweep: 1 node')).toBeInTheDocument();
expect(screen.getByText('repeater')).toBeInTheDocument();
expect(screen.getByText('heard 2 times')).toBeInTheDocument();
expect(screen.getByText('8s listen window')).toBeInTheDocument();
});
it('saves advert location source through radio config save', async () => {
const { onSave } = renderModal();
openRadioSection();
@@ -336,6 +382,9 @@ describe('SettingsModal', () => {
onDisconnect={vi.fn(async () => {})}
onReconnect={vi.fn(async () => {})}
onAdvertise={vi.fn(async () => {})}
meshDiscovery={null}
meshDiscoveryLoadingTarget={null}
onDiscoverMesh={vi.fn(async () => {})}
onHealthRefresh={vi.fn(async () => {})}
onRefreshAppSettings={vi.fn(async () => {})}
/>
+17
View File
@@ -28,6 +28,23 @@ export interface RadioConfigUpdate {
advert_location_source?: 'off' | 'current';
}
export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
export interface RadioDiscoveryResult {
public_key: string;
node_type: 'repeater' | 'sensor';
heard_count: number;
local_snr: number | null;
local_rssi: number | null;
remote_snr: number | null;
}
export interface RadioDiscoveryResponse {
target: RadioDiscoveryTarget;
duration_seconds: number;
results: RadioDiscoveryResult[];
}
export interface FanoutStatusEntry {
name: string;
type: string;
+267
View File
@@ -9,13 +9,16 @@ from fastapi import HTTPException
from meshcore import EventType
from pydantic import ValidationError
from app.models import Contact
from app.radio import RadioManager, radio_manager
from app.routers.radio import (
PrivateKeyUpdate,
RadioConfigResponse,
RadioConfigUpdate,
RadioDiscoveryRequest,
RadioSettings,
disconnect_radio,
discover_mesh,
get_radio_config,
reboot_radio,
reconnect_radio,
@@ -80,6 +83,9 @@ def _mock_meshcore_with_info():
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())
mc.commands.send_node_discover_req = AsyncMock(return_value=_radio_result())
mc.stop_auto_message_fetching = AsyncMock()
mc.start_auto_message_fetching = AsyncMock()
return mc
@@ -256,6 +262,267 @@ class TestPrivateKeyImport:
assert exc.value.status_code == 500
class TestDiscoverMesh:
@pytest.mark.asyncio
async def test_discovers_repeaters_and_deduplicates_by_pubkey(self):
mc = _mock_meshcore_with_info()
callbacks = {}
def _subscribe(event_type, callback, attribute_filters=None):
callbacks["event_type"] = event_type
callbacks["callback"] = callback
callbacks["filters"] = attribute_filters
subscription = MagicMock()
subscription.unsubscribe = MagicMock()
callbacks["subscription"] = subscription
return subscription
async def _send_node_discover_req(filter_bits, prefix_only=True, tag=None, since=None):
assert filter_bits == (1 << 2)
assert prefix_only is False
assert since is None
callbacks["callback"](
_radio_result(
payload={
"pubkey": "11" * 32,
"node_type": 2,
"SNR": 7.5,
"RSSI": -101,
"SNR_in": 4.0,
}
)
)
callbacks["callback"](
_radio_result(
payload={
"pubkey": "11" * 32,
"node_type": 2,
"SNR": 9.0,
"RSSI": -99,
"SNR_in": 3.0,
}
)
)
callbacks["callback"](
_radio_result(
payload={
"pubkey": "22" * 32,
"node_type": 2,
"SNR": 2.5,
"RSSI": -110,
"SNR_in": 1.0,
}
)
)
return _radio_result()
mc.subscribe = MagicMock(side_effect=_subscribe)
mc.commands.send_node_discover_req = AsyncMock(side_effect=_send_node_discover_req)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
return_value=None,
),
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
assert response.target == "repeaters"
assert len(response.results) == 2
assert response.results[0].public_key == "11" * 32
assert response.results[0].node_type == "repeater"
assert response.results[0].heard_count == 2
assert response.results[0].local_snr == 9.0
assert response.results[0].local_rssi == -99
assert response.results[0].remote_snr == 4.0
assert callbacks["event_type"] == EventType.DISCOVER_RESPONSE
assert callbacks["subscription"].unsubscribe.called
mc.stop_auto_message_fetching.assert_awaited_once()
mc.start_auto_message_fetching.assert_awaited_once()
@pytest.mark.asyncio
async def test_persists_newly_discovered_nodes_and_broadcasts_contact_updates(self):
mc = _mock_meshcore_with_info()
created_contact = Contact(
public_key="44" * 32,
name=None,
type=2,
flags=0,
last_path=None,
last_path_len=-1,
out_path_hash_mode=0,
last_advert=None,
lat=None,
lon=None,
last_seen=123,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=123,
)
def _subscribe(_event_type, callback, _attribute_filters=None):
callback(
_radio_result(
payload={
"pubkey": "44" * 32,
"node_type": 2,
"SNR": 6.0,
"RSSI": -100,
"SNR_in": 2.5,
}
)
)
return MagicMock(unsubscribe=MagicMock())
mc.subscribe = MagicMock(side_effect=_subscribe)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
side_effect=[None, created_contact],
) as mock_get_by_key,
patch(
"app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock
) as mock_upsert,
patch("app.routers.radio.broadcast_event") as mock_broadcast,
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
assert len(response.results) == 1
mock_get_by_key.assert_awaited()
mock_upsert.assert_awaited_once()
upsert_arg = mock_upsert.await_args.args[0]
assert upsert_arg.public_key == "44" * 32
assert upsert_arg.type == 2
assert upsert_arg.on_radio is False
mock_broadcast.assert_called_once_with("contact", created_contact.model_dump())
@pytest.mark.asyncio
async def test_does_not_reinsert_existing_discovered_nodes(self):
mc = _mock_meshcore_with_info()
existing_contact = Contact(
public_key="55" * 32,
name="Known",
type=4,
flags=0,
last_path=None,
last_path_len=-1,
out_path_hash_mode=0,
last_advert=None,
lat=None,
lon=None,
last_seen=123,
on_radio=False,
last_contacted=None,
last_read_at=None,
first_seen=123,
)
def _subscribe(_event_type, callback, _attribute_filters=None):
callback(
_radio_result(
payload={
"pubkey": "55" * 32,
"node_type": 4,
"SNR": 5.0,
"RSSI": -102,
"SNR_in": 1.5,
}
)
)
return MagicMock(unsubscribe=MagicMock())
mc.subscribe = MagicMock(side_effect=_subscribe)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
return_value=existing_contact,
),
patch(
"app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock
) as mock_upsert,
patch("app.routers.radio.broadcast_event") as mock_broadcast,
):
await discover_mesh(RadioDiscoveryRequest(target="sensors"))
mock_upsert.assert_not_awaited()
mock_broadcast.assert_not_called()
@pytest.mark.asyncio
async def test_discovers_all_supported_types(self):
mc = _mock_meshcore_with_info()
def _subscribe(_event_type, callback, _attribute_filters=None):
callback(
_radio_result(
payload={
"pubkey": "33" * 32,
"node_type": 4,
"SNR": 5.0,
"RSSI": -100,
"SNR_in": 2.0,
}
)
)
subscription = MagicMock()
subscription.unsubscribe = MagicMock()
return subscription
mc.subscribe = MagicMock(side_effect=_subscribe)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.DISCOVERY_WINDOW_SECONDS", 0.01),
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
return_value=None,
),
patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
mc.commands.send_node_discover_req.assert_awaited_once()
assert mc.commands.send_node_discover_req.await_args.args[0] == (1 << 2) | (1 << 4)
assert response.results[0].node_type == "sensor"
@pytest.mark.asyncio
async def test_raises_when_discovery_request_fails(self):
mc = _mock_meshcore_with_info()
mc.subscribe = MagicMock(return_value=MagicMock(unsubscribe=MagicMock()))
mc.commands.send_node_discover_req = AsyncMock(
return_value=_radio_result(EventType.ERROR, {"error": "nope"})
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
with pytest.raises(HTTPException) as exc:
await discover_mesh(RadioDiscoveryRequest(target="sensors"))
assert exc.value.status_code == 500
assert exc.value.detail == "Failed to start mesh discovery"
@pytest.mark.asyncio
async def test_successful_import_refreshes_keystore(self):
mc = _mock_meshcore_with_info()