mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
Add custom pathing (closes #45)
This commit is contained in:
+2
-1
@@ -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()
|
||||
|
||||
+32
-5
@@ -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(
|
||||
|
||||
+67
-17
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user