diff --git a/AGENTS.md b/AGENTS.md index 07ecc5f..36401e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,125 +1,130 @@ -# AGENTS.md — EVABRPUploader +# AGENTS.md — Nils' MG4 -Context for AI agents and new contributors working in this repository. - -EVABRPUploader is part of **EVSuite**. The workspace `AGENTS.md` and normative -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. +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 +(status, to-dos, a dated log of every change) and is updated with each change. ## What this is -An Android Automotive app for the **MG4 (SAIC eh32)** that reads vehicle telemetry and -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 +An app for the **MG4 (SAIC eh32)** head unit plus the backend it talks to: ``` -AbrpUploadService foreground service; scheduler thread does all IPC and HTTP -CarPropertyAdapter reflection bridge to android.car (not on the compile classpath) -TelemetryPayload pure JSON building — no Android types, unit-tested -UploadCadence when a tick actually uploads — pure, unit-tested -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 +MG4 head unit (Android 9, AAOS, MT2712) + app/ "Nils' MG4" ──live data, consumption, charging──▶ server/ (Go) ──▶ InfluxDB 2 ◀── Grafana + │ │ relays app releases from the forge + └──live data, straight, with the driver's own key──▶ ABRP (A Better Route Planner) ``` -**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM; -anything inside the service effectively cannot be tested without a car. +- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the + `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 | -|---|---|---| -| `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 | +## Rules -The stable channel does not merely disable self-update — the updater is **not in the -APK**. +1. **Nothing touches the Kubernetes cluster.** Write and validate manifests only; the owner + 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 -differ. Property IDs confirmed on one generation are not universal. Anything vehicle-facing -must tolerate a property simply not existing. +- Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar), + about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible. +- 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 | -|---|---|---| -| Speed, outside temp, park | `EVHardware` getters (`getVehicleSpeedKmh`, `getOutsideTempCelsius`, `isVehicleInPark`) | all six — EVHardware branches per generation internally | -| SOC, range | `CarPropertyAdapter` vendor IDs (`0x2160F404`, `0x2140F41C`) | **SWI68 only** | -| Charge rate/port, cabin temp | `CarPropertyAdapter` standard-AAOS IDs | wherever the VHAL implements them | +| Tab | Contents | +|---|---| +| 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 | +| Consumption / Charging | period totals, daily log, charging sessions and curve | +| 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 -the generation and picks the right underlying API. Fields it covers therefore work on every -supported firmware. +Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, +15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server +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 -`CarPropertyAdapter` are supported only on SWI68 firmware. EVHardware -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. +**Put logic in pure, unit-tested classes** (no Android types); the service and activity are +hard to test without a car. -## 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 -mise run check # what CI runs: permission gate + lint + unit tests -mise run build -mise run logs # adb logcat, filtered to this app +**Flavours:** `stable` (no updater at all) and `unstable` (the car build: system UID, OTA and USB +install). The car always gets `unstable`. + +## Build, test, ship + +```sh +nix develop # JDK 21, Android SDK, Go +./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug +build-car # build/car/NilsMG4-unstable-.apk + .sha256 +publish-car # pre-release car- 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`, -a stale Gradle daemon started by VS Code's Java extension is being reused — -`pkill -f GradleDaemon` and retry. +- **Versions:** three-part `versionName` in `app/build.gradle`; raise `versionCode` by one with + every build that goes to the car (a lower code is refused as a downgrade). +- **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:`, + 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. - 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 - implementation is added as a test dependency; do not remove it. -- **`service_running` in preferences goes stale-true** after a force-kill. Use - `AbrpUploadService.isRunning()`. -- **Permission changes fail CI** until allowlisted with a justification. - -## Honesty requirements - -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. +| File | What | +|---|---| +| `SPECS.md` | journal: architecture, status, signals the car lacks, to-dos, dated done-log | +| `README.md` | user-facing overview, install, setup, config file | +| `OTA.md` | releases, the updater's checks, USB install | +| `FIRMWARE.md` | the head unit, firmware generations, display | +| `server/README.md`, `k8s/README.md` | API, configuration, deployment | +| `LICENSE.md` | MIT, EVHardware's PolyForm Noncommercial, the fork chain | +| `DESIGN.md`, `CHANGELOG.md` | upstream's design system and release history | diff --git a/SPECS.md b/SPECS.md index 3234c6c..aa47a41 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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 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 — 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 `NilsMG4-unstable-.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 diff --git a/flake.nix b/flake.nix index 1b7b9d5..6a9d01d 100644 --- a/flake.nix +++ b/flake.nix @@ -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"; + }; }; }