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:
Ben
2026-03-04 02:27:27 +00:00
co-authored by Claude Sonnet 4.6
parent 6fdeaf7d01
commit 99daf1e4d2
16 changed files with 742 additions and 97 deletions
+29
View File
@@ -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
View File
@@ -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 };
+23
View File
@@ -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
View File
@@ -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}`);
});
+34 -1
View File
@@ -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`);
}
+21 -3
View File
@@ -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
View File
@@ -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 5080 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 (5080 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; // 01
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' },
];
+35 -1
View File
@@ -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
+36 -1
View File
@@ -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 && <> &middot; {Math.round(lk.itm_path_loss_db)} dB</>}
</span>
</div>
);
})}
</div>
)}
</div>
</Popup>
</Marker>
+16 -4
View File
@@ -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>
);
})}
+2
View File
@@ -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(),
+1 -1
View File
@@ -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(() => {});
+1 -1
View File
@@ -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));
+74
View File
@@ -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
View File
@@ -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()