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
+1
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,
+4 -3
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()
+49
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()
+7 -3
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"),
+51 -1
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:
+24 -6
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
+1 -1
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
+25 -50
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(