mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Track advert path and use in mesh visualizer
Track advert path and use in mesh visualizer
This commit is contained in:
11
AGENTS.md
11
AGENTS.md
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
### 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/reconnect` | Manual radio reconnection |
|
||||
| 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}/advert-paths` | List recent unique advert paths for one repeater |
|
||||
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
||||
| DELETE | `/api/contacts/{key}` | Delete contact |
|
||||
| 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)
|
||||
);
|
||||
|
||||
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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
MigratePreferencesResponse,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RepeaterAdvertPath,
|
||||
RepeaterAdvertPathSummary,
|
||||
StatisticsResponse,
|
||||
TelemetryResponse,
|
||||
TraceResponse,
|
||||
@@ -94,6 +96,12 @@ export const api = {
|
||||
// Contacts
|
||||
getContacts: (limit = 100, offset = 0) =>
|
||||
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) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
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.
|
||||
|
||||
### 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)
|
||||
|
||||
**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 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) |
|
||||
| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
|
||||
| Let 'em drift | On | Continuous layout optimization |
|
||||
|
||||
@@ -16,7 +16,14 @@ import {
|
||||
} from 'd3-force-3d';
|
||||
import type { SimulationLinkDatum } from 'd3-force';
|
||||
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 { Checkbox } from './ui/checkbox';
|
||||
import {
|
||||
@@ -50,6 +57,7 @@ interface GraphNode extends SimulationNodeDatum3D {
|
||||
isAmbiguous: boolean;
|
||||
lastActivity: number;
|
||||
lastSeen?: number | null;
|
||||
probableIdentity?: string | null;
|
||||
ambiguousNames?: string[];
|
||||
}
|
||||
|
||||
@@ -110,8 +118,10 @@ interface UseVisualizerData3DOptions {
|
||||
packets: RawPacket[];
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
repeaterAdvertPaths: RepeaterAdvertPathSummary[];
|
||||
showAmbiguousPaths: boolean;
|
||||
showAmbiguousNodes: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
chargeStrength: number;
|
||||
letEmDrift: boolean;
|
||||
@@ -133,8 +143,10 @@ function useVisualizerData3D({
|
||||
packets,
|
||||
contacts,
|
||||
config,
|
||||
repeaterAdvertPaths,
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
letEmDrift,
|
||||
@@ -182,6 +194,15 @@ function useVisualizerData3D({
|
||||
return { byPrefix12, byName, byPrefix };
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
speedMultiplierRef.current = particleSpeedMultiplier;
|
||||
@@ -304,7 +325,13 @@ function useVisualizerData3D({
|
||||
trafficPatternsRef.current.clear();
|
||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||
syncSimulation();
|
||||
}, [showAmbiguousPaths, showAmbiguousNodes, splitAmbiguousByTraffic, syncSimulation]);
|
||||
}, [
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
splitAmbiguousByTraffic,
|
||||
syncSimulation,
|
||||
]);
|
||||
|
||||
const addNode = useCallback(
|
||||
(
|
||||
@@ -312,13 +339,15 @@ function useVisualizerData3D({
|
||||
name: string | null,
|
||||
type: NodeType,
|
||||
isAmbiguous: boolean,
|
||||
probableIdentity?: string | null,
|
||||
ambiguousNames?: string[],
|
||||
lastSeen?: number | null
|
||||
) => {
|
||||
const existing = nodesRef.current.get(id);
|
||||
if (existing) {
|
||||
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 (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
||||
} else {
|
||||
@@ -332,6 +361,7 @@ function useVisualizerData3D({
|
||||
type,
|
||||
isAmbiguous,
|
||||
lastActivity: Date.now(),
|
||||
probableIdentity,
|
||||
lastSeen,
|
||||
ambiguousNames,
|
||||
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(
|
||||
(
|
||||
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
||||
@@ -397,6 +469,7 @@ function useVisualizerData3D({
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact?.last_seen
|
||||
);
|
||||
return nodeId;
|
||||
@@ -407,11 +480,19 @@ function useVisualizerData3D({
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
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;
|
||||
}
|
||||
const nodeId = `name:${source.value}`;
|
||||
addNode(nodeId, source.value, 'client', false);
|
||||
addNode(nodeId, source.value, 'client', false, undefined);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
@@ -421,7 +502,15 @@ function useVisualizerData3D({
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -433,7 +522,7 @@ function useVisualizerData3D({
|
||||
if (filtered.length === 1) {
|
||||
const c = filtered[0];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -446,6 +535,20 @@ function useVisualizerData3D({
|
||||
|
||||
let nodeId = `?${source.value.toLowerCase()}`;
|
||||
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) {
|
||||
const prefix = source.value.toLowerCase();
|
||||
@@ -465,7 +568,9 @@ function useVisualizerData3D({
|
||||
if (analysis.shouldSplit && trafficContext.nextPrefix) {
|
||||
const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase();
|
||||
nodeId = `?${prefix}:>${nextShort}`;
|
||||
displayName = `${source.value.toUpperCase()}:>${nextShort}`;
|
||||
if (!probableIdentity) {
|
||||
displayName = `${source.value.toUpperCase()}:>${nextShort}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,7 +580,8 @@ function useVisualizerData3D({
|
||||
displayName,
|
||||
isRepeater ? 'repeater' : 'client',
|
||||
true,
|
||||
names.length > 0 ? names : undefined,
|
||||
probableIdentity,
|
||||
ambiguousNames,
|
||||
lastSeen
|
||||
);
|
||||
return nodeId;
|
||||
@@ -484,7 +590,13 @@ function useVisualizerData3D({
|
||||
|
||||
return null;
|
||||
},
|
||||
[contactIndex, addNode, splitAmbiguousByTraffic]
|
||||
[
|
||||
contactIndex,
|
||||
addNode,
|
||||
useAdvertPathHints,
|
||||
pickLikelyRepeaterByAdvertPath,
|
||||
splitAmbiguousByTraffic,
|
||||
]
|
||||
);
|
||||
|
||||
const buildPath = useCallback(
|
||||
@@ -892,6 +1004,7 @@ export function PacketVisualizer3D({
|
||||
// Options
|
||||
const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true);
|
||||
const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false);
|
||||
const [useAdvertPathHints, setUseAdvertPathHints] = useState(true);
|
||||
const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(true);
|
||||
const [chargeStrength, setChargeStrength] = useState(-200);
|
||||
const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC);
|
||||
@@ -900,6 +1013,31 @@ export function PacketVisualizer3D({
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [autoOrbit, setAutoOrbit] = 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
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
@@ -914,8 +1052,10 @@ export function PacketVisualizer3D({
|
||||
packets,
|
||||
contacts,
|
||||
config,
|
||||
repeaterAdvertPaths,
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
letEmDrift,
|
||||
@@ -1228,9 +1368,7 @@ export function PacketVisualizer3D({
|
||||
if (nd.labelDiv.style.color !== labelColor) {
|
||||
nd.labelDiv.style.color = labelColor;
|
||||
}
|
||||
const labelText = node.isAmbiguous
|
||||
? node.id
|
||||
: node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
||||
const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
||||
if (nd.labelDiv.textContent !== labelText) {
|
||||
nd.labelDiv.textContent = labelText;
|
||||
}
|
||||
@@ -1535,6 +1673,19 @@ export function PacketVisualizer3D({
|
||||
Show ambiguous sender/recipient
|
||||
</span>
|
||||
</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">
|
||||
<Checkbox
|
||||
checked={splitAmbiguousByTraffic}
|
||||
@@ -1702,9 +1853,13 @@ export function PacketVisualizer3D({
|
||||
Type: {node.type}
|
||||
{node.isAmbiguous ? ' (ambiguous)' : ''}
|
||||
</div>
|
||||
{node.probableIdentity && (
|
||||
<div className="text-muted-foreground">Probably: {node.probableIdentity}</div>
|
||||
)}
|
||||
{node.ambiguousNames && node.ambiguousNames.length > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
Possible: {node.ambiguousNames.join(', ')}
|
||||
{node.probableIdentity ? 'Other possible: ' : 'Possible: '}
|
||||
{node.ambiguousNames.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{neighbors.length > 0 && (
|
||||
|
||||
@@ -106,6 +106,19 @@ describe('fetchJson (via api methods)', () => {
|
||||
const [url] = mockFetch.mock.calls[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', () => {
|
||||
|
||||
@@ -52,6 +52,20 @@ export interface Contact {
|
||||
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 {
|
||||
key: string;
|
||||
name: string;
|
||||
|
||||
@@ -15,7 +15,7 @@ from meshcore import EventType
|
||||
|
||||
from app.database import Database
|
||||
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
|
||||
KEY_A = "aa" * 32 # aaaa...aa
|
||||
@@ -208,6 +208,50 @@ class TestGetContact:
|
||||
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:
|
||||
"""Test POST /api/contacts/{public_key}/mark-read."""
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 21 # All migrations run
|
||||
assert await get_version(conn) == 21
|
||||
assert applied == 22 # All migrations run
|
||||
assert await get_version(conn) == 22
|
||||
|
||||
# Verify columns exist by inserting and selecting
|
||||
await conn.execute(
|
||||
@@ -183,9 +183,9 @@ class TestMigration001:
|
||||
applied1 = 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 await get_version(conn) == 21
|
||||
assert await get_version(conn) == 22
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -246,8 +246,8 @@ class TestMigration001:
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
# All migrations applied (version incremented) but no error
|
||||
assert applied == 21
|
||||
assert await get_version(conn) == 21
|
||||
assert applied == 22
|
||||
assert await get_version(conn) == 22
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -374,10 +374,10 @@ class TestMigration013:
|
||||
)
|
||||
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)
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 21
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 22
|
||||
|
||||
# Verify bots array was created with migrated data
|
||||
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
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 21
|
||||
assert await get_version(conn) == 22
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -571,8 +571,8 @@ class TestMigration018:
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 4 # Migrations 18+19+20+21 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 21
|
||||
assert applied == 5 # Migrations 18+19+20+21+22 run (18+19 skip internally)
|
||||
assert await get_version(conn) == 22
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -644,7 +644,7 @@ class TestMigration019:
|
||||
assert await cursor.fetchone() is not None
|
||||
|
||||
await run_migrations(conn)
|
||||
assert await get_version(conn) == 21
|
||||
assert await get_version(conn) == 22
|
||||
|
||||
# Verify autoindex is gone
|
||||
cursor = await conn.execute(
|
||||
@@ -710,8 +710,8 @@ class TestMigration020:
|
||||
assert (await cursor.fetchone())[0] == "delete"
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 2 # Migrations 20+21
|
||||
assert await get_version(conn) == 21
|
||||
assert applied == 3 # Migrations 20+21+22
|
||||
assert await get_version(conn) == 22
|
||||
|
||||
# Verify WAL mode
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
@@ -741,7 +741,7 @@ class TestMigration020:
|
||||
await set_version(conn, 20)
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 1 # Migration 21 still runs
|
||||
assert applied == 2 # Migrations 21+22 still run
|
||||
|
||||
# Still WAL + INCREMENTAL
|
||||
cursor = await conn.execute("PRAGMA journal_mode")
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from app.database import Database
|
||||
from app.repository import MessageRepository
|
||||
from app.repository import ContactRepository, MessageRepository, RepeaterAdvertPathRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -298,6 +298,68 @@ class TestMessageRepositoryGetAckCount:
|
||||
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:
|
||||
"""Test AppSettingsRepository parsing and migration edge cases."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user