BUG1: GET /api/config retourne config courante (host/port/path/url/interval)
POST /api/config update à chaud + log "cible changée"
Page pré-remplit les inputs au chargement via loadConfig()
Toast "Appliqué" après POST réussi
BUG2: JS reécrit pour lire UNIQUEMENT api/latest (relatif)
Tous les champs mappés sur json tags Go minuscules:
gps.valid/sats/lat/lon/hdg/speed_kn, rssi, power.voltage/current_mA,
seaker.angle/dist/rx_freq, targetf.lat/lon/r95_m, deviceId, firmware.version, ip
Sparklines alimentées depuis rows[] ring-buffer backend
Plus aucun fetch direct vers ESP32
846 lines
26 KiB
Go
846 lines
26 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/csv"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"io"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const Version = "0.2.1"
|
||
|
||
// ─── QC Thresholds (edit here) ───────────────────────────────────────────────
|
||
const (
|
||
ThreshGpsSatsOk = 6 // sats >= 6 → green
|
||
ThreshRssiOk = -75 // dBm > -75 → green
|
||
ThreshRssiWarn = -90 // dBm > -90 → orange, below → red
|
||
ThreshBattVoltOk = 11.5 // V >= 11.5 → green
|
||
ThreshBattVoltWarn = 10.5 // V >= 10.5 → orange, below → red
|
||
ThreshLinkLostSec = 5 // s before "link lost" red indicator
|
||
RingBufferSize = 600 // points kept in RAM
|
||
)
|
||
|
||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||
type Config struct {
|
||
Host string
|
||
Port int
|
||
Path string
|
||
URL string // full URL override (http or https), replaces host:port/path
|
||
Interval int // seconds
|
||
CSV string
|
||
Web int
|
||
Mode string // "poll" | "receive"
|
||
}
|
||
|
||
func defaultConfig() Config {
|
||
today := time.Now().Format("2006-01-02")
|
||
return Config{
|
||
Host: "192.168.1.50",
|
||
Port: 80,
|
||
Path: "/api/telemetry",
|
||
Interval: 1,
|
||
CSV: "seakesp_log_" + today + ".csv",
|
||
Web: 8099,
|
||
Mode: "poll",
|
||
}
|
||
}
|
||
|
||
func loadIni(path string, cfg *Config) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer f.Close()
|
||
scanner := bufio.NewScanner(f)
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||
continue
|
||
}
|
||
parts := strings.SplitN(line, "=", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
k, v := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||
switch k {
|
||
case "host":
|
||
cfg.Host = v
|
||
case "port":
|
||
if n, e := strconv.Atoi(v); e == nil {
|
||
cfg.Port = n
|
||
}
|
||
case "path":
|
||
cfg.Path = v
|
||
case "interval":
|
||
if n, e := strconv.Atoi(v); e == nil {
|
||
cfg.Interval = n
|
||
}
|
||
case "csv":
|
||
cfg.CSV = v
|
||
case "web":
|
||
if n, e := strconv.Atoi(v); e == nil {
|
||
cfg.Web = n
|
||
}
|
||
case "mode":
|
||
cfg.Mode = v
|
||
case "url":
|
||
cfg.URL = v
|
||
}
|
||
}
|
||
}
|
||
|
||
func parseFlags(cfg *Config) {
|
||
args := os.Args[1:]
|
||
for i := 0; i < len(args); i++ {
|
||
arg := args[i]
|
||
if !strings.HasPrefix(arg, "-") {
|
||
continue
|
||
}
|
||
key := strings.TrimLeft(arg, "-")
|
||
var val string
|
||
if strings.Contains(key, "=") {
|
||
parts := strings.SplitN(key, "=", 2)
|
||
key, val = parts[0], parts[1]
|
||
} else if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||
i++
|
||
val = args[i]
|
||
} else {
|
||
continue
|
||
}
|
||
switch key {
|
||
case "host":
|
||
cfg.Host = val
|
||
case "port":
|
||
if n, e := strconv.Atoi(val); e == nil {
|
||
cfg.Port = n
|
||
}
|
||
case "path":
|
||
cfg.Path = val
|
||
case "interval":
|
||
if n, e := strconv.Atoi(val); e == nil {
|
||
cfg.Interval = n
|
||
}
|
||
case "csv":
|
||
cfg.CSV = val
|
||
case "web":
|
||
if n, e := strconv.Atoi(val); e == nil {
|
||
cfg.Web = n
|
||
}
|
||
case "mode":
|
||
cfg.Mode = val
|
||
case "url":
|
||
cfg.URL = val
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Telemetry payload (matches ESP32 /api/telemetry JSON) ───────────────────
|
||
type Firmware struct {
|
||
Version string `json:"version"`
|
||
Build string `json:"build"`
|
||
}
|
||
type GPS struct {
|
||
Valid int `json:"valid"`
|
||
Lat float64 `json:"lat"`
|
||
Lon float64 `json:"lon"`
|
||
Hdg float64 `json:"hdg"`
|
||
Sats int `json:"sats"`
|
||
Hdop float64 `json:"hdop"`
|
||
SpeedKn float64 `json:"speed_kn"`
|
||
Status string `json:"status"`
|
||
}
|
||
type Seaker struct {
|
||
Status string `json:"status"`
|
||
Angle float64 `json:"angle"`
|
||
Dist float64 `json:"dist"`
|
||
RxFreq float64 `json:"rx_freq"`
|
||
}
|
||
type Power struct {
|
||
Voltage float64 `json:"voltage"`
|
||
CurrentMA float64 `json:"current_mA"`
|
||
}
|
||
type Ntrip struct {
|
||
Enabled int `json:"enabled"`
|
||
Host string `json:"host"`
|
||
Port int `json:"port"`
|
||
Mount string `json:"mount"`
|
||
Streaming int `json:"streaming"`
|
||
}
|
||
type TargetF struct {
|
||
Lat float64 `json:"lat"`
|
||
Lon float64 `json:"lon"`
|
||
R95M float64 `json:"r95_m"`
|
||
}
|
||
type Telemetry struct {
|
||
DeviceID string `json:"deviceId"`
|
||
Firmware Firmware `json:"firmware"`
|
||
GPS GPS `json:"gps"`
|
||
Seaker Seaker `json:"seaker"`
|
||
Power Power `json:"power"`
|
||
Ntrip Ntrip `json:"ntrip"`
|
||
TargetF TargetF `json:"targetf"`
|
||
IP string `json:"ip"`
|
||
RSSI int `json:"rssi"`
|
||
}
|
||
|
||
// Row enriched with PC timestamp
|
||
type Row struct {
|
||
Timestamp string `json:"Timestamp"`
|
||
T Telemetry `json:"T"`
|
||
}
|
||
|
||
// ─── Ring buffer ─────────────────────────────────────────────────────────────
|
||
type Ring struct {
|
||
mu sync.RWMutex
|
||
buf []Row
|
||
head int
|
||
size int
|
||
}
|
||
|
||
func newRing(n int) *Ring { return &Ring{buf: make([]Row, n), size: n} }
|
||
|
||
func (r *Ring) Push(row Row) {
|
||
r.mu.Lock()
|
||
r.buf[r.head] = row
|
||
r.head = (r.head + 1) % r.size
|
||
r.mu.Unlock()
|
||
}
|
||
|
||
func (r *Ring) Last(n int) []Row {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
if n > r.size {
|
||
n = r.size
|
||
}
|
||
out := make([]Row, 0, n)
|
||
for i := n - 1; i >= 0; i-- {
|
||
idx := (r.head - 1 - i + r.size*2) % r.size
|
||
if r.buf[idx].Timestamp != "" {
|
||
out = append(out, r.buf[idx])
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (r *Ring) Latest() (Row, bool) {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
idx := (r.head - 1 + r.size) % r.size
|
||
row := r.buf[idx]
|
||
return row, row.Timestamp != ""
|
||
}
|
||
|
||
// ─── CSV writer ──────────────────────────────────────────────────────────────
|
||
var csvHeader = []string{
|
||
"timestamp_iso",
|
||
"device_id",
|
||
"firmware_version", "firmware_build",
|
||
"gps_valid", "gps_lat", "gps_lon", "gps_hdg", "gps_sats", "gps_hdop", "gps_speed_kn", "gps_status",
|
||
"seaker_status", "seaker_angle", "seaker_dist", "seaker_rx_freq",
|
||
"power_voltage", "power_current_mA",
|
||
"ntrip_enabled", "ntrip_host", "ntrip_port", "ntrip_mount", "ntrip_streaming",
|
||
"target_lat", "target_lon", "target_r95_m",
|
||
"ip", "rssi",
|
||
}
|
||
|
||
func rowToCSV(row Row) []string {
|
||
t := row.T
|
||
return []string{
|
||
row.Timestamp,
|
||
t.DeviceID,
|
||
t.Firmware.Version, t.Firmware.Build,
|
||
strconv.Itoa(t.GPS.Valid),
|
||
strconv.FormatFloat(t.GPS.Lat, 'f', 7, 64),
|
||
strconv.FormatFloat(t.GPS.Lon, 'f', 7, 64),
|
||
strconv.FormatFloat(t.GPS.Hdg, 'f', 1, 64),
|
||
strconv.Itoa(t.GPS.Sats),
|
||
strconv.FormatFloat(t.GPS.Hdop, 'f', 1, 64),
|
||
strconv.FormatFloat(t.GPS.SpeedKn, 'f', 2, 64),
|
||
t.GPS.Status,
|
||
t.Seaker.Status,
|
||
strconv.FormatFloat(t.Seaker.Angle, 'f', 1, 64),
|
||
strconv.FormatFloat(t.Seaker.Dist, 'f', 1, 64),
|
||
strconv.FormatFloat(t.Seaker.RxFreq, 'f', 1, 64),
|
||
strconv.FormatFloat(t.Power.Voltage, 'f', 2, 64),
|
||
strconv.FormatFloat(t.Power.CurrentMA, 'f', 0, 64),
|
||
strconv.Itoa(t.Ntrip.Enabled),
|
||
t.Ntrip.Host,
|
||
strconv.Itoa(t.Ntrip.Port),
|
||
t.Ntrip.Mount,
|
||
strconv.Itoa(t.Ntrip.Streaming),
|
||
strconv.FormatFloat(t.TargetF.Lat, 'f', 7, 64),
|
||
strconv.FormatFloat(t.TargetF.Lon, 'f', 7, 64),
|
||
strconv.FormatFloat(t.TargetF.R95M, 'f', 2, 64),
|
||
t.IP,
|
||
strconv.Itoa(t.RSSI),
|
||
}
|
||
}
|
||
|
||
// ─── CSV file (auto-rotate by day) ──────────────────────────────────────────
|
||
type CSVWriter struct {
|
||
mu sync.Mutex
|
||
pattern string // e.g. "seakesp_log_YYYY-MM-DD.csv" or "seakesp_log.csv"
|
||
dayBased bool
|
||
curDay string
|
||
file *os.File
|
||
writer *csv.Writer
|
||
}
|
||
|
||
func newCSVWriter(pattern string) (*CSVWriter, error) {
|
||
dayBased := strings.Contains(pattern, "YYYY-MM-DD")
|
||
w := &CSVWriter{pattern: pattern, dayBased: dayBased}
|
||
return w, w.open()
|
||
}
|
||
|
||
func (w *CSVWriter) open() error {
|
||
today := time.Now().Format("2006-01-02")
|
||
path := w.pattern
|
||
if w.dayBased {
|
||
path = strings.ReplaceAll(path, "YYYY-MM-DD", today)
|
||
}
|
||
w.curDay = today
|
||
|
||
needHeader := false
|
||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||
needHeader = true
|
||
}
|
||
|
||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if w.file != nil {
|
||
w.file.Close()
|
||
}
|
||
w.file = f
|
||
w.writer = csv.NewWriter(f)
|
||
if needHeader {
|
||
w.writer.Write(csvHeader)
|
||
w.writer.Flush()
|
||
f.Sync()
|
||
}
|
||
log.Printf("[CSV] fichier: %s", path)
|
||
return nil
|
||
}
|
||
|
||
func (w *CSVWriter) Write(row Row) error {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
if w.dayBased {
|
||
today := time.Now().Format("2006-01-02")
|
||
if today != w.curDay {
|
||
w.open()
|
||
}
|
||
}
|
||
if err := w.writer.Write(rowToCSV(row)); err != nil {
|
||
return err
|
||
}
|
||
w.writer.Flush()
|
||
return w.file.Sync()
|
||
}
|
||
|
||
func (w *CSVWriter) Close() {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
if w.writer != nil {
|
||
w.writer.Flush()
|
||
}
|
||
if w.file != nil {
|
||
w.file.Close()
|
||
}
|
||
}
|
||
|
||
// ─── Poller ──────────────────────────────────────────────────────────────────
|
||
type App struct {
|
||
cfg Config
|
||
ring *Ring
|
||
csvw *CSVWriter
|
||
mu sync.RWMutex
|
||
linked bool
|
||
lastOk time.Time
|
||
// runtime config
|
||
espHost string
|
||
espPort int
|
||
espPath string
|
||
espURL string // full URL override
|
||
}
|
||
|
||
func (a *App) poll() {
|
||
client := &http.Client{Timeout: 4 * time.Second}
|
||
ticker := time.NewTicker(time.Duration(a.cfg.Interval) * time.Second)
|
||
defer ticker.Stop()
|
||
for range ticker.C {
|
||
a.mu.RLock()
|
||
var pollURL string
|
||
if a.espURL != "" {
|
||
pollURL = a.espURL
|
||
} else {
|
||
pollURL = fmt.Sprintf("http://%s:%d%s", a.espHost, a.espPort, a.espPath)
|
||
}
|
||
a.mu.RUnlock()
|
||
url := pollURL
|
||
|
||
resp, err := client.Get(url)
|
||
if err != nil {
|
||
log.Printf("[POLL] lien perdu: %v", err)
|
||
a.mu.Lock()
|
||
a.linked = false
|
||
a.mu.Unlock()
|
||
continue
|
||
}
|
||
body, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
|
||
var t Telemetry
|
||
if err := json.Unmarshal(body, &t); err != nil {
|
||
log.Printf("[POLL] JSON invalide: %v", err)
|
||
continue
|
||
}
|
||
|
||
row := Row{Timestamp: time.Now().Format(time.RFC3339), T: t}
|
||
a.ring.Push(row)
|
||
if err := a.csvw.Write(row); err != nil {
|
||
log.Printf("[CSV] erreur: %v", err)
|
||
}
|
||
a.mu.Lock()
|
||
a.linked = true
|
||
a.lastOk = time.Now()
|
||
a.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// ─── Receive mode (ESP32 POSTs to us) ────────────────────────────────────────
|
||
func (a *App) receiveHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "POST only", 405)
|
||
return
|
||
}
|
||
body, _ := io.ReadAll(r.Body)
|
||
r.Body.Close()
|
||
var t Telemetry
|
||
if err := json.Unmarshal(body, &t); err != nil {
|
||
http.Error(w, "bad json", 400)
|
||
return
|
||
}
|
||
row := Row{Timestamp: time.Now().Format(time.RFC3339), T: t}
|
||
a.ring.Push(row)
|
||
a.csvw.Write(row)
|
||
a.mu.Lock()
|
||
a.linked = true
|
||
a.lastOk = time.Now()
|
||
a.mu.Unlock()
|
||
w.WriteHeader(200)
|
||
w.Write([]byte(`{"ok":true}`))
|
||
}
|
||
|
||
// ─── API handlers ────────────────────────────────────────────────────────────
|
||
func (a *App) apiLatest(w http.ResponseWriter, r *http.Request) {
|
||
n := 600
|
||
if v := r.URL.Query().Get("n"); v != "" {
|
||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||
n = parsed
|
||
}
|
||
}
|
||
rows := a.ring.Last(n)
|
||
a.mu.RLock()
|
||
linked := a.linked
|
||
lastOk := a.lastOk
|
||
a.mu.RUnlock()
|
||
|
||
type Out struct {
|
||
Rows []Row `json:"rows"`
|
||
Linked bool `json:"linked"`
|
||
AgeSec int `json:"age_sec"`
|
||
}
|
||
age := 0
|
||
if !lastOk.IsZero() {
|
||
age = int(math.Round(time.Since(lastOk).Seconds()))
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(Out{Rows: rows, Linked: linked, AgeSec: age})
|
||
}
|
||
|
||
func (a *App) apiConfig(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
if r.Method == http.MethodGet {
|
||
a.mu.RLock()
|
||
type Out struct {
|
||
Host string `json:"host"`
|
||
Port int `json:"port"`
|
||
Path string `json:"path"`
|
||
URL string `json:"url"`
|
||
Interval int `json:"interval"`
|
||
}
|
||
out := Out{Host: a.espHost, Port: a.espPort, Path: a.espPath, URL: a.espURL, Interval: a.cfg.Interval}
|
||
a.mu.RUnlock()
|
||
json.NewEncoder(w).Encode(out)
|
||
return
|
||
}
|
||
if r.Method == http.MethodPost {
|
||
var in struct {
|
||
Host string `json:"host"`
|
||
Port int `json:"port"`
|
||
Path string `json:"path"`
|
||
URL string `json:"url"`
|
||
Interval int `json:"interval"`
|
||
}
|
||
json.NewDecoder(r.Body).Decode(&in)
|
||
a.mu.Lock()
|
||
if in.Host != "" {
|
||
a.espHost = in.Host
|
||
// switching to host mode: clear url override
|
||
if in.URL == "" {
|
||
a.espURL = ""
|
||
}
|
||
}
|
||
if in.Port != 0 {
|
||
a.espPort = in.Port
|
||
}
|
||
if in.Path != "" {
|
||
a.espPath = in.Path
|
||
}
|
||
if in.URL != "" {
|
||
a.espURL = in.URL
|
||
}
|
||
if in.Interval > 0 {
|
||
a.cfg.Interval = in.Interval
|
||
}
|
||
a.mu.Unlock()
|
||
log.Printf("[CFG] cible changée → %s:%d%s (url=%q)", a.espHost, a.espPort, a.espPath, a.espURL)
|
||
w.WriteHeader(200)
|
||
w.Write([]byte(`{"ok":true}`))
|
||
return
|
||
}
|
||
http.Error(w, "method not allowed", 405)
|
||
}
|
||
|
||
// ─── Dashboard HTML template ─────────────────────────────────────────────────
|
||
const dashHTML = `<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>SeaKESP Logger v{{.Version}}</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:#0a0f1a;color:#e0e8ff;font-family:'Segoe UI',system-ui,sans-serif;padding:12px}
|
||
h1{font-size:1.1rem;color:#7ab4ff;margin-bottom:12px}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;margin-bottom:16px}
|
||
.card{background:#111c2e;border:1px solid #1e3050;border-radius:8px;padding:10px}
|
||
.card .label{font-size:0.68rem;text-transform:uppercase;letter-spacing:.08em;color:#5a7899;margin-bottom:4px}
|
||
.card .value{font-size:1.4rem;font-weight:700}
|
||
.card .unit{font-size:0.75rem;color:#5a7899;margin-left:3px}
|
||
.ok{color:#4ade80}.warn{color:#facc15}.err{color:#f87171}.na{color:#4a6080}
|
||
.link-bar{padding:6px 12px;border-radius:6px;font-size:0.85rem;margin-bottom:14px;display:inline-block}
|
||
.link-ok{background:#0d3b1f;color:#4ade80;border:1px solid #166534}
|
||
.link-err{background:#3b0d0d;color:#f87171;border:1px solid #991b1b}
|
||
.spark-row{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;margin-bottom:16px}
|
||
.spark-card{background:#111c2e;border:1px solid #1e3050;border-radius:8px;padding:10px}
|
||
.spark-card .label{font-size:0.68rem;text-transform:uppercase;letter-spacing:.08em;color:#5a7899;margin-bottom:6px}
|
||
canvas{width:100%;height:60px;display:block}
|
||
.config-bar{background:#111c2e;border:1px solid #1e3050;border-radius:8px;padding:10px;margin-bottom:14px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||
.config-bar input{background:#0a0f1a;border:1px solid #1e3050;color:#e0e8ff;padding:4px 8px;border-radius:4px;font-size:0.85rem;width:140px}
|
||
.config-bar button{background:#1a3a6e;border:none;color:#7ab4ff;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:0.85rem}
|
||
.config-bar button:hover{background:#1e4a8e}
|
||
footer{font-size:0.7rem;color:#2a4060;margin-top:8px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>SeaKESP Logger <span style="color:#2a6099">v{{.Version}}</span></h1>
|
||
|
||
<div id="link-indicator" class="link-bar link-err">Connexion...</div>
|
||
|
||
<div class="config-bar">
|
||
<span style="font-size:0.8rem;color:#5a7899">ESP32:</span>
|
||
<input id="cfg-host" placeholder="192.168.1.50" />
|
||
<input id="cfg-port" placeholder="80" style="width:60px"/>
|
||
<input id="cfg-path" placeholder="/api/telemetry" style="width:140px"/>
|
||
<button onclick="saveConfig()">Appliquer</button>
|
||
<span id="cfg-status" style="font-size:0.8rem;color:#4ade80;display:none">Appliqué</span>
|
||
</div>
|
||
|
||
<div class="grid" id="cards">
|
||
<div class="card"><div class="label">GPS Fix</div><div class="value na" id="c-gps-valid">–</div></div>
|
||
<div class="card"><div class="label">Satellites</div><div class="value na" id="c-gps-sats">–</div></div>
|
||
<div class="card"><div class="label">Latitude</div><div class="value na" id="c-lat">–</div></div>
|
||
<div class="card"><div class="label">Longitude</div><div class="value na" id="c-lon">–</div></div>
|
||
<div class="card"><div class="label">Cap (°)</div><div class="value na" id="c-hdg">–</div></div>
|
||
<div class="card"><div class="label">Vitesse (kn)</div><div class="value na" id="c-speed">–</div></div>
|
||
<div class="card"><div class="label">RSSI (dBm)</div><div class="value na" id="c-rssi">–</div></div>
|
||
<div class="card"><div class="label">Batterie (V)</div><div class="value na" id="c-volt">–</div></div>
|
||
<div class="card"><div class="label">Courant (mA)</div><div class="value na" id="c-amp">–</div></div>
|
||
<div class="card"><div class="label">Seaker Angle (°)</div><div class="value na" id="c-sk-ang">–</div></div>
|
||
<div class="card"><div class="label">Seaker Dist (m)</div><div class="value na" id="c-sk-dist">–</div></div>
|
||
<div class="card"><div class="label">Seaker Freq (Hz)</div><div class="value na" id="c-sk-freq">–</div></div>
|
||
<div class="card"><div class="label">ROV Lat</div><div class="value na" id="c-rov-lat">–</div></div>
|
||
<div class="card"><div class="label">ROV Lon</div><div class="value na" id="c-rov-lon">–</div></div>
|
||
<div class="card"><div class="label">ROV R95 (m)</div><div class="value na" id="c-rov-r95">–</div></div>
|
||
<div class="card"><div class="label">Device ID</div><div class="value na" style="font-size:0.85rem" id="c-devid">–</div></div>
|
||
<div class="card"><div class="label">Firmware</div><div class="value na" style="font-size:0.85rem" id="c-fw">–</div></div>
|
||
<div class="card"><div class="label">ESP IP</div><div class="value na" style="font-size:0.9rem" id="c-ip">–</div></div>
|
||
</div>
|
||
|
||
<div class="spark-row">
|
||
<div class="spark-card"><div class="label">Batterie (V)</div><canvas id="sp-volt"></canvas></div>
|
||
<div class="spark-card"><div class="label">RSSI (dBm)</div><canvas id="sp-rssi"></canvas></div>
|
||
<div class="spark-card"><div class="label">Seaker Dist (m)</div><canvas id="sp-dist"></canvas></div>
|
||
<div class="spark-card"><div class="label">Vitesse (kn)</div><canvas id="sp-speed"></canvas></div>
|
||
</div>
|
||
|
||
<footer>SeaKESP Logger v{{.Version}} — dashboard local — données non transmises</footer>
|
||
|
||
<script>
|
||
const THRESH_SATS_OK={{.ThreshSatsOk}};
|
||
const THRESH_RSSI_OK={{.ThreshRssiOk}};
|
||
const THRESH_RSSI_WARN={{.ThreshRssiWarn}};
|
||
const THRESH_BATT_OK={{.ThreshBattOk}};
|
||
const THRESH_BATT_WARN={{.ThreshBattWarn}};
|
||
const THRESH_LINK_S={{.ThreshLinkS}};
|
||
|
||
const sparkData={volt:[],rssi:[],dist:[],speed:[]};
|
||
const SPARK_N=120;
|
||
|
||
function cls(v,ok,warn){
|
||
if(v>=ok)return'ok';
|
||
if(v>=warn)return'warn';
|
||
return'err';
|
||
}
|
||
function setCard(id,text,cls_name){
|
||
const el=document.getElementById(id);
|
||
if(!el)return;
|
||
el.textContent=text;
|
||
el.className='value '+cls_name;
|
||
}
|
||
function fmt(v,d=1){return(v==null||v===undefined)?'–':Number(v).toFixed(d);}
|
||
|
||
function drawSpark(id,data,color,minV,maxV){
|
||
const canvas=document.getElementById(id);
|
||
if(!canvas)return;
|
||
const W=canvas.offsetWidth||240,H=60;
|
||
canvas.width=W; canvas.height=H;
|
||
const ctx=canvas.getContext('2d');
|
||
ctx.clearRect(0,0,W,H);
|
||
if(data.length<2)return;
|
||
const lo=minV!==undefined?minV:Math.min(...data);
|
||
const hi=maxV!==undefined?maxV:Math.max(...data);
|
||
const range=hi-lo||1;
|
||
ctx.beginPath();
|
||
ctx.strokeStyle=color;
|
||
ctx.lineWidth=1.5;
|
||
data.forEach((v,i)=>{
|
||
const x=(i/(data.length-1))*W;
|
||
const y=H-(((v-lo)/range)*(H-8)+4);
|
||
if(i===0)ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
||
});
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Pré-chargement config au démarrage (BUG1 fix)
|
||
async function loadConfig(){
|
||
try{
|
||
const resp=await fetch('api/config');
|
||
const cfg=await resp.json();
|
||
if(cfg.host)document.getElementById('cfg-host').value=cfg.host;
|
||
if(cfg.port)document.getElementById('cfg-port').value=cfg.port;
|
||
if(cfg.path)document.getElementById('cfg-path').value=cfg.path;
|
||
}catch(e){}
|
||
}
|
||
|
||
async function fetchAndUpdate(){
|
||
try{
|
||
const resp=await fetch('api/latest?n='+SPARK_N);
|
||
const data=await resp.json();
|
||
const linked=data.linked;
|
||
const age=data.age_sec;
|
||
const rows=data.rows||[];
|
||
|
||
// link indicator
|
||
const li=document.getElementById('link-indicator');
|
||
if(!linked||age>THRESH_LINK_S){
|
||
li.className='link-bar link-err';
|
||
li.textContent=linked?'Lien perdu ('+age+'s sans trame)':'Hors ligne';
|
||
}else{
|
||
li.className='link-bar link-ok';
|
||
li.textContent='En ligne — dernière trame il y a '+age+'s';
|
||
}
|
||
|
||
if(rows.length===0)return;
|
||
// JSON sérialisé par Go : Row.Timestamp="Timestamp", Row.T="T"
|
||
// Telemetry fields utilisent les json tags (minuscules)
|
||
const r=rows[rows.length-1];
|
||
const t=r.T||{};
|
||
|
||
// GPS — json tags: gps.valid, gps.sats, gps.lat, gps.lon, gps.hdg, gps.speed_kn, gps.status
|
||
const gps=t.gps||{};
|
||
const gv=gps.valid===1;
|
||
setCard('c-gps-valid',gv?(gps.status||'FIX'):'NO FIX',gv?'ok':'err');
|
||
const sats=gps.sats!=null?gps.sats:0;
|
||
setCard('c-gps-sats',sats,sats>=THRESH_SATS_OK?'ok':sats>0?'warn':'err');
|
||
setCard('c-lat',fmt(gps.lat,7),'na');
|
||
setCard('c-lon',fmt(gps.lon,7),'na');
|
||
setCard('c-hdg',fmt(gps.hdg,1),'na');
|
||
setCard('c-speed',fmt(gps.speed_kn,2),'na');
|
||
|
||
// RSSI — json tag: rssi
|
||
const rssi=t.rssi!=null?t.rssi:0;
|
||
setCard('c-rssi',rssi,cls(rssi,THRESH_RSSI_OK,THRESH_RSSI_WARN));
|
||
|
||
// Power — json tags: power.voltage, power.current_mA
|
||
const pwr=t.power||{};
|
||
const volt=pwr.voltage!=null?pwr.voltage:0;
|
||
setCard('c-volt',fmt(volt,2),cls(volt,THRESH_BATT_OK,THRESH_BATT_WARN));
|
||
setCard('c-amp',fmt(pwr.current_mA,0),'na');
|
||
|
||
// Seaker — json tags: seaker.angle, seaker.dist, seaker.rx_freq
|
||
const sk=t.seaker||{};
|
||
setCard('c-sk-ang',fmt(sk.angle,1),'na');
|
||
setCard('c-sk-dist',fmt(sk.dist,1),'na');
|
||
setCard('c-sk-freq',fmt(sk.rx_freq,1),'na');
|
||
|
||
// Target / ROV — json tags: targetf.lat, targetf.lon, targetf.r95_m
|
||
const tf=t.targetf||{};
|
||
setCard('c-rov-lat',fmt(tf.lat,7),'na');
|
||
setCard('c-rov-lon',fmt(tf.lon,7),'na');
|
||
setCard('c-rov-r95',fmt(tf.r95_m,2),'na');
|
||
|
||
// json tags: deviceId, firmware.version, ip
|
||
setCard('c-devid',t.deviceId||'–','na');
|
||
const fw=t.firmware||{};
|
||
setCard('c-fw',fw.version||'–','na');
|
||
setCard('c-ip',t.ip||'–','na');
|
||
|
||
// Sparklines — alimentées depuis rows (historique ring-buffer)
|
||
sparkData.volt.length=0;sparkData.rssi.length=0;sparkData.dist.length=0;sparkData.speed.length=0;
|
||
rows.forEach(row=>{
|
||
const tt=row.T||{};
|
||
sparkData.volt.push((tt.power||{}).voltage||0);
|
||
sparkData.rssi.push(tt.rssi||0);
|
||
sparkData.dist.push((tt.seaker||{}).dist||0);
|
||
sparkData.speed.push((tt.gps||{}).speed_kn||0);
|
||
});
|
||
|
||
drawSpark('sp-volt',sparkData.volt,'#4ade80');
|
||
drawSpark('sp-rssi',sparkData.rssi,'#7ab4ff');
|
||
drawSpark('sp-dist',sparkData.dist,'#facc15');
|
||
drawSpark('sp-speed',sparkData.speed,'#fb923c');
|
||
}catch(e){
|
||
const li=document.getElementById('link-indicator');
|
||
li.className='link-bar link-err';
|
||
li.textContent='Erreur dashboard: '+e.message;
|
||
}
|
||
}
|
||
|
||
async function saveConfig(){
|
||
const host=document.getElementById('cfg-host').value.trim();
|
||
const port=parseInt(document.getElementById('cfg-port').value)||0;
|
||
const path=document.getElementById('cfg-path').value.trim();
|
||
try{
|
||
const resp=await fetch('api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host,port,path})});
|
||
const ok=await resp.json();
|
||
const st=document.getElementById('cfg-status');
|
||
if(ok.ok){
|
||
st.textContent='Appliqué';st.style.color='#4ade80';
|
||
}else{
|
||
st.textContent='Erreur';st.style.color='#f87171';
|
||
}
|
||
st.style.display='inline';
|
||
setTimeout(()=>{st.style.display='none';},3000);
|
||
}catch(e){
|
||
const st=document.getElementById('cfg-status');
|
||
st.textContent='Erreur: '+e.message;st.style.color='#f87171';st.style.display='inline';
|
||
}
|
||
}
|
||
|
||
loadConfig();
|
||
setInterval(fetchAndUpdate,1000);
|
||
fetchAndUpdate();
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
|
||
// ─── main ─────────────────────────────────────────────────────────────────────
|
||
func main() {
|
||
cfg := defaultConfig()
|
||
// Load ini next to exe
|
||
exe, _ := os.Executable()
|
||
loadIni(filepath.Join(filepath.Dir(exe), "config.ini"), &cfg)
|
||
parseFlags(&cfg)
|
||
|
||
fmt.Printf("SeaKESP Logger v%s\n", Version)
|
||
fmt.Printf("Mode: %s | ESP32: %s:%d%s | interval: %ds\n", cfg.Mode, cfg.Host, cfg.Port, cfg.Path, cfg.Interval)
|
||
fmt.Printf("CSV: %s | Dashboard: http://localhost:%d\n", cfg.CSV, cfg.Web)
|
||
|
||
ring := newRing(RingBufferSize)
|
||
csvw, err := newCSVWriter(cfg.CSV)
|
||
if err != nil {
|
||
log.Fatalf("[CSV] impossible d'ouvrir: %v", err)
|
||
}
|
||
defer csvw.Close()
|
||
|
||
app := &App{
|
||
cfg: cfg,
|
||
ring: ring,
|
||
csvw: csvw,
|
||
espHost: cfg.Host,
|
||
espPort: cfg.Port,
|
||
espPath: cfg.Path,
|
||
espURL: cfg.URL,
|
||
}
|
||
|
||
// Start poller or receiver
|
||
if cfg.Mode == "receive" {
|
||
log.Printf("[RECEIVE] écoute POST /seakesp sur :%d", cfg.Web)
|
||
} else {
|
||
go app.poll()
|
||
}
|
||
|
||
// HTTP mux
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/latest", app.apiLatest)
|
||
mux.HandleFunc("/api/config", app.apiConfig)
|
||
if cfg.Mode == "receive" {
|
||
mux.HandleFunc("/seakesp", app.receiveHandler)
|
||
}
|
||
|
||
// Dashboard
|
||
type TplData struct {
|
||
Version string
|
||
ThreshSatsOk int
|
||
ThreshRssiOk int
|
||
ThreshRssiWarn int
|
||
ThreshBattOk float64
|
||
ThreshBattWarn float64
|
||
ThreshLinkS int
|
||
}
|
||
tmpl := template.Must(template.New("dash").Parse(dashHTML))
|
||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
tmpl.Execute(w, TplData{
|
||
Version: Version,
|
||
ThreshSatsOk: ThreshGpsSatsOk,
|
||
ThreshRssiOk: ThreshRssiOk,
|
||
ThreshRssiWarn: ThreshRssiWarn,
|
||
ThreshBattOk: ThreshBattVoltOk,
|
||
ThreshBattWarn: ThreshBattVoltWarn,
|
||
ThreshLinkS: ThreshLinkLostSec,
|
||
})
|
||
})
|
||
|
||
addr := fmt.Sprintf(":%d", cfg.Web)
|
||
log.Printf("[WEB] dashboard sur http://localhost%s", addr)
|
||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||
log.Fatalf("[WEB] %v", err)
|
||
}
|
||
}
|