4 Commits

Author SHA1 Message Date
Jack Kingsman
5f039b9c41 Phase 4 2026-03-07 15:05:13 -08:00
Jack Kingsman
f302cc04ae Phase 3 2026-03-06 23:48:24 -08:00
Jack Kingsman
3edc7d9bd1 Step 2 2026-03-06 23:12:21 -08:00
Jack Kingsman
9c54ea623e First wave of work 2026-03-06 17:41:37 -08:00
46 changed files with 1242 additions and 196 deletions

View File

@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS contacts (
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
out_path_hash_mode INTEGER,
last_advert INTEGER,
lat REAL,
lon REAL,

View File

@@ -79,9 +79,11 @@ class PacketInfo:
route_type: RouteType
payload_type: PayloadType
payload_version: int
path_length: int
path: bytes # The routing path (empty if path_length is 0)
path_length: int # Hop count encoded in the lower 6 bits of the path byte
path: bytes # The routing path bytes (empty if path_length is 0)
payload: bytes
path_hash_size: int = 1 # Bytes per hop encoded in the upper 2 bits of the path byte
path_byte_length: int = 0
def calculate_channel_hash(channel_key: bytes) -> str:
@@ -93,6 +95,14 @@ def calculate_channel_hash(channel_key: bytes) -> str:
return format(hash_bytes[0], "02x")
def decode_path_metadata(path_byte: int) -> tuple[int, int, int]:
"""Decode the packed path byte into hop count and byte length."""
path_hash_size = (path_byte >> 6) + 1
path_length = path_byte & 0x3F
path_byte_length = path_length * path_hash_size
return path_length, path_hash_size, path_byte_length
def extract_payload(raw_packet: bytes) -> bytes | None:
"""
Extract just the payload from a raw packet, skipping header and path.
@@ -100,8 +110,10 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
Packet structure:
- Byte 0: header (route_type, payload_type, version)
- For TRANSPORT routes: bytes 1-4 are transport codes
- Next byte: path_length
- Next path_length bytes: path data
- Next byte: packed path metadata
- upper 2 bits: bytes per hop minus 1
- lower 6 bits: hop count
- Next hop_count * path_hash_size bytes: path data
- Remaining: payload
Returns the payload bytes, or None if packet is malformed.
@@ -120,16 +132,16 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
return None
offset += 4
# Get path length
# Decode packed path metadata
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
path_length, _path_hash_size, path_byte_length = decode_path_metadata(raw_packet[offset])
offset += 1
# Skip path data
if len(raw_packet) < offset + path_length:
if len(raw_packet) < offset + path_byte_length:
return None
offset += path_length
offset += path_byte_length
# Rest is payload
return raw_packet[offset:]
@@ -156,17 +168,17 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None:
return None
offset += 4
# Get path length
# Decode packed path metadata
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
path_length, path_hash_size, path_byte_length = decode_path_metadata(raw_packet[offset])
offset += 1
# Extract path data
if len(raw_packet) < offset + path_length:
if len(raw_packet) < offset + path_byte_length:
return None
path = raw_packet[offset : offset + path_length]
offset += path_length
path = raw_packet[offset : offset + path_byte_length]
offset += path_byte_length
# Rest is payload
payload = raw_packet[offset:]
@@ -178,6 +190,8 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None:
path_length=path_length,
path=path,
payload=payload,
path_hash_size=path_hash_size,
path_byte_length=path_byte_length,
)
except (ValueError, IndexError):
return None

View File

@@ -106,13 +106,21 @@ async def on_contact_message(event: "Event") -> None:
ts = payload.get("sender_timestamp")
sender_timestamp = ts if ts is not None else received_at
sender_name = contact.name if contact else None
path = payload.get("path")
payload_path_len = payload.get("path_len")
normalized_path_len = (
payload_path_len
if isinstance(payload_path_len, int)
else (len(path) // 2 if path is not None else None)
)
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=payload.get("text", ""),
conversation_key=sender_pubkey,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=payload.get("path"),
path=path,
path_len=normalized_path_len,
txt_type=txt_type,
signature=payload.get("signature"),
sender_key=sender_pubkey,
@@ -129,8 +137,11 @@ async def on_contact_message(event: "Event") -> None:
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
# Build paths array for broadcast
path = payload.get("path")
paths = [MessagePath(path=path or "", received_at=received_at)] if path is not None else None
paths = (
[MessagePath(path=path or "", received_at=received_at, path_len=normalized_path_len)]
if path is not None
else None
)
# Broadcast the new message
broadcast_event(

View File

@@ -44,8 +44,13 @@ def _format_body(data: dict, *, include_path: bool) -> str:
via = ""
if include_path:
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
path_str = paths[0].get("path", "") if isinstance(paths[0], dict) else ""
first_path = (
paths[0]
if isinstance(paths, list) and len(paths) > 0 and isinstance(paths[0], dict)
else None
)
if first_path is not None:
path_str = first_path.get("path", "")
else:
path_str = None
@@ -56,7 +61,13 @@ def _format_body(data: dict, *, include_path: bool) -> str:
if path_str == "":
via = " **via:** [`direct`]"
else:
hops = [path_str[i : i + 2] for i in range(0, len(path_str), 2)]
path_len = first_path.get("path_len") if first_path is not None else None
hop_chars = (
len(path_str) // path_len
if isinstance(path_len, int) and path_len > 0 and len(path_str) % path_len == 0
else 2
)
hops = [path_str[i : i + hop_chars] for i in range(0, len(path_str), hop_chars)]
if hops:
hop_list = ", ".join(f"`{h}`" for h in hops)
via = f" **via:** [{hop_list}]"

View File

@@ -23,6 +23,7 @@ from typing import Any, Protocol
import aiomqtt
import nacl.bindings
from app.decoder import decode_path_metadata
from app.fanout.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__)
@@ -146,23 +147,24 @@ def _calculate_packet_hash(raw_bytes: bytes) -> str:
if has_transport:
offset += 4 # Skip 4 bytes of transport codes
# Read path_len (1 byte on wire). Invalid/truncated packets map to zero hash.
# Read packed path metadata. Invalid/truncated packets map to zero hash.
if offset >= len(raw_bytes):
return "0" * 16
path_len = raw_bytes[offset]
packed_path_len = raw_bytes[offset]
path_len, _path_hash_size, path_byte_length = decode_path_metadata(packed_path_len)
offset += 1
# Skip past path to get to payload. Invalid/truncated packets map to zero hash.
if len(raw_bytes) < offset + path_len:
if len(raw_bytes) < offset + path_byte_length:
return "0" * 16
payload_start = offset + path_len
payload_start = offset + path_byte_length
payload_data = raw_bytes[payload_start:]
# Hash: payload_type(1 byte) [+ path_len as uint16_t LE for TRACE] + payload_data
# Hash: payload_type(1 byte) [+ packed path_len as uint16_t LE for TRACE] + payload_data
hash_obj = hashlib.sha256()
hash_obj.update(bytes([payload_type]))
if payload_type == 9: # PAYLOAD_TYPE_TRACE
hash_obj.update(path_len.to_bytes(2, byteorder="little"))
hash_obj.update(packed_path_len.to_bytes(2, byteorder="little"))
hash_obj.update(payload_data)
return hash_obj.hexdigest()[:16].upper()
@@ -202,20 +204,24 @@ def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], i
if len(raw_bytes) <= offset:
return route, packet_type, payload_len, path_values, payload_type
path_len = raw_bytes[offset]
path_len, path_hash_size, path_byte_length = decode_path_metadata(raw_bytes[offset])
offset += 1
if len(raw_bytes) < offset + path_len:
if len(raw_bytes) < offset + path_byte_length:
return route, packet_type, payload_len, path_values, payload_type
path_bytes = raw_bytes[offset : offset + path_len]
offset += path_len
path_bytes = raw_bytes[offset : offset + path_byte_length]
offset += path_byte_length
payload_type = (header >> 2) & 0x0F
route = _ROUTE_MAP.get(route_type, "U")
packet_type = str(payload_type)
payload_len = str(max(0, len(raw_bytes) - offset))
path_values = [f"{b:02x}" for b in path_bytes]
path_values = [
path_bytes[i : i + path_hash_size].hex()
for i in range(0, len(path_bytes), path_hash_size)
if i + path_hash_size <= len(path_bytes)
]
return route, packet_type, payload_len, path_values, payload_type
except Exception:

View File

@@ -13,6 +13,9 @@ from hashlib import sha256
import aiosqlite
from app.decoder import extract_payload, parse_packet
from app.models import Contact
logger = logging.getLogger(__name__)
@@ -303,6 +306,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 38)
applied += 1
# Migration 39: Persist exact contact out_path_hash_mode from radio sync
if version < 39:
logger.info("Applying migration 39: add contacts.out_path_hash_mode")
await _migrate_039_add_contact_out_path_hash_mode(conn)
await set_version(conn, 39)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -442,35 +452,7 @@ def _extract_payload_for_hash(raw_packet: bytes) -> bytes | None:
Returns the payload bytes, or None if packet is malformed.
"""
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Skip path bytes
if len(raw_packet) < offset + path_length:
return None
offset += path_length
# Rest is payload (may be empty, matching decoder.py behavior)
return raw_packet[offset:]
except (IndexError, ValueError):
return None
return extract_payload(raw_packet)
async def _migrate_005_backfill_payload_hashes(conn: aiosqlite.Connection) -> None:
@@ -624,34 +606,10 @@ def _extract_path_from_packet(raw_packet: bytes) -> str | None:
Returns the path as a hex string, or None if packet is malformed.
"""
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Extract path bytes
if len(raw_packet) < offset + path_length:
return None
path_bytes = raw_packet[offset : offset + path_length]
return path_bytes.hex()
except (IndexError, ValueError):
packet_info = parse_packet(raw_packet)
if packet_info is None:
return None
return packet_info.path.hex()
async def _migrate_007_backfill_message_paths(conn: aiosqlite.Connection) -> None:
@@ -2280,3 +2238,44 @@ async def _migrate_038_drop_legacy_columns(conn: aiosqlite.Connection) -> None:
raise
await conn.commit()
async def _migrate_039_add_contact_out_path_hash_mode(conn: aiosqlite.Connection) -> None:
"""Add contacts.out_path_hash_mode and backfill best-effort values for existing rows."""
tables = await conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'contacts'"
)
if await tables.fetchone() is None:
await conn.commit()
return
columns_cursor = await conn.execute("PRAGMA table_info(contacts)")
columns = {row["name"] for row in await columns_cursor.fetchall()}
if "out_path_hash_mode" not in columns:
await conn.execute("ALTER TABLE contacts ADD COLUMN out_path_hash_mode INTEGER")
columns.add("out_path_hash_mode")
if not {"last_path", "last_path_len"}.issubset(columns):
await conn.commit()
return
rows_cursor = await conn.execute(
"""
SELECT public_key, last_path, last_path_len, out_path_hash_mode
FROM contacts
"""
)
rows = await rows_cursor.fetchall()
for row in rows:
if row["out_path_hash_mode"] is not None:
continue
if row["last_path_len"] is None or row["last_path_len"] < 0:
continue
derived = Contact._derive_out_path_hash_mode(row["last_path"], row["last_path_len"])
await conn.execute(
"UPDATE contacts SET out_path_hash_mode = ? WHERE public_key = ?",
(derived, row["public_key"]),
)
await conn.commit()

View File

@@ -10,6 +10,7 @@ class Contact(BaseModel):
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
out_path_hash_mode: int | None = None
last_advert: int | None = None
lat: float | None = None
lon: float | None = None
@@ -19,12 +20,36 @@ class Contact(BaseModel):
last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None
@staticmethod
def _derive_out_path_hash_mode(path_hex: str | None, path_len: int) -> int:
"""Infer the contact path hash mode from stored path bytes and hop count."""
if path_len < 0:
return -1
if path_len == 0 or not path_hex:
return 0
if len(path_hex) % 2 != 0:
return 0
path_bytes = len(path_hex) // 2
if path_bytes == 0 or path_bytes % path_len != 0:
return 0
bytes_per_hop = path_bytes // path_len
if bytes_per_hop < 1:
return 0
return bytes_per_hop - 1
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.).
"""
out_path_hash_mode = self.out_path_hash_mode
if out_path_hash_mode is None:
out_path_hash_mode = self._derive_out_path_hash_mode(self.last_path, self.last_path_len)
return {
"public_key": self.public_key,
"adv_name": self.name or "",
@@ -32,6 +57,7 @@ class Contact(BaseModel):
"flags": self.flags,
"out_path": self.last_path or "",
"out_path_len": self.last_path_len,
"out_path_hash_mode": out_path_hash_mode,
"adv_lat": self.lat if self.lat is not None else 0.0,
"adv_lon": self.lon if self.lon is not None else 0.0,
"last_advert": self.last_advert if self.last_advert is not None else 0,
@@ -51,6 +77,7 @@ class Contact(BaseModel):
"flags": radio_data.get("flags", 0),
"last_path": radio_data.get("out_path"),
"last_path_len": radio_data.get("out_path_len", -1),
"out_path_hash_mode": radio_data.get("out_path_hash_mode"),
"lat": radio_data.get("adv_lat"),
"lon": radio_data.get("adv_lon"),
"last_advert": radio_data.get("last_advert"),
@@ -79,7 +106,7 @@ class ContactAdvertPath(BaseModel):
path: str = Field(description="Hex-encoded routing path (empty string for direct)")
path_len: int = Field(description="Number of hops in the path")
next_hop: str | None = Field(
default=None, description="First hop toward us (2-char hex), or null for direct"
default=None, description="First hop toward us, or null for direct"
)
first_seen: int = Field(description="Unix timestamp of first observation")
last_seen: int = Field(description="Unix timestamp of most recent observation")
@@ -176,8 +203,9 @@ class ChannelDetail(BaseModel):
class MessagePath(BaseModel):
"""A single path that a message took to reach us."""
path: str = Field(description="Hex-encoded routing path (2 chars per hop)")
path: str = Field(description="Hex-encoded routing path")
received_at: int = Field(description="Unix timestamp when this path was received")
path_len: int | None = Field(default=None, description="Number of hops in the path, when known")
class Message(BaseModel):

View File

@@ -57,6 +57,7 @@ async def _handle_duplicate_message(
text: str,
sender_timestamp: int,
path: str | None,
path_len: int | None,
received: int,
) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record.
@@ -90,7 +91,7 @@ async def _handle_duplicate_message(
# Add path if provided
if path is not None:
paths = await MessageRepository.add_path(existing_msg.id, path, received)
paths = await MessageRepository.add_path(existing_msg.id, path, received, path_len=path_len)
else:
# Get current paths for broadcast
paths = existing_msg.paths or []
@@ -128,6 +129,7 @@ async def create_message_from_decrypted(
timestamp: int,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
channel_name: str | None = None,
realtime: bool = True,
) -> int | None:
@@ -150,6 +152,9 @@ async def create_message_from_decrypted(
Returns the message ID if created, None if duplicate.
"""
received = received_at or int(time.time())
normalized_path_len = (
path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None)
)
# Format the message text with sender prefix if present
text = f"{sender}: {message_text}" if sender else message_text
@@ -172,6 +177,7 @@ async def create_message_from_decrypted(
sender_timestamp=timestamp,
received_at=received,
path=path,
path_len=normalized_path_len,
sender_name=sender,
sender_key=resolved_sender_key,
)
@@ -182,7 +188,14 @@ async def create_message_from_decrypted(
# 2. Same message arrives via multiple paths before first is committed
# In either case, add the path to the existing message.
await _handle_duplicate_message(
packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received
packet_id,
"CHAN",
channel_key_normalized,
text,
timestamp,
path,
normalized_path_len,
received,
)
return None
@@ -193,7 +206,11 @@ async def create_message_from_decrypted(
# Build paths array for broadcast
# Use "is not None" to include empty string (direct/0-hop messages)
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
paths = (
[MessagePath(path=path or "", received_at=received, path_len=normalized_path_len)]
if path is not None
else None
)
# Broadcast new message to connected clients (and fanout modules when realtime)
broadcast_event(
@@ -223,6 +240,7 @@ async def create_dm_message_from_decrypted(
our_public_key: str | None,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
outgoing: bool = False,
realtime: bool = True,
) -> int | None:
@@ -255,6 +273,9 @@ async def create_dm_message_from_decrypted(
return None
received = received_at or int(time.time())
normalized_path_len = (
path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None)
)
# conversation_key is always the other party's public key
conversation_key = their_public_key.lower()
@@ -270,6 +291,7 @@ async def create_dm_message_from_decrypted(
sender_timestamp=decrypted.timestamp,
received_at=received,
path=path,
path_len=normalized_path_len,
outgoing=outgoing,
sender_key=conversation_key if not outgoing else None,
sender_name=sender_name,
@@ -284,6 +306,7 @@ async def create_dm_message_from_decrypted(
decrypted.message,
decrypted.timestamp,
path,
normalized_path_len,
received,
)
return None
@@ -299,7 +322,11 @@ async def create_dm_message_from_decrypted(
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
# Build paths array for broadcast
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
paths = (
[MessagePath(path=path or "", received_at=received, path_len=normalized_path_len)]
if path is not None
else None
)
# Broadcast new message to connected clients (and fanout modules when realtime)
sender_name = contact.name if contact and not outgoing else None
@@ -391,6 +418,7 @@ async def run_historical_dm_decryption(
our_public_key=our_public_key_bytes.hex(),
received_at=packet_timestamp,
path=path_hex,
path_len=packet_info.path_length if packet_info else None,
outgoing=outgoing,
realtime=False, # Historical decryption should not trigger fanout
)
@@ -606,6 +634,7 @@ async def _process_group_text(
timestamp=decrypted.timestamp,
received_at=timestamp,
path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None,
)
return {
@@ -700,6 +729,7 @@ async def _process_advertisement(
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
path_len=new_path_len,
)
# Record name history
@@ -872,6 +902,7 @@ async def _process_direct_message(
our_public_key=our_public_key.hex(),
received_at=timestamp,
path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None,
outgoing=is_outgoing,
)

32
app/path_utils.py Normal file
View File

@@ -0,0 +1,32 @@
"""Helpers for working with hex-encoded routing paths."""
def get_path_hop_width(path_hex: str | None, path_len: int | None) -> int:
"""Return hop width in hex chars, falling back to legacy 1-byte hops."""
if not path_hex:
return 2
if isinstance(path_len, int) and path_len > 0 and len(path_hex) % path_len == 0:
hop_width = len(path_hex) // path_len
if hop_width > 0 and hop_width % 2 == 0:
return hop_width
return 2
def split_path_hops(path_hex: str | None, path_len: int | None) -> list[str]:
"""Split a hex path string into hop-sized chunks."""
if not path_hex:
return []
hop_width = get_path_hop_width(path_hex, path_len)
normalized = path_hex.lower()
return [
normalized[i : i + hop_width]
for i in range(0, len(normalized), hop_width)
if i + hop_width <= len(normalized)
]
def first_path_hop(path_hex: str | None, path_len: int | None) -> str | None:
"""Return the first hop from a hex path string, if any."""
hops = split_path_hops(path_hex, path_len)
return hops[0] if hops else None

View File

@@ -1,11 +1,12 @@
import asyncio
import glob
import inspect
import logging
import platform
from contextlib import asynccontextmanager, nullcontext
from pathlib import Path
from meshcore import MeshCore
from meshcore import EventType, MeshCore
from app.config import settings
@@ -128,6 +129,8 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
self._path_hash_mode: int = 0
self._path_hash_mode_supported: bool = False
async def _acquire_operation_lock(
self,
@@ -257,6 +260,7 @@ class RadioManager:
# Sync radio clock with system time
await sync_radio_time(mc)
await self.refresh_path_hash_mode_info(mc)
# Apply flood scope from settings (best-effort; older firmware
# may not support set_flood_scope)
@@ -331,6 +335,48 @@ class RadioManager:
def is_setup_complete(self) -> bool:
return self._setup_complete
@property
def path_hash_mode_info(self) -> tuple[int, bool]:
return self._path_hash_mode, self._path_hash_mode_supported
def set_path_hash_mode_info(self, mode: int, supported: bool) -> None:
self._path_hash_mode = mode if supported and 0 <= mode <= 2 else 0
self._path_hash_mode_supported = supported
async def refresh_path_hash_mode_info(self, mc: MeshCore | None = None) -> tuple[int, bool]:
target = mc or self._meshcore
if target is None:
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
commands = getattr(target, "commands", None)
send_device_query = getattr(commands, "send_device_query", None)
if commands is None or not callable(send_device_query):
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
try:
result = send_device_query()
if inspect.isawaitable(result):
result = await result
except Exception as exc:
logger.debug("Failed to query device info for path hash mode: %s", exc)
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
if result is None or getattr(result, "type", None) == EventType.ERROR:
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
payload_obj = getattr(result, "payload", None)
payload = payload_obj if isinstance(payload_obj, dict) else {}
mode = payload.get("path_hash_mode")
if isinstance(mode, int) and 0 <= mode <= 2:
self.set_path_hash_mode_info(mode, True)
else:
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
async def connect(self) -> None:
"""Connect to the radio using the configured transport."""
if self._meshcore is not None:
@@ -369,6 +415,7 @@ class RadioManager:
self._connection_info = f"Serial: {port}"
self._last_connected = True
self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("Serial connection established")
async def _connect_tcp(self) -> None:
@@ -386,6 +433,7 @@ class RadioManager:
self._connection_info = f"TCP: {host}:{port}"
self._last_connected = True
self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("TCP connection established")
async def _connect_ble(self) -> None:
@@ -403,6 +451,7 @@ class RadioManager:
self._connection_info = f"BLE: {address}"
self._last_connected = True
self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("BLE connection established")
async def disconnect(self) -> None:
@@ -412,6 +461,7 @@ class RadioManager:
await self._meshcore.disconnect()
self._meshcore = None
self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("Radio disconnected")
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:

View File

@@ -8,6 +8,7 @@ from app.models import (
ContactAdvertPathSummary,
ContactNameHistory,
)
from app.path_utils import first_path_hop
class AmbiguousPublicKeyPrefixError(ValueError):
@@ -25,15 +26,19 @@ class ContactRepository:
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
out_path_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,
flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len,
out_path_hash_mode = COALESCE(
excluded.out_path_hash_mode, contacts.out_path_hash_mode
),
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
lat = COALESCE(excluded.lat, contacts.lat),
lon = COALESCE(excluded.lon, contacts.lon),
@@ -49,6 +54,7 @@ class ContactRepository:
contact.get("flags", 0),
contact.get("last_path"),
contact.get("last_path_len", -1),
contact.get("out_path_hash_mode"),
contact.get("last_advert"),
contact.get("lat"),
contact.get("lon"),
@@ -70,6 +76,7 @@ class ContactRepository:
flags=row["flags"],
last_path=row["last_path"],
last_path_len=row["last_path_len"],
out_path_hash_mode=row["out_path_hash_mode"],
last_advert=row["last_advert"],
lat=row["lat"],
lon=row["lon"],
@@ -200,11 +207,23 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def update_path(public_key: str, path: str, path_len: int) -> None:
await db.conn.execute(
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
(path, path_len, int(time.time()), public_key.lower()),
)
async def update_path(
public_key: str, path: str, path_len: int, out_path_hash_mode: int | None = None
) -> None:
if out_path_hash_mode is None:
await db.conn.execute(
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
(path, path_len, int(time.time()), public_key.lower()),
)
else:
await db.conn.execute(
"""
UPDATE contacts
SET last_path = ?, last_path_len = ?, out_path_hash_mode = ?, last_seen = ?
WHERE public_key = ?
""",
(path, path_len, out_path_hash_mode, int(time.time()), public_key.lower()),
)
await db.conn.commit()
@staticmethod
@@ -287,7 +306,7 @@ class ContactAdvertPathRepository:
@staticmethod
def _row_to_path(row) -> ContactAdvertPath:
path = row["path_hex"] or ""
next_hop = path[:2].lower() if len(path) >= 2 else None
next_hop = first_path_hop(path, row["path_len"])
return ContactAdvertPath(
path=path,
path_len=row["path_len"],
@@ -303,6 +322,7 @@ class ContactAdvertPathRepository:
path_hex: str,
timestamp: int,
max_paths: int = 10,
path_len: int | None = None,
) -> None:
"""
Upsert a unique advert path observation for a contact and prune to N most recent.
@@ -312,7 +332,7 @@ class ContactAdvertPathRepository:
normalized_key = public_key.lower()
normalized_path = path_hex.lower()
path_len = len(normalized_path) // 2
normalized_path_len = path_len if isinstance(path_len, int) else len(normalized_path) // 2
await db.conn.execute(
"""
@@ -324,7 +344,7 @@ class ContactAdvertPathRepository:
path_len = excluded.path_len,
heard_count = contact_advert_paths.heard_count + 1
""",
(normalized_key, normalized_path, path_len, timestamp, timestamp),
(normalized_key, normalized_path, normalized_path_len, timestamp, timestamp),
)
# Keep only the N most recent unique paths per contact.

View File

@@ -26,6 +26,7 @@ class MessageRepository:
conversation_key: str,
sender_timestamp: int | None = None,
path: str | None = None,
path_len: int | None = None,
txt_type: int = 0,
signature: str | None = None,
outgoing: bool = False,
@@ -43,7 +44,10 @@ class MessageRepository:
# Convert single path to paths array format
paths_json = None
if path is not None:
paths_json = json.dumps([{"path": path, "received_at": received_at}])
normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2
path_entry: dict[str, Any] = {"path": path, "received_at": received_at}
path_entry["path_len"] = normalized_path_len
paths_json = json.dumps([path_entry])
cursor = await db.conn.execute(
"""
@@ -74,7 +78,7 @@ class MessageRepository:
@staticmethod
async def add_path(
message_id: int, path: str, received_at: int | None = None
message_id: int, path: str, received_at: int | None = None, path_len: int | None = None
) -> list[MessagePath]:
"""Add a new path to an existing message.
@@ -85,7 +89,10 @@ class MessageRepository:
# Atomic append: use json_insert to avoid read-modify-write race when
# multiple duplicate packets arrive concurrently for the same message.
new_entry = json.dumps({"path": path, "received_at": ts})
normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2
new_entry_dict: dict[str, Any] = {"path": path, "received_at": ts}
new_entry_dict["path_len"] = normalized_path_len
new_entry = json.dumps(new_entry_dict)
await db.conn.execute(
"""UPDATE messages SET paths = json_insert(
COALESCE(paths, '[]'), '$[#]', json(?)

View File

@@ -16,6 +16,7 @@ from app.models import (
TraceResponse,
)
from app.packet_processor import start_historical_dm_decryption
from app.path_utils import first_path_hop
from app.radio import radio_manager
from app.repository import (
AmbiguousPublicKeyPrefixError,
@@ -201,11 +202,11 @@ async def get_contact_detail(public_key: str) -> ContactDetail:
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
# Compute nearest repeaters from first-hop prefixes in advert paths
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
# Compute nearest repeaters from first hops in advert paths
first_hop_stats: dict[str, dict] = {} # first hop -> {heard_count, path_len, last_seen}
for p in advert_paths:
if p.path and len(p.path) >= 2:
prefix = p.path[:2].lower()
prefix = first_path_hop(p.path, p.path_len)
if prefix:
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
@@ -462,7 +463,7 @@ async def reset_contact_path(public_key: str) -> dict:
"""Reset a contact's routing path to flood."""
contact = await _resolve_contact_or_404(public_key)
await ContactRepository.update_path(contact.public_key, "", -1)
await ContactRepository.update_path(contact.public_key, "", -1, out_path_hash_mode=-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

View File

@@ -71,6 +71,7 @@ async def _run_historical_channel_decryption(
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
path_len=packet_info.path_length if packet_info else None,
realtime=False, # Historical decryption should not trigger fanout
)

View File

@@ -1,4 +1,6 @@
import logging
from collections.abc import Awaitable, Callable
from typing import Any, cast
from fastapi import APIRouter, HTTPException
from meshcore import EventType
@@ -27,6 +29,12 @@ class RadioConfigResponse(BaseModel):
lon: float
tx_power: int = Field(description="Transmit power in dBm")
max_tx_power: int = Field(description="Maximum transmit power in dBm")
path_hash_mode: int = Field(
default=0, description="Default outbound path hash mode (0=1 byte, 1=2 bytes, 2=3 bytes)"
)
path_hash_mode_supported: bool = Field(
default=False, description="Whether the connected radio/firmware exposes path hash mode"
)
radio: RadioSettings
@@ -35,6 +43,9 @@ class RadioConfigUpdate(BaseModel):
lat: float | None = None
lon: float | None = None
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
path_hash_mode: int | None = Field(
default=None, ge=0, le=2, description="Default outbound path hash mode"
)
radio: RadioSettings | None = None
@@ -42,15 +53,47 @@ class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
async def _set_path_hash_mode(mc, mode: int):
"""Set path hash mode using either the new helper or raw command fallback."""
commands = getattr(mc, "commands", None)
if commands is None:
raise HTTPException(status_code=503, detail="Radio command interface unavailable")
set_path_hash_mode = cast(
Callable[[int], Awaitable[Any]] | None, getattr(commands, "set_path_hash_mode", None)
)
send_raw = cast(
Callable[[bytes, list[EventType]], Awaitable[Any]] | None,
getattr(commands, "send", None),
)
if callable(set_path_hash_mode):
result = await set_path_hash_mode(mode)
elif callable(send_raw):
data = b"\x3d\x00" + int(mode).to_bytes(1, "little")
result = await send_raw(data, [EventType.OK, EventType.ERROR])
else:
raise HTTPException(
status_code=400,
detail="Installed meshcore interface library cannot set path hash mode",
)
if result is not None and result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to set path hash mode on radio")
return result
@router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration."""
mc = require_connected()
info = mc.self_info
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
path_hash_mode, path_hash_mode_supported = radio_manager.path_hash_mode_info
return RadioConfigResponse(
public_key=info.get("public_key", ""),
name=info.get("name", ""),
@@ -58,6 +101,8 @@ async def get_radio_config() -> RadioConfigResponse:
lon=info.get("adv_lon", 0.0),
tx_power=info.get("tx_power", 0),
max_tx_power=info.get("max_tx_power", 0),
path_hash_mode=path_hash_mode,
path_hash_mode_supported=path_hash_mode_supported,
radio=RadioSettings(
freq=info.get("radio_freq", 0.0),
bw=info.get("radio_bw", 0.0),
@@ -88,6 +133,20 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
logger.info("Setting TX power to %d dBm", update.tx_power)
await mc.commands.set_tx_power(val=update.tx_power)
if update.path_hash_mode is not None:
current_mode, supported = radio_manager.path_hash_mode_info
if not supported:
current_mode, supported = await radio_manager.refresh_path_hash_mode_info(mc)
if not supported:
raise HTTPException(
status_code=400,
detail="Connected radio/firmware does not expose path hash mode",
)
if current_mode != update.path_hash_mode:
logger.info("Setting path hash mode to %d", update.path_hash_mode)
await _set_path_hash_mode(mc, update.path_hash_mode)
radio_manager.set_path_hash_mode_info(update.path_hash_mode, True)
if update.radio is not None:
logger.info(
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",

View File

@@ -1,7 +1,12 @@
import { useEffect, useState } from 'react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import {
isValidLocation,
calculateDistance,
formatDistance,
parsePathHops,
} from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -413,7 +418,7 @@ export function ContactInfoPane({
className="flex justify-between items-center text-sm"
>
<span className="font-mono text-xs truncate">
{p.path ? p.path.match(/.{2}/g)!.join(' → ') : '(direct)'}
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)}

View File

@@ -1,46 +1,13 @@
import '../utils/meshcoreDecoderPatch';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { GroupTextCracker, type ProgressReport } from 'meshcore-hashtag-cracker';
import NoSleep from 'nosleep.js';
import type { RawPacket, Channel } from '../types';
import { api } from '../api';
import { extractRawPacketPayload } from '../utils/rawPacketPayload';
import { toast } from './ui/sonner';
import { cn } from '@/lib/utils';
/**
* Extract the payload from a raw packet hex string, skipping header and path.
* Returns the payload as a hex string, or null if malformed.
*/
function extractPayload(packetHex: string): string | null {
if (packetHex.length < 4) return null; // Need at least 2 bytes
try {
const header = parseInt(packetHex.slice(0, 2), 16);
const routeType = header & 0x03;
let offset = 2; // 1 byte = 2 hex chars
// Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if (routeType === 0x00 || routeType === 0x03) {
if (packetHex.length < offset + 8) return null; // Need 4 more bytes
offset += 8; // 4 bytes = 8 hex chars
}
// Get path length
if (packetHex.length < offset + 2) return null;
const pathLength = parseInt(packetHex.slice(offset, offset + 2), 16);
offset += 2;
// Skip path data
const pathBytes = pathLength * 2; // hex chars
if (packetHex.length < offset + pathBytes) return null;
offset += pathBytes;
// Rest is payload
return packetHex.slice(offset);
} catch {
return null;
}
}
interface CrackedRoom {
roomName: string;
key: string;
@@ -177,7 +144,7 @@ export function CrackerPanel({
for (const packet of undecryptedGroupText) {
if (!newQueue.has(packet.id)) {
// Extract payload and check for duplicates
const payload = extractPayload(packet.data);
const payload = extractRawPacketPayload(packet.data);
if (payload && seenPayloadsRef.current.has(payload)) {
// Skip - we already have a packet with this payload queued
newSkipped++;

View File

@@ -52,7 +52,7 @@ export function PathModal({
const resolvedPaths = hasPaths
? paths.map((p) => ({
...p,
resolved: resolvePath(p.path, senderInfo, contacts, config),
resolved: resolvePath(p.path, senderInfo, contacts, config, p.path_len),
}))
: [];
@@ -90,7 +90,7 @@ export function PathModal({
{/* Raw path summary */}
<div className="text-sm">
{paths.map((p, index) => {
const hops = parsePathHops(p.path);
const hops = parsePathHops(p.path, p.path_len);
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
return (
<div key={index}>

View File

@@ -1,3 +1,4 @@
import '../utils/meshcoreDecoderPatch';
import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';

View File

@@ -43,6 +43,7 @@ export function SettingsRadioSection({
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [pathHashMode, setPathHashMode] = useState('0');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
@@ -73,6 +74,7 @@ export function SettingsRadioSection({
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setPathHashMode(String(config.path_hash_mode));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
@@ -145,6 +147,7 @@ export function SettingsRadioSection({
const parsedLat = parseFloat(lat);
const parsedLon = parseFloat(lon);
const parsedTxPower = parseInt(txPower, 10);
const parsedPathHashMode = parseInt(pathHashMode, 10);
const parsedFreq = parseFloat(freq);
const parsedBw = parseFloat(bw);
const parsedSf = parseInt(sf, 10);
@@ -159,11 +162,20 @@ export function SettingsRadioSection({
return null;
}
if (
config.path_hash_mode_supported &&
(isNaN(parsedPathHashMode) || parsedPathHashMode < 0 || parsedPathHashMode > 2)
) {
setError('Path hash mode must be between 0 and 2');
return null;
}
return {
name,
lat: parsedLat,
lon: parsedLon,
tx_power: parsedTxPower,
...(config.path_hash_mode_supported && { path_hash_mode: parsedPathHashMode }),
radio: {
freq: parsedFreq,
bw: parsedBw,
@@ -384,6 +396,26 @@ export function SettingsRadioSection({
</div>
</div>
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
disabled={!config.path_hash_mode_supported}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="0">1 byte per hop</option>
<option value="1">2 bytes per hop</option>
<option value="2">3 bytes per hop</option>
</select>
<p className="text-xs text-muted-foreground">
{config.path_hash_mode_supported
? 'Controls the default hop hash width your radio uses for outbound routed paths.'
: 'Connected radio or firmware does not expose this setting.'}
</p>
</div>
<Separator />
<div className="space-y-2">

View File

@@ -1,3 +1,4 @@
import './utils/meshcoreDecoderPatch';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';

View File

@@ -173,6 +173,8 @@ const baseConfig = {
lon: 0,
tx_power: 17,
max_tx_power: 22,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
};

View File

@@ -201,6 +201,8 @@ describe('App search jump target handling', () => {
lon: 0,
tx_power: 17,
max_tx_power: 22,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
});
mocks.api.getSettings.mockResolvedValue({

View File

@@ -157,6 +157,8 @@ describe('App startup hash resolution', () => {
lon: 0,
tx_power: 17,
max_tx_power: 22,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
});
mocks.api.getSettings.mockResolvedValue({

View File

@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from '../api';
import { ContactInfoPane } from '../components/ContactInfoPane';
import type { Contact, ContactDetail } from '../types';
vi.mock('../api', () => ({
api: {
getContactDetail: vi.fn(),
},
}));
const baseContact: Contact = {
public_key: 'aa'.repeat(32),
name: 'Repeater Alpha',
type: 2,
flags: 0,
last_path: null,
last_path_len: 2,
last_advert: 1700000000,
lat: null,
lon: null,
last_seen: 1700000000,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: 1699990000,
};
describe('ContactInfoPane', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('renders advert paths using hop-aware grouping', async () => {
const detail: ContactDetail = {
contact: baseContact,
name_history: [],
dm_message_count: 0,
channel_message_count: 0,
most_active_rooms: [],
advert_paths: [
{
path: '20273031',
path_len: 2,
next_hop: '2027',
first_seen: 1700000000,
last_seen: 1700000100,
heard_count: 3,
},
],
advert_frequency: null,
nearest_repeaters: [],
};
vi.mocked(api.getContactDetail).mockResolvedValue(detail);
render(
<ContactInfoPane
contactKey={baseContact.public_key}
onClose={vi.fn()}
contacts={[baseContact]}
config={null}
favorites={[]}
onToggleFavorite={vi.fn()}
/>
);
expect(await screen.findByText('2027 → 3031')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import '../utils/meshcoreDecoderPatch';
import { MeshCoreDecoder } from '@michaelhart/meshcore-decoder';
describe('meshcoreDecoderPatch', () => {
it('groups two-byte hops and preserves payload extraction', () => {
const decoded = MeshCoreDecoder.decode('3E4220273031DEADBEEF');
expect(decoded.isValid).toBe(true);
expect(decoded.pathLength).toBe(2);
expect(decoded.path).toEqual(['2027', '3031']);
expect(decoded.payload.raw).toBe('DEADBEEF');
});
it('groups three-byte hops and preserves payload extraction', () => {
const decoded = MeshCoreDecoder.decode('3E82112233445566DEADBEEF');
expect(decoded.isValid).toBe(true);
expect(decoded.pathLength).toBe(2);
expect(decoded.path).toEqual(['112233', '445566']);
expect(decoded.payload.raw).toBe('DEADBEEF');
});
it('patches async decode entrypoints used by the cracker', async () => {
const decoded = await MeshCoreDecoder.decodeWithVerification('3E4220273031DEADBEEF');
expect(decoded.isValid).toBe(true);
expect(decoded.pathLength).toBe(2);
expect(decoded.path).toEqual(['2027', '3031']);
expect(decoded.payload.raw).toBe('DEADBEEF');
});
it('validates multi-byte packets using rewritten byte lengths', () => {
const result = MeshCoreDecoder.validate('3E82112233445566DEADBEEF');
expect(result.isValid).toBe(true);
});
});

View File

@@ -40,6 +40,8 @@ function createConfig(overrides: Partial<RadioConfig> = {}): RadioConfig {
lon: -74.006,
tx_power: 10,
max_tx_power: 20,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 915, bw: 250, sf: 10, cr: 8 },
...overrides,
};
@@ -60,6 +62,14 @@ describe('parsePathHops', () => {
expect(parsePathHops('1A2B3C')).toEqual(['1A', '2B', '3C']);
});
it('parses multi-byte hops when path length is provided', () => {
expect(parsePathHops('1A2B3C4D', 2)).toEqual(['1A2B', '3C4D']);
});
it('parses three-byte hops when path length is provided', () => {
expect(parsePathHops('1A2B3C4D5E6F', 2)).toEqual(['1A2B3C', '4D5E6F']);
});
it('converts to uppercase', () => {
expect(parsePathHops('1a2b')).toEqual(['1A', '2B']);
});
@@ -197,6 +207,29 @@ describe('resolvePath', () => {
expect(result.receiver.prefix).toBe('FF');
});
it('resolves multi-byte hop prefixes when path length is provided', () => {
const wideContacts = [
createContact({
public_key: '1A2B' + 'A'.repeat(60),
name: 'WideRepeater1',
type: CONTACT_TYPE_REPEATER,
}),
createContact({
public_key: '3C4D' + 'B'.repeat(60),
name: 'WideRepeater2',
type: CONTACT_TYPE_REPEATER,
}),
];
const result = resolvePath('1A2B3C4D', sender, wideContacts, config, 2);
expect(result.hops).toHaveLength(2);
expect(result.hops[0].prefix).toBe('1A2B');
expect(result.hops[0].matches[0].name).toBe('WideRepeater1');
expect(result.hops[1].prefix).toBe('3C4D');
expect(result.hops[1].matches[0].name).toBe('WideRepeater2');
});
it('handles unknown repeaters (no matches)', () => {
const result = resolvePath('XX', sender, contacts, config);
@@ -545,6 +578,13 @@ describe('formatHopCounts', () => {
expect(result.hasMultiple).toBe(false);
});
it('uses explicit path_len for multi-byte hop counts', () => {
const result = formatHopCounts([{ path: '1A2B3C4D', path_len: 2, received_at: 1700000000 }]);
expect(result.display).toBe('2');
expect(result.allDirect).toBe(false);
expect(result.hasMultiple).toBe(false);
});
it('formats multiple paths sorted by hop count', () => {
const result = formatHopCounts([
{ path: '1A2B3C', received_at: 1700000000 }, // 3 hops

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { extractRawPacketPayload } from '../utils/rawPacketPayload';
describe('extractRawPacketPayload', () => {
it('extracts payload for legacy one-byte hops', () => {
expect(extractRawPacketPayload('1502AABBDEADBEEF')).toBe('DEADBEEF');
});
it('extracts payload for multi-byte hops', () => {
expect(extractRawPacketPayload('154220273031DEADBEEF')).toBe('DEADBEEF');
});
it('extracts payload for transport packets with multi-byte hops', () => {
expect(extractRawPacketPayload('14010203044220273031DEADBEEF')).toBe('DEADBEEF');
});
it('extracts payload for three-byte-hop packets', () => {
expect(extractRawPacketPayload('1582112233445566DEADBEEF')).toBe('DEADBEEF');
});
it('returns null for truncated multi-byte path data', () => {
expect(extractRawPacketPayload('15422027')).toBeNull();
});
});

View File

@@ -24,6 +24,8 @@ const baseConfig: RadioConfig = {
lon: 2,
tx_power: 17,
max_tx_power: 22,
path_hash_mode: 1,
path_hash_mode_supported: true,
radio: {
freq: 910.525,
bw: 62.5,
@@ -57,6 +59,7 @@ const baseSettings: AppSettings = {
};
function renderModal(overrides?: {
config?: RadioConfig;
appSettings?: AppSettings;
health?: HealthStatus;
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
@@ -83,7 +86,7 @@ function renderModal(overrides?: {
const commonProps = {
open: overrides?.open ?? true,
pageMode: overrides?.pageMode,
config: baseConfig,
config: overrides?.config ?? baseConfig,
health: overrides?.health ?? baseHealth,
appSettings: overrides?.appSettings ?? baseSettings,
onClose,
@@ -218,6 +221,36 @@ describe('SettingsModal', () => {
});
});
it('saves radio path hash mode through onSave', async () => {
const { onSave } = renderModal();
openRadioSection();
fireEvent.change(screen.getByLabelText('Path Hash Mode'), {
target: { value: '2' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
path_hash_mode: 2,
})
);
});
});
it('disables path hash mode when the connected radio does not expose it', () => {
renderModal({
config: { ...baseConfig, path_hash_mode_supported: false },
});
openRadioSection();
expect(screen.getByLabelText('Path Hash Mode')).toBeDisabled();
expect(
screen.getByText('Connected radio or firmware does not expose this setting.')
).toBeInTheDocument();
});
it('renders selected section from external sidebar nav on desktop mode', async () => {
renderModal({
externalSidebarNav: true,

View File

@@ -12,6 +12,8 @@ export interface RadioConfig {
lon: number;
tx_power: number;
max_tx_power: number;
path_hash_mode: number;
path_hash_mode_supported: boolean;
radio: RadioSettings;
}
@@ -20,6 +22,7 @@ export interface RadioConfigUpdate {
lat?: number;
lon?: number;
tx_power?: number;
path_hash_mode?: number;
radio?: RadioSettings;
}
@@ -62,6 +65,7 @@ export interface Contact {
flags: number;
last_path: string | null;
last_path_len: number;
out_path_hash_mode?: number | null;
last_advert: number | null;
lat: number | null;
lon: number | null;
@@ -149,10 +153,12 @@ export interface ChannelDetail {
/** A single path that a message took to reach us */
export interface MessagePath {
/** Hex-encoded routing path (2 chars per hop) */
/** Hex-encoded routing path */
path: string;
/** Unix timestamp when this path was received */
received_at: number;
/** Number of hops in the path, when known */
path_len?: number;
}
export interface Message {

View File

@@ -0,0 +1,115 @@
import { MeshCorePacketDecoder, bytesToHex, hexToBytes } from '@michaelhart/meshcore-decoder';
type DecoderClass = typeof MeshCorePacketDecoder & {
__multiBytePathPatchApplied?: boolean;
};
type DecoderOptions = Parameters<typeof MeshCorePacketDecoder.decode>[1];
interface PathRewrite {
hexData: string;
hopCount: number;
pathHashSize: number;
}
function decodePathMetadata(pathByte: number): {
hopCount: number;
pathHashSize: number;
pathByteLength: number;
} {
const pathHashSize = (pathByte >> 6) + 1;
const hopCount = pathByte & 0x3f;
return {
hopCount,
pathHashSize,
pathByteLength: hopCount * pathHashSize,
};
}
function getPackedPathOffset(bytes: Uint8Array): number | null {
if (bytes.length < 2) return null;
let offset = 1;
const routeType = bytes[0] & 0x03;
if (routeType === 0x00 || routeType === 0x03) {
if (bytes.length < offset + 4) return null;
offset += 4;
}
return bytes.length > offset ? offset : null;
}
function rewritePackedPathHex(hexData: string): PathRewrite | null {
let bytes: Uint8Array;
try {
bytes = hexToBytes(hexData);
} catch {
return null;
}
const pathOffset = getPackedPathOffset(bytes);
if (pathOffset === null) return null;
const { hopCount, pathHashSize, pathByteLength } = decodePathMetadata(bytes[pathOffset]);
if (pathHashSize === 1) return null;
if (bytes.length < pathOffset + 1 + pathByteLength) return null;
const rewritten = bytes.slice();
rewritten[pathOffset] = pathByteLength;
return {
hexData: bytesToHex(rewritten),
hopCount,
pathHashSize,
};
}
function regroupPath(path: string[] | null, pathHashSize: number): string[] | null {
if (!path || pathHashSize <= 1) return path;
const hops: string[] = [];
for (let i = 0; i + pathHashSize <= path.length; i += pathHashSize) {
hops.push(path.slice(i, i + pathHashSize).join(''));
}
return hops;
}
function normalizeDecodedPacket<
T extends {
isValid?: boolean;
pathLength?: number;
path?: string[] | null;
},
>(packet: T, rewrite: PathRewrite | null): T {
if (!rewrite || packet?.isValid === false) return packet;
packet.pathLength = rewrite.hopCount;
packet.path = regroupPath(packet.path ?? null, rewrite.pathHashSize);
return packet;
}
const decoder = MeshCorePacketDecoder as DecoderClass;
if (!decoder.__multiBytePathPatchApplied) {
const originalDecode = decoder.decode.bind(decoder);
const originalDecodeWithVerification = decoder.decodeWithVerification.bind(decoder);
const originalValidate = decoder.validate.bind(decoder);
decoder.decode = ((hexData: string, options?: DecoderOptions) => {
const rewrite = rewritePackedPathHex(hexData);
const packet = originalDecode(rewrite?.hexData ?? hexData, options);
return normalizeDecodedPacket(packet, rewrite);
}) as typeof decoder.decode;
decoder.decodeWithVerification = (async (hexData: string, options?: DecoderOptions) => {
const rewrite = rewritePackedPathHex(hexData);
const packet = await originalDecodeWithVerification(rewrite?.hexData ?? hexData, options);
return normalizeDecodedPacket(packet, rewrite);
}) as typeof decoder.decodeWithVerification;
decoder.validate = ((hexData: string) => {
const rewrite = rewritePackedPathHex(hexData);
return originalValidate(rewrite?.hexData ?? hexData);
}) as typeof decoder.validate;
decoder.__multiBytePathPatchApplied = true;
}

View File

@@ -2,7 +2,7 @@ import type { Contact, RadioConfig, MessagePath } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
export interface PathHop {
prefix: string; // 2-char hex prefix (e.g., "1A")
prefix: string; // Hex prefix for a single hop
matches: Contact[]; // Matched repeaters (empty=unknown, multiple=ambiguous)
distanceFromPrev: number | null; // km from previous hop
}
@@ -30,20 +30,21 @@ export interface SenderInfo {
}
/**
* Split hex string into 2-char hops
* Split hex string into hop-sized chunks.
*/
export function parsePathHops(path: string | null | undefined): string[] {
export function parsePathHops(path: string | null | undefined, pathLen?: number): string[] {
if (!path || path.length === 0) {
return [];
}
const normalized = path.toUpperCase();
const hopCount = pathLen ?? Math.floor(normalized.length / 2);
const charsPerHop =
hopCount > 0 && normalized.length % hopCount === 0 ? normalized.length / hopCount : 2;
const hops: string[] = [];
for (let i = 0; i < normalized.length; i += 2) {
if (i + 1 < normalized.length) {
hops.push(normalized.slice(i, i + 2));
}
for (let i = 0; i + charsPerHop <= normalized.length; i += charsPerHop) {
hops.push(normalized.slice(i, i + charsPerHop));
}
return hops;
@@ -148,11 +149,11 @@ function sortContactsByDistance(
/**
* Get simple hop count from path string
*/
function getHopCount(path: string | null | undefined): number {
function getHopCount(path: string | null | undefined, pathLen?: number): number {
if (!path || path.length === 0) {
return 0;
}
return Math.floor(path.length / 2);
return pathLen ?? Math.floor(path.length / 2);
}
/**
@@ -170,7 +171,7 @@ export function formatHopCounts(paths: MessagePath[] | null | undefined): {
}
// Get hop counts for all paths and sort ascending
const hopCounts = paths.map((p) => getHopCount(p.path)).sort((a, b) => a - b);
const hopCounts = paths.map((p) => getHopCount(p.path, p.path_len)).sort((a, b) => a - b);
const allDirect = hopCounts.every((h) => h === 0);
const hasMultiple = paths.length > 1;
@@ -189,9 +190,10 @@ export function resolvePath(
path: string | null | undefined,
sender: SenderInfo,
contacts: Contact[],
config: RadioConfig | null
config: RadioConfig | null,
pathLen?: number
): ResolvedPath {
const hopPrefixes = parsePathHops(path);
const hopPrefixes = parsePathHops(path, pathLen);
// Build sender info
const senderPrefix = sender.publicKeyOrPrefix.toUpperCase().slice(0, 2);

View File

@@ -0,0 +1,39 @@
function decodePathMetadata(pathByteHex: string): { pathByteLength: number } {
const pathByte = parseInt(pathByteHex, 16);
const pathHashSize = (pathByte >> 6) + 1;
const pathLength = pathByte & 0x3f;
return {
pathByteLength: pathLength * pathHashSize,
};
}
/**
* Extract the payload from a raw packet hex string, skipping header and path.
* Returns the payload as a hex string, or null if malformed.
*/
export function extractRawPacketPayload(packetHex: string): string | null {
if (packetHex.length < 4) return null;
try {
const header = parseInt(packetHex.slice(0, 2), 16);
const routeType = header & 0x03;
let offset = 2;
if (routeType === 0x00 || routeType === 0x03) {
if (packetHex.length < offset + 8) return null;
offset += 8;
}
if (packetHex.length < offset + 2) return null;
const { pathByteLength } = decodePathMetadata(packetHex.slice(offset, offset + 2));
offset += 2;
const pathChars = pathByteLength * 2;
if (packetHex.length < offset + pathChars) return null;
offset += pathChars;
return packetHex.slice(offset);
} catch {
return null;
}
}

View File

@@ -1,3 +1,4 @@
import './meshcoreDecoderPatch';
import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder';
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket } from '../types';
import { hashString } from './contactAvatar';

View File

@@ -127,6 +127,7 @@ export function deleteContact(publicKey: string): Promise<{ status: string }> {
export interface MessagePath {
path: string;
received_at: number;
path_len?: number;
}
export interface Message {

View File

@@ -260,6 +260,12 @@ class TestPacketFormatConversion:
assert result["route"] == "D"
assert result["path"] == "aa,bb"
def test_adds_path_for_multi_byte_direct_route(self):
data = {"timestamp": 0, "data": "024220273031CC", "snr": 1.0, "rssi": -70}
result = _format_raw_packet(data, "Node", "AA" * 32)
assert result["route"] == "D"
assert result["path"] == "2027,3031"
def test_direct_route_includes_empty_path_field(self):
data = {"timestamp": 0, "data": "0200", "snr": 1.0, "rssi": -70}
result = _format_raw_packet(data, "Node", "AA" * 32)
@@ -359,6 +365,30 @@ class TestCalculatePacketHash:
expected = hashlib.sha256(bytes([2]) + payload).hexdigest()[:16].upper()
assert result == expected
def test_multi_byte_path_uses_packed_path_byte_for_trace_hash(self):
import hashlib
payload = b"\x99\x88"
raw = bytes([0x25, 0x42, 0x20, 0x27, 0x30, 0x31]) + payload
result = _calculate_packet_hash(raw)
expected = (
hashlib.sha256(bytes([9]) + (0x42).to_bytes(2, byteorder="little") + payload)
.hexdigest()[:16]
.upper()
)
assert result == expected
def test_multi_byte_path_skips_full_byte_length(self):
import hashlib
payload = b"\xde\xad\xbe\xef"
raw = bytes([0x09, 0x42, 0x20, 0x27, 0x30, 0x31]) + payload
result = _calculate_packet_hash(raw)
expected = hashlib.sha256(bytes([2]) + payload).hexdigest()[:16].upper()
assert result == expected
def test_truncated_packet_returns_zeroes(self):
# Header says TRANSPORT_FLOOD, but missing path_len at required offset.
raw = bytes([0x10, 0x01, 0x02])

View File

@@ -214,6 +214,22 @@ class TestAdvertPaths:
assert data[0]["path"] == ""
assert data[0]["next_hop"] is None
@pytest.mark.asyncio
async def test_get_contact_advert_paths_with_multi_byte_hops(self, test_db, client):
repeater_key = KEY_A
await _insert_contact(repeater_key, "R1", type=2)
await ContactAdvertPathRepository.record_observation(
repeater_key, "a1b2c3d4", 1000, path_len=2
)
response = await client.get(f"/api/contacts/{repeater_key}/advert-paths")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["path_len"] == 2
assert data[0]["next_hop"] == "a1b2"
@pytest.mark.asyncio
async def test_get_contact_advert_paths_works_for_non_repeater(self, test_db, client):
await _insert_contact(KEY_A, "Alice", type=1)
@@ -326,6 +342,25 @@ class TestContactDetail:
assert repeater["name"] == "Relay1"
assert repeater["heard_count"] == 2
@pytest.mark.asyncio
async def test_detail_nearest_repeaters_resolved_for_multi_byte_hops(self, test_db, client):
await _insert_contact(KEY_A, "Alice", type=1)
repeater_key = "b1c2" + "dd" * 30
await _insert_contact(repeater_key, "RelayWide", type=2)
await ContactAdvertPathRepository.record_observation(KEY_A, "b1c2eeff", 1000, path_len=2)
await ContactAdvertPathRepository.record_observation(KEY_A, "b1c21122", 1010, path_len=2)
response = await client.get(f"/api/contacts/{KEY_A}/detail")
assert response.status_code == 200
data = response.json()
assert len(data["nearest_repeaters"]) == 1
repeater = data["nearest_repeaters"][0]
assert repeater["public_key"] == repeater_key
assert repeater["name"] == "RelayWide"
assert repeater["heard_count"] == 2
@pytest.mark.asyncio
async def test_detail_advert_frequency_computed(self, test_db, client):
"""Advert frequency is computed from path observations over time span."""

View File

@@ -19,6 +19,7 @@ from app.decoder import (
decrypt_group_text,
derive_public_key,
derive_shared_secret,
extract_payload,
parse_packet,
try_decrypt_dm,
try_decrypt_packet_with_channel_key,
@@ -81,6 +82,44 @@ class TestPacketParsing:
assert result.route_type == RouteType.DIRECT
assert result.payload_type == PayloadType.TEXT_MESSAGE
assert result.path_length == 3
assert result.path_hash_size == 1
assert result.path_byte_length == 3
assert result.payload == b"msg"
def test_parse_packet_with_two_byte_hops(self):
"""Packets with multi-byte hop identifiers decode hop count separately from byte length."""
packet = bytes([0x0A, 0x42, 0x01, 0x02, 0x03, 0x04]) + b"msg"
result = parse_packet(packet)
assert result is not None
assert result.route_type == RouteType.DIRECT
assert result.payload_type == PayloadType.TEXT_MESSAGE
assert result.path_length == 2
assert result.path_hash_size == 2
assert result.path_byte_length == 4
assert result.path == bytes([0x01, 0x02, 0x03, 0x04])
assert result.payload == b"msg"
def test_extract_payload_with_two_byte_hops(self):
"""Payload extraction skips the full path byte length for multi-byte hops."""
packet = bytes([0x15, 0x42, 0xAA, 0xBB, 0xCC, 0xDD]) + b"payload_data"
assert extract_payload(packet) == b"payload_data"
def test_parse_packet_with_three_byte_hops(self):
"""Packets support three-byte hop identifiers as well as one/two-byte hops."""
packet = bytes([0x0A, 0x82, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]) + b"msg"
result = parse_packet(packet)
assert result is not None
assert result.route_type == RouteType.DIRECT
assert result.payload_type == PayloadType.TEXT_MESSAGE
assert result.path_length == 2
assert result.path_hash_size == 3
assert result.path_byte_length == 6
assert result.path == bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06])
assert result.payload == b"msg"
def test_parse_transport_flood_skips_transport_code(self):

View File

@@ -682,7 +682,7 @@ class TestDirectMessageDirectionDetection:
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(message_broadcasts) == 1
assert message_broadcasts[0]["data"]["paths"] == [
{"path": "", "received_at": SENDER_TIMESTAMP}
{"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0}
]
@pytest.mark.asyncio

View File

@@ -652,6 +652,21 @@ class TestAppriseFormatBody:
assert "`20`" in body
assert "`27`" in body
def test_dm_with_multi_byte_path(self):
from app.fanout.apprise_mod import _format_body
body = _format_body(
{
"type": "PRIV",
"text": "hi",
"sender_name": "Alice",
"paths": [{"path": "20273031", "path_len": 2}],
},
include_path=True,
)
assert "`2027`" in body
assert "`3031`" in body
def test_dm_no_path_shows_direct(self):
from app.fanout.apprise_mod import _format_body

View File

@@ -3,7 +3,13 @@
import aiosqlite
import pytest
from app.migrations import get_version, run_migrations, set_version
from app.migrations import (
_extract_path_from_packet,
_extract_payload_for_hash,
get_version,
run_migrations,
set_version,
)
class TestMigrationSystem:
@@ -30,6 +36,14 @@ class TestMigrationSystem:
finally:
await conn.close()
def test_extract_payload_for_hash_handles_multi_byte_hops(self):
raw = bytes([0x15, 0x42, 0x20, 0x27, 0x30, 0x31]) + b"\xde\xad\xbe\xef"
assert _extract_payload_for_hash(raw) == b"\xde\xad\xbe\xef"
def test_extract_path_from_packet_handles_multi_byte_hops(self):
raw = bytes([0x15, 0x42, 0x20, 0x27, 0x30, 0x31]) + b"\xde\xad\xbe\xef"
assert _extract_path_from_packet(raw) == "20273031"
class TestMigration001:
"""Test migration 001: add last_read_at columns."""
@@ -100,8 +114,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 38 # All migrations run
assert await get_version(conn) == 38
assert applied == 39 # All migrations run
assert await get_version(conn) == 39
# Verify columns exist by inserting and selecting
await conn.execute(
@@ -183,9 +197,9 @@ class TestMigration001:
applied1 = await run_migrations(conn)
applied2 = await run_migrations(conn)
assert applied1 == 38 # All migrations run
assert applied1 == 39 # All migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 38
assert await get_version(conn) == 39
finally:
await conn.close()
@@ -246,8 +260,8 @@ class TestMigration001:
applied = await run_migrations(conn)
# All migrations applied (version incremented) but no error
assert applied == 38
assert await get_version(conn) == 38
assert applied == 39
assert await get_version(conn) == 39
finally:
await conn.close()
@@ -374,10 +388,10 @@ class TestMigration013:
)
await conn.commit()
# Run migration 13 (plus 14-38 which also run)
# Run migration 13 (plus 14-39 which also run)
applied = await run_migrations(conn)
assert applied == 26
assert await get_version(conn) == 38
assert applied == 27
assert await get_version(conn) == 39
# Bots were migrated from app_settings to fanout_configs (migration 37)
# and the bots column was dropped (migration 38)
@@ -495,7 +509,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 38
assert await get_version(conn) == 39
# Verify autoindex is gone
cursor = await conn.execute(
@@ -573,8 +587,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 21 # Migrations 18-38 run (18+19 skip internally)
assert await get_version(conn) == 38
assert applied == 22 # Migrations 18-39 run (18+19 skip internally)
assert await get_version(conn) == 39
finally:
await conn.close()
@@ -646,7 +660,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 38
assert await get_version(conn) == 39
# Verify autoindex is gone
cursor = await conn.execute(
@@ -712,8 +726,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
assert applied == 19 # Migrations 20-38
assert await get_version(conn) == 38
assert applied == 20 # Migrations 20-39
assert await get_version(conn) == 39
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -743,7 +757,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
assert applied == 18 # Migrations 21-38 still run
assert applied == 19 # Migrations 21-39 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
@@ -801,8 +815,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 11
assert await get_version(conn) == 38
assert applied == 12
assert await get_version(conn) == 39
# Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -871,8 +885,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 11 # Version still bumped
assert await get_version(conn) == 38
assert applied == 12 # Version still bumped
assert await get_version(conn) == 39
# Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -921,8 +935,8 @@ class TestMigration032:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 7
assert await get_version(conn) == 38
assert applied == 8
assert await get_version(conn) == 39
# Community MQTT columns were added by migration 32 and dropped by migration 38.
# Verify community settings were NOT migrated (no community config existed).
@@ -988,8 +1002,8 @@ class TestMigration034:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 38
assert applied == 6
assert await get_version(conn) == 39
# Verify column exists with correct default
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
@@ -1031,8 +1045,8 @@ class TestMigration033:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 38
assert applied == 7
assert await get_version(conn) == 39
cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
@@ -1088,3 +1102,54 @@ class TestMigration033:
assert row["on_radio"] == 1 # Not overwritten
finally:
await conn.close()
class TestMigration039:
"""Test migration 039: add contacts.out_path_hash_mode."""
@pytest.mark.asyncio
async def test_migration_adds_out_path_hash_mode_and_backfills(self):
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
try:
await set_version(conn, 38)
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,
last_advert INTEGER,
lat REAL,
lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER,
last_read_at INTEGER
)
""")
await conn.execute(
"""
INSERT INTO contacts (
public_key, last_path, last_path_len, on_radio
) VALUES (?, ?, ?, ?)
""",
("aa" * 32, "11223344", 2, 1),
)
await conn.commit()
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 39
cursor = await conn.execute(
"SELECT out_path_hash_mode FROM contacts WHERE public_key = ?",
("aa" * 32,),
)
row = await cursor.fetchone()
assert row["out_path_hash_mode"] == 1
finally:
await conn.close()

View File

@@ -678,6 +678,7 @@ class TestMessageBroadcastStructure:
assert broadcast["paths"] is not None
assert len(broadcast["paths"]) == 1
assert broadcast["paths"][0]["path"] == "" # Empty string = direct/flood
assert broadcast["paths"][0]["path_len"] == 0
class TestRawPacketStorage:
@@ -927,6 +928,7 @@ class TestCreateDMMessageFromDecrypted:
our_public_key=self.FACE12_PUB,
received_at=1700000001,
path="aabbcc", # Path through 3 repeaters
path_len=3,
outgoing=False,
)
@@ -937,6 +939,7 @@ class TestCreateDMMessageFromDecrypted:
assert broadcast["paths"] is not None
assert len(broadcast["paths"]) == 1
assert broadcast["paths"][0]["path"] == "aabbcc"
assert broadcast["paths"][0]["path_len"] == 3
assert broadcast["paths"][0]["received_at"] == 1700000001

View File

@@ -6,6 +6,7 @@ import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshcore import EventType
class TestRadioManagerConnect:
@@ -688,3 +689,38 @@ class TestPostConnectSetupOrdering:
await rm.post_connect_setup()
mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
@pytest.mark.asyncio
async def test_path_hash_mode_cached_during_setup(self):
"""post_connect_setup caches path hash mode from device info."""
from app.models import AppSettings
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.start_auto_message_fetching = AsyncMock()
mock_mc.commands.set_flood_scope = AsyncMock()
mock_mc.commands.send_device_query = AsyncMock(
return_value=MagicMock(type=EventType.DEVICE_INFO, payload={"path_hash_mode": 2})
)
rm._meshcore = mock_mc
with (
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
patch(
"app.repository.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=AppSettings(),
),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
patch("app.radio_sync.start_periodic_advert"),
patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
patch("app.radio_sync.start_message_polling"),
):
await rm.post_connect_setup()
assert rm.path_hash_mode_info == (2, True)

View File

@@ -45,9 +45,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
prev_path_hash_mode = radio_manager._path_hash_mode
prev_path_hash_mode_supported = radio_manager._path_hash_mode_supported
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
radio_manager._path_hash_mode = prev_path_hash_mode
radio_manager._path_hash_mode_supported = prev_path_hash_mode_supported
def _mock_meshcore_with_info():
@@ -70,6 +74,10 @@ def _mock_meshcore_with_info():
mc.commands.set_tx_power = AsyncMock()
mc.commands.set_radio = AsyncMock()
mc.commands.send_appstart = AsyncMock()
mc.commands.send_device_query = AsyncMock(
return_value=_radio_result(payload={"path_hash_mode": 1})
)
mc.commands.send = AsyncMock(return_value=_radio_result())
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
return mc
@@ -78,7 +86,11 @@ class TestGetRadioConfig:
@pytest.mark.asyncio
async def test_maps_self_info_to_response(self):
mc = _mock_meshcore_with_info()
with patch("app.routers.radio.require_connected", return_value=mc):
radio_manager.set_path_hash_mode_info(1, True)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await get_radio_config()
assert response.public_key == "aa" * 32
@@ -87,17 +99,38 @@ class TestGetRadioConfig:
assert response.lon == 20.0
assert response.radio.freq == 910.525
assert response.radio.cr == 5
assert response.path_hash_mode == 1
assert response.path_hash_mode_supported is True
mc.commands.send_device_query.assert_not_awaited()
@pytest.mark.asyncio
async def test_returns_503_when_self_info_missing(self):
mc = MagicMock()
mc.self_info = None
with patch("app.routers.radio.require_connected", return_value=mc):
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
with pytest.raises(HTTPException) as exc:
await get_radio_config()
assert exc.value.status_code == 503
@pytest.mark.asyncio
async def test_marks_path_hash_mode_unsupported_when_device_info_lacks_field(self):
mc = _mock_meshcore_with_info()
radio_manager.set_path_hash_mode_info(0, False)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await get_radio_config()
assert response.path_hash_mode == 0
assert response.path_hash_mode_supported is False
mc.commands.send_device_query.assert_not_awaited()
class TestUpdateRadioConfig:
@pytest.mark.asyncio
@@ -110,12 +143,15 @@ class TestUpdateRadioConfig:
lon=20.0,
tx_power=17,
max_tx_power=22,
path_hash_mode=1,
path_hash_mode_supported=True,
radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5),
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock) as mock_sync_time,
patch(
"app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected
@@ -131,6 +167,54 @@ class TestUpdateRadioConfig:
mock_sync_time.assert_awaited_once()
assert result == expected
@pytest.mark.asyncio
async def test_updates_path_hash_mode_via_raw_command_fallback(self):
mc = _mock_meshcore_with_info()
mc.commands.set_path_hash_mode = None
radio_manager.set_path_hash_mode_info(1, True)
expected = RadioConfigResponse(
public_key="aa" * 32,
name="NodeA",
lat=10.0,
lon=20.0,
tx_power=17,
max_tx_power=22,
path_hash_mode=2,
path_hash_mode_supported=True,
radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5),
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock),
patch(
"app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected
),
):
result = await update_radio_config(RadioConfigUpdate(path_hash_mode=2))
mc.commands.send.assert_awaited_once_with(b"\x3d\x00\x02", [EventType.OK, EventType.ERROR])
assert result == expected
@pytest.mark.asyncio
async def test_rejects_path_hash_mode_update_when_radio_does_not_expose_it(self):
mc = _mock_meshcore_with_info()
mc.commands.send_device_query = AsyncMock(return_value=_radio_result(payload={}))
radio_manager.set_path_hash_mode_info(0, False)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
):
with pytest.raises(HTTPException) as exc:
await update_radio_config(RadioConfigUpdate(path_hash_mode=2))
assert exc.value.status_code == 400
assert "path hash mode" in exc.value.detail.lower()
class TestPrivateKeyImport:
@pytest.mark.asyncio

View File

@@ -36,12 +36,13 @@ class TestMessageRepositoryAddPath:
msg_id = await _create_message(test_db)
result = await MessageRepository.add_path(
message_id=msg_id, path="1A2B", received_at=1700000000
message_id=msg_id, path="1A2B", received_at=1700000000, path_len=1
)
assert len(result) == 1
assert result[0].path == "1A2B"
assert result[0].received_at == 1700000000
assert result[0].path_len == 1
@pytest.mark.asyncio
async def test_add_path_to_message_with_existing_paths(self, test_db):
@@ -273,6 +274,20 @@ class TestContactAdvertPathRepository:
assert paths[0].last_seen == 1010
assert paths[0].heard_count == 2
@pytest.mark.asyncio
async def test_record_observation_preserves_multi_byte_next_hop(self, test_db):
repeater_key = "ab" * 32
await ContactRepository.upsert({"public_key": repeater_key, "name": "R3", "type": 2})
await ContactAdvertPathRepository.record_observation(
repeater_key, "a1b2c3d4", 1000, path_len=2
)
paths = await ContactAdvertPathRepository.get_recent_for_contact(repeater_key, limit=10)
assert len(paths) == 1
assert paths[0].path_len == 2
assert paths[0].next_hop == "a1b2"
@pytest.mark.asyncio
async def test_prunes_to_most_recent_n_unique_paths(self, test_db):
repeater_key = "bb" * 32

View File

@@ -30,9 +30,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
prev_path_hash_mode = radio_manager._path_hash_mode
prev_path_hash_mode_supported = radio_manager._path_hash_mode_supported
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
radio_manager._path_hash_mode = prev_path_hash_mode
radio_manager._path_hash_mode_supported = prev_path_hash_mode_supported
def _make_radio_result(payload=None):
@@ -125,6 +129,70 @@ class TestOutgoingDMBroadcast:
assert exc_info.value.status_code == 409
assert "ambiguous" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_send_dm_add_contact_preserves_out_path_hash_mode(self, test_db):
"""Direct-send contact export includes the inferred path hash mode for multi-byte routes."""
mc = _make_mc()
pub_key = "cd" * 32
await ContactRepository.upsert(
{
"public_key": pub_key,
"name": "Bob",
"type": 0,
"flags": 0,
"last_path": "11223344",
"last_path_len": 2,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
}
)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
await send_direct_message(SendDirectMessageRequest(destination=pub_key, text="hi"))
add_contact_arg = mc.commands.add_contact.await_args.args[0]
assert add_contact_arg["out_path"] == "11223344"
assert add_contact_arg["out_path_len"] == 2
assert add_contact_arg["out_path_hash_mode"] == 1
@pytest.mark.asyncio
async def test_send_dm_uses_persisted_out_path_hash_mode_when_present(self, test_db):
mc = _make_mc()
pub_key = "ef" * 32
await ContactRepository.upsert(
{
"public_key": pub_key,
"name": "Carol",
"type": 0,
"flags": 0,
"last_path": "11223344",
"last_path_len": 2,
"out_path_hash_mode": 0,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
}
)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
await send_direct_message(SendDirectMessageRequest(destination=pub_key, text="hi"))
add_contact_arg = mc.commands.add_contact.await_args.args[0]
assert add_contact_arg["out_path_hash_mode"] == 0
class TestOutgoingChannelBroadcast:
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""