mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add custom pathing (closes #45)
This commit is contained in:
@@ -124,6 +124,7 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
|
||||
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
|
||||
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
|
||||
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
|
||||
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
||||
|
||||
## Data Flow
|
||||
@@ -289,7 +290,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio |
|
||||
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
||||
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
||||
| POST | `/api/contacts/{public_key}/reset-path` | Reset contact path to flood |
|
||||
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
||||
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
||||
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
|
||||
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
||||
|
||||
@@ -78,6 +78,7 @@ app/
|
||||
- Packet `path_len` values are hop counts, not byte counts.
|
||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
|
||||
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
|
||||
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
|
||||
|
||||
### Read/unread state
|
||||
@@ -144,7 +145,7 @@ app/
|
||||
- `POST /contacts/{public_key}/remove-from-radio`
|
||||
- `POST /contacts/{public_key}/mark-read`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
- `POST /contacts/{public_key}/reset-path`
|
||||
- `POST /contacts/{public_key}/routing-override`
|
||||
- `POST /contacts/{public_key}/trace`
|
||||
- `POST /contacts/{public_key}/repeater/login`
|
||||
- `POST /contacts/{public_key}/repeater/status`
|
||||
|
||||
@@ -16,6 +16,9 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
out_path_hash_mode INTEGER DEFAULT 0,
|
||||
route_override_path TEXT,
|
||||
route_override_len INTEGER,
|
||||
route_override_hash_mode INTEGER,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
|
||||
@@ -317,6 +317,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 40)
|
||||
applied += 1
|
||||
|
||||
# Migration 41: Persist optional routing overrides separately from learned paths
|
||||
if version < 41:
|
||||
logger.info("Applying migration 41: add contacts routing override columns")
|
||||
await _migrate_041_add_contact_routing_override_columns(conn)
|
||||
await set_version(conn, 41)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -2382,3 +2389,29 @@ async def _migrate_040_rebuild_contact_advert_paths_identity(
|
||||
"ON contact_advert_paths(public_key, last_seen DESC)"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_041_add_contact_routing_override_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Add nullable routing-override columns to contacts."""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
for column_name, column_type in (
|
||||
("route_override_path", "TEXT"),
|
||||
("route_override_len", "INTEGER"),
|
||||
("route_override_hash_mode", "INTEGER"),
|
||||
):
|
||||
try:
|
||||
await conn.execute(f"ALTER TABLE contacts ADD COLUMN {column_name} {column_type}")
|
||||
logger.debug("Added %s to contacts table", column_name)
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.debug("contacts.%s already exists, skipping", column_name)
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
@@ -13,6 +13,9 @@ class Contact(BaseModel):
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int = 0
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
last_advert: int | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
@@ -22,17 +25,29 @@ class Contact(BaseModel):
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
|
||||
def has_route_override(self) -> bool:
|
||||
return self.route_override_len is not None
|
||||
|
||||
def effective_route(self) -> tuple[str, int, int]:
|
||||
if self.has_route_override():
|
||||
return normalize_contact_route(
|
||||
self.route_override_path,
|
||||
self.route_override_len,
|
||||
self.route_override_hash_mode,
|
||||
)
|
||||
return normalize_contact_route(
|
||||
self.last_path,
|
||||
self.last_path_len,
|
||||
self.out_path_hash_mode,
|
||||
)
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
|
||||
The radio API uses different field names (adv_name, out_path, etc.)
|
||||
than our database schema (name, last_path, etc.).
|
||||
"""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
self.last_path,
|
||||
self.last_path_len,
|
||||
self.out_path_hash_mode,
|
||||
)
|
||||
last_path, last_path_len, out_path_hash_mode = self.effective_route()
|
||||
return {
|
||||
"public_key": self.public_key,
|
||||
"adv_name": self.name or "",
|
||||
@@ -87,6 +102,18 @@ class CreateContactRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ContactRoutingOverrideRequest(BaseModel):
|
||||
"""Request to set, force, or clear a contact routing override."""
|
||||
|
||||
route: str = Field(
|
||||
description=(
|
||||
"Blank clears the override and resets learned routing to flood, "
|
||||
'"-1" forces flood, "0" forces direct, and explicit routes are '
|
||||
"comma-separated 1/2/3-byte hop hex values"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
|
||||
|
||||
@@ -202,3 +202,45 @@ def normalize_contact_route(
|
||||
normalized_len = actual_bytes // bytes_per_hop
|
||||
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
def normalize_route_override(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None,
|
||||
) -> tuple[str | None, int | None, int | None]:
|
||||
"""Normalize optional route-override fields while preserving the unset state."""
|
||||
if path_len is None:
|
||||
return None, None, None
|
||||
|
||||
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
|
||||
path_hex,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
)
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
|
||||
"""Parse a comma-separated explicit hop route into stored contact fields."""
|
||||
hops = [hop.strip().lower() for hop in route_text.split(",") if hop.strip()]
|
||||
if not hops:
|
||||
raise ValueError("Explicit path must include at least one hop")
|
||||
|
||||
hop_chars = len(hops[0])
|
||||
if hop_chars not in (2, 4, 6):
|
||||
raise ValueError("Each hop must be 1, 2, or 3 bytes of hex")
|
||||
|
||||
for hop in hops:
|
||||
if len(hop) != hop_chars:
|
||||
raise ValueError("All hops must use the same width")
|
||||
try:
|
||||
bytes.fromhex(hop)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Each hop must be valid hex") from exc
|
||||
|
||||
hash_size = hop_chars // 2
|
||||
if path_wire_len(len(hops), hash_size) > MAX_PATH_SIZE:
|
||||
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
||||
|
||||
return "".join(hops), len(hops), hash_size - 1
|
||||
|
||||
@@ -38,6 +38,9 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
"last_path": contact.last_path,
|
||||
"last_path_len": contact.last_path_len,
|
||||
"out_path_hash_mode": contact.out_path_hash_mode,
|
||||
"route_override_path": contact.route_override_path,
|
||||
"route_override_len": contact.route_override_len,
|
||||
"route_override_hash_mode": contact.route_override_hash_mode,
|
||||
"last_advert": contact.last_advert,
|
||||
"lat": contact.lat,
|
||||
"lon": contact.lon,
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.models import (
|
||||
ContactAdvertPathSummary,
|
||||
ContactNameHistory,
|
||||
)
|
||||
from app.path_utils import first_hop_hex, normalize_contact_route
|
||||
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
@@ -28,14 +28,23 @@ class ContactRepository:
|
||||
contact.get("last_path_len", -1),
|
||||
contact.get("out_path_hash_mode"),
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
contact.get("route_override_path"),
|
||||
contact.get("route_override_len"),
|
||||
contact.get("route_override_hash_mode"),
|
||||
)
|
||||
)
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
|
||||
out_path_hash_mode,
|
||||
route_override_path, route_override_len,
|
||||
route_override_hash_mode,
|
||||
last_advert, lat, lon, last_seen,
|
||||
on_radio, last_contacted, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(public_key) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, contacts.name),
|
||||
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
|
||||
@@ -43,6 +52,15 @@ class ContactRepository:
|
||||
last_path = COALESCE(excluded.last_path, contacts.last_path),
|
||||
last_path_len = excluded.last_path_len,
|
||||
out_path_hash_mode = excluded.out_path_hash_mode,
|
||||
route_override_path = COALESCE(
|
||||
excluded.route_override_path, contacts.route_override_path
|
||||
),
|
||||
route_override_len = COALESCE(
|
||||
excluded.route_override_len, contacts.route_override_len
|
||||
),
|
||||
route_override_hash_mode = COALESCE(
|
||||
excluded.route_override_hash_mode, contacts.route_override_hash_mode
|
||||
),
|
||||
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
|
||||
lat = COALESCE(excluded.lat, contacts.lat),
|
||||
lon = COALESCE(excluded.lon, contacts.lon),
|
||||
@@ -59,6 +77,9 @@ class ContactRepository:
|
||||
last_path,
|
||||
last_path_len,
|
||||
out_path_hash_mode,
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
contact.get("last_advert"),
|
||||
contact.get("lat"),
|
||||
contact.get("lon"),
|
||||
@@ -78,6 +99,25 @@ class ContactRepository:
|
||||
row["last_path_len"],
|
||||
row["out_path_hash_mode"],
|
||||
)
|
||||
available_columns = set(row.keys())
|
||||
route_override_path = (
|
||||
row["route_override_path"] if "route_override_path" in available_columns else None
|
||||
)
|
||||
route_override_len = (
|
||||
row["route_override_len"] if "route_override_len" in available_columns else None
|
||||
)
|
||||
route_override_hash_mode = (
|
||||
row["route_override_hash_mode"]
|
||||
if "route_override_hash_mode" in available_columns
|
||||
else None
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
return Contact(
|
||||
public_key=row["public_key"],
|
||||
name=row["name"],
|
||||
@@ -86,6 +126,9 @@ class ContactRepository:
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
route_override_path=route_override_path,
|
||||
route_override_len=route_override_len,
|
||||
route_override_hash_mode=route_override_hash_mode,
|
||||
last_advert=row["last_advert"],
|
||||
lat=row["lat"],
|
||||
lon=row["lon"],
|
||||
@@ -241,6 +284,47 @@ class ContactRepository:
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_routing_override(
|
||||
public_key: str,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET route_override_path = ?, route_override_len = ?, route_override_hash_mode = ?
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
normalized_path,
|
||||
normalized_len,
|
||||
normalized_hash_mode,
|
||||
public_key.lower(),
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def clear_routing_override(public_key: str) -> None:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET route_override_path = NULL,
|
||||
route_override_len = NULL,
|
||||
route_override_hash_mode = NULL
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(public_key.lower(),),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_on_radio(public_key: str, on_radio: bool) -> None:
|
||||
await db.conn.execute(
|
||||
|
||||
@@ -11,11 +11,13 @@ from app.models import (
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
ContactRoutingOverrideRequest,
|
||||
CreateContactRequest,
|
||||
NearestRepeater,
|
||||
TraceResponse,
|
||||
)
|
||||
from app.packet_processor import start_historical_dm_decryption
|
||||
from app.path_utils import parse_explicit_hop_route
|
||||
from app.radio import radio_manager
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
@@ -59,6 +61,34 @@ async def _ensure_on_radio(mc, contact: Contact) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _best_effort_push_contact_to_radio(contact: Contact, operation_name: str) -> None:
|
||||
"""Push the current effective route to the radio when the contact is already loaded."""
|
||||
if not radio_manager.is_connected or not contact.on_radio:
|
||||
return
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(operation_name) as mc:
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to push updated routing to radio for %s: %s",
|
||||
contact.public_key[:12],
|
||||
result.payload,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to push updated routing to radio for %s",
|
||||
contact.public_key[:12],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_contact_update(contact: Contact) -> None:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
|
||||
@router.get("", response_model=list[Contact])
|
||||
async def list_contacts(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
@@ -459,29 +489,49 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
return TraceResponse(remote_snr=remote_snr, local_snr=local_snr, path_len=path_len)
|
||||
|
||||
|
||||
@router.post("/{public_key}/reset-path")
|
||||
async def reset_contact_path(public_key: str) -> dict:
|
||||
"""Reset a contact's routing path to flood."""
|
||||
@router.post("/{public_key}/routing-override")
|
||||
async def set_contact_routing_override(
|
||||
public_key: str, request: ContactRoutingOverrideRequest
|
||||
) -> dict:
|
||||
"""Set, force, or clear an explicit routing override for a contact."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
||||
logger.info("Reset path to flood for %s", contact.public_key[:12])
|
||||
|
||||
# Push the updated path to radio if connected and contact is on radio
|
||||
if radio_manager.is_connected and contact.on_radio:
|
||||
route_text = request.route.strip()
|
||||
if route_text == "":
|
||||
await ContactRepository.clear_routing_override(contact.public_key)
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
||||
logger.info(
|
||||
"Cleared routing override and reset learned path to flood for %s",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif route_text == "-1":
|
||||
await ContactRepository.set_routing_override(contact.public_key, "", -1, -1)
|
||||
logger.info("Set forced flood routing override for %s", contact.public_key[:12])
|
||||
elif route_text == "0":
|
||||
await ContactRepository.set_routing_override(contact.public_key, "", 0, 0)
|
||||
logger.info("Set forced direct routing override for %s", contact.public_key[:12])
|
||||
else:
|
||||
try:
|
||||
updated = await ContactRepository.get_by_key(contact.public_key)
|
||||
if updated:
|
||||
async with radio_manager.radio_operation("reset_path_on_radio") as mc:
|
||||
await mc.commands.add_contact(updated.to_radio_dict())
|
||||
except Exception:
|
||||
logger.warning("Failed to push flood path to radio for %s", contact.public_key[:12])
|
||||
path_hex, path_len, hash_mode = parse_explicit_hop_route(route_text)
|
||||
except ValueError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
|
||||
# Broadcast updated contact so frontend refreshes
|
||||
from app.websocket import broadcast_event
|
||||
await ContactRepository.set_routing_override(
|
||||
contact.public_key,
|
||||
path_hex,
|
||||
path_len,
|
||||
hash_mode,
|
||||
)
|
||||
logger.info(
|
||||
"Set explicit routing override for %s: %d hop(s), %d-byte IDs",
|
||||
contact.public_key[:12],
|
||||
path_len,
|
||||
hash_mode + 1,
|
||||
)
|
||||
|
||||
updated_contact = await ContactRepository.get_by_key(contact.public_key)
|
||||
if updated_contact:
|
||||
broadcast_event("contact", updated_contact.model_dump())
|
||||
await _best_effort_push_contact_to_radio(updated_contact, "set_routing_override_on_radio")
|
||||
await _broadcast_contact_update(updated_contact)
|
||||
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
@@ -137,9 +137,10 @@ export const api = {
|
||||
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
resetContactPath: (publicKey: string) =>
|
||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/reset-path`, {
|
||||
setContactRoutingOverride: (publicKey: string, route: string) =>
|
||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/routing-override`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ route }),
|
||||
}),
|
||||
|
||||
// Channels
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
isValidLocation,
|
||||
calculateDistance,
|
||||
formatDistance,
|
||||
formatRouteLabel,
|
||||
getEffectiveContactRoute,
|
||||
hasRoutingOverride,
|
||||
parsePathHops,
|
||||
} from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
@@ -106,7 +109,12 @@ export function ContactInfoPane({
|
||||
isValidLocation(contact.lat, contact.lon)
|
||||
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
||||
: null;
|
||||
const pathHashModeLabel = contact ? formatPathHashMode(contact.out_path_hash_mode) : null;
|
||||
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
|
||||
const pathHashModeLabel =
|
||||
effectiveRoute && effectiveRoute.pathLen >= 0
|
||||
? formatPathHashMode(effectiveRoute.pathHashMode)
|
||||
: null;
|
||||
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
|
||||
|
||||
return (
|
||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -220,17 +228,24 @@ export function ContactInfoPane({
|
||||
{distFromUs !== null && (
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
|
||||
)}
|
||||
{contact.last_path_len >= 0 && (
|
||||
{effectiveRoute && (
|
||||
<InfoItem
|
||||
label="Hops"
|
||||
label="Routing"
|
||||
value={
|
||||
contact.last_path_len === 0
|
||||
? 'Direct'
|
||||
: `${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
|
||||
effectiveRoute.forced ? (
|
||||
<span>
|
||||
{formatRouteLabel(effectiveRoute.pathLen, true)}{' '}
|
||||
<span className="text-destructive">(forced)</span>
|
||||
</span>
|
||||
) : (
|
||||
formatRouteLabel(effectiveRoute.pathLen, true)
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{contact.last_path_len === -1 && <InfoItem label="Routing" value="Flood" />}
|
||||
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
|
||||
<InfoItem label="Learned Route" value={learnedRouteLabel} />
|
||||
)}
|
||||
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,7 +483,7 @@ function ChannelAttributionWarning() {
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
|
||||
@@ -2,7 +2,14 @@ import type { ReactNode } from 'react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
||||
import {
|
||||
isValidLocation,
|
||||
calculateDistance,
|
||||
formatDistance,
|
||||
formatRouteLabel,
|
||||
formatRoutingOverrideInput,
|
||||
getEffectiveContactRoute,
|
||||
} from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import type { Contact } from '../types';
|
||||
@@ -19,58 +26,48 @@ interface ContactStatusInfoProps {
|
||||
*/
|
||||
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
|
||||
const parts: ReactNode[] = [];
|
||||
const effectiveRoute = getEffectiveContactRoute(contact);
|
||||
|
||||
const editRoutingOverride = () => {
|
||||
const route = window.prompt(
|
||||
'Enter explicit path as comma-separated 1, 2, or 3 byte hops (for example "ae,f1" or "ae92,f13e").\nEnter 0 to force direct always.\nEnter -1 to force flooding always.\nLeave blank to clear the override and reset to flood until a new path is heard.',
|
||||
formatRoutingOverrideInput(contact)
|
||||
);
|
||||
if (route === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.setContactRoutingOverride(contact.public_key, route).then(
|
||||
() =>
|
||||
toast.success(
|
||||
route.trim() === '' ? 'Routing override cleared' : 'Routing override updated'
|
||||
),
|
||||
(err: unknown) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update routing override')
|
||||
);
|
||||
};
|
||||
|
||||
if (contact.last_seen) {
|
||||
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
|
||||
}
|
||||
|
||||
if (contact.last_path_len === -1) {
|
||||
parts.push('flood');
|
||||
} else if (contact.last_path_len === 0) {
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
api.resetContactPath(contact.public_key).then(
|
||||
() => toast.success('Path reset to flood'),
|
||||
() => toast.error('Failed to reset path')
|
||||
);
|
||||
}
|
||||
}}
|
||||
title="Click to reset path to flood"
|
||||
>
|
||||
direct
|
||||
</span>
|
||||
);
|
||||
} else if (contact.last_path_len > 0) {
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
api.resetContactPath(contact.public_key).then(
|
||||
() => toast.success('Path reset to flood'),
|
||||
() => toast.error('Failed to reset path')
|
||||
);
|
||||
}
|
||||
}}
|
||||
title="Click to reset path to flood"
|
||||
>
|
||||
{contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editRoutingOverride();
|
||||
}}
|
||||
title="Click to edit routing override"
|
||||
>
|
||||
{formatRouteLabel(effectiveRoute.pathLen)}
|
||||
{effectiveRoute.forced && <span className="text-destructive"> (forced)</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isValidLocation(contact.lat, contact.lon)) {
|
||||
const distFromUs =
|
||||
|
||||
@@ -106,4 +106,25 @@ describe('ContactInfoPane', () => {
|
||||
expect(screen.getByText('Flood')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows forced routing override and learned route separately', async () => {
|
||||
const contact = createContact({
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
route_override_path: 'ae92f13e',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
});
|
||||
getContactDetail.mockResolvedValue(createDetail(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
await screen.findByText('Alice');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Routing')).toBeInTheDocument();
|
||||
expect(screen.getByText('(forced)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Learned Route')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 hop')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
extractPacketPayloadHex,
|
||||
findContactsByPrefix,
|
||||
calculateDistance,
|
||||
formatRouteLabel,
|
||||
formatRoutingOverrideInput,
|
||||
getEffectiveContactRoute,
|
||||
resolvePath,
|
||||
formatDistance,
|
||||
formatHopCounts,
|
||||
@@ -131,6 +134,42 @@ describe('extractPacketPayloadHex', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('contact routing helpers', () => {
|
||||
it('prefers routing override over learned route', () => {
|
||||
const effective = getEffectiveContactRoute(
|
||||
createContact({
|
||||
last_path: 'AABB',
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
route_override_path: 'AE92F13E',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
})
|
||||
);
|
||||
|
||||
expect(effective.path).toBe('AE92F13E');
|
||||
expect(effective.pathLen).toBe(2);
|
||||
expect(effective.pathHashMode).toBe(1);
|
||||
expect(effective.forced).toBe(true);
|
||||
});
|
||||
|
||||
it('formats route labels and override input', () => {
|
||||
expect(formatRouteLabel(-1)).toBe('flood');
|
||||
expect(formatRouteLabel(0)).toBe('direct');
|
||||
expect(formatRouteLabel(2, true)).toBe('2 hops');
|
||||
|
||||
expect(
|
||||
formatRoutingOverrideInput(
|
||||
createContact({
|
||||
route_override_path: 'AE92F13E',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
})
|
||||
)
|
||||
).toBe('ae92,f13e');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findContactsByPrefix', () => {
|
||||
const contacts: Contact[] = [
|
||||
createContact({
|
||||
|
||||
@@ -307,67 +307,82 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('1 hop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('direct path is clickable with reset title', () => {
|
||||
it('direct path is clickable with routing override title', () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
const directEl = screen.getByTitle('Click to reset path to flood');
|
||||
const directEl = screen.getByTitle('Click to edit routing override');
|
||||
expect(directEl).toBeInTheDocument();
|
||||
expect(directEl.textContent).toBe('direct');
|
||||
});
|
||||
|
||||
it('clicking direct path calls resetContactPath on confirm', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
it('shows forced decorator when a routing override is active', () => {
|
||||
const forcedContacts: Contact[] = [
|
||||
{
|
||||
...contacts[0],
|
||||
last_path_len: 1,
|
||||
last_seen: 1700000000,
|
||||
route_override_path: 'ae92f13e',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock window.confirm to return true
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={forcedContacts} />);
|
||||
|
||||
// Mock the api module
|
||||
const { api } = await import('../api');
|
||||
const resetSpy = vi.spyOn(api, 'resetContactPath').mockResolvedValue({
|
||||
status: 'ok',
|
||||
public_key: REPEATER_KEY,
|
||||
});
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Click to reset path to flood'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Reset path to flood?');
|
||||
expect(resetSpy).toHaveBeenCalledWith(REPEATER_KEY);
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
resetSpy.mockRestore();
|
||||
expect(screen.getByText('2 hops')).toBeInTheDocument();
|
||||
expect(screen.getByText('(forced)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking path does not call API when confirm is cancelled', async () => {
|
||||
it('clicking direct path opens prompt and updates routing override', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
// Mock window.confirm to return false
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('0');
|
||||
|
||||
const { api } = await import('../api');
|
||||
const resetSpy = vi.spyOn(api, 'resetContactPath').mockResolvedValue({
|
||||
const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({
|
||||
status: 'ok',
|
||||
public_key: REPEATER_KEY,
|
||||
});
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Click to reset path to flood'));
|
||||
fireEvent.click(screen.getByTitle('Click to edit routing override'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Reset path to flood?');
|
||||
expect(resetSpy).not.toHaveBeenCalled();
|
||||
expect(promptSpy).toHaveBeenCalled();
|
||||
expect(overrideSpy).toHaveBeenCalledWith(REPEATER_KEY, '0');
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
resetSpy.mockRestore();
|
||||
promptSpy.mockRestore();
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clicking path does not call API when prompt is cancelled', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue(null);
|
||||
|
||||
const { api } = await import('../api');
|
||||
const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({
|
||||
status: 'ok',
|
||||
public_key: REPEATER_KEY,
|
||||
});
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Click to edit routing override'));
|
||||
|
||||
expect(promptSpy).toHaveBeenCalled();
|
||||
expect(overrideSpy).not.toHaveBeenCalled();
|
||||
|
||||
promptSpy.mockRestore();
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface Contact {
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
out_path_hash_mode: number;
|
||||
route_override_path?: string | null;
|
||||
route_override_len?: number | null;
|
||||
route_override_hash_mode?: number | null;
|
||||
last_advert: number | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
|
||||
@@ -32,6 +32,13 @@ export interface SenderInfo {
|
||||
pathHashMode?: number | null;
|
||||
}
|
||||
|
||||
export interface EffectiveContactRoute {
|
||||
path: string | null;
|
||||
pathLen: number;
|
||||
pathHashMode: number;
|
||||
forced: boolean;
|
||||
}
|
||||
|
||||
function normalizePathHashMode(mode: number | null | undefined): number | null {
|
||||
if (mode == null || !Number.isInteger(mode) || mode < 0 || mode > 2) {
|
||||
return null;
|
||||
@@ -106,6 +113,58 @@ export function parsePathHops(path: string | null | undefined, hopCount?: number
|
||||
return hops;
|
||||
}
|
||||
|
||||
export function hasRoutingOverride(contact: Contact): boolean {
|
||||
return contact.route_override_len !== null && contact.route_override_len !== undefined;
|
||||
}
|
||||
|
||||
export function getEffectiveContactRoute(contact: Contact): EffectiveContactRoute {
|
||||
const forced = hasRoutingOverride(contact);
|
||||
const pathLen = forced ? (contact.route_override_len ?? -1) : contact.last_path_len;
|
||||
const path = forced ? (contact.route_override_path ?? '') : (contact.last_path ?? '');
|
||||
|
||||
let pathHashMode = forced
|
||||
? (contact.route_override_hash_mode ?? null)
|
||||
: (contact.out_path_hash_mode ?? null);
|
||||
|
||||
if (pathLen === -1) {
|
||||
pathHashMode = -1;
|
||||
} else if (pathHashMode == null || pathHashMode < 0 || pathHashMode > 2) {
|
||||
pathHashMode = inferPathHashMode(path, pathLen) ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
path: path || null,
|
||||
pathLen,
|
||||
pathHashMode,
|
||||
forced,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRouteLabel(pathLen: number, capitalize: boolean = false): string {
|
||||
const label =
|
||||
pathLen === -1
|
||||
? 'flood'
|
||||
: pathLen === 0
|
||||
? 'direct'
|
||||
: `${pathLen} hop${pathLen === 1 ? '' : 's'}`;
|
||||
return capitalize ? label.charAt(0).toUpperCase() + label.slice(1) : label;
|
||||
}
|
||||
|
||||
export function formatRoutingOverrideInput(contact: Contact): string {
|
||||
if (!hasRoutingOverride(contact)) {
|
||||
return '';
|
||||
}
|
||||
if (contact.route_override_len === -1) {
|
||||
return '-1';
|
||||
}
|
||||
if (contact.route_override_len === 0) {
|
||||
return '0';
|
||||
}
|
||||
return parsePathHops(contact.route_override_path, contact.route_override_len)
|
||||
.map((hop) => hop.toLowerCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the payload portion from a raw packet hex string using firmware-equivalent
|
||||
* path-byte validation. Returns null for malformed or payload-less packets.
|
||||
|
||||
@@ -91,6 +91,9 @@ export interface Contact {
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
out_path_hash_mode: number;
|
||||
route_override_path?: string | null;
|
||||
route_override_len?: number | null;
|
||||
route_override_hash_mode?: number | null;
|
||||
last_advert: number | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
|
||||
@@ -649,45 +649,39 @@ class TestCreateContactWithHistorical:
|
||||
mock_start.assert_not_awaited()
|
||||
|
||||
|
||||
class TestResetPath:
|
||||
"""Test POST /api/contacts/{public_key}/reset-path."""
|
||||
class TestRoutingOverride:
|
||||
"""Test POST /api/contacts/{public_key}/routing-override."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_to_flood(self, test_db, client):
|
||||
"""Happy path: resets path to flood and returns ok."""
|
||||
await _insert_contact(KEY_A, last_path="1122", last_path_len=1)
|
||||
async def test_set_explicit_routing_override(self, test_db, client):
|
||||
await _insert_contact(KEY_A, last_path="11", last_path_len=1, out_path_hash_mode=0)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
patch("app.websocket.broadcast_event"),
|
||||
patch("app.websocket.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = False
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/reset-path")
|
||||
response = await client.post(
|
||||
f"/api/contacts/{KEY_A}/routing-override",
|
||||
json={"route": "ae92,f13e"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["public_key"] == KEY_A
|
||||
|
||||
# Verify path was reset in DB
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact.last_path == ""
|
||||
assert contact.last_path_len == -1
|
||||
assert contact.out_path_hash_mode == -1
|
||||
assert contact is not None
|
||||
assert contact.last_path == "11"
|
||||
assert contact.last_path_len == 1
|
||||
assert contact.route_override_path == "ae92f13e"
|
||||
assert contact.route_override_len == 2
|
||||
assert contact.route_override_hash_mode == 1
|
||||
mock_broadcast.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_not_found(self, test_db, client):
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/reset-path")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_pushes_to_radio(self, test_db, client):
|
||||
"""When radio connected and contact on_radio, pushes updated path."""
|
||||
async def test_force_flood_routing_override_pushes_effective_route(self, test_db, client):
|
||||
await _insert_contact(
|
||||
KEY_A,
|
||||
on_radio=True,
|
||||
last_path="1122",
|
||||
last_path="11",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
)
|
||||
@@ -703,33 +697,64 @@ class TestResetPath:
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/reset-path")
|
||||
response = await client.post(
|
||||
f"/api/contacts/{KEY_A}/routing-override",
|
||||
json={"route": "-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_mc.commands.add_contact.assert_called_once()
|
||||
contact_payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert contact_payload["out_path"] == ""
|
||||
assert contact_payload["out_path_len"] == -1
|
||||
assert contact_payload["out_path_hash_mode"] == -1
|
||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert payload["out_path"] == ""
|
||||
assert payload["out_path_len"] == -1
|
||||
assert payload["out_path_hash_mode"] == -1
|
||||
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact is not None
|
||||
assert contact.route_override_len == -1
|
||||
assert contact.last_path == "11"
|
||||
assert contact.last_path_len == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_broadcasts_websocket_event(self, test_db, client):
|
||||
"""After resetting, broadcasts updated contact via WebSocket."""
|
||||
await _insert_contact(KEY_A, last_path="1122", last_path_len=1)
|
||||
async def test_blank_route_clears_override_and_resets_learned_path(self, test_db, client):
|
||||
await _insert_contact(
|
||||
KEY_A,
|
||||
last_path="11",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
route_override_path="ae92f13e",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.radio_manager") as mock_rm,
|
||||
patch("app.websocket.broadcast_event") as mock_broadcast,
|
||||
patch("app.websocket.broadcast_event"),
|
||||
):
|
||||
mock_rm.is_connected = False
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/reset-path")
|
||||
response = await client.post(
|
||||
f"/api/contacts/{KEY_A}/routing-override",
|
||||
json={"route": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_broadcast.assert_called_once()
|
||||
event_type, event_data = mock_broadcast.call_args[0]
|
||||
assert event_type == "contact"
|
||||
assert event_data["public_key"] == KEY_A
|
||||
assert event_data["last_path_len"] == -1
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact is not None
|
||||
assert contact.route_override_len is None
|
||||
assert contact.last_path == ""
|
||||
assert contact.last_path_len == -1
|
||||
assert contact.out_path_hash_mode == -1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_invalid_explicit_route(self, test_db, client):
|
||||
await _insert_contact(KEY_A)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/contacts/{KEY_A}/routing-override",
|
||||
json={"route": "ae,f13e"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "same width" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestAddRemoveRadio:
|
||||
|
||||
@@ -1116,8 +1116,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 2
|
||||
assert await get_version(conn) == 40
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 41
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1186,8 +1186,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 2
|
||||
assert await get_version(conn) == 40
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 41
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1240,8 +1240,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 40
|
||||
assert applied == 2
|
||||
assert await get_version(conn) == 41
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1271,6 +1271,69 @@ class TestMigration040:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestMigration041:
|
||||
"""Test migration 041: add nullable routing override columns."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adds_contact_routing_override_columns(self):
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
try:
|
||||
await set_version(conn, 40)
|
||||
await conn.execute("""
|
||||
CREATE TABLE contacts (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type INTEGER DEFAULT 0,
|
||||
flags INTEGER DEFAULT 0,
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
out_path_hash_mode INTEGER DEFAULT 0,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER
|
||||
)
|
||||
""")
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 41
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (
|
||||
public_key,
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
("aa" * 32, "ae92f13e", 2, 1),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT route_override_path, route_override_len, route_override_hash_mode
|
||||
FROM contacts
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
("aa" * 32,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row["route_override_path"] == "ae92f13e"
|
||||
assert row["route_override_len"] == 2
|
||||
assert row["route_override_hash_mode"] == 1
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestMigrationPacketHelpers:
|
||||
"""Test migration-local packet helpers against canonical path validation."""
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ from app.path_utils import (
|
||||
decode_path_byte,
|
||||
first_hop_hex,
|
||||
normalize_contact_route,
|
||||
normalize_route_override,
|
||||
parse_explicit_hop_route,
|
||||
parse_packet_envelope,
|
||||
path_wire_len,
|
||||
split_path_hex,
|
||||
@@ -174,6 +176,29 @@ class TestNormalizeContactRoute:
|
||||
assert hash_mode == -1
|
||||
|
||||
|
||||
class TestNormalizeRouteOverride:
|
||||
def test_preserves_unset_override(self):
|
||||
assert normalize_route_override(None, None, None) == (None, None, None)
|
||||
|
||||
def test_normalizes_forced_direct_override(self):
|
||||
path_hex, path_len, hash_mode = normalize_route_override(None, 0, None)
|
||||
assert path_hex == ""
|
||||
assert path_len == 0
|
||||
assert hash_mode == 0
|
||||
|
||||
|
||||
class TestParseExplicitHopRoute:
|
||||
def test_parses_one_byte_hops(self):
|
||||
assert parse_explicit_hop_route("ae,f1") == ("aef1", 2, 0)
|
||||
|
||||
def test_parses_two_byte_hops(self):
|
||||
assert parse_explicit_hop_route("ae92,f13e") == ("ae92f13e", 2, 1)
|
||||
|
||||
def test_rejects_mixed_width_hops(self):
|
||||
with pytest.raises(ValueError, match="same width"):
|
||||
parse_explicit_hop_route("ae,f13e")
|
||||
|
||||
|
||||
class TestContactToRadioDictHashMode:
|
||||
"""Test that Contact.to_radio_dict() preserves the stored out_path_hash_mode."""
|
||||
|
||||
@@ -251,6 +276,23 @@ class TestContactToRadioDictHashMode:
|
||||
assert d["out_path_len"] == 3
|
||||
assert d["out_path_hash_mode"] == 2
|
||||
|
||||
def test_route_override_takes_precedence_over_learned_route(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(
|
||||
public_key="11" * 32,
|
||||
last_path="aabb",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
route_override_path="cc00dd00",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path"] == "cc00dd00"
|
||||
assert d["out_path_len"] == 2
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
|
||||
|
||||
class TestContactFromRadioDictHashMode:
|
||||
"""Test that Contact.from_radio_dict() preserves explicit path hash mode."""
|
||||
|
||||
@@ -152,6 +152,34 @@ class TestOutgoingDMBroadcast:
|
||||
assert contact_payload["out_path_len"] == 2
|
||||
assert contact_payload["out_path_hash_mode"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_prefers_route_override_over_learned_path(self, test_db):
|
||||
mc = _make_mc()
|
||||
pub_key = "ef" * 32
|
||||
await _insert_contact(
|
||||
pub_key,
|
||||
"Alice",
|
||||
last_path="aabb",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
route_override_path="cc00dd00",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
await send_direct_message(request)
|
||||
|
||||
contact_payload = mc.commands.add_contact.call_args.args[0]
|
||||
assert contact_payload["out_path"] == "cc00dd00"
|
||||
assert contact_payload["out_path_len"] == 2
|
||||
assert contact_payload["out_path_hash_mode"] == 1
|
||||
|
||||
|
||||
class TestOutgoingChannelBroadcast:
|
||||
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""
|
||||
|
||||
Reference in New Issue
Block a user