From 04529be33b0b454bd4ec63c55f0cfefa61df9975 Mon Sep 17 00:00:00 2001 From: iceice400 Date: Sat, 6 Jun 2026 04:50:04 -0400 Subject: [PATCH] feat(nodes): signal health sparklines and RSSI health badge Add 15-minute RSSI/SNR buckets via metrics_history, optional signal_health config, canvas sparklines on node cards with lazy IntersectionObserver loading, and green/yellow/red health badges from 1-hour rolling averages. --- config/default.yaml | 27 ++--- frontend/css/node_cards.css | 53 ++++++++++ frontend/index.html | 3 + frontend/js/node_cards.js | 131 ++++++++++++++++++++++-- frontend/js/signal_sparkline.js | 145 +++++++++++++++++++++++++++ src/api/routes/config_enrichment.py | 21 ++-- src/api/routes/nodes.py | 13 ++- src/config.py | 148 +++++++++++----------------- src/storage/packet_repository.py | 118 ++++++++++++++++++++-- tests/test_signal_buckets.py | 104 +++++++++++++++++++ 10 files changed, 637 insertions(+), 126 deletions(-) create mode 100644 frontend/js/signal_sparkline.js create mode 100644 tests/test_signal_buckets.py diff --git a/config/default.yaml b/config/default.yaml index b8f7bad..7e1b743 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -11,6 +11,11 @@ radio: sync_word: 0x2B preamble_length: 16 tx_power_dbm: 22 + # HAL GPS/PPS (RAK Pi HAT u-blox → concentrator PPS pin). Off by default. + # gps_pps_enabled: false + # gps_pps_tty_path: "/dev/ttyAMA0" + # gps_family: "ubx7" + # gps_pps_target_baud: 0 # 0 = HAL default (9600) meshtastic: default_key_b64: "AQ==" @@ -50,7 +55,7 @@ device: longitude: 0.0 altitude: 25 hardware_description: "RAK2287 + Raspberry Pi 4" - firmware_version: "0.7.6" + firmware_version: "0.7.5.1" relay: enabled: false @@ -80,13 +85,6 @@ transmit: nodeinfo: interval_minutes: 180 # 5..1440 min, default 180 (3 hr); 0 = disabled startup_delay_seconds: 60 # delay before first broadcast on boot - position: - interval_minutes: 15 - startup_delay_seconds: 180 - # LoRa mesh POSITION packets (Meshtastic app map). Meshradar fleet pin - # always uses device.latitude/longitude above, not live GPS. - coordinate_source: "static" # static | live (live needs gpsd/uart source) - location_precision: "approximate" # exact | approximate | none (live only) web_auth: # First-run state: hashes are empty -> dashboard forces /setup. @@ -124,18 +122,23 @@ mqtt: homeassistant_discovery: false location: - # Where live GPS fixes come from (skyplot + optional mesh POSITION). - # Registered coordinates for Meshradar live in device.latitude/longitude - # and are not overwritten by gpsd. + # Where the Meshpoint's reported lat/lon/alt comes from. # static : use device.latitude/longitude/altitude (default) # gpsd : poll a local or remote gpsd daemon for live fixes - # uart : reserved (RAK Pi HAT GPS, not yet wired) + # uart : on-board RAK Pi HAT GPS via /dev/ttyAMA0 (NMEA GGA) source: "static" # gpsd connection. Defaults match the well-known local socket; only # change when running gpsd on a peer machine on the LAN. gpsd_host: "127.0.0.1" gpsd_port: 2947 + uart_path: "/dev/ttyAMA0" + uart_baud: 9600 # How often the coordinator wakes to read the active source. update_interval_seconds: 5 # Minimum acceptable fix mode: 0=any (incl. no-fix), 1=2D, 2=3D. min_fix_quality: 1 + +# signal_health: +# green_rssi_floor: -100 # badge green when 1h avg RSSI is above this +# yellow_rssi_floor: -115 # badge yellow above this, red below +# min_packets_per_hour: 5 # hide badge when fewer packets in the last hour diff --git a/frontend/css/node_cards.css b/frontend/css/node_cards.css index 7df888b..6443986 100644 --- a/frontend/css/node_cards.css +++ b/frontend/css/node_cards.css @@ -211,6 +211,59 @@ color: var(--accent-purple); } +/* ---- Signal health sparkline + badge ---- */ + +.nc-card__health { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.3rem; + min-height: 28px; +} + +.nc-health-badge { + flex-shrink: 0; + font-size: 0.55rem; + font-weight: 700; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.12rem 0.35rem; + border-radius: 999px; + line-height: 1.2; +} + +.nc-health-badge--hidden { + display: none; +} + +.nc-health-badge--green { + background: rgba(34, 197, 94, 0.18); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.35); +} + +.nc-health-badge--yellow { + background: rgba(245, 158, 11, 0.18); + color: #f59e0b; + border: 1px solid rgba(245, 158, 11, 0.35); +} + +.nc-health-badge--red { + background: rgba(239, 68, 68, 0.18); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.35); +} + +.nc-sparkline { + flex: 1; + min-width: 0; + height: 28px; + width: 100%; + display: block; + opacity: 0.9; +} + /* ---- Card Rows ---- */ .nc-card__row { diff --git a/frontend/index.html b/frontend/index.html index 239398c..5e4a3d7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -36,6 +36,7 @@ + @@ -677,10 +678,12 @@

