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

View File

@@ -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 |

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`

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,

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()

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

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

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,

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(

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}

View File

@@ -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

View File

@@ -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>

View File

@@ -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 =

View File

@@ -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();
});
});
});

View File

@@ -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({

View File

@@ -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();
});
});
});

View File

@@ -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;

View File

@@ -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.

View File

@@ -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;

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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."""