'use strict'; const express = require('express'); const http = require('http'); const { WebSocketServer } = require('ws'); const mqtt = require('mqtt'); const path = require('path'); const config = require('./config.json'); const decoder = require('./decoder'); const crypto = require('crypto'); // Compute a content hash from raw hex: header byte + payload (skipping path hops) // This correctly groups retransmissions of the same packet (same content, different paths) function computeContentHash(rawHex) { try { const buf = Buffer.from(rawHex, 'hex'); if (buf.length < 2) return rawHex.slice(0, 16); const pathByte = buf[1]; const hashSize = ((pathByte >> 6) & 0x3) + 1; const hashCount = pathByte & 0x3F; const pathBytes = hashSize * hashCount; const payloadStart = 2 + pathBytes; const payload = buf.subarray(payloadStart); const toHash = Buffer.concat([Buffer.from([buf[0]]), payload]); return crypto.createHash('sha256').update(toHash).digest('hex').slice(0, 16); } catch { return rawHex.slice(0, 16); } } const db = require('./db'); const channelKeys = require("./config.json").channelKeys || {}; // --- TTL Cache --- class TTLCache { constructor() { this.store = new Map(); this.hits = 0; this.misses = 0; } get(key) { const entry = this.store.get(key); if (!entry) { this.misses++; return undefined; } if (Date.now() > entry.expires) { this.store.delete(key); this.misses++; return undefined; } this.hits++; return entry.value; } set(key, value, ttlMs) { this.store.set(key, { value, expires: Date.now() + ttlMs }); } invalidate(prefix) { for (const key of this.store.keys()) { if (key.startsWith(prefix)) this.store.delete(key); } } clear() { this.store.clear(); } get size() { return this.store.size; } } const cache = new TTLCache(); // Seed DB if empty db.seed(); const app = express(); const server = http.createServer(app); // --- Performance Instrumentation --- const perfStats = { requests: 0, totalMs: 0, endpoints: {}, // { path: { count, totalMs, maxMs, avgMs, p95: [], lastSlow } } slowQueries: [], // last 50 requests > 100ms startedAt: Date.now(), reset() { this.requests = 0; this.totalMs = 0; this.endpoints = {}; this.slowQueries = []; this.startedAt = Date.now(); } }; app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); const start = process.hrtime.bigint(); const origEnd = res.end; res.end = function(...args) { const ms = Number(process.hrtime.bigint() - start) / 1e6; perfStats.requests++; perfStats.totalMs += ms; // Normalize parameterized routes const key = req.route ? req.route.path : req.path.replace(/[0-9a-f]{8,}/gi, ':id'); if (!perfStats.endpoints[key]) perfStats.endpoints[key] = { count: 0, totalMs: 0, maxMs: 0, recent: [] }; const ep = perfStats.endpoints[key]; ep.count++; ep.totalMs += ms; if (ms > ep.maxMs) ep.maxMs = ms; ep.recent.push(ms); if (ep.recent.length > 100) ep.recent.shift(); if (ms > 100) { perfStats.slowQueries.push({ path: req.path, ms: Math.round(ms * 10) / 10, time: new Date().toISOString(), status: res.statusCode }); if (perfStats.slowQueries.length > 50) perfStats.slowQueries.shift(); } origEnd.apply(res, args); }; next(); }); app.get('/api/perf', (req, res) => { const summary = {}; for (const [path, ep] of Object.entries(perfStats.endpoints)) { const sorted = [...ep.recent].sort((a, b) => a - b); const p95 = sorted[Math.floor(sorted.length * 0.95)] || 0; const p50 = sorted[Math.floor(sorted.length * 0.5)] || 0; summary[path] = { count: ep.count, avgMs: Math.round(ep.totalMs / ep.count * 10) / 10, p50Ms: Math.round(p50 * 10) / 10, p95Ms: Math.round(p95 * 10) / 10, maxMs: Math.round(ep.maxMs * 10) / 10, }; } // Sort by total time spent (count * avg) descending const sorted = Object.entries(summary).sort((a, b) => (b[1].count * b[1].avgMs) - (a[1].count * a[1].avgMs)); res.json({ uptime: Math.round((Date.now() - perfStats.startedAt) / 1000), totalRequests: perfStats.requests, avgMs: perfStats.requests ? Math.round(perfStats.totalMs / perfStats.requests * 10) / 10 : 0, endpoints: Object.fromEntries(sorted), slowQueries: perfStats.slowQueries.slice(-20), cache: { size: cache.size, hits: cache.hits, misses: cache.misses, hitRate: cache.hits + cache.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0 }, }); }); app.post('/api/perf/reset', (req, res) => { perfStats.reset(); res.json({ ok: true }); }); // --- WebSocket --- const wss = new WebSocketServer({ server }); function broadcast(msg) { const data = JSON.stringify(msg); wss.clients.forEach(c => { if (c.readyState === 1) c.send(data); }); } // Auto-create stub nodes from path hops (≥2 bytes / 4 hex chars) // When an advert arrives later with a full pubkey matching the prefix, upsertNode will upgrade it const hopNodeCache = new Set(); // Avoid repeated DB lookups for known hops // Shared distance helper (degrees, ~111km/lat, ~85km/lon at 37°N) function geoDist(lat1, lon1, lat2, lon2) { return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2); } // Sequential hop disambiguation: resolve 1-byte prefixes to best-matching nodes // Returns array of {hop, name, lat, lon, pubkey, ambiguous, unreliable} per hop function disambiguateHops(hops, allNodes) { const MAX_HOP_DIST = 1.8; // ~200km // Build prefix index on first call (cached on allNodes array) if (!allNodes._prefixIdx) { allNodes._prefixIdx = {}; allNodes._prefixIdxName = {}; for (const n of allNodes) { const pk = n.public_key.toLowerCase(); for (let len = 1; len <= 3; len++) { const p = pk.slice(0, len * 2); if (!allNodes._prefixIdx[p]) allNodes._prefixIdx[p] = []; allNodes._prefixIdx[p].push(n); if (!allNodes._prefixIdxName[p]) allNodes._prefixIdxName[p] = n; } } } // First pass: find candidates per hop const resolved = hops.map(hop => { const h = hop.toLowerCase(); const withCoords = (allNodes._prefixIdx[h] || []).filter(n => n.lat && n.lon && !(n.lat === 0 && n.lon === 0)); if (withCoords.length === 1) { return { hop, name: withCoords[0].name, lat: withCoords[0].lat, lon: withCoords[0].lon, pubkey: withCoords[0].public_key, known: true }; } else if (withCoords.length > 1) { return { hop, name: hop, lat: null, lon: null, pubkey: null, known: false, candidates: withCoords }; } const nameMatch = allNodes._prefixIdxName[h]; return { hop, name: nameMatch?.name || hop, lat: null, lon: null, pubkey: nameMatch?.public_key || null, known: false }; }); // Forward pass: resolve ambiguous hops by distance to previous let lastPos = null; for (const r of resolved) { if (r.known && r.lat) { lastPos = [r.lat, r.lon]; continue; } if (!r.candidates) continue; if (lastPos) r.candidates.sort((a, b) => geoDist(a.lat, a.lon, lastPos[0], lastPos[1]) - geoDist(b.lat, b.lon, lastPos[0], lastPos[1])); const best = r.candidates[0]; r.name = best.name; r.lat = best.lat; r.lon = best.lon; r.pubkey = best.public_key; r.known = true; lastPos = [r.lat, r.lon]; } // Backward pass let nextPos = null; for (let i = resolved.length - 1; i >= 0; i--) { const r = resolved[i]; if (r.known && r.lat) { nextPos = [r.lat, r.lon]; continue; } if (!r.candidates || !nextPos) continue; r.candidates.sort((a, b) => geoDist(a.lat, a.lon, nextPos[0], nextPos[1]) - geoDist(b.lat, b.lon, nextPos[0], nextPos[1])); const best = r.candidates[0]; r.name = best.name; r.lat = best.lat; r.lon = best.lon; r.pubkey = best.public_key; r.known = true; nextPos = [r.lat, r.lon]; } // Distance sanity check for (let i = 0; i < resolved.length; i++) { const r = resolved[i]; if (!r.lat) continue; const prev = i > 0 && resolved[i-1].lat ? resolved[i-1] : null; const next = i < resolved.length-1 && resolved[i+1].lat ? resolved[i+1] : null; if (!prev && !next) continue; const dPrev = prev ? geoDist(r.lat, r.lon, prev.lat, prev.lon) : 0; const dNext = next ? geoDist(r.lat, r.lon, next.lat, next.lon) : 0; if ((prev && dPrev > MAX_HOP_DIST) && (next && dNext > MAX_HOP_DIST)) { r.unreliable = true; r.lat = null; r.lon = null; } else if (prev && !next && dPrev > MAX_HOP_DIST) { r.unreliable = true; r.lat = null; r.lon = null; } else if (!prev && next && dNext > MAX_HOP_DIST) { r.unreliable = true; r.lat = null; r.lon = null; } } return resolved.map(r => ({ hop: r.hop, name: r.name, lat: r.lat, lon: r.lon, pubkey: r.pubkey, ambiguous: !!r.candidates, unreliable: !!r.unreliable })); } function autoLearnHopNodes(hops, now) { for (const hop of hops) { if (hop.length < 4) continue; // Skip 1-byte hops — too ambiguous if (hopNodeCache.has(hop)) continue; const hopLower = hop.toLowerCase(); const existing = db.db.prepare("SELECT public_key FROM nodes WHERE LOWER(public_key) LIKE ?").get(hopLower + '%'); if (existing) { hopNodeCache.add(hop); continue; } // Create stub node — role is likely repeater (most hops are) db.upsertNode({ public_key: hopLower, name: null, role: 'repeater', lat: null, lon: null, last_seen: now }); hopNodeCache.add(hop); } } // --- MQTT --- try { const mqttClient = mqtt.connect(config.mqtt.broker, { reconnectPeriod: 5000 }); mqttClient.on('connect', () => { console.log(`MQTT connected to ${config.mqtt.broker}`); // Subscribe to both packet-logging format and companion bridge format mqttClient.subscribe(config.mqtt.topic, (err) => { if (err) console.error('MQTT subscribe error:', err); else console.log(`MQTT subscribed to ${config.mqtt.topic}`); }); mqttClient.subscribe('meshcore/#', (err) => { if (err) console.error('MQTT subscribe error (bridge):', err); else console.log('MQTT subscribed to meshcore/#'); }); }); mqttClient.on('error', () => {}); // MQTT errors are expected when broker is offline mqttClient.on('offline', () => console.log('MQTT offline')); mqttClient.on('message', (topic, message) => { try { const msg = JSON.parse(message.toString()); const parts = topic.split('/'); const now = new Date().toISOString(); // --- Format 1: Raw packet logging (meshcoretomqtt / Cisien format) --- // Topic: meshcore///packets, payload: { raw, SNR, RSSI, hash } if (msg.raw && typeof msg.raw === 'string') { const decoded = decoder.decodePacket(msg.raw, channelKeys); const observerId = parts[2] || null; const region = parts[1] || null; const packetId = db.insertPacket({ raw_hex: msg.raw, timestamp: now, observer_id: observerId, snr: msg.SNR ?? null, rssi: msg.RSSI ?? null, hash: computeContentHash(msg.raw), route_type: decoded.header.routeType, payload_type: decoded.header.payloadType, payload_version: decoded.header.payloadVersion, path_json: JSON.stringify(decoded.path.hops), decoded_json: JSON.stringify(decoded.payload), }); if (decoded.path.hops.length > 0) { db.insertPath(packetId, decoded.path.hops); // Auto-create stub nodes from 2+ byte path hops autoLearnHopNodes(decoded.path.hops, now); } if (decoded.header.payloadTypeName === 'ADVERT' && decoded.payload.pubKey) { const p = decoded.payload; const role = p.flags ? (p.flags.repeater ? 'repeater' : p.flags.room ? 'room' : p.flags.sensor ? 'sensor' : 'companion') : 'companion'; db.upsertNode({ public_key: p.pubKey, name: p.name || null, role, lat: p.lat, lon: p.lon, last_seen: now }); } if (observerId) { db.upsertObserver({ id: observerId, iata: region }); } // Invalidate caches on new data cache.invalidate('analytics:'); cache.invalidate('channels'); cache.invalidate('node:'); cache.invalidate('health:'); cache.invalidate('observers'); cache.invalidate('bulk-health'); const broadcastData = { id: packetId, raw: msg.raw, decoded, snr: msg.SNR, rssi: msg.RSSI, hash: msg.hash, observer: observerId }; broadcast({ type: 'packet', data: broadcastData }); if (decoded.header.payloadTypeName === 'GRP_TXT') { broadcast({ type: 'message', data: broadcastData }); } return; } // --- Format 2: Companion bridge (ipnet-mesh/meshcore-mqtt) --- // Topics: meshcore/advertisement, meshcore/message/channel/, meshcore/message/direct/, etc. // Skip status/connection topics if (topic === 'meshcore/status' || topic === 'meshcore/events/connection') return; // Handle self_info - local node identity if (topic === 'meshcore/self_info') { const info = msg.payload || msg; const pubKey = info.pubkey || info.pub_key || info.public_key; if (pubKey) { db.upsertNode({ public_key: pubKey, name: info.name || 'L1 Pro (Local)', role: info.role || 'companion', lat: info.lat ?? null, lon: info.lon ?? null, last_seen: now }); } return; } // Extract event type from topic const eventType = parts.slice(1).join('/'); // Handle advertisements if (topic === 'meshcore/advertisement') { const advert = msg.payload || msg; if (advert.pubkey || advert.pub_key || advert.public_key || advert.name) { const pubKey = advert.pubkey || advert.pub_key || advert.public_key || `node-${(advert.name||'unknown').toLowerCase().replace(/[^a-z0-9]/g, '')}`; const name = advert.name || advert.node_name || null; const lat = advert.lat ?? advert.latitude ?? null; const lon = advert.lon ?? advert.lng ?? advert.longitude ?? null; const role = advert.role || (advert.flags?.repeater ? 'repeater' : advert.flags?.room ? 'room' : 'companion'); db.upsertNode({ public_key: pubKey, name, role, lat, lon, last_seen: now }); const packetId = db.insertPacket({ raw_hex: null, timestamp: now, observer_id: 'companion', observer_name: 'L1 Pro (BLE)', snr: advert.SNR ?? advert.snr ?? null, rssi: advert.RSSI ?? advert.rssi ?? null, hash: 'advert', route_type: 1, // FLOOD payload_type: 4, // ADVERT payload_version: 0, path_json: JSON.stringify([]), decoded_json: JSON.stringify(advert), }); broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'ADVERT' }, payload: advert } } }); } return; } // Handle channel messages if (topic.startsWith('meshcore/message/channel/')) { const channelMsg = msg.payload || msg; const channelIdx = channelMsg.channel_idx ?? msg.attributes?.channel_idx ?? topic.split('/').pop(); const channelHash = `ch${channelIdx}`; // Extract sender name from "Name: message" format const senderName = channelMsg.text?.split(':')[0] || null; // Create/update node for sender if (senderName) { const senderKey = `sender-${senderName.toLowerCase().replace(/[^a-z0-9]/g, '')}`; db.upsertNode({ public_key: senderKey, name: senderName, role: 'companion', lat: null, lon: null, last_seen: now }); } const packetId = db.insertPacket({ raw_hex: null, timestamp: now, observer_id: 'companion', observer_name: 'L1 Pro (BLE)', snr: channelMsg.SNR ?? channelMsg.snr ?? null, rssi: channelMsg.RSSI ?? channelMsg.rssi ?? null, hash: channelHash, route_type: 1, payload_type: 5, // GRP_TXT payload_version: 0, path_json: JSON.stringify([]), decoded_json: JSON.stringify(channelMsg), }); broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } }); broadcast({ type: 'message', data: { id: packetId, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } }); return; } // Handle direct messages if (topic.startsWith('meshcore/message/direct/')) { const dm = msg.payload || msg; const packetId = db.insertPacket({ raw_hex: null, timestamp: dm.timestamp || now, observer_id: 'companion', snr: dm.snr ?? null, rssi: dm.rssi ?? null, hash: null, route_type: 0, payload_type: 2, // TXT_MSG payload_version: 0, path_json: JSON.stringify(dm.hops || []), decoded_json: JSON.stringify(dm), }); broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'TXT_MSG' }, payload: dm } } }); return; } // Handle traceroute if (topic.startsWith('meshcore/traceroute/')) { const trace = msg.payload || msg; const packetId = db.insertPacket({ raw_hex: null, timestamp: now, observer_id: 'companion', snr: null, rssi: null, hash: null, route_type: 1, payload_type: 8, // PATH/TRACE payload_version: 0, path_json: JSON.stringify(trace.hops || trace.path || []), decoded_json: JSON.stringify(trace), }); broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'TRACE' }, payload: trace } } }); return; } } catch (e) { if (topic !== 'meshcore/status' && topic !== 'meshcore/events/connection') { console.error(`MQTT handler error [${topic}]:`, e.message); try { console.error(' payload:', message.toString().substring(0, 200)); } catch {} } } }); } catch (e) { console.error('MQTT connection failed (non-fatal):', e.message); } // --- Express --- app.use(express.json()); // REST API app.get('/api/stats', (req, res) => { const stats = db.getStats(); // Get role counts const counts = {}; for (const role of ['repeater', 'room', 'companion', 'sensor']) { const r = db.db.prepare(`SELECT COUNT(*) as count FROM nodes WHERE role = ?`).get(role); counts[role + 's'] = r.count; } res.json({ ...stats, counts }); }); app.get('/api/packets', (req, res) => { const { limit = 50, offset = 0, type, route, region, observer, hash, since, until, groupByHash, node } = req.query; if (groupByHash === 'true') { let where = []; let params = {}; if (type !== undefined) { where.push('payload_type = @type'); params.type = Number(type); } if (route !== undefined) { where.push('route_type = @route'); params.route = Number(route); } if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = @region)'); params.region = region; } if (observer) { where.push('observer_id = @observer'); params.observer = observer; } if (hash) { where.push('hash = @hash'); params.hash = hash; } if (since) { where.push('timestamp > @since'); params.since = since; } if (until) { where.push('timestamp < @until'); params.until = until; } if (node) { where.push("(decoded_json LIKE @nodePattern OR decoded_json LIKE @nodeNamePattern)"); params.nodePattern = `%${node}%`; const n = db.db.prepare('SELECT name FROM nodes WHERE public_key = ?').get(node); params.nodeNamePattern = n ? `%${n.name}%` : `%${node}%`; } const clause = where.length ? 'WHERE ' + where.join(' AND ') : ''; const packets = db.db.prepare(`SELECT hash, COUNT(DISTINCT observer_id) as observer_count, COUNT(*) as count, MAX(timestamp) as latest, (SELECT observer_id FROM packets pObs WHERE pObs.hash = packets.hash ORDER BY pObs.timestamp ASC LIMIT 1) as observer_id, (SELECT observer_name FROM packets pOn WHERE pOn.hash = packets.hash ORDER BY pOn.timestamp ASC LIMIT 1) as observer_name, (SELECT path_json FROM packets p2 WHERE p2.hash = packets.hash ORDER BY LENGTH(path_json) DESC LIMIT 1) as path_json, (SELECT payload_type FROM packets p3 WHERE p3.hash = packets.hash ORDER BY p3.timestamp DESC LIMIT 1) as payload_type, (SELECT raw_hex FROM packets p4 WHERE p4.hash = packets.hash ORDER BY LENGTH(raw_hex) DESC LIMIT 1) as raw_hex, (SELECT decoded_json FROM packets p5 WHERE p5.hash = packets.hash ORDER BY p5.timestamp DESC LIMIT 1) as decoded_json FROM packets ${clause} GROUP BY hash ORDER BY latest DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit: Number(limit), offset: Number(offset) }); const total = db.db.prepare(`SELECT COUNT(DISTINCT hash) as count FROM packets ${clause}`).get(params).count; return res.json({ packets, total }); } let where = []; let params = {}; if (type !== undefined) { where.push('payload_type = @type'); params.type = Number(type); } if (route !== undefined) { where.push('route_type = @route'); params.route = Number(route); } if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = @region)'); params.region = region; } if (observer) { where.push('observer_id = @observer'); params.observer = observer; } if (hash) { where.push('hash = @hash'); params.hash = hash; } if (since) { where.push('timestamp > @since'); params.since = since; } if (until) { where.push('timestamp < @until'); params.until = until; } if (node) { where.push("(decoded_json LIKE @nodePattern OR decoded_json LIKE @nodeNamePattern)"); params.nodePattern = `%${node}%`; const nn = db.db.prepare('SELECT name FROM nodes WHERE public_key = ?').get(node); params.nodeNamePattern = nn ? `%${nn.name}%` : `%${node}%`; } const clause = where.length ? 'WHERE ' + where.join(' AND ') : ''; const orderDir = req.query.order === 'asc' ? 'ASC' : 'DESC'; const packets = db.db.prepare(`SELECT * FROM packets ${clause} ORDER BY timestamp ${orderDir} LIMIT @limit OFFSET @offset`).all({ ...params, limit: Number(limit), offset: Number(offset) }); const total = db.db.prepare(`SELECT COUNT(*) as count FROM packets ${clause}`).get(params).count; res.json({ packets, total }); }); // Lightweight endpoint: just timestamps for timeline sparkline app.get('/api/packets/timestamps', (req, res) => { const { since } = req.query; if (!since) return res.status(400).json({ error: 'since required' }); const rows = db.db.prepare('SELECT timestamp FROM packets WHERE timestamp > ? ORDER BY timestamp ASC').all(since); res.json(rows.map(r => r.timestamp)); }); app.get('/api/packets/:id', (req, res) => { const packet = db.getPacket(Number(req.params.id)); if (!packet) return res.status(404).json({ error: 'Not found' }); // Use the sibling with the longest path (most hops) for display if (packet.hash) { const best = db.db.prepare('SELECT id, path_json, raw_hex FROM packets WHERE hash = ? ORDER BY LENGTH(path_json) DESC LIMIT 1').get(packet.hash); if (best && best.path_json && best.path_json.length > (packet.path_json || '').length) { packet.path_json = best.path_json; packet.raw_hex = best.raw_hex; } } const pathHops = packet.paths || []; let decoded; try { decoded = JSON.parse(packet.decoded_json); } catch { decoded = null; } // Build byte breakdown const breakdown = buildBreakdown(packet.raw_hex, decoded); res.json({ packet, path: pathHops, breakdown }); }); function buildBreakdown(rawHex, decoded) { if (!rawHex) return {}; const buf = Buffer.from(rawHex, 'hex'); const ranges = []; // Header ranges.push({ start: 0, end: 0, color: 'red', label: 'Header' }); if (buf.length < 2) return { ranges }; // Path length byte ranges.push({ start: 1, end: 1, color: 'orange', label: 'Path Length' }); const header = decoder.decodePacket(rawHex, channelKeys); let offset = 2; // Transport codes if (header.transportCodes) { ranges.push({ start: 2, end: 5, color: 'blue', label: 'Transport Codes' }); offset = 6; } // Path data const pathByte = buf[1]; const hashSize = (pathByte >> 6) + 1; const hashCount = pathByte & 0x3F; const pathBytes = hashSize * hashCount; if (pathBytes > 0) { ranges.push({ start: offset, end: offset + pathBytes - 1, color: 'green', label: 'Path' }); } const payloadStart = offset + pathBytes; // Payload if (payloadStart < buf.length) { ranges.push({ start: payloadStart, end: buf.length - 1, color: 'yellow', label: 'Payload' }); // Sub-ranges for ADVERT if (decoded && decoded.type === 'ADVERT') { const ps = payloadStart; const subRanges = []; subRanges.push({ start: ps, end: ps + 31, color: '#FFD700', label: 'PubKey' }); subRanges.push({ start: ps + 32, end: ps + 35, color: '#FFA500', label: 'Timestamp' }); subRanges.push({ start: ps + 36, end: ps + 99, color: '#FF6347', label: 'Signature' }); if (buf.length > ps + 100) { subRanges.push({ start: ps + 100, end: ps + 100, color: '#7FFFD4', label: 'Flags' }); let off = ps + 101; const flags = buf[ps + 100]; if (flags & 0x10 && buf.length >= off + 8) { subRanges.push({ start: off, end: off + 3, color: '#87CEEB', label: 'Latitude' }); subRanges.push({ start: off + 4, end: off + 7, color: '#87CEEB', label: 'Longitude' }); off += 8; } if (flags & 0x80 && off < buf.length) { subRanges.push({ start: off, end: buf.length - 1, color: '#DDA0DD', label: 'Name' }); } } ranges.push(...subRanges); } } return { ranges }; } // Decode-only endpoint (no DB insert) app.post('/api/decode', (req, res) => { try { const { hex } = req.body; if (!hex) return res.status(400).json({ error: 'hex is required' }); const decoded = decoder.decodePacket(hex.trim().replace(/\s+/g, ''), channelKeys); res.json({ decoded }); } catch (e) { res.status(400).json({ error: e.message }); } }); app.post('/api/packets', (req, res) => { try { const { hex, observer, snr, rssi, region, hash } = req.body; if (!hex) return res.status(400).json({ error: 'hex is required' }); const decoded = decoder.decodePacket(hex, channelKeys); const now = new Date().toISOString(); const packetId = db.insertPacket({ raw_hex: hex.toUpperCase(), timestamp: now, observer_id: observer || null, snr: snr ?? null, rssi: rssi ?? null, hash: computeContentHash(hex), route_type: decoded.header.routeType, payload_type: decoded.header.payloadType, payload_version: decoded.header.payloadVersion, path_json: JSON.stringify(decoded.path.hops), decoded_json: JSON.stringify(decoded.payload), }); if (decoded.path.hops.length > 0) { db.insertPath(packetId, decoded.path.hops); autoLearnHopNodes(decoded.path.hops, new Date().toISOString()); } if (decoded.header.payloadTypeName === 'ADVERT' && decoded.payload.pubKey) { const p = decoded.payload; const role = p.flags ? (p.flags.repeater ? 'repeater' : p.flags.room ? 'room' : p.flags.sensor ? 'sensor' : 'companion') : 'companion'; db.upsertNode({ public_key: p.pubKey, name: p.name || null, role, lat: p.lat, lon: p.lon, last_seen: now }); } if (observer) { db.upsertObserver({ id: observer, iata: region || null }); } // Invalidate caches on new data cache.invalidate('analytics:'); cache.invalidate('channels'); cache.invalidate('node:'); cache.invalidate('health:'); cache.invalidate('observers'); cache.invalidate('bulk-health'); broadcast({ type: 'packet', data: { id: packetId, decoded } }); res.json({ id: packetId, decoded }); } catch (e) { res.status(400).json({ error: e.message }); } }); app.get('/api/nodes', (req, res) => { const { limit = 50, offset = 0, role, region, lastHeard, sortBy = 'lastSeen', search, before } = req.query; let where = []; let params = {}; if (role) { where.push('role = @role'); params.role = role; } if (search) { where.push('name LIKE @search'); params.search = `%${search}%`; } if (before) { where.push('first_seen <= @before'); params.before = before; } if (lastHeard) { const durations = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000, '30d': 2592000000 }; const ms = durations[lastHeard]; if (ms) { where.push('last_seen > @since'); params.since = new Date(Date.now() - ms).toISOString(); } } const clause = where.length ? 'WHERE ' + where.join(' AND ') : ''; const sortMap = { name: 'name ASC', lastSeen: 'last_seen DESC', packetCount: 'advert_count DESC' }; const order = sortMap[sortBy] || 'last_seen DESC'; const nodes = db.db.prepare(`SELECT * FROM nodes ${clause} ORDER BY ${order} LIMIT @limit OFFSET @offset`).all({ ...params, limit: Number(limit), offset: Number(offset) }); const total = db.db.prepare(`SELECT COUNT(*) as count FROM nodes ${clause}`).get(params).count; const counts = {}; for (const r of ['repeater', 'room', 'companion', 'sensor']) { counts[r + 's'] = db.db.prepare(`SELECT COUNT(*) as count FROM nodes WHERE role = ?`).get(r).count; } res.json({ nodes, total, counts }); }); app.get('/api/nodes/search', (req, res) => { const q = req.query.q || ''; if (!q.trim()) return res.json({ nodes: [] }); const nodes = db.searchNodes(q.trim()); res.json({ nodes }); }); // Bulk health summary for analytics — single query approach (MUST be before :pubkey routes) app.get('/api/nodes/bulk-health', (req, res) => { const limit = Math.min(Number(req.query.limit) || 50, 200); const _ck = 'bulk-health:' + limit; const _c = cache.get(_ck); if (_c) return res.json(_c); const nodes = db.db.prepare(`SELECT * FROM nodes ORDER BY last_seen DESC LIMIT ?`).all(limit); const todayStart = new Date(); todayStart.setUTCHours(0, 0, 0, 0); const todayISO = todayStart.toISOString(); if (nodes.length === 0) { cache.set(_ck, [], 60000); return res.json([]); } // Build OR conditions for all nodes to fetch matching packets in ONE query const likeConditions = []; const params = {}; nodes.forEach((node, i) => { params['k' + i] = '%' + node.public_key + '%'; likeConditions.push('decoded_json LIKE @k' + i); if (node.name) { params['n' + i] = '%' + node.name.replace(/[%_]/g, '') + '%'; likeConditions.push('decoded_json LIKE @n' + i); } }); // Single query to get ALL matching packets const allPackets = db.db.prepare( 'SELECT decoded_json, snr, rssi, timestamp, observer_id, observer_name FROM packets WHERE ' + likeConditions.join(' OR ') ).all(params); // Match packets to nodes in JS const nodeMap = new Map(); for (const node of nodes) { nodeMap.set(node.public_key, { node, totalPackets: 0, packetsToday: 0, snrSum: 0, snrCount: 0, lastHeard: null, observers: {} }); } for (const pkt of allPackets) { const dj = pkt.decoded_json || ''; for (const [pk, data] of nodeMap) { const nd = data.node; if (!dj.includes(pk) && !(nd.name && dj.includes(nd.name))) continue; data.totalPackets++; if (pkt.timestamp > todayISO) data.packetsToday++; if (pkt.snr != null) { data.snrSum += pkt.snr; data.snrCount++; } if (!data.lastHeard || pkt.timestamp > data.lastHeard) data.lastHeard = pkt.timestamp; if (pkt.observer_id) { if (!data.observers[pkt.observer_id]) { data.observers[pkt.observer_id] = { name: pkt.observer_name, snrSum: 0, snrCount: 0, rssiSum: 0, rssiCount: 0, count: 0 }; } const obs = data.observers[pkt.observer_id]; obs.count++; if (pkt.snr != null) { obs.snrSum += pkt.snr; obs.snrCount++; } if (pkt.rssi != null) { obs.rssiSum += pkt.rssi; obs.rssiCount++; } } } } const results = []; for (const [pk, data] of nodeMap) { const observerRows = Object.entries(data.observers) .map(([id, o]) => ({ observer_id: id, observer_name: o.name, avgSnr: o.snrCount ? o.snrSum / o.snrCount : null, avgRssi: o.rssiCount ? o.rssiSum / o.rssiCount : null, packetCount: o.count })) .sort((a, b) => b.packetCount - a.packetCount); results.push({ public_key: pk, name: data.node.name, role: data.node.role, lat: data.node.lat, lon: data.node.lon, stats: { totalPackets: data.totalPackets, packetsToday: data.packetsToday, avgSnr: data.snrCount ? data.snrSum / data.snrCount : null, lastHeard: data.lastHeard }, observers: observerRows }); } cache.set(_ck, results, 60000); res.json(results); }); app.get('/api/nodes/network-status', (req, res) => { const now = Date.now(); const allNodes = db.db.prepare('SELECT public_key, name, role, last_seen FROM nodes').all(); let active = 0, degraded = 0, silent = 0; const roleCounts = {}; allNodes.forEach(n => { const r = n.role || 'unknown'; roleCounts[r] = (roleCounts[r] || 0) + 1; const ls = n.last_seen ? new Date(n.last_seen).getTime() : 0; const age = now - ls; const isInfra = r === 'repeater' || r === 'room'; const degradedMs = isInfra ? 86400000 : 3600000; const silentMs = isInfra ? 259200000 : 86400000; if (age < degradedMs) active++; else if (age < silentMs) degraded++; else silent++; }); res.json({ total: allNodes.length, active, degraded, silent, roleCounts }); }); app.get('/api/nodes/:pubkey', (req, res) => { const _ck = 'node:' + req.params.pubkey; const _c = cache.get(_ck); if (_c) return res.json(_c); const node = db.getNode(req.params.pubkey); if (!node) return res.status(404).json({ error: 'Not found' }); const recentAdverts = node.recentPackets || []; delete node.recentPackets; const _nResult = { node, recentAdverts }; cache.set(_ck, _nResult, 30000); res.json(_nResult); }); // --- Analytics API --- // --- RF Analytics --- app.get('/api/analytics/rf', (req, res) => { const _c = cache.get('analytics:rf'); if (_c) return res.json(_c); const PTYPES = { 0:'REQ',1:'RESPONSE',2:'TXT_MSG',3:'ACK',4:'ADVERT',5:'GRP_TXT',7:'ANON_REQ',8:'PATH',9:'TRACE',11:'CONTROL' }; const packets = db.db.prepare(`SELECT snr, rssi, payload_type, timestamp, raw_hex FROM packets WHERE snr IS NOT NULL`).all(); const snrVals = packets.map(p => p.snr).filter(v => v != null); const rssiVals = packets.map(p => p.rssi).filter(v => v != null); const packetSizes = packets.filter(p => p.raw_hex).map(p => p.raw_hex.length / 2); const sorted = arr => [...arr].sort((a, b) => a - b); const median = arr => { const s = sorted(arr); return s.length ? s[Math.floor(s.length/2)] : 0; }; const stddev = (arr, avg) => Math.sqrt(arr.reduce((s, v) => s + (v - avg) ** 2, 0) / Math.max(arr.length, 1)); const snrAvg = snrVals.reduce((a, b) => a + b, 0) / Math.max(snrVals.length, 1); const rssiAvg = rssiVals.reduce((a, b) => a + b, 0) / Math.max(rssiVals.length, 1); // Packets per hour const hourBuckets = {}; packets.forEach(p => { const hr = p.timestamp.slice(0, 13); hourBuckets[hr] = (hourBuckets[hr] || 0) + 1; }); const packetsPerHour = Object.entries(hourBuckets).sort().map(([hour, count]) => ({ hour, count })); // Payload type distribution const typeBuckets = {}; packets.forEach(p => { typeBuckets[p.payload_type] = (typeBuckets[p.payload_type] || 0) + 1; }); const payloadTypes = Object.entries(typeBuckets) .map(([type, count]) => ({ type: +type, name: PTYPES[type] || `UNK(${type})`, count })) .sort((a, b) => b.count - a.count); // SNR by payload type const snrByType = {}; packets.forEach(p => { const name = PTYPES[p.payload_type] || `UNK(${p.payload_type})`; if (!snrByType[name]) snrByType[name] = { vals: [] }; snrByType[name].vals.push(p.snr); }); const snrByTypeArr = Object.entries(snrByType).map(([name, d]) => ({ name, count: d.vals.length, avg: d.vals.reduce((a, b) => a + b, 0) / d.vals.length, min: Math.min(...d.vals), max: Math.max(...d.vals) })).sort((a, b) => b.count - a.count); // Signal over time const sigTime = {}; packets.forEach(p => { const hr = p.timestamp.slice(0, 13); if (!sigTime[hr]) sigTime[hr] = { snrs: [], count: 0 }; sigTime[hr].snrs.push(p.snr); sigTime[hr].count++; }); const signalOverTime = Object.entries(sigTime).sort().map(([hour, d]) => ({ hour, count: d.count, avgSnr: d.snrs.reduce((a, b) => a + b, 0) / d.snrs.length })); // Scatter data (SNR vs RSSI) const scatterData = packets.filter(p => p.snr != null && p.rssi != null).map(p => ({ snr: p.snr, rssi: p.rssi })); const times = packets.map(p => new Date(p.timestamp).getTime()); const timeSpanHours = times.length ? (Math.max(...times) - Math.min(...times)) / 3600000 : 0; const _rfResult = { totalPackets: packets.length, snr: { min: Math.min(...snrVals), max: Math.max(...snrVals), avg: snrAvg, median: median(snrVals), stddev: stddev(snrVals, snrAvg) }, rssi: { min: Math.min(...rssiVals), max: Math.max(...rssiVals), avg: rssiAvg, median: median(rssiVals), stddev: stddev(rssiVals, rssiAvg) }, snrValues: snrVals, rssiValues: rssiVals, packetSizes, minPacketSize: packetSizes.length ? Math.min(...packetSizes) : 0, maxPacketSize: packetSizes.length ? Math.max(...packetSizes) : 0, avgPacketSize: packetSizes.length ? Math.round(packetSizes.reduce((a, b) => a + b, 0) / packetSizes.length) : 0, packetsPerHour, payloadTypes, snrByType: snrByTypeArr, signalOverTime, scatterData, timeSpanHours }; cache.set('analytics:rf', _rfResult, 60000); res.json(_rfResult); }); // --- Topology Analytics --- app.get('/api/analytics/topology', (req, res) => { const _c = cache.get('analytics:topology'); if (_c) return res.json(_c); const packets = db.db.prepare(`SELECT path_json, snr, decoded_json, observer_id FROM packets WHERE path_json IS NOT NULL AND path_json != '[]'`).all(); const allNodes = db.db.prepare('SELECT public_key, name, lat, lon FROM nodes WHERE name IS NOT NULL').all(); const resolveHop = (hop, contextPositions) => { const h = hop.toLowerCase(); const candidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(h)); if (candidates.length === 0) return null; if (candidates.length === 1) return { name: candidates[0].name, pubkey: candidates[0].public_key }; // Disambiguate by proximity to context positions if (contextPositions && contextPositions.length > 0) { const cLat = contextPositions.reduce((s, p) => s + p.lat, 0) / contextPositions.length; const cLon = contextPositions.reduce((s, p) => s + p.lon, 0) / contextPositions.length; const withLoc = candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0)); if (withLoc.length) { withLoc.sort((a, b) => Math.hypot(a.lat - cLat, a.lon - cLon) - Math.hypot(b.lat - cLat, b.lon - cLon)); return { name: withLoc[0].name, pubkey: withLoc[0].public_key }; } } return { name: candidates[0].name, pubkey: candidates[0].public_key }; }; // Hop distribution const hopCounts = {}; const allHopsList = []; const hopSnr = {}; const hopFreq = {}; const pairFreq = {}; packets.forEach(p => { const hops = JSON.parse(p.path_json); const n = hops.length; hopCounts[n] = (hopCounts[n] || 0) + 1; allHopsList.push(n); if (!hopSnr[n]) hopSnr[n] = []; if (p.snr != null) hopSnr[n].push(p.snr); hops.forEach(h => { hopFreq[h] = (hopFreq[h] || 0) + 1; }); for (let i = 0; i < hops.length - 1; i++) { const pair = [hops[i], hops[i + 1]].sort().join('|'); pairFreq[pair] = (pairFreq[pair] || 0) + 1; } }); const hopDistribution = Object.entries(hopCounts) .map(([hops, count]) => ({ hops: +hops, count })) .filter(h => h.hops <= 25) .sort((a, b) => a.hops - b.hops); const avgHops = allHopsList.length ? allHopsList.reduce((a, b) => a + b, 0) / allHopsList.length : 0; const medianHops = allHopsList.length ? [...allHopsList].sort((a, b) => a - b)[Math.floor(allHopsList.length / 2)] : 0; const maxHops = allHopsList.length ? Math.max(...allHopsList) : 0; // Top repeaters const topRepeaters = Object.entries(hopFreq) .sort((a, b) => b[1] - a[1]) .slice(0, 20) .map(([hop, count]) => { const resolved = resolveHop(hop); return { hop, count, name: resolved?.name || null, pubkey: resolved?.pubkey || null }; }); // Top pairs const topPairs = Object.entries(pairFreq) .sort((a, b) => b[1] - a[1]) .slice(0, 15) .map(([pair, count]) => { const [a, b] = pair.split('|'); const rA = resolveHop(a), rB = resolveHop(b); return { hopA: a, hopB: b, count, nameA: rA?.name, nameB: rB?.name, pubkeyA: rA?.pubkey, pubkeyB: rB?.pubkey }; }); // Hops vs SNR const hopsVsSnr = Object.entries(hopSnr) .filter(([h]) => +h <= 20) .map(([hops, snrs]) => ({ hops: +hops, count: snrs.length, avgSnr: snrs.reduce((a, b) => a + b, 0) / snrs.length })) .sort((a, b) => a.hops - b.hops); // Reachability: per-observer hop distances + cross-observer comparison + best path const observers = db.db.prepare(`SELECT DISTINCT observer_id, observer_name FROM packets WHERE path_json IS NOT NULL AND path_json != '[]'`).all(); // Per-observer: node → min hop distance seen from that observer const perObserver = {}; // observer_id → { hop_hex → { minDist, maxDist, count } } const bestPath = {}; // hop_hex → { minDist, observer } const crossObserver = {}; // hop_hex → [ { observer_id, observer_name, minDist, count } ] packets.forEach(p => { const obsId = p.observer_id; if (!perObserver[obsId]) perObserver[obsId] = {}; const hops = JSON.parse(p.path_json); hops.forEach((h, i) => { const dist = hops.length - i; if (!perObserver[obsId][h]) perObserver[obsId][h] = { minDist: dist, maxDist: dist, count: 0 }; const entry = perObserver[obsId][h]; entry.minDist = Math.min(entry.minDist, dist); entry.maxDist = Math.max(entry.maxDist, dist); entry.count++; }); }); // Build cross-observer and best-path from perObserver for (const [obsId, nodes] of Object.entries(perObserver)) { const obsName = observers.find(o => o.observer_id === obsId)?.observer_name || obsId; for (const [hop, data] of Object.entries(nodes)) { // Cross-observer if (!crossObserver[hop]) crossObserver[hop] = []; crossObserver[hop].push({ observer_id: obsId, observer_name: obsName, minDist: data.minDist, count: data.count }); // Best path if (!bestPath[hop] || data.minDist < bestPath[hop].minDist) { bestPath[hop] = { minDist: data.minDist, observer_id: obsId, observer_name: obsName }; } } } // Format per-observer reachability (grouped by distance) const perObserverReach = {}; for (const [obsId, nodes] of Object.entries(perObserver)) { const obsInfo = observers.find(o => o.observer_id === obsId); const byDist = {}; for (const [hop, data] of Object.entries(nodes)) { const d = data.minDist; if (d > 15) continue; if (!byDist[d]) byDist[d] = []; const r = resolveHop(hop); byDist[d].push({ hop, name: r?.name || null, pubkey: r?.pubkey || null, count: data.count, distRange: data.minDist === data.maxDist ? null : `${data.minDist}-${data.maxDist}` }); } perObserverReach[obsId] = { observer_name: obsInfo?.observer_name || obsId, rings: Object.entries(byDist).map(([dist, nodes]) => ({ hops: +dist, nodes: nodes.sort((a, b) => b.count - a.count) })).sort((a, b) => a.hops - b.hops) }; } // Cross-observer: nodes seen by multiple observers const multiObsNodes = Object.entries(crossObserver) .filter(([, obs]) => obs.length > 1) .map(([hop, obs]) => { const r = resolveHop(hop); return { hop, name: r?.name || null, pubkey: r?.pubkey || null, observers: obs.sort((a, b) => a.minDist - b.minDist) }; }) .sort((a, b) => b.observers.length - a.observers.length) .slice(0, 50); // Best path: sorted by distance const bestPathList = Object.entries(bestPath) .map(([hop, data]) => { const r = resolveHop(hop); return { hop, name: r?.name || null, pubkey: r?.pubkey || null, ...data }; }) .sort((a, b) => a.minDist - b.minDist) .slice(0, 50); const _topoResult = { uniqueNodes: new Set(Object.keys(hopFreq)).size, avgHops, medianHops, maxHops, hopDistribution, topRepeaters, topPairs, hopsVsSnr, observers: observers.map(o => ({ id: o.observer_id, name: o.observer_name || o.observer_id })), perObserverReach, multiObsNodes, bestPathList }; cache.set('analytics:topology', _topoResult, 60000); res.json(_topoResult); }); // --- Channel Analytics --- app.get('/api/analytics/channels', (req, res) => { const _c = cache.get('analytics:channels'); if (_c) return res.json(_c); const packets = db.db.prepare(`SELECT decoded_json, timestamp FROM packets WHERE payload_type = 5 AND decoded_json IS NOT NULL`).all(); const channels = {}; const senderCounts = {}; const msgLengths = []; const timeline = {}; packets.forEach(p => { try { const d = JSON.parse(p.decoded_json); const hash = d.channelHash || d.channel_hash || '?'; const name = d.channelName || (d.type === 'CHAN' ? (d.channel || `ch${hash}`) : `ch${hash}`); const encrypted = !d.text && !d.sender; if (!channels[hash]) channels[hash] = { hash, name, messages: 0, senders: new Set(), lastActivity: p.timestamp, encrypted }; channels[hash].messages++; channels[hash].lastActivity = p.timestamp; if (!encrypted) channels[hash].encrypted = false; if (d.sender) { channels[hash].senders.add(d.sender); senderCounts[d.sender] = (senderCounts[d.sender] || 0) + 1; } if (d.text) msgLengths.push(d.text.length); // Timeline const hr = p.timestamp.slice(0, 13); const key = hr + '|' + (name || `ch${hash}`); timeline[key] = (timeline[key] || 0) + 1; } catch {} }); const channelList = Object.values(channels) .map(c => ({ ...c, senders: c.senders.size })) .sort((a, b) => b.messages - a.messages); const topSenders = Object.entries(senderCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 15) .map(([name, count]) => ({ name, count })); const channelTimeline = Object.entries(timeline) .map(([key, count]) => { const [hour, channel] = key.split('|'); return { hour, channel, count }; }) .sort((a, b) => a.hour.localeCompare(b.hour)); const _chanResult = { activeChannels: channelList.length, decryptable: channelList.filter(c => !c.encrypted).length, channels: channelList, topSenders, channelTimeline, msgLengths }; cache.set('analytics:channels', _chanResult, 60000); res.json(_chanResult); }); app.get('/api/analytics/hash-sizes', (req, res) => { const _c = cache.get('analytics:hash-sizes'); if (_c) return res.json(_c); // Get all packets with raw_hex and non-empty paths, extract hash_size from path_length byte const packets = db.db.prepare(` SELECT raw_hex, path_json, timestamp, payload_type, decoded_json FROM packets WHERE raw_hex IS NOT NULL AND path_json IS NOT NULL AND path_json != '[]' ORDER BY timestamp DESC `).all(); const distribution = { 1: 0, 2: 0, 3: 0 }; const byHour = {}; // hour bucket → { 1: n, 2: n, 3: n } const byNode = {}; // node name/prefix → { hashSize, packets, lastSeen } const uniqueHops = {}; // hop hex → { size, count, resolvedName } // Resolve all known nodes for hop matching const allNodes = db.db.prepare('SELECT public_key, name FROM nodes WHERE name IS NOT NULL').all(); for (const p of packets) { const pathByte = parseInt(p.raw_hex.slice(2, 4), 16); // Check if this packet has transport codes (route type 0 or 3) const header = parseInt(p.raw_hex.slice(0, 2), 16); const routeType = header & 0x03; let pathByteIdx = 1; // normally byte index 1 if (routeType === 0 || routeType === 3) pathByteIdx = 5; // skip 4 transport code bytes const actualPathByte = parseInt(p.raw_hex.slice(pathByteIdx * 2, pathByteIdx * 2 + 2), 16); const hashSize = ((actualPathByte >> 6) & 0x3) + 1; const hashCount = actualPathByte & 0x3F; if (hashSize > 3) continue; // reserved distribution[hashSize] = (distribution[hashSize] || 0) + 1; // Hourly buckets const hour = p.timestamp.slice(0, 13); // "2026-03-18T04" if (!byHour[hour]) byHour[hour] = { 1: 0, 2: 0, 3: 0 }; byHour[hour][hashSize]++; // Track unique hops with their sizes const hops = JSON.parse(p.path_json); for (const hop of hops) { if (!uniqueHops[hop]) { const hopLower = hop.toLowerCase(); const match = allNodes.find(n => n.public_key.toLowerCase().startsWith(hopLower)); uniqueHops[hop] = { size: Math.ceil(hop.length / 2), count: 0, name: match?.name || null, pubkey: match?.public_key || null }; } uniqueHops[hop].count++; } // Try to identify originator from decoded_json for advert packets if (p.payload_type === 4) { try { const d = JSON.parse(p.decoded_json); const name = d.name || (d.pubKey || d.public_key || '').slice(0, 8); if (name) { if (!byNode[name]) byNode[name] = { hashSize, packets: 0, lastSeen: p.timestamp, pubkey: d.pubKey || d.public_key || null }; byNode[name].packets++; byNode[name].hashSize = hashSize; byNode[name].lastSeen = p.timestamp; } } catch {} } } // Sort hourly data const hourly = Object.entries(byHour) .sort(([a], [b]) => a.localeCompare(b)) .map(([hour, sizes]) => ({ hour, ...sizes })); // Top hops by frequency const topHops = Object.entries(uniqueHops) .sort(([, a], [, b]) => b.count - a.count) .slice(0, 50) .map(([hex, data]) => ({ hex, ...data })); // Nodes that use non-default (>1 byte) hash sizes const multiByteNodes = Object.entries(byNode) .filter(([, v]) => v.hashSize > 1) .sort(([, a], [, b]) => b.packets - a.packets) .map(([name, data]) => ({ name, ...data })); const _hsResult = { total: packets.length, distribution, hourly, topHops, multiByteNodes }; cache.set('analytics:hash-sizes', _hsResult, 60000); res.json(_hsResult); }); // Resolve path hop hex prefixes to node names app.get('/api/resolve-hops', (req, res) => { const hops = (req.query.hops || '').split(',').filter(Boolean); const observerId = req.query.observer || null; if (!hops.length) return res.json({ resolved: {} }); const allNodes = db.db.prepare('SELECT public_key, name, lat, lon FROM nodes WHERE name IS NOT NULL').all(); // Build observer geographic position let observerLat = null, observerLon = null; if (observerId) { // Try exact name match first const obsNode = allNodes.find(n => n.name === observerId); if (obsNode && obsNode.lat && obsNode.lon && !(obsNode.lat === 0 && obsNode.lon === 0)) { observerLat = obsNode.lat; observerLon = obsNode.lon; } else { // Fall back to averaging nearby nodes from adverts this observer received const obsNodes = db.db.prepare(` SELECT n.lat, n.lon FROM packets p JOIN nodes n ON n.public_key = json_extract(p.decoded_json, '$.pubKey') WHERE (p.observer_id = ? OR p.observer_name = ?) AND p.payload_type = 4 AND n.lat IS NOT NULL AND n.lat != 0 AND n.lon != 0 GROUP BY n.public_key ORDER BY COUNT(*) DESC LIMIT 20 `).all(observerId, observerId); if (obsNodes.length) { observerLat = obsNodes.reduce((s, n) => s + n.lat, 0) / obsNodes.length; observerLon = obsNodes.reduce((s, n) => s + n.lon, 0) / obsNodes.length; } } } const resolved = {}; // First pass: find all candidates for each hop for (const hop of hops) { const hopLower = hop.toLowerCase(); const candidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hopLower)); if (candidates.length === 0) { resolved[hop] = { name: null, candidates: [] }; } else if (candidates.length === 1) { resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, candidates: [{ name: candidates[0].name, pubkey: candidates[0].public_key }] }; } else { resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, ambiguous: true, candidates: candidates.map(c => ({ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon })) }; } } // Sequential disambiguation: each hop must be near the previous one // Walk the path forward, resolving ambiguous hops by distance to last known position // Start from first unambiguous hop (or observer position as anchor for last hop) // Build initial resolved positions map const hopPositions = {}; // hop -> {lat, lon} for (const hop of hops) { const r = resolved[hop]; if (r && !r.ambiguous && r.pubkey) { const node = allNodes.find(n => n.public_key === r.pubkey); if (node && node.lat && node.lon && !(node.lat === 0 && node.lon === 0)) { hopPositions[hop] = { lat: node.lat, lon: node.lon }; } } } const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2); // Forward pass: resolve each ambiguous hop using previous hop's position let lastPos = null; for (let hi = 0; hi < hops.length; hi++) { const hop = hops[hi]; if (hopPositions[hop]) { lastPos = hopPositions[hop]; continue; } const r = resolved[hop]; if (!r || !r.ambiguous) continue; const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0)); if (!withLoc.length) continue; // Use previous hop position, or observer position for last hop, or skip let anchor = lastPos; if (!anchor && hi === hops.length - 1 && observerLat != null) { anchor = { lat: observerLat, lon: observerLon }; } if (anchor) { withLoc.sort((a, b) => dist(a.lat, a.lon, anchor.lat, anchor.lon) - dist(b.lat, b.lon, anchor.lat, anchor.lon)); } r.name = withLoc[0].name; r.pubkey = withLoc[0].pubkey; hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon }; lastPos = hopPositions[hop]; } // Backward pass: resolve any remaining ambiguous hops using next hop's position let nextPos = observerLat != null ? { lat: observerLat, lon: observerLon } : null; for (let hi = hops.length - 1; hi >= 0; hi--) { const hop = hops[hi]; if (hopPositions[hop]) { nextPos = hopPositions[hop]; continue; } const r = resolved[hop]; if (!r || !r.ambiguous) continue; const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0)); if (!withLoc.length || !nextPos) continue; withLoc.sort((a, b) => dist(a.lat, a.lon, nextPos.lat, nextPos.lon) - dist(b.lat, b.lon, nextPos.lat, nextPos.lon)); r.name = withLoc[0].name; r.pubkey = withLoc[0].pubkey; hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon }; nextPos = hopPositions[hop]; } // Sanity check: drop hops impossibly far from both neighbors (>200km ≈ 1.8°) const MAX_HOP_DIST = 1.8; for (let i = 0; i < hops.length; i++) { const pos = hopPositions[hops[i]]; if (!pos) continue; const prev = i > 0 ? hopPositions[hops[i-1]] : null; const next = i < hops.length-1 ? hopPositions[hops[i+1]] : null; if (!prev && !next) continue; const dPrev = prev ? dist(pos.lat, pos.lon, prev.lat, prev.lon) : 0; const dNext = next ? dist(pos.lat, pos.lon, next.lat, next.lon) : 0; const tooFarPrev = prev && dPrev > MAX_HOP_DIST; const tooFarNext = next && dNext > MAX_HOP_DIST; if ((tooFarPrev && tooFarNext) || (tooFarPrev && !next) || (tooFarNext && !prev)) { // Mark as unreliable — likely prefix collision with distant node const r = resolved[hops[i]]; if (r) { r.unreliable = true; } delete hopPositions[hops[i]]; } } res.json({ resolved }); }); // Channel hash → name mapping from configured keys const channelHashNames = {}; { const crypto = require('crypto'); for (const [name, key] of Object.entries(channelKeys)) { const hash = crypto.createHash('sha256').update(Buffer.from(key, 'hex')).digest()[0]; channelHashNames[hash] = name; } } app.get('/api/channels', (req, res) => { const _c = cache.get('channels'); if (_c) return res.json(_c); const packets = db.db.prepare(`SELECT * FROM packets WHERE payload_type = 5 ORDER BY timestamp DESC`).all(); const channelMap = {}; for (const pkt of packets) { let decoded; try { decoded = JSON.parse(pkt.decoded_json); } catch { continue; } const ch = decoded.channelHash !== undefined ? decoded.channelHash : decoded.channel_idx; if (ch === undefined) continue; const knownName = channelHashNames[ch]; // If hash matches a known channel but decryption failed, it's a collision — separate bucket const isDecrypted = decoded.type === 'CHAN' || decoded.text; const isCollision = !!(knownName && !isDecrypted && decoded.encryptedData); const key = isCollision ? `unk_${ch}` : String(ch); if (!channelMap[key]) { channelMap[key] = { hash: key, name: isCollision ? `Unknown (hash 0x${Number(ch).toString(16).toUpperCase()})` : (knownName || `Channel 0x${Number(ch).toString(16).toUpperCase()}`), encrypted: isCollision || !knownName, lastMessage: null, lastSender: null, messageCount: 0, lastActivity: pkt.timestamp, }; } channelMap[key].messageCount++; if (!channelMap[key].lastMessage || pkt.timestamp >= channelMap[key].lastActivity) { channelMap[key].lastActivity = pkt.timestamp; if (decoded.text) { const colonIdx = decoded.text.indexOf(': '); channelMap[key].lastMessage = colonIdx > 0 ? decoded.text.slice(colonIdx + 2) : decoded.text; channelMap[key].lastSender = decoded.sender || null; } } } // Also include companion bridge messages (no raw_hex, have text directly) const companionPkts = db.db.prepare(`SELECT * FROM packets WHERE payload_type = 5 AND raw_hex IS NULL ORDER BY timestamp DESC`).all(); for (const pkt of companionPkts) { let decoded; try { decoded = JSON.parse(pkt.decoded_json); } catch { continue; } const ch = decoded.channel_idx !== undefined ? `c${decoded.channel_idx}` : null; if (!ch) continue; if (!channelMap[ch]) { channelMap[ch] = { hash: ch, name: `Companion Ch ${decoded.channel_idx}`, encrypted: false, lastMessage: null, lastSender: null, messageCount: 0, lastActivity: pkt.timestamp, }; } // Don't double-count if already counted above } const _chResult = { channels: Object.values(channelMap) }; cache.set('channels', _chResult, 30000); res.json(_chResult); }); app.get('/api/channels/:hash/messages', (req, res) => { const _ck = 'channels:' + req.params.hash + ':' + (req.query.limit||100) + ':' + (req.query.offset||0); const _c = cache.get(_ck); if (_c) return res.json(_c); const { limit = 100, offset = 0 } = req.query; const channelHash = req.params.hash; const packets = db.db.prepare(`SELECT * FROM packets WHERE payload_type = 5 ORDER BY timestamp ASC`).all(); // Group by message content + timestamp to deduplicate repeats const msgMap = new Map(); for (const pkt of packets) { let decoded; try { decoded = JSON.parse(pkt.decoded_json); } catch { continue; } const rawCh = decoded.channelHash !== undefined ? decoded.channelHash : decoded.channel_idx; const isDecrypted = decoded.type === 'CHAN' || decoded.text; const knownName = channelHashNames[rawCh]; const isCollision = !!(knownName && !isDecrypted && decoded.encryptedData); const ch = isCollision ? `unk_${rawCh}` : (rawCh !== undefined ? String(rawCh) : (decoded.channel_idx !== undefined ? `c${decoded.channel_idx}` : null)); if (ch !== channelHash) continue; const sender = decoded.sender || (decoded.text ? decoded.text.split(': ')[0] : null) || pkt.observer_name || pkt.observer_id || 'Unknown'; const text = decoded.text || decoded.encryptedData || ''; const ts = decoded.sender_timestamp || pkt.timestamp; const dedupeKey = `${sender}:${ts}`; if (msgMap.has(dedupeKey)) { const existing = msgMap.get(dedupeKey); existing.repeats++; if (pkt.observer_name && !existing.observers.includes(pkt.observer_name)) { existing.observers.push(pkt.observer_name); } } else { // Parse sender and message from "sender: message" format let displaySender = sender; let displayText = text; if (decoded.text) { const colonIdx = decoded.text.indexOf(': '); if (colonIdx > 0 && colonIdx < 50) { displaySender = decoded.text.slice(0, colonIdx); displayText = decoded.text.slice(colonIdx + 2); } } msgMap.set(dedupeKey, { sender: displaySender, text: displayText, encrypted: !decoded.text && !decoded.sender, timestamp: pkt.timestamp, sender_timestamp: decoded.sender_timestamp || null, packetId: pkt.id, repeats: 1, observers: [pkt.observer_name || pkt.observer_id].filter(Boolean), hops: decoded.path_len || (pkt.path_json ? JSON.parse(pkt.path_json).length : 0), snr: pkt.snr || (decoded.SNR !== undefined ? decoded.SNR : null), }); } } const allMessages = [...msgMap.values()]; const total = allMessages.length; // Return the latest messages (tail), not the oldest (head) const start = Math.max(0, total - Number(limit) - Number(offset)); const end = total - Number(offset); const messages = allMessages.slice(Math.max(0, start), Math.max(0, end)); const _msgResult = { messages, total }; cache.set(_ck, _msgResult, 15000); res.json(_msgResult); }); app.get('/api/observers', (req, res) => { const _c = cache.get('observers'); if (_c) return res.json(_c); const observers = db.getObservers(); const oneHourAgo = new Date(Date.now() - 3600000).toISOString(); const result = observers.map(o => { const lastHour = db.db.prepare(`SELECT COUNT(*) as count FROM packets WHERE observer_id = ? AND timestamp > ?`).get(o.id, oneHourAgo); return { ...o, packetsLastHour: lastHour.count }; }); const _oResult = { observers: result, server_time: new Date().toISOString() }; cache.set('observers', _oResult, 30000); res.json(_oResult); }); app.get('/api/traces/:hash', (req, res) => { const packets = db.db.prepare(`SELECT observer_id, timestamp, snr, rssi FROM packets WHERE hash = ? ORDER BY timestamp`).all(req.params.hash); const traces = packets.map(p => ({ observer: p.observer_id, time: p.timestamp, snr: p.snr, rssi: p.rssi })); res.json({ traces }); }); app.get('/api/nodes/:pubkey/health', (req, res) => { const _ck = 'health:' + req.params.pubkey; const _c = cache.get(_ck); if (_c) return res.json(_c); const health = db.getNodeHealth(req.params.pubkey); if (!health) return res.status(404).json({ error: 'Not found' }); cache.set(_ck, health, 30000); res.json(health); }); app.get('/api/nodes/:pubkey/analytics', (req, res) => { const days = Math.min(Math.max(Number(req.query.days) || 7, 1), 365); const data = db.getNodeAnalytics(req.params.pubkey, days); if (!data) return res.status(404).json({ error: 'Not found' }); res.json(data); }); // Subpath frequency analysis app.get('/api/analytics/subpaths', (req, res) => { const _ck = 'analytics:subpaths:' + (req.query.minLen||2) + ':' + (req.query.maxLen||8) + ':' + (req.query.limit||100); const _c = cache.get(_ck); if (_c) return res.json(_c); const minLen = Math.max(2, Number(req.query.minLen) || 2); const maxLen = Number(req.query.maxLen) || 8; const packets = db.db.prepare(`SELECT path_json FROM packets WHERE path_json IS NOT NULL AND path_json != '[]'`).all(); const allNodes = db.db.prepare('SELECT public_key, name, lat, lon FROM nodes WHERE name IS NOT NULL').all(); // Disambiguate per path with caching (same hop sequence = same result) const disambigCache = {}; function cachedDisambiguate(hops) { const key = hops.join(','); if (disambigCache[key]) return disambigCache[key]; const result = disambiguateHops(hops, allNodes); disambigCache[key] = result; return result; } const subpathCounts = {}; let totalPaths = 0; for (const pkt of packets) { let hops; try { hops = JSON.parse(pkt.path_json); } catch { continue; } if (!Array.isArray(hops) || hops.length < 2) continue; totalPaths++; const resolved = cachedDisambiguate(hops); const named = resolved.map(r => r.name); // Extract all subpaths of length minLen..maxLen for (let len = minLen; len <= Math.min(maxLen, named.length); len++) { for (let start = 0; start <= named.length - len; start++) { const sub = named.slice(start, start + len).join(' → '); const raw = hops.slice(start, start + len).join(','); if (!subpathCounts[sub]) subpathCounts[sub] = { count: 0, raw }; subpathCounts[sub].count++; } } } // Sort by frequency, return top results const limit = Number(req.query.limit) || 100; const ranked = Object.entries(subpathCounts) .map(([path, data]) => ({ path, rawHops: data.raw.split(','), count: data.count, hops: path.split(' → ').length, pct: totalPaths > 0 ? Math.round(data.count / totalPaths * 1000) / 10 : 0 })) .sort((a, b) => b.count - a.count) .slice(0, limit); const _spResult = { subpaths: ranked, totalPaths }; cache.set(_ck, _spResult, 60000); res.json(_spResult); }); // Subpath detail — stats for a specific subpath (by raw hop prefixes) app.get('/api/analytics/subpath-detail', (req, res) => { const rawHops = (req.query.hops || '').split(',').filter(Boolean); if (rawHops.length < 2) return res.json({ error: 'Need at least 2 hops' }); const packets = db.db.prepare(`SELECT path_json, snr, rssi, timestamp, decoded_json, observer_name FROM packets WHERE path_json IS NOT NULL AND path_json != '[]'`).all(); const allNodes = db.db.prepare('SELECT public_key, name, lat, lon FROM nodes WHERE name IS NOT NULL').all(); // Disambiguate the requested hops const resolvedHops = disambiguateHops(rawHops, allNodes); const matching = []; const parentPaths = {}; const hourBuckets = new Array(24).fill(0); let snrSum = 0, snrCount = 0, rssiSum = 0, rssiCount = 0; const observers = {}; const _detailCache = {}; for (const pkt of packets) { let hops; try { hops = JSON.parse(pkt.path_json); } catch { continue; } if (!Array.isArray(hops) || hops.length < rawHops.length) continue; // Check if rawHops appears as a contiguous subsequence let found = false; for (let i = 0; i <= hops.length - rawHops.length; i++) { let match = true; for (let j = 0; j < rawHops.length; j++) { if (hops[i + j].toLowerCase() !== rawHops[j].toLowerCase()) { match = false; break; } } if (match) { found = true; break; } } if (!found) continue; matching.push(pkt); const hr = new Date(pkt.timestamp).getUTCHours(); hourBuckets[hr]++; if (pkt.snr != null) { snrSum += pkt.snr; snrCount++; } if (pkt.rssi != null) { rssiSum += pkt.rssi; rssiCount++; } if (pkt.observer_name) observers[pkt.observer_name] = (observers[pkt.observer_name] || 0) + 1; // Track full parent paths (disambiguated, cached) const cacheKey = hops.join(','); if (!_detailCache[cacheKey]) _detailCache[cacheKey] = disambiguateHops(hops, allNodes); const fullPath = _detailCache[cacheKey].map(r => r.name).join(' → '); parentPaths[fullPath] = (parentPaths[fullPath] || 0) + 1; } // Use disambiguated nodes for map const nodes = resolvedHops.map(r => ({ hop: r.hop, name: r.name, lat: r.lat, lon: r.lon, pubkey: r.pubkey })); const topParents = Object.entries(parentPaths) .sort((a, b) => b[1] - a[1]) .slice(0, 15) .map(([path, count]) => ({ path, count })); const topObservers = Object.entries(observers) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([name, count]) => ({ name, count })); res.json({ hops: rawHops, nodes, totalMatches: matching.length, firstSeen: matching.length ? matching[0].timestamp : null, lastSeen: matching.length ? matching[matching.length - 1].timestamp : null, signal: { avgSnr: snrCount ? Math.round(snrSum / snrCount * 10) / 10 : null, avgRssi: rssiCount ? Math.round(rssiSum / rssiCount) : null, samples: snrCount }, hourDistribution: hourBuckets, parentPaths: topParents, observers: topObservers }); }); // Static files + SPA fallback app.use(express.static(path.join(__dirname, 'public'), { etag: false, lastModified: false, setHeaders: (res, filePath) => { if (filePath.endsWith('.js') || filePath.endsWith('.css') || filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); } } })); app.get('/{*splat}', (req, res) => { const indexPath = path.join(__dirname, 'public', 'index.html'); const fs = require('fs'); if (fs.existsSync(indexPath)) { res.sendFile(indexPath); } else { res.status(200).send('

MeshCore Analyzer

Frontend not yet built.

'); } }); // --- Start --- server.listen(process.env.PORT || config.port, () => { console.log(`MeshCore Analyzer running on http://localhost:${config.port}`); }); module.exports = { app, server, wss };