From 5f039b9c415d2608961ca71a1d159194e3cffc49 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 7 Mar 2026 15:05:13 -0800 Subject: [PATCH] Phase 4 --- app/database.py | 1 + app/fanout/community_mqtt.py | 7 +- app/migrations.py | 49 ++++++++ app/models.py | 10 +- app/radio.py | 52 +++++++- app/repository/contacts.py | 30 ++++- app/routers/contacts.py | 2 +- app/routers/radio.py | 75 ++++-------- frontend/src/components/ContactInfoPane.tsx | 9 +- frontend/src/components/CrackerPanel.tsx | 1 + frontend/src/components/RawPacketList.tsx | 1 + frontend/src/main.tsx | 1 + frontend/src/test/contactInfoPane.test.tsx | 72 +++++++++++ .../src/test/meshcoreDecoderPatch.test.ts | 39 ++++++ frontend/src/test/pathUtils.test.ts | 4 + frontend/src/test/rawPacketPayload.test.ts | 4 + frontend/src/types.ts | 1 + frontend/src/utils/meshcoreDecoderPatch.ts | 115 ++++++++++++++++++ frontend/src/utils/visualizerUtils.ts | 1 + tests/test_community_mqtt.py | 4 +- tests/test_decoder.py | 15 +++ tests/test_migrations.py | 103 ++++++++++++---- tests/test_radio.py | 36 ++++++ tests/test_radio_router.py | 14 ++- tests/test_send_messages.py | 35 ++++++ 25 files changed, 583 insertions(+), 98 deletions(-) create mode 100644 frontend/src/test/contactInfoPane.test.tsx create mode 100644 frontend/src/test/meshcoreDecoderPatch.test.ts create mode 100644 frontend/src/utils/meshcoreDecoderPatch.ts 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."""