Files
seakesp-logger/main.go

784 lines
24 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.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)
}
}