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()