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 = ` 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, 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) } }