Server: rewrite in Go as an API-only service

Same endpoints, request formats, validation rules and InfluxDB layout as
the Next.js server, so the car needs no change: POST /v1/telemetry,
PUT /v1/consumption/daily, PUT /v1/charging-sessions/{id}, GET
/v1/consumption/history/{id}, GET /v1/health. The dashboard, login and
the /v1/history reads are gone; Grafana takes over the dashboards.

- Go 1.26 standard library (net/http routing, log/slog) plus the
  official InfluxDB 2 client. Fails closed at startup on a missing
  InfluxDB setting or an ingest token under 24 characters.
- ABRP forwarder ported: newest fresh sample, rate limited, never
  queued, credentials only in the form body.
- 22 tests: validation, aggregation, forwarding, config, and the
  handlers through a fake store. govulncheck clean (x/net 0.60.0,
  toolchain go1.26.9).
- Image: static binary on distroless, about 11 MB, UID 65532, port
  8080; the build runs vet and the tests.
- Dev shell: Go, gopls and govulncheck instead of Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-17 15:17:42 +02:00
co-authored by Claude Opus 5.5
parent 538d03361d
commit 51e6f6fd48
82 changed files with 1673 additions and 3966 deletions
+51
View File
@@ -0,0 +1,51 @@
package main
import "sort"
// aggregateDays folds chunks into days, newest day first, each with its running lifetime
// total. Pure, so the arithmetic is tested without a database.
func aggregateDays(chunks []ConsumptionChunk) []DailyConsumption {
type day struct {
DailyConsumption
sohAt int64
hasSoh bool
}
byDate := map[string]*day{}
for _, chunk := range chunks {
d, ok := byDate[chunk.Date]
if !ok {
d = &day{DailyConsumption: DailyConsumption{
InstallationID: chunk.InstallationID,
Date: chunk.Date,
RecordedAt: chunk.RecordedAt,
}}
byDate[chunk.Date] = d
}
d.Day.add(chunk.Day)
if chunk.RecordedAt.After(d.RecordedAt) {
d.RecordedAt = chunk.RecordedAt
}
// The newest manual SoH of the day wins.
at := chunk.RecordedAt.UnixMilli()
if chunk.SohPercent != nil && (!d.hasSoh || at > d.sohAt) {
soh := *chunk.SohPercent
d.SohPercent = &soh
d.sohAt = at
d.hasSoh = true
}
}
days := make([]DailyConsumption, 0, len(byDate))
for _, d := range byDate {
days = append(days, d.DailyConsumption)
}
sort.Slice(days, func(i, j int) bool { return days[i].Date < days[j].Date })
var running Totals
for i := range days {
running.add(days[i].Day)
days[i].Lifetime = running
}
for i, j := 0, len(days)-1; i < j; i, j = i+1, j-1 {
days[i], days[j] = days[j], days[i]
}
return days
}