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
+1
View File
@@ -325,6 +325,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
| POST | `/api/radio/discover-regions` | Sweep nearby repeaters (anon regions request) for flood-allowed region names to merge into `known_regions` |
| POST | `/api/radio/trace` | Send a multi-hop trace loop through known repeaters and back to the local radio |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
+1
View File
@@ -210,6 +210,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `PUT /radio/private-key`
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
- `POST /radio/discover-regions` — sweep nearby repeaters via the guest anon regions request; aggregates flood-allowed region names into a deduped union for merging into `known_regions` (direct-routed, so only in-range repeaters answer; optional `public_keys`, else recent repeaters)
- `POST /radio/trace` — send a multi-hop trace loop through known repeaters and back to the local radio
- `POST /radio/disconnect`
- `POST /radio/reboot`
+54
View File
@@ -894,6 +894,60 @@ class RadioDiscoveryResponse(BaseModel):
)
class RadioRegionDiscoveryRequest(BaseModel):
"""Request to sweep nearby repeaters for their flood-allowed region names.
Uses the guest-accessible anon regions request (direct-routed, so only
repeaters in range answer). When ``public_keys`` is omitted, the sweep
targets the most recently seen repeater contacts.
"""
public_keys: list[str] | None = Field(
default=None,
description="Specific repeater public keys to query; None = most recent repeater contacts",
)
max_repeaters: int = Field(
default=8,
ge=1,
le=40,
description="Maximum number of repeaters to query in one sweep",
)
class RadioRegionDiscoveryRepeater(BaseModel):
"""One repeater's result from a region discovery sweep."""
public_key: str = Field(description="Repeater public key")
name: str | None = Field(default=None, description="Known contact name, if any")
answered: bool = Field(description="True if the repeater answered the anon regions request")
regions: list[str] = Field(
default_factory=list,
description="Flood-allowed region names reported by this repeater (wildcard excluded)",
)
class RadioRegionDiscoveryResponse(BaseModel):
"""Aggregated result of a region discovery sweep across nearby repeaters.
``regions`` is the deduplicated union of every repeater's flood-allowed
region names the list an operator can merge into ``known_regions``. The
anon request only reports flood-allowed names, so blocked regions and the
hierarchy are not visible here (use the per-repeater admin regions pane for
the full picture). See issue #309.
"""
repeaters_queried: int = Field(description="How many repeaters were contacted")
repeaters_answered: int = Field(description="How many repeaters answered the request")
regions: list[str] = Field(
default_factory=list,
description="Deduplicated union of flood-allowed region names across all repeaters",
)
results: list[RadioRegionDiscoveryRepeater] = Field(
default_factory=list,
description="Per-repeater region results",
)
class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
+21
View File
@@ -291,6 +291,27 @@ class ContactRepository:
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_repeaters_by_recent(limit: int = 8) -> list[Contact]:
"""Get repeater contacts ordered by most recently seen.
Used by the region discovery sweep, which prefers recently-heard
repeaters since the anon regions request is direct-routed and only
in-range repeaters will answer.
"""
async with db.readonly() as conn:
async with conn.execute(
"""
SELECT * FROM contacts
WHERE type = 2 AND length(public_key) = 64
ORDER BY COALESCE(last_seen, 0) DESC
LIMIT ?
""",
(limit,),
) as cursor:
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_recently_contacted_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get recently interacted-with non-repeater contacts."""
+94
View File
@@ -11,10 +11,14 @@ from pydantic import BaseModel, Field
from app.models import (
CONTACT_TYPE_REPEATER,
Contact,
ContactUpsert,
RadioDiscoveryRequest,
RadioDiscoveryResponse,
RadioDiscoveryResult,
RadioRegionDiscoveryRepeater,
RadioRegionDiscoveryRequest,
RadioRegionDiscoveryResponse,
RadioTraceHopRequest,
RadioTraceNode,
RadioTraceRequest,
@@ -23,6 +27,7 @@ from app.models import (
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.routers.repeaters import request_anon_region_names
from app.routers.server_control import _monotonic
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
@@ -550,6 +555,95 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
)
def _dedupe_region_names(names: list[str]) -> list[str]:
"""Dedupe region names case-insensitively, preserving first-seen order.
Drops the wildcard ``*`` (means "allows unscoped flood", not a nameable
region) and blanks so the result is safe to merge into ``known_regions``.
"""
out: list[str] = []
seen: set[str] = set()
for raw in names:
name = (raw or "").strip()
if not name or name == "*" or name.lower() in seen:
continue
seen.add(name.lower())
out.append(name)
return out
async def _resolve_region_discovery_targets(request: RadioRegionDiscoveryRequest) -> list[Contact]:
"""Resolve the repeater contacts to sweep for regions.
Explicit ``public_keys`` win (filtered to known repeaters); otherwise fall
back to the most recently seen repeater contacts. Capped at
``max_repeaters`` either way.
"""
if request.public_keys:
targets: list[Contact] = []
seen: set[str] = set()
for raw in request.public_keys:
key = (raw or "").strip().lower()
if not key or key in seen:
continue
seen.add(key)
contact = await ContactRepository.get_by_key(key)
if contact is None or contact.type != CONTACT_TYPE_REPEATER:
continue
targets.append(contact)
if len(targets) >= request.max_repeaters:
break
return targets
return await ContactRepository.get_repeaters_by_recent(limit=request.max_repeaters)
@router.post("/discover-regions", response_model=RadioRegionDiscoveryResponse)
async def discover_regions(
request: RadioRegionDiscoveryRequest,
) -> RadioRegionDiscoveryResponse:
"""Sweep nearby repeaters for their flood-allowed region names.
Sends the guest-accessible anon regions request to each target repeater and
aggregates the flood-allowed names into a deduplicated union that the
operator can merge into ``known_regions``. The request is direct-routed, so
only repeaters in range answer; unreachable ones are reported as
``answered=false``. This is the radio-wide companion to the per-repeater
admin regions pane (issue #309).
"""
radio_manager.require_connected()
targets = await _resolve_region_discovery_targets(request)
if not targets:
return RadioRegionDiscoveryResponse(
repeaters_queried=0, repeaters_answered=0, regions=[], results=[]
)
results: list[RadioRegionDiscoveryRepeater] = []
# One radio_operation for the whole sweep: keeps polling paused once and
# runs the per-repeater anon requests serially behind the single radio lock.
async with radio_manager.radio_operation(
"discover_regions", pause_polling=True, suspend_auto_fetch=True
) as mc:
for contact in targets:
names = await request_anon_region_names(mc, contact)
results.append(
RadioRegionDiscoveryRepeater(
public_key=contact.public_key,
name=contact.name,
answered=names is not None,
regions=_dedupe_region_names(names or []),
)
)
union = _dedupe_region_names([name for result in results for name in result.regions])
return RadioRegionDiscoveryResponse(
repeaters_queried=len(targets),
repeaters_answered=sum(1 for result in results if result.answered),
regions=union,
results=results,
)
@router.post("/trace", response_model=RadioTraceResponse)
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
"""Send a multi-hop trace loop through known repeaters and back to the local radio."""
+27 -11
View File
@@ -493,6 +493,28 @@ def _parse_anon_region_names(names: str) -> list[RepeaterRegionEntry]:
return entries
async def request_anon_region_names(mc, contact: Contact) -> list[str] | None:
"""Send the guest anon regions request over an already-open radio session.
Ensures the contact is on the radio, settles, then requests its
flood-allowed region names. Returns the parsed names (wildcard ``*``
included), or ``None`` if the repeater did not answer (older firmware, out
of range, add failure). The caller must already hold ``radio_operation``.
This is the shared per-repeater primitive behind both the single-repeater
guest fallback and the radio-wide region discovery sweep.
"""
try:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0) # settle after add_contact
names = await mc.commands.req_regions_sync(contact.public_key, timeout=10, min_timeout=5)
except Exception as exc:
logger.debug("anon regions request failed for %s: %s", contact.public_key[:12], exc)
return None
if not names:
return None
return [entry.name for entry in _parse_anon_region_names(names)]
async def _fetch_anon_flood_allowed_regions(contact: Contact) -> list[RepeaterRegionEntry] | None:
"""Guest-accessible fallback: fetch flood-allowed region names via anon request.
@@ -502,19 +524,13 @@ async def _fetch_anon_flood_allowed_regions(contact: Contact) -> list[RepeaterRe
async with radio_manager.radio_operation(
"repeater_regions_anon", pause_polling=True, suspend_auto_fetch=True
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0) # settle after add_contact
try:
names = await mc.commands.req_regions_sync(
contact.public_key, timeout=10, min_timeout=5
)
except Exception as exc:
logger.debug("anon regions request failed for %s: %s", contact.public_key[:12], exc)
return None
names = await request_anon_region_names(mc, contact)
if not names:
if names is None:
return None
return _parse_anon_region_names(names)
return [
RepeaterRegionEntry(name=name, depth=0, flood_allowed=True, is_home=False) for name in names
]
@router.post("/{public_key}/repeater/regions", response_model=RepeaterRegionsResponse)
+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 {
+203 -1
View File
@@ -9,7 +9,13 @@ from fastapi import HTTPException
from meshcore import EventType
from pydantic import ValidationError
from app.models import CONTACT_TYPE_REPEATER, Contact, RadioTraceHopRequest, RadioTraceRequest
from app.models import (
CONTACT_TYPE_REPEATER,
Contact,
RadioRegionDiscoveryRequest,
RadioTraceHopRequest,
RadioTraceRequest,
)
from app.radio import RadioManager, radio_manager
from app.routers.radio import (
PrivateKeyUpdate,
@@ -18,8 +24,10 @@ from app.routers.radio import (
RadioConfigUpdate,
RadioDiscoveryRequest,
RadioSettings,
_dedupe_region_names,
disconnect_radio,
discover_mesh,
discover_regions,
get_private_key,
get_radio_config,
reboot_radio,
@@ -1089,3 +1097,197 @@ class TestRebootAndReconnect:
assert result["paused"] is True
mock_rm.pause_connection.assert_awaited_once()
mock_broadcast.assert_called_once_with(False, "BLE: AA:BB:CC:DD:EE:FF")
def _repeater(public_key: str, name: str | None = "RPT") -> Contact:
return Contact(public_key=public_key, name=name, type=2)
class TestDedupeRegionNames:
def test_drops_wildcard_blanks_and_case_insensitive_dupes(self):
assert _dedupe_region_names(["*", "us", "US", "", " ", "ca", "Us"]) == ["us", "ca"]
def test_empty(self):
assert _dedupe_region_names([]) == []
class TestDiscoverRegions:
@pytest.mark.asyncio
async def test_sweeps_explicit_repeaters_and_aggregates_union(self):
mc = _mock_meshcore_with_info()
key_a, key_b = "aa" * 32, "bb" * 32
contacts = {key_a: _repeater(key_a, "Alpha"), key_b: _repeater(key_b, "Bravo")}
region_map = {key_a: ["us", "ca"], key_b: ["ca", "de"]}
async def _anon(_mc, contact):
return region_map[contact.public_key]
with (
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
side_effect=lambda key: contacts.get(key),
),
patch("app.routers.radio.request_anon_region_names", side_effect=_anon),
):
response = await discover_regions(
RadioRegionDiscoveryRequest(public_keys=[key_a, key_b])
)
assert response.repeaters_queried == 2
assert response.repeaters_answered == 2
assert response.regions == ["us", "ca", "de"]
assert [(r.public_key, r.name, r.answered, r.regions) for r in response.results] == [
(key_a, "Alpha", True, ["us", "ca"]),
(key_b, "Bravo", True, ["ca", "de"]),
]
# One radio_operation held for the whole sweep -> auto-fetch toggled once.
mc.stop_auto_message_fetching.assert_awaited_once()
mc.start_auto_message_fetching.assert_awaited_once()
@pytest.mark.asyncio
async def test_unreachable_repeater_is_reported_and_excluded_from_union(self):
mc = _mock_meshcore_with_info()
key_a, key_b = "aa" * 32, "bb" * 32
contacts = {key_a: _repeater(key_a), key_b: _repeater(key_b)}
async def _anon(_mc, contact):
return ["us"] if contact.public_key == key_a else None
with (
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
side_effect=lambda key: contacts.get(key),
),
patch("app.routers.radio.request_anon_region_names", side_effect=_anon),
):
response = await discover_regions(
RadioRegionDiscoveryRequest(public_keys=[key_a, key_b])
)
assert response.repeaters_queried == 2
assert response.repeaters_answered == 1
assert response.regions == ["us"]
assert response.results[1].answered is False
assert response.results[1].regions == []
@pytest.mark.asyncio
async def test_wildcard_and_dupes_stripped_per_repeater(self):
mc = _mock_meshcore_with_info()
key_a = "aa" * 32
with (
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
return_value=_repeater(key_a),
),
patch(
"app.routers.radio.request_anon_region_names",
new=AsyncMock(return_value=["*", "US", "us", "ca"]),
),
):
response = await discover_regions(RadioRegionDiscoveryRequest(public_keys=[key_a]))
assert response.results[0].regions == ["US", "ca"]
assert response.regions == ["US", "ca"]
@pytest.mark.asyncio
async def test_auto_targets_recent_repeaters_when_no_keys(self):
mc = _mock_meshcore_with_info()
key_a = "aa" * 32
recent = AsyncMock(return_value=[_repeater(key_a, "Recent")])
with (
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.ContactRepository.get_repeaters_by_recent", new=recent),
patch(
"app.routers.radio.request_anon_region_names",
new=AsyncMock(return_value=["eu"]),
),
):
response = await discover_regions(RadioRegionDiscoveryRequest(max_repeaters=5))
recent.assert_awaited_once_with(limit=5)
assert response.repeaters_queried == 1
assert response.regions == ["eu"]
@pytest.mark.asyncio
async def test_no_targets_returns_empty_without_touching_radio(self):
mc = _mock_meshcore_with_info()
anon = AsyncMock(return_value=["us"])
with (
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_repeaters_by_recent",
new=AsyncMock(return_value=[]),
),
patch("app.routers.radio.request_anon_region_names", new=anon),
):
response = await discover_regions(RadioRegionDiscoveryRequest())
assert response.repeaters_queried == 0
assert response.repeaters_answered == 0
assert response.regions == []
assert response.results == []
anon.assert_not_awaited()
# No radio_operation should have been entered.
mc.stop_auto_message_fetching.assert_not_awaited()
@pytest.mark.asyncio
async def test_non_repeater_keys_are_filtered_out(self):
mc = _mock_meshcore_with_info()
key_a = "aa" * 32
client = Contact(public_key=key_a, name="Client", type=1)
with (
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
return_value=client,
),
patch(
"app.routers.radio.request_anon_region_names",
new=AsyncMock(return_value=["us"]),
),
):
response = await discover_regions(RadioRegionDiscoveryRequest(public_keys=[key_a]))
assert response.repeaters_queried == 0
assert response.results == []
@pytest.mark.asyncio
async def test_explicit_keys_capped_at_max_repeaters(self):
mc = _mock_meshcore_with_info()
keys = ["aa" * 32, "bb" * 32, "cc" * 32]
contacts = {k: _repeater(k) for k in keys}
anon = AsyncMock(return_value=["us"])
with (
patch("app.routers.radio.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch(
"app.routers.radio.ContactRepository.get_by_key",
new_callable=AsyncMock,
side_effect=lambda key: contacts.get(key),
),
patch("app.routers.radio.request_anon_region_names", new=anon),
):
response = await discover_regions(
RadioRegionDiscoveryRequest(public_keys=keys, max_repeaters=2)
)
assert response.repeaters_queried == 2
assert anon.await_count == 2