mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 18:54:42 +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>
151 lines
3.9 KiB
Go
151 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
// BatteryThresholdsConfig: voltage cutoffs for low-battery alerts (#663).
|
|
// All values in millivolts. When a node's most-recent battery sample falls
|
|
// below LowMv it is flagged "low"; below CriticalMv it is flagged "critical".
|
|
type BatteryThresholdsConfig struct {
|
|
LowMv int `json:"lowMv"`
|
|
CriticalMv int `json:"criticalMv"`
|
|
}
|
|
|
|
// LowBatteryMv returns the configured low-battery threshold or the default 3300mV.
|
|
func (c *Config) LowBatteryMv() int {
|
|
if c.BatteryThresholds != nil && c.BatteryThresholds.LowMv > 0 {
|
|
return c.BatteryThresholds.LowMv
|
|
}
|
|
return 3300
|
|
}
|
|
|
|
// CriticalBatteryMv returns the configured critical-battery threshold or the default 3000mV.
|
|
func (c *Config) CriticalBatteryMv() int {
|
|
if c.BatteryThresholds != nil && c.BatteryThresholds.CriticalMv > 0 {
|
|
return c.BatteryThresholds.CriticalMv
|
|
}
|
|
return 3000
|
|
}
|
|
|
|
// NodeBatterySample is a single (timestamp, battery_mv) point.
|
|
type NodeBatterySample struct {
|
|
Timestamp string `json:"timestamp"`
|
|
BatteryMv int `json:"battery_mv"`
|
|
}
|
|
|
|
// GetNodeBatteryHistory returns time-ordered battery_mv samples for a node,
|
|
// pulled from observer_metrics by joining observers.id (uppercase pubkey)
|
|
// against the node's public_key (lowercase). Rows with NULL battery are skipped.
|
|
//
|
|
// The match is case-insensitive on observer_id to tolerate historical
|
|
// variation in pubkey casing.
|
|
func (db *DB) GetNodeBatteryHistory(pubkey, since string) ([]NodeBatterySample, error) {
|
|
if pubkey == "" {
|
|
return nil, nil
|
|
}
|
|
pk := strings.ToLower(pubkey)
|
|
rows, err := db.conn.Query(`
|
|
SELECT timestamp, battery_mv
|
|
FROM observer_metrics
|
|
WHERE LOWER(observer_id) = ?
|
|
AND battery_mv IS NOT NULL
|
|
AND timestamp >= ?
|
|
ORDER BY timestamp ASC`, pk, since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []NodeBatterySample
|
|
for rows.Next() {
|
|
var ts string
|
|
var mv int
|
|
if err := rows.Scan(&ts, &mv); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, NodeBatterySample{Timestamp: ts, BatteryMv: mv})
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// handleNodeBattery serves GET /api/nodes/{pubkey}/battery?days=N (#663).
|
|
//
|
|
// Returns voltage time-series for a node and a status flag based on the most
|
|
// recent sample evaluated against configured thresholds:
|
|
// - "critical" : latest_mv < CriticalBatteryMv
|
|
// - "low" : latest_mv < LowBatteryMv
|
|
// - "ok" : latest_mv >= LowBatteryMv
|
|
// - "unknown" : no samples in window
|
|
func (s *Server) handleNodeBattery(w http.ResponseWriter, r *http.Request) {
|
|
pubkey := mux.Vars(r)["pubkey"]
|
|
if pubkey == "" {
|
|
writeError(w, 400, "missing pubkey")
|
|
return
|
|
}
|
|
|
|
// 404 if node unknown — keeps URL space tidy and matches /health behavior.
|
|
node, err := s.db.GetNodeByPubkey(pubkey)
|
|
if err != nil {
|
|
writeError(w, 500, err.Error())
|
|
return
|
|
}
|
|
if node == nil {
|
|
writeError(w, 404, "node not found")
|
|
return
|
|
}
|
|
|
|
days := 7
|
|
if d, _ := strconv.Atoi(r.URL.Query().Get("days")); d > 0 && d <= 365 {
|
|
days = d
|
|
}
|
|
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
samples, err := s.db.GetNodeBatteryHistory(pubkey, since)
|
|
if err != nil {
|
|
writeError(w, 500, err.Error())
|
|
return
|
|
}
|
|
if samples == nil {
|
|
samples = []NodeBatterySample{}
|
|
}
|
|
|
|
low := s.cfg.LowBatteryMv()
|
|
crit := s.cfg.CriticalBatteryMv()
|
|
|
|
status := "unknown"
|
|
var latestMv interface{}
|
|
var latestTs interface{}
|
|
if n := len(samples); n > 0 {
|
|
mv := samples[n-1].BatteryMv
|
|
latestMv = mv
|
|
latestTs = samples[n-1].Timestamp
|
|
switch {
|
|
case mv < crit:
|
|
status = "critical"
|
|
case mv < low:
|
|
status = "low"
|
|
default:
|
|
status = "ok"
|
|
}
|
|
}
|
|
|
|
writeJSON(w, map[string]interface{}{
|
|
"public_key": strings.ToLower(pubkey),
|
|
"days": days,
|
|
"samples": samples,
|
|
"latest_mv": latestMv,
|
|
"latest_ts": latestTs,
|
|
"status": status,
|
|
"thresholds": map[string]interface{}{
|
|
"low_mv": low,
|
|
"critical_mv": crit,
|
|
},
|
|
})
|
|
}
|