mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-16 12:02:35 +00:00
Add node links, improved path resolution, and confirmed neighbours
- node_links table: stores ITM-viable hop pairs from observed packet paths with directional counts (count_a_to_b / count_b_to_a) and path loss dB - Historical backfill: decodes all stored packets on first run to populate links - Per-node /api/nodes/:id/links endpoint: fetched on popup open (not preloaded) - Node popup: shows confirmed neighbours with directional arrows and path loss - Links layer: amber polylines on map showing all ITM-viable link pairs - Beta path resolver rewritten as backtracking DFS: - Confirmed neighbours tried first (real observed link data) - Falls back to closest node within terrain-derived radio range - Backtracks when a candidate leads to dead ends at subsequent hops - Skips capped at ceil(hops/3) to prevent degenerate all-skip paths - Visited set enforces each node appears at most once per path - hopCount used to slice path_hashes to actual relay count - Viable link pairs sent in WebSocket initial_state for path resolution - LINK_BUDGET_DB raised to 148 dB; fade margin removed from viability check - Viewshed worker: directional link tracking, UK mainland clipping fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,35 @@ router.get('/nodes', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/nodes/:id/links — ITM-viable neighbours for a node
|
||||
router.get('/nodes/:id/links', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id']!;
|
||||
const result = await query<{
|
||||
peer_id: string; peer_name: string | null; observed_count: number;
|
||||
itm_path_loss_db: number | null;
|
||||
count_this_to_peer: number; count_peer_to_this: number;
|
||||
}>(
|
||||
`SELECT
|
||||
CASE WHEN node_a_id = $1 THEN node_b_id ELSE node_a_id END AS peer_id,
|
||||
n.name AS peer_name,
|
||||
observed_count,
|
||||
itm_path_loss_db,
|
||||
CASE WHEN node_a_id = $1 THEN count_a_to_b ELSE count_b_to_a END AS count_this_to_peer,
|
||||
CASE WHEN node_a_id = $1 THEN count_b_to_a ELSE count_a_to_b END AS count_peer_to_this
|
||||
FROM node_links
|
||||
LEFT JOIN nodes n ON n.node_id = CASE WHEN node_a_id = $1 THEN node_b_id ELSE node_a_id END
|
||||
WHERE (node_a_id = $1 OR node_b_id = $1) AND itm_viable = true
|
||||
ORDER BY observed_count DESC`,
|
||||
[id],
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error('[api] GET /nodes/:id/links', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/nodes/:id/history?hours=24
|
||||
router.get('/nodes/:id/history', async (req, res) => {
|
||||
try {
|
||||
|
||||
+14
-4
@@ -81,15 +81,17 @@ export async function insertPacket(p: {
|
||||
payload?: Record<string, unknown>;
|
||||
rawHex: string;
|
||||
advertCount?: number;
|
||||
pathHashes?: string[];
|
||||
}): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO packets
|
||||
(time, packet_hash, rx_node_id, src_node_id, topic, packet_type, route_type,
|
||||
hop_count, rssi, snr, payload, raw_hex, advert_count)
|
||||
VALUES (NOW(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
hop_count, rssi, snr, payload, raw_hex, advert_count, path_hashes)
|
||||
VALUES (NOW(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||
[p.packetHash, p.rxNodeId, p.srcNodeId, p.topic, p.packetType,
|
||||
p.routeType, p.hopCount, p.rssi, p.snr,
|
||||
p.payload ? JSON.stringify(p.payload) : null, p.rawHex, p.advertCount ?? null]
|
||||
p.payload ? JSON.stringify(p.payload) : null, p.rawHex, p.advertCount ?? null,
|
||||
p.pathHashes ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,7 +132,7 @@ export async function getLastNPackets(n: number) {
|
||||
const res = await pool.query(
|
||||
`SELECT * FROM (
|
||||
SELECT DISTINCT ON (packet_hash) time, packet_hash, rx_node_id, src_node_id,
|
||||
packet_type, hop_count, payload, advert_count
|
||||
packet_type, hop_count, payload, advert_count, path_hashes
|
||||
FROM packets
|
||||
WHERE time > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY packet_hash, time DESC
|
||||
@@ -141,4 +143,12 @@ export async function getLastNPackets(n: number) {
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
/** Returns only viable link pairs — compact for sending in initial WebSocket state. */
|
||||
export async function getViableLinkPairs(): Promise<[string, string][]> {
|
||||
const res = await pool.query<{ node_a_id: string; node_b_id: string }>(
|
||||
`SELECT node_a_id, node_b_id FROM node_links WHERE itm_viable = true`
|
||||
);
|
||||
return res.rows.map((r) => [r.node_a_id, r.node_b_id]);
|
||||
}
|
||||
|
||||
export { pool };
|
||||
|
||||
@@ -72,6 +72,7 @@ SELECT add_retention_policy(
|
||||
);
|
||||
|
||||
ALTER TABLE packets ADD COLUMN IF NOT EXISTS advert_count INTEGER;
|
||||
ALTER TABLE packets ADD COLUMN IF NOT EXISTS path_hashes TEXT[];
|
||||
|
||||
-- Indexes for common query patterns
|
||||
CREATE INDEX IF NOT EXISTS packets_hash_idx ON packets (packet_hash, time DESC);
|
||||
@@ -80,6 +81,28 @@ CREATE INDEX IF NOT EXISTS packets_src_idx ON packets (src_node_id, time DESC
|
||||
|
||||
-- ─── Coverage polygons (one row per node, recalculated on position change) ───
|
||||
|
||||
-- ─── Observed + ITM-validated RF links between nodes ─────────────────────────
|
||||
-- Populated by the viewshed worker as real packets with path data arrive.
|
||||
-- node_a_id < node_b_id (sorted) so each pair has exactly one row.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node_links (
|
||||
node_a_id TEXT NOT NULL,
|
||||
node_b_id TEXT NOT NULL,
|
||||
observed_count INTEGER NOT NULL DEFAULT 1,
|
||||
last_observed TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
itm_path_loss_db DOUBLE PRECISION,
|
||||
itm_viable BOOLEAN,
|
||||
itm_computed_at TIMESTAMPTZ,
|
||||
count_a_to_b INTEGER NOT NULL DEFAULT 0,
|
||||
count_b_to_a INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (node_a_id, node_b_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS node_links_b_idx ON node_links(node_b_id);
|
||||
ALTER TABLE node_links ADD COLUMN IF NOT EXISTS count_a_to_b INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE node_links ADD COLUMN IF NOT EXISTS count_b_to_a INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- ─── Coverage polygons (one row per node, recalculated on position change) ───
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node_coverage (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
geom JSONB NOT NULL, -- GeoJSON Polygon or MultiPolygon
|
||||
|
||||
+21
-3
@@ -6,8 +6,8 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { initDb, query } from './db/index.js';
|
||||
import { startMqttClient, onPacket, onNodeSeen, onNodeUpsert } from './mqtt/client.js';
|
||||
import { initWebSocketServer, broadcastPacket, broadcastNodeUpdate, broadcastNodeUpsert, queueViewshedJob } from './ws/server.js';
|
||||
import { startMqttClient, onPacket, onNodeSeen, onNodeUpsert, backfillHistoricalLinks } from './mqtt/client.js';
|
||||
import { initWebSocketServer, broadcastPacket, broadcastNodeUpdate, broadcastNodeUpsert, queueViewshedJob, queueLinkJob } from './ws/server.js';
|
||||
import apiRoutes from './api/routes.js';
|
||||
|
||||
const ALLOWED_ORIGINS = (process.env['ALLOWED_ORIGINS'] ?? '')
|
||||
@@ -45,7 +45,12 @@ async function main() {
|
||||
}
|
||||
|
||||
// 2. Wire up MQTT → WS broadcast
|
||||
onPacket((packet) => broadcastPacket(packet));
|
||||
onPacket((packet) => {
|
||||
broadcastPacket(packet);
|
||||
if (packet.path?.length && packet.rxNodeId) {
|
||||
queueLinkJob(packet.rxNodeId, packet.srcNodeId, packet.path, packet.hopCount);
|
||||
}
|
||||
});
|
||||
onNodeSeen((nodeId) => broadcastNodeUpdate(nodeId));
|
||||
onNodeUpsert((node) => {
|
||||
broadcastNodeUpsert(node);
|
||||
@@ -105,6 +110,19 @@ async function main() {
|
||||
// 5. Start MQTT client
|
||||
startMqttClient();
|
||||
|
||||
// 6. Backfill node_links from historical packets (once, if table is empty)
|
||||
process.nextTick(async () => {
|
||||
const { rows } = await query<{ count: string }>('SELECT COUNT(*) AS count FROM node_links');
|
||||
if (Number(rows[0]?.count ?? 0) === 0) {
|
||||
console.log('[app] node_links empty — backfilling from historical packets…');
|
||||
await backfillHistoricalLinks((rxNodeId, srcNodeId, path, hopCount) => {
|
||||
queueLinkJob(rxNodeId, srcNodeId, path, hopCount);
|
||||
});
|
||||
} else {
|
||||
console.log('[app] node_links already populated, skipping historical backfill');
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[app] listening on http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
AdvertPayload, GroupTextPayload, TextMessagePayload,
|
||||
TracePayload, PathPayload, AckPayload,
|
||||
} from '@michaelhart/meshcore-decoder';
|
||||
import { insertPacket, upsertNode, incrementAdvertCount } from '../db/index.js';
|
||||
import { insertPacket, upsertNode, incrementAdvertCount, query } from '../db/index.js';
|
||||
import type { LivePacket } from '../types/index.js';
|
||||
|
||||
type PacketCallback = (packet: LivePacket) => void;
|
||||
@@ -359,8 +359,41 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
|
||||
payload: innerPayload ?? json,
|
||||
rawHex,
|
||||
advertCount,
|
||||
pathHashes: path,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[mqtt] db insert failed', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode all historical packets with raw_hex and queue link jobs for those with
|
||||
* relay path data. Called once at startup when node_links is empty.
|
||||
*/
|
||||
export async function backfillHistoricalLinks(
|
||||
queueFn: (rxNodeId: string, srcNodeId: string | undefined, path: string[], hopCount: number | undefined) => void,
|
||||
): Promise<void> {
|
||||
const res = await query<{
|
||||
rx_node_id: string; src_node_id: string | null; hop_count: number | null; raw_hex: string;
|
||||
}>(
|
||||
`SELECT DISTINCT ON (packet_hash)
|
||||
rx_node_id, src_node_id, hop_count, raw_hex
|
||||
FROM packets
|
||||
WHERE rx_node_id IS NOT NULL AND raw_hex IS NOT NULL AND raw_hex != ''
|
||||
ORDER BY packet_hash, time DESC`,
|
||||
);
|
||||
|
||||
let queued = 0;
|
||||
for (const row of res.rows) {
|
||||
try {
|
||||
const decoded = MeshCoreDecoder.decode(row.raw_hex, { keyStore });
|
||||
if (decoded?.path && Array.isArray(decoded.path) && decoded.path.length > 0) {
|
||||
queueFn(row.rx_node_id, row.src_node_id ?? undefined, decoded.path as string[], decoded.pathLength);
|
||||
queued++;
|
||||
}
|
||||
} catch {
|
||||
// Skip undecipherable packets
|
||||
}
|
||||
}
|
||||
console.log(`[app] historical link backfill: queued ${queued} packets`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { IncomingMessage } from 'node:http';
|
||||
import type { Server } from 'node:http';
|
||||
import { Redis } from 'ioredis';
|
||||
import type { WSMessage, LivePacket } from '../types/index.js';
|
||||
import { getNodes, getLastNPackets } from '../db/index.js';
|
||||
import { getNodes, getLastNPackets, getViableLinkPairs } from '../db/index.js';
|
||||
|
||||
const REDIS_CHANNEL = 'meshcore:live';
|
||||
|
||||
@@ -49,10 +49,12 @@ export function initWebSocketServer(httpServer: Server): WebSocketServer {
|
||||
|
||||
// Send initial state: known nodes + last 5 minutes of packets
|
||||
try {
|
||||
const [nodes, packets] = await Promise.all([getNodes(), getLastNPackets(10)]);
|
||||
const [nodes, packets, viablePairs] = await Promise.all([
|
||||
getNodes(), getLastNPackets(10), getViableLinkPairs(),
|
||||
]);
|
||||
const initMsg: WSMessage = {
|
||||
type: 'initial_state',
|
||||
data: { nodes, packets },
|
||||
data: { nodes, packets, viable_pairs: viablePairs },
|
||||
ts: Date.now(),
|
||||
};
|
||||
ws.send(JSON.stringify(initMsg));
|
||||
@@ -101,3 +103,19 @@ export function broadcastNodeUpsert(node: Record<string, unknown>): void {
|
||||
export function queueViewshedJob(nodeId: string, lat: number, lon: number): void {
|
||||
void pub.lpush('meshcore:viewshed_jobs', JSON.stringify({ node_id: nodeId, lat, lon }));
|
||||
}
|
||||
|
||||
/** Push a link observation job for a received packet with relay path data. */
|
||||
export function queueLinkJob(
|
||||
rxNodeId: string,
|
||||
srcNodeId: string | undefined,
|
||||
pathHashes: string[],
|
||||
hopCount: number | undefined,
|
||||
): void {
|
||||
if (!pathHashes.length) return;
|
||||
void pub.lpush('meshcore:link_jobs', JSON.stringify({
|
||||
rx_node_id: rxNodeId,
|
||||
src_node_id: srcNodeId,
|
||||
path_hashes: pathHashes,
|
||||
hop_count: hopCount,
|
||||
}));
|
||||
}
|
||||
|
||||
+171
-73
@@ -6,7 +6,7 @@ import { FilterPanel, FILTER_ROWS, type Filters } from './components/FilterPanel
|
||||
import { StatsPanel } from './components/StatsPanel/StatsPanel.js';
|
||||
import { PacketFeed } from './components/PacketFeed.js';
|
||||
import { useWebSocket, type WSMessage, type WSReadyState } from './hooks/useWebSocket.js';
|
||||
import { useNodes, type LivePacketData, type MeshNode } from './hooks/useNodes.js';
|
||||
import { useNodes, type LivePacketData, type MeshNode, type AggregatedPacket } from './hooks/useNodes.js';
|
||||
import { useCoverage, type NodeCoverage } from './hooks/useCoverage.js';
|
||||
|
||||
const DEFAULT_FILTERS: Filters = {
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_FILTERS: Filters = {
|
||||
packetPaths: false,
|
||||
betaPaths: false,
|
||||
betaPathThreshold: 0.5,
|
||||
links: false,
|
||||
};
|
||||
|
||||
// Connectivity indicator
|
||||
@@ -99,49 +100,42 @@ function resolvePathWaypoints(
|
||||
}
|
||||
|
||||
// ── Beta path helpers ─────────────────────────────────────────────────────────
|
||||
// Effective range for a node: uses the stored radio-horizon radius from the
|
||||
// viewshed worker (computed from actual SRTM terrain elevation), clamped 50–80 km.
|
||||
|
||||
const MAX_BETA_HOPS = 15;
|
||||
|
||||
/** Canonical lookup key for a link between two nodes (order-independent). */
|
||||
function linkKey(a: string, b: string): string {
|
||||
return a < b ? `${a}:${b}` : `${b}:${a}`;
|
||||
}
|
||||
|
||||
function distKm(a: MeshNode, b: MeshNode): number {
|
||||
const midLat = ((a.lat! + b.lat!) / 2) * (Math.PI / 180);
|
||||
const dlat = (a.lat! - b.lat!) * 111;
|
||||
const dlon = (a.lon! - b.lon!) * 111 * Math.cos(midLat);
|
||||
return Math.hypot(dlat, dlon);
|
||||
}
|
||||
|
||||
// Fallback range check (used when no ITM data exists for a pair).
|
||||
function nodeRange(nodeId: string, coverage: NodeCoverage[]): number {
|
||||
const cov = coverage.find((c) => c.node_id === nodeId);
|
||||
if (!cov?.radius_m) return 50;
|
||||
return Math.min(80, Math.max(50, cov.radius_m / 1000));
|
||||
}
|
||||
|
||||
// Returns true if two nodes are within range of each other.
|
||||
// Threshold is the max of each node's elevation-derived range (50–80 km).
|
||||
function canReach(a: MeshNode, b: MeshNode, coverage: NodeCoverage[]): boolean {
|
||||
const threshold = Math.max(nodeRange(a.node_id, coverage), nodeRange(b.node_id, coverage));
|
||||
const midLat = ((a.lat! + b.lat!) / 2) * (Math.PI / 180);
|
||||
const dlat = (a.lat! - b.lat!) * 111;
|
||||
const dlon = (a.lon! - b.lon!) * 111 * Math.cos(midLat);
|
||||
return Math.hypot(dlat, dlon) < threshold;
|
||||
}
|
||||
|
||||
function pickByInterpolation(
|
||||
candidates: MeshNode[],
|
||||
src: MeshNode | null,
|
||||
rx: MeshNode,
|
||||
hopIndex: number,
|
||||
totalHops: number,
|
||||
): MeshNode {
|
||||
if (candidates.length === 1) return candidates[0]!;
|
||||
if (!src?.lat || !src?.lon) return candidates[0]!;
|
||||
const t = (hopIndex + 1) / (totalHops + 1);
|
||||
const expLat = src.lat + t * (rx.lat! - src.lat);
|
||||
const expLon = src.lon + t * (rx.lon! - src.lon);
|
||||
return candidates.reduce((a, b) =>
|
||||
Math.hypot(a.lat! - expLat, a.lon! - expLon) <= Math.hypot(b.lat! - expLat, b.lon! - expLon) ? a : b,
|
||||
);
|
||||
return distKm(a, b) < threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scores a beta path using per-hop confidence:
|
||||
* - ambiguity factor: 1 / n_candidates (fewer matches → more confident)
|
||||
* - distance factor: 1.0 if chosen hop is within 50 km of prev node
|
||||
* 0.3 if out of range
|
||||
* 0.7 if prev node unknown (first hop, src unseen)
|
||||
* Last relay → rx is also distance-checked and penalised if out of range.
|
||||
* Returns null if any hop has zero known candidates.
|
||||
* Beta path resolver — backtracking DFS working backwards from the receiver.
|
||||
*
|
||||
* For each relay prefix (reversed, so we start anchored at a known position):
|
||||
* 1. Try confirmed neighbours of prevNode first (real observed link data).
|
||||
* 2. If none, try candidates within mutual radio range (coverage-radius based).
|
||||
* 3. If a chosen candidate leads to dead ends at subsequent hops, backtrack
|
||||
* and try the next candidate at this hop.
|
||||
* 4. If no candidate leads to a valid continuation, skip this hop (up to
|
||||
* maxSkips total). Skips are bounded so the path can't just drop all hops.
|
||||
*/
|
||||
function resolveBetaPath(
|
||||
pathHashes: string[],
|
||||
@@ -149,55 +143,78 @@ function resolveBetaPath(
|
||||
rx: MeshNode,
|
||||
allNodes: Map<string, MeshNode>,
|
||||
coverage: NodeCoverage[],
|
||||
linkPairs: Set<string>,
|
||||
): { path: [number, number][]; confidence: number } | null {
|
||||
if (!rx.lat || !rx.lon || pathHashes.length === 0) return null;
|
||||
if (pathHashes.length >= MAX_BETA_HOPS) return null;
|
||||
|
||||
const resolvedNodes: MeshNode[] = [];
|
||||
const hopConfidences: number[] = [];
|
||||
let prevNode: MeshNode | null = src?.lat && src?.lon ? src : null;
|
||||
type HopResult = { node: MeshNode; conf: number } | null; // null = skipped
|
||||
|
||||
for (let i = 0; i < pathHashes.length; i++) {
|
||||
const prefix = pathHashes[i]!.slice(0, 2).toUpperCase();
|
||||
const candidates = Array.from(allNodes.values()).filter(
|
||||
/** Ordered candidate list for a single hop: confirmed neighbours first, then reachable. */
|
||||
function getCandidates(prefix: string, prevNode: MeshNode): Array<{ node: MeshNode; conf: number }> {
|
||||
const all = Array.from(allNodes.values()).filter(
|
||||
(n) => n.lat && n.lon && (n.role === undefined || n.role === 2)
|
||||
&& !n.name?.includes('🚫') && n.node_id.toUpperCase().startsWith(prefix),
|
||||
);
|
||||
if (candidates.length === 0) continue;
|
||||
const confirmedSet = new Set<string>();
|
||||
const confirmed = all
|
||||
.filter((c) => linkPairs.has(linkKey(c.node_id, prevNode.node_id)))
|
||||
.sort((a, b) => distKm(a, prevNode) - distKm(b, prevNode))
|
||||
.map((c) => { confirmedSet.add(c.node_id); return { node: c, conf: 0.9 }; });
|
||||
const reachable = all
|
||||
.filter((c) => !confirmedSet.has(c.node_id) && canReach(c, prevNode, coverage))
|
||||
.sort((a, b) => distKm(a, prevNode) - distKm(b, prevNode))
|
||||
.slice(0, 2) // cap to top-2 to bound the search tree
|
||||
.map((c) => ({ node: c, conf: 0.3 / Math.max(1, all.length) }));
|
||||
return [...confirmed, ...reachable];
|
||||
}
|
||||
|
||||
const ambiguityFactor = 1.0 / candidates.length;
|
||||
let chosen: MeshNode;
|
||||
let coverageFactor: number;
|
||||
// Allow skipping at most 1/3 of hops — prevents degenerate all-skip paths.
|
||||
const maxSkips = Math.ceil(pathHashes.length / 3);
|
||||
// Hard limit on total DFS calls to prevent exponential blowup on ambiguous prefixes.
|
||||
let budget = 300;
|
||||
|
||||
if (prevNode) {
|
||||
const reachable = candidates.filter((c) => canReach(prevNode!, c, coverage));
|
||||
if (reachable.length > 0) {
|
||||
chosen = pickByInterpolation(reachable, src, rx, i, pathHashes.length);
|
||||
coverageFactor = 1.0;
|
||||
} else {
|
||||
chosen = pickByInterpolation(candidates, src, rx, i, pathHashes.length);
|
||||
coverageFactor = 0.3;
|
||||
}
|
||||
} else {
|
||||
// First hop, source unknown — can't check inbound coverage
|
||||
chosen = pickByInterpolation(candidates, src, rx, i, pathHashes.length);
|
||||
coverageFactor = 0.7;
|
||||
/**
|
||||
* Recursive DFS. Returns the hop results (rx-to-src order) or null if budget
|
||||
* was exhausted before a valid path could be found.
|
||||
*/
|
||||
function solve(hopIdx: number, prevNode: MeshNode, skipsLeft: number, visited: Set<string>): HopResult[] | null {
|
||||
if (hopIdx < 0) return [];
|
||||
if (--budget <= 0) return null;
|
||||
|
||||
const prefix = pathHashes[hopIdx]!.slice(0, 2).toUpperCase();
|
||||
// Exclude nodes already used in this path — MeshCore nodes only relay a packet once.
|
||||
const options = getCandidates(prefix, prevNode).filter((o) => !visited.has(o.node.node_id));
|
||||
|
||||
// Try each candidate. If it leads to a dead end, backtrack and try the next.
|
||||
for (const opt of options) {
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(opt.node.node_id);
|
||||
const rest = solve(hopIdx - 1, opt.node, skipsLeft, nextVisited);
|
||||
if (rest !== null) return [opt, ...rest];
|
||||
}
|
||||
|
||||
hopConfidences.push(coverageFactor * ambiguityFactor);
|
||||
resolvedNodes.push(chosen);
|
||||
prevNode = chosen;
|
||||
// No candidate produced a valid continuation — try skipping this hop.
|
||||
if (skipsLeft > 0) {
|
||||
const rest = solve(hopIdx - 1, prevNode, skipsLeft - 1, visited);
|
||||
if (rest !== null) return [null, ...rest];
|
||||
}
|
||||
|
||||
return null; // truly stuck — caller will try its next candidate
|
||||
}
|
||||
|
||||
// Penalise if the last relay can't reach rx
|
||||
if (resolvedNodes.length > 0 && !canReach(resolvedNodes[resolvedNodes.length - 1]!, rx, coverage)) {
|
||||
hopConfidences[hopConfidences.length - 1]! *= 0.3;
|
||||
}
|
||||
const raw = solve(pathHashes.length - 1, rx, maxSkips, new Set([rx.node_id]));
|
||||
if (!raw) return null;
|
||||
|
||||
const confidence = hopConfidences.reduce((a, b) => a + b, 0) / hopConfidences.length;
|
||||
// raw is in rx→src order; reverse to get src→rx order for rendering.
|
||||
const hops = [...raw].reverse().filter((r): r is { node: MeshNode; conf: number } => r !== null);
|
||||
if (hops.length === 0) return null;
|
||||
|
||||
const confidence = hops.reduce((sum, h) => sum + h.conf, 0) / hops.length;
|
||||
|
||||
const pathNodes: MeshNode[] = [
|
||||
...(src?.lat && src?.lon ? [src] : []),
|
||||
...resolvedNodes,
|
||||
...hops.map((h) => h.node),
|
||||
rx,
|
||||
];
|
||||
if (pathNodes.length < 2) return null;
|
||||
@@ -241,9 +258,13 @@ export const App: React.FC = () => {
|
||||
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
||||
const [stats, setStats] = useState({ mqttNodes: 0, staleNodes: 0, packetsDay: 0 });
|
||||
const [map, setMap] = useState<LeafletMap | null>(null);
|
||||
const [linkPairs, setLinkPairs] = useState<Set<string>>(new Set());
|
||||
const [viablePairsArr, setViablePairsArr] = useState<[string, string][]>([]);
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(() => !localStorage.getItem(DISCLAIMER_KEY));
|
||||
const [packetPath, setPacketPath] = useState<[number, number][] | null>(null);
|
||||
const [betaPacketPath, setBetaPacketPath] = useState<[number, number][] | null>(null);
|
||||
const [pinnedPacketId, setPinnedPacketId] = useState<string | null>(null);
|
||||
const pinnedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const dismissDisclaimer = useCallback(() => {
|
||||
localStorage.setItem(DISCLAIMER_KEY, '1');
|
||||
@@ -294,9 +315,10 @@ export const App: React.FC = () => {
|
||||
|
||||
// Compute dotted path lines from most-recent packet's source → observer.
|
||||
// Clears after PATH_TTL ms (with a 1s fade), or immediately when the next
|
||||
// distinct packet arrives.
|
||||
// distinct packet arrives. Skipped while a packet is pinned by the user.
|
||||
const latestId = packets[0]?.id;
|
||||
useEffect(() => {
|
||||
if (pinnedPacketId !== null) return;
|
||||
if (pathTimerRef.current) clearTimeout(pathTimerRef.current);
|
||||
if (pathFadeRef.current !== null) { cancelAnimationFrame(pathFadeRef.current); pathFadeRef.current = null; }
|
||||
|
||||
@@ -318,10 +340,11 @@ export const App: React.FC = () => {
|
||||
// ── Beta path (unambiguous hops + coverage validation) ────────────────────
|
||||
if (filters.betaPaths && latest?.rxNodeId && latest.path?.length && rx?.lat && rx?.lon) {
|
||||
const src = latest.srcNodeId ? (nodes.get(latest.srcNodeId) ?? null) : null;
|
||||
const hops = latest.hopCount != null ? latest.path.slice(0, latest.hopCount) : latest.path;
|
||||
const result = resolveBetaPath(
|
||||
latest.path,
|
||||
hops,
|
||||
src?.lat && src?.lon ? src : null,
|
||||
rx, nodes, coverage,
|
||||
rx, nodes, coverage, linkPairs,
|
||||
);
|
||||
setBetaPacketPath(result && result.confidence >= filters.betaPathThreshold ? result.path : null);
|
||||
} else {
|
||||
@@ -350,11 +373,77 @@ export const App: React.FC = () => {
|
||||
pathFadeRef.current = requestAnimationFrame(animate);
|
||||
}, PATH_TTL - 1_000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [latestId, filters.packetPaths, filters.betaPaths]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [latestId, filters.packetPaths, filters.betaPaths, pinnedPacketId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handlePacketPin = useCallback((packet: AggregatedPacket) => {
|
||||
// Toggle: clicking the already-pinned packet unpins it
|
||||
if (pinnedPacketId === packet.id) {
|
||||
setPinnedPacketId(null);
|
||||
if (pinnedTimerRef.current) { clearTimeout(pinnedTimerRef.current); pinnedTimerRef.current = null; }
|
||||
if (pathTimerRef.current) { clearTimeout(pathTimerRef.current); pathTimerRef.current = null; }
|
||||
if (pathFadeRef.current !== null) { cancelAnimationFrame(pathFadeRef.current); pathFadeRef.current = null; }
|
||||
setPacketPath(null);
|
||||
setBetaPacketPath(null);
|
||||
setPathOpacity(0.75);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any running auto timers
|
||||
if (pathTimerRef.current) { clearTimeout(pathTimerRef.current); pathTimerRef.current = null; }
|
||||
if (pathFadeRef.current !== null) { cancelAnimationFrame(pathFadeRef.current); pathFadeRef.current = null; }
|
||||
if (pinnedTimerRef.current) { clearTimeout(pinnedTimerRef.current); pinnedTimerRef.current = null; }
|
||||
|
||||
const rx = packet.rxNodeId ? nodes.get(packet.rxNodeId) : undefined;
|
||||
|
||||
setPacketPath(null);
|
||||
|
||||
if (packet.rxNodeId && packet.path?.length && rx?.lat && rx?.lon) {
|
||||
const src = packet.srcNodeId ? (nodes.get(packet.srcNodeId) ?? null) : null;
|
||||
const hops = packet.hopCount != null ? packet.path.slice(0, packet.hopCount) : packet.path;
|
||||
const result = resolveBetaPath(
|
||||
hops, src?.lat && src?.lon ? src : null, rx, nodes, coverage, linkPairs,
|
||||
);
|
||||
setBetaPacketPath(result ? result.path : null);
|
||||
} else {
|
||||
setBetaPacketPath(null);
|
||||
}
|
||||
|
||||
setPathOpacity(0.75);
|
||||
setPinnedPacketId(packet.id);
|
||||
|
||||
// Auto-release after 30s with a 1s fade
|
||||
pinnedTimerRef.current = setTimeout(() => {
|
||||
const FADE_MS = 1_000;
|
||||
const startTime = performance.now();
|
||||
const animate = (now: number) => {
|
||||
const t = Math.min(1, (now - startTime) / FADE_MS);
|
||||
setPathOpacity(0.75 * (1 - t));
|
||||
if (t < 1) {
|
||||
pathFadeRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
pathFadeRef.current = null;
|
||||
setPacketPath(null);
|
||||
setBetaPacketPath(null);
|
||||
setPathOpacity(0.75);
|
||||
setPinnedPacketId(null);
|
||||
pinnedTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
pathFadeRef.current = requestAnimationFrame(animate);
|
||||
}, 9_000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pinnedPacketId, nodes, coverage, linkPairs, filters.betaPaths, filters.betaPathThreshold]);
|
||||
|
||||
const handleMessage = useCallback((msg: WSMessage) => {
|
||||
if (msg.type === 'initial_state') {
|
||||
handleInitialState(msg.data as Parameters<typeof handleInitialState>[0]);
|
||||
const data = msg.data as Parameters<typeof handleInitialState>[0] & {
|
||||
viable_pairs?: [string, string][];
|
||||
};
|
||||
handleInitialState(data);
|
||||
if (data.viable_pairs) {
|
||||
setLinkPairs(new Set(data.viable_pairs.map(([a, b]) => linkKey(a, b))));
|
||||
setViablePairsArr(data.viable_pairs);
|
||||
}
|
||||
} else if (msg.type === 'packet') {
|
||||
handlePacket(msg.data as LivePacketData);
|
||||
} else if (msg.type === 'node_update') {
|
||||
@@ -436,9 +525,11 @@ export const App: React.FC = () => {
|
||||
showPackets={filters.livePackets}
|
||||
showCoverage={filters.coverage}
|
||||
showClientNodes={filters.clientNodes}
|
||||
showLinks={filters.links}
|
||||
viablePairsArr={viablePairsArr}
|
||||
packetPath={packetPath}
|
||||
betaPath={betaPacketPath}
|
||||
showBetaPaths={filters.betaPaths}
|
||||
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
|
||||
pathOpacity={pathOpacity}
|
||||
onMapReady={setMap}
|
||||
/>
|
||||
@@ -447,7 +538,14 @@ export const App: React.FC = () => {
|
||||
<FilterPanel filters={filters} onChange={setFilters} />
|
||||
|
||||
{/* ── Live Packet Feed ───────────────────────────────────────────── */}
|
||||
{filters.livePackets && <PacketFeed packets={packets} nodes={nodes} />}
|
||||
{filters.livePackets && (
|
||||
<PacketFeed
|
||||
packets={packets}
|
||||
nodes={nodes}
|
||||
onPacketClick={handlePacketPin}
|
||||
pinnedPacketId={pinnedPacketId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Disclaimer modal ───────────────────────────────────────────── */}
|
||||
{showDisclaimer && <DisclaimerModal onClose={dismissDisclaimer} />}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Filters {
|
||||
packetPaths: boolean;
|
||||
betaPaths: boolean;
|
||||
betaPathThreshold: number; // 0–1
|
||||
links: boolean;
|
||||
}
|
||||
|
||||
interface FilterPanelProps {
|
||||
@@ -18,6 +19,7 @@ export const FILTER_ROWS: Array<{ key: keyof Filters; label: string; color: stri
|
||||
{ key: 'livePackets', label: 'Live Feed', color: '#00c4ff' },
|
||||
{ key: 'packetPaths', label: 'Packet Paths', color: '#00c4ff', hollow: true },
|
||||
{ key: 'betaPaths', label: 'Paths (Beta)', color: '#a855f7', hollow: true },
|
||||
{ key: 'links', label: 'Links', color: '#fbbf24' },
|
||||
{ key: 'coverage', label: 'Coverage', color: '#00e676' },
|
||||
{ key: 'clientNodes', label: 'Companion / Room', color: '#ff9800' },
|
||||
];
|
||||
|
||||
@@ -82,6 +82,8 @@ interface MapViewProps {
|
||||
showPackets: boolean;
|
||||
showCoverage: boolean;
|
||||
showClientNodes: boolean;
|
||||
showLinks: boolean;
|
||||
viablePairsArr: [string, string][];
|
||||
packetPath: [number, number][] | null;
|
||||
betaPath: [number, number][] | null;
|
||||
showBetaPaths: boolean;
|
||||
@@ -95,7 +97,7 @@ const DEFAULT_ZOOM = 11;
|
||||
|
||||
export const MapView: React.FC<MapViewProps> = ({
|
||||
nodes, arcs, activeNodes, coverage, showPackets, showCoverage, showClientNodes,
|
||||
packetPath, betaPath, showBetaPaths, pathOpacity, onMapReady,
|
||||
showLinks, viablePairsArr, packetPath, betaPath, showBetaPaths, pathOpacity, onMapReady,
|
||||
}) => {
|
||||
const [map, setMap] = useState<LeafletMap | null>(null);
|
||||
|
||||
@@ -170,6 +172,20 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
|
||||
const coverageRings = useCoverageDisplayRings(coverage);
|
||||
|
||||
// Resolve viable link pairs to lat/lon polyline positions
|
||||
const linkLines = useMemo(() => {
|
||||
if (!showLinks || viablePairsArr.length === 0) return [];
|
||||
const lines: [number, number][][] = [];
|
||||
for (const [aId, bId] of viablePairsArr) {
|
||||
const a = nodes.get(aId);
|
||||
const b = nodes.get(bId);
|
||||
if (a?.lat && a?.lon && b?.lat && b?.lon) {
|
||||
lines.push([[a.lat, a.lon], [b.lat, b.lon]]);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}, [showLinks, viablePairsArr, nodes]);
|
||||
|
||||
return (
|
||||
<div className="map-area">
|
||||
<NodeSearch nodes={nodes} map={map} />
|
||||
@@ -212,6 +228,24 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
</Pane>
|
||||
)}
|
||||
|
||||
{/* Confirmed link lines — ITM-viable node pairs */}
|
||||
{showLinks && linkLines.length > 0 && (
|
||||
<Pane name="linksPane" style={{ zIndex: 400 }}>
|
||||
{linkLines.map((positions, i) => (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: '#fbbf24',
|
||||
weight: 1.5,
|
||||
opacity: 0.55,
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
</Pane>
|
||||
)}
|
||||
|
||||
{/* Repeater markers — Leaflet default marker pane at zIndex 600 */}
|
||||
{nodesWithPos.map((node) => (
|
||||
<NodeMarker
|
||||
|
||||
@@ -69,6 +69,12 @@ function coverageToRings(cov: NodeCoverage): LatLngExpression[][] {
|
||||
return [];
|
||||
}
|
||||
|
||||
interface NodeLink {
|
||||
peer_id: string; peer_name: string | null; observed_count: number;
|
||||
itm_path_loss_db: number | null;
|
||||
count_this_to_peer: number; count_peer_to_this: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
node: MeshNode;
|
||||
isActive: boolean;
|
||||
@@ -77,6 +83,7 @@ interface Props {
|
||||
|
||||
export const NodeMarker: React.FC<Props> = React.memo(({ node, isActive, nodeCoverage }) => {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [links, setLinks] = useState<NodeLink[] | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
||||
@@ -110,7 +117,15 @@ export const NodeMarker: React.FC<Props> = React.memo(({ node, isActive, nodeCov
|
||||
position={[node.lat, node.lon]}
|
||||
icon={buildIcon(node.is_online, isActive, isStale, variant)}
|
||||
>
|
||||
<Popup>
|
||||
<Popup eventHandlers={{
|
||||
add: () => {
|
||||
if (links !== null) return; // already fetched
|
||||
fetch(`https://app.teessidemesh.com/api/nodes/${node.node_id}/links`)
|
||||
.then((r) => r.json())
|
||||
.then((data: NodeLink[]) => setLinks(data))
|
||||
.catch(() => setLinks([]));
|
||||
},
|
||||
}}>
|
||||
<div className="node-popup">
|
||||
<div className="node-popup__name">{node.name ?? `Unknown ${fallbackName}`}</div>
|
||||
{node.role !== undefined && node.role !== 2 && (
|
||||
@@ -157,6 +172,26 @@ export const NodeMarker: React.FC<Props> = React.memo(({ node, isActive, nodeCov
|
||||
{showPreview ? 'Showing coverage…' : 'Preview coverage'}
|
||||
</button>
|
||||
)}
|
||||
{links === null && <div className="node-popup__neighbours-loading">Loading neighbours…</div>}
|
||||
{links !== null && links.length > 0 && (
|
||||
<div className="node-popup__neighbours">
|
||||
<div className="node-popup__neighbours-title">Confirmed neighbours</div>
|
||||
{links.map((lk) => {
|
||||
const tx = lk.count_this_to_peer > 0;
|
||||
const rx = lk.count_peer_to_this > 0;
|
||||
const arrow = tx && rx ? '↔' : tx ? '→' : '←';
|
||||
return (
|
||||
<div key={lk.peer_id} className="node-popup__neighbour-row">
|
||||
<span className="node-popup__neighbour-name">{arrow} {lk.peer_name ?? lk.peer_id.slice(0, 8)}</span>
|
||||
<span className="node-popup__neighbour-meta">
|
||||
{lk.observed_count}× seen
|
||||
{lk.itm_path_loss_db != null && <> · {Math.round(lk.itm_path_loss_db)} dB</>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
|
||||
@@ -15,11 +15,13 @@ const TYPE_LABELS: Record<number, string> = {
|
||||
};
|
||||
|
||||
interface Props {
|
||||
packets: AggregatedPacket[];
|
||||
nodes: Map<string, MeshNode>;
|
||||
packets: AggregatedPacket[];
|
||||
nodes: Map<string, MeshNode>;
|
||||
onPacketClick?: (packet: AggregatedPacket) => void;
|
||||
pinnedPacketId?: string | null;
|
||||
}
|
||||
|
||||
export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes }) => (
|
||||
export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, onPacketClick, pinnedPacketId }) => (
|
||||
<div className={`packet-feed${packets.length >= 7 ? ' packet-feed--overflow' : ''}`}>
|
||||
{packets.slice(0, 7).map((p) => {
|
||||
const typeLabel = p.packetType !== undefined
|
||||
@@ -37,8 +39,17 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes }) => (
|
||||
? (p.advertCount === 1 ? 'NEW' : `${p.advertCount}`)
|
||||
: undefined;
|
||||
|
||||
const isPinned = pinnedPacketId === p.id;
|
||||
|
||||
return (
|
||||
<div key={p.id} className="packet-item">
|
||||
<div
|
||||
key={p.id}
|
||||
className={`packet-item packet-item--clickable${isPinned ? ' packet-item--pinned' : ''}`}
|
||||
onClick={() => onPacketClick?.(p)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onPacketClick?.(p)}
|
||||
>
|
||||
<span className="packet-item__type">{typeLabel}</span>
|
||||
{advertBadge && (
|
||||
<span className="packet-item__advert-badge">{advertBadge}</span>
|
||||
@@ -53,6 +64,7 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes }) => (
|
||||
{p.rxCount > 0 && <span className="count count--rx">{p.rxCount}rx</span>}
|
||||
{p.txCount > 0 && <span className="count count--tx">{p.txCount}tx</span>}
|
||||
</span>
|
||||
{isPinned && <span className="packet-item__pin">●</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -91,6 +91,7 @@ export function useNodes() {
|
||||
hop_count?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
advert_count?: number | null;
|
||||
path_hashes?: string[] | null;
|
||||
}>;
|
||||
}) => {
|
||||
const nodeMap = new Map<string, MeshNode>();
|
||||
@@ -115,6 +116,7 @@ export function useNodes() {
|
||||
srcNodeId: row.src_node_id,
|
||||
summary,
|
||||
hopCount: row.hop_count,
|
||||
path: row.path_hashes ?? undefined,
|
||||
rxCount: 1,
|
||||
txCount: 0,
|
||||
ts: new Date(row.time).getTime(),
|
||||
|
||||
@@ -43,7 +43,7 @@ export const HomePage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const fetch_ = () =>
|
||||
fetch('/api/stats')
|
||||
fetch('https://app.teessidemesh.com/api/stats')
|
||||
.then(r => r.json())
|
||||
.then(d => setStats({ packetsDay: d.packetsDay, totalNodes: d.totalNodes, longestHop: d.longestHop, longestHopHash: d.longestHopHash ?? null }))
|
||||
.catch(() => {});
|
||||
|
||||
@@ -94,7 +94,7 @@ export const StatsPage: React.FC = () => {
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
const load = () => {
|
||||
fetch('/api/stats/charts')
|
||||
fetch('https://app.teessidemesh.com/api/stats/charts')
|
||||
.then(r => r.json())
|
||||
.then((d: ChartData) => { setData(d); setLoading(false); setLastUpdate(new Date()); })
|
||||
.catch(() => setLoading(false));
|
||||
|
||||
@@ -464,6 +464,44 @@ html, body, #root {
|
||||
border-color: #1ec850;
|
||||
color: #1ec850;
|
||||
}
|
||||
.node-popup__neighbours-loading {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.node-popup__neighbours {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.node-popup__neighbours-title {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.node-popup__neighbour-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.node-popup__neighbour-name {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 120px;
|
||||
}
|
||||
.node-popup__neighbour-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Live packet feed (bottom left) ────────────────────────────────────── */
|
||||
.packet-feed {
|
||||
@@ -480,6 +518,11 @@ html, body, #root {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Re-enable pointer events on individual items so they're clickable */
|
||||
.packet-item {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Fade the oldest (topmost) item only when the feed is full */
|
||||
.packet-feed--overflow {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 40px, black 100%);
|
||||
@@ -505,6 +548,37 @@ html, body, #root {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.packet-item--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.packet-item--clickable:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.packet-item--pinned {
|
||||
background: rgba(0, 196, 255, 0.08);
|
||||
border-color: rgba(0, 196, 255, 0.4);
|
||||
}
|
||||
|
||||
.packet-item--pinned:hover {
|
||||
background: rgba(0, 196, 255, 0.12);
|
||||
}
|
||||
|
||||
.packet-item__pin {
|
||||
font-size: 7px;
|
||||
color: var(--accent);
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
animation: pin-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pin-pulse {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from { transform: translateY(8px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
|
||||
+262
-5
@@ -40,8 +40,9 @@ SRTM_DIR = Path(os.environ.get('SRTM_DIR', '/data/srtm'))
|
||||
REDIS_URL = os.environ.get('REDIS_URL', 'redis://redis:6379')
|
||||
DATABASE_URL = os.environ.get('DATABASE_URL')
|
||||
|
||||
JOB_QUEUE = 'meshcore:viewshed_jobs'
|
||||
LIVE_CHANNEL = 'meshcore:live'
|
||||
JOB_QUEUE = 'meshcore:viewshed_jobs'
|
||||
LINK_JOB_QUEUE = 'meshcore:link_jobs'
|
||||
LIVE_CHANNEL = 'meshcore:live'
|
||||
|
||||
ANTENNA_HEIGHT_M = 5 # observer height above ground (m) — fixed 5 m antenna
|
||||
MAX_RADIUS_M = 100_000 # absolute cap on viewshed radius (m)
|
||||
@@ -53,6 +54,111 @@ STEP_M = 50.0 # ray step size in metres
|
||||
K_FACTOR = 4 / 3 # effective Earth radius multiplier (standard troposphere)
|
||||
R_EARTH_M = 6_371_000 # mean Earth radius (m)
|
||||
|
||||
# ── RF propagation model parameters (LoRa 868 MHz) ───────────────────────────
|
||||
|
||||
FREQ_MHZ = 868.0
|
||||
LAMBDA_M = 3e8 / (FREQ_MHZ * 1e6) # wavelength ~0.345 m
|
||||
LINK_BUDGET_DB = 148.0 # 17 dBm TX + ~130 dBm RX sensitivity (LoRa SF10 BW125) +1 dB SRTM DSM correction
|
||||
FADE_MARGIN_DB = 10.0 # safety / link margin
|
||||
PROFILE_STEP_M = 250.0 # terrain profile sample spacing (m)
|
||||
|
||||
def compute_path_loss(lat1: float, lon1: float, elev1: float,
|
||||
lat2: float, lon2: float, elev2: float,
|
||||
vrt_path: str) -> tuple[float, bool]:
|
||||
"""Estimate RF path loss (dB) between two points.
|
||||
|
||||
Uses free-space path loss plus ITU-R P.526 single knife-edge diffraction
|
||||
over the dominant terrain obstruction, corrected for Earth curvature.
|
||||
Returns (path_loss_db, is_viable).
|
||||
"""
|
||||
cos_mid = math.cos(math.radians((lat1 + lat2) / 2))
|
||||
dlat = (lat2 - lat1) * 111_320
|
||||
dlon = (lon2 - lon1) * 111_320 * cos_mid
|
||||
d_total = math.sqrt(dlat ** 2 + dlon ** 2)
|
||||
|
||||
if d_total < 1.0:
|
||||
return 0.0, True
|
||||
|
||||
# Free-space path loss (dB)
|
||||
fspl = 20 * math.log10(4 * math.pi * d_total / LAMBDA_M)
|
||||
|
||||
# Terrain profile: N evenly-spaced samples along the path
|
||||
N = max(20, min(200, int(d_total / PROFILE_STEP_M)))
|
||||
|
||||
ds = gdal.Open(vrt_path)
|
||||
if ds is None:
|
||||
viable = fspl < LINK_BUDGET_DB
|
||||
return fspl, viable
|
||||
|
||||
gt = ds.GetGeoTransform()
|
||||
inv_gt = gdal.InvGeoTransform(gt)
|
||||
band = ds.GetRasterBand(1)
|
||||
|
||||
heights: list[float] = []
|
||||
dists: list[float] = []
|
||||
for i in range(N + 1):
|
||||
t = i / N
|
||||
la = lat1 + t * (lat2 - lat1)
|
||||
lo = lon1 + t * (lon2 - lon1)
|
||||
px, py = gdal.ApplyGeoTransform(inv_gt, lo, la)
|
||||
px = int(np.clip(px, 0, ds.RasterXSize - 1))
|
||||
py = int(np.clip(py, 0, ds.RasterYSize - 1))
|
||||
data = band.ReadAsArray(px, py, 1, 1)
|
||||
h = max(0.0, float(data[0][0])) if data is not None else 0.0
|
||||
heights.append(h)
|
||||
dists.append(t * d_total)
|
||||
ds = None
|
||||
|
||||
h_tx = elev1 + ANTENNA_HEIGHT_M # transmitter height ASL + antenna
|
||||
h_rx = elev2 + ANTENNA_HEIGHT_M
|
||||
|
||||
# Find dominant obstruction via Fresnel-Kirchhoff diffraction parameter
|
||||
max_v = -999.0
|
||||
for i in range(1, N):
|
||||
d1 = dists[i]
|
||||
d2 = d_total - dists[i]
|
||||
if d1 <= 0 or d2 <= 0:
|
||||
continue
|
||||
los_h = h_tx + (h_rx - h_tx) * (d1 / d_total)
|
||||
earth_bulge = (d1 * d2) / (2 * K_FACTOR * R_EARTH_M)
|
||||
excess_h = heights[i] + earth_bulge - los_h
|
||||
v = excess_h * math.sqrt(2 * (d1 + d2) / (LAMBDA_M * d1 * d2))
|
||||
max_v = max(max_v, v)
|
||||
|
||||
# ITU-R P.526 knife-edge diffraction loss (dB)
|
||||
if max_v <= -0.78:
|
||||
diff_loss = 0.0
|
||||
else:
|
||||
diff_loss = max(0.0, 6.9 + 20 * math.log10(
|
||||
math.sqrt((max_v - 0.1) ** 2 + 1) + max_v - 0.1
|
||||
))
|
||||
|
||||
total_loss = fspl + diff_loss
|
||||
viable = total_loss < LINK_BUDGET_DB
|
||||
return total_loss, viable
|
||||
|
||||
|
||||
def build_link_vrt(lat1: float, lon1: float, lat2: float, lon2: float,
|
||||
tmp_dir: str) -> Optional[str]:
|
||||
"""Build a GDAL VRT from already-cached SRTM tiles covering the path.
|
||||
Returns None if no tiles are available (will be retried later once
|
||||
nearby viewsheds have triggered tile downloads)."""
|
||||
min_lat = math.floor(min(lat1, lat2))
|
||||
max_lat = math.floor(max(lat1, lat2))
|
||||
min_lon = math.floor(min(lon1, lon2))
|
||||
max_lon = math.floor(max(lon1, lon2))
|
||||
paths = [
|
||||
str(SRTM_DIR / f'{tile_name(lt, ln)}.hgt')
|
||||
for lt in range(min_lat, max_lat + 1)
|
||||
for ln in range(min_lon, max_lon + 1)
|
||||
if (SRTM_DIR / f'{tile_name(lt, ln)}.hgt').exists()
|
||||
]
|
||||
if not paths:
|
||||
return None
|
||||
vrt = f'{tmp_dir}/link.vrt'
|
||||
r = subprocess.run(['gdalbuildvrt', vrt] + paths, capture_output=True, text=True)
|
||||
return vrt if r.returncode == 0 else None
|
||||
|
||||
# ── UK mainland polygon (loaded once at startup for ocean clipping) ───────────
|
||||
|
||||
def _load_uk_mainland():
|
||||
@@ -360,6 +466,146 @@ def backfill_elevations(db):
|
||||
log.info(f' {node_id[:12]}…: elevation={elevation_m:.0f} m ASL (from radius {radius_m/1000:.1f} km)')
|
||||
db.commit()
|
||||
|
||||
def process_link_job(db, r_client, job: dict):
|
||||
"""Resolve relay path prefixes to known nodes (backwards from the receiver),
|
||||
record observations in node_links, and compute RF path loss for new pairs."""
|
||||
rx_node_id = job.get('rx_node_id')
|
||||
src_node_id = job.get('src_node_id')
|
||||
path_hashes = job.get('path_hashes', [])
|
||||
|
||||
if not rx_node_id or not path_hashes:
|
||||
return
|
||||
|
||||
# Load all positioned nodes
|
||||
with db.cursor() as cur:
|
||||
cur.execute(
|
||||
'SELECT node_id, lat, lon, elevation_m, name, role FROM nodes '
|
||||
'WHERE lat IS NOT NULL AND lon IS NOT NULL'
|
||||
)
|
||||
all_nodes = {
|
||||
row[0]: {'lat': row[1], 'lon': row[2], 'elevation_m': row[3] or 0.0,
|
||||
'name': row[4], 'role': row[5]}
|
||||
for row in cur.fetchall()
|
||||
}
|
||||
|
||||
rx = all_nodes.get(rx_node_id)
|
||||
if not rx:
|
||||
return
|
||||
|
||||
def node_dist(a: dict, b: dict) -> float:
|
||||
cos_m = math.cos(math.radians((a['lat'] + b['lat']) / 2))
|
||||
return math.sqrt(
|
||||
((a['lat'] - b['lat']) * 111.32) ** 2 +
|
||||
((a['lon'] - b['lon']) * 111.32 * cos_m) ** 2
|
||||
)
|
||||
|
||||
# Resolve path working backwards from rx (known position anchor)
|
||||
resolved: list[tuple[str, dict]] = [] # built in reverse, then flipped
|
||||
prev = rx
|
||||
for prefix in reversed(path_hashes):
|
||||
prefix = prefix[:2].upper()
|
||||
candidates = [
|
||||
(nid, nd) for nid, nd in all_nodes.items()
|
||||
if nid.upper().startswith(prefix)
|
||||
and (nd['role'] is None or nd['role'] == 2)
|
||||
and nd['name'] and '🚫' not in nd['name']
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
candidates.sort(key=lambda x: node_dist(x[1], prev))
|
||||
best_id, best = candidates[0]
|
||||
resolved.insert(0, (best_id, best))
|
||||
prev = best
|
||||
|
||||
# Build adjacency list: src → relays → rx
|
||||
full: list[tuple[str, dict]] = []
|
||||
if src_node_id and src_node_id in all_nodes:
|
||||
full.append((src_node_id, all_nodes[src_node_id]))
|
||||
full.extend(resolved)
|
||||
full.append((rx_node_id, rx))
|
||||
|
||||
if len(full) < 2:
|
||||
return
|
||||
|
||||
# Upsert observations and compute path loss for each adjacent pair.
|
||||
# full[i] → full[i+1] means full[i] transmitted, full[i+1] received.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for i in range(len(full) - 1):
|
||||
src_id, src = full[i] # transmitted
|
||||
dst_id, dst = full[i + 1] # received
|
||||
if not src['lat'] or not dst['lat']:
|
||||
continue
|
||||
|
||||
# Canonical ordering (lower ID first) → unique primary key
|
||||
if src_id < dst_id:
|
||||
a_id, a, b_id, b = src_id, src, dst_id, dst
|
||||
inc_atob, inc_btoa = 1, 0 # src==a transmitted to dst==b
|
||||
else:
|
||||
a_id, a, b_id, b = dst_id, dst, src_id, src
|
||||
inc_atob, inc_btoa = 0, 1 # src==b transmitted to dst==a
|
||||
|
||||
# Upsert observation with directional counts; check whether ITM already computed
|
||||
with db.cursor() as cur:
|
||||
cur.execute(
|
||||
'''INSERT INTO node_links
|
||||
(node_a_id, node_b_id, observed_count, last_observed,
|
||||
count_a_to_b, count_b_to_a)
|
||||
VALUES (%s, %s, 1, NOW(), %s, %s)
|
||||
ON CONFLICT (node_a_id, node_b_id) DO UPDATE
|
||||
SET observed_count = node_links.observed_count + 1,
|
||||
last_observed = NOW(),
|
||||
count_a_to_b = node_links.count_a_to_b + %s,
|
||||
count_b_to_a = node_links.count_b_to_a + %s
|
||||
RETURNING observed_count, itm_computed_at''',
|
||||
(a_id, b_id, inc_atob, inc_btoa, inc_atob, inc_btoa),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
obs_count = row[0] if row else 1
|
||||
itm_computed = row[1] if row else None
|
||||
|
||||
# Compute ITM path loss if not yet done and tiles are cached
|
||||
path_loss_db: Optional[float] = None
|
||||
itm_viable: Optional[bool] = None
|
||||
if itm_computed is None:
|
||||
vrt = build_link_vrt(a['lat'], a['lon'], b['lat'], b['lon'], tmp)
|
||||
if vrt:
|
||||
try:
|
||||
path_loss_db, itm_viable = compute_path_loss(
|
||||
a['lat'], a['lon'], a['elevation_m'],
|
||||
b['lat'], b['lon'], b['elevation_m'],
|
||||
vrt,
|
||||
)
|
||||
with db.cursor() as cur:
|
||||
cur.execute(
|
||||
'''UPDATE node_links
|
||||
SET itm_path_loss_db = %s,
|
||||
itm_viable = %s,
|
||||
itm_computed_at = NOW()
|
||||
WHERE node_a_id = %s AND node_b_id = %s''',
|
||||
(round(path_loss_db, 1), itm_viable, a_id, b_id),
|
||||
)
|
||||
log.info(
|
||||
f'Link {a_id[:8]}…↔{b_id[:8]}…: '
|
||||
f'{path_loss_db:.1f} dB {"✓" if itm_viable else "✗"} '
|
||||
f'(obs={obs_count})'
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning(f'Path loss computation failed: {exc}')
|
||||
|
||||
# Notify frontend
|
||||
r_client.publish(LIVE_CHANNEL, json.dumps({
|
||||
'type': 'link_update',
|
||||
'data': {
|
||||
'node_a_id': a_id,
|
||||
'node_b_id': b_id,
|
||||
'observed_count': obs_count,
|
||||
'itm_path_loss_db': path_loss_db,
|
||||
'itm_viable': itm_viable,
|
||||
},
|
||||
'ts': int(time.time() * 1000),
|
||||
}))
|
||||
|
||||
|
||||
def enqueue_uncovered(db, r_client):
|
||||
"""On startup, queue all nodes that have a position but no coverage yet."""
|
||||
# Remove any coverage that was previously computed for hidden or non-repeater nodes.
|
||||
@@ -458,11 +704,22 @@ def worker_loop():
|
||||
|
||||
while True:
|
||||
try:
|
||||
item = r_client.brpop(JOB_QUEUE, timeout=60)
|
||||
# Drain any pending link jobs first (fast) before blocking on viewshed
|
||||
while True:
|
||||
raw = r_client.rpop(LINK_JOB_QUEUE)
|
||||
if raw is None:
|
||||
break
|
||||
process_link_job(db, r_client, json.loads(raw))
|
||||
|
||||
# Block-wait for a viewshed or link job (viewshed has priority)
|
||||
item = r_client.brpop([JOB_QUEUE, LINK_JOB_QUEUE], timeout=30)
|
||||
if item is None:
|
||||
continue
|
||||
_, raw = item
|
||||
process_job(db, r_client, json.loads(raw))
|
||||
queue_name, raw = item
|
||||
if queue_name == LINK_JOB_QUEUE:
|
||||
process_link_job(db, r_client, json.loads(raw))
|
||||
else:
|
||||
process_job(db, r_client, json.loads(raw))
|
||||
except psycopg2.OperationalError:
|
||||
log.warning(f'{name}: DB connection lost — reconnecting')
|
||||
db = wait_for_db()
|
||||
|
||||
Reference in New Issue
Block a user