mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-27 21:41:02 +02:00
Track advert path and use in mesh visualizer
Track advert path and use in mesh visualizer
This commit is contained in:
@@ -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);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user