mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-09 02:03:19 +02:00
Add local node discovery
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user