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
@@ -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)