Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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=="
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
53 changes: 53 additions & 0 deletions frontend/css/node_cards.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<link rel="stylesheet" href="css/settings.css" />
<link rel="stylesheet" href="css/configuration.css" />
<link rel="stylesheet" href="css/gps.css" />
<link rel="stylesheet" href="css/packet_detail_modal.css" />
<link rel="stylesheet" href="css/terminal_theme.css" />
<link rel="stylesheet" href="css/terminal.css" />
<link rel="stylesheet" href="/vendor/xterm/xterm.css" />
Expand Down Expand Up @@ -677,10 +678,12 @@ <h3 class="dangerous-panel__title">Service actions</h3>
<script src="js/meshpoint_display_units.js"></script>
<script src="js/meshpoint_node_favorites.js"></script>
<script src="js/node_cards_sort.js"></script>
<script src="js/signal_sparkline.js"></script>
<script src="js/node_cards.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<script src="js/node_metrics_chart.js"></script>
<script src="js/node_drawer.js"></script>
<script src="js/packet_detail_modal.js"></script>
<script src="js/simple_packet_feed.js"></script>
<script src="js/messaging_contacts.js"></script>
<script src="js/messaging_chat.js"></script>
Expand Down
131 changes: 125 additions & 6 deletions frontend/js/node_cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 },
}));
});
});
}
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);

Expand All @@ -261,11 +368,23 @@ class NodeCards {
<span class="nc-proto nc-proto--${proto}">${protoBadge}</span>
</div>
${signal}
${health}
${telemetry}
${meta}
</div>`;
}

_buildHealthRow(n) {
const rssi = n.latest_rssi ?? n.rssi;
if (rssi == null) return '';
return `<div class="nc-card__health">
<span class="nc-health-badge nc-health-badge--hidden"
data-health-badge
aria-hidden="true"></span>
<canvas class="nc-sparkline" data-sparkline aria-hidden="true"></canvas>
</div>`;
}

_buildSignal(n) {
const parts = [];
const rssi = n.latest_rssi ?? n.rssi;
Expand Down
Loading
Loading