From 3a4ea8022b26a0e0c6a37b4994d80551a5d192d5 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 13 Mar 2026 17:25:28 -0700 Subject: [PATCH] Add local node discovery --- AGENTS.md | 1 + app/AGENTS.md | 1 + app/models.py | 42 +++ app/routers/radio.py | 174 +++++++++++- frontend/AGENTS.md | 5 +- frontend/src/App.tsx | 6 + frontend/src/api.ts | 7 + frontend/src/components/SettingsModal.tsx | 11 + .../settings/SettingsRadioSection.tsx | 98 ++++++- frontend/src/hooks/useRadioControl.ts | 34 ++- frontend/src/test/api.test.ts | 15 + frontend/src/test/settingsModal.test.tsx | 49 ++++ frontend/src/types.ts | 17 ++ tests/test_radio_router.py | 267 ++++++++++++++++++ 14 files changed, 721 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb7e1c7..67aa1ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/app/AGENTS.md b/app/AGENTS.md index 7b91296..0d2f0f0 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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` diff --git a/app/models.py b/app/models.py index b53b548..95ef0d2 100644 --- a/app/models.py +++ b/app/models.py @@ -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.""" diff --git a/app/routers/radio.py b/app/routers/radio.py index 6114896..8488872 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -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() diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index eddd30a..f4fac06 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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`) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ffeabc2..e3347f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 39ff59f..c9ef350 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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('/radio/discover', { + method: 'POST', + body: JSON.stringify({ target }), + }), rebootRadio: () => fetchJson<{ status: string; message: string }>('/radio/reboot', { method: 'POST', diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 22e2d6d..60a8938 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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; onReconnect: () => Promise; onAdvertise: () => Promise; + meshDiscovery: RadioDiscoveryResponse | null; + meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null; + onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise; onHealthRefresh: () => Promise; onRefreshAppSettings: () => Promise; 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} /> diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 4c3dc7c..c201f47 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -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; onReconnect: () => Promise; onAdvertise: () => Promise; + meshDiscovery: RadioDiscoveryResponse | null; + meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null; + onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise; onClose: () => void; className?: string; }) { @@ -75,6 +83,7 @@ export function SettingsRadioSection({ // Advertise state const [advertising, setAdvertising] = useState(false); + const [discoverError, setDiscoverError] = useState(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({ - {/* Send Advertisement */} +
+ +
+

@@ -704,6 +725,81 @@ export function SettingsRadioSection({

Radio not connected

)}
+ +
+ +

+ Discover nearby node types that currently respond to mesh discovery requests: repeaters + and sensors. +

+
+ {[ + { target: 'repeaters', label: 'Discover Repeaters' }, + { target: 'sensors', label: 'Discover Sensors' }, + { target: 'all', label: 'Discover Both' }, + ].map(({ target, label }) => ( + + ))} +
+ {!health?.radio_connected && ( +

Radio not connected

+ )} + {discoverError && ( +

+ {discoverError} +

+ )} + {meshDiscovery && ( +
+
+

+ Last sweep: {meshDiscovery.results.length} node + {meshDiscovery.results.length === 1 ? '' : 's'} +

+

+ {meshDiscovery.duration_seconds.toFixed(0)}s listen window +

+
+ {meshDiscovery.results.length === 0 ? ( +

+ No supported nodes responded during the last discovery sweep. +

+ ) : ( +
+ {meshDiscovery.results.map((result) => ( +
+
+ {result.node_type} + + heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'} + +
+

+ {result.public_key} +

+

+ 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. +

+
+ ))} +
+ )} +
+ )} +
); } diff --git a/frontend/src/hooks/useRadioControl.ts b/frontend/src/hooks/useRadioControl.ts index 3326133..3a2b06c 100644 --- a/frontend/src/hooks/useRadioControl.ts +++ b/frontend/src/hooks/useRadioControl.ts @@ -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(null); const [config, setConfig] = useState(null); + const [meshDiscovery, setMeshDiscovery] = useState(null); + const [meshDiscoveryLoadingTarget, setMeshDiscoveryLoadingTarget] = + useState(null); const prevHealthRef = useRef(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, }; } diff --git a/frontend/src/test/api.test.ts b/frontend/src/test/api.test.ts index f645894..4e27f8d 100644 --- a/frontend/src/test/api.test.ts +++ b/frontend/src/test/api.test.ts @@ -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({ diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 9d2144b..41c169e 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -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; onDisconnect?: () => Promise; onReconnect?: () => Promise; + meshDiscovery?: RadioDiscoveryResponse | null; + meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null; + onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise; 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 () => {})} /> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 603d979..a1e4ea9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index 0d4bcdb..10a2afd 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -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()