Track advert path and use in mesh visualizer

Track advert path and use in mesh visualizer
This commit is contained in:
Jack Kingsman
2026-02-24 14:58:21 -08:00
15 changed files with 580 additions and 33 deletions
+14
View File
@@ -59,6 +59,18 @@ CREATE TABLE IF NOT EXISTS raw_packets (
FOREIGN KEY (message_id) REFERENCES messages(id)
);
CREATE TABLE IF NOT EXISTS repeater_advert_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repeater_key TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_len INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(repeater_key, path_hex),
FOREIGN KEY (repeater_key) REFERENCES contacts(public_key)
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(type, conversation_key);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
@@ -66,6 +78,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
CREATE INDEX IF NOT EXISTS idx_repeater_advert_paths_recent
ON repeater_advert_paths(repeater_key, last_seen DESC);
"""
+35
View File
@@ -184,6 +184,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 21)
applied += 1
# Migration 22: Track recent unique advert paths per repeater
if version < 22:
logger.info("Applying migration 22: add repeater_advert_paths table")
await _migrate_022_add_repeater_advert_paths(conn)
await set_version(conn, 22)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -1283,3 +1290,31 @@ async def _migrate_021_enforce_min_advert_interval(conn: aiosqlite.Connection) -
)
await conn.commit()
logger.debug("Clamped advert_interval to minimum 3600 seconds")
async def _migrate_022_add_repeater_advert_paths(conn: aiosqlite.Connection) -> None:
"""
Create table for recent unique advert paths per repeater.
This keeps path diversity for repeater advertisements without changing the
existing payload-hash raw packet dedup policy.
"""
await conn.execute("""
CREATE TABLE IF NOT EXISTS repeater_advert_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repeater_key TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_len INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(repeater_key, path_hex),
FOREIGN KEY (repeater_key) REFERENCES contacts(public_key)
)
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_repeater_advert_paths_recent "
"ON repeater_advert_paths(repeater_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Ensured repeater_advert_paths table and indexes exist")
+22
View File
@@ -72,6 +72,28 @@ class CreateContactRequest(BaseModel):
CONTACT_TYPE_REPEATER = 2
class RepeaterAdvertPath(BaseModel):
"""A unique advert path observed for a repeater."""
path: str = Field(description="Hex-encoded routing path (empty string for direct)")
path_len: int = Field(description="Number of hops in the path")
next_hop: str | None = Field(
default=None, description="First hop toward us (2-char hex), or null for direct"
)
first_seen: int = Field(description="Unix timestamp of first observation")
last_seen: int = Field(description="Unix timestamp of most recent observation")
heard_count: int = Field(description="Number of times this unique path was heard")
class RepeaterAdvertPathSummary(BaseModel):
"""Recent unique advertisement paths for a single repeater."""
repeater_key: str = Field(description="Repeater public key (64-char hex)")
paths: list[RepeaterAdvertPath] = Field(
default_factory=list, description="Most recent unique advert paths"
)
class Channel(BaseModel):
key: str = Field(description="Channel key (32-char hex)")
name: str
+10
View File
@@ -34,6 +34,7 @@ from app.repository import (
ContactRepository,
MessageRepository,
RawPacketRepository,
RepeaterAdvertPathRepository,
)
from app.websocket import broadcast_error, broadcast_event
@@ -688,6 +689,15 @@ async def _process_advertisement(
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
)
# Keep recent unique advert paths for repeaters to improve frontend identity hints.
if contact_type == CONTACT_TYPE_REPEATER:
await RepeaterAdvertPathRepository.record_observation(
repeater_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths_per_repeater=10,
)
contact_data = {
"public_key": advert.public_key.lower(),
"name": advert.name,
+109
View File
@@ -15,6 +15,8 @@ from app.models import (
Favorite,
Message,
MessagePath,
RepeaterAdvertPath,
RepeaterAdvertPathSummary,
)
logger = logging.getLogger(__name__)
@@ -241,6 +243,113 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
class RepeaterAdvertPathRepository:
"""Repository for recent unique repeater advertisement paths."""
@staticmethod
def _row_to_path(row) -> RepeaterAdvertPath:
path = row["path_hex"] or ""
next_hop = path[:2].lower() if len(path) >= 2 else None
return RepeaterAdvertPath(
path=path,
path_len=row["path_len"],
next_hop=next_hop,
first_seen=row["first_seen"],
last_seen=row["last_seen"],
heard_count=row["heard_count"],
)
@staticmethod
async def record_observation(
repeater_key: str, path_hex: str, timestamp: int, max_paths_per_repeater: int = 10
) -> None:
"""
Upsert a unique advert path observation for a repeater and prune to N most recent.
"""
if max_paths_per_repeater < 1:
max_paths_per_repeater = 1
normalized_key = repeater_key.lower()
normalized_path = path_hex.lower()
path_len = len(normalized_path) // 2
await db.conn.execute(
"""
INSERT INTO repeater_advert_paths
(repeater_key, path_hex, path_len, first_seen, last_seen, heard_count)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(repeater_key, path_hex) DO UPDATE SET
last_seen = MAX(repeater_advert_paths.last_seen, excluded.last_seen),
path_len = excluded.path_len,
heard_count = repeater_advert_paths.heard_count + 1
""",
(normalized_key, normalized_path, path_len, timestamp, timestamp),
)
# Keep only the N most recent unique paths per repeater.
await db.conn.execute(
"""
DELETE FROM repeater_advert_paths
WHERE repeater_key = ?
AND path_hex NOT IN (
SELECT path_hex
FROM repeater_advert_paths
WHERE repeater_key = ?
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
LIMIT ?
)
""",
(normalized_key, normalized_key, max_paths_per_repeater),
)
await db.conn.commit()
@staticmethod
async def get_recent_for_repeater(
repeater_key: str, limit: int = 10
) -> list[RepeaterAdvertPath]:
cursor = await db.conn.execute(
"""
SELECT path_hex, path_len, first_seen, last_seen, heard_count
FROM repeater_advert_paths
WHERE repeater_key = ?
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
LIMIT ?
""",
(repeater_key.lower(), limit),
)
rows = await cursor.fetchall()
return [RepeaterAdvertPathRepository._row_to_path(row) for row in rows]
@staticmethod
async def get_recent_for_all_repeaters(
limit_per_repeater: int = 10,
) -> list[RepeaterAdvertPathSummary]:
cursor = await db.conn.execute(
"""
SELECT repeater_key, path_hex, path_len, first_seen, last_seen, heard_count
FROM repeater_advert_paths
ORDER BY repeater_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
"""
)
rows = await cursor.fetchall()
grouped: dict[str, list[RepeaterAdvertPath]] = {}
for row in rows:
repeater_key = row["repeater_key"]
paths = grouped.get(repeater_key)
if paths is None:
paths = []
grouped[repeater_key] = paths
if len(paths) >= limit_per_repeater:
continue
paths.append(RepeaterAdvertPathRepository._row_to_path(row))
return [
RepeaterAdvertPathSummary(repeater_key=repeater_key, paths=paths)
for repeater_key, paths in grouped.items()
]
class ChannelRepository:
@staticmethod
async def upsert(key: str, name: str, is_hashtag: bool = False, on_radio: bool = False) -> None:
+33 -1
View File
@@ -16,13 +16,20 @@ from app.models import (
Contact,
CreateContactRequest,
NeighborInfo,
RepeaterAdvertPath,
RepeaterAdvertPathSummary,
TelemetryRequest,
TelemetryResponse,
TraceResponse,
)
from app.packet_processor import start_historical_dm_decryption
from app.radio import radio_manager
from app.repository import AmbiguousPublicKeyPrefixError, ContactRepository, MessageRepository
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactRepository,
MessageRepository,
RepeaterAdvertPathRepository,
)
if TYPE_CHECKING:
from meshcore.events import Event
@@ -182,6 +189,16 @@ async def list_contacts(
return await ContactRepository.get_all(limit=limit, offset=offset)
@router.get("/repeaters/advert-paths", response_model=list[RepeaterAdvertPathSummary])
async def list_repeater_advert_paths(
limit_per_repeater: int = Query(default=10, ge=1, le=50),
) -> list[RepeaterAdvertPathSummary]:
"""List recent unique advert paths for all repeaters."""
return await RepeaterAdvertPathRepository.get_recent_for_all_repeaters(
limit_per_repeater=limit_per_repeater
)
@router.post("", response_model=Contact)
async def create_contact(
request: CreateContactRequest, background_tasks: BackgroundTasks
@@ -265,6 +282,21 @@ async def get_contact(public_key: str) -> Contact:
return await _resolve_contact_or_404(public_key)
@router.get("/{public_key}/advert-paths", response_model=list[RepeaterAdvertPath])
async def get_contact_advert_paths(
public_key: str,
limit: int = Query(default=10, ge=1, le=50),
) -> list[RepeaterAdvertPath]:
"""List recent unique advert paths for a single repeater contact."""
contact = await _resolve_contact_or_404(public_key)
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})",
)
return await RepeaterAdvertPathRepository.get_recent_for_repeater(contact.public_key, limit)
@router.post("/sync")
async def sync_contacts_from_radio() -> dict:
"""Sync contacts from the radio to the database."""