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