feat: seakesp-logger v0.1.0 — logger WiFi ESP32 Go stdlib uniquement

This commit is contained in:
2026-06-08 12:30:31 +00:00
commit 42cf29d7e3
5 changed files with 850 additions and 0 deletions

783
main.go Normal file
View File

@@ -0,0 +1,783 @@
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.1.0"
// ─── 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
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
}
}
}
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
}
}
}
// ─── 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
T Telemetry
}
// ─── 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
}
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()
url := fmt.Sprintf("http://%s:%d%s", a.espHost, a.espPort, a.espPath)
a.mu.RUnlock()
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) {
if r.Method == http.MethodGet {
a.mu.RLock()
type Out struct {
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
}
json.NewEncoder(w).Encode(Out{Host: a.espHost, Port: a.espPort, Path: a.espPath})
a.mu.RUnlock()
return
}
if r.Method == http.MethodPost {
var in struct {
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
}
json.NewDecoder(r.Body).Decode(&in)
a.mu.Lock()
if in.Host != "" {
a.espHost = in.Host
}
if in.Port != 0 {
a.espPort = in.Port
}
if in.Path != "" {
a.espPath = in.Path
}
a.mu.Unlock()
log.Printf("[CFG] ESP32 → %s:%d%s", a.espHost, a.espPort, a.espPath)
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>
</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);
el.textContent=text;
el.className='value '+cls_name;
}
function fmt(v,d=1){return v==null?'':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();
}
function push(arr,v){arr.push(v);if(arr.length>SPARK_N)arr.shift();}
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;
const r=rows[rows.length-1];
const t=r.T;
// GPS
const gv=t.GPS&&t.GPS.Valid===1;
setCard('c-gps-valid',gv?'FIX':'NO FIX',gv?'ok':'err');
const sats=t.GPS?t.GPS.Sats:0;
setCard('c-gps-sats',sats,sats>=THRESH_SATS_OK?'ok':sats>0?'warn':'err');
setCard('c-lat',fmt(t.GPS&&t.GPS.Lat,7),'na');
setCard('c-lon',fmt(t.GPS&&t.GPS.Lon,7),'na');
setCard('c-hdg',fmt(t.GPS&&t.GPS.Hdg,1),'na');
const spd=t.GPS?t.GPS.SpeedKn:0;
setCard('c-speed',fmt(spd,2),'na');
// RSSI
const rssi=t.RSSI||0;
setCard('c-rssi',rssi,cls(rssi,THRESH_RSSI_OK,THRESH_RSSI_WARN));
// Power
const volt=t.Power?t.Power.Voltage:0;
setCard('c-volt',fmt(volt,2),cls(volt,THRESH_BATT_OK,THRESH_BATT_WARN));
setCard('c-amp',fmt(t.Power&&t.Power.CurrentMA,0),'na');
// Seaker
setCard('c-sk-ang',fmt(t.Seaker&&t.Seaker.Angle,1),'na');
setCard('c-sk-dist',fmt(t.Seaker&&t.Seaker.Dist,1),'na');
setCard('c-sk-freq',fmt(t.Seaker&&t.Seaker.RxFreq,1),'na');
// Target / ROV
setCard('c-rov-lat',fmt(t.TargetF&&t.TargetF.Lat,7),'na');
setCard('c-rov-lon',fmt(t.TargetF&&t.TargetF.Lon,7),'na');
setCard('c-rov-r95',fmt(t.TargetF&&t.TargetF.R95M,2),'na');
setCard('c-devid',t.DeviceID||'','na');
setCard('c-fw',(t.Firmware&&t.Firmware.Version)||'','na');
setCard('c-ip',t.IP||'','na');
// Sparklines — fill from all rows
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?tt.Power.Voltage:0);
sparkData.rssi.push(tt.RSSI||0);
sparkData.dist.push(tt.Seaker?tt.Seaker.Dist:0);
sparkData.speed.push(tt.GPS?tt.GPS.SpeedKn: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;
const port=parseInt(document.getElementById('cfg-port').value)||0;
const path=document.getElementById('cfg-path').value;
await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host,port,path})});
}
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,
}
// 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)
}
}