Add custom pathing (closes #45)

This commit is contained in:
Jack Kingsman
2026-03-09 10:26:01 -07:00
parent 7e384c12bb
commit 0c5b37c07c
22 changed files with 718 additions and 163 deletions
+2 -1
View File
@@ -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`
+3
View File
@@ -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,
+33
View File
@@ -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()
+32 -5
View File
@@ -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
+42
View File
@@ -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
+3
View File
@@ -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,
+86 -2
View File
@@ -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(
+67 -17
View File
@@ -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}