Add repeater region discover

This commit is contained in:
Jack Kingsman
2026-07-10 15:00:14 -07:00
parent 789d37e240
commit 5da6e19c9b
14 changed files with 639 additions and 12 deletions
+6
View File
@@ -159,6 +159,9 @@ export function App() {
meshDiscovery,
meshDiscoveryLoadingTarget,
handleDiscoverMesh,
regionDiscovery,
regionDiscoveryLoading,
handleDiscoverRegions,
handleHealthRefresh,
} = useRadioControl();
@@ -707,6 +710,9 @@ export function App() {
meshDiscovery,
meshDiscoveryLoadingTarget,
onDiscoverMesh: handleDiscoverMesh,
regionDiscovery,
regionDiscoveryLoading,
onDiscoverRegions: handleDiscoverRegions,
onHealthRefresh: handleHealthRefresh,
onRefreshAppSettings: fetchAppSettings,
blockedKeys: appSettings?.blocked_keys,
+6
View File
@@ -19,6 +19,7 @@ import type {
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioRegionDiscoveryResponse,
RadioTraceHopRequest,
RadioTraceResponse,
RadioDiscoveryTarget,
@@ -115,6 +116,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ target }),
}),
discoverRegions: (publicKeys?: string[]) =>
fetchJson<RadioRegionDiscoveryResponse>('/radio/discover-regions', {
method: 'POST',
body: JSON.stringify(publicKeys && publicKeys.length > 0 ? { public_keys: publicKeys } : {}),
}),
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
fetchJson<RadioTraceResponse>('/radio/trace', {
method: 'POST',
+10
View File
@@ -10,6 +10,7 @@ import type {
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
RadioRegionDiscoveryResponse,
} from '../types';
import type { LocalLabel } from '../utils/localLabel';
import {
@@ -43,6 +44,9 @@ interface SettingsModalBaseProps {
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
regionDiscovery: RadioRegionDiscoveryResponse | null;
regionDiscoveryLoading: boolean;
onDiscoverRegions: (publicKeys?: string[]) => Promise<void>;
onHealthRefresh: () => Promise<void>;
onRefreshAppSettings: () => Promise<void>;
onLocalLabelChange?: (label: LocalLabel) => void;
@@ -83,6 +87,9 @@ export function SettingsModal(props: SettingsModalProps) {
meshDiscovery,
meshDiscoveryLoadingTarget,
onDiscoverMesh,
regionDiscovery,
regionDiscoveryLoading,
onDiscoverRegions,
onHealthRefresh,
onRefreshAppSettings,
onLocalLabelChange,
@@ -218,6 +225,9 @@ export function SettingsModal(props: SettingsModalProps) {
meshDiscovery={meshDiscovery}
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
onDiscoverMesh={onDiscoverMesh}
regionDiscovery={regionDiscovery}
regionDiscoveryLoading={regionDiscoveryLoading}
onDiscoverRegions={onDiscoverRegions}
onClose={onClose}
className={sectionContentClass}
/>
@@ -26,6 +26,7 @@ import type {
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
RadioRegionDiscoveryResponse,
RadioStatsSnapshot,
} from '../../types';
@@ -151,6 +152,9 @@ export function SettingsRadioSection({
meshDiscovery,
meshDiscoveryLoadingTarget,
onDiscoverMesh,
regionDiscovery,
regionDiscoveryLoading,
onDiscoverRegions,
onClose,
className,
}: {
@@ -168,6 +172,9 @@ export function SettingsRadioSection({
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
regionDiscovery: RadioRegionDiscoveryResponse | null;
regionDiscoveryLoading: boolean;
onDiscoverRegions: (publicKeys?: string[]) => Promise<void>;
onClose: () => void;
className?: string;
}) {
@@ -478,6 +485,34 @@ export function SettingsRadioSection({
}
};
const handleDiscoverRegions = async () => {
// Prefer repeaters from the most recent mesh-discovery sweep (they just
// answered, so they're likely in range for the direct-routed regions
// request); otherwise let the backend pick recent repeater contacts.
const discoveredRepeaterKeys = (meshDiscovery?.results ?? [])
.filter((r) => r.node_type === 'repeater')
.map((r) => r.public_key);
await onDiscoverRegions(discoveredRepeaterKeys);
};
const handleAddDiscoveredRegions = () => {
if (!regionDiscovery || regionDiscovery.regions.length === 0) return;
const existing = knownRegions
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
const seen = new Set(existing.map((s) => s.toLowerCase()));
const additions = regionDiscovery.regions.filter((r) => !seen.has(r.toLowerCase()));
if (additions.length === 0) {
toast.info('All discovered regions are already listed');
return;
}
setKnownRegions([...existing, ...additions].join('\n'));
toast.success(
`Added ${additions.length} region${additions.length === 1 ? '' : 's'} — review and Save Messaging Settings`
);
};
const importInputRef = useRef<HTMLInputElement>(null);
const [keyImportDialogOpen, setKeyImportDialogOpen] = useState(false);
const pendingImportRef = useRef<Record<string, unknown> | null>(null);
@@ -1186,6 +1221,72 @@ export function SettingsRadioSection({
label instead of a raw transport code. Saving a change re-tags existing messages whose
original packet is still stored.
</p>
<div className="space-y-2 rounded-md border border-input bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Discover regions from repeaters
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDiscoverRegions}
disabled={regionDiscoveryLoading || !health?.radio_connected}
>
{regionDiscoveryLoading ? 'Asking repeaters...' : 'Discover Regions'}
</Button>
</div>
<p className="text-[0.8125rem] text-muted-foreground">
Asks nearby repeaters which regions they flood, so you can populate the list above. Uses
repeaters from your last mesh discovery sweep when available, otherwise your most
recently seen repeaters. Only repeaters in direct range answer, and this only reveals
flood-allowed regions (not blocked ones).
</p>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
{regionDiscovery && (
<div className="space-y-2">
<p className="text-sm font-medium">
{regionDiscovery.repeaters_answered}/{regionDiscovery.repeaters_queried} repeater
{regionDiscovery.repeaters_queried === 1 ? '' : 's'} answered
{regionDiscovery.regions.length > 0
? `${regionDiscovery.regions.length} region${regionDiscovery.regions.length === 1 ? '' : 's'} found`
: ''}
</p>
{regionDiscovery.regions.length > 0 ? (
<>
<div className="flex flex-wrap gap-1.5">
{regionDiscovery.regions.map((region) => (
<span
key={region}
className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 font-mono"
>
{region}
</span>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddDiscoveredRegions}
className="border-success/50 text-success hover:bg-success/10"
>
Add to Known Regions
</Button>
</>
) : (
regionDiscovery.repeaters_queried > 0 && (
<p className="text-sm text-muted-foreground">
No flood-allowed regions were reported.
</p>
)
)}
</div>
)}
</div>
</div>
<div className="space-y-2">
+32
View File
@@ -9,6 +9,7 @@ import type {
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
RadioRegionDiscoveryResponse,
} from '../types';
export function useRadioControl() {
@@ -17,6 +18,8 @@ export function useRadioControl() {
const [meshDiscovery, setMeshDiscovery] = useState<RadioDiscoveryResponse | null>(null);
const [meshDiscoveryLoadingTarget, setMeshDiscoveryLoadingTarget] =
useState<RadioDiscoveryTarget | null>(null);
const [regionDiscovery, setRegionDiscovery] = useState<RadioRegionDiscoveryResponse | null>(null);
const [regionDiscoveryLoading, setRegionDiscoveryLoading] = useState(false);
const prevHealthRef = useRef<HealthStatus | null>(null);
const rebootPollTokenRef = useRef(0);
@@ -127,6 +130,32 @@ export function useRadioControl() {
}
}, []);
const handleDiscoverRegions = useCallback(async (publicKeys?: string[]) => {
setRegionDiscoveryLoading(true);
try {
const data = await api.discoverRegions(publicKeys);
setRegionDiscovery(data);
if (data.repeaters_queried === 0) {
toast.info('No repeaters available to query for regions');
} else if (data.regions.length === 0) {
toast.info(
`No regions reported (${data.repeaters_answered}/${data.repeaters_queried} repeaters answered)`
);
} else {
toast.success(
`Found ${data.regions.length} region${data.regions.length === 1 ? '' : 's'} from ${data.repeaters_answered}/${data.repeaters_queried} repeaters`
);
}
} catch (err) {
console.error('Failed to discover regions:', err);
toast.error('Failed to discover regions', {
description: err instanceof Error ? err.message : 'Check radio connection',
});
} finally {
setRegionDiscoveryLoading(false);
}
}, []);
const handleHealthRefresh = useCallback(async () => {
try {
const data = await api.getHealth();
@@ -152,6 +181,9 @@ export function useRadioControl() {
meshDiscovery,
meshDiscoveryLoadingTarget,
handleDiscoverMesh,
regionDiscovery,
regionDiscoveryLoading,
handleDiscoverRegions,
handleHealthRefresh,
};
}
+68
View File
@@ -12,6 +12,7 @@ import type {
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
RadioRegionDiscoveryResponse,
StatisticsResponse,
} from '../types';
import type { SettingsSection } from '../components/settings/settingsConstants';
@@ -93,6 +94,7 @@ function renderModal(overrides?: {
meshDiscovery?: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
regionDiscovery?: RadioRegionDiscoveryResponse | null;
contacts?: Contact[];
trackedTelemetryRepeaters?: string[];
open?: boolean;
@@ -113,6 +115,7 @@ function renderModal(overrides?: {
const onReconnect = overrides?.onReconnect ?? vi.fn(async () => {});
const onAdvertise = overrides?.onAdvertise ?? vi.fn(async (_mode: RadioAdvertMode) => {});
const onDiscoverMesh = overrides?.onDiscoverMesh ?? vi.fn(async () => {});
const onDiscoverRegions = vi.fn(async () => {});
const commonProps = {
open: overrides?.open ?? true,
@@ -131,6 +134,9 @@ function renderModal(overrides?: {
meshDiscovery: overrides?.meshDiscovery ?? null,
meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null,
onDiscoverMesh,
regionDiscovery: overrides?.regionDiscovery ?? null,
regionDiscoveryLoading: false,
onDiscoverRegions,
onHealthRefresh: vi.fn(async () => {}),
onRefreshAppSettings,
contacts: overrides?.contacts,
@@ -158,6 +164,7 @@ function renderModal(overrides?: {
onReconnect,
onAdvertise,
onDiscoverMesh,
onDiscoverRegions,
view,
};
}
@@ -335,6 +342,67 @@ describe('SettingsModal', () => {
expect(screen.getByText('8s listen window')).toBeInTheDocument();
});
it('discovers regions using repeaters from the last mesh sweep', async () => {
const { onDiscoverRegions } = renderModal({
meshDiscovery: {
target: 'all',
duration_seconds: 8,
results: [
{
public_key: '11'.repeat(32),
name: 'RPT-A',
node_type: 'repeater',
heard_count: 1,
local_snr: 5,
local_rssi: -100,
remote_snr: 3,
},
{
public_key: '22'.repeat(32),
name: 'Sensor',
node_type: 'sensor',
heard_count: 1,
local_snr: 5,
local_rssi: -100,
remote_snr: 3,
},
],
},
});
openRadioSection();
fireEvent.click(screen.getByRole('button', { name: 'Discover Regions' }));
// Only the repeater's key is passed, not the sensor's.
await waitFor(() => {
expect(onDiscoverRegions).toHaveBeenCalledWith(['11'.repeat(32)]);
});
});
it('adds discovered regions to the known-regions field', () => {
renderModal({
regionDiscovery: {
repeaters_queried: 2,
repeaters_answered: 2,
regions: ['nl-gr', 'de-by'],
results: [],
},
});
openRadioSection();
expect(screen.getByText('2/2 repeaters answered — 2 regions found')).toBeInTheDocument();
const knownRegions = screen.getByLabelText(
'Known Regions (for decoding)'
) as HTMLTextAreaElement;
fireEvent.change(knownRegions, { target: { value: 'nl-gr' } });
fireEvent.click(screen.getByRole('button', { name: 'Add to Known Regions' }));
// Existing 'nl-gr' preserved, only the new 'de-by' appended.
expect(knownRegions.value).toBe('nl-gr\nde-by');
});
it('saves advert location source through radio config save', async () => {
const { onSave } = renderModal();
openRadioSection();
+15
View File
@@ -54,6 +54,21 @@ export interface RadioDiscoveryResponse {
results: RadioDiscoveryResult[];
}
export interface RadioRegionDiscoveryRepeater {
public_key: string;
name: string | null;
answered: boolean;
regions: string[];
}
export interface RadioRegionDiscoveryResponse {
repeaters_queried: number;
repeaters_answered: number;
/** Deduplicated union of flood-allowed region names across all repeaters. */
regions: string[];
results: RadioRegionDiscoveryRepeater[];
}
export type RadioAdvertMode = 'flood' | 'zero_hop';
export interface FanoutStatusEntry {