mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 22:24:41 +00:00
3ab404b545
## Summary Closes #663 (Phase 2 + 3 partial — time-series tracking + thresholds for nodes that are also observers). Adds a per-node battery voltage trend chart and `/api/nodes/{pubkey}/battery` endpoint, sourced from the existing `observer_metrics.battery_mv` samples populated by observer status messages. No new ingest or schema changes — purely surfaces data we were already collecting. ## Scope (TDD red→green) **RED commit:** test(node-battery) — DB query, endpoint shape (200/404/no-data), and config getters all asserted. **GREEN commit:** feat(node-battery) — implementation only. ## Changes ### Backend - `cmd/server/node_battery.go` (new): - `DB.GetNodeBatteryHistory(pubkey, since)` — pulls `(timestamp, battery_mv)` rows from `observer_metrics WHERE LOWER(observer_id) = LOWER(public_key) AND battery_mv IS NOT NULL`. Case-insensitive join tolerates historical pubkey casing variation (observers persist uppercase, nodes lowercase in this DB). - `Server.handleNodeBattery` — `GET /api/nodes/{pubkey}/battery?days=N` (default 7, max 365). Returns `{public_key, days, samples[], latest_mv, latest_ts, status, thresholds}`. - `Config.LowBatteryMv()` / `CriticalBatteryMv()` — defaults 3300 / 3000 mV. - `cmd/server/config.go` — `BatteryThresholds *BatteryThresholdsConfig` field. - `cmd/server/routes.go` — route registration alongside existing `/health`, `/analytics`. ### Frontend - `public/node-analytics.js` — new "Battery Voltage" chart card with status badge (🔋 OK / ⚠️ Low / 🪫 Critical / No data). Renders dashed threshold lines at `lowMv` and `criticalMv`. Empty-state message when no samples in window. ### Config - `config.example.json` — `batteryThresholds: { lowMv: 3300, criticalMv: 3000 }` with `_comment` per Config Documentation Rule. ## Status semantics | latest_mv | status | |-----------------------|------------| | no samples in window | `unknown` | | `>= lowMv` | `ok` | | `< lowMv`, `>= critMv`| `low` | | `< criticalMv` | `critical` | ## What this PR does NOT do (deferred) The issue's full Phase 1 (writing decoded sensor advert telemetry into `nodes.battery_mv` / `temperature_c` from server-side decoder) and Phase 4 (firmware/active polling for repeaters without observers) are out of scope here. This PR delivers the requested Phase 2/3 surfacing for the data path that already lands rows: `observer_metrics`. Repeaters that are also observers (i.e. publish status to MQTT) will get a voltage trend immediately; pure passive nodes won't until Phase 1 lands. ## Tests - `TestGetNodeBatteryHistory_FromObserverMetrics` — case-insensitive join, NULL skipping, ordering. - `TestNodeBatteryEndpoint` — full happy path with thresholds + status. - `TestNodeBatteryEndpoint_NoData` — 200 + status=unknown. - `TestNodeBatteryEndpoint_404` — unknown node. - `TestBatteryThresholds_ConfigOverride` — config getters + defaults. `cd cmd/server && go test ./...` — green. ## Performance Endpoint is per-pubkey (called once on analytics page open), indexed by `(observer_id, timestamp)` PK on `observer_metrics`. No hot-path impact. --------- Co-authored-by: bot <bot@corescope>
162 lines
5.5 KiB
Go
162 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
// TestGetNodeBatteryHistory_FromObserverMetrics validates that the DB layer
|
|
// can pull a node's battery_mv time-series from observer_metrics, joining
|
|
// observers.id (uppercase hex pubkey) to nodes.public_key (lowercase hex).
|
|
func TestGetNodeBatteryHistory_FromObserverMetrics(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
now := time.Now().UTC()
|
|
|
|
// node + observer with matching pubkey (cases differ on purpose)
|
|
pkLower := "deadbeefcafef00d11223344"
|
|
idUpper := strings.ToUpper(pkLower)
|
|
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen) VALUES (?, 'BatNode', 'repeater', ?, ?)`,
|
|
pkLower, now.Format(time.RFC3339), now.Add(-72*time.Hour).Format(time.RFC3339))
|
|
db.conn.Exec(`INSERT INTO observers (id, name, last_seen, first_seen) VALUES (?, 'BatNode', ?, ?)`,
|
|
idUpper, now.Format(time.RFC3339), now.Add(-72*time.Hour).Format(time.RFC3339))
|
|
|
|
// 3 metrics samples: 3700, 3500, 3200 mV
|
|
for i, mv := range []int{3700, 3500, 3200} {
|
|
ts := now.Add(time.Duration(-2+i) * time.Hour).Format(time.RFC3339)
|
|
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp, battery_mv) VALUES (?, ?, ?)`,
|
|
idUpper, ts, mv)
|
|
}
|
|
// One sample with NULL battery should be skipped
|
|
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp) VALUES (?, ?)`,
|
|
idUpper, now.Add(-3*time.Hour).Format(time.RFC3339))
|
|
|
|
since := now.Add(-24 * time.Hour).Format(time.RFC3339)
|
|
samples, err := db.GetNodeBatteryHistory(pkLower, since)
|
|
if err != nil {
|
|
t.Fatalf("GetNodeBatteryHistory: %v", err)
|
|
}
|
|
if len(samples) != 3 {
|
|
t.Fatalf("expected 3 samples, got %d", len(samples))
|
|
}
|
|
if samples[0].BatteryMv != 3700 || samples[2].BatteryMv != 3200 {
|
|
t.Errorf("samples=%+v", samples)
|
|
}
|
|
}
|
|
|
|
// TestNodeBatteryEndpoint validates the /api/nodes/{pubkey}/battery endpoint
|
|
// returns time-series data plus configured thresholds and a status flag.
|
|
func TestNodeBatteryEndpoint(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
seedTestData(t, db)
|
|
|
|
now := time.Now().UTC()
|
|
pkLower := "aabbccdd11223344"
|
|
idUpper := strings.ToUpper(pkLower)
|
|
db.conn.Exec(`INSERT INTO observers (id, name, last_seen, first_seen) VALUES (?, 'TestRepeater', ?, ?)`,
|
|
idUpper, now.Format(time.RFC3339), now.Add(-72*time.Hour).Format(time.RFC3339))
|
|
for i, mv := range []int{3800, 3600, 3200} {
|
|
ts := now.Add(time.Duration(-2+i) * time.Hour).Format(time.RFC3339)
|
|
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp, battery_mv) VALUES (?, ?, ?)`,
|
|
idUpper, ts, mv)
|
|
}
|
|
|
|
cfg := &Config{Port: 3000}
|
|
hub := NewHub()
|
|
srv := NewServer(db, cfg, hub)
|
|
store := NewPacketStore(db, nil)
|
|
if err := store.Load(); err != nil {
|
|
t.Fatalf("store.Load: %v", err)
|
|
}
|
|
srv.store = store
|
|
router := mux.NewRouter()
|
|
srv.RegisterRoutes(router)
|
|
|
|
req := httptest.NewRequest("GET", "/api/nodes/"+pkLower+"/battery?days=7", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
samples, ok := body["samples"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("samples missing: %+v", body)
|
|
}
|
|
if len(samples) != 3 {
|
|
t.Errorf("expected 3 samples, got %d", len(samples))
|
|
}
|
|
thr, ok := body["thresholds"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("thresholds missing: %+v", body)
|
|
}
|
|
if int(thr["low_mv"].(float64)) != 3300 {
|
|
t.Errorf("default low_mv expected 3300, got %v", thr["low_mv"])
|
|
}
|
|
if int(thr["critical_mv"].(float64)) != 3000 {
|
|
t.Errorf("default critical_mv expected 3000, got %v", thr["critical_mv"])
|
|
}
|
|
// latest 3200 -> "low" (below 3300, above 3000)
|
|
if body["status"] != "low" {
|
|
t.Errorf("expected status=low, got %v", body["status"])
|
|
}
|
|
if int(body["latest_mv"].(float64)) != 3200 {
|
|
t.Errorf("latest_mv expected 3200, got %v", body["latest_mv"])
|
|
}
|
|
}
|
|
|
|
// TestNodeBatteryEndpoint_NoData returns 200 with empty samples and status="unknown".
|
|
func TestNodeBatteryEndpoint_NoData(t *testing.T) {
|
|
_, router := setupTestServer(t)
|
|
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/battery", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 200 {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
var body map[string]interface{}
|
|
json.Unmarshal(w.Body.Bytes(), &body)
|
|
if body["status"] != "unknown" {
|
|
t.Errorf("expected unknown when no samples, got %v", body["status"])
|
|
}
|
|
}
|
|
|
|
// TestNodeBatteryEndpoint_404 returns 404 for unknown node.
|
|
func TestNodeBatteryEndpoint_404(t *testing.T) {
|
|
_, router := setupTestServer(t)
|
|
req := httptest.NewRequest("GET", "/api/nodes/notarealnode00000000/battery", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != 404 {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestBatteryThresholds_ConfigOverride confirms config overrides take effect.
|
|
func TestBatteryThresholds_ConfigOverride(t *testing.T) {
|
|
cfg := &Config{
|
|
BatteryThresholds: &BatteryThresholdsConfig{LowMv: 3500, CriticalMv: 3100},
|
|
}
|
|
if cfg.LowBatteryMv() != 3500 {
|
|
t.Errorf("LowBatteryMv override failed: %d", cfg.LowBatteryMv())
|
|
}
|
|
if cfg.CriticalBatteryMv() != 3100 {
|
|
t.Errorf("CriticalBatteryMv override failed: %d", cfg.CriticalBatteryMv())
|
|
}
|
|
|
|
empty := &Config{}
|
|
if empty.LowBatteryMv() != 3300 {
|
|
t.Errorf("default LowBatteryMv expected 3300, got %d", empty.LowBatteryMv())
|
|
}
|
|
if empty.CriticalBatteryMv() != 3000 {
|
|
t.Errorf("default CriticalBatteryMv expected 3000, got %d", empty.CriticalBatteryMv())
|
|
}
|
|
}
|