Telemetry no longer loses queued batches. The server drops a bad value, or a sample without a plausible time, instead of rejecting the batch, and answers 200 with what it dropped. The app sends only values inside the server's ranges (speed as a magnitude, so reversing is fine) and no samples before the head unit's clock is set. Server: - Pairing hands out and accepts a token only after the device is saved; a failed save leaves the request pending. - A paired token serves only the installation id it was paired with (403 otherwise); the shared INGEST_TOKEN still serves any. - Wrong-password lockouts and open pairing requests are limited per client address (last X-Forwarded-For entry), so a stranger cannot lock the owner out; a car starting again replaces its own request. - ABRP forwarding skips samples stamped ahead of the server's clock, which used to stop it until a restart. App: - Settings written to plaintext during a keystore failure move back into encrypted storage; the stored installation id wins. - The installation id is derived from ANDROID_ID on first start, so a reinstall finds its history; a stored or imported id still wins. - The scanned config file is deleted after import (it holds the token). - GPS stops while live telemetry is off or the server unconfigured, and pauses while parked and idle, keeping the fix the car parked with. - Range estimates use the monotonic clock. - One HTTP helper (ServerHttp) for telemetry, sync and pairing. - Last Turkish strings translated; mise logs shows the NilsMG4 tags. Grafana reads InfluxDB with its own read-only token (new grafana-influx Secret, created by hand as described in k8s/README.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
143 lines
4.1 KiB
Go
143 lines
4.1 KiB
Go
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
|
|
// Newer than now by more than this, a sample's clock is wrong, and ABRP gets nothing.
|
|
abrpMaxAhead = 30 * time.Second
|
|
)
|
|
|
|
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) {
|
|
// A sample stamped ahead of now has a wrong clock. Taking it would also set lastUTC into
|
|
// the future, and every real sample after it would look older and never be sent.
|
|
latest := now.Add(abrpMaxAhead).Unix()
|
|
var newest TelemetrySample
|
|
found := false
|
|
for _, s := range samples {
|
|
if s.UTC <= latest && (!found || s.UTC > newest.UTC) {
|
|
newest, found = s, true
|
|
}
|
|
}
|
|
if !found || 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
|
|
}
|