Files
seakesp-logger/main.go
Poulpe dde2bc61fa fix(qc-dash): v0.2.1 — hot-reconfig + cartes depuis api/latest
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
2026-06-09 06:48:37 +00:00

846 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}