commit 42cf29d7e3d38a20d27e085de6b548539badaf77 Author: Poulpe Date: Mon Jun 8 12:30:31 2026 +0000 feat: seakesp-logger v0.1.0 — logger WiFi ESP32 Go stdlib uniquement diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9859633 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.exe +seakesp-logger-linux +seakesp_log_*.csv +config.ini +*.csv diff --git a/README.md b/README.md new file mode 100644 index 0000000..87fdbde --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# seakesp-logger + +Logger WiFi autonome pour ESP32 SeaKESP. Binaire Windows unique, zéro install. + +## Usage + +Double-cliquez ou : + +seakesp-logger.exe -host 192.168.x.x + +Puis ouvrez http://localhost:8099 pour le dashboard QC temps réel. + +## Flags + +| Flag | Défaut | Description | +|------|--------|-------------| +| -host | 192.168.1.50 | IP de l'ESP32 | +| -port | 80 | Port HTTP ESP32 | +| -path | /api/telemetry | Endpoint JSON | +| -interval | 1 | Polling en secondes | +| -csv | seakesp_log_YYYY-MM-DD.csv | Fichier CSV (rotation par jour) | +| -web | 8099 | Port dashboard local | +| -mode | poll | poll ou receive | + +## Config + +Copiez en à côté de l'exe et éditez. + +## CSV + +Le CSV est créé à côté de l'exe. En-tête une seule fois, append au fil du temps. +Rotation automatique à minuit si le nom contient YYYY-MM-DD. + +## Dashboard QC + +http://localhost:8099 — cartes couleur par métrique, sparklines, indicateur lien. diff --git a/config.ini.example b/config.ini.example new file mode 100644 index 0000000..9509eb2 --- /dev/null +++ b/config.ini.example @@ -0,0 +1,23 @@ +# SeaKESP Logger — config optionnelle (copiez en config.ini à côté de l'exe) +# Les valeurs ici sont les défauts + +# IP de l'ESP32 +host=192.168.1.50 + +# Port HTTP de l'ESP32 (80 = firmware standard) +port=80 + +# Endpoint telemetry (GET JSON) +path=/api/telemetry + +# Intervalle de polling en secondes +interval=1 + +# Fichier CSV (YYYY-MM-DD sera remplacé par la date du jour = rotation auto) +csv=seakesp_log_YYYY-MM-DD.csv + +# Port du dashboard QC local +web=8099 + +# Mode: poll (GET vers l'ESP32) ou receive (HTTP server, reçoit les POST de l'ESP32) +mode=poll diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..18cf25f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module seakesp-logger + +go 1.22.12 diff --git a/main.go b/main.go new file mode 100644 index 0000000..33d4f7e --- /dev/null +++ b/main.go @@ -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 = ` + + + + +SeaKESP Logger v{{.Version}} + + + +

SeaKESP Logger v{{.Version}}

+ + + +
+ ESP32: + + + + +
+ +
+
GPS Fix
+
Satellites
+
Latitude
+
Longitude
+
Cap (°)
+
Vitesse (kn)
+
RSSI (dBm)
+
Batterie (V)
+
Courant (mA)
+
Seaker Angle (°)
+
Seaker Dist (m)
+
Seaker Freq (Hz)
+
ROV Lat
+
ROV Lon
+
ROV R95 (m)
+
Device ID
+
Firmware
+
ESP IP
+
+ +
+
Batterie (V)
+
RSSI (dBm)
+
Seaker Dist (m)
+
Vitesse (kn)
+
+ + + + + +` + +// ─── 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) + } +}