Compare commits

...

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
+1
View File
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS contacts (
flags INTEGER DEFAULT 0, flags INTEGER DEFAULT 0,
last_path TEXT, last_path TEXT,
last_path_len INTEGER DEFAULT -1, last_path_len INTEGER DEFAULT -1,
out_path_hash_mode INTEGER,
last_advert INTEGER, last_advert INTEGER,
lat REAL, lat REAL,
lon REAL, lon REAL,
+27 -13
View File
@@ -79,9 +79,11 @@ class PacketInfo:
route_type: RouteType route_type: RouteType
payload_type: PayloadType payload_type: PayloadType
payload_version: int payload_version: int
path_length: int path_length: int # Hop count encoded in the lower 6 bits of the path byte
path: bytes # The routing path (empty if path_length is 0) path: bytes # The routing path bytes (empty if path_length is 0)
payload: bytes 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: 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") 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: def extract_payload(raw_packet: bytes) -> bytes | None:
""" """
Extract just the payload from a raw packet, skipping header and path. 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: Packet structure:
- Byte 0: header (route_type, payload_type, version) - Byte 0: header (route_type, payload_type, version)
- For TRANSPORT routes: bytes 1-4 are transport codes - For TRANSPORT routes: bytes 1-4 are transport codes
- Next byte: path_length - Next byte: packed path metadata
- Next path_length bytes: path data - upper 2 bits: bytes per hop minus 1
- lower 6 bits: hop count
- Next hop_count * path_hash_size bytes: path data
- Remaining: payload - Remaining: payload
Returns the payload bytes, or None if packet is malformed. Returns the payload bytes, or None if packet is malformed.
@@ -120,16 +132,16 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
return None return None
offset += 4 offset += 4
# Get path length # Decode packed path metadata
if len(raw_packet) < offset + 1: if len(raw_packet) < offset + 1:
return None return None
path_length = raw_packet[offset] path_length, _path_hash_size, path_byte_length = decode_path_metadata(raw_packet[offset])
offset += 1 offset += 1
# Skip path data # Skip path data
if len(raw_packet) < offset + path_length: if len(raw_packet) < offset + path_byte_length:
return None return None
offset += path_length offset += path_byte_length
# Rest is payload # Rest is payload
return raw_packet[offset:] return raw_packet[offset:]
@@ -156,17 +168,17 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None:
return None return None
offset += 4 offset += 4
# Get path length # Decode packed path metadata
if len(raw_packet) < offset + 1: if len(raw_packet) < offset + 1:
return None return None
path_length = raw_packet[offset] path_length, path_hash_size, path_byte_length = decode_path_metadata(raw_packet[offset])
offset += 1 offset += 1
# Extract path data # Extract path data
if len(raw_packet) < offset + path_length: if len(raw_packet) < offset + path_byte_length:
return None return None
path = raw_packet[offset : offset + path_length] path = raw_packet[offset : offset + path_byte_length]
offset += path_length offset += path_byte_length
# Rest is payload # Rest is payload
payload = raw_packet[offset:] payload = raw_packet[offset:]
@@ -178,6 +190,8 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None:
path_length=path_length, path_length=path_length,
path=path, path=path,
payload=payload, payload=payload,
path_hash_size=path_hash_size,
path_byte_length=path_byte_length,
) )
except (ValueError, IndexError): except (ValueError, IndexError):
return None return None
+14 -3
View File
@@ -106,13 +106,21 @@ async def on_contact_message(event: "Event") -> None:
ts = payload.get("sender_timestamp") ts = payload.get("sender_timestamp")
sender_timestamp = ts if ts is not None else received_at sender_timestamp = ts if ts is not None else received_at
sender_name = contact.name if contact else None 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_id = await MessageRepository.create(
msg_type="PRIV", msg_type="PRIV",
text=payload.get("text", ""), text=payload.get("text", ""),
conversation_key=sender_pubkey, conversation_key=sender_pubkey,
sender_timestamp=sender_timestamp, sender_timestamp=sender_timestamp,
received_at=received_at, received_at=received_at,
path=payload.get("path"), path=path,
path_len=normalized_path_len,
txt_type=txt_type, txt_type=txt_type,
signature=payload.get("signature"), signature=payload.get("signature"),
sender_key=sender_pubkey, 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]) logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
# Build paths array for broadcast # Build paths array for broadcast
path = payload.get("path") paths = (
paths = [MessagePath(path=path or "", received_at=received_at)] if path is not None else None [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 the new message
broadcast_event( broadcast_event(
+14 -3
View File
@@ -44,8 +44,13 @@ def _format_body(data: dict, *, include_path: bool) -> str:
via = "" via = ""
if include_path: if include_path:
paths = data.get("paths") paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0: first_path = (
path_str = paths[0].get("path", "") if isinstance(paths[0], dict) else "" 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: else:
path_str = None path_str = None
@@ -56,7 +61,13 @@ def _format_body(data: dict, *, include_path: bool) -> str:
if path_str == "": if path_str == "":
via = " **via:** [`direct`]" via = " **via:** [`direct`]"
else: 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: if hops:
hop_list = ", ".join(f"`{h}`" for h in hops) hop_list = ", ".join(f"`{h}`" for h in hops)
via = f" **via:** [{hop_list}]" via = f" **via:** [{hop_list}]"
+17 -11
View File
@@ -23,6 +23,7 @@ from typing import Any, Protocol
import aiomqtt import aiomqtt
import nacl.bindings import nacl.bindings
from app.decoder import decode_path_metadata
from app.fanout.mqtt_base import BaseMqttPublisher from app.fanout.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -146,23 +147,24 @@ def _calculate_packet_hash(raw_bytes: bytes) -> str:
if has_transport: if has_transport:
offset += 4 # Skip 4 bytes of transport codes 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): if offset >= len(raw_bytes):
return "0" * 16 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 offset += 1
# Skip past path to get to payload. Invalid/truncated packets map to zero hash. # 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 return "0" * 16
payload_start = offset + path_len payload_start = offset + path_byte_length
payload_data = raw_bytes[payload_start:] 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 = hashlib.sha256()
hash_obj.update(bytes([payload_type])) hash_obj.update(bytes([payload_type]))
if payload_type == 9: # PAYLOAD_TYPE_TRACE 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) hash_obj.update(payload_data)
return hash_obj.hexdigest()[:16].upper() 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: if len(raw_bytes) <= offset:
return route, packet_type, payload_len, path_values, payload_type 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 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 return route, packet_type, payload_len, path_values, payload_type
path_bytes = raw_bytes[offset : offset + path_len] path_bytes = raw_bytes[offset : offset + path_byte_length]
offset += path_len offset += path_byte_length
payload_type = (header >> 2) & 0x0F payload_type = (header >> 2) & 0x0F
route = _ROUTE_MAP.get(route_type, "U") route = _ROUTE_MAP.get(route_type, "U")
packet_type = str(payload_type) packet_type = str(payload_type)
payload_len = str(max(0, len(raw_bytes) - offset)) 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 return route, packet_type, payload_len, path_values, payload_type
except Exception: except Exception:
+55 -56
View File
@@ -13,6 +13,9 @@ from hashlib import sha256
import aiosqlite import aiosqlite
from app.decoder import extract_payload, parse_packet
from app.models import Contact
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -303,6 +306,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 38) await set_version(conn, 38)
applied += 1 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: if applied > 0:
logger.info( logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn) "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. Returns the payload bytes, or None if packet is malformed.
""" """
if len(raw_packet) < 2: return extract_payload(raw_packet)
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
async def _migrate_005_backfill_payload_hashes(conn: aiosqlite.Connection) -> None: 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. Returns the path as a hex string, or None if packet is malformed.
""" """
if len(raw_packet) < 2: packet_info = parse_packet(raw_packet)
return None if packet_info is 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):
return None return None
return packet_info.path.hex()
async def _migrate_007_backfill_message_paths(conn: aiosqlite.Connection) -> None: 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 raise
await conn.commit() 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()
+30 -2
View File
@@ -10,6 +10,7 @@ class Contact(BaseModel):
flags: int = 0 flags: int = 0
last_path: str | None = None last_path: str | None = None
last_path_len: int = -1 last_path_len: int = -1
out_path_hash_mode: int | None = None
last_advert: int | None = None last_advert: int | None = None
lat: float | None = None lat: float | None = None
lon: 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 last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None 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: def to_radio_dict(self) -> dict:
"""Convert to the dict format expected by meshcore radio commands. """Convert to the dict format expected by meshcore radio commands.
The radio API uses different field names (adv_name, out_path, etc.) The radio API uses different field names (adv_name, out_path, etc.)
than our database schema (name, last_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 { return {
"public_key": self.public_key, "public_key": self.public_key,
"adv_name": self.name or "", "adv_name": self.name or "",
@@ -32,6 +57,7 @@ class Contact(BaseModel):
"flags": self.flags, "flags": self.flags,
"out_path": self.last_path or "", "out_path": self.last_path or "",
"out_path_len": self.last_path_len, "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_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, "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, "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), "flags": radio_data.get("flags", 0),
"last_path": radio_data.get("out_path"), "last_path": radio_data.get("out_path"),
"last_path_len": radio_data.get("out_path_len", -1), "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"), "lat": radio_data.get("adv_lat"),
"lon": radio_data.get("adv_lon"), "lon": radio_data.get("adv_lon"),
"last_advert": radio_data.get("last_advert"), "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: str = Field(description="Hex-encoded routing path (empty string for direct)")
path_len: int = Field(description="Number of hops in the path") path_len: int = Field(description="Number of hops in the path")
next_hop: str | None = Field( 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") first_seen: int = Field(description="Unix timestamp of first observation")
last_seen: int = Field(description="Unix timestamp of most recent observation") last_seen: int = Field(description="Unix timestamp of most recent observation")
@@ -176,8 +203,9 @@ class ChannelDetail(BaseModel):
class MessagePath(BaseModel): class MessagePath(BaseModel):
"""A single path that a message took to reach us.""" """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") 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): class Message(BaseModel):
+35 -4
View File
@@ -57,6 +57,7 @@ async def _handle_duplicate_message(
text: str, text: str,
sender_timestamp: int, sender_timestamp: int,
path: str | None, path: str | None,
path_len: int | None,
received: int, received: int,
) -> None: ) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record. """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 # Add path if provided
if path is not None: 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: else:
# Get current paths for broadcast # Get current paths for broadcast
paths = existing_msg.paths or [] paths = existing_msg.paths or []
@@ -128,6 +129,7 @@ async def create_message_from_decrypted(
timestamp: int, timestamp: int,
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None,
channel_name: str | None = None, channel_name: str | None = None,
realtime: bool = True, realtime: bool = True,
) -> int | None: ) -> int | None:
@@ -150,6 +152,9 @@ async def create_message_from_decrypted(
Returns the message ID if created, None if duplicate. Returns the message ID if created, None if duplicate.
""" """
received = received_at or int(time.time()) 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 # Format the message text with sender prefix if present
text = f"{sender}: {message_text}" if sender else message_text text = f"{sender}: {message_text}" if sender else message_text
@@ -172,6 +177,7 @@ async def create_message_from_decrypted(
sender_timestamp=timestamp, sender_timestamp=timestamp,
received_at=received, received_at=received,
path=path, path=path,
path_len=normalized_path_len,
sender_name=sender, sender_name=sender,
sender_key=resolved_sender_key, 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 # 2. Same message arrives via multiple paths before first is committed
# In either case, add the path to the existing message. # In either case, add the path to the existing message.
await _handle_duplicate_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 return None
@@ -193,7 +206,11 @@ async def create_message_from_decrypted(
# Build paths array for broadcast # Build paths array for broadcast
# Use "is not None" to include empty string (direct/0-hop messages) # 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 new message to connected clients (and fanout modules when realtime)
broadcast_event( broadcast_event(
@@ -223,6 +240,7 @@ async def create_dm_message_from_decrypted(
our_public_key: str | None, our_public_key: str | None,
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None,
outgoing: bool = False, outgoing: bool = False,
realtime: bool = True, realtime: bool = True,
) -> int | None: ) -> int | None:
@@ -255,6 +273,9 @@ async def create_dm_message_from_decrypted(
return None return None
received = received_at or int(time.time()) 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 is always the other party's public key
conversation_key = their_public_key.lower() conversation_key = their_public_key.lower()
@@ -270,6 +291,7 @@ async def create_dm_message_from_decrypted(
sender_timestamp=decrypted.timestamp, sender_timestamp=decrypted.timestamp,
received_at=received, received_at=received,
path=path, path=path,
path_len=normalized_path_len,
outgoing=outgoing, outgoing=outgoing,
sender_key=conversation_key if not outgoing else None, sender_key=conversation_key if not outgoing else None,
sender_name=sender_name, sender_name=sender_name,
@@ -284,6 +306,7 @@ async def create_dm_message_from_decrypted(
decrypted.message, decrypted.message,
decrypted.timestamp, decrypted.timestamp,
path, path,
normalized_path_len,
received, received,
) )
return None return None
@@ -299,7 +322,11 @@ async def create_dm_message_from_decrypted(
await RawPacketRepository.mark_decrypted(packet_id, msg_id) await RawPacketRepository.mark_decrypted(packet_id, msg_id)
# Build paths array for broadcast # 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) # Broadcast new message to connected clients (and fanout modules when realtime)
sender_name = contact.name if contact and not outgoing else None 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(), our_public_key=our_public_key_bytes.hex(),
received_at=packet_timestamp, received_at=packet_timestamp,
path=path_hex, path=path_hex,
path_len=packet_info.path_length if packet_info else None,
outgoing=outgoing, outgoing=outgoing,
realtime=False, # Historical decryption should not trigger fanout realtime=False, # Historical decryption should not trigger fanout
) )
@@ -606,6 +634,7 @@ async def _process_group_text(
timestamp=decrypted.timestamp, timestamp=decrypted.timestamp,
received_at=timestamp, received_at=timestamp,
path=packet_info.path.hex() if packet_info else None, path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None,
) )
return { return {
@@ -700,6 +729,7 @@ async def _process_advertisement(
path_hex=new_path_hex, path_hex=new_path_hex,
timestamp=timestamp, timestamp=timestamp,
max_paths=10, max_paths=10,
path_len=new_path_len,
) )
# Record name history # Record name history
@@ -872,6 +902,7 @@ async def _process_direct_message(
our_public_key=our_public_key.hex(), our_public_key=our_public_key.hex(),
received_at=timestamp, received_at=timestamp,
path=packet_info.path.hex() if packet_info else None, path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None,
outgoing=is_outgoing, outgoing=is_outgoing,
) )
+32
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
+51 -1
View File
@@ -1,11 +1,12 @@
import asyncio import asyncio
import glob import glob
import inspect
import logging import logging
import platform import platform
from contextlib import asynccontextmanager, nullcontext from contextlib import asynccontextmanager, nullcontext
from pathlib import Path from pathlib import Path
from meshcore import MeshCore from meshcore import EventType, MeshCore
from app.config import settings from app.config import settings
@@ -128,6 +129,8 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False self._setup_in_progress: bool = False
self._setup_complete: 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( async def _acquire_operation_lock(
self, self,
@@ -257,6 +260,7 @@ class RadioManager:
# Sync radio clock with system time # Sync radio clock with system time
await sync_radio_time(mc) await sync_radio_time(mc)
await self.refresh_path_hash_mode_info(mc)
# Apply flood scope from settings (best-effort; older firmware # Apply flood scope from settings (best-effort; older firmware
# may not support set_flood_scope) # may not support set_flood_scope)
@@ -331,6 +335,48 @@ class RadioManager:
def is_setup_complete(self) -> bool: def is_setup_complete(self) -> bool:
return self._setup_complete 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: async def connect(self) -> None:
"""Connect to the radio using the configured transport.""" """Connect to the radio using the configured transport."""
if self._meshcore is not None: if self._meshcore is not None:
@@ -369,6 +415,7 @@ class RadioManager:
self._connection_info = f"Serial: {port}" self._connection_info = f"Serial: {port}"
self._last_connected = True self._last_connected = True
self._setup_complete = False self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("Serial connection established") logger.debug("Serial connection established")
async def _connect_tcp(self) -> None: async def _connect_tcp(self) -> None:
@@ -386,6 +433,7 @@ class RadioManager:
self._connection_info = f"TCP: {host}:{port}" self._connection_info = f"TCP: {host}:{port}"
self._last_connected = True self._last_connected = True
self._setup_complete = False self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("TCP connection established") logger.debug("TCP connection established")
async def _connect_ble(self) -> None: async def _connect_ble(self) -> None:
@@ -403,6 +451,7 @@ class RadioManager:
self._connection_info = f"BLE: {address}" self._connection_info = f"BLE: {address}"
self._last_connected = True self._last_connected = True
self._setup_complete = False self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("BLE connection established") logger.debug("BLE connection established")
async def disconnect(self) -> None: async def disconnect(self) -> None:
@@ -412,6 +461,7 @@ class RadioManager:
await self._meshcore.disconnect() await self._meshcore.disconnect()
self._meshcore = None self._meshcore = None
self._setup_complete = False self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("Radio disconnected") logger.debug("Radio disconnected")
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool: async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
+29 -9
View File
@@ -8,6 +8,7 @@ from app.models import (
ContactAdvertPathSummary, ContactAdvertPathSummary,
ContactNameHistory, ContactNameHistory,
) )
from app.path_utils import first_path_hop
class AmbiguousPublicKeyPrefixError(ValueError): class AmbiguousPublicKeyPrefixError(ValueError):
@@ -25,15 +26,19 @@ class ContactRepository:
await db.conn.execute( await db.conn.execute(
""" """
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len, 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, last_advert, lat, lon, last_seen, on_radio, last_contacted,
first_seen) first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(public_key) DO UPDATE SET ON CONFLICT(public_key) DO UPDATE SET
name = COALESCE(excluded.name, contacts.name), name = COALESCE(excluded.name, contacts.name),
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END, type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
flags = excluded.flags, flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path), last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len, 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), last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
lat = COALESCE(excluded.lat, contacts.lat), lat = COALESCE(excluded.lat, contacts.lat),
lon = COALESCE(excluded.lon, contacts.lon), lon = COALESCE(excluded.lon, contacts.lon),
@@ -49,6 +54,7 @@ class ContactRepository:
contact.get("flags", 0), contact.get("flags", 0),
contact.get("last_path"), contact.get("last_path"),
contact.get("last_path_len", -1), contact.get("last_path_len", -1),
contact.get("out_path_hash_mode"),
contact.get("last_advert"), contact.get("last_advert"),
contact.get("lat"), contact.get("lat"),
contact.get("lon"), contact.get("lon"),
@@ -70,6 +76,7 @@ class ContactRepository:
flags=row["flags"], flags=row["flags"],
last_path=row["last_path"], last_path=row["last_path"],
last_path_len=row["last_path_len"], last_path_len=row["last_path_len"],
out_path_hash_mode=row["out_path_hash_mode"],
last_advert=row["last_advert"], last_advert=row["last_advert"],
lat=row["lat"], lat=row["lat"],
lon=row["lon"], lon=row["lon"],
@@ -200,11 +207,23 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows] return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod @staticmethod
async def update_path(public_key: str, path: str, path_len: int) -> None: async def update_path(
await db.conn.execute( public_key: str, path: str, path_len: int, out_path_hash_mode: int | None = None
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?", ) -> None:
(path, path_len, int(time.time()), public_key.lower()), 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() await db.conn.commit()
@staticmethod @staticmethod
@@ -287,7 +306,7 @@ class ContactAdvertPathRepository:
@staticmethod @staticmethod
def _row_to_path(row) -> ContactAdvertPath: def _row_to_path(row) -> ContactAdvertPath:
path = row["path_hex"] or "" 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( return ContactAdvertPath(
path=path, path=path,
path_len=row["path_len"], path_len=row["path_len"],
@@ -303,6 +322,7 @@ class ContactAdvertPathRepository:
path_hex: str, path_hex: str,
timestamp: int, timestamp: int,
max_paths: int = 10, max_paths: int = 10,
path_len: int | None = None,
) -> None: ) -> None:
""" """
Upsert a unique advert path observation for a contact and prune to N most recent. 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_key = public_key.lower()
normalized_path = path_hex.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( await db.conn.execute(
""" """
@@ -324,7 +344,7 @@ class ContactAdvertPathRepository:
path_len = excluded.path_len, path_len = excluded.path_len,
heard_count = contact_advert_paths.heard_count + 1 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. # Keep only the N most recent unique paths per contact.
+10 -3
View File
@@ -26,6 +26,7 @@ class MessageRepository:
conversation_key: str, conversation_key: str,
sender_timestamp: int | None = None, sender_timestamp: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None,
txt_type: int = 0, txt_type: int = 0,
signature: str | None = None, signature: str | None = None,
outgoing: bool = False, outgoing: bool = False,
@@ -43,7 +44,10 @@ class MessageRepository:
# Convert single path to paths array format # Convert single path to paths array format
paths_json = None paths_json = None
if path is not 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( cursor = await db.conn.execute(
""" """
@@ -74,7 +78,7 @@ class MessageRepository:
@staticmethod @staticmethod
async def add_path( 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]: ) -> list[MessagePath]:
"""Add a new path to an existing message. """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 # Atomic append: use json_insert to avoid read-modify-write race when
# multiple duplicate packets arrive concurrently for the same message. # 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( await db.conn.execute(
"""UPDATE messages SET paths = json_insert( """UPDATE messages SET paths = json_insert(
COALESCE(paths, '[]'), '$[#]', json(?) COALESCE(paths, '[]'), '$[#]', json(?)
+6 -5
View File
@@ -16,6 +16,7 @@ from app.models import (
TraceResponse, TraceResponse,
) )
from app.packet_processor import start_historical_dm_decryption 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.radio import radio_manager
from app.repository import ( from app.repository import (
AmbiguousPublicKeyPrefixError, AmbiguousPublicKeyPrefixError,
@@ -201,11 +202,11 @@ async def get_contact_detail(public_key: str) -> ContactDetail:
if span_hours > 0: if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2) advert_frequency = round(total_observations / span_hours, 2)
# Compute nearest repeaters from first-hop prefixes in advert paths # Compute nearest repeaters from first hops in advert paths
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen} first_hop_stats: dict[str, dict] = {} # first hop -> {heard_count, path_len, last_seen}
for p in advert_paths: for p in advert_paths:
if p.path and len(p.path) >= 2: prefix = first_path_hop(p.path, p.path_len)
prefix = p.path[:2].lower() if prefix:
if prefix not in first_hop_stats: if prefix not in first_hop_stats:
first_hop_stats[prefix] = { first_hop_stats[prefix] = {
"heard_count": 0, "heard_count": 0,
@@ -462,7 +463,7 @@ async def reset_contact_path(public_key: str) -> dict:
"""Reset a contact's routing path to flood.""" """Reset a contact's routing path to flood."""
contact = await _resolve_contact_or_404(public_key) 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]) 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 # Push the updated path to radio if connected and contact is on radio
+1
View File
@@ -71,6 +71,7 @@ async def _run_historical_channel_decryption(
timestamp=result.timestamp, timestamp=result.timestamp,
received_at=packet_timestamp, received_at=packet_timestamp,
path=path_hex, path=path_hex,
path_len=packet_info.path_length if packet_info else None,
realtime=False, # Historical decryption should not trigger fanout realtime=False, # Historical decryption should not trigger fanout
) )
+60 -1
View File
@@ -1,4 +1,6 @@
import logging import logging
from collections.abc import Awaitable, Callable
from typing import Any, cast
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from meshcore import EventType from meshcore import EventType
@@ -27,6 +29,12 @@ class RadioConfigResponse(BaseModel):
lon: float lon: float
tx_power: int = Field(description="Transmit power in dBm") tx_power: int = Field(description="Transmit power in dBm")
max_tx_power: int = Field(description="Maximum 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 radio: RadioSettings
@@ -35,6 +43,9 @@ class RadioConfigUpdate(BaseModel):
lat: float | None = None lat: float | None = None
lon: float | None = None lon: float | None = None
tx_power: int | None = Field(default=None, description="Transmit power in dBm") 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 radio: RadioSettings | None = None
@@ -42,15 +53,47 @@ class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string") 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) @router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse: async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration.""" """Get the current radio configuration."""
mc = require_connected() mc = require_connected()
info = mc.self_info info = mc.self_info
if not info: if not info:
raise HTTPException(status_code=503, detail="Radio info not available") 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( return RadioConfigResponse(
public_key=info.get("public_key", ""), public_key=info.get("public_key", ""),
name=info.get("name", ""), name=info.get("name", ""),
@@ -58,6 +101,8 @@ async def get_radio_config() -> RadioConfigResponse:
lon=info.get("adv_lon", 0.0), lon=info.get("adv_lon", 0.0),
tx_power=info.get("tx_power", 0), tx_power=info.get("tx_power", 0),
max_tx_power=info.get("max_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( radio=RadioSettings(
freq=info.get("radio_freq", 0.0), freq=info.get("radio_freq", 0.0),
bw=info.get("radio_bw", 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) logger.info("Setting TX power to %d dBm", update.tx_power)
await mc.commands.set_tx_power(val=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: if update.radio is not None:
logger.info( logger.info(
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d", "Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
+7 -2
View File
@@ -1,7 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../api'; import { api } from '../api';
import { formatTime } from '../utils/messageParser'; 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 { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites'; import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
@@ -413,7 +418,7 @@ export function ContactInfoPane({
className="flex justify-between items-center text-sm" className="flex justify-between items-center text-sm"
> >
<span className="font-mono text-xs truncate"> <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>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2"> <span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)} {p.heard_count}x · {formatTime(p.last_seen)}
+3 -36
View File
@@ -1,46 +1,13 @@
import '../utils/meshcoreDecoderPatch';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { GroupTextCracker, type ProgressReport } from 'meshcore-hashtag-cracker'; import { GroupTextCracker, type ProgressReport } from 'meshcore-hashtag-cracker';
import NoSleep from 'nosleep.js'; import NoSleep from 'nosleep.js';
import type { RawPacket, Channel } from '../types'; import type { RawPacket, Channel } from '../types';
import { api } from '../api'; import { api } from '../api';
import { extractRawPacketPayload } from '../utils/rawPacketPayload';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { cn } from '@/lib/utils'; 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 { interface CrackedRoom {
roomName: string; roomName: string;
key: string; key: string;
@@ -177,7 +144,7 @@ export function CrackerPanel({
for (const packet of undecryptedGroupText) { for (const packet of undecryptedGroupText) {
if (!newQueue.has(packet.id)) { if (!newQueue.has(packet.id)) {
// Extract payload and check for duplicates // Extract payload and check for duplicates
const payload = extractPayload(packet.data); const payload = extractRawPacketPayload(packet.data);
if (payload && seenPayloadsRef.current.has(payload)) { if (payload && seenPayloadsRef.current.has(payload)) {
// Skip - we already have a packet with this payload queued // Skip - we already have a packet with this payload queued
newSkipped++; newSkipped++;
+2 -2
View File
@@ -52,7 +52,7 @@ export function PathModal({
const resolvedPaths = hasPaths const resolvedPaths = hasPaths
? paths.map((p) => ({ ? paths.map((p) => ({
...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 */} {/* Raw path summary */}
<div className="text-sm"> <div className="text-sm">
{paths.map((p, index) => { {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'; const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
return ( return (
<div key={index}> <div key={index}>
@@ -1,3 +1,4 @@
import '../utils/meshcoreDecoderPatch';
import { useEffect, useRef, useMemo } from 'react'; import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder'; import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types'; import type { RawPacket } from '../types';
@@ -43,6 +43,7 @@ export function SettingsRadioSection({
const [lat, setLat] = useState(''); const [lat, setLat] = useState('');
const [lon, setLon] = useState(''); const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState(''); const [txPower, setTxPower] = useState('');
const [pathHashMode, setPathHashMode] = useState('0');
const [freq, setFreq] = useState(''); const [freq, setFreq] = useState('');
const [bw, setBw] = useState(''); const [bw, setBw] = useState('');
const [sf, setSf] = useState(''); const [sf, setSf] = useState('');
@@ -73,6 +74,7 @@ export function SettingsRadioSection({
setLat(String(config.lat)); setLat(String(config.lat));
setLon(String(config.lon)); setLon(String(config.lon));
setTxPower(String(config.tx_power)); setTxPower(String(config.tx_power));
setPathHashMode(String(config.path_hash_mode));
setFreq(String(config.radio.freq)); setFreq(String(config.radio.freq));
setBw(String(config.radio.bw)); setBw(String(config.radio.bw));
setSf(String(config.radio.sf)); setSf(String(config.radio.sf));
@@ -145,6 +147,7 @@ export function SettingsRadioSection({
const parsedLat = parseFloat(lat); const parsedLat = parseFloat(lat);
const parsedLon = parseFloat(lon); const parsedLon = parseFloat(lon);
const parsedTxPower = parseInt(txPower, 10); const parsedTxPower = parseInt(txPower, 10);
const parsedPathHashMode = parseInt(pathHashMode, 10);
const parsedFreq = parseFloat(freq); const parsedFreq = parseFloat(freq);
const parsedBw = parseFloat(bw); const parsedBw = parseFloat(bw);
const parsedSf = parseInt(sf, 10); const parsedSf = parseInt(sf, 10);
@@ -159,11 +162,20 @@ export function SettingsRadioSection({
return null; 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 { return {
name, name,
lat: parsedLat, lat: parsedLat,
lon: parsedLon, lon: parsedLon,
tx_power: parsedTxPower, tx_power: parsedTxPower,
...(config.path_hash_mode_supported && { path_hash_mode: parsedPathHashMode }),
radio: { radio: {
freq: parsedFreq, freq: parsedFreq,
bw: parsedBw, bw: parsedBw,
@@ -384,6 +396,26 @@ export function SettingsRadioSection({
</div> </div>
</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 /> <Separator />
<div className="space-y-2"> <div className="space-y-2">
+1
View File
@@ -1,3 +1,4 @@
import './utils/meshcoreDecoderPatch';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App } from './App'; import { App } from './App';
+2
View File
@@ -173,6 +173,8 @@ const baseConfig = {
lon: 0, lon: 0,
tx_power: 17, tx_power: 17,
max_tx_power: 22, max_tx_power: 22,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
}; };
+2
View File
@@ -201,6 +201,8 @@ describe('App search jump target handling', () => {
lon: 0, lon: 0,
tx_power: 17, tx_power: 17,
max_tx_power: 22, max_tx_power: 22,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
}); });
mocks.api.getSettings.mockResolvedValue({ mocks.api.getSettings.mockResolvedValue({
@@ -157,6 +157,8 @@ describe('App startup hash resolution', () => {
lon: 0, lon: 0,
tx_power: 17, tx_power: 17,
max_tx_power: 22, max_tx_power: 22,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
}); });
mocks.api.getSettings.mockResolvedValue({ mocks.api.getSettings.mockResolvedValue({
@@ -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();
});
});
@@ -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);
});
});
+40
View File
@@ -40,6 +40,8 @@ function createConfig(overrides: Partial<RadioConfig> = {}): RadioConfig {
lon: -74.006, lon: -74.006,
tx_power: 10, tx_power: 10,
max_tx_power: 20, max_tx_power: 20,
path_hash_mode: 0,
path_hash_mode_supported: true,
radio: { freq: 915, bw: 250, sf: 10, cr: 8 }, radio: { freq: 915, bw: 250, sf: 10, cr: 8 },
...overrides, ...overrides,
}; };
@@ -60,6 +62,14 @@ describe('parsePathHops', () => {
expect(parsePathHops('1A2B3C')).toEqual(['1A', '2B', '3C']); 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', () => { it('converts to uppercase', () => {
expect(parsePathHops('1a2b')).toEqual(['1A', '2B']); expect(parsePathHops('1a2b')).toEqual(['1A', '2B']);
}); });
@@ -197,6 +207,29 @@ describe('resolvePath', () => {
expect(result.receiver.prefix).toBe('FF'); 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)', () => { it('handles unknown repeaters (no matches)', () => {
const result = resolvePath('XX', sender, contacts, config); const result = resolvePath('XX', sender, contacts, config);
@@ -545,6 +578,13 @@ describe('formatHopCounts', () => {
expect(result.hasMultiple).toBe(false); 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', () => { it('formats multiple paths sorted by hop count', () => {
const result = formatHopCounts([ const result = formatHopCounts([
{ path: '1A2B3C', received_at: 1700000000 }, // 3 hops { path: '1A2B3C', received_at: 1700000000 }, // 3 hops
@@ -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();
});
});
+34 -1
View File
@@ -24,6 +24,8 @@ const baseConfig: RadioConfig = {
lon: 2, lon: 2,
tx_power: 17, tx_power: 17,
max_tx_power: 22, max_tx_power: 22,
path_hash_mode: 1,
path_hash_mode_supported: true,
radio: { radio: {
freq: 910.525, freq: 910.525,
bw: 62.5, bw: 62.5,
@@ -57,6 +59,7 @@ const baseSettings: AppSettings = {
}; };
function renderModal(overrides?: { function renderModal(overrides?: {
config?: RadioConfig;
appSettings?: AppSettings; appSettings?: AppSettings;
health?: HealthStatus; health?: HealthStatus;
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>; onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
@@ -83,7 +86,7 @@ function renderModal(overrides?: {
const commonProps = { const commonProps = {
open: overrides?.open ?? true, open: overrides?.open ?? true,
pageMode: overrides?.pageMode, pageMode: overrides?.pageMode,
config: baseConfig, config: overrides?.config ?? baseConfig,
health: overrides?.health ?? baseHealth, health: overrides?.health ?? baseHealth,
appSettings: overrides?.appSettings ?? baseSettings, appSettings: overrides?.appSettings ?? baseSettings,
onClose, 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 () => { it('renders selected section from external sidebar nav on desktop mode', async () => {
renderModal({ renderModal({
externalSidebarNav: true, externalSidebarNav: true,
+7 -1
View File
@@ -12,6 +12,8 @@ export interface RadioConfig {
lon: number; lon: number;
tx_power: number; tx_power: number;
max_tx_power: number; max_tx_power: number;
path_hash_mode: number;
path_hash_mode_supported: boolean;
radio: RadioSettings; radio: RadioSettings;
} }
@@ -20,6 +22,7 @@ export interface RadioConfigUpdate {
lat?: number; lat?: number;
lon?: number; lon?: number;
tx_power?: number; tx_power?: number;
path_hash_mode?: number;
radio?: RadioSettings; radio?: RadioSettings;
} }
@@ -62,6 +65,7 @@ export interface Contact {
flags: number; flags: number;
last_path: string | null; last_path: string | null;
last_path_len: number; last_path_len: number;
out_path_hash_mode?: number | null;
last_advert: number | null; last_advert: number | null;
lat: number | null; lat: number | null;
lon: number | null; lon: number | null;
@@ -149,10 +153,12 @@ export interface ChannelDetail {
/** A single path that a message took to reach us */ /** A single path that a message took to reach us */
export interface MessagePath { export interface MessagePath {
/** Hex-encoded routing path (2 chars per hop) */ /** Hex-encoded routing path */
path: string; path: string;
/** Unix timestamp when this path was received */ /** Unix timestamp when this path was received */
received_at: number; received_at: number;
/** Number of hops in the path, when known */
path_len?: number;
} }
export interface Message { export interface Message {
+115
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;
}
+14 -12
View File
@@ -2,7 +2,7 @@ import type { Contact, RadioConfig, MessagePath } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types'; import { CONTACT_TYPE_REPEATER } from '../types';
export interface PathHop { 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) matches: Contact[]; // Matched repeaters (empty=unknown, multiple=ambiguous)
distanceFromPrev: number | null; // km from previous hop 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) { if (!path || path.length === 0) {
return []; return [];
} }
const normalized = path.toUpperCase(); 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[] = []; const hops: string[] = [];
for (let i = 0; i < normalized.length; i += 2) { for (let i = 0; i + charsPerHop <= normalized.length; i += charsPerHop) {
if (i + 1 < normalized.length) { hops.push(normalized.slice(i, i + charsPerHop));
hops.push(normalized.slice(i, i + 2));
}
} }
return hops; return hops;
@@ -148,11 +149,11 @@ function sortContactsByDistance(
/** /**
* Get simple hop count from path string * 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) { if (!path || path.length === 0) {
return 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 // 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 allDirect = hopCounts.every((h) => h === 0);
const hasMultiple = paths.length > 1; const hasMultiple = paths.length > 1;
@@ -189,9 +190,10 @@ export function resolvePath(
path: string | null | undefined, path: string | null | undefined,
sender: SenderInfo, sender: SenderInfo,
contacts: Contact[], contacts: Contact[],
config: RadioConfig | null config: RadioConfig | null,
pathLen?: number
): ResolvedPath { ): ResolvedPath {
const hopPrefixes = parsePathHops(path); const hopPrefixes = parsePathHops(path, pathLen);
// Build sender info // Build sender info
const senderPrefix = sender.publicKeyOrPrefix.toUpperCase().slice(0, 2); const senderPrefix = sender.publicKeyOrPrefix.toUpperCase().slice(0, 2);
+39
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;
}
}
+1
View File
@@ -1,3 +1,4 @@
import './meshcoreDecoderPatch';
import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder'; import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder';
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket } from '../types'; import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket } from '../types';
import { hashString } from './contactAvatar'; import { hashString } from './contactAvatar';
+1
View File
@@ -127,6 +127,7 @@ export function deleteContact(publicKey: string): Promise<{ status: string }> {
export interface MessagePath { export interface MessagePath {
path: string; path: string;
received_at: number; received_at: number;
path_len?: number;
} }
export interface Message { export interface Message {
+30
View File
@@ -260,6 +260,12 @@ class TestPacketFormatConversion:
assert result["route"] == "D" assert result["route"] == "D"
assert result["path"] == "aa,bb" 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): def test_direct_route_includes_empty_path_field(self):
data = {"timestamp": 0, "data": "0200", "snr": 1.0, "rssi": -70} data = {"timestamp": 0, "data": "0200", "snr": 1.0, "rssi": -70}
result = _format_raw_packet(data, "Node", "AA" * 32) result = _format_raw_packet(data, "Node", "AA" * 32)
@@ -359,6 +365,30 @@ class TestCalculatePacketHash:
expected = hashlib.sha256(bytes([2]) + payload).hexdigest()[:16].upper() expected = hashlib.sha256(bytes([2]) + payload).hexdigest()[:16].upper()
assert result == expected 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): def test_truncated_packet_returns_zeroes(self):
# Header says TRANSPORT_FLOOD, but missing path_len at required offset. # Header says TRANSPORT_FLOOD, but missing path_len at required offset.
raw = bytes([0x10, 0x01, 0x02]) raw = bytes([0x10, 0x01, 0x02])
+35
View File
@@ -214,6 +214,22 @@ class TestAdvertPaths:
assert data[0]["path"] == "" assert data[0]["path"] == ""
assert data[0]["next_hop"] is None 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 @pytest.mark.asyncio
async def test_get_contact_advert_paths_works_for_non_repeater(self, test_db, client): async def test_get_contact_advert_paths_works_for_non_repeater(self, test_db, client):
await _insert_contact(KEY_A, "Alice", type=1) await _insert_contact(KEY_A, "Alice", type=1)
@@ -326,6 +342,25 @@ class TestContactDetail:
assert repeater["name"] == "Relay1" assert repeater["name"] == "Relay1"
assert repeater["heard_count"] == 2 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 @pytest.mark.asyncio
async def test_detail_advert_frequency_computed(self, test_db, client): async def test_detail_advert_frequency_computed(self, test_db, client):
"""Advert frequency is computed from path observations over time span.""" """Advert frequency is computed from path observations over time span."""
+39
View File
@@ -19,6 +19,7 @@ from app.decoder import (
decrypt_group_text, decrypt_group_text,
derive_public_key, derive_public_key,
derive_shared_secret, derive_shared_secret,
extract_payload,
parse_packet, parse_packet,
try_decrypt_dm, try_decrypt_dm,
try_decrypt_packet_with_channel_key, try_decrypt_packet_with_channel_key,
@@ -81,6 +82,44 @@ class TestPacketParsing:
assert result.route_type == RouteType.DIRECT assert result.route_type == RouteType.DIRECT
assert result.payload_type == PayloadType.TEXT_MESSAGE assert result.payload_type == PayloadType.TEXT_MESSAGE
assert result.path_length == 3 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" assert result.payload == b"msg"
def test_parse_transport_flood_skips_transport_code(self): def test_parse_transport_flood_skips_transport_code(self):
+1 -1
View File
@@ -682,7 +682,7 @@ class TestDirectMessageDirectionDetection:
message_broadcasts = [b for b in broadcasts if b["type"] == "message"] message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(message_broadcasts) == 1 assert len(message_broadcasts) == 1
assert message_broadcasts[0]["data"]["paths"] == [ assert message_broadcasts[0]["data"]["paths"] == [
{"path": "", "received_at": SENDER_TIMESTAMP} {"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0}
] ]
@pytest.mark.asyncio @pytest.mark.asyncio
+15
View File
@@ -652,6 +652,21 @@ class TestAppriseFormatBody:
assert "`20`" in body assert "`20`" in body
assert "`27`" 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): def test_dm_no_path_shows_direct(self):
from app.fanout.apprise_mod import _format_body from app.fanout.apprise_mod import _format_body
+92 -27
View File
@@ -3,7 +3,13 @@
import aiosqlite import aiosqlite
import pytest 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: class TestMigrationSystem:
@@ -30,6 +36,14 @@ class TestMigrationSystem:
finally: finally:
await conn.close() 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: class TestMigration001:
"""Test migration 001: add last_read_at columns.""" """Test migration 001: add last_read_at columns."""
@@ -100,8 +114,8 @@ class TestMigration001:
# Run migrations # Run migrations
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 38 # All migrations run assert applied == 39 # All migrations run
assert await get_version(conn) == 38 assert await get_version(conn) == 39
# Verify columns exist by inserting and selecting # Verify columns exist by inserting and selecting
await conn.execute( await conn.execute(
@@ -183,9 +197,9 @@ class TestMigration001:
applied1 = await run_migrations(conn) applied1 = await run_migrations(conn)
applied2 = 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 applied2 == 0 # No migrations on second run
assert await get_version(conn) == 38 assert await get_version(conn) == 39
finally: finally:
await conn.close() await conn.close()
@@ -246,8 +260,8 @@ class TestMigration001:
applied = await run_migrations(conn) applied = await run_migrations(conn)
# All migrations applied (version incremented) but no error # All migrations applied (version incremented) but no error
assert applied == 38 assert applied == 39
assert await get_version(conn) == 38 assert await get_version(conn) == 39
finally: finally:
await conn.close() await conn.close()
@@ -374,10 +388,10 @@ class TestMigration013:
) )
await conn.commit() 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) applied = await run_migrations(conn)
assert applied == 26 assert applied == 27
assert await get_version(conn) == 38 assert await get_version(conn) == 39
# Bots were migrated from app_settings to fanout_configs (migration 37) # Bots were migrated from app_settings to fanout_configs (migration 37)
# and the bots column was dropped (migration 38) # and the bots column was dropped (migration 38)
@@ -495,7 +509,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None assert await cursor.fetchone() is not None
await run_migrations(conn) await run_migrations(conn)
assert await get_version(conn) == 38 assert await get_version(conn) == 39
# Verify autoindex is gone # Verify autoindex is gone
cursor = await conn.execute( cursor = await conn.execute(
@@ -573,8 +587,8 @@ class TestMigration018:
await conn.commit() await conn.commit()
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 21 # Migrations 18-38 run (18+19 skip internally) assert applied == 22 # Migrations 18-39 run (18+19 skip internally)
assert await get_version(conn) == 38 assert await get_version(conn) == 39
finally: finally:
await conn.close() await conn.close()
@@ -646,7 +660,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None assert await cursor.fetchone() is not None
await run_migrations(conn) await run_migrations(conn)
assert await get_version(conn) == 38 assert await get_version(conn) == 39
# Verify autoindex is gone # Verify autoindex is gone
cursor = await conn.execute( cursor = await conn.execute(
@@ -712,8 +726,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete" assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 19 # Migrations 20-38 assert applied == 20 # Migrations 20-39
assert await get_version(conn) == 38 assert await get_version(conn) == 39
# Verify WAL mode # Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode") cursor = await conn.execute("PRAGMA journal_mode")
@@ -743,7 +757,7 @@ class TestMigration020:
await set_version(conn, 20) await set_version(conn, 20)
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 18 # Migrations 21-38 still run assert applied == 19 # Migrations 21-39 still run
# Still WAL + INCREMENTAL # Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode") cursor = await conn.execute("PRAGMA journal_mode")
@@ -801,8 +815,8 @@ class TestMigration028:
await conn.commit() await conn.commit()
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 11 assert applied == 12
assert await get_version(conn) == 38 assert await get_version(conn) == 39
# Verify payload_hash column is now BLOB # Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)") cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -871,8 +885,8 @@ class TestMigration028:
await conn.commit() await conn.commit()
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 11 # Version still bumped assert applied == 12 # Version still bumped
assert await get_version(conn) == 38 assert await get_version(conn) == 39
# Verify data unchanged # Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets") cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -921,8 +935,8 @@ class TestMigration032:
await conn.commit() await conn.commit()
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 7 assert applied == 8
assert await get_version(conn) == 38 assert await get_version(conn) == 39
# Community MQTT columns were added by migration 32 and dropped by migration 38. # Community MQTT columns were added by migration 32 and dropped by migration 38.
# Verify community settings were NOT migrated (no community config existed). # Verify community settings were NOT migrated (no community config existed).
@@ -988,8 +1002,8 @@ class TestMigration034:
await conn.commit() await conn.commit()
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 5 assert applied == 6
assert await get_version(conn) == 38 assert await get_version(conn) == 39
# Verify column exists with correct default # Verify column exists with correct default
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1") cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
@@ -1031,8 +1045,8 @@ class TestMigration033:
await conn.commit() await conn.commit()
applied = await run_migrations(conn) applied = await run_migrations(conn)
assert applied == 6 assert applied == 7
assert await get_version(conn) == 38 assert await get_version(conn) == 39
cursor = await conn.execute( cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?", "SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
@@ -1088,3 +1102,54 @@ class TestMigration033:
assert row["on_radio"] == 1 # Not overwritten assert row["on_radio"] == 1 # Not overwritten
finally: finally:
await conn.close() 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()
+3
View File
@@ -678,6 +678,7 @@ class TestMessageBroadcastStructure:
assert broadcast["paths"] is not None assert broadcast["paths"] is not None
assert len(broadcast["paths"]) == 1 assert len(broadcast["paths"]) == 1
assert broadcast["paths"][0]["path"] == "" # Empty string = direct/flood assert broadcast["paths"][0]["path"] == "" # Empty string = direct/flood
assert broadcast["paths"][0]["path_len"] == 0
class TestRawPacketStorage: class TestRawPacketStorage:
@@ -927,6 +928,7 @@ class TestCreateDMMessageFromDecrypted:
our_public_key=self.FACE12_PUB, our_public_key=self.FACE12_PUB,
received_at=1700000001, received_at=1700000001,
path="aabbcc", # Path through 3 repeaters path="aabbcc", # Path through 3 repeaters
path_len=3,
outgoing=False, outgoing=False,
) )
@@ -937,6 +939,7 @@ class TestCreateDMMessageFromDecrypted:
assert broadcast["paths"] is not None assert broadcast["paths"] is not None
assert len(broadcast["paths"]) == 1 assert len(broadcast["paths"]) == 1
assert broadcast["paths"][0]["path"] == "aabbcc" assert broadcast["paths"][0]["path"] == "aabbcc"
assert broadcast["paths"][0]["path_len"] == 3
assert broadcast["paths"][0]["received_at"] == 1700000001 assert broadcast["paths"][0]["received_at"] == 1700000001
+36
View File
@@ -6,6 +6,7 @@ import asyncio
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from meshcore import EventType
class TestRadioManagerConnect: class TestRadioManagerConnect:
@@ -688,3 +689,38 @@ class TestPostConnectSetupOrdering:
await rm.post_connect_setup() await rm.post_connect_setup()
mock_mc.commands.set_flood_scope.assert_awaited_once_with("") 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)
+86 -2
View File
@@ -45,9 +45,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak.""" """Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock 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 yield
radio_manager._meshcore = prev radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock 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(): def _mock_meshcore_with_info():
@@ -70,6 +74,10 @@ def _mock_meshcore_with_info():
mc.commands.set_tx_power = AsyncMock() mc.commands.set_tx_power = AsyncMock()
mc.commands.set_radio = AsyncMock() mc.commands.set_radio = AsyncMock()
mc.commands.send_appstart = 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()) mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
return mc return mc
@@ -78,7 +86,11 @@ class TestGetRadioConfig:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_maps_self_info_to_response(self): async def test_maps_self_info_to_response(self):
mc = _mock_meshcore_with_info() 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() response = await get_radio_config()
assert response.public_key == "aa" * 32 assert response.public_key == "aa" * 32
@@ -87,17 +99,38 @@ class TestGetRadioConfig:
assert response.lon == 20.0 assert response.lon == 20.0
assert response.radio.freq == 910.525 assert response.radio.freq == 910.525
assert response.radio.cr == 5 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 @pytest.mark.asyncio
async def test_returns_503_when_self_info_missing(self): async def test_returns_503_when_self_info_missing(self):
mc = MagicMock() mc = MagicMock()
mc.self_info = None 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: with pytest.raises(HTTPException) as exc:
await get_radio_config() await get_radio_config()
assert exc.value.status_code == 503 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: class TestUpdateRadioConfig:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -110,12 +143,15 @@ class TestUpdateRadioConfig:
lon=20.0, lon=20.0,
tx_power=17, tx_power=17,
max_tx_power=22, 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), radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5),
) )
with ( with (
patch("app.routers.radio.require_connected", return_value=mc), patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", 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.sync_radio_time", new_callable=AsyncMock) as mock_sync_time,
patch( patch(
"app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected "app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected
@@ -131,6 +167,54 @@ class TestUpdateRadioConfig:
mock_sync_time.assert_awaited_once() mock_sync_time.assert_awaited_once()
assert result == expected 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: class TestPrivateKeyImport:
@pytest.mark.asyncio @pytest.mark.asyncio
+16 -1
View File
@@ -36,12 +36,13 @@ class TestMessageRepositoryAddPath:
msg_id = await _create_message(test_db) msg_id = await _create_message(test_db)
result = await MessageRepository.add_path( 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 len(result) == 1
assert result[0].path == "1A2B" assert result[0].path == "1A2B"
assert result[0].received_at == 1700000000 assert result[0].received_at == 1700000000
assert result[0].path_len == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_path_to_message_with_existing_paths(self, test_db): 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].last_seen == 1010
assert paths[0].heard_count == 2 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 @pytest.mark.asyncio
async def test_prunes_to_most_recent_n_unique_paths(self, test_db): async def test_prunes_to_most_recent_n_unique_paths(self, test_db):
repeater_key = "bb" * 32 repeater_key = "bb" * 32
+68
View File
@@ -30,9 +30,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak.""" """Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock 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 yield
radio_manager._meshcore = prev radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock 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): def _make_radio_result(payload=None):
@@ -125,6 +129,70 @@ class TestOutgoingDMBroadcast:
assert exc_info.value.status_code == 409 assert exc_info.value.status_code == 409
assert "ambiguous" in exc_info.value.detail.lower() 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: class TestOutgoingChannelBroadcast:
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch.""" """Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""