mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
Add repeater region discover
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user