Service actions

+ + diff --git a/frontend/js/node_cards.js b/frontend/js/node_cards.js index 2fb356a..39ab14b 100644 --- a/frontend/js/node_cards.js +++ b/frontend/js/node_cards.js @@ -20,6 +20,12 @@ class NodeCards { this._sortBy = this._loadSavedSort(); this._filter = this._loadSavedFilter(); this._favoritesOnly = this._loadSavedFavoritesOnly(); + this._observer = null; + this._sparklines = new Map(); + this._bucketCache = new Map(); + this._loadingNodes = new Set(); + this._signalHealth = null; + this._configPromise = null; const searchEl = document.getElementById('node-search'); if (searchEl) { @@ -49,9 +55,12 @@ class NodeCards { } _loadSavedFilter() { - return window.MeshpointNodeCardsSort - ? window.MeshpointNodeCardsSort.readSavedFilter() - : 'all'; + try { + const v = localStorage.getItem(NodeCards.FILTER_STORAGE_KEY); + return NodeCards.FILTER_KEYS.has(v) ? v : 'all'; + } catch (_e) { + return 'all'; + } } _loadSavedFavoritesOnly() { @@ -105,9 +114,6 @@ class NodeCards { b.classList.toggle('nc-pill--active', b.dataset.filter === value); }); this._render(); - document.dispatchEvent(new CustomEvent('meshpoint:nodeCardsFilter', { - detail: { filter: value }, - })); }); }); } @@ -137,8 +143,22 @@ class NodeCards { this._render(); } + _ensureSignalHealthConfig() { + if (this._configPromise) return this._configPromise; + this._configPromise = fetch('/api/config', { credentials: 'same-origin' }) + .then((res) => (res.ok ? res.json() : {})) + .then((data) => { + this._signalHealth = data.signal_health || null; + }) + .catch(() => { + this._signalHealth = null; + }); + return this._configPromise; + } + updateFromPacket(packet) { if (!packet.source_id) return; + this._bucketCache.delete(packet.source_id); const idx = this._nodes.findIndex(n => n.node_id === packet.source_id); if (idx >= 0) { const n = this._nodes[idx]; @@ -202,6 +222,92 @@ class NodeCards { if (node && this._onCardClick) this._onCardClick(node); }); }); + + this._observeCards(); + } + + _observeCards() { + if (this._observer) this._observer.disconnect(); + if (!window.SignalSparkline || typeof IntersectionObserver === 'undefined') { + return; + } + + this._ensureSignalHealthConfig(); + this._observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const card = entry.target; + const nodeId = card.dataset.nodeId; + if (nodeId) this._loadSignalData(nodeId, card); + this._observer.unobserve(card); + }); + }, { root: null, rootMargin: '48px', threshold: 0.08 }); + + this._container.querySelectorAll('.nc-card').forEach((card) => { + this._observer.observe(card); + }); + } + + async _loadSignalData(nodeId, cardEl) { + if (!nodeId || this._loadingNodes.has(nodeId)) return; + + if (this._bucketCache.has(nodeId)) { + this._applySignalHealth(nodeId, cardEl, this._bucketCache.get(nodeId)); + return; + } + + this._loadingNodes.add(nodeId); + try { + await this._ensureSignalHealthConfig(); + const url = `/api/nodes/${encodeURIComponent(nodeId)}/metrics_history` + + '?hours=24&bucket_minutes=15&limit=100'; + const res = await fetch(url, { credentials: 'same-origin' }); + if (!res.ok) return; + const data = await res.json(); + const buckets = data.signal_buckets || []; + this._bucketCache.set(nodeId, buckets); + this._applySignalHealth(nodeId, cardEl, buckets); + } catch (e) { + console.error('Signal bucket load failed:', nodeId, e); + } finally { + this._loadingNodes.delete(nodeId); + } + } + + _applySignalHealth(nodeId, cardEl, buckets) { + const canvas = cardEl.querySelector('[data-sparkline]'); + if (!canvas || !window.SignalSparkline) return; + + let spark = this._sparklines.get(nodeId); + if (!spark) { + spark = new window.SignalSparkline(canvas); + this._sparklines.set(nodeId, spark); + } + spark.setBuckets(buckets, { bucketMinutes: 15 }); + + const badge = cardEl.querySelector('[data-health-badge]'); + if (!badge) return; + + const health = window.SignalSparkline.classifyHealth( + buckets, + this._signalHealth, + ); + if (!health.level) { + badge.className = 'nc-health-badge nc-health-badge--hidden'; + badge.setAttribute('aria-hidden', 'true'); + badge.textContent = ''; + badge.removeAttribute('title'); + return; + } + + badge.className = `nc-health-badge nc-health-badge--${health.level}`; + badge.setAttribute('aria-hidden', 'false'); + badge.title = `1h avg ${health.avgRssi.toFixed(0)} dBm (${health.packetCount} pkts)`; + badge.textContent = health.level === 'green' + ? 'Good' + : health.level === 'yellow' + ? 'Fair' + : 'Poor'; } _applyFilter(nodes) { @@ -242,6 +348,7 @@ class NodeCards { const favGlyph = isFav ? '\u2605' : '\u2606'; const signal = this._buildSignal(n); + const health = this._buildHealthRow(n); const telemetry = this._buildTelemetry(n); const meta = this._buildMeta(n); @@ -261,11 +368,23 @@ class NodeCards { ${protoBadge} ${signal} + ${health} ${telemetry} ${meta} `; } + _buildHealthRow(n) { + const rssi = n.latest_rssi ?? n.rssi; + if (rssi == null) return ''; + return `
+ + +
`; + } + _buildSignal(n) { const parts = []; const rssi = n.latest_rssi ?? n.rssi; diff --git a/frontend/js/signal_sparkline.js b/frontend/js/signal_sparkline.js new file mode 100644 index 0000000..5b18a9b --- /dev/null +++ b/frontend/js/signal_sparkline.js @@ -0,0 +1,145 @@ +/** + * RSSI sparkline for node cards. + * + * Paints a tiny canvas line chart from 15-minute signal buckets. + * Gaps longer than one bucket use a dashed connector. Health helpers + * classify a rolling 1-hour average against configurable thresholds. + */ +class SignalSparkline { + static DEFAULT_THRESHOLDS = { + green_rssi_floor: -100, + yellow_rssi_floor: -115, + min_packets_per_hour: 5, + }; + + constructor(canvasEl) { + this._canvas = canvasEl; + this._ctx = canvasEl.getContext('2d'); + this._buckets = []; + this._floor = -130; + this._ceiling = -70; + this._gapMs = 15 * 60 * 1000; + this._resize(); + window.addEventListener('resize', () => { + this._resize(); + this._render(); + }); + } + + static classifyHealth(buckets, thresholds) { + const t = { ...SignalSparkline.DEFAULT_THRESHOLDS, ...(thresholds || {}) }; + const hourAgo = Date.now() - 3_600_000; + let packetCount = 0; + let weighted = 0; + + for (const b of buckets || []) { + const ts = Date.parse(b.bucket); + if (Number.isNaN(ts) || ts < hourAgo) continue; + const count = b.packet_count || 0; + if (b.rssi_avg == null || count <= 0) continue; + packetCount += count; + weighted += b.rssi_avg * count; + } + + if (packetCount < t.min_packets_per_hour) { + return { level: null, avgRssi: null, packetCount }; + } + + const avgRssi = weighted / packetCount; + let level = 'red'; + if (avgRssi >= t.green_rssi_floor) level = 'green'; + else if (avgRssi >= t.yellow_rssi_floor) level = 'yellow'; + + return { level, avgRssi, packetCount }; + } + + setBuckets(buckets, opts = {}) { + this._buckets = Array.isArray(buckets) ? buckets.slice() : []; + if (opts.bucketMinutes) { + this._gapMs = opts.bucketMinutes * 60 * 1000; + } + this._render(); + } + + _resize() { + const dpr = window.devicePixelRatio || 1; + const rect = this._canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + this._canvas.width = Math.floor(rect.width * dpr); + this._canvas.height = Math.floor(rect.height * dpr); + this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + _bucketMs(bucket) { + const ts = Date.parse(bucket.bucket); + return Number.isNaN(ts) ? null : ts; + } + + _render() { + const ctx = this._ctx; + const rect = this._canvas.getBoundingClientRect(); + const w = rect.width; + const h = rect.height; + ctx.clearRect(0, 0, w, h); + + const valid = (this._buckets || []).filter( + (b) => b.rssi_avg != null && (b.packet_count || 0) > 0, + ); + if (valid.length < 1) return; + + const floor = this._floor; + const top = this._ceiling; + const range = top - floor; + if (range <= 0) return; + + const times = valid.map((b) => this._bucketMs(b)).filter((t) => t != null); + if (!times.length) return; + const minT = Math.min(...times); + const maxT = Math.max(...times); + const span = Math.max(maxT - minT, this._gapMs); + + const toPoint = (bucket) => { + const t = this._bucketMs(bucket); + const clamped = Math.max(floor, Math.min(top, bucket.rssi_avg)); + const yFraction = 1 - (clamped - floor) / range; + const x = span > 0 ? ((t - minT) / span) * w : w * 0.5; + return { x, y: yFraction * h, rssi: bucket.rssi_avg }; + }; + + const points = valid.map(toPoint); + const strokeFor = (rssi) => { + if (rssi >= -95) return '#22c55e'; + if (rssi >= -110) return '#f59e0b'; + return '#ef4444'; + }; + + const drawSegment = (from, to, dashed) => { + ctx.save(); + ctx.strokeStyle = strokeFor(to.rssi); + ctx.lineWidth = dashed ? 1.25 : 1.75; + if (dashed) ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + ctx.stroke(); + ctx.restore(); + }; + + for (let i = 1; i < points.length; i++) { + const prev = valid[i - 1]; + const curr = valid[i]; + const prevMs = this._bucketMs(prev); + const currMs = this._bucketMs(curr); + const gap = currMs != null && prevMs != null ? currMs - prevMs : 0; + drawSegment(points[i - 1], points[i], gap > this._gapMs * 1.5); + } + + const last = points[points.length - 1]; + ctx.fillStyle = strokeFor(last.rssi); + ctx.beginPath(); + ctx.arc(last.x, last.y, 2, 0, Math.PI * 2); + ctx.fill(); + } +} + +window.SignalSparkline = SignalSparkline; diff --git a/src/api/routes/config_enrichment.py b/src/api/routes/config_enrichment.py index 5f0ae3a..0555f14 100644 --- a/src/api/routes/config_enrichment.py +++ b/src/api/routes/config_enrichment.py @@ -56,20 +56,25 @@ def enrich_config_payload(cfg: AppConfig, base: dict) -> dict: base["radio_advanced"] = { "spectral_scan_interval_seconds": radio.spectral_scan_interval_seconds, "sx1261_spi_path": radio.sx1261_spi_path or "", + "carrier_type": radio.carrier_type or "", + "gps_pps_enabled": radio.gps_pps_enabled, + "gps_pps_tty_path": radio.gps_pps_tty_path, + "gps_family": radio.gps_family, + "gps_pps_target_baud": radio.gps_pps_target_baud, } base["location"] = { "source": location.source, "gpsd_host": location.gpsd_host, "gpsd_port": location.gpsd_port, + "uart_path": location.uart_path, + "uart_baud": location.uart_baud, "update_interval_seconds": location.update_interval_seconds, "min_fix_quality": location.min_fix_quality, } - pos = cfg.transmit.position - if "transmit" in base: - base["transmit"]["position"] = { - "interval_minutes": pos.interval_minutes, - "startup_delay_seconds": pos.startup_delay_seconds, - "coordinate_source": pos.coordinate_source, - "location_precision": pos.location_precision, - } + sh = cfg.signal_health + base["signal_health"] = { + "green_rssi_floor": sh.green_rssi_floor, + "yellow_rssi_floor": sh.yellow_rssi_floor, + "min_packets_per_hour": sh.min_packets_per_hour, + } return base diff --git a/src/api/routes/nodes.py b/src/api/routes/nodes.py index 7a28d8b..f55caf7 100644 --- a/src/api/routes/nodes.py +++ b/src/api/routes/nodes.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from src.analytics.network_mapper import NetworkMapper from src.storage.node_repository import NodeRepository @@ -57,6 +57,7 @@ async def metrics_history( node_id: str, limit: int = 300, hours: float | None = 168, + bucket_minutes: int | None = Query(None, ge=1, le=60), ): """Telemetry rows + RSSI samples for node drawer time-series charts.""" if _packet_repo is None or _telemetry_repo is None: @@ -68,11 +69,19 @@ async def metrics_history( telemetry = await _telemetry_repo.get_history(node_id, limit, hours) signal = await _packet_repo.get_signal_history(node_id, limit, hours) - return { + payload = { "node_id": node_id, "telemetry": [t.to_dict() for t in telemetry], "signal": signal, } + if bucket_minutes is not None: + window_hours = float(hours) if hours is not None else 24.0 + payload["signal_buckets"] = await _packet_repo.get_signal_buckets( + node_id, + hours=window_hours, + bucket_minutes=bucket_minutes, + ) + return payload @router.get("/{node_id}") diff --git a/src/config.py b/src/config.py index 4622e2b..6b14b6e 100644 --- a/src/config.py +++ b/src/config.py @@ -1,7 +1,6 @@ from __future__ import annotations import dataclasses -import logging import os import sys from dataclasses import dataclass, field @@ -12,8 +11,6 @@ from src.version import __version__ -logger = logging.getLogger(__name__) - # Band-start frequencies (MHz) for the Meshtastic slot formula # freq = freqStart + BW/2000 + (slot-1) * BW/1000 @@ -60,14 +57,26 @@ class RadioConfig: # (default; spectral scan stays unavailable, packet-derived # noise floor remains in use). # - # On RAK2287 / RAK5146 / SenseCap M1 this is typically - # ``/dev/spidev0.1`` (separate from the SX1302's - # ``/dev/spidev0.0``). Some carriers daisy-chain the SX1261 - # behind the SX1302's SPI router and want this set to the same - # path as the SX1302 SPI device. Wrong path = HAL refuses to - # ``lgw_start`` after our config attempt, so we ship empty by - # default and ask interested users to opt in explicitly. + # On RAK2287 / RAK5146 / SenseCap M1 the SX1261 is behind the + # concentrator's internal SPI router, not on a Pi chip-select — + # leave empty. Only the Semtech reference kit and custom carriers + # with a dedicated SX1261 CE line need a path (e.g. + # ``/dev/spidev0.1``). Wrong path can brick ``lgw_start`` on + # boards without Pi-visible SX1261; ``carrier_type`` guard clears + # mistaken values on RAK/SenseCap. sx1261_spi_path: str = "" + # Carrier board signature from setup wizard I2C probe (``rak``, + # ``sensecap_m1``, or empty). Used to block Pi-visible SX1261 SPI + # paths that brick ``lgw_start()`` on RAK/SenseCap concentrators. + carrier_type: str = "" + # HAL GPS/PPS: align concentrator packet timestamps with GPS time via + # libloragw ``lgw_gps_*`` + ``sx1302_gps_enable``. Requires a HAL build + # that includes loragw_gps.c. Exclusive with ``location.source: uart`` on + # the same TTY (only one process may open the GPS serial port). + gps_pps_enabled: bool = False + gps_pps_tty_path: str = "/dev/ttyAMA0" + gps_family: str = "ubx7" + gps_pps_target_baud: int = 0 @dataclass @@ -152,29 +161,6 @@ class RelayConfig: max_relay_rssi: float = -50.0 -@dataclass -class TelemetryConfig: - """Periodic device_metrics telemetry broadcast settings.""" - - interval_minutes: int = 30 - startup_delay_seconds: int = 120 - - -@dataclass -class PositionConfig: - """Periodic POSITION broadcast settings.""" - - interval_minutes: int = 15 - startup_delay_seconds: int = 180 - # Coordinates sent on the public LoRa mesh (Meshtastic POSITION packets). - # ``static`` uses ``device.{latitude,longitude,altitude}`` (wizard pin). - # ``live`` reads the active ``LocationSource`` (gpsd/uart) when a fix exists. - coordinate_source: str = "static" - # Privacy when ``coordinate_source`` is ``live``: exact, approximate - # (~1.1 km rounding), or none (skip position on mesh). Ignored for static. - location_precision: str = "approximate" - - @dataclass class MqttConfig: enabled: bool = False @@ -184,8 +170,6 @@ class MqttConfig: password: str = "large4cats" topic_root: str = "msh" region: str = "US" - tls_enabled: bool = False - tls_ca_cert: str = "" # Optional ``!xxxxxxxx`` override; blank uses MD5 hash of device name. gateway_id: Optional[str] = None publish_channels: list[str] = field(default_factory=lambda: ["LongFast", "MeshCore"]) @@ -230,8 +214,6 @@ class TransmitConfig: short_name: str = "MPNT" hop_limit: int = 3 nodeinfo: NodeInfoConfig = field(default_factory=NodeInfoConfig) - telemetry: TelemetryConfig = field(default_factory=TelemetryConfig) - position: PositionConfig = field(default_factory=PositionConfig) @dataclass @@ -241,15 +223,12 @@ class LocationConfig: ``source`` values: - ``"static"`` : use ``device.latitude/longitude/altitude`` from ``local.yaml``. Backward-compatible default. - - ``"gpsd"`` : connect to a local or remote ``gpsd`` daemon for - live fixes (skyplot, optional mesh POSITION). - Does not change ``device.{lat,lon,alt}`` (Meshradar - pin). Auto-installed by ``scripts/install.sh``. - - ``"uart"`` : reserved for direct on-board UART NMEA reading - (RAK Pi HAT GPS). Plumbing exists in - ``src.hal.gps_reader`` but is not wired into - the runtime yet; treated as ``static`` until - the source is implemented. + - ``"gpsd"`` : connect to a local or remote ``gpsd`` daemon and + overwrite ``device.{lat,lon,alt}`` when fixes + arrive. Auto-installed by ``scripts/install.sh``. + - ``"uart"`` : read NMEA GGA from an on-board UART GPS (RAK Pi + HAT on ``/dev/ttyAMA0``). Uses + ``src.hal.gps_reader.GpsReader``. ``gpsd_host`` / ``gpsd_port`` default to gpsd's well-known localhost socket. Override only when running gpsd on a peer @@ -269,6 +248,8 @@ class LocationConfig: source: str = "static" gpsd_host: str = "127.0.0.1" gpsd_port: int = 2947 + uart_path: str = "/dev/ttyAMA0" + uart_baud: int = 9600 update_interval_seconds: int = 5 min_fix_quality: int = 1 @@ -304,6 +285,15 @@ class WebAuthConfig: session_version: int = 1 +@dataclass +class SignalHealthConfig: + """Thresholds for node-card RSSI health badges and sparklines.""" + + green_rssi_floor: float = -100 + yellow_rssi_floor: float = -115 + min_packets_per_hour: int = 5 + + @dataclass class AppConfig: radio: RadioConfig = field(default_factory=RadioConfig) @@ -319,6 +309,7 @@ class AppConfig: transmit: TransmitConfig = field(default_factory=TransmitConfig) web_auth: WebAuthConfig = field(default_factory=WebAuthConfig) location: LocationConfig = field(default_factory=LocationConfig) + signal_health: SignalHealthConfig = field(default_factory=SignalHealthConfig) def _resolve_radio_frequency(radio: "RadioConfig") -> None: @@ -356,25 +347,6 @@ def _merge_dataclass(instance, overrides: dict): setattr(instance, key, value) -def _collect_unknown_keys(instance, overrides: dict, prefix: str = "") -> list[str]: - """Return dotted paths of override keys with no matching dataclass field. - - Mirrors the descent rules in :func:`_merge_dataclass`: it only recurses - into a nested dataclass (e.g. ``transmit.nodeinfo``), so user-supplied - mapping fields such as ``meshtastic.channel_keys`` are treated as opaque - values rather than scanned for "unknown" keys. - """ - unknown: list[str] = [] - for key, value in overrides.items(): - if not hasattr(instance, key): - unknown.append(f"{prefix}{key}") - continue - current = getattr(instance, key) - if dataclasses.is_dataclass(current) and isinstance(value, dict): - unknown.extend(_collect_unknown_keys(current, value, f"{prefix}{key}.")) - return unknown - - def _apply_yaml(cfg: AppConfig, path: Path) -> None: """Merge a single YAML file into an existing AppConfig.""" if not path.exists(): @@ -383,10 +355,6 @@ def _apply_yaml(cfg: AppConfig, path: Path) -> None: with open(path, "r") as fh: raw = yaml.safe_load(fh) or {} - if not isinstance(raw, dict): - logger.warning("Ignoring %s: top-level YAML is not a mapping.", path) - return - section_map = { "radio": cfg.radio, "meshtastic": cfg.meshtastic, @@ -401,28 +369,12 @@ def _apply_yaml(cfg: AppConfig, path: Path) -> None: "transmit": cfg.transmit, "web_auth": cfg.web_auth, "location": cfg.location, + "signal_health": cfg.signal_health, } - unknown_keys: list[str] = [] - for section_name, section_value in raw.items(): - section_instance = section_map.get(section_name) - if section_instance is None: - unknown_keys.append(section_name) - continue - _merge_dataclass(section_instance, section_value) - if isinstance(section_value, dict): - unknown_keys.extend( - _collect_unknown_keys(section_instance, section_value, f"{section_name}.") - ) - - if unknown_keys: - logger.warning( - "Ignoring %d unknown config key(s) in %s: %s. " - "These were not applied -- check for typos against the documented schema.", - len(unknown_keys), - path, - ", ".join(sorted(unknown_keys)), - ) + for section_name, section_instance in section_map.items(): + if section_name in raw: + _merge_dataclass(section_instance, raw[section_name]) _VALID_CONFIG_EXTENSIONS = {".yaml", ".yml"} @@ -449,10 +401,28 @@ def load_config(config_path: Optional[str] = None) -> AppConfig: local = config_path or os.environ.get("CONCENTRATOR_CONFIG", "config/local.yaml") _apply_yaml(cfg, _validated_config_path(local)) _resolve_radio_frequency(cfg.radio) + validate_config_consistency(cfg) return cfg +def validate_config_consistency(config: AppConfig) -> None: + """Reject impossible radio/location combinations before hardware starts.""" + if not config.radio.gps_pps_enabled: + return + if config.location.source != "uart": + return + pps_tty = os.path.normpath(config.radio.gps_pps_tty_path) + uart_tty = os.path.normpath(config.location.uart_path) + if pps_tty == uart_tty: + raise ValueError( + "radio.gps_pps_enabled and location.source=uart cannot share " + f"the same serial device ({pps_tty!r}). Use location.source=gpsd " + "or static for dashboard coordinates while PPS owns the HAT UART, " + "or disable gps_pps_enabled when using UART for location only." + ) + + def _get_local_yaml_path() -> Path: """Resolve the local.yaml path used for user overrides.""" raw = os.environ.get("CONCENTRATOR_CONFIG", "config/local.yaml") diff --git a/src/storage/packet_repository.py b/src/storage/packet_repository.py index d6e847a..2709c69 100644 --- a/src/storage/packet_repository.py +++ b/src/storage/packet_repository.py @@ -96,15 +96,6 @@ async def get_signal_history( for row in rows ] - async def get_source_id_by_packet_id(self, packet_id: str) -> str: - if not packet_id: - return "" - row = await self._db.fetch_one( - "SELECT source_id FROM packets WHERE packet_id = ? LIMIT 1", - (packet_id,), - ) - return row["source_id"] if row else "" - async def get_by_source( self, source_id: str, limit: int = 100 ) -> list[Packet]: @@ -137,6 +128,115 @@ async def get_type_distribution(self) -> dict[str, int]: ) return {r["packet_type"]: r["cnt"] for r in rows} + async def get_hourly_traffic( + self, + hours: int = 24, + *, + default_sf: int = 11, + default_bw_khz: float = 250.0, + ) -> tuple[list[dict], dict[str, list[dict]]]: + """Hourly packet counts and modem buckets for ToA estimation. + + Returns: + (count_rows, modem_buckets_by_hour) where count_rows has keys + hour_start, meshtastic, meshcore, total; modem buckets are + grouped per hour_start with sf, bw, packet_count, avg_payload. + """ + hours = max(1, min(int(hours), 168)) + since = ( + datetime.now(timezone.utc) - timedelta(hours=hours) + ).isoformat() + + count_rows = await self._db.fetch_all( + """ + SELECT + strftime('%Y-%m-%dT%H:00:00Z', timestamp) AS hour_start, + SUM(CASE WHEN protocol = 'meshtastic' THEN 1 ELSE 0 END) AS meshtastic, + SUM(CASE WHEN protocol = 'meshcore' THEN 1 ELSE 0 END) AS meshcore, + COUNT(*) AS total + FROM packets + WHERE timestamp >= ? + GROUP BY hour_start + ORDER BY hour_start ASC + """, + (since,), + ) + + modem_rows = await self._db.fetch_all( + """ + SELECT + strftime('%Y-%m-%dT%H:00:00Z', timestamp) AS hour_start, + COALESCE(NULLIF(spreading_factor, 0), ?) AS sf, + COALESCE(NULLIF(bandwidth_khz, 0), ?) AS bw, + COUNT(*) AS packet_count, + AVG( + CASE + WHEN LENGTH(COALESCE(decoded_payload, '')) < 20 THEN 20 + ELSE LENGTH(COALESCE(decoded_payload, '')) + END + ) AS avg_payload + FROM packets + WHERE timestamp >= ? + GROUP BY hour_start, sf, bw + ORDER BY hour_start ASC + """, + (default_sf, default_bw_khz, since), + ) + + modem_by_hour: dict[str, list[dict]] = {} + for row in modem_rows: + hour = row["hour_start"] + modem_by_hour.setdefault(hour, []).append(row) + + return count_rows, modem_by_hour + + async def get_signal_buckets( + self, + source_id: str, + hours: float = 24, + bucket_minutes: int = 15, + ) -> list[dict]: + """15-minute RSSI/SNR buckets for node-card sparklines.""" + bucket_minutes = max(1, min(int(bucket_minutes), 60)) + since = ( + datetime.now(timezone.utc) - timedelta(hours=hours) + ).isoformat() + + rows = await self._db.fetch_all( + """ + SELECT + strftime('%Y-%m-%dT%H:', timestamp) || + printf('%02d:00Z', + (CAST(strftime('%M', timestamp) AS INTEGER) / ?) * ?) + AS bucket_start, + AVG(rssi) AS rssi_avg, + AVG(snr) AS snr_avg, + COUNT(*) AS packet_count + FROM packets + WHERE source_id = ? AND timestamp >= ? AND rssi IS NOT NULL + GROUP BY bucket_start + ORDER BY bucket_start ASC + """, + (bucket_minutes, bucket_minutes, source_id, since), + ) + return [ + { + "bucket": row["bucket_start"].replace("Z", "+00:00"), + "rssi_avg": ( + round(row["rssi_avg"], 1) + if row["rssi_avg"] is not None + else None + ), + "snr_avg": ( + round(row["snr_avg"], 1) + if row["snr_avg"] is not None + else None + ), + "packet_count": int(row["packet_count"] or 0), + } + for row in rows + ] + async def cleanup_old(self, max_retained: int) -> int: total = await self.get_count() if total <= max_retained: diff --git a/tests/test_signal_buckets.py b/tests/test_signal_buckets.py new file mode 100644 index 0000000..70b7b4e --- /dev/null +++ b/tests/test_signal_buckets.py @@ -0,0 +1,104 @@ +"""Signal bucket aggregation for node-card sparklines.""" + +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone + +from src.storage.database import DatabaseManager +from src.storage.packet_repository import PacketRepository + + +class TestSignalBuckets(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.db = DatabaseManager(":memory:") + await self.db.connect() + self.repo = PacketRepository(self.db) + + async def asyncTearDown(self): + await self.db.disconnect() + + async def _insert_signal( + self, + packet_id: str, + rssi: float, + ts: datetime, + *, + source_id: str = "node1", + ) -> None: + await self.db.execute( + """ + INSERT INTO packets ( + packet_id, source_id, destination_id, protocol, + packet_type, hop_limit, hop_start, channel_hash, + want_ack, via_mqtt, relay_node, decrypted, + rssi, snr, frequency_mhz, spreading_factor, + bandwidth_khz, capture_source, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + packet_id, source_id, "ffffffff", "meshtastic", "text", + 3, 3, 8, 0, 0, 0, 1, rssi, 5.0, + 906.875, 11, 250, "concentrator", ts.isoformat(), + ), + ) + + async def test_buckets_aggregate_by_15_minutes(self): + base = datetime.now(timezone.utc).replace( + minute=10, second=0, microsecond=0, + ) + await self._insert_signal("a", -95.0, base) + await self._insert_signal("b", -85.0, base + timedelta(minutes=2)) + await self._insert_signal("c", -105.0, base + timedelta(minutes=8)) + await self.db.commit() + + buckets = await self.repo.get_signal_buckets( + "node1", hours=24, bucket_minutes=15, + ) + self.assertEqual(len(buckets), 2) + + first_key = base.replace(minute=0).strftime("%Y-%m-%dT%H:00:00+00:00") + second_key = base.replace(minute=15).strftime("%Y-%m-%dT%H:15:00+00:00") + by_bucket = {b["bucket"]: b for b in buckets} + + self.assertEqual(by_bucket[first_key]["packet_count"], 2) + self.assertAlmostEqual(by_bucket[first_key]["rssi_avg"], -90.0) + self.assertEqual(by_bucket[second_key]["packet_count"], 1) + self.assertAlmostEqual(by_bucket[second_key]["rssi_avg"], -105.0) + + async def test_buckets_ignore_other_nodes(self): + now = datetime.now(timezone.utc) + await self._insert_signal("mine", -88.0, now, source_id="node1") + await self._insert_signal("other", -70.0, now, source_id="node2") + await self.db.commit() + + buckets = await self.repo.get_signal_buckets("node1", hours=1) + self.assertEqual(len(buckets), 1) + self.assertAlmostEqual(buckets[0]["rssi_avg"], -88.0) + + async def test_buckets_skip_null_rssi(self): + now = datetime.now(timezone.utc) + await self.db.execute( + """ + INSERT INTO packets ( + packet_id, source_id, destination_id, protocol, + packet_type, hop_limit, hop_start, channel_hash, + want_ack, via_mqtt, relay_node, decrypted, + rssi, snr, frequency_mhz, spreading_factor, + bandwidth_khz, capture_source, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "null", "node1", "ffffffff", "meshtastic", "text", + 3, 3, 8, 0, 0, 0, 1, None, None, + 906.875, 11, 250, "concentrator", now.isoformat(), + ), + ) + await self.db.commit() + + buckets = await self.repo.get_signal_buckets("node1", hours=1) + self.assertEqual(buckets, []) + + +if __name__ == "__main__": + unittest.main()