mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
Track advert path and use in mesh visualizer
Track advert path and use in mesh visualizer
This commit is contained in:
@@ -110,6 +110,15 @@ Raw packet handling uses two identities by design:
|
|||||||
|
|
||||||
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
|
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
|
||||||
|
|
||||||
|
## Repeater Advert Path Memory
|
||||||
|
|
||||||
|
To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per repeater in a dedicated table (`repeater_advert_paths`).
|
||||||
|
|
||||||
|
- This is independent of raw-packet payload deduplication.
|
||||||
|
- Paths are keyed per repeater + path, with `heard_count`, `first_seen`, and `last_seen`.
|
||||||
|
- Only the N most recent unique paths are retained per repeater (currently 10).
|
||||||
|
- See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
### Incoming Messages
|
### Incoming Messages
|
||||||
@@ -259,7 +268,9 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||||
| GET | `/api/contacts` | List contacts |
|
| GET | `/api/contacts` | List contacts |
|
||||||
|
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all repeaters |
|
||||||
| GET | `/api/contacts/{key}` | Get contact by public key or prefix |
|
| GET | `/api/contacts/{key}` | Get contact by public key or prefix |
|
||||||
|
| GET | `/api/contacts/{key}/advert-paths` | List recent unique advert paths for one repeater |
|
||||||
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
||||||
| DELETE | `/api/contacts/{key}` | Delete contact |
|
| DELETE | `/api/contacts/{key}` | Delete contact |
|
||||||
| POST | `/api/contacts/sync` | Pull from radio |
|
| POST | `/api/contacts/sync` | Pull from radio |
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ CREATE TABLE IF NOT EXISTS raw_packets (
|
|||||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
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_conversation ON messages(type, conversation_key);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
|
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 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 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_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)
|
await set_version(conn, 21)
|
||||||
applied += 1
|
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:
|
if applied > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
"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()
|
await conn.commit()
|
||||||
logger.debug("Clamped advert_interval to minimum 3600 seconds")
|
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
|
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):
|
class Channel(BaseModel):
|
||||||
key: str = Field(description="Channel key (32-char hex)")
|
key: str = Field(description="Channel key (32-char hex)")
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from app.repository import (
|
|||||||
ContactRepository,
|
ContactRepository,
|
||||||
MessageRepository,
|
MessageRepository,
|
||||||
RawPacketRepository,
|
RawPacketRepository,
|
||||||
|
RepeaterAdvertPathRepository,
|
||||||
)
|
)
|
||||||
from app.websocket import broadcast_error, broadcast_event
|
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)
|
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 = {
|
contact_data = {
|
||||||
"public_key": advert.public_key.lower(),
|
"public_key": advert.public_key.lower(),
|
||||||
"name": advert.name,
|
"name": advert.name,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from app.models import (
|
|||||||
Favorite,
|
Favorite,
|
||||||
Message,
|
Message,
|
||||||
MessagePath,
|
MessagePath,
|
||||||
|
RepeaterAdvertPath,
|
||||||
|
RepeaterAdvertPathSummary,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -241,6 +243,113 @@ class ContactRepository:
|
|||||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
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:
|
class ChannelRepository:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def upsert(key: str, name: str, is_hashtag: bool = False, on_radio: bool = False) -> None:
|
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,
|
Contact,
|
||||||
CreateContactRequest,
|
CreateContactRequest,
|
||||||
NeighborInfo,
|
NeighborInfo,
|
||||||
|
RepeaterAdvertPath,
|
||||||
|
RepeaterAdvertPathSummary,
|
||||||
TelemetryRequest,
|
TelemetryRequest,
|
||||||
TelemetryResponse,
|
TelemetryResponse,
|
||||||
TraceResponse,
|
TraceResponse,
|
||||||
)
|
)
|
||||||
from app.packet_processor import start_historical_dm_decryption
|
from app.packet_processor import start_historical_dm_decryption
|
||||||
from app.radio import radio_manager
|
from app.radio import radio_manager
|
||||||
from app.repository import AmbiguousPublicKeyPrefixError, ContactRepository, MessageRepository
|
from app.repository import (
|
||||||
|
AmbiguousPublicKeyPrefixError,
|
||||||
|
ContactRepository,
|
||||||
|
MessageRepository,
|
||||||
|
RepeaterAdvertPathRepository,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from meshcore.events import Event
|
from meshcore.events import Event
|
||||||
@@ -182,6 +189,16 @@ async def list_contacts(
|
|||||||
return await ContactRepository.get_all(limit=limit, offset=offset)
|
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)
|
@router.post("", response_model=Contact)
|
||||||
async def create_contact(
|
async def create_contact(
|
||||||
request: CreateContactRequest, background_tasks: BackgroundTasks
|
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)
|
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")
|
@router.post("/sync")
|
||||||
async def sync_contacts_from_radio() -> dict:
|
async def sync_contacts_from_radio() -> dict:
|
||||||
"""Sync contacts from the radio to the database."""
|
"""Sync contacts from the radio to the database."""
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
MigratePreferencesResponse,
|
MigratePreferencesResponse,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
|
RepeaterAdvertPath,
|
||||||
|
RepeaterAdvertPathSummary,
|
||||||
StatisticsResponse,
|
StatisticsResponse,
|
||||||
TelemetryResponse,
|
TelemetryResponse,
|
||||||
TraceResponse,
|
TraceResponse,
|
||||||
@@ -94,6 +96,12 @@ export const api = {
|
|||||||
// Contacts
|
// Contacts
|
||||||
getContacts: (limit = 100, offset = 0) =>
|
getContacts: (limit = 100, offset = 0) =>
|
||||||
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
|
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
|
||||||
|
getRepeaterAdvertPaths: (limitPerRepeater = 10) =>
|
||||||
|
fetchJson<RepeaterAdvertPathSummary[]>(
|
||||||
|
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||||
|
),
|
||||||
|
getContactAdvertPaths: (publicKey: string, limit = 10) =>
|
||||||
|
fetchJson<RepeaterAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
|
||||||
deleteContact: (publicKey: string) =>
|
deleteContact: (publicKey: string) =>
|
||||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -174,6 +174,23 @@ function resolveNode(source, isRepeater, showAmbiguous): string | null {
|
|||||||
|
|
||||||
When only a 1-byte prefix is known (from packet path bytes), the node is marked ambiguous and shown with a `?` prefix and gray styling. However, if the node is identified as a repeater (via advert or path hop), it shows blue regardless of ambiguity.
|
When only a 1-byte prefix is known (from packet path bytes), the node is marked ambiguous and shown with a `?` prefix and gray styling. However, if the node is identified as a repeater (via advert or path hop), it shows blue regardless of ambiguity.
|
||||||
|
|
||||||
|
### Advert-Path Identity Hints
|
||||||
|
|
||||||
|
**Problem:** When multiple repeaters share a 1-byte prefix, the visualizer can't tell which physical repeater a path hop refers to.
|
||||||
|
|
||||||
|
**Solution:** The backend tracks recent unique advertisement paths per repeater in `repeater_advert_paths` (see root `AGENTS.md` § "Repeater Advert Path Memory"). On mount (and when new contacts appear), the visualizer fetches this data via `GET /api/contacts/repeaters/advert-paths` and builds an index keyed by 12-char prefix.
|
||||||
|
|
||||||
|
**Scoring:** `pickLikelyRepeaterByAdvertPath(candidates, nextPrefix)` scores each candidate repeater by how often its stored advert paths' `next_hop` matches the packet's actual next-hop prefix. It requires:
|
||||||
|
|
||||||
|
- At least 2 matching observations (stronger-than-trivial evidence)
|
||||||
|
- The top candidate's match score must be at least 2x the runner-up's
|
||||||
|
|
||||||
|
When a winner is found, the ambiguous node gets a `probableIdentity` label (the likely repeater's name) and the display name updates accordingly. The remaining candidates are listed as "Other possible" in the tooltip.
|
||||||
|
|
||||||
|
**Interaction with traffic splitting:** Advert-path hints run first. If a probable identity is found, the display name is set. Traffic splitting can still produce separate node IDs (`?XX:>YY`), but won't overwrite the advert-path display name.
|
||||||
|
|
||||||
|
**Toggle:** "Use repeater advert-path identity hints" checkbox (enabled by default, disabled when ambiguous repeaters are hidden).
|
||||||
|
|
||||||
### Traffic Pattern Splitting (Experimental)
|
### Traffic Pattern Splitting (Experimental)
|
||||||
|
|
||||||
**Problem:** Multiple physical repeaters can share the same 1-byte prefix (collision). Since packet paths only contain 1-byte hashes, we can't directly distinguish them. However, traffic patterns provide a heuristic.
|
**Problem:** Multiple physical repeaters can share the same 1-byte prefix (collision). Since packet paths only contain 1-byte hashes, we can't directly distinguish them. However, traffic patterns provide a heuristic.
|
||||||
@@ -292,6 +309,7 @@ function buildPath(parsed, packet, myPrefix): string[] {
|
|||||||
| -------------------------- | ------- | --------------------------------------------------------- |
|
| -------------------------- | ------- | --------------------------------------------------------- |
|
||||||
| Ambiguous repeaters | On | Show nodes when only partial prefix known |
|
| Ambiguous repeaters | On | Show nodes when only partial prefix known |
|
||||||
| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
|
| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
|
||||||
|
| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters |
|
||||||
| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
|
| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
|
||||||
| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
|
| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
|
||||||
| Let 'em drift | On | Continuous layout optimization |
|
| Let 'em drift | On | Continuous layout optimization |
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ import {
|
|||||||
} from 'd3-force-3d';
|
} from 'd3-force-3d';
|
||||||
import type { SimulationLinkDatum } from 'd3-force';
|
import type { SimulationLinkDatum } from 'd3-force';
|
||||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||||
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket, type RadioConfig } from '../types';
|
import { api } from '../api';
|
||||||
|
import {
|
||||||
|
CONTACT_TYPE_REPEATER,
|
||||||
|
type Contact,
|
||||||
|
type RawPacket,
|
||||||
|
type RadioConfig,
|
||||||
|
type RepeaterAdvertPathSummary,
|
||||||
|
} from '../types';
|
||||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||||
import { Checkbox } from './ui/checkbox';
|
import { Checkbox } from './ui/checkbox';
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +57,7 @@ interface GraphNode extends SimulationNodeDatum3D {
|
|||||||
isAmbiguous: boolean;
|
isAmbiguous: boolean;
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
lastSeen?: number | null;
|
lastSeen?: number | null;
|
||||||
|
probableIdentity?: string | null;
|
||||||
ambiguousNames?: string[];
|
ambiguousNames?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +118,10 @@ interface UseVisualizerData3DOptions {
|
|||||||
packets: RawPacket[];
|
packets: RawPacket[];
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
config: RadioConfig | null;
|
config: RadioConfig | null;
|
||||||
|
repeaterAdvertPaths: RepeaterAdvertPathSummary[];
|
||||||
showAmbiguousPaths: boolean;
|
showAmbiguousPaths: boolean;
|
||||||
showAmbiguousNodes: boolean;
|
showAmbiguousNodes: boolean;
|
||||||
|
useAdvertPathHints: boolean;
|
||||||
splitAmbiguousByTraffic: boolean;
|
splitAmbiguousByTraffic: boolean;
|
||||||
chargeStrength: number;
|
chargeStrength: number;
|
||||||
letEmDrift: boolean;
|
letEmDrift: boolean;
|
||||||
@@ -133,8 +143,10 @@ function useVisualizerData3D({
|
|||||||
packets,
|
packets,
|
||||||
contacts,
|
contacts,
|
||||||
config,
|
config,
|
||||||
|
repeaterAdvertPaths,
|
||||||
showAmbiguousPaths,
|
showAmbiguousPaths,
|
||||||
showAmbiguousNodes,
|
showAmbiguousNodes,
|
||||||
|
useAdvertPathHints,
|
||||||
splitAmbiguousByTraffic,
|
splitAmbiguousByTraffic,
|
||||||
chargeStrength,
|
chargeStrength,
|
||||||
letEmDrift,
|
letEmDrift,
|
||||||
@@ -182,6 +194,15 @@ function useVisualizerData3D({
|
|||||||
return { byPrefix12, byName, byPrefix };
|
return { byPrefix12, byName, byPrefix };
|
||||||
}, [contacts]);
|
}, [contacts]);
|
||||||
|
|
||||||
|
const advertPathIndex = useMemo(() => {
|
||||||
|
const byRepeater = new Map<string, RepeaterAdvertPathSummary['paths']>();
|
||||||
|
for (const summary of repeaterAdvertPaths) {
|
||||||
|
const key = summary.repeater_key.slice(0, 12).toLowerCase();
|
||||||
|
byRepeater.set(key, summary.paths);
|
||||||
|
}
|
||||||
|
return { byRepeater };
|
||||||
|
}, [repeaterAdvertPaths]);
|
||||||
|
|
||||||
// Keep refs in sync with props
|
// Keep refs in sync with props
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
speedMultiplierRef.current = particleSpeedMultiplier;
|
speedMultiplierRef.current = particleSpeedMultiplier;
|
||||||
@@ -304,7 +325,13 @@ function useVisualizerData3D({
|
|||||||
trafficPatternsRef.current.clear();
|
trafficPatternsRef.current.clear();
|
||||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||||
syncSimulation();
|
syncSimulation();
|
||||||
}, [showAmbiguousPaths, showAmbiguousNodes, splitAmbiguousByTraffic, syncSimulation]);
|
}, [
|
||||||
|
showAmbiguousPaths,
|
||||||
|
showAmbiguousNodes,
|
||||||
|
useAdvertPathHints,
|
||||||
|
splitAmbiguousByTraffic,
|
||||||
|
syncSimulation,
|
||||||
|
]);
|
||||||
|
|
||||||
const addNode = useCallback(
|
const addNode = useCallback(
|
||||||
(
|
(
|
||||||
@@ -312,13 +339,15 @@ function useVisualizerData3D({
|
|||||||
name: string | null,
|
name: string | null,
|
||||||
type: NodeType,
|
type: NodeType,
|
||||||
isAmbiguous: boolean,
|
isAmbiguous: boolean,
|
||||||
|
probableIdentity?: string | null,
|
||||||
ambiguousNames?: string[],
|
ambiguousNames?: string[],
|
||||||
lastSeen?: number | null
|
lastSeen?: number | null
|
||||||
) => {
|
) => {
|
||||||
const existing = nodesRef.current.get(id);
|
const existing = nodesRef.current.get(id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.lastActivity = Date.now();
|
existing.lastActivity = Date.now();
|
||||||
if (name && !existing.name) existing.name = name;
|
if (name) existing.name = name;
|
||||||
|
if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity;
|
||||||
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
|
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
|
||||||
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
||||||
} else {
|
} else {
|
||||||
@@ -332,6 +361,7 @@ function useVisualizerData3D({
|
|||||||
type,
|
type,
|
||||||
isAmbiguous,
|
isAmbiguous,
|
||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
|
probableIdentity,
|
||||||
lastSeen,
|
lastSeen,
|
||||||
ambiguousNames,
|
ambiguousNames,
|
||||||
x: r * Math.sin(phi) * Math.cos(theta),
|
x: r * Math.sin(phi) * Math.cos(theta),
|
||||||
@@ -378,6 +408,48 @@ function useVisualizerData3D({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const pickLikelyRepeaterByAdvertPath = useCallback(
|
||||||
|
(candidates: Contact[], nextPrefix: string | null) => {
|
||||||
|
const nextHop = nextPrefix?.toLowerCase() ?? null;
|
||||||
|
const scored = candidates
|
||||||
|
.map((candidate) => {
|
||||||
|
const prefix12 = candidate.public_key.slice(0, 12).toLowerCase();
|
||||||
|
const paths = advertPathIndex.byRepeater.get(prefix12) ?? [];
|
||||||
|
let matchScore = 0;
|
||||||
|
let totalScore = 0;
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
totalScore += path.heard_count;
|
||||||
|
const pathNextHop = path.next_hop?.toLowerCase() ?? null;
|
||||||
|
if (pathNextHop === nextHop) {
|
||||||
|
matchScore += path.heard_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { candidate, matchScore, totalScore };
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.totalScore > 0)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.matchScore - a.matchScore ||
|
||||||
|
b.totalScore - a.totalScore ||
|
||||||
|
a.candidate.public_key.localeCompare(b.candidate.public_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scored.length === 0) return null;
|
||||||
|
|
||||||
|
const top = scored[0];
|
||||||
|
const second = scored[1] ?? null;
|
||||||
|
|
||||||
|
// Require stronger-than-trivial evidence and a clear winner.
|
||||||
|
if (top.matchScore < 2) return null;
|
||||||
|
if (second && top.matchScore < second.matchScore * 2) return null;
|
||||||
|
|
||||||
|
return top.candidate;
|
||||||
|
},
|
||||||
|
[advertPathIndex]
|
||||||
|
);
|
||||||
|
|
||||||
const resolveNode = useCallback(
|
const resolveNode = useCallback(
|
||||||
(
|
(
|
||||||
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
||||||
@@ -397,6 +469,7 @@ function useVisualizerData3D({
|
|||||||
getNodeType(contact),
|
getNodeType(contact),
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
contact?.last_seen
|
contact?.last_seen
|
||||||
);
|
);
|
||||||
return nodeId;
|
return nodeId;
|
||||||
@@ -407,11 +480,19 @@ function useVisualizerData3D({
|
|||||||
if (contact) {
|
if (contact) {
|
||||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||||
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
|
addNode(
|
||||||
|
nodeId,
|
||||||
|
contact.name,
|
||||||
|
getNodeType(contact),
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
contact.last_seen
|
||||||
|
);
|
||||||
return nodeId;
|
return nodeId;
|
||||||
}
|
}
|
||||||
const nodeId = `name:${source.value}`;
|
const nodeId = `name:${source.value}`;
|
||||||
addNode(nodeId, source.value, 'client', false);
|
addNode(nodeId, source.value, 'client', false, undefined);
|
||||||
return nodeId;
|
return nodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +502,15 @@ function useVisualizerData3D({
|
|||||||
if (contact) {
|
if (contact) {
|
||||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||||
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
|
addNode(
|
||||||
|
nodeId,
|
||||||
|
contact.name,
|
||||||
|
getNodeType(contact),
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
contact.last_seen
|
||||||
|
);
|
||||||
return nodeId;
|
return nodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +522,7 @@ function useVisualizerData3D({
|
|||||||
if (filtered.length === 1) {
|
if (filtered.length === 1) {
|
||||||
const c = filtered[0];
|
const c = filtered[0];
|
||||||
const nodeId = c.public_key.slice(0, 12).toLowerCase();
|
const nodeId = c.public_key.slice(0, 12).toLowerCase();
|
||||||
addNode(nodeId, c.name, getNodeType(c), false, undefined, c.last_seen);
|
addNode(nodeId, c.name, getNodeType(c), false, undefined, undefined, c.last_seen);
|
||||||
return nodeId;
|
return nodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +535,20 @@ function useVisualizerData3D({
|
|||||||
|
|
||||||
let nodeId = `?${source.value.toLowerCase()}`;
|
let nodeId = `?${source.value.toLowerCase()}`;
|
||||||
let displayName = source.value.toUpperCase();
|
let displayName = source.value.toUpperCase();
|
||||||
|
let probableIdentity: string | null = null;
|
||||||
|
let ambiguousNames = names.length > 0 ? names : undefined;
|
||||||
|
|
||||||
|
if (useAdvertPathHints && isRepeater && trafficContext) {
|
||||||
|
const likely = pickLikelyRepeaterByAdvertPath(filtered, trafficContext.nextPrefix);
|
||||||
|
if (likely) {
|
||||||
|
const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase();
|
||||||
|
probableIdentity = likelyName;
|
||||||
|
displayName = likelyName;
|
||||||
|
ambiguousNames = filtered
|
||||||
|
.filter((c) => c.public_key !== likely.public_key)
|
||||||
|
.map((c) => c.name || c.public_key.slice(0, 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
if (splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
||||||
const prefix = source.value.toLowerCase();
|
const prefix = source.value.toLowerCase();
|
||||||
@@ -465,7 +568,9 @@ function useVisualizerData3D({
|
|||||||
if (analysis.shouldSplit && trafficContext.nextPrefix) {
|
if (analysis.shouldSplit && trafficContext.nextPrefix) {
|
||||||
const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase();
|
const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase();
|
||||||
nodeId = `?${prefix}:>${nextShort}`;
|
nodeId = `?${prefix}:>${nextShort}`;
|
||||||
displayName = `${source.value.toUpperCase()}:>${nextShort}`;
|
if (!probableIdentity) {
|
||||||
|
displayName = `${source.value.toUpperCase()}:>${nextShort}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,7 +580,8 @@ function useVisualizerData3D({
|
|||||||
displayName,
|
displayName,
|
||||||
isRepeater ? 'repeater' : 'client',
|
isRepeater ? 'repeater' : 'client',
|
||||||
true,
|
true,
|
||||||
names.length > 0 ? names : undefined,
|
probableIdentity,
|
||||||
|
ambiguousNames,
|
||||||
lastSeen
|
lastSeen
|
||||||
);
|
);
|
||||||
return nodeId;
|
return nodeId;
|
||||||
@@ -484,7 +590,13 @@ function useVisualizerData3D({
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[contactIndex, addNode, splitAmbiguousByTraffic]
|
[
|
||||||
|
contactIndex,
|
||||||
|
addNode,
|
||||||
|
useAdvertPathHints,
|
||||||
|
pickLikelyRepeaterByAdvertPath,
|
||||||
|
splitAmbiguousByTraffic,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const buildPath = useCallback(
|
const buildPath = useCallback(
|
||||||
@@ -892,6 +1004,7 @@ export function PacketVisualizer3D({
|
|||||||
// Options
|
// Options
|
||||||
const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true);
|
const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true);
|
||||||
const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false);
|
const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false);
|
||||||
|
const [useAdvertPathHints, setUseAdvertPathHints] = useState(true);
|
||||||
const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(true);
|
const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(true);
|
||||||
const [chargeStrength, setChargeStrength] = useState(-200);
|
const [chargeStrength, setChargeStrength] = useState(-200);
|
||||||
const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC);
|
const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC);
|
||||||
@@ -900,6 +1013,31 @@ export function PacketVisualizer3D({
|
|||||||
const [showControls, setShowControls] = useState(true);
|
const [showControls, setShowControls] = useState(true);
|
||||||
const [autoOrbit, setAutoOrbit] = useState(false);
|
const [autoOrbit, setAutoOrbit] = useState(false);
|
||||||
const [pruneStaleNodes, setPruneStaleNodes] = useState(false);
|
const [pruneStaleNodes, setPruneStaleNodes] = useState(false);
|
||||||
|
const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState<RepeaterAdvertPathSummary[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadRepeaterAdvertPaths() {
|
||||||
|
try {
|
||||||
|
const data = await api.getRepeaterAdvertPaths(10);
|
||||||
|
if (!cancelled) {
|
||||||
|
setRepeaterAdvertPaths(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
// Best-effort hinting; keep visualizer fully functional without this data.
|
||||||
|
console.debug('Failed to load repeater advert path hints', error);
|
||||||
|
setRepeaterAdvertPaths([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRepeaterAdvertPaths();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [contacts.length]);
|
||||||
|
|
||||||
// Hover & click-to-pin
|
// Hover & click-to-pin
|
||||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||||
@@ -914,8 +1052,10 @@ export function PacketVisualizer3D({
|
|||||||
packets,
|
packets,
|
||||||
contacts,
|
contacts,
|
||||||
config,
|
config,
|
||||||
|
repeaterAdvertPaths,
|
||||||
showAmbiguousPaths,
|
showAmbiguousPaths,
|
||||||
showAmbiguousNodes,
|
showAmbiguousNodes,
|
||||||
|
useAdvertPathHints,
|
||||||
splitAmbiguousByTraffic,
|
splitAmbiguousByTraffic,
|
||||||
chargeStrength,
|
chargeStrength,
|
||||||
letEmDrift,
|
letEmDrift,
|
||||||
@@ -1228,9 +1368,7 @@ export function PacketVisualizer3D({
|
|||||||
if (nd.labelDiv.style.color !== labelColor) {
|
if (nd.labelDiv.style.color !== labelColor) {
|
||||||
nd.labelDiv.style.color = labelColor;
|
nd.labelDiv.style.color = labelColor;
|
||||||
}
|
}
|
||||||
const labelText = node.isAmbiguous
|
const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
||||||
? node.id
|
|
||||||
: node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
|
||||||
if (nd.labelDiv.textContent !== labelText) {
|
if (nd.labelDiv.textContent !== labelText) {
|
||||||
nd.labelDiv.textContent = labelText;
|
nd.labelDiv.textContent = labelText;
|
||||||
}
|
}
|
||||||
@@ -1535,6 +1673,19 @@ export function PacketVisualizer3D({
|
|||||||
Show ambiguous sender/recipient
|
Show ambiguous sender/recipient
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={useAdvertPathHints}
|
||||||
|
onCheckedChange={(c) => setUseAdvertPathHints(c === true)}
|
||||||
|
disabled={!showAmbiguousPaths}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
title="Use stored repeater advert paths to assign likely identity labels for ambiguous repeater nodes"
|
||||||
|
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||||
|
>
|
||||||
|
Use repeater advert-path identity hints
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={splitAmbiguousByTraffic}
|
checked={splitAmbiguousByTraffic}
|
||||||
@@ -1702,9 +1853,13 @@ export function PacketVisualizer3D({
|
|||||||
Type: {node.type}
|
Type: {node.type}
|
||||||
{node.isAmbiguous ? ' (ambiguous)' : ''}
|
{node.isAmbiguous ? ' (ambiguous)' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
{node.probableIdentity && (
|
||||||
|
<div className="text-muted-foreground">Probably: {node.probableIdentity}</div>
|
||||||
|
)}
|
||||||
{node.ambiguousNames && node.ambiguousNames.length > 0 && (
|
{node.ambiguousNames && node.ambiguousNames.length > 0 && (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
Possible: {node.ambiguousNames.join(', ')}
|
{node.probableIdentity ? 'Other possible: ' : 'Possible: '}
|
||||||
|
{node.ambiguousNames.join(', ')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{neighbors.length > 0 && (
|
{neighbors.length > 0 && (
|
||||||
|
|||||||
@@ -106,6 +106,19 @@ describe('fetchJson (via api methods)', () => {
|
|||||||
const [url] = mockFetch.mock.calls[0];
|
const [url] = mockFetch.mock.calls[0];
|
||||||
expect(url).toBe('/api/contacts?limit=100&offset=0');
|
expect(url).toBe('/api/contacts?limit=100&offset=0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds repeater advert path endpoint query', async () => {
|
||||||
|
installMockFetch();
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.getRepeaterAdvertPaths(12);
|
||||||
|
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ export interface Contact {
|
|||||||
last_read_at: number | null;
|
last_read_at: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RepeaterAdvertPath {
|
||||||
|
path: string;
|
||||||
|
path_len: number;
|
||||||
|
next_hop: string | null;
|
||||||
|
first_seen: number;
|
||||||
|
last_seen: number;
|
||||||
|
heard_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepeaterAdvertPathSummary {
|
||||||
|
repeater_key: string;
|
||||||
|
paths: RepeaterAdvertPath[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from meshcore import EventType
|
|||||||
|
|
||||||
from app.database import Database
|
from app.database import Database
|
||||||
from app.radio import radio_manager
|
from app.radio import radio_manager
|
||||||
from app.repository import ContactRepository, MessageRepository
|
from app.repository import ContactRepository, MessageRepository, RepeaterAdvertPathRepository
|
||||||
|
|
||||||
# Sample 64-char hex public keys for testing
|
# Sample 64-char hex public keys for testing
|
||||||
KEY_A = "aa" * 32 # aaaa...aa
|
KEY_A = "aa" * 32 # aaaa...aa
|
||||||
@@ -208,6 +208,50 @@ class TestGetContact:
|
|||||||
assert "ambiguous" in response.json()["detail"].lower()
|
assert "ambiguous" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdvertPaths:
|
||||||
|
"""Test repeater advert path endpoints."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_repeater_advert_paths(self, test_db, client):
|
||||||
|
repeater_key = KEY_A
|
||||||
|
await _insert_contact(repeater_key, "R1", type=2)
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(repeater_key, "1122", 1000)
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(repeater_key, "3344", 1010)
|
||||||
|
|
||||||
|
response = await client.get("/api/contacts/repeaters/advert-paths?limit_per_repeater=1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["repeater_key"] == repeater_key
|
||||||
|
assert len(data[0]["paths"]) == 1
|
||||||
|
assert data[0]["paths"][0]["path"] == "3344"
|
||||||
|
assert data[0]["paths"][0]["next_hop"] == "33"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_contact_advert_paths_for_repeater(self, test_db, client):
|
||||||
|
repeater_key = KEY_A
|
||||||
|
await _insert_contact(repeater_key, "R1", type=2)
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(repeater_key, "", 1000)
|
||||||
|
|
||||||
|
response = await client.get(f"/api/contacts/{repeater_key}/advert-paths")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["path"] == ""
|
||||||
|
assert data[0]["next_hop"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_contact_advert_paths_rejects_non_repeater(self, test_db, client):
|
||||||
|
await _insert_contact(KEY_A, "Alice", type=1)
|
||||||
|
|
||||||
|
response = await client.get(f"/api/contacts/{KEY_A}/advert-paths")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "not a repeater" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
class TestMarkRead:
|
class TestMarkRead:
|
||||||
"""Test POST /api/contacts/{public_key}/mark-read."""
|
"""Test POST /api/contacts/{public_key}/mark-read."""
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -100,8 +100,8 @@ class TestMigration001:
|
|||||||
# Run migrations
|
# Run migrations
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied == 21 # All migrations run
|
assert applied == 22 # All migrations run
|
||||||
assert await get_version(conn) == 21
|
assert await get_version(conn) == 22
|
||||||
|
|
||||||
# Verify columns exist by inserting and selecting
|
# Verify columns exist by inserting and selecting
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@@ -183,9 +183,9 @@ class TestMigration001:
|
|||||||
applied1 = await run_migrations(conn)
|
applied1 = await run_migrations(conn)
|
||||||
applied2 = await run_migrations(conn)
|
applied2 = await run_migrations(conn)
|
||||||
|
|
||||||
assert applied1 == 21 # All 21 migrations run
|
assert applied1 == 22 # All migrations run
|
||||||
assert applied2 == 0 # No migrations on second run
|
assert applied2 == 0 # No migrations on second run
|
||||||
assert await get_version(conn) == 21
|
assert await get_version(conn) == 22
|
||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
@@ -246,8 +246,8 @@ class TestMigration001:
|
|||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
|
|
||||||
# All migrations applied (version incremented) but no error
|
# All migrations applied (version incremented) but no error
|
||||||
assert applied == 21
|
assert applied == 22
|
||||||
assert await get_version(conn) == 21
|
assert await get_version(conn) == 22
|
||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
@@ -374,10 +374,10 @@ class TestMigration013:
|
|||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
# Run migration 13 (plus 14-21 which also run)
|
# Run migration 13 (plus 14-22 which also run)
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
assert applied == 9
|
assert applied == 10
|
||||||
assert await get_version(conn) == 21
|
assert await get_version(conn) == 22
|
||||||
|
|
||||||
# Verify bots array was created with migrated data
|
# Verify bots array was created with migrated data
|
||||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||||
@@ -497,7 +497,7 @@ class TestMigration018:
|
|||||||
assert await cursor.fetchone() is not None
|
assert await cursor.fetchone() is not None
|
||||||
|
|
||||||
await run_migrations(conn)
|
await run_migrations(conn)
|
||||||
assert await get_version(conn) == 21
|
assert await get_version(conn) == 22
|
||||||
|
|
||||||
# Verify autoindex is gone
|
# Verify autoindex is gone
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
@@ -571,8 +571,8 @@ class TestMigration018:
|
|||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
assert applied == 4 # Migrations 18+19+20+21 run (18+19 skip internally)
|
assert applied == 5 # Migrations 18+19+20+21+22 run (18+19 skip internally)
|
||||||
assert await get_version(conn) == 21
|
assert await get_version(conn) == 22
|
||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
@@ -644,7 +644,7 @@ class TestMigration019:
|
|||||||
assert await cursor.fetchone() is not None
|
assert await cursor.fetchone() is not None
|
||||||
|
|
||||||
await run_migrations(conn)
|
await run_migrations(conn)
|
||||||
assert await get_version(conn) == 21
|
assert await get_version(conn) == 22
|
||||||
|
|
||||||
# Verify autoindex is gone
|
# Verify autoindex is gone
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
@@ -710,8 +710,8 @@ class TestMigration020:
|
|||||||
assert (await cursor.fetchone())[0] == "delete"
|
assert (await cursor.fetchone())[0] == "delete"
|
||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
assert applied == 2 # Migrations 20+21
|
assert applied == 3 # Migrations 20+21+22
|
||||||
assert await get_version(conn) == 21
|
assert await get_version(conn) == 22
|
||||||
|
|
||||||
# Verify WAL mode
|
# Verify WAL mode
|
||||||
cursor = await conn.execute("PRAGMA journal_mode")
|
cursor = await conn.execute("PRAGMA journal_mode")
|
||||||
@@ -741,7 +741,7 @@ class TestMigration020:
|
|||||||
await set_version(conn, 20)
|
await set_version(conn, 20)
|
||||||
|
|
||||||
applied = await run_migrations(conn)
|
applied = await run_migrations(conn)
|
||||||
assert applied == 1 # Migration 21 still runs
|
assert applied == 2 # Migrations 21+22 still run
|
||||||
|
|
||||||
# Still WAL + INCREMENTAL
|
# Still WAL + INCREMENTAL
|
||||||
cursor = await conn.execute("PRAGMA journal_mode")
|
cursor = await conn.execute("PRAGMA journal_mode")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.database import Database
|
from app.database import Database
|
||||||
from app.repository import MessageRepository
|
from app.repository import ContactRepository, MessageRepository, RepeaterAdvertPathRepository
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -298,6 +298,68 @@ class TestMessageRepositoryGetAckCount:
|
|||||||
assert result == 0
|
assert result == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepeaterAdvertPathRepository:
|
||||||
|
"""Test storing and retrieving recent unique repeater advert paths."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_observation_upserts_and_tracks_count(self, test_db):
|
||||||
|
repeater_key = "aa" * 32
|
||||||
|
await ContactRepository.upsert({"public_key": repeater_key, "name": "R1", "type": 2})
|
||||||
|
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(repeater_key, "112233", 1000)
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(repeater_key, "112233", 1010)
|
||||||
|
|
||||||
|
paths = await RepeaterAdvertPathRepository.get_recent_for_repeater(repeater_key, limit=10)
|
||||||
|
assert len(paths) == 1
|
||||||
|
assert paths[0].path == "112233"
|
||||||
|
assert paths[0].path_len == 3
|
||||||
|
assert paths[0].next_hop == "11"
|
||||||
|
assert paths[0].first_seen == 1000
|
||||||
|
assert paths[0].last_seen == 1010
|
||||||
|
assert paths[0].heard_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prunes_to_most_recent_n_unique_paths(self, test_db):
|
||||||
|
repeater_key = "bb" * 32
|
||||||
|
await ContactRepository.upsert({"public_key": repeater_key, "name": "R2", "type": 2})
|
||||||
|
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(
|
||||||
|
repeater_key, "aa", 1000, max_paths_per_repeater=2
|
||||||
|
)
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(
|
||||||
|
repeater_key, "bb", 1001, max_paths_per_repeater=2
|
||||||
|
)
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(
|
||||||
|
repeater_key, "cc", 1002, max_paths_per_repeater=2
|
||||||
|
)
|
||||||
|
|
||||||
|
paths = await RepeaterAdvertPathRepository.get_recent_for_repeater(repeater_key, limit=10)
|
||||||
|
assert [p.path for p in paths] == ["cc", "bb"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recent_for_all_repeaters_respects_limit(self, test_db):
|
||||||
|
repeater_a = "cc" * 32
|
||||||
|
repeater_b = "dd" * 32
|
||||||
|
await ContactRepository.upsert({"public_key": repeater_a, "name": "RA", "type": 2})
|
||||||
|
await ContactRepository.upsert({"public_key": repeater_b, "name": "RB", "type": 2})
|
||||||
|
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(repeater_a, "01", 1000)
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(repeater_a, "02", 1001)
|
||||||
|
await RepeaterAdvertPathRepository.record_observation(repeater_b, "", 1002)
|
||||||
|
|
||||||
|
grouped = await RepeaterAdvertPathRepository.get_recent_for_all_repeaters(
|
||||||
|
limit_per_repeater=1
|
||||||
|
)
|
||||||
|
by_key = {item.repeater_key: item.paths for item in grouped}
|
||||||
|
|
||||||
|
assert repeater_a in by_key
|
||||||
|
assert repeater_b in by_key
|
||||||
|
assert len(by_key[repeater_a]) == 1
|
||||||
|
assert by_key[repeater_a][0].path == "02"
|
||||||
|
assert by_key[repeater_b][0].path == ""
|
||||||
|
assert by_key[repeater_b][0].next_hop is None
|
||||||
|
|
||||||
|
|
||||||
class TestAppSettingsRepository:
|
class TestAppSettingsRepository:
|
||||||
"""Test AppSettingsRepository parsing and migration edge cases."""
|
"""Test AppSettingsRepository parsing and migration edge cases."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user