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