AGENTS.md: an overview for agents; car-emulator in the flake

AGENTS.md still described upstream's EVABRPUploader (GitHub updates,
the old architecture and tabs). It is now the entry point for agents:
what the project is (app, Go server, k8s, EVHardware), the rules
(nothing touches the cluster, secrets, read-only telemetry, null is not
zero, fail closed, honesty), the facts confirmed on the car, the app's
structure and UI conventions, how to build, test, publish and install,
and which document holds what. SPECS.md stays the journal.

The Android 9 test emulator, so far a file under the ignored build/,
is now reproducible: nix run .#car-emulator creates an AVD at
1920x720/160 dpi with the AOSP image that shares the car's platform key
and starts it on port 5560. Checked from scratch: the car build
installs as the system user.

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-18 21:12:41 +02:00
co-authored by Claude Opus 5.5
parent 69d284f81b
commit 253c768781
3 changed files with 164 additions and 102 deletions
+107 -102
View File
@@ -1,125 +1,130 @@
# AGENTS.md — EVABRPUploader # AGENTS.md — Nils' MG4
Context for AI agents and new contributors working in this repository. Start here. What this repository is, how its parts fit, how to build, test and ship, and the
rules for changing it. Details live in the documents linked below; `SPECS.md` is the journal
EVABRPUploader is part of **EVSuite**. The workspace `AGENTS.md` and normative (status, to-dos, a dated log of every change) and is updated with each change.
workspace `DESIGN.md` apply; this file contains only telemetry-specific additions.
## App-specific visual direction (2.2.6)
The user explicitly requested a permanent OLED-black theme, charcoal glass-like cards,
an accent the user picks from the MG4 paint colours (Dynamic Red #E0424C by default; the palette
is theme attributes in `res/values/attrs_accent.xml` and `accents.xml` — use `?attr/evAccent…`,
never a hard-coded accent hex). These app resources intentionally override the shared
DESIGN.md palette, DayNight preference and flat-surface rules. Do not overwrite them
with the shared token generator. Use static gradients rather than continuous effects;
keep large touch targets, readable text and null telemetry shown as a dash.
Wide layouts start at 1000dp; narrower windows use a bottom navigation bar.
## What this is ## What this is
An Android Automotive app for the **MG4 (SAIC eh32)** that reads vehicle telemetry and An app for the **MG4 (SAIC eh32)** head unit plus the backend it talks to:
pushes it to A Better Route Planner. It runs on the car's head unit (AAOS 9, MT2712).
Fork of Leon Kernan's `ABRP_Uploader` (MIT, like every fork after it). See `LICENSE.md`: the
repository is MIT except `EVHardware/`, which is PolyForm Noncommercial — so the built APK may
only be used and shared non-commercially. Keep every copyright line in `LICENSE`.
## Non-negotiables
1. **Telemetry is read-only.** No vehicle settings may be written. Window automation
was removed in 2.2.5 at the user's request, including its service and door listener.
2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null`
means "could not read". Null fields are omitted from the payload. Sending 0 for SOC
tells ABRP the battery is empty and wrecks a live route plan.
3. **Fail closed.** Update origin, APK signature, unknown vehicle state — refuse rather
than assume.
4. **Never let a credential reach a URL, a log line, or a crash report.**
5. **AAOS 9 must stay stable.** A third-party app crashing the head unit is unacceptable.
Every car call goes through reflection with try/catch; keep it that way.
6. **Power matters.** The head unit is awake whenever the car is on. No wake locks, no
alarms. The GPS subscription and the upload tick both cost power — keep them in step.
## Architecture
``` ```
AbrpUploadService foreground service; scheduler thread does all IPC and HTTP MG4 head unit (Android 9, AAOS, MT2712)
CarPropertyAdapter reflection bridge to android.car (not on the compile classpath) app/ "Nils' MG4" ──live data, consumption, charging──▶ server/ (Go) ──▶ InfluxDB 2 ◀── Grafana
TelemetryPayload pure JSON building — no Android types, unit-tested │ │ relays app releases from the forge
UploadCadence when a tick actually uploads — pure, unit-tested └──live data, straight, with the driver's own key──▶ ABRP (A Better Route Planner)
UploadSettings user cadence configuration
UploadLog last 20 attempts + derived service state — pure, unit-tested
SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext
AbrpApi HTTP; credentials in the POST body, never the query string
MainActivity configuration, service state, upload log
``` ```
**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM; - **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the
anything inside the service effectively cannot be tested without a car. `EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the
server and to ABRP, and updates itself.
- **server/** — Go 1.26 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`.
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
dashboards, Traefik routes). See `k8s/README.md`.
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
## Source sets Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), work branch
`english-translation`. Upstream fork: `fatih` → github.com/fatihdonmezdev/FatihsMG4.
| Set | Contents | Ships in | ## Rules
|---|---|---|
| `main` | everything shared | all builds |
| `stable` | `UpdateHook` no-op | stable channel |
| `unstable` | active GitHub pre-release updater and PackageInstaller result receiver | unstable channel |
| `debug` | `VhalProbe` — enumerates the whole VHAL | debug builds only |
| `testUnstable` | OTA policy tests | test only |
The stable channel does not merely disable self-update — the updater is **not in the 1. **Nothing touches the Kubernetes cluster.** Write and validate manifests only; the owner
APK**. applies them. No `kubectl` against the cluster, not even read-only, unless asked.
2. **Secrets.** Filled-in values only in `k8s/secrets/secrets.yaml` (gitignored);
`secrets.example.yaml` holds `CHANGE-ME` placeholders only. Stage files explicitly, check the
index for changes that are not yours (the owner works in parallel), and scan the staged diff
for secrets before every commit. Never print a token.
3. **Telemetry is read-only.** The app never writes a vehicle setting.
4. **A failed read is not a zero.** Unreadable values are `null`, omitted from payloads and
shown as "—". Sending 0 for SOC tells ABRP the battery is empty.
5. **Fail closed.** Updates (origin, SHA-256, package, signature), credentials, unknown state.
6. **No credential in a URL, a log line or a crash report.** Tokens go in headers or bodies.
7. **The head unit must stay stable.** Every car call is reflection with try/catch; no
blocking car reads on the main thread.
8. **Power.** No wake locks or alarms; GPS pauses while parked and idle.
9. **Honesty.** Most behaviour cannot be verified off the car. Say what was tested (unit tests,
emulator, podman) and what was not. "Not tried on the car" is an acceptable answer.
## Firmware generations ## The car (confirmed on the owner's car, firmware `SWI68-29958-1300R69`)
MG4 infotainment ships as SWI68 / SWI69 / SWI131 / SWI132 / SWI133 / SWI165, and they - Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar),
differ. Property IDs confirmed on one generation are not universal. Anything vehicle-facing about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible.
must tolerate a property simply not existing. - Signed with the public **AOSP test platform key**; the car build runs as `android.uid.system`.
- Steering-wheel keys arrive as the broadcast `com.saic.keyevent.hardkey.report`
(`android.intent.extra.hardkey.keycode` / `.down` / `.longpress`); the voice key is **287**.
- Not published by the car: battery, cabin and outside temperature, pack energy and capacity,
paint colour (only `ro.build.mtk2712.vehicle_type = AS28`). See `SPECS.md`, `FIRMWARE.md`.
- No third-party apps in the MG launcher: the app opens by a double press of the voice key.
- Its internet comes from the car itself (no Wi-Fi needed).
**Two read paths, split by whether the signal is firmware-agnostic:** ## The app
| Signal | Read path | Firmwares | | Tab | Contents |
|---|---|---| |---|---|
| Speed, outside temp, park | `EVHardware` getters (`getVehicleSpeedKmh`, `getOutsideTempCelsius`, `isVehicleInPark`) | all six — EVHardware branches per generation internally | | Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info, tyre pressures; read every 0.5 s on a background thread |
| SOC, range | `CarPropertyAdapter` vendor IDs (`0x2160F404`, `0x2140F41C`) | **SWI68 only** | | Consumption / Charging | period totals, daily log, charging sessions and curve |
| Charge rate/port, cabin temp | `CarPropertyAdapter` standard-AAOS IDs | wherever the VHAL implements them | | Live data | on/off, ABRP API key and user token, upload frequency |
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
| Settings | full screen, voice key, tyre unit, accent colour, battery, charging price, version, update check, install from USB, About |
| Logs | upload log |
`EVHardware.init()` is called in `AbrpUploadService.onCreate()` (idempotent); it detects Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
the generation and picks the right underlying API. Fields it covers therefore work on every 15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
supported firmware. limits), `AbrpApi`/`AbrpConfig`, `ServerHttp`/`ServerConfig`/`PairingClient`/
`PhoneSettingsClient`, `ConsumptionTracker`/`ConsumptionCloudClient`, `ChargeSessionTracker`,
`RangeEstimator`, `SecurePrefs`, `ConfigImport`, `SteeringKeyListener`, `MainActivity` (UI).
Updater (car build only): `app/src/unstable/` `OtaUpdater`, `UpdateHook`, `UsbUpdate`.
**Reference firmware: `SWI68-29958-1300R69`.** The vendor EV IDs still in **Put logic in pure, unit-tested classes** (no Android types); the service and activity are
`CarPropertyAdapter` are supported only on SWI68 firmware. EVHardware hard to test without a car.
has **no EV-battery abstraction for any generation**, so SOC and range cannot go through it;
until those vendor IDs are confirmed on the other generations they stay SWI68-only, and on
another firmware they may silently return nothing (handled safely — the field is omitted, but
SOC/range would be missing). Re-confirm with `VhalProbe` before trusting the vendor IDs on any
other generation.
## Working here **UI conventions:** OLED black, glass cards, an accent the user picks from the MG4 paints —
always `?attr/evAccent…` (`res/values/attrs_accent.xml`, overlays in `accents.xml`), never a
hard-coded accent hex. All UI text in `strings.xml`. 72 dp touch targets. Values right-aligned
with tabular digits. Drop-downs are Material exposed dropdown menus and dialogs go through
`showKeepingFullScreen` — a Spinner or a plain dialog brings the car's bars back. Android 9
crashes on gradients with theme colours: use layers.
```bash **Flavours:** `stable` (no updater at all) and `unstable` (the car build: system UID, OTA and USB
mise run check # what CI runs: permission gate + lint + unit tests install). The car always gets `unstable`.
mise run build
mise run logs # adb logcat, filtered to this app ## Build, test, ship
```sh
nix develop # JDK 21, Android SDK, Go
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
build-car # build/car/NilsMG4-unstable-<version>.apk + .sha256
publish-car # pre-release car-<version> on the forge (needs a token)
cd server && go vet ./... && go test ./... && govulncheck ./...
``` ```
Gradle needs JDK 17. If a build fails in `JdkImageTransform` complaining about `jlink`, - **Versions:** three-part `versionName` in `app/build.gradle`; raise `versionCode` by one with
a stale Gradle daemon started by VS Code's Java extension is being reused — every build that goes to the car (a lower code is refused as a downgrade).
`pkill -f GradleDaemon` and retry. - **Getting it to the car:** in-app update (Settings → Check for updates, via the server), or
Install from USB stick (APK + `.sha256` in the stick's top folder), or the first time through
the Bluetooth-settings file manager (`README.md`). `OTA.md` has the whole flow.
- **Server image:** `podman build` in `server/`, push to `git.stinnesbeck.com/nils/mg4-server:<v>`,
pin the pushed digest in `k8s/server.yaml`. Validate manifests offline with
`kustomize build k8s | kubeconform` (see `k8s/README.md`).
- **Emulator:** `nix run .#car-emulator` (add `-no-window` for headless) starts an Android 9 AOSP
emulator at 1920×720, 160 dpi on port 5560 (state in `build/emulator/`). Its image shares the
car's platform key, so the car build installs as the system user. `adb shell wm size 1778x640`
gives the car's app window (taps then use those coordinates; screenshots stay 1920×720). It has
no car data (every value is "—"). While the app is open, `uiautomator dump` never sees an idle
screen; use screenshots.
- **After each change:** a `SPECS.md` done-log entry, docs that the change affects, explicit
staging, commit, push.
## Things that will bite you ## Documents
- **`android.car` is not on the compile classpath.** Everything car-related is reflection. | File | What |
R8 cannot see those uses, so `proguard-rules.pro` keeps them by name. |---|---|
- **Unit tests get a stub `org.json`** from `android.jar` that returns null. The real | `SPECS.md` | journal: architecture, status, signals the car lacks, to-dos, dated done-log |
implementation is added as a test dependency; do not remove it. | `README.md` | user-facing overview, install, setup, config file |
- **`service_running` in preferences goes stale-true** after a force-kill. Use | `OTA.md` | releases, the updater's checks, USB install |
`AbrpUploadService.isRunning()`. | `FIRMWARE.md` | the head unit, firmware generations, display |
- **Permission changes fail CI** until allowlisted with a justification. | `server/README.md`, `k8s/README.md` | API, configuration, deployment |
| `LICENSE.md` | MIT, EVHardware's PolyForm Noncommercial, the fork chain |
## Honesty requirements | `DESIGN.md`, `CHANGELOG.md` | upstream's design system and release history |
Most of this cannot be verified off a vehicle. When reporting work, state plainly what was
tested and what was not. "Unit tests pass, not tried on the car" is the expected and
acceptable answer. Do not imply road-testing that did not happen.
+4
View File
@@ -112,6 +112,10 @@ 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 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 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. `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
- 2026-10-18 — AGENTS.md rewritten as the entry point for agents: what the project is, rules,
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-18 — App 2.18.0: Settings → App → "Install from USB stick" (car build only): the newest - 2026-10-18 — App 2.18.0: Settings → App → "Install from USB stick" (car build only): the newest
`NilsMG4-unstable-<version>.apk` newer than the installed one in a stick's top folder, checked `NilsMG4-unstable-<version>.apk` newer than the installed one in a stick's top folder, checked
against its `.sha256` and the package, installed with the OTA installer session. Tested on the against its `.sha256` and the package, installed with the OTA installer session. Tested on the
+53
View File
@@ -182,5 +182,58 @@
''; '';
}; };
}); });
# An Android 9 emulator shaped like the head unit, for testing the car build: the AOSP
# "default" image is signed with the same public platform key, so the car APK installs
# there as the system user too. 1920x720 at 160 dpi; `adb shell wm size 1778x640` gives
# the app window the car leaves. No vehicle data: every value shows "—". Not in the dev
# shell, because the emulator and its image are a large download.
# nix run .#car-emulator (from the checkout; state in build/emulator/)
packages.x86_64-linux.car-emulator =
let
pkgs = import nixpkgs {
system = "x86_64-linux";
config = { allowUnfree = true; android_sdk.accept_license = true; };
};
emu = (pkgs.androidenv.composeAndroidPackages {
platformVersions = [ "28" ];
includeEmulator = true;
includeSystemImages = true;
systemImageTypes = [ "default" ];
abiVersions = [ "x86_64" ];
includeNDK = false;
}).androidsdk;
in
pkgs.writeShellApplication {
name = "car-emulator";
runtimeInputs = [ pkgs.coreutils pkgs.gnugrep ];
text = ''
root=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
state="$root/build/emulator"
mkdir -p "$state/avd" "$state/home"
export ANDROID_HOME=${emu}/libexec/android-sdk ANDROID_SDK_ROOT=${emu}/libexec/android-sdk
export ANDROID_AVD_HOME="$state/avd" ANDROID_EMULATOR_HOME="$state/home" ANDROID_USER_HOME="$state/home"
avd="$state/avd/mg4test.avd"
if [ ! -d "$avd" ]; then
echo no | ${emu}/bin/avdmanager create avd -n mg4test \
-k "system-images;android-28;default;x86_64" --force -p "$avd"
{
echo "hw.lcd.width=1920"
echo "hw.lcd.height=720"
echo "hw.lcd.density=160"
echo "hw.ramSize=2G"
echo "disk.dataPartition.size=10G"
} >> "$avd/config.ini"
fi
echo "Starting the MG4-shaped emulator on port 5560 (adb: emulator-5560); Ctrl-C stops it."
echo "adb: ${emu}/bin/adb -s emulator-5560"
exec "$ANDROID_HOME/emulator/emulator" -avd mg4test -no-audio -no-boot-anim \
-gpu swiftshader_indirect -no-snapshot -port 5560 "$@"
'';
};
apps.x86_64-linux.car-emulator = {
type = "app";
program = "${self.packages.x86_64-linux.car-emulator}/bin/car-emulator";
};
}; };
} }