diff --git a/AGENTS.md b/AGENTS.md index a4acae4..9fca0cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/app/AGENTS.md b/app/AGENTS.md index 2ff4915..d5d6a47 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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` diff --git a/app/models.py b/app/models.py index 61f0675..263077e 100644 --- a/app/models.py +++ b/app/models.py @@ -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.""" diff --git a/app/repository/contacts.py b/app/repository/contacts.py index 9a5a45e..d65ed86 100644 --- a/app/repository/contacts.py +++ b/app/repository/contacts.py @@ -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.""" diff --git a/app/routers/radio.py b/app/routers/radio.py index e3b2c69..10a4a41 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -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.""" diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 9d14f61..779efdc 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5786a15..bba9d33 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c400eb7..e5bc34d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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('/radio/discover-regions', { + method: 'POST', + body: JSON.stringify(publicKeys && publicKeys.length > 0 ? { public_keys: publicKeys } : {}), + }), requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) => fetchJson('/radio/trace', { method: 'POST', diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 4999401..0918468 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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; + regionDiscovery: RadioRegionDiscoveryResponse | null; + regionDiscoveryLoading: boolean; + onDiscoverRegions: (publicKeys?: string[]) => Promise; onHealthRefresh: () => Promise; onRefreshAppSettings: () => Promise; 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} /> diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index be51af0..b300d0c 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -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; + regionDiscovery: RadioRegionDiscoveryResponse | null; + regionDiscoveryLoading: boolean; + onDiscoverRegions: (publicKeys?: string[]) => Promise; 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(null); const [keyImportDialogOpen, setKeyImportDialogOpen] = useState(false); const pendingImportRef = useRef | 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.

+ +
+
+ + Discover regions from repeaters + + +
+

+ 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). +

+ {!health?.radio_connected && ( +

Radio not connected

+ )} + {regionDiscovery && ( +
+

+ {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` + : ''} +

+ {regionDiscovery.regions.length > 0 ? ( + <> +
+ {regionDiscovery.regions.map((region) => ( + + {region} + + ))} +
+ + + ) : ( + regionDiscovery.repeaters_queried > 0 && ( +

+ No flood-allowed regions were reported. +

+ ) + )} +
+ )} +
diff --git a/frontend/src/hooks/useRadioControl.ts b/frontend/src/hooks/useRadioControl.ts index d1243fb..d432166 100644 --- a/frontend/src/hooks/useRadioControl.ts +++ b/frontend/src/hooks/useRadioControl.ts @@ -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(null); const [meshDiscoveryLoadingTarget, setMeshDiscoveryLoadingTarget] = useState(null); + const [regionDiscovery, setRegionDiscovery] = useState(null); + const [regionDiscoveryLoading, setRegionDiscoveryLoading] = useState(false); const prevHealthRef = useRef(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, }; } diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index ed019e0..3b0cffd 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -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; + 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(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e486a97..ea258b5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 { diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index f08fcf7..b2bbdc6 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -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