// Command server is the Nils' MG4 car server: the API the car writes its telemetry, // consumption and charging sessions to, stored in InfluxDB 2. The car sends live data to ABRP // itself. // Dashboards are Grafana's job; Grafana reads the same bucket directly. package main import ( "context" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" ) const ( minIngestTokenLength = 24 minApprovalPasswordLength = 12 ) type config struct { listenAddr string influxURL string influxToken string influxOrg string influxBucket string ingestToken string approvalPass string publicURL string } // 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"), approvalPass: getenv("APPROVAL_PASSWORD"), publicURL: getenv("PUBLIC_URL"), } 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) } // Pairing is optional; when it is on, its password guards token issuance and must not // be guessable. if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength { return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength) } 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() // Paired cars' tokens must be known before the first request; starting without them // would answer every paired car with 401, so a failed load stops the start instead. loadCtx, cancelLoad := context.WithTimeout(context.Background(), 30*time.Second) devices, err := store.LoadDevices(loadCtx) cancelLoad() if err != nil { slog.Error("paired devices could not be loaded", "error", err) os.Exit(1) } pairing := NewPairing(cfg.approvalPass, devices) api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, PublicURL: cfg.publicURL} 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, "pairing", pairing.Enabled(), "pairedDevices", len(devices)) 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) } }