mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-10 07:15:09 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f039b9c41 | |||
| f302cc04ae | |||
| 3edc7d9bd1 | |||
| 9c54ea623e |
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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}]"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(?)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user