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:
+139
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Forwarder sends live telemetry on to A Better Route Planner.
|
||||
//
|
||||
// The ABRP credentials exist only here, from ABRP_API_KEY and ABRP_USER_TOKEN in the
|
||||
// server's environment; the car never holds them. Forwarding is off when either is empty.
|
||||
//
|
||||
// Only the newest sample of a batch is sent, and at most one per MinInterval. A failed
|
||||
// forward is logged and dropped, never queued: ABRP plans from the car's state now, and a
|
||||
// backlog replayed later would describe a car that has moved on. The stored copy in
|
||||
// InfluxDB is unaffected either way.
|
||||
type Forwarder struct {
|
||||
APIKey string
|
||||
UserToken string
|
||||
MinInterval time.Duration
|
||||
Endpoint string
|
||||
Client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
lastUTC int64
|
||||
lastSentAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
abrpEndpoint = "https://api.iternio.com/1/tlm/send"
|
||||
// Older than this, a sample is history rather than live state, and ABRP gets nothing.
|
||||
abrpMaxAge = 5 * time.Minute
|
||||
)
|
||||
|
||||
func (f *Forwarder) configured() bool {
|
||||
return f != nil && f.APIKey != "" && f.UserToken != ""
|
||||
}
|
||||
|
||||
// pick chooses the sample to forward, or false when there is nothing new or it is too
|
||||
// soon. It records the choice, so two batches arriving together cannot both forward.
|
||||
func (f *Forwarder) pick(samples []TelemetrySample, now time.Time) (TelemetrySample, bool) {
|
||||
if len(samples) == 0 {
|
||||
return TelemetrySample{}, false
|
||||
}
|
||||
newest := samples[0]
|
||||
for _, s := range samples[1:] {
|
||||
if s.UTC > newest.UTC {
|
||||
newest = s
|
||||
}
|
||||
}
|
||||
if now.Sub(time.Unix(newest.UTC, 0)) > abrpMaxAge {
|
||||
return TelemetrySample{}, false
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.lastUTC != 0 && newest.UTC <= f.lastUTC {
|
||||
return TelemetrySample{}, false
|
||||
}
|
||||
if !f.lastSentAt.IsZero() && now.Sub(f.lastSentAt) < f.MinInterval {
|
||||
return TelemetrySample{}, false
|
||||
}
|
||||
f.lastUTC = newest.UTC
|
||||
f.lastSentAt = now
|
||||
return newest, true
|
||||
}
|
||||
|
||||
// Forward is fire-and-forget: the car's request is answered without waiting for ABRP.
|
||||
func (f *Forwarder) Forward(samples []TelemetrySample) {
|
||||
if !f.configured() {
|
||||
return
|
||||
}
|
||||
sample, ok := f.pick(samples, time.Now())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := f.send(context.Background(), sample); err != nil {
|
||||
slog.Warn("ABRP forward failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// tlm is the object ABRP expects: the same field names, flags as 0/1.
|
||||
func tlm(sample TelemetrySample) map[string]any {
|
||||
out := map[string]any{"utc": sample.UTC}
|
||||
for field, value := range sample.Numbers {
|
||||
out[field] = value
|
||||
}
|
||||
for field, value := range sample.Flags {
|
||||
if value {
|
||||
out[field] = 1
|
||||
} else {
|
||||
out[field] = 0
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *Forwarder) send(ctx context.Context, sample TelemetrySample) error {
|
||||
payload, err := json.Marshal(tlm(sample))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Credentials go in the form body, never the URL, which ends up in access logs.
|
||||
form := url.Values{"api_key": {f.APIKey}, "token": {f.UserToken}, "tlm": {string(payload)}}
|
||||
endpoint := f.Endpoint
|
||||
if endpoint == "" {
|
||||
endpoint = abrpEndpoint
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
client := f.Client
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// A url.Error from the client quotes the URL, which holds no credentials.
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The response body can echo the request; only the status is reported.
|
||||
return fmt.Errorf("rejected: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user