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 = `