mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Add repeater region discover
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user