mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-04 20:43:03 +02:00
Phase 4
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
52
app/radio.py
52
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './utils/meshcoreDecoderPatch';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
72
frontend/src/test/contactInfoPane.test.tsx
Normal file
72
frontend/src/test/contactInfoPane.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
39
frontend/src/test/meshcoreDecoderPatch.test.ts
Normal file
39
frontend/src/test/meshcoreDecoderPatch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
115
frontend/src/utils/meshcoreDecoderPatch.ts
Normal file
115
frontend/src/utils/meshcoreDecoderPatch.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user