From 0c5b37c07c55baffffc8b9149d0a72c69f10c569 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 10:26:01 -0700 Subject: [PATCH] Add custom pathing (closes #45) --- AGENTS.md | 3 +- app/AGENTS.md | 3 +- app/database.py | 3 + app/migrations.py | 33 ++++++ app/models.py | 37 +++++- app/path_utils.py | 42 +++++++ app/radio_sync.py | 3 + app/repository/contacts.py | 88 ++++++++++++++- app/routers/contacts.py | 84 +++++++++++--- frontend/src/api.ts | 5 +- frontend/src/components/ContactInfoPane.tsx | 33 ++++-- frontend/src/components/ContactStatusInfo.tsx | 93 ++++++++-------- frontend/src/test/contactInfoPane.test.tsx | 21 ++++ frontend/src/test/pathUtils.test.ts | 39 +++++++ frontend/src/test/repeaterDashboard.test.tsx | 79 +++++++------ frontend/src/types.ts | 3 + frontend/src/utils/pathUtils.ts | 59 ++++++++++ tests/e2e/helpers/api.ts | 3 + tests/test_contacts_router.py | 105 +++++++++++------- tests/test_migrations.py | 75 ++++++++++++- tests/test_path_utils.py | 42 +++++++ tests/test_send_messages.py | 28 +++++ 22 files changed, 718 insertions(+), 163 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 710d2d8..fb4343a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/app/AGENTS.md b/app/AGENTS.md index 2597baf..321290a 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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` diff --git a/app/database.py b/app/database.py index de575a0..3855b4e 100644 --- a/app/database.py +++ b/app/database.py @@ -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, diff --git a/app/migrations.py b/app/migrations.py index 6382c8f..ae23cd6 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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() diff --git a/app/models.py b/app/models.py index 474e65c..ba8bad1 100644 --- a/app/models.py +++ b/app/models.py @@ -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 diff --git a/app/path_utils.py b/app/path_utils.py index eb19c89..0234311 100644 --- a/app/path_utils.py +++ b/app/path_utils.py @@ -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 diff --git a/app/radio_sync.py b/app/radio_sync.py index aa8edc1..0430be8 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -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, diff --git a/app/repository/contacts.py b/app/repository/contacts.py index f1fbad5..24c3e22 100644 --- a/app/repository/contacts.py +++ b/app/repository/contacts.py @@ -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( diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 227ed46..f145312 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -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} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b4b4d11..af195bf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -137,9 +137,10 @@ export const api = { fetchJson(`/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 diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 6d1a565..a99f89b 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -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 ( !open && onClose()}> @@ -220,17 +228,24 @@ export function ContactInfoPane({ {distFromUs !== null && ( )} - {contact.last_path_len >= 0 && ( + {effectiveRoute && ( 1 ? 's' : ''}` + effectiveRoute.forced ? ( + + {formatRouteLabel(effectiveRoute.pathLen, true)}{' '} + (forced) + + ) : ( + formatRouteLabel(effectiveRoute.pathLen, true) + ) } /> )} - {contact.last_path_len === -1 && } + {contact && hasRoutingOverride(contact) && learnedRouteLabel && ( + + )} {pathHashModeLabel && } @@ -468,7 +483,7 @@ function ChannelAttributionWarning() { ); } -function InfoItem({ label, value }: { label: string; value: string }) { +function InfoItem({ label, value }: { label: string; value: ReactNode }) { return (
{label} diff --git a/frontend/src/components/ContactStatusInfo.tsx b/frontend/src/components/ContactStatusInfo.tsx index c99ccb3..4167af2 100644 --- a/frontend/src/components/ContactStatusInfo.tsx +++ b/frontend/src/components/ContactStatusInfo.tsx @@ -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( - { - 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 - - ); - } else if (contact.last_path_len > 0) { - parts.push( - { - 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' : ''} - - ); - } + parts.push( + { + e.stopPropagation(); + editRoutingOverride(); + }} + title="Click to edit routing override" + > + {formatRouteLabel(effectiveRoute.pathLen)} + {effectiveRoute.forced && (forced)} + + ); if (isValidLocation(contact.lat, contact.lon)) { const distFromUs = diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx index fadb029..65c83fc 100644 --- a/frontend/src/test/contactInfoPane.test.tsx +++ b/frontend/src/test/contactInfoPane.test.tsx @@ -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(); + + 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(); + }); + }); }); diff --git a/frontend/src/test/pathUtils.test.ts b/frontend/src/test/pathUtils.test.ts index e19fe20..0b7f29e 100644 --- a/frontend/src/test/pathUtils.test.ts +++ b/frontend/src/test/pathUtils.test.ts @@ -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({ diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 5ebbb88..c258cd8 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -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(); - 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(); - // Mock the api module - const { api } = await import('../api'); - const resetSpy = vi.spyOn(api, 'resetContactPath').mockResolvedValue({ - status: 'ok', - public_key: REPEATER_KEY, - }); - - render(); - - 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(); - 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(); + + fireEvent.click(screen.getByTitle('Click to edit routing override')); + + expect(promptSpy).toHaveBeenCalled(); + expect(overrideSpy).not.toHaveBeenCalled(); + + promptSpy.mockRestore(); + overrideSpy.mockRestore(); }); }); }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f91cf20..af7774a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; diff --git a/frontend/src/utils/pathUtils.ts b/frontend/src/utils/pathUtils.ts index 8bfb827..5d84f26 100644 --- a/frontend/src/utils/pathUtils.ts +++ b/frontend/src/utils/pathUtils.ts @@ -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. diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index c2fd999..3b05e6d 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -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; diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 33a56f0..12d3453 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -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: diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 4a33f31..23ba44a 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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.""" diff --git a/tests/test_path_utils.py b/tests/test_path_utils.py index 7f4b143..b127c44 100644 --- a/tests/test_path_utils.py +++ b/tests/test_path_utils.py @@ -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.""" diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index 34e6ef3..444cbdb 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -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."""