diff --git a/app/database.py b/app/database.py
index 637fd6f..a5c8226 100644
--- a/app/database.py
+++ b/app/database.py
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS contacts (
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
+ out_path_hash_mode INTEGER,
last_advert INTEGER,
lat REAL,
lon REAL,
diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py
index f28e42a..d1327b3 100644
--- a/app/fanout/community_mqtt.py
+++ b/app/fanout/community_mqtt.py
@@ -150,7 +150,8 @@ def _calculate_packet_hash(raw_bytes: bytes) -> str:
# Read packed path metadata. Invalid/truncated packets map to zero hash.
if offset >= len(raw_bytes):
return "0" * 16
- path_len, _path_hash_size, path_byte_length = decode_path_metadata(raw_bytes[offset])
+ packed_path_len = raw_bytes[offset]
+ path_len, _path_hash_size, path_byte_length = decode_path_metadata(packed_path_len)
offset += 1
# Skip past path to get to payload. Invalid/truncated packets map to zero hash.
@@ -159,11 +160,11 @@ def _calculate_packet_hash(raw_bytes: bytes) -> str:
payload_start = offset + path_byte_length
payload_data = raw_bytes[payload_start:]
- # Hash: payload_type(1 byte) [+ path_len as uint16_t LE for TRACE] + payload_data
+ # Hash: payload_type(1 byte) [+ packed path_len as uint16_t LE for TRACE] + payload_data
hash_obj = hashlib.sha256()
hash_obj.update(bytes([payload_type]))
if payload_type == 9: # PAYLOAD_TYPE_TRACE
- hash_obj.update(path_len.to_bytes(2, byteorder="little"))
+ hash_obj.update(packed_path_len.to_bytes(2, byteorder="little"))
hash_obj.update(payload_data)
return hash_obj.hexdigest()[:16].upper()
diff --git a/app/migrations.py b/app/migrations.py
index 4ec70e8..e01f244 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -14,6 +14,7 @@ from hashlib import sha256
import aiosqlite
from app.decoder import extract_payload, parse_packet
+from app.models import Contact
logger = logging.getLogger(__name__)
@@ -305,6 +306,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 38)
applied += 1
+ # Migration 39: Persist exact contact out_path_hash_mode from radio sync
+ if version < 39:
+ logger.info("Applying migration 39: add contacts.out_path_hash_mode")
+ await _migrate_039_add_contact_out_path_hash_mode(conn)
+ await set_version(conn, 39)
+ applied += 1
+
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2230,3 +2238,44 @@ async def _migrate_038_drop_legacy_columns(conn: aiosqlite.Connection) -> None:
raise
await conn.commit()
+
+
+async def _migrate_039_add_contact_out_path_hash_mode(conn: aiosqlite.Connection) -> None:
+ """Add contacts.out_path_hash_mode and backfill best-effort values for existing rows."""
+ tables = await conn.execute(
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'contacts'"
+ )
+ if await tables.fetchone() is None:
+ await conn.commit()
+ return
+
+ columns_cursor = await conn.execute("PRAGMA table_info(contacts)")
+ columns = {row["name"] for row in await columns_cursor.fetchall()}
+
+ if "out_path_hash_mode" not in columns:
+ await conn.execute("ALTER TABLE contacts ADD COLUMN out_path_hash_mode INTEGER")
+ columns.add("out_path_hash_mode")
+
+ if not {"last_path", "last_path_len"}.issubset(columns):
+ await conn.commit()
+ return
+
+ rows_cursor = await conn.execute(
+ """
+ SELECT public_key, last_path, last_path_len, out_path_hash_mode
+ FROM contacts
+ """
+ )
+ rows = await rows_cursor.fetchall()
+ for row in rows:
+ if row["out_path_hash_mode"] is not None:
+ continue
+ if row["last_path_len"] is None or row["last_path_len"] < 0:
+ continue
+ derived = Contact._derive_out_path_hash_mode(row["last_path"], row["last_path_len"])
+ await conn.execute(
+ "UPDATE contacts SET out_path_hash_mode = ? WHERE public_key = ?",
+ (derived, row["public_key"]),
+ )
+
+ await conn.commit()
diff --git a/app/models.py b/app/models.py
index bdeeada..806d5e7 100644
--- a/app/models.py
+++ b/app/models.py
@@ -10,6 +10,7 @@ class Contact(BaseModel):
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
+ out_path_hash_mode: int | None = None
last_advert: int | None = None
lat: float | None = None
lon: float | None = None
@@ -45,6 +46,10 @@ class Contact(BaseModel):
The radio API uses different field names (adv_name, out_path, etc.)
than our database schema (name, last_path, etc.).
"""
+ out_path_hash_mode = self.out_path_hash_mode
+ if out_path_hash_mode is None:
+ out_path_hash_mode = self._derive_out_path_hash_mode(self.last_path, self.last_path_len)
+
return {
"public_key": self.public_key,
"adv_name": self.name or "",
@@ -52,9 +57,7 @@ class Contact(BaseModel):
"flags": self.flags,
"out_path": self.last_path or "",
"out_path_len": self.last_path_len,
- "out_path_hash_mode": self._derive_out_path_hash_mode(
- self.last_path, self.last_path_len
- ),
+ "out_path_hash_mode": out_path_hash_mode,
"adv_lat": self.lat if self.lat is not None else 0.0,
"adv_lon": self.lon if self.lon is not None else 0.0,
"last_advert": self.last_advert if self.last_advert is not None else 0,
@@ -74,6 +77,7 @@ class Contact(BaseModel):
"flags": radio_data.get("flags", 0),
"last_path": radio_data.get("out_path"),
"last_path_len": radio_data.get("out_path_len", -1),
+ "out_path_hash_mode": radio_data.get("out_path_hash_mode"),
"lat": radio_data.get("adv_lat"),
"lon": radio_data.get("adv_lon"),
"last_advert": radio_data.get("last_advert"),
diff --git a/app/radio.py b/app/radio.py
index 95d5dd0..4c0ab59 100644
--- a/app/radio.py
+++ b/app/radio.py
@@ -1,11 +1,12 @@
import asyncio
import glob
+import inspect
import logging
import platform
from contextlib import asynccontextmanager, nullcontext
from pathlib import Path
-from meshcore import MeshCore
+from meshcore import EventType, MeshCore
from app.config import settings
@@ -128,6 +129,8 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
+ self._path_hash_mode: int = 0
+ self._path_hash_mode_supported: bool = False
async def _acquire_operation_lock(
self,
@@ -257,6 +260,7 @@ class RadioManager:
# Sync radio clock with system time
await sync_radio_time(mc)
+ await self.refresh_path_hash_mode_info(mc)
# Apply flood scope from settings (best-effort; older firmware
# may not support set_flood_scope)
@@ -331,6 +335,48 @@ class RadioManager:
def is_setup_complete(self) -> bool:
return self._setup_complete
+ @property
+ def path_hash_mode_info(self) -> tuple[int, bool]:
+ return self._path_hash_mode, self._path_hash_mode_supported
+
+ def set_path_hash_mode_info(self, mode: int, supported: bool) -> None:
+ self._path_hash_mode = mode if supported and 0 <= mode <= 2 else 0
+ self._path_hash_mode_supported = supported
+
+ async def refresh_path_hash_mode_info(self, mc: MeshCore | None = None) -> tuple[int, bool]:
+ target = mc or self._meshcore
+ if target is None:
+ self.set_path_hash_mode_info(0, False)
+ return self.path_hash_mode_info
+
+ commands = getattr(target, "commands", None)
+ send_device_query = getattr(commands, "send_device_query", None)
+ if commands is None or not callable(send_device_query):
+ self.set_path_hash_mode_info(0, False)
+ return self.path_hash_mode_info
+
+ try:
+ result = send_device_query()
+ if inspect.isawaitable(result):
+ result = await result
+ except Exception as exc:
+ logger.debug("Failed to query device info for path hash mode: %s", exc)
+ self.set_path_hash_mode_info(0, False)
+ return self.path_hash_mode_info
+
+ if result is None or getattr(result, "type", None) == EventType.ERROR:
+ self.set_path_hash_mode_info(0, False)
+ return self.path_hash_mode_info
+
+ payload_obj = getattr(result, "payload", None)
+ payload = payload_obj if isinstance(payload_obj, dict) else {}
+ mode = payload.get("path_hash_mode")
+ if isinstance(mode, int) and 0 <= mode <= 2:
+ self.set_path_hash_mode_info(mode, True)
+ else:
+ self.set_path_hash_mode_info(0, False)
+ return self.path_hash_mode_info
+
async def connect(self) -> None:
"""Connect to the radio using the configured transport."""
if self._meshcore is not None:
@@ -369,6 +415,7 @@ class RadioManager:
self._connection_info = f"Serial: {port}"
self._last_connected = True
self._setup_complete = False
+ self.set_path_hash_mode_info(0, False)
logger.debug("Serial connection established")
async def _connect_tcp(self) -> None:
@@ -386,6 +433,7 @@ class RadioManager:
self._connection_info = f"TCP: {host}:{port}"
self._last_connected = True
self._setup_complete = False
+ self.set_path_hash_mode_info(0, False)
logger.debug("TCP connection established")
async def _connect_ble(self) -> None:
@@ -403,6 +451,7 @@ class RadioManager:
self._connection_info = f"BLE: {address}"
self._last_connected = True
self._setup_complete = False
+ self.set_path_hash_mode_info(0, False)
logger.debug("BLE connection established")
async def disconnect(self) -> None:
@@ -412,6 +461,7 @@ class RadioManager:
await self._meshcore.disconnect()
self._meshcore = None
self._setup_complete = False
+ self.set_path_hash_mode_info(0, False)
logger.debug("Radio disconnected")
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
diff --git a/app/repository/contacts.py b/app/repository/contacts.py
index 071d3a2..c430160 100644
--- a/app/repository/contacts.py
+++ b/app/repository/contacts.py
@@ -26,15 +26,19 @@ class ContactRepository:
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
+ out_path_hash_mode,
last_advert, lat, lon, last_seen, on_radio, last_contacted,
first_seen)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(public_key) DO UPDATE SET
name = COALESCE(excluded.name, contacts.name),
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len,
+ out_path_hash_mode = COALESCE(
+ excluded.out_path_hash_mode, contacts.out_path_hash_mode
+ ),
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
lat = COALESCE(excluded.lat, contacts.lat),
lon = COALESCE(excluded.lon, contacts.lon),
@@ -50,6 +54,7 @@ class ContactRepository:
contact.get("flags", 0),
contact.get("last_path"),
contact.get("last_path_len", -1),
+ contact.get("out_path_hash_mode"),
contact.get("last_advert"),
contact.get("lat"),
contact.get("lon"),
@@ -71,6 +76,7 @@ class ContactRepository:
flags=row["flags"],
last_path=row["last_path"],
last_path_len=row["last_path_len"],
+ out_path_hash_mode=row["out_path_hash_mode"],
last_advert=row["last_advert"],
lat=row["lat"],
lon=row["lon"],
@@ -201,11 +207,23 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
- async def update_path(public_key: str, path: str, path_len: int) -> None:
- await db.conn.execute(
- "UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
- (path, path_len, int(time.time()), public_key.lower()),
- )
+ async def update_path(
+ public_key: str, path: str, path_len: int, out_path_hash_mode: int | None = None
+ ) -> None:
+ if out_path_hash_mode is None:
+ await db.conn.execute(
+ "UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
+ (path, path_len, int(time.time()), public_key.lower()),
+ )
+ else:
+ await db.conn.execute(
+ """
+ UPDATE contacts
+ SET last_path = ?, last_path_len = ?, out_path_hash_mode = ?, last_seen = ?
+ WHERE public_key = ?
+ """,
+ (path, path_len, out_path_hash_mode, int(time.time()), public_key.lower()),
+ )
await db.conn.commit()
@staticmethod
diff --git a/app/routers/contacts.py b/app/routers/contacts.py
index 401cd1f..0b2ba9a 100644
--- a/app/routers/contacts.py
+++ b/app/routers/contacts.py
@@ -463,7 +463,7 @@ async def reset_contact_path(public_key: str) -> dict:
"""Reset a contact's routing path to flood."""
contact = await _resolve_contact_or_404(public_key)
- await ContactRepository.update_path(contact.public_key, "", -1)
+ await ContactRepository.update_path(contact.public_key, "", -1, out_path_hash_mode=-1)
logger.info("Reset path to flood for %s", contact.public_key[:12])
# Push the updated path to radio if connected and contact is on radio
diff --git a/app/routers/radio.py b/app/routers/radio.py
index c8a8bfb..da2336c 100644
--- a/app/routers/radio.py
+++ b/app/routers/radio.py
@@ -53,32 +53,6 @@ class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
-async def _get_path_hash_mode_info(mc) -> tuple[int, bool]:
- """Return (mode, supported) using the best interface available."""
- commands = getattr(mc, "commands", None)
- send_device_query = cast(
- Callable[[], Awaitable[Any]] | None, getattr(commands, "send_device_query", None)
- )
- if commands is None or not callable(send_device_query):
- return 0, False
-
- try:
- result = await send_device_query()
- except Exception as exc:
- logger.debug("Failed to query device info for path hash mode: %s", exc)
- return 0, False
-
- if result is None or result.type == EventType.ERROR:
- return 0, False
-
- payload = result.payload if isinstance(result.payload, dict) else {}
- mode = payload.get("path_hash_mode")
- if isinstance(mode, int) and 0 <= mode <= 2:
- return mode, True
-
- return 0, False
-
-
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)
@@ -113,31 +87,29 @@ async def _set_path_hash_mode(mc, mode: int):
@router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration."""
- require_connected()
+ mc = require_connected()
+ info = mc.self_info
+ if not info:
+ raise HTTPException(status_code=503, detail="Radio info not available")
- async with radio_manager.radio_operation("get_radio_config") as mc:
- info = mc.self_info
- if not info:
- raise HTTPException(status_code=503, detail="Radio info not available")
+ path_hash_mode, path_hash_mode_supported = radio_manager.path_hash_mode_info
- path_hash_mode, path_hash_mode_supported = await _get_path_hash_mode_info(mc)
-
- return RadioConfigResponse(
- public_key=info.get("public_key", ""),
- name=info.get("name", ""),
- lat=info.get("adv_lat", 0.0),
- lon=info.get("adv_lon", 0.0),
- tx_power=info.get("tx_power", 0),
- max_tx_power=info.get("max_tx_power", 0),
- path_hash_mode=path_hash_mode,
- path_hash_mode_supported=path_hash_mode_supported,
- radio=RadioSettings(
- freq=info.get("radio_freq", 0.0),
- bw=info.get("radio_bw", 0.0),
- sf=info.get("radio_sf", 0),
- cr=info.get("radio_cr", 0),
- ),
- )
+ return RadioConfigResponse(
+ public_key=info.get("public_key", ""),
+ name=info.get("name", ""),
+ lat=info.get("adv_lat", 0.0),
+ lon=info.get("adv_lon", 0.0),
+ tx_power=info.get("tx_power", 0),
+ max_tx_power=info.get("max_tx_power", 0),
+ path_hash_mode=path_hash_mode,
+ path_hash_mode_supported=path_hash_mode_supported,
+ radio=RadioSettings(
+ freq=info.get("radio_freq", 0.0),
+ bw=info.get("radio_bw", 0.0),
+ sf=info.get("radio_sf", 0),
+ cr=info.get("radio_cr", 0),
+ ),
+ )
@router.patch("/config", response_model=RadioConfigResponse)
@@ -162,7 +134,9 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
await mc.commands.set_tx_power(val=update.tx_power)
if update.path_hash_mode is not None:
- current_mode, supported = await _get_path_hash_mode_info(mc)
+ 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,
@@ -171,6 +145,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
if current_mode != update.path_hash_mode:
logger.info("Setting path hash mode to %d", update.path_hash_mode)
await _set_path_hash_mode(mc, update.path_hash_mode)
+ radio_manager.set_path_hash_mode_info(update.path_hash_mode, True)
if update.radio is not None:
logger.info(
diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx
index 3228b8a..65b9cf9 100644
--- a/frontend/src/components/ContactInfoPane.tsx
+++ b/frontend/src/components/ContactInfoPane.tsx
@@ -1,7 +1,12 @@
import { useEffect, useState } from 'react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
-import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
+import {
+ isValidLocation,
+ calculateDistance,
+ formatDistance,
+ parsePathHops,
+} from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -413,7 +418,7 @@ export function ContactInfoPane({
className="flex justify-between items-center text-sm"
>
- {p.path ? p.path.match(/.{2}/g)!.join(' โ ') : '(direct)'}
+ {p.path ? parsePathHops(p.path, p.path_len).join(' โ ') : '(direct)'}
{p.heard_count}x ยท {formatTime(p.last_seen)}
diff --git a/frontend/src/components/CrackerPanel.tsx b/frontend/src/components/CrackerPanel.tsx
index 5c133e1..b964a88 100644
--- a/frontend/src/components/CrackerPanel.tsx
+++ b/frontend/src/components/CrackerPanel.tsx
@@ -1,3 +1,4 @@
+import '../utils/meshcoreDecoderPatch';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { GroupTextCracker, type ProgressReport } from 'meshcore-hashtag-cracker';
import NoSleep from 'nosleep.js';
diff --git a/frontend/src/components/RawPacketList.tsx b/frontend/src/components/RawPacketList.tsx
index 823ff1a..8d7ad71 100644
--- a/frontend/src/components/RawPacketList.tsx
+++ b/frontend/src/components/RawPacketList.tsx
@@ -1,3 +1,4 @@
+import '../utils/meshcoreDecoderPatch';
import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 0d45952..c870c71 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,3 +1,4 @@
+import './utils/meshcoreDecoderPatch';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx
new file mode 100644
index 0000000..b3cb0ed
--- /dev/null
+++ b/frontend/src/test/contactInfoPane.test.tsx
@@ -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(
+
+ );
+
+ expect(await screen.findByText('2027 โ 3031')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/meshcoreDecoderPatch.test.ts b/frontend/src/test/meshcoreDecoderPatch.test.ts
new file mode 100644
index 0000000..0009599
--- /dev/null
+++ b/frontend/src/test/meshcoreDecoderPatch.test.ts
@@ -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);
+ });
+});
diff --git a/frontend/src/test/pathUtils.test.ts b/frontend/src/test/pathUtils.test.ts
index e158623..377b783 100644
--- a/frontend/src/test/pathUtils.test.ts
+++ b/frontend/src/test/pathUtils.test.ts
@@ -66,6 +66,10 @@ describe('parsePathHops', () => {
expect(parsePathHops('1A2B3C4D', 2)).toEqual(['1A2B', '3C4D']);
});
+ it('parses three-byte hops when path length is provided', () => {
+ expect(parsePathHops('1A2B3C4D5E6F', 2)).toEqual(['1A2B3C', '4D5E6F']);
+ });
+
it('converts to uppercase', () => {
expect(parsePathHops('1a2b')).toEqual(['1A', '2B']);
});
diff --git a/frontend/src/test/rawPacketPayload.test.ts b/frontend/src/test/rawPacketPayload.test.ts
index 1774760..724d6c6 100644
--- a/frontend/src/test/rawPacketPayload.test.ts
+++ b/frontend/src/test/rawPacketPayload.test.ts
@@ -15,6 +15,10 @@ describe('extractRawPacketPayload', () => {
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();
});
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index c0aacf8..1625741 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -65,6 +65,7 @@ export interface Contact {
flags: number;
last_path: string | null;
last_path_len: number;
+ out_path_hash_mode?: number | null;
last_advert: number | null;
lat: number | null;
lon: number | null;
diff --git a/frontend/src/utils/meshcoreDecoderPatch.ts b/frontend/src/utils/meshcoreDecoderPatch.ts
new file mode 100644
index 0000000..2443c55
--- /dev/null
+++ b/frontend/src/utils/meshcoreDecoderPatch.ts
@@ -0,0 +1,115 @@
+import { MeshCorePacketDecoder, bytesToHex, hexToBytes } from '@michaelhart/meshcore-decoder';
+
+type DecoderClass = typeof MeshCorePacketDecoder & {
+ __multiBytePathPatchApplied?: boolean;
+};
+type DecoderOptions = Parameters[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;
+}
diff --git a/frontend/src/utils/visualizerUtils.ts b/frontend/src/utils/visualizerUtils.ts
index 14e0df0..cfbf88b 100644
--- a/frontend/src/utils/visualizerUtils.ts
+++ b/frontend/src/utils/visualizerUtils.ts
@@ -1,3 +1,4 @@
+import './meshcoreDecoderPatch';
import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder';
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket } from '../types';
import { hashString } from './contactAvatar';
diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py
index 4855a3c..87b7b61 100644
--- a/tests/test_community_mqtt.py
+++ b/tests/test_community_mqtt.py
@@ -365,7 +365,7 @@ class TestCalculatePacketHash:
expected = hashlib.sha256(bytes([2]) + payload).hexdigest()[:16].upper()
assert result == expected
- def test_multi_byte_path_uses_hop_count_for_trace_hash(self):
+ def test_multi_byte_path_uses_packed_path_byte_for_trace_hash(self):
import hashlib
payload = b"\x99\x88"
@@ -373,7 +373,7 @@ class TestCalculatePacketHash:
result = _calculate_packet_hash(raw)
expected = (
- hashlib.sha256(bytes([9]) + (2).to_bytes(2, byteorder="little") + payload)
+ hashlib.sha256(bytes([9]) + (0x42).to_bytes(2, byteorder="little") + payload)
.hexdigest()[:16]
.upper()
)
diff --git a/tests/test_decoder.py b/tests/test_decoder.py
index b51433a..9413b01 100644
--- a/tests/test_decoder.py
+++ b/tests/test_decoder.py
@@ -107,6 +107,21 @@ class TestPacketParsing:
assert extract_payload(packet) == b"payload_data"
+ def test_parse_packet_with_three_byte_hops(self):
+ """Packets support three-byte hop identifiers as well as one/two-byte hops."""
+ packet = bytes([0x0A, 0x82, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]) + b"msg"
+
+ result = parse_packet(packet)
+
+ assert result is not None
+ assert result.route_type == RouteType.DIRECT
+ assert result.payload_type == PayloadType.TEXT_MESSAGE
+ assert result.path_length == 2
+ assert result.path_hash_size == 3
+ assert result.path_byte_length == 6
+ assert result.path == bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06])
+ assert result.payload == b"msg"
+
def test_parse_transport_flood_skips_transport_code(self):
"""TRANSPORT_FLOOD packets have 4-byte transport code to skip."""
# Header: route_type=TRANSPORT_FLOOD(0), payload_type=GROUP_TEXT(5)
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 4a584f8..e8446ba 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -114,8 +114,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
- assert applied == 38 # All migrations run
- assert await get_version(conn) == 38
+ assert applied == 39 # All migrations run
+ assert await get_version(conn) == 39
# Verify columns exist by inserting and selecting
await conn.execute(
@@ -197,9 +197,9 @@ class TestMigration001:
applied1 = await run_migrations(conn)
applied2 = await run_migrations(conn)
- assert applied1 == 38 # All migrations run
+ assert applied1 == 39 # All migrations run
assert applied2 == 0 # No migrations on second run
- assert await get_version(conn) == 38
+ assert await get_version(conn) == 39
finally:
await conn.close()
@@ -260,8 +260,8 @@ class TestMigration001:
applied = await run_migrations(conn)
# All migrations applied (version incremented) but no error
- assert applied == 38
- assert await get_version(conn) == 38
+ assert applied == 39
+ assert await get_version(conn) == 39
finally:
await conn.close()
@@ -388,10 +388,10 @@ class TestMigration013:
)
await conn.commit()
- # Run migration 13 (plus 14-38 which also run)
+ # Run migration 13 (plus 14-39 which also run)
applied = await run_migrations(conn)
- assert applied == 26
- assert await get_version(conn) == 38
+ assert applied == 27
+ assert await get_version(conn) == 39
# Bots were migrated from app_settings to fanout_configs (migration 37)
# and the bots column was dropped (migration 38)
@@ -509,7 +509,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
- assert await get_version(conn) == 38
+ assert await get_version(conn) == 39
# Verify autoindex is gone
cursor = await conn.execute(
@@ -587,8 +587,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 21 # Migrations 18-38 run (18+19 skip internally)
- assert await get_version(conn) == 38
+ assert applied == 22 # Migrations 18-39 run (18+19 skip internally)
+ assert await get_version(conn) == 39
finally:
await conn.close()
@@ -660,7 +660,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
- assert await get_version(conn) == 38
+ assert await get_version(conn) == 39
# Verify autoindex is gone
cursor = await conn.execute(
@@ -726,8 +726,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
- assert applied == 19 # Migrations 20-38
- assert await get_version(conn) == 38
+ assert applied == 20 # Migrations 20-39
+ assert await get_version(conn) == 39
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -757,7 +757,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
- assert applied == 18 # Migrations 21-38 still run
+ assert applied == 19 # Migrations 21-39 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
@@ -815,8 +815,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 11
- assert await get_version(conn) == 38
+ assert applied == 12
+ assert await get_version(conn) == 39
# Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -885,8 +885,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 11 # Version still bumped
- assert await get_version(conn) == 38
+ assert applied == 12 # Version still bumped
+ assert await get_version(conn) == 39
# Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -935,8 +935,8 @@ class TestMigration032:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 7
- assert await get_version(conn) == 38
+ assert applied == 8
+ assert await get_version(conn) == 39
# Community MQTT columns were added by migration 32 and dropped by migration 38.
# Verify community settings were NOT migrated (no community config existed).
@@ -1002,8 +1002,8 @@ class TestMigration034:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 5
- assert await get_version(conn) == 38
+ assert applied == 6
+ assert await get_version(conn) == 39
# Verify column exists with correct default
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
@@ -1045,8 +1045,8 @@ class TestMigration033:
await conn.commit()
applied = await run_migrations(conn)
- assert applied == 6
- assert await get_version(conn) == 38
+ assert applied == 7
+ assert await get_version(conn) == 39
cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
@@ -1102,3 +1102,54 @@ class TestMigration033:
assert row["on_radio"] == 1 # Not overwritten
finally:
await conn.close()
+
+
+class TestMigration039:
+ """Test migration 039: add contacts.out_path_hash_mode."""
+
+ @pytest.mark.asyncio
+ async def test_migration_adds_out_path_hash_mode_and_backfills(self):
+ conn = await aiosqlite.connect(":memory:")
+ conn.row_factory = aiosqlite.Row
+ try:
+ await set_version(conn, 38)
+ await conn.execute("""
+ CREATE TABLE contacts (
+ public_key TEXT PRIMARY KEY,
+ name TEXT,
+ type INTEGER DEFAULT 0,
+ flags INTEGER DEFAULT 0,
+ last_path TEXT,
+ last_path_len INTEGER DEFAULT -1,
+ last_advert INTEGER,
+ lat REAL,
+ lon REAL,
+ last_seen INTEGER,
+ on_radio INTEGER DEFAULT 0,
+ last_contacted INTEGER,
+ first_seen INTEGER,
+ last_read_at INTEGER
+ )
+ """)
+ await conn.execute(
+ """
+ INSERT INTO contacts (
+ public_key, last_path, last_path_len, on_radio
+ ) VALUES (?, ?, ?, ?)
+ """,
+ ("aa" * 32, "11223344", 2, 1),
+ )
+ await conn.commit()
+
+ applied = await run_migrations(conn)
+ assert applied == 1
+ assert await get_version(conn) == 39
+
+ cursor = await conn.execute(
+ "SELECT out_path_hash_mode FROM contacts WHERE public_key = ?",
+ ("aa" * 32,),
+ )
+ row = await cursor.fetchone()
+ assert row["out_path_hash_mode"] == 1
+ finally:
+ await conn.close()
diff --git a/tests/test_radio.py b/tests/test_radio.py
index 2ff8cce..1722e11 100644
--- a/tests/test_radio.py
+++ b/tests/test_radio.py
@@ -6,6 +6,7 @@ import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
+from meshcore import EventType
class TestRadioManagerConnect:
@@ -688,3 +689,38 @@ class TestPostConnectSetupOrdering:
await rm.post_connect_setup()
mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
+
+ @pytest.mark.asyncio
+ async def test_path_hash_mode_cached_during_setup(self):
+ """post_connect_setup caches path hash mode from device info."""
+ from app.models import AppSettings
+ from app.radio import RadioManager
+
+ rm = RadioManager()
+ mock_mc = MagicMock()
+ mock_mc.start_auto_message_fetching = AsyncMock()
+ mock_mc.commands.set_flood_scope = AsyncMock()
+ mock_mc.commands.send_device_query = AsyncMock(
+ return_value=MagicMock(type=EventType.DEVICE_INFO, payload={"path_hash_mode": 2})
+ )
+ rm._meshcore = mock_mc
+
+ with (
+ patch("app.event_handlers.register_event_handlers"),
+ patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
+ patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
+ patch(
+ "app.repository.AppSettingsRepository.get",
+ new_callable=AsyncMock,
+ return_value=AppSettings(),
+ ),
+ patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
+ patch("app.radio_sync.start_periodic_sync"),
+ patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
+ patch("app.radio_sync.start_periodic_advert"),
+ patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
+ patch("app.radio_sync.start_message_polling"),
+ ):
+ await rm.post_connect_setup()
+
+ assert rm.path_hash_mode_info == (2, True)
diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py
index bdb6e0c..97c5d9b 100644
--- a/tests/test_radio_router.py
+++ b/tests/test_radio_router.py
@@ -45,9 +45,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
+ prev_path_hash_mode = radio_manager._path_hash_mode
+ prev_path_hash_mode_supported = radio_manager._path_hash_mode_supported
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
+ radio_manager._path_hash_mode = prev_path_hash_mode
+ radio_manager._path_hash_mode_supported = prev_path_hash_mode_supported
def _mock_meshcore_with_info():
@@ -82,10 +86,10 @@ class TestGetRadioConfig:
@pytest.mark.asyncio
async def test_maps_self_info_to_response(self):
mc = _mock_meshcore_with_info()
+ 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),
- patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
):
response = await get_radio_config()
@@ -97,6 +101,7 @@ class TestGetRadioConfig:
assert response.radio.cr == 5
assert response.path_hash_mode == 1
assert response.path_hash_mode_supported is True
+ mc.commands.send_device_query.assert_not_awaited()
@pytest.mark.asyncio
async def test_returns_503_when_self_info_missing(self):
@@ -105,7 +110,6 @@ class TestGetRadioConfig:
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 get_radio_config()
@@ -115,17 +119,17 @@ class TestGetRadioConfig:
@pytest.mark.asyncio
async def test_marks_path_hash_mode_unsupported_when_device_info_lacks_field(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)),
):
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:
@@ -167,6 +171,7 @@ class TestUpdateRadioConfig:
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",
@@ -197,6 +202,7 @@ class TestUpdateRadioConfig:
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),
diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py
index 834ae41..c6d8c30 100644
--- a/tests/test_send_messages.py
+++ b/tests/test_send_messages.py
@@ -30,9 +30,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
+ prev_path_hash_mode = radio_manager._path_hash_mode
+ prev_path_hash_mode_supported = radio_manager._path_hash_mode_supported
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
+ radio_manager._path_hash_mode = prev_path_hash_mode
+ radio_manager._path_hash_mode_supported = prev_path_hash_mode_supported
def _make_radio_result(payload=None):
@@ -158,6 +162,37 @@ class TestOutgoingDMBroadcast:
assert add_contact_arg["out_path_len"] == 2
assert add_contact_arg["out_path_hash_mode"] == 1
+ @pytest.mark.asyncio
+ async def test_send_dm_uses_persisted_out_path_hash_mode_when_present(self, test_db):
+ mc = _make_mc()
+ pub_key = "ef" * 32
+ await ContactRepository.upsert(
+ {
+ "public_key": pub_key,
+ "name": "Carol",
+ "type": 0,
+ "flags": 0,
+ "last_path": "11223344",
+ "last_path_len": 2,
+ "out_path_hash_mode": 0,
+ "last_advert": None,
+ "lat": None,
+ "lon": None,
+ "last_seen": None,
+ "on_radio": False,
+ "last_contacted": None,
+ }
+ )
+
+ with (
+ patch("app.routers.messages.require_connected", return_value=mc),
+ patch.object(radio_manager, "_meshcore", mc),
+ ):
+ await send_direct_message(SendDirectMessageRequest(destination=pub_key, text="hi"))
+
+ add_contact_arg = mc.commands.add_contact.await_args.args[0]
+ assert add_contact_arg["out_path_hash_mode"] == 0
+
class TestOutgoingChannelBroadcast:
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""