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
119 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|