feat: seakesp-logger v0.1.0 — logger WiFi ESP32 Go stdlib uniquement
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
*.exe
|
||||||
|
seakesp-logger-linux
|
||||||
|
seakesp_log_*.csv
|
||||||
|
config.ini
|
||||||
|
*.csv
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -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.
|
||||||
23
config.ini.example
Normal file
23
config.ini.example
Normal file
@@ -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
|
||||||
783
main.go
Normal file
783
main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user