// Command server is the Nils' MG4 car server: the API the car writes its telemetry, // consumption and charging sessions to, stored in InfluxDB 2 and forwarded live to ABRP. // Dashboards are Grafana's job; Grafana reads the same bucket directly. package main import ( "context" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "strconv" "syscall" "time" ) const minIngestTokenLength = 24 type config struct { listenAddr string influxURL string influxToken string influxOrg string influxBucket string ingestToken string abrpAPIKey string abrpUserToken string abrpMinInterval time.Duration } // loadConfig reads the environment and fails closed: a missing InfluxDB setting or a // short ingest token stops the server from starting rather than serving something broken. func loadConfig(getenv func(string) string) (config, error) { c := config{ listenAddr: getenv("LISTEN_ADDR"), influxURL: getenv("INFLUX_URL"), influxToken: getenv("INFLUX_TOKEN"), influxOrg: getenv("INFLUX_ORG"), influxBucket: getenv("INFLUX_BUCKET"), ingestToken: getenv("INGEST_TOKEN"), abrpAPIKey: getenv("ABRP_API_KEY"), abrpUserToken: getenv("ABRP_USER_TOKEN"), } if c.listenAddr == "" { c.listenAddr = ":8080" } if c.influxBucket == "" { c.influxBucket = "mg4" } for name, value := range map[string]string{"INFLUX_URL": c.influxURL, "INFLUX_TOKEN": c.influxToken, "INFLUX_ORG": c.influxOrg} { if value == "" { return config{}, fmt.Errorf("%s is not configured", name) } } if len(c.ingestToken) < minIngestTokenLength { return config{}, fmt.Errorf("INGEST_TOKEN must be at least %d characters", minIngestTokenLength) } c.abrpMinInterval = 5 * time.Second if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" { seconds, err := strconv.ParseFloat(raw, 64) if err != nil || seconds < 0 { return config{}, errors.New("ABRP_MIN_INTERVAL_SECONDS must be a non-negative number") } c.abrpMinInterval = time.Duration(seconds * float64(time.Second)) } return c, nil } func main() { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) cfg, err := loadConfig(os.Getenv) if err != nil { slog.Error("invalid configuration", "error", err) os.Exit(1) } store := NewInfluxStore(cfg.influxURL, cfg.influxToken, cfg.influxOrg, cfg.influxBucket) defer store.Close() forwarder := &Forwarder{ APIKey: cfg.abrpAPIKey, UserToken: cfg.abrpUserToken, MinInterval: cfg.abrpMinInterval, Client: &http.Client{Timeout: 10 * time.Second}, } api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder} srv := &http.Server{ Addr: cfg.listenAddr, Handler: api.Routes(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, "abrp", forwarder.configured()) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server failed", "error", err) os.Exit(1) } }() <-ctx.Done() shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdown); err != nil { slog.Error("shutdown", "error", err) } }