Server 2.4.0, app 2.11.1: ABRP forwarding removed from the server
The car sends live data to ABRP itself, and the car and the server go over the same mobile connection: when the car cannot reach ABRP, it cannot reach the server either, so the server's fallback bought nothing. The forwarder, its ABRP_* settings and abrpSentUtc are gone; the server only stores. The app stops sending abrpSentUtc (the server ignores unknown fields, so 2.11.0 keeps working) and its texts no longer promise a server fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
# Nils' MG4
|
||||
|
||||
An Android Automotive app for the **MG4 (SAIC eh32)** head unit, plus a small server: the car
|
||||
sends its telemetry to my own server at car.k8s.stinnesbeck.com, which stores it in InfluxDB 2,
|
||||
forwards live data to [A Better Route Planner](https://abetterrouteplanner.com) and feeds the
|
||||
Grafana dashboards.
|
||||
sends its telemetry to my own server at car.k8s.stinnesbeck.com, which stores it in InfluxDB 2
|
||||
for the Grafana dashboards, and its live data straight to
|
||||
[A Better Route Planner](https://abetterrouteplanner.com).
|
||||
|
||||
No OBD dongle and no phone in the loop: the app runs on the head unit and reads the car's own
|
||||
APIs.
|
||||
@@ -47,18 +47,16 @@ vehicle there, so every value shows as a dash).
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP (what the car did not send)
|
||||
car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
|
||||
│ ──daily chunks─────▶ │
|
||||
│ ──charging sessions▶ ▼
|
||||
│ InfluxDB 2 ◀── Grafana
|
||||
└──live telemetry (optional, direct)──────────────────▶ ABRP
|
||||
└──live telemetry─────▶ ABRP
|
||||
```
|
||||
|
||||
With ABRP credentials set in the app (Live data tab), the car sends live data to ABRP itself,
|
||||
so ABRP keeps getting it while the server is down; the server then forwards only samples the
|
||||
car could not deliver. Without them in the app, the server forwards everything with its own
|
||||
ABRP credentials. The car authenticates with the server using a token of its own, obtained by
|
||||
pairing with a phone (below).
|
||||
The car sends live data to ABRP itself, with the ABRP credentials set on the Live data tab, so
|
||||
ABRP and the server do not depend on each other. The server only stores. The car authenticates
|
||||
with the server using a token of its own, obtained by pairing with a phone (below).
|
||||
|
||||
## The app
|
||||
|
||||
@@ -118,8 +116,7 @@ pointed at this repository's releases (see `SPECS.md`).
|
||||
3. Tap **Pair with phone**. Scan the QR code with a phone (or open the address shown and enter
|
||||
the code), and approve with the server's approval password. The car receives its token.
|
||||
4. On the **Live data** tab, turn live data on. The service starts with the car from then on.
|
||||
5. Optional: enter your ABRP API key and user token on the same tab, so the car also sends
|
||||
live data straight to ABRP.
|
||||
5. Enter your ABRP API key and user token on the same tab, so the car sends live data to ABRP.
|
||||
|
||||
### Config file
|
||||
|
||||
@@ -141,8 +138,8 @@ installation_id = only to carry over history from an install older than 2.9.0
|
||||
interval_sec = 60 # 15 / 30 / 60 / 120 / 300
|
||||
boost_low_soc = true
|
||||
low_soc_percent = 20
|
||||
abrp_api_key = optional: your ABRP API key, to send to ABRP directly
|
||||
abrp_token = optional: your ABRP user token
|
||||
abrp_api_key = your ABRP API key
|
||||
abrp_token = your ABRP user token
|
||||
```
|
||||
|
||||
After a successful import the app deletes the file, since it holds the token; credentials live
|
||||
@@ -182,7 +179,7 @@ work for the plain Gradle builds.
|
||||
## Server and dashboards
|
||||
|
||||
- **Server** ([`server/`](server/README.md)): Go, API only. Live telemetry, daily consumption
|
||||
chunks, charging sessions, pairing; forwards live data to ABRP. Image at
|
||||
chunks, charging sessions, pairing. Image at
|
||||
`git.stinnesbeck.com/nils/mg4-server`.
|
||||
- **Kubernetes** ([`k8s/`](k8s/README.md)): plain manifests for the server, InfluxDB 2 and
|
||||
Grafana, applied by hand with `kubectl apply -k k8s/`. Secrets go in the gitignored
|
||||
|
||||
@@ -9,15 +9,16 @@ what has been changed, and what is still to do.
|
||||
|
||||
## Architecture: car server at car.k8s.stinnesbeck.com
|
||||
|
||||
The car sends everything to our own server. The server stores it in InfluxDB 2 and forwards live
|
||||
telemetry to ABRP, so the ABRP token lives only on the server, never in the APK. Grafana reads
|
||||
InfluxDB directly for the dashboards.
|
||||
The car sends everything to our own server, which stores it in InfluxDB 2, and its live data
|
||||
straight to ABRP with the ABRP credentials kept (encrypted) in the app. The server does not talk
|
||||
to ABRP. Grafana reads InfluxDB directly for the dashboards.
|
||||
|
||||
```
|
||||
car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) ──▶ ABRP api.iternio.com/1/tlm/send
|
||||
──daily chunks─────▶ │
|
||||
──charging sessions▶ ▼
|
||||
InfluxDB 2 ◀── Grafana (car-grafana.k8s.stinnesbeck.com)
|
||||
car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
|
||||
│ ──daily chunks─────▶ │
|
||||
│ ──charging sessions▶ ▼
|
||||
│ InfluxDB 2 ◀── Grafana (car-grafana.k8s.stinnesbeck.com)
|
||||
└──live telemetry─────▶ ABRP api.iternio.com/1/tlm/send
|
||||
```
|
||||
|
||||
- **Server:** `server/`, Go 1.26, API only — see `server/README.md` for endpoints, configuration
|
||||
@@ -112,6 +113,9 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
||||
INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
|
||||
the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
|
||||
`kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
|
||||
- 2026-10-18 — App 2.11.1, server 2.4.0: ABRP forwarding removed from the server. The car and the
|
||||
server use the same mobile connection, so a server fallback for ABRP bought nothing. The car no
|
||||
longer sends `abrpSentUtc`; the server ignores unknown fields, so older apps keep working.
|
||||
- 2026-10-18 — App 2.11.0, server 2.3.0:
|
||||
- Live data goes to ABRP straight from the car when ABRP credentials are set in the app
|
||||
(Live data tab or config file: `abrp_api_key`, `abrp_token`), so ABRP keeps getting it
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
||||
applicationId "com.stinnesbeck.nilsmg4"
|
||||
minSdk 27
|
||||
targetSdk 36
|
||||
versionCode 73
|
||||
versionName "2.11.0"
|
||||
versionCode 74
|
||||
versionName "2.11.1"
|
||||
|
||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||
|
||||
@@ -8,9 +8,8 @@ import android.content.SharedPreferences;
|
||||
* sending) and the user token (which car in which ABRP account). Kept in {@link SecurePrefs}
|
||||
* next to the server settings, entered on the Live data tab or imported from a config file.
|
||||
*
|
||||
* With both set, the car sends to ABRP itself, so ABRP keeps getting live data when the car
|
||||
* server is down; the server then only forwards what the car could not deliver. Without them,
|
||||
* the server forwards as before.
|
||||
* The car is the only one that sends to ABRP: the server stores live data but does not forward
|
||||
* it. Without these, ABRP gets no live data.
|
||||
*/
|
||||
final class AbrpConfig {
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ import java.util.concurrent.TimeUnit;
|
||||
/**
|
||||
* Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally,
|
||||
* syncs it to the car server in 2-minute delta chunks, and sends live telemetry samples that
|
||||
* the server stores and forwards to ABRP ({@link TelemetryUploader}). The car itself never
|
||||
* talks to ABRP and holds no ABRP credentials.
|
||||
* the server stores and, with ABRP credentials set, that go straight to ABRP
|
||||
* ({@link TelemetryUploader}).
|
||||
*
|
||||
* Always-on by design: started at boot (BootReceiver) and on app open, never gated on
|
||||
* credentials or a toggle. The only thing it needs is a network, and a missing one is
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Live telemetry to the car server, which stores it and forwards the newest sample to ABRP.
|
||||
* Live telemetry to the car server, which stores it, and the newest sample straight to ABRP.
|
||||
*
|
||||
* Called on every service tick. While the car is in use every tick becomes a sample; the
|
||||
* queue is sent when {@link UploadCadence} says so — every tick on a state change, otherwise
|
||||
@@ -193,8 +193,6 @@ final class TelemetryUploader {
|
||||
JSONObject body = new JSONObject()
|
||||
.put("installationId", config.installationId)
|
||||
.put("samples", samples);
|
||||
// What the car already delivered to ABRP itself; the server forwards only newer samples.
|
||||
if (lastAbrpUtc > 0) body.put("abrpSentUtc", lastAbrpUtc);
|
||||
return ServerHttp.request("POST", config.url + "/v1/telemetry", config.token, body);
|
||||
}
|
||||
|
||||
@@ -209,7 +207,7 @@ final class TelemetryUploader {
|
||||
/**
|
||||
* Sends the newest queued sample straight to ABRP when ABRP credentials are set: once per
|
||||
* upload, only if it is newer than the last one sent and still live. It stays queued for
|
||||
* the server either way.
|
||||
* the server either way; the server does not forward to ABRP.
|
||||
*/
|
||||
private void sendNewestToAbrp() {
|
||||
AbrpConfig abrp = AbrpConfig.get(context);
|
||||
|
||||
@@ -122,12 +122,12 @@
|
||||
<string name="hint_steering_keys">The last key reports the car sent, newest first. If double-pressing the voice key does not open the app, a photo of this list shows how the car reports it.</string>
|
||||
<string name="steering_keys_empty">No key reports received yet. Press a steering-wheel key.</string>
|
||||
<string name="label_abrp_upload_enabled">Send live data</string>
|
||||
<string name="hint_abrp_upload_enabled">To your server, which stores it, and to ABRP: straight from the car when the ABRP credentials below are set, otherwise forwarded by the server. Consumption and charging tracking keep running when this is off.</string>
|
||||
<string name="hint_abrp_direct">With your ABRP API key and user token here, the car sends live data to ABRP itself, so ABRP keeps getting it while your server is down. The server then forwards only what the car could not send. Leave both empty to let the server forward everything.</string>
|
||||
<string name="hint_abrp_upload_enabled">To your server, which stores it, and straight to ABRP when the ABRP credentials below are set. Consumption and charging tracking keep running when this is off.</string>
|
||||
<string name="hint_abrp_direct">The car sends live data to ABRP itself with your ABRP API key and user token. Without them, ABRP gets no live data; your server only stores it.</string>
|
||||
<string name="hint_abrp_api_key">ABRP API key</string>
|
||||
<string name="hint_abrp_token">ABRP user token</string>
|
||||
<string name="abrp_saved">Saved: the car sends live data to ABRP directly</string>
|
||||
<string name="abrp_cleared">Cleared: the server forwards live data to ABRP</string>
|
||||
<string name="abrp_cleared">Cleared: ABRP gets no live data</string>
|
||||
<string name="abrp_invalid">Enter both, without spaces, or leave both empty</string>
|
||||
<string name="abrp_testing">Checking with ABRP…</string>
|
||||
<string name="abrp_test_ok">ABRP accepted these credentials</string>
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ is applied automatically. Apply them yourself.
|
||||
covered by Traefik's default certificate.
|
||||
2. **Create the Secrets.** `cp k8s/secrets/secrets.example.yaml k8s/secrets/secrets.yaml` (gitignored), replace
|
||||
every `CHANGE-ME` there (`openssl rand -hex 32`): `influxdb-auth`, `mg4-server` (ingest token,
|
||||
approval password for pairing, your ABRP API key and user token), `grafana-admin` and
|
||||
approval password for pairing), `grafana-admin` and
|
||||
`grafana-influx` (see below). Keep the template itself on placeholders only.
|
||||
3. **Registry access.** The `mg4-server` package on git.stinnesbeck.com is public, so no pull
|
||||
secret is needed. If you make it private, add an `imagePullSecrets` entry to `server.yaml`.
|
||||
|
||||
@@ -26,11 +26,6 @@ stringData:
|
||||
# Approves a car on your phone when it pairs by QR code (at least 12 characters). Leave it
|
||||
# empty to switch pairing off; then only the INGEST_TOKEN above works.
|
||||
APPROVAL_PASSWORD: CHANGE-ME-at-least-12-characters
|
||||
# Your ABRP credentials. Leave either empty to switch forwarding off. Keep them set even
|
||||
# when the app sends to ABRP itself: the server then forwards only what the car could not.
|
||||
ABRP_API_KEY: CHANGE-ME
|
||||
ABRP_USER_TOKEN: CHANGE-ME
|
||||
ABRP_MIN_INTERVAL_SECONDS: "5"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
||||
+5
-6
@@ -1,5 +1,5 @@
|
||||
# The car server (Go). One replica on purpose: the ABRP rate limit is kept in memory, and
|
||||
# two replicas would each forward to ABRP at the full rate.
|
||||
# The car server (Go). One replica on purpose: pending pairing requests and the wrong-password
|
||||
# counters are kept in memory.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@@ -29,8 +29,8 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: server
|
||||
# 2.3.0, pinned by digest so a re-pushed tag cannot change what runs.
|
||||
image: git.stinnesbeck.com/nils/mg4-server@sha256:dc5eb49cd88a1fd6a8e853c6e2291f2b5d62252e8bb6daf1750e1c3698bc7e0a
|
||||
# 2.4.0, pinned by digest so a re-pushed tag cannot change what runs.
|
||||
image: git.stinnesbeck.com/nils/mg4-server@sha256:ab243f37812efa7294efc594f65ca65f9d551161a85e7cc641afb168e226a2fe
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
@@ -49,8 +49,7 @@ spec:
|
||||
secretKeyRef:
|
||||
name: influxdb-auth
|
||||
key: admin-token
|
||||
# INGEST_TOKEN, APPROVAL_PASSWORD, ABRP_API_KEY, ABRP_USER_TOKEN,
|
||||
# ABRP_MIN_INTERVAL_SECONDS.
|
||||
# INGEST_TOKEN, APPROVAL_PASSWORD.
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: mg4-server
|
||||
|
||||
+5
-12
@@ -1,11 +1,11 @@
|
||||
# Nils' MG4 — car server
|
||||
|
||||
A small Go service at car.k8s.stinnesbeck.com: the API the car writes to, and the ABRP
|
||||
forwarder. Data lives in InfluxDB 2; Grafana reads it from there for the dashboards
|
||||
(`../k8s/grafana/`).
|
||||
A small Go service at car.k8s.stinnesbeck.com: the API the car writes to. Data lives in
|
||||
InfluxDB 2; Grafana reads it from there for the dashboards (`../k8s/grafana/`). The car sends
|
||||
its live data to ABRP itself; the server does not talk to ABRP (removed in 2.4.0).
|
||||
|
||||
```
|
||||
car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send
|
||||
car ──live telemetry───▶ server
|
||||
──daily chunks─────▶ │
|
||||
──charging sessions▶ ▼
|
||||
InfluxDB 2 ◀── Grafana
|
||||
@@ -20,14 +20,10 @@ Environment only; the server refuses to start when a required value is missing.
|
||||
| `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG` | required; the token needs read/write on the bucket |
|
||||
| `INFLUX_BUCKET` | default `mg4` |
|
||||
| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` |
|
||||
| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward |
|
||||
| `ABRP_MIN_INTERVAL_SECONDS` | default 5 |
|
||||
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
|
||||
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
|
||||
| `LISTEN_ADDR` | default `:8080` |
|
||||
|
||||
The ABRP credentials never reach the car.
|
||||
|
||||
## Endpoints
|
||||
|
||||
The ingest endpoints accept `Authorization: Bearer <token>` with either the shared `INGEST_TOKEN`
|
||||
@@ -39,10 +35,7 @@ with (403 otherwise); the shared token works for any.
|
||||
dropped, known ones bounded: a value out of range is dropped on its own, and a sample without
|
||||
a plausible time (before 2001, or more than an hour ahead) is dropped whole. The rest is stored
|
||||
and the answer is `200 {stored, droppedCount, dropped}`, even when nothing was usable, since
|
||||
the car would only send it again. The newest sample is forwarded to ABRP if it is under
|
||||
5 minutes old and not ahead of the server's clock. A car that sends to ABRP itself adds
|
||||
`abrpSentUtc`, the newest sample ABRP took from it; only samples newer than that are
|
||||
forwarded, so ABRP gets each one once.
|
||||
the car would only send it again.
|
||||
- `PUT /v1/consumption/daily` — one delta chunk of a day's consumption.
|
||||
- `PUT /v1/charging-sessions/{sessionId}` — a finished DC session with its curve.
|
||||
- `GET /v1/consumption/history/{installationId}` — the car reads its own totals back.
|
||||
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
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
|
||||
}
|
||||
+3
-21
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
// API serves the car. Every endpoint but /v1/health needs the ingest token.
|
||||
//
|
||||
// POST /v1/telemetry live samples; stored, newest forwarded to ABRP
|
||||
// POST /v1/telemetry live samples, stored
|
||||
// PUT /v1/consumption/daily one delta chunk of a day's consumption
|
||||
// PUT /v1/charging-sessions/{sessionId} one finished DC charging session
|
||||
// GET /v1/consumption/history/{installationId} the car reads its own totals back
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
type API struct {
|
||||
Store Store
|
||||
IngestToken string
|
||||
Forwarder *Forwarder
|
||||
Pairing *Pairing
|
||||
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
|
||||
// derived from the request when empty.
|
||||
@@ -117,27 +116,11 @@ func (a *API) ingest(h handler) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// notYetAtAbrp leaves out the samples the car already sent to ABRP itself.
|
||||
func notYetAtAbrp(samples []TelemetrySample, sentUTC int64) []TelemetrySample {
|
||||
if sentUTC == 0 {
|
||||
return samples
|
||||
}
|
||||
out := make([]TelemetrySample, 0, len(samples))
|
||||
for _, s := range samples {
|
||||
if s.UTC > sentUTC {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// maxDroppedListed caps the dropped values named in a telemetry response and log line.
|
||||
const maxDroppedListed = 20
|
||||
|
||||
// telemetry stores the batch first; only once the write succeeded is the newest sample
|
||||
// handed to ABRP, so the car's retry of a failed request cannot leave ABRP with data the
|
||||
// database never got. The answer says how many samples were stored and what was dropped;
|
||||
// a batch with nothing usable is still a 200, since sending it again would not help.
|
||||
// telemetry stores the batch. The answer says how many samples were stored and what was
|
||||
// dropped; a batch with nothing usable is still a 200, since sending it again would not help.
|
||||
func (a *API) telemetry(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
|
||||
batch, err := validateTelemetryBatch(body, time.Now())
|
||||
if err != nil {
|
||||
@@ -150,7 +133,6 @@ func (a *API) telemetry(w http.ResponseWriter, r *http.Request, c caller, body [
|
||||
if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
|
||||
return err
|
||||
}
|
||||
a.Forwarder.Forward(notYetAtAbrp(batch.Samples, batch.AbrpSentUTC))
|
||||
}
|
||||
dropped := batch.Dropped
|
||||
if len(dropped) > maxDroppedListed {
|
||||
|
||||
+1
-21
@@ -76,7 +76,7 @@ const (
|
||||
|
||||
func newTestAPI() (*API, *fakeStore) {
|
||||
store := &fakeStore{}
|
||||
return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{},
|
||||
return &API{Store: store, IngestToken: testToken,
|
||||
Pairing: NewPairing(testApprovalPassword, nil), PublicURL: "https://car.example.com"}, store
|
||||
}
|
||||
|
||||
@@ -204,23 +204,3 @@ func jsonInt(v int64) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestSamplesTheCarSentToAbrpAreNotForwardedAgain(t *testing.T) {
|
||||
samples := []TelemetrySample{{UTC: 100}, {UTC: 115}, {UTC: 130}}
|
||||
if got := notYetAtAbrp(samples, 0); len(got) != 3 {
|
||||
t.Fatalf("without abrpSentUtc everything is forwardable: %d", len(got))
|
||||
}
|
||||
if got := notYetAtAbrp(samples, 130); len(got) != 0 {
|
||||
t.Fatalf("the car sent the newest: %d left", len(got))
|
||||
}
|
||||
if got := notYetAtAbrp(samples, 115); len(got) != 1 || got[0].UTC != 130 {
|
||||
t.Fatalf("only the newer sample: %+v", got)
|
||||
}
|
||||
b, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":1790000000,"samples":[{"utc":1790000000}]}`), testNow)
|
||||
if err != nil || b.AbrpSentUTC != 1790000000 {
|
||||
t.Fatalf("abrpSentUtc not read: %v %d", err, b.AbrpSentUTC)
|
||||
}
|
||||
if _, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":"x","samples":[{"utc":1790000000}]}`), testNow); !isValidationError(err) {
|
||||
t.Fatal("a malformed abrpSentUtc was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-67
@@ -1,10 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -49,68 +45,6 @@ func TestAggregateDaysNewestSohWins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTlmSendsFlagsAsZeroOrOne(t *testing.T) {
|
||||
got := tlm(TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 50},
|
||||
Flags: map[string]bool{"is_charging": true, "is_parked": false}})
|
||||
if got["utc"] != int64(testNowS) || got["soc"] != 50.0 || got["is_charging"] != 1 || got["is_parked"] != 0 {
|
||||
t.Fatalf("tlm %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwarderPicksOnlyTheNewestFreshSampleOncePerInterval(t *testing.T) {
|
||||
f := &Forwarder{MinInterval: 5 * time.Second}
|
||||
now := time.Unix(testNowS, 0)
|
||||
got, ok := f.pick([]TelemetrySample{{UTC: testNowS - 10}, {UTC: testNowS - 2}, {UTC: testNowS - 5}}, now)
|
||||
if !ok || got.UTC != testNowS-2 {
|
||||
t.Fatalf("picked %+v %v", got, ok)
|
||||
}
|
||||
if _, ok := f.pick([]TelemetrySample{{UTC: testNowS}}, now.Add(time.Second)); ok {
|
||||
t.Fatal("forwarded again within the interval")
|
||||
}
|
||||
if _, ok := f.pick([]TelemetrySample{{UTC: testNowS - 3}}, now.Add(time.Minute)); ok {
|
||||
t.Fatal("forwarded an older sample than the last one")
|
||||
}
|
||||
// A sample from ten minutes ago describes a car that has moved on.
|
||||
fresh := &Forwarder{MinInterval: 5 * time.Second}
|
||||
if _, ok := fresh.pick([]TelemetrySample{{UTC: testNowS - 600}}, now); ok {
|
||||
t.Fatal("forwarded a stale sample")
|
||||
}
|
||||
// A sample from a clock a day ahead is skipped, and does not block the real ones after it.
|
||||
ahead := &Forwarder{MinInterval: 5 * time.Second}
|
||||
got, ok = ahead.pick([]TelemetrySample{{UTC: testNowS + 86400}, {UTC: testNowS - 1}}, now)
|
||||
if !ok || got.UTC != testNowS-1 {
|
||||
t.Fatalf("picked %+v %v", got, ok)
|
||||
}
|
||||
if _, ok := ahead.pick([]TelemetrySample{{UTC: testNowS + 86400}}, now.Add(time.Minute)); ok {
|
||||
t.Fatal("forwarded a sample from the future")
|
||||
}
|
||||
if _, ok := ahead.pick([]TelemetrySample{{UTC: testNowS + 60}}, now.Add(time.Minute)); !ok {
|
||||
t.Fatal("a real sample after a future one was not forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwarderPutsCredentialsInTheBodyNotTheURL(t *testing.T) {
|
||||
received := make(chan *http.Request, 1)
|
||||
var form url.Values
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
form, _ = url.ParseQuery(string(body))
|
||||
received <- r
|
||||
}))
|
||||
defer srv.Close()
|
||||
f := &Forwarder{APIKey: "key-123", UserToken: "token-456", Endpoint: srv.URL + "/1/tlm/send"}
|
||||
if err := f.send(t.Context(), TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 80}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := <-received
|
||||
if r.URL.RawQuery != "" {
|
||||
t.Fatalf("credentials leaked into the URL: %s", r.URL.RawQuery)
|
||||
}
|
||||
if form.Get("api_key") != "key-123" || form.Get("token") != "token-456" || form.Get("tlm") != `{"soc":80,"utc":1790000000}` {
|
||||
t.Fatalf("form %v", form)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFailsClosed(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"INFLUX_URL": "http://influxdb:8086", "INFLUX_TOKEN": "t", "INFLUX_ORG": "mg4",
|
||||
@@ -118,7 +52,7 @@ func TestLoadConfigFailsClosed(t *testing.T) {
|
||||
}
|
||||
get := func(k string) string { return env[k] }
|
||||
c, err := loadConfig(get)
|
||||
if err != nil || c.listenAddr != ":8080" || c.influxBucket != "mg4" || c.abrpMinInterval != 5*time.Second {
|
||||
if err != nil || c.listenAddr != ":8080" || c.influxBucket != "mg4" {
|
||||
t.Fatalf("defaults: %+v %v", c, err)
|
||||
}
|
||||
env["INGEST_TOKEN"] = "short"
|
||||
|
||||
+4
-24
@@ -1,5 +1,6 @@
|
||||
// Command server is the Nils' MG4 car server: the API the car writes its telemetry,
|
||||
// consumption and charging sessions to, stored in InfluxDB 2 and forwarded live to ABRP.
|
||||
// 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
|
||||
|
||||
@@ -11,7 +12,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
@@ -28,9 +28,6 @@ type config struct {
|
||||
influxOrg string
|
||||
influxBucket string
|
||||
ingestToken string
|
||||
abrpAPIKey string
|
||||
abrpUserToken string
|
||||
abrpMinInterval time.Duration
|
||||
approvalPass string
|
||||
publicURL string
|
||||
}
|
||||
@@ -45,8 +42,6 @@ func loadConfig(getenv func(string) string) (config, error) {
|
||||
influxOrg: getenv("INFLUX_ORG"),
|
||||
influxBucket: getenv("INFLUX_BUCKET"),
|
||||
ingestToken: getenv("INGEST_TOKEN"),
|
||||
abrpAPIKey: getenv("ABRP_API_KEY"),
|
||||
abrpUserToken: getenv("ABRP_USER_TOKEN"),
|
||||
approvalPass: getenv("APPROVAL_PASSWORD"),
|
||||
publicURL: getenv("PUBLIC_URL"),
|
||||
}
|
||||
@@ -69,14 +64,6 @@ func loadConfig(getenv func(string) string) (config, error) {
|
||||
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
|
||||
return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength)
|
||||
}
|
||||
c.abrpMinInterval = 5 * time.Second
|
||||
if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" {
|
||||
seconds, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || seconds < 0 {
|
||||
return config{}, errors.New("ABRP_MIN_INTERVAL_SECONDS must be a non-negative number")
|
||||
}
|
||||
c.abrpMinInterval = time.Duration(seconds * float64(time.Second))
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -90,12 +77,6 @@ func main() {
|
||||
|
||||
store := NewInfluxStore(cfg.influxURL, cfg.influxToken, cfg.influxOrg, cfg.influxBucket)
|
||||
defer store.Close()
|
||||
forwarder := &Forwarder{
|
||||
APIKey: cfg.abrpAPIKey,
|
||||
UserToken: cfg.abrpUserToken,
|
||||
MinInterval: cfg.abrpMinInterval,
|
||||
Client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
// 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)
|
||||
@@ -106,8 +87,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
pairing := NewPairing(cfg.approvalPass, devices)
|
||||
api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder,
|
||||
Pairing: pairing, PublicURL: cfg.publicURL}
|
||||
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, PublicURL: cfg.publicURL}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.listenAddr,
|
||||
@@ -122,7 +102,7 @@ func main() {
|
||||
defer stop()
|
||||
go func() {
|
||||
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket,
|
||||
"abrp", forwarder.configured(), "pairing", pairing.Enabled(), "pairedDevices", len(devices))
|
||||
"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)
|
||||
|
||||
@@ -107,7 +107,4 @@ type TelemetryBatch struct {
|
||||
Samples []TelemetrySample
|
||||
// Dropped names the values and samples validation left out, e.g. "samples[3].speed".
|
||||
Dropped []string
|
||||
// AbrpSentUTC is the newest sample the car delivered to ABRP itself (0: none). The
|
||||
// server forwards only samples newer than that, so ABRP gets each one once.
|
||||
AbrpSentUTC int64
|
||||
}
|
||||
|
||||
@@ -273,11 +273,6 @@ func validateTelemetryBatch(body []byte, now time.Time) (TelemetryBatch, error)
|
||||
return TelemetryBatch{}, invalid("samples is invalid")
|
||||
}
|
||||
batch := TelemetryBatch{InstallationID: id, Samples: make([]TelemetrySample, 0, len(raw))}
|
||||
if o.has("abrpSentUtc") {
|
||||
if batch.AbrpSentUTC, err = integer(o["abrpSentUtc"], "abrpSentUtc"); err != nil {
|
||||
return TelemetryBatch{}, err
|
||||
}
|
||||
}
|
||||
latest := now.Add(maxClockAhead).Unix()
|
||||
for i, entry := range raw {
|
||||
name := fmt.Sprintf("samples[%d]", i)
|
||||
|
||||
Reference in New Issue
Block a user