This commit is contained in:
Jack Kingsman
2026-03-07 15:05:13 -08:00
parent f302cc04ae
commit 5f039b9c41
25 changed files with 583 additions and 98 deletions

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ class Contact(BaseModel):
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
out_path_hash_mode: int | None = None
last_advert: int | None = None
lat: float | None = None
lon: float | None = None
@@ -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"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,9 +45,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
prev_path_hash_mode = radio_manager._path_hash_mode
prev_path_hash_mode_supported = radio_manager._path_hash_mode_supported
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
radio_manager._path_hash_mode = prev_path_hash_mode
radio_manager._path_hash_mode_supported = prev_path_hash_mode_supported
def _mock_meshcore_with_info():
@@ -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),

View File

@@ -30,9 +30,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
prev_path_hash_mode = radio_manager._path_hash_mode
prev_path_hash_mode_supported = radio_manager._path_hash_mode_supported
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
radio_manager._path_hash_mode = prev_path_hash_mode
radio_manager._path_hash_mode_supported = prev_path_hash_mode_supported
def _make_radio_result(payload=None):
@@ -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."""