mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Add repeater region discover
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user