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
52 lines
1.3 KiB
Go
52 lines
1.3 KiB
Go
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
|
|
}
|