Files
mg4/server/main.go
T
nilsandClaude Opus 5.5 d711e0cfda App 2.12.0, server 2.5.0: enter settings on the phone
The ABRP credentials are long to type on the car. "Enter on phone"
(Live data tab, and Settings next to the charging price) shows a QR
code; it opens a form on the server where the phone sends the ABRP API
key and user token, charging price and usable capacity, confirmed with
the approval password. The car polls with its own token and collects
the values once, then saves them through the same checks as the fields
on screen.

Same device-flow shape as pairing. The server keeps the values in memory
only, at most ten minutes, hands them out once with no-store, and never
logs them or puts them back into the page; a paired token only reaches
its own installation's request. Pairing's QR dialog is now shared
(PhoneQrDialog), and the phone pages share one stylesheet.

The config file also takes charging_price and usable_kwh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 12:59:03 +02:00

119 lines
3.7 KiB
Go

// 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, Handoff: NewHandoff(),
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)
}
}