mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-23 03:25:10 +02:00
Add local node discovery
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {})}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user