Compare commits

..
89 Commits
Author SHA1 Message Date
nilsandClaude Opus 5.5 461f9d5578 k8s/agent-access: a namespace-limited service account and kubeconfig for a coding agent
Read-only by default, optional deploy role; no Secrets, exec,
port-forward or RBAC. Tokens from kubectl create token, time-limited.
Not applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-19 09:49:20 +02:00
nilsandClaude Opus 5.5 f0f4f57fee App 2.19.0: EVHardware updated from upstream (malys/EVHardware e4e1158)
Three-way merge from b595ccb, the commit Fatih copied, to upstream's
current state; Fatih's vendor reads (tyres, mileage, charge status,
SWI69) stay. LICENSE.md records the base for the next update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-19 09:44:00 +02:00
nilsandClaude Opus 5.5 c6e6b3e88e Drop the IntelliJ project files; ignore /.idea/ as a whole
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-19 09:37:08 +02:00
nilsandClaude Opus 5.5 3d2e63ad22 App 2.18.3: larger Battery value; first-row tiles share one height
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-19 09:28:39 +02:00
nilsandClaude Opus 5.5 6c89bf587c App 2.18.2: larger values in Overview's Range, Power and Speed tiles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-19 09:19:45 +02:00
nilsandClaude Opus 5.5 bae56bec2f Server 2.6.1: code field upper-cases and inserts the dash
The phone pages' code field gets one inline script, allowed by its
SHA-256 hash in the CSP; a test checks the served script against the
header. Image pinned by digest in k8s/server.yaml (not applied).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-19 08:19:00 +02:00
nilsandClaude Opus 5.5 31ea4e7d78 README: current tabs, updating, emulator; screenshots of all tabs from 2.18.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 23:42:16 +02:00
nilsandClaude Opus 5.5 496fb821c3 App 2.18.1: every tab fits the car's window without scrolling
Charging and Consumption get two-column wide layouts; Live data and
Settings are tightened (fields beside their buttons, button rows,
16 dp vertical card padding). Checked on the emulator at 1778x640.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 22:11:05 +02:00
nilsandClaude Opus 5.5 dfd8569cfa AGENTS.md, SPECS: work happens on main now
english-translation is merged and deleted, master renamed to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 21:25:27 +02:00
nilsandClaude Opus 5.5 f54e684ae1 Merge english-translation: Nils' MG4 (app 2.18.0, server 2.6.0)
Turns the FatihsMG4 fork into Nils' MG4: an English Android Automotive
app for the MG4 head unit, a Go car server with InfluxDB 2 and Grafana,
Kubernetes manifests, and updates over the air or from a USB stick.
AGENTS.md is the overview, SPECS.md the journal of every change.

App (com.stinnesbeck.nilsmg4, car build .unstable)
- English throughout; all UI text in strings.xml. Renamed with a new
  package ID, NilsMG4-*.apk names, three-part versions; About links to
  git.stinnesbeck.com/nils/mg4 and keeps the upstream credits.
- build-car (Nix flake) signs the car build with the AOSP platform key
  the head unit trusts; nix run .#car-emulator gives a matching Android
  9 emulator.
- Overview, fitting the car's 1778x720 window without scrolling:
  battery, range, range/usage/energy since start, 5-minute averages of
  range, usage, power and speed, vehicle info, tyre pressures in
  bar/kPa/psi. Read every 0.5 s on a background thread; values
  right-aligned with equal-width digits; recuperation as negative usage
  and an infinite range.
- Head unit: full screen (kept with drop-downs and dialogs), voice-key
  double press opens the app, selectable accent colour after the MG4
  paints (Dynamic Red by default), no scroll bars.
- Tabs: Overview, Consumption, Charging, Live data (on/off, ABRP
  credentials, frequency), Server (URL, QR pairing, token, read-only
  installation ID, config import), Settings (display, battery,
  charging price, update check, install from USB, About), Logs.
- Live data to the car server and straight to ABRP; only values within
  the server's ranges; the queue survives restarts; GPS pauses while
  parked and idle.
- Settings can be typed on a phone (QR code, form on the server) or
  imported from a config file, which is deleted afterwards.
- Security: credentials in encrypted storage (moved back after a
  keystore failure), installation ID derived from the head unit.
- Updates: from this repository's pre-releases through the car server,
  or from a USB stick; SHA-256, package and signature checked; the
  installer session ran silently on the emulator.
- Removed: Wi-Fi auto-connect (and its permissions), the floating
  button, upstream's diagnostics.

Server (server/, Go 1.26, replaces upstream's Next.js app)
- Telemetry, consumption chunks and charging sessions in InfluxDB 2;
  idempotent writes; a bad value or time drops only itself.
- Pairing by QR code approved on a phone; paired tokens bound to their
  installation; lockouts and limits per client address.
- Settings from a phone (in memory only, at most 10 minutes, handed out
  once); app releases relayed from the private forge with a read-only
  FORGE_TOKEN. No ABRP forwarding: the car sends to ABRP itself.

Infrastructure (k8s/, plain manifests, applied by hand)
- InfluxDB 2.7.12, the server pinned by digest, Grafana 13.2.3 with
  provisioned Live, Consumption and Charging dashboards (euro),
  Traefik traefik-internal routes.
- Grafana's read-only InfluxDB token is made by an init container at
  each start; the Grafana process never sees the admin token.
- Filled-in secrets only in the gitignored k8s/secrets/secrets.yaml.

Licence
- MIT throughout, like Leon Kernan's ABRP_Uploader (MIT since
  2026-07-29) and the forks in between; LICENSE names every copyright
  holder. EVHardware/ is PolyForm Noncommercial, so the built APK is
  non-commercial. EVProfile's MG4Control lineage is from MG4Control's
  MIT period (checked).

Deploying
1. Fill in k8s/secrets/secrets.yaml from the template (InfluxDB admin,
   ingest token, approval password, read-only FORGE_TOKEN, Grafana
   admin password).
2. kubectl apply -f k8s/namespace.yaml, then
   kubectl apply -f k8s/secrets/secrets.yaml, then kubectl apply -k k8s/
3. Car: install the car build once by hand; after that build-car, then
   publish-car (or copy the APK and .sha256 to a USB stick), then the
   buttons on the Settings tab.

Testing
- App unit tests (stable and car build) and lint pass; server tests
  pass and govulncheck is clean; manifests validate offline.
- End to end in podman: server with InfluxDB (telemetry, pairing, phone
  settings), Grafana with the init-container token; release relay
  against a fake Gitea.
- Emulator (Android 9, 1920x720 and 1778x640): layouts, tabs, accent
  colours, config import, queue restore, USB install.
- On the car: pairing, live values, voice key, full screen, screen
  size. Not yet on the car: direct ABRP sending, Enter on phone, accent
  colours, 0.5 s refresh, over-the-air and USB updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 21:22:13 +02:00
nilsandClaude Opus 5.5 253c768781 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
2026-10-18 21:12:41 +02:00
nilsandClaude Opus 5.5 69d284f81b App 2.18.0: install from a USB stick
Settings -> App -> "Install from USB stick" (car build only). It looks
in the top folder of every mounted volume (found from the app's own
folder on it, since the head unit has no file picker) for
NilsMG4-unstable-<version>.apk, takes the newest one newer than the
installed version, checks it against its .sha256 sidecar and the
package name, and installs it with the same PackageInstaller session
as the over-the-air update. The stable build gets a stub and no button.

Tested on the emulator: 2.18.0 installed a 2.18.1 from the storage's
top folder without a confirmation, as the system user, so the OTA
install step should not need one either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:59:45 +02:00
nilsandClaude Opus 5.5 0fd024cc2e SPECS: done-log entry for app 2.17.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:53:21 +02:00
nilsandClaude Opus 5.5 5a82fe3dc1 App 2.17.5: Server tab fits; installation ID is read-only
- The "Your private connection" card is gone; its text was out of date
  (the ABRP credentials are no longer on the server).
- Import from file moved next to Save and Test, in one row.
- The installation ID is shown as text instead of an editable field:
  it is derived from the head unit, and changing it by hand only risked
  splitting the history. A config file can still carry an older one
  over, as before.
- The page fits the car's 1778x640 window without scrolling (checked
  on the emulator), and the subtitle no longer says the server passes
  data on to ABRP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:53:05 +02:00
nilsandClaude Opus 5.5 5ead905e5f App 2.17.4: the panel is called "Vehicle info"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:37:16 +02:00
nilsandClaude Opus 5.5 224840bde2 App 2.17.3: "Odometer and climate" instead of "Energy and temperature"
The Overview panel's title came from upstream, where it also showed
pack energy and temperatures. The MG4 publishes none of those, so the
panel only holds the odometer and the climate setpoint; its title now
says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:35:43 +02:00
nilsandClaude Opus 5.5 d4bb502026 App 2.17.2: Wi-Fi auto-connect removed
Upstream kept the head unit on the driver's phone hotspot by re-joining
a saved network every few minutes. This car's head unit has its own
connection and the Wi-Fi is not used, so the feature goes entirely:
WifiAutoConnect, its call on the service tick, the preference key, and
the ACCESS_WIFI_STATE / CHANGE_WIFI_STATE permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:34:05 +02:00
nilsandClaude Opus 5.5 851a4a34d4 App 2.17.1: tidier Settings and Logs
- Settings: the Wi-Fi and saved-networks cards are gone, and the rest
  sits in three columns (display and steering wheel | battery |
  charging price and app) instead of one tall column. The background
  Wi-Fi auto-connect stays on, without a switch.
- Logs: only the upload log. The steering-key reports, the car-colour
  probe and the screen line answered their questions and are removed,
  with their code (CarColorProbe, the key-report log) and strings.
- README lists the current tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:32:01 +02:00
nilsandClaude Opus 5.5 8d3eb80f66 App 2.17.0: speed average, compact top bar, About in Settings
- "Speed, 5-min average" card under Speed: time-weighted mean speed,
  standing included, from the same samples as the other averages.
- The top bar is one compact line (title and subtitle side by side).
  Version, update check and About moved from it to an App card on the
  Settings tab.
- The Overview no longer scrolls in the car's window: checked on the
  emulator at 1778x640, the layout height the car reports, after
  trimming the page's padding and the tyre card's height.
- The capacity hint says "Overview" instead of "Vehicle tab".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:26:02 +02:00
nilsandClaude Opus 5.5 79bcfa05f6 App 2.16.4: drop-downs that keep full screen
Back to drop-downs for upload frequency, tyre pressure unit and accent
colour, as Material exposed dropdown menus instead of Spinners. A
Spinner's list is a focusable window of its own without the
full-screen flags, so the car showed its bars while it was open; an
exposed dropdown's list leaves the focus with the activity, so the app
stays full screen. Styled like the other input fields, with full-height
rows. Replaces the buttons of 2.16.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:11:25 +02:00
nilsandClaude Opus 5.5 f614ab08be App 2.16.3: full screen stays with drop-downs and dialogs
Opening a drop-down or a dialog brought the car's bars back until it
closed: each is a window of its own, and the system shows the bars when
a window without the full-screen flags takes focus.

- The drop-downs (upload frequency, tyre pressure unit, accent colour)
  are now single-choice buttons on the page: no extra window, one tap
  instead of two, full touch targets, the choice in the accent colour.
- Dialogs (About, update, pairing, phone settings, trip reset, date
  picker) are shown unfocusable, given the activity's full-screen
  flags, and only then allowed focus. None of them takes typing, which
  an unfocusable window would block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:05:26 +02:00
nilsandClaude Opus 5.5 98ec3f3fcf Docs: the car's screen is 1920x720 at 160 dpi; no colour property
Read on the car with 2.16.2: panel 1920x720 px at 160 dpi, app window
1778x720 px (the car's own sidebar takes the rest), so EVProfile's
1280x480 (1920x720 at 240 dpi, in dp) does not apply here. The colour
probe found only ro.build.mtk2712.vehicle_type = AS28: the paint colour
is not among the system properties. FIRMWARE.md, mise.toml and
SPECS.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:56:57 +02:00
nilsandClaude Opus 5.5 bfa33d51bd App 2.16.2: show the car's screen size; display figures documented
The MG4's display is given as 1920x720 (this project), 1280x480
(EVProfile's README) and a 1778x720 app window (upstream). 1280x480 is
exactly 1920x720 px at 240 dpi expressed in dp, so the figures may
describe one panel at different densities, which this platform lets be
changed per app.

The Logs tab's new SCREEN line shows the panel's pixels and density and
the app window in px and dp, to settle it from the car. FIRMWARE.md
lists the three figures and what the emulator shows at 240 dpi;
mise.toml, README and SPECS.md no longer state 160 dpi as fact for the
car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:50:24 +02:00
nilsandClaude Opus 5.5 c2c0640672 LICENSE.md: EVProfile's upstream licence is settled
EVHardware's notes call the licence of EVProfile's upstream unresolved.
Checked: malys/EVProfile is MIT, forked from SliDeeN/MG4Control and last
merged from it on 2026-08-02. MG4Control is MIT up to 2.6.5
(2026-08-05) and GPL v3 from 2.6.6 (2026-09-02), with the earlier
versions staying MIT, so the code EVProfile took is from the MIT
period. Recorded with links; not an audit of every EVHardware line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:41:12 +02:00
nilsandClaude Opus 5.5 a293a0fa94 Licence: the original is MIT; EVHardware is noncommercial
Leon Kernan's ABRP_Uploader has carried the MIT Licence since
2026-07-29 (lkernan/ABRP_Uploader commit 75d8f2bc), so the inherited
"no licence upstream, status unresolved" notes no longer hold.

- LICENSE: MIT, now naming every copyright holder in the fork chain
  (Leon Kernan, EVABRPUploader contributors, FatihsMG4 contributors,
  Nils Stinnesbeck).
- LICENSE.md: rewritten. The repository is MIT except EVHardware/,
  which is PolyForm Noncommercial 1.0.0; the app links it into the APK,
  so the built app is non-commercial. EVHardware's own caveat about
  EVProfile stays with it.
- README, AGENTS.md, CONTRIBUTING.md and SPECS.md say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:38:21 +02:00
nilsandClaude Opus 5.5 c71fa4504e App 2.16.1: Overview values every half second
The Overview read the car every 2 s, on the main thread. It now reads
every 0.5 s on a background thread (one reader; a read still running
makes the next tick skip rather than queue) and shows the values on the
main thread. The consumption and charging trackers stay on the 2-second
tick with the newest reading: they integrate over time, and the
charging tracker writes its session to storage on every sample.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:32:50 +02:00
nilsandClaude Opus 5.5 9226616261 Grafana's read-only InfluxDB token is made automatically
The read-only token for Grafana needed a manual influx command and a
Secret. InfluxDB generates every token but the admin one and ignores a
chosen value (tested on 2.7.12), so it cannot be preset in a Secret.

Instead an init container in the Grafana pod creates it at every start
with the admin token, deletes the one from the previous start, and
writes only the read-only token to a memory-backed volume; the
provisioned datasource reads it with $__file{/run/influx/token}. The
Grafana process never sees the admin token. The grafana-influx Secret
is gone from the template and the README.

Tested in podman: InfluxDB 2.7.12, the script run twice as the Grafana
user (the old token is replaced, not piled up), and Grafana 13.2.3
reporting the datasource healthy with the file-based token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 19:05:58 +02:00
nilsandClaude Opus 5.5 e5549dddb0 App 2.16.0, server 2.6.0: over-the-air updates from our own repository
The car updates itself again, from this fork's own releases, while the
repository stays private:

- publish-car (flake) creates the pre-release car-<version> on the
  forge and attaches the APK and its .sha256. It needs a forge token
  that may write releases, from ~/.config/nilsmg4/forge-token or
  $FORGE_TOKEN; it is sent in a header and never printed.
- The server relays releases and attachments (/v1/app/releases,
  /v1/app/assets/...), read with a read-only FORGE_TOKEN from its
  Secret, GitHub-shaped with download URLs back at itself. Redirects
  away from the forge are not followed; off (404) without a token.
- The app asks its server with its own paired token, allows exactly
  the server's host, downloads the APK itself instead of through the
  DownloadManager (so the token stays in the app), verifies SHA-256 and
  package name, and tries a PackageInstaller session (it may install
  silently as the system user) before falling back to Downloads.

OTA.md is rewritten for this flow; the k8s and server READMEs explain
the new token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 18:08:08 +02:00
nilsandClaude Opus 5.5 f88249427f App 2.15.0: queued live data survives restarts
Unsent live samples were kept in memory only, so a reboot or a killed
process during a dead spot lost them. The queue is now saved to
files/telemetry_queue.json with AtomicFile, at most every 5 minutes and
only after a change (the flash is not written every 15 s), and at once
when the service stops; it is restored, oldest first and within the
2000-sample cap, when the service starts. A damaged file restores
nothing rather than failing. Tested on the emulator across a reboot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:56:25 +02:00
nilsandClaude Opus 5.5 f63f48131e App 2.14.4: all UI text in strings.xml
The Charging, Consumption and Settings labels, toasts, the trip-reset
dialog, the charging and Wi-Fi states, the daily summary and the "—"
placeholder move from layouts and code into strings.xml; lint now
reports no hard-coded text. Upload-log entries stay in code, as log
text. No visible change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:50:55 +02:00
nilsandClaude Opus 5.5 29064cf8db Translate the remaining French and Turkish
mise.toml, the comments in app/build.gradle (corrected where they no
longer described the build: the car build is signed by build-car, and
the updater reads the version from build-car's file name), OTA.md's
message table (now the updater's current English messages), the Turkish
messages quoted in CHANGELOG.md and OTA.md (glossed in English), two
test assertion messages, an icon comment, and the ignition-state comment
in EVHardware.kt. The vendored EVHardware library still has about 120
half-French comment lines; noted in SPECS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:45:56 +02:00
nilsandClaude Opus 5.5 5e2e354bdd About dialog points to our repository; SPECS status refreshed
The About dialog's link and QR code open
https://git.stinnesbeck.com/nils/mg4 instead of Fatih's GitHub
repository (the credits to upstream stay). SPECS.md: the Status section
describes the current versions and what has and has not been tried on
the car; the architecture notes list the current tabs and data paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:42:54 +02:00
nilsandClaude Opus 5.5 727da51a3c App 2.14.2: "Range, 5-min average"
Same form as "Usage, 5-min average" and "Power, 5-min average".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:25:35 +02:00
nilsandClaude Opus 5.5 010a0cd50d App 2.14.1: right-aligned values with equal-width digits
The Overview tiles' values (and the notes under them) are right-aligned
and use tabular figures, so a value going from 9 to 10 to 100 grows to
the left instead of shifting the whole text, and digits no longer
change width as they tick. The labels stay left; tyre pressures stay
centred but use tabular figures too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:22:22 +02:00
nilsandClaude Opus 5.5 9b5fcc0734 App 2.14.0: accent colour after the MG4 paints; car colour probe
The accent is selectable in Settings → Display: Dynamic Red (default),
Volcano Orange, Holborn Blue, Racing Green, Camden Grey, Monument
Silver, Arctic White and Black Pearl. White, silver and black cannot be
an accent on the black screen as they are, so they become light greys
and a pearl blue-grey.

The accent palette is now theme attributes (attrs_accent.xml) with one
overlay per paint (accents.xml), applied before the activity inflates;
layouts, colour lists, drawables and the views that draw in code use
them, including the red tints that were hard-coded. A change recreates
the activity on the same page. Android 9 crashes on gradients with
theme colours, so the battery card is built from layers.

Logs → "Look for the car colour" lists the head unit's system
properties that mention colour, paint or car model, to find out whether
the paint colour can be read from the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 13:52:57 +02:00
nilsandClaude Opus 5.5 40906ec4a7 App 2.13.0: tyre pressures in bar; 5-minute average power
Tyre pressures show in bar by default; Settings → Display offers kPa
and psi as well. Display only: the car reports kPa and the server keeps
storing kPa.

A "Power, 5-min average" card under Power: the time-weighted mean
battery power over the last five minutes, standing included, from the
same samples as the 5-minute range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 13:26:02 +02:00
nilsandClaude Opus 5.5 66fed48aae App 2.12.1: no scroll bars; energy since start
The pages' scroll bars never faded on the car: a ScrollView shows its
bar on every layout, and the live values lay the page out every two
seconds. StableScrollView, the root of every page, now has none.

A new "Energy since start" tile, left of "Usage since start", shows the
net kWh taken from the pack since the trip began, recuperation counted
back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 13:12:12 +02:00
nilsandClaude Opus 5.5 d711e0cfda App 2.12.0, server 2.5.0: enter settings on the phone
The ABRP credentials are long to type on the car. "Enter on phone"
(Live data tab, and Settings next to the charging price) shows a QR
code; it opens a form on the server where the phone sends the ABRP API
key and user token, charging price and usable capacity, confirmed with
the approval password. The car polls with its own token and collects
the values once, then saves them through the same checks as the fields
on screen.

Same device-flow shape as pairing. The server keeps the values in memory
only, at most ten minutes, hands them out once with no-store, and never
logs them or puts them back into the page; a paired token only reaches
its own installation's request. Pairing's QR dialog is now shared
(PhoneQrDialog), and the phone pages share one stylesheet.

The config file also takes charging_price and usable_kwh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 12:59:03 +02:00
nilsandClaude Opus 5.5 9f184362f0 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
2026-10-18 12:49:46 +02:00
nilsandClaude Opus 5.5 ca2f899403 App 2.11.0, server 2.3.0: live data straight to ABRP; settings regrouped
ABRP keeps getting live data while the car server is down: with an ABRP
API key and user token set in the app (Live data tab, or abrp_api_key /
abrp_token in the config file), the car sends the newest sample to ABRP
itself at every upload. It tells the server the newest sample ABRP took
(abrpSentUtc), and the server forwards only newer ones, so ABRP gets
each sample once. Without credentials in the app the server forwards
everything, as before. The credentials sit in encrypted storage, go in
the request body, and neither they nor ABRP's answer are logged.

Settings are grouped by what they are for:
- Live data (was Upload): on/off, ABRP credentials, upload frequency.
- Settings (replaces the Wi-Fi tab): full screen and voice key,
  usable capacity and manual SoH (were on Consumption), charging price
  (was on Charging), Wi-Fi.
Charging and Live data get icons of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 12:15:21 +02:00
nilsandClaude Opus 5.5 087f193f8c App 2.10.2: infinity as the range while recuperating
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 11:01:02 +02:00
nilsandClaude Opus 5.5 3ae1e18a43 App 2.10.1: show negative usage while recuperating
Net regeneration over the window (or the trip) used to hide both
estimates behind "needs a few minutes of driving". It is a real
reading: the usage now shows as negative kWh/100 km, and the range as
"> 999 km" with "while recuperating" beneath it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 10:07:08 +02:00
nilsandClaude Opus 5.5 2a0c4c9675 App 2.10.0: range since start, usage in its own cards
"Range since start" replaces the range at current power: remaining
energy over the consumption since the car was switched on, standing
with the climate on included. A trip starts again after 10 minutes
without samples (the car was off) or when charging.

The kWh/100 km for both estimates moved out of the range tiles into
two cards of their own, directly below them; the overview still fits
the full-screen 1920x720 display without scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 10:01:11 +02:00
nilsandClaude Opus 5.5 a5db5604d3 Code review fixes: app 2.9.0, server 2.2.0
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
2026-10-18 00:36:19 +02:00
nilsandClaude Opus 5.5 9bebf56b3a Docs: new package ID and APK names
OTA.md, mise.toml and docs/MG4_PLATFORM_INSTALL.md now use
com.stinnesbeck.nilsmg4(.unstable) and NilsMG4-*.apk. OTA.md says the
updater is switched off and that its publishing steps are upstream's
GitHub process; it also drops the old four-part version example.
CHANGELOG.md keeps the old ID as upstream history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 00:09:52 +02:00
nilsandClaude Opus 5.5 4b858e3c35 README for Nils' MG4
Rewritten for this fork: the car server, InfluxDB and Grafana setup,
pairing by QR code, the app's tabs, voice-key opening and full screen,
installing the platform-signed car build, the config file under the new
package ID, building with Nix, and credits. Adds a current screenshot
(2.8.2 at 1920x720, emulator).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 00:08:14 +02:00
nilsandClaude Opus 5.5 1b918e3c39 App 2.8.2: full screen fills the space the bars leave
On the MG4 the immersive flags hid SAIC's bars but left a black band
where they had been. SYSTEM_UI_FLAG_LAYOUT_STABLE reports the bars'
insets even while they are hidden, and the fitsSystemWindows root
padded itself by them. In full screen the flag is dropped and the root
no longer fits system windows; switching full screen off restores both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 00:00:58 +02:00
nilsandClaude Opus 5.5 8bad5fc024 Three-part version names for the car build
The unstable flavor appended upstream's CI build number as a fourth
part (2.8.0.0-unstable; always .0 without CI). Versions are now the
plain three-part defaultConfig version: 2.8.1-unstable, and build-car
names the APK NilsMG4-unstable-2.8.1.apk. The version code keeps the
x100000 factor (2.8.1 = 6700000): without it the code would fall below
the 6600000 already installed, and the installer refuses downgrades.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 23:55:08 +02:00
nilsandClaude Opus 5.5 3f1e196817 App 2.8.0: full screen
Immersive full screen while the app is in front: the system's top and
bottom bars are hidden, and a swipe from the edge shows them briefly.
Re-applied whenever the window regains focus. A "Full screen" switch in
the Upload tab (default on) turns it off.

On the Android 9 emulator the bars are hidden (system UI flags 0x1706),
stay hidden after leaving and returning, and the switch toggles them
through the UI. Whether SAIC's own bars on the MG4 obey the flags is
only known on the car; if they do not, nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 23:52:33 +02:00
nilsandClaude Opus 5.5 55710fb722 App 2.7.1: voice-key double press works; floating button removed
The car's key report, from the on-screen key log in 2.7.0:
android.intent.extra.hardkey.keycode = 287 (Integer),
android.intent.extra.hardkey.down (Boolean; one report on press, one on
release) and android.intent.extra.hardkey.longpress (Boolean). 2.7.0
only looked for short names like "keyCode", so it never found the code.
SteeringKeyReport now matches extras by the last part of their name,
case-insensitively, and is Android-free; its tests include the exact
double press logged on the car (releases 279 ms apart). Replayed in the
car's format on the emulator: double press opens the app, single and
long presses do not.

The floating button is removed (class, geometry and test, the overlay
permission, setting, switch and strings): it did not show over the MG4
launcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 23:38:36 +02:00
nilsandClaude Opus 5.5 af15b15db1 App 2.7.0: voice-key double press opens the app; no scroll jumps
Steering wheel: a double press of the voice key (code 287) opens the
app. The floating button from 2.6.0 does not show over the MG4
launcher, so this is the way back into the app. The head unit reports
keys as the broadcast com.saic.keyevent.hardkey.report, whose extras
are not documented: the receiver tries the usual names (and an
embedded KeyEvent), and counts one press per report whichever way the
up/down field turns out to work; long presses never open the app. The
last 20 reports are listed raw on the Logs tab, so if the guess
misses, a photo of that list gives the real format. A switch in the
Upload tab turns it off. DoublePressDetector (500 ms window) has 5
tests; on the emulator, simulated double presses open the app and
single presses, other keys and long presses do not.

Scrolling: pages jumped back to the top on every 2 s refresh. A stack
trace on the emulator showed why: ViewPager2's RecyclerView lays the
pages out while measuring, at a provisional height, and
ScrollView.onLayout clamps the position to 0. StableScrollView keeps
the driver's position across layouts and is now the root of every
page; the scrolled Charging page stays put across refreshes.

Vehicle tab: all six value tiles, including both range estimates, sit
in one row at the top, so nothing needs scrolling at 1920x720.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 23:24:48 +02:00
nilsandClaude Opus 5.5 fde963533d Head unit is 1920x720, not 1920x1080
FIRMWARE.md, the mise emulator profile and SPECS.md said 1920x1080.
The MG4's display is 1920x720 at 160 dpi (an app window has been seen
at 1778x720, the car's sidebar taking the rest). The floating button's
geometry test now uses 1920x720.

The app itself assumed no height: checked on an Android 9 emulator at
1920x720, all tabs render (longer ones scroll), the sidebar holds all
seven tabs, and the floating button starts at 1840,312.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:58:28 +02:00
nilsandClaude Opus 5.5 11d53e7bc7 App 2.6.0: floating button to open the app
The MG4's launcher does not list third-party apps, so the app could
only be opened by reinstalling it. Now a small round MG-red button
floats over the car's screens: tap opens the app, drag moves it (the
position is kept and clamped to the screen), and it hides while the
app itself is open. A switch in the Upload tab turns it off.

The always-on service owns the overlay, so the button is there from
boot. It needs the overlay permission (SYSTEM_ALERT_WINDOW), which the
car build has through the system UID; without it, or if the window
manager refuses, the button stays away and nothing crashes, and the
Upload tab offers the system permission screen.

Tested on an Android 9 AOSP emulator at the head unit's 1920x1080 /
160 dpi with the car APK (installs as UID 1000): button appears over
the home screen, tap opens the app, hidden while open, drag moves it
without opening the app, position survives a force-stop, the switch
works through the UI. FloatingButtonGeometry has 3 unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:55:15 +02:00
nilsandClaude Opus 5.5 a53ea589b2 App 2.5.0: range estimates from current and 5-minute average power
The Vehicle tab gets two tiles next to the car's own range:

- Range at current power: remaining energy / (power / speed) right now.
- Range at 5-min average: remaining energy / (energy used / distance
  driven) over the last 5 minutes, integrated time-weighted from both
  the service tick (15 s) and the open screen (2 s), so it is ready as
  soon as the app opens.

Each shows the consumption it is based on (kWh/100 km). Remaining
energy is SOC x usable capacity, because the car publishes no pack
energy; the capacity is a new setting in the Consumption tab (default
50.8 kWh, the 51 kWh battery). Estimates read "—" with the reason when
they cannot mean anything: standing still, regenerating, too little
driving, or across a gap in the samples; over 999 km shows "> 999 km".

RangeEstimator is Android-free and has 7 tests. Also fixes the battery
card's note, which still said the SoH goes to MongoDB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:33:10 +02:00
nilsandClaude Opus 5.5 5f6cf1e901 Revert "Grafana: line charts in MG red"
Back to Grafana's default palette for the line charts, as before.
This reverts commit c5d61d1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:27:37 +02:00
nilsandClaude Opus 5.5 c5d61d1d11 Grafana: line charts in MG red
The tiles, bar charts and route map were already MG red; the line
charts still used Grafana's default palette. Now the first line of a
pair is MG red (#E0424C) and the second light red (#FF9A9E), as in
the app; the tyre chart adds deep (#A8323A) and pale (#FFD0D2) red.
Covers SoC/range, power/speed, voltage/current, tyre pressures and the
charging curve. Checked by rendering the Live and Charging dashboards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:25:35 +02:00
nilsandClaude Opus 5.5 582c084b35 Live dashboard: show what the car sends, compute pack energy
The car publishes no battery, cabin or outside temperature and no pack
energy (checked against the fields that reached InfluxDB). Instead of
panels that stay empty:

- Battery temperature tile -> Climate setpoint.
- Outside temperature tile -> Last update ("2 minutes ago").
- Energy in pack = SOC x usable capacity, from the hidden dashboard
  constant usableKwh = 50.8 (51 kWh battery).
- Temperatures chart -> Battery voltage and current.

Checked with a simulated drive carrying exactly the car's fields: all
11 panels return data, energy matches 61 % x 50.8 kWh. SPECS.md lists
the missing signals and why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 22:17:41 +02:00
nilsandClaude Opus 5.5 f3de272c69 Grafana: map zooms to the route; plain hyphens in titles
- "Where the car was" fits the route again: in Grafana's fit view the
  zoom value is the maximum zoom, and 6 held it at country level. Now
  15 with 10 % padding. Checked by rendering the Live dashboard with a
  simulated 10 km drive.
- Dashboard titles use "-" instead of an em dash: "MG4 - Live",
  "MG4 - Consumption", "MG4 - Charging".
- SPECS.md: Grafana keeps its state in SQLite on its PVC, which is why
  it runs as one replica with Recreate; high availability needs
  PostgreSQL or MySQL first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:59:17 +02:00
nilsandClaude Opus 5.5 cee0eaeab3 Grafana: memory request 512Mi, limit 1Gi
It runs at about 406 MiB in the cluster, too close to the 512Mi limit:
long-range dashboard queries could get it OOM-killed. The request now
matches what it uses, so the scheduler reserves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:45:56 +02:00
nilsandClaude Opus 5.5 756ae65d0b Grafana: startup probe so a slow first start is not killed
Grafana's first start creates and migrates its database: 44 s at
0.5 CPU and 3 minutes at 0.1 CPU (measured with 13.2.3 on an empty
volume). With only 50m requested, the liveness probe (30 s delay,
3 x 30 s) restarted it mid-migration, in a loop, and readiness never
passed.

- startupProbe on /api/health allows up to 10 minutes; liveness and
  readiness start only after it succeeds.
- 5 s timeouts on all probes instead of the 1 s default.
- Requests raised to 250m CPU / 256Mi (measured about 240 MB in use);
  still no CPU limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:43:49 +02:00
nilsandClaude Opus 5.5 84fb3aac21 k8s: work with kubectl apply -f as well as -k
- The secrets template moves to k8s/secrets/, and the filled-in copy to
  k8s/secrets/secrets.yaml (gitignored). `kubectl apply -f k8s/` does
  not recurse, so neither the placeholder template nor the real secrets
  can be applied by accident.
- The server image digest is pinned in server.yaml instead of through
  kustomize's images override, which `-f` ignores (it would have pulled
  a nonexistent :latest).
- README and kustomization.yaml say to use `-k`: plain `-f` never
  creates the generated Grafana ConfigMaps.
- The IngressRoutes drop `tls: {}`, like the other routes on this
  Traefik: TLS is configured on the websecure entrypoint.

Manifests validated both ways (14 resources rendered with kustomize,
11 as plain files — the difference is the Grafana ConfigMaps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:36:17 +02:00
nilsandClaude Opus 5.5 46dccee1de App 2.4.3: pairing code visible on the car screen
The code below the QR code was not visible on the head unit: the dialog
stacked the text, a 240 dp QR code, the code and the status, and its
content could not scroll, so on the wide, low screen the lower part
was cut off. On wide screens the QR code now sits left of the text,
the content scrolls, and the code is also part of the instruction
sentence, so it is readable wherever the text is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 21:12:21 +02:00
nilsandClaude Opus 5.5 8a5813fa16 App 2.4.2: pairing dialog shows errors instead of closing
- On a failure the pairing dialog stays open and shows the reason in
  red (Cancel becomes Close); before, it closed at once and the reason
  was only in the Server tab's status line.
- "Pairing is not enabled" only when the server says so. Any other 404
  (a server older than 2.1.0, or a proxy's page) is reported as a
  server without pairing.
- Pairing and Test connection name the problem: a server name that
  does not resolve (Test connection used to say "No internet
  connection"), a server that does not answer, or the HTTP status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 20:56:02 +02:00
nilsandClaude Opus 5.5 6f57351866 Use the traefik-internal ingress class
Both IngressRoutes (server and Grafana) now use traefik-internal, like
the rest of *.k8s.stinnesbeck.com, instead of the traefik-external
placeholder. README and SPECS updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 18:16:52 +02:00
nilsandClaude Opus 5.5 315586f94c Move the server to car.k8s.stinnesbeck.com
The server's host is car.k8s.stinnesbeck.com, not car.stinnesbeck.com:
IngressRoute, PUBLIC_URL (so the pairing QR code points there), the
app's pre-filled server URL and hint, and the docs. Grafana's
placeholder host follows to car-grafana.k8s.stinnesbeck.com, so one
*.k8s.stinnesbeck.com default certificate covers both. App 2.4.1 for the
new default; the server image is unchanged (its URL comes from the
manifest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 18:08:35 +02:00
nilsandClaude Opus 5.5 59ee2cae3b Deploy server 2.1.0; keep filled-in secrets out of git
- kustomization pins mg4-server 2.1.0 by digest; server.yaml sets
  PUBLIC_URL for the pairing QR code; the secrets template gains
  APPROVAL_PASSWORD.
- Filled-in Secrets now go into k8s/secrets.yaml, which is gitignored;
  the template stays on placeholders. This follows a leak where real
  values typed into the template were committed and pushed (amended and
  force-pushed since; both values regenerated).
- SPECS.md records pairing and the incident.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:16:52 +02:00
nilsandClaude Opus 5.5 c578a882bd App 2.4.0: pair with the server by QR code
The Server tab gets "Pair with phone" right under the server URL, which
is now pre-filled with https://car.stinnesbeck.com. It opens a dialog
with a QR code and the short code, polls the server, and on approval
saves this car's own token like Save would — nothing to type in the
car. Denied, expired, and pairing switched off on the server are each
reported. PairingClient is tested against a stand-in for the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:16:52 +02:00
nilsandClaude Opus 5.5 d5f68e1bd9 Server 2.1.0: pair cars by QR code instead of typing the token
Modelled on the OAuth device authorization grant (RFC 8628) and on
only when APPROVAL_PASSWORD is set (at least 12 characters):

- POST /v1/pair/start opens a request for an installation and returns
  a short user code and the /pair URL for the car's QR code;
  POST /v1/pair/poll returns pending, the car's own token (once),
  denied or expired. Requests live 10 minutes, in memory.
- GET/POST /pair is a script-free approval page for the phone
  (strict CSP, no framing). Five wrong passwords lock approvals for a
  minute.
- Each car gets its own 43-character token; only its SHA-256 is stored
  (measurement "device"), and the device is persisted before the car
  can collect the token. Tokens are loaded at startup, so a restart
  keeps paired cars working; the server refuses to start if they cannot
  be loaded.
- GET /v1/devices and POST /v1/devices/{id}/revoke, authenticated with
  the approval password, list and revoke cars.
- The shared INGEST_TOKEN keeps working.

31 tests (race detector clean), govulncheck clean. Tested end to end
against InfluxDB 2.7.12: pairing, wrong password, single collection,
persistence across restarts, revocation (also across a restart), and
that InfluxDB holds only the hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:16:19 +02:00
nilsandClaude Opus 5.5 4fe29189c0 Pin the pushed server image; drop the pull secret
mg4-server:2.0.0 is on git.stinnesbeck.com and pulls anonymously, so
the Deployment no longer references a registry pull secret. The image
is pinned by digest (sha256:6feea777…) so a re-pushed tag cannot
change what the cluster runs. README and secrets template updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:14:53 +02:00
nilsandClaude Opus 5.5 e874578e54 Replace the turquoise with MG red tones
ev_cyan becomes a light red (#FF9A9E, for values and secondary
highlights, readable on black) and ev_cyan_deep a deep red (#3A1215,
version badge). Turquoise hardcoded in the battery card, switch track,
input panel, car outline, background glow and charging-graph grid is
replaced with matching red tones, as are two gold leftovers (tab
outline, second background glow). The mint "OK" status colour and the
amber charging curve are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 15:45:18 +02:00
nilsandClaude Opus 5.5 5473ab1c2b Dashboards in Grafana; clean up after the Next.js app
- k8s/grafana.yaml: Grafana 13.2.3 with a 1Gi PVC, sign-up and
  anonymous access off. The InfluxDB datasource (URL and token from the
  environment) and three dashboards — Live, Consumption, Charging — are
  provisioned from k8s/grafana/ via kustomize-generated ConfigMaps.
- server.yaml for the Go server (port 8080, UID 65532, smaller limits),
  image tag 2.0.0. The NetworkPolicy now admits Grafana to InfluxDB.
- ingress.yaml: IngressRoutes for car.stinnesbeck.com and Grafana
  (placeholder host car-grafana.stinnesbeck.com) with tls: {}, using
  Traefik's default certificate; the cert-manager Certificate is gone.
- secrets.example.yaml: drops the dashboard secrets, adds grafana-admin.
- AGENTS.md loses its Next.js block; SPECS.md describes the new setup.

The removal of web/ (the Next.js app) and of the stale .next/ cache
upstream had committed already landed in 51e6f6f, which was staged
together with the Go server by mistake.

Checked end to end in podman (Go server + InfluxDB 2.7.12 + Grafana
13.2.3): all 25 dashboard queries return the expected data, and the
dashboards were rendered with headless Chromium. Manifests validated
offline with kustomize and kubeconform. Nothing applied to a cluster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 15:18:09 +02:00
nilsandClaude Opus 5.5 51e6f6fd48 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
2026-10-17 15:17:42 +02:00
nilsandClaude Opus 5.5 538d03361d Translate the dashboard to English; euro instead of lira
Dashboard: every label, empty state, chart legend and the login page
are English; numbers and dates use en-GB (24-hour clock, day-month
dates), money is EUR, percentages read 80% instead of %80, durations
"2 h 05 min". html lang is en. Server version 1.1.2; manifests pin it.

Car app: the charging tab's price suffix, validation message and total
cost use € instead of ₺.

Checked by rendering all three pages from the 1.1.2 image against
InfluxDB 2.7.12 in podman.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 14:39:59 +02:00
nilsandClaude Opus 5.5 818442db08 Rename the web app from FatihsMG4 to Nils' MG4
Browser title, login heading, hidden password-manager username, session
cookie (nilsmg4_session) and package name. The README keeps the credit
to fatihdonmezdev/FatihsMG4. Server version 1.1.1; the manifests pin it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 14:34:16 +02:00
nilsandClaude Opus 5.5 e39d9cdcf9 Replace the muted gold accent with MG red
ev_accent is now #E0424C, the red of the launcher icon (about 4.6:1 on
the black background, enough for accent-coloured text; the darker MG
brand red is about 3:1). ev_on_accent and ev_gold_surface follow with a
dark red tint; the gold hardcoded in two drawables and the consumption
period buttons is replaced too. AGENTS.md's visual direction updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 14:29:01 +02:00
nilsandClaude Opus 5.5 6145736c97 App 2.3.0: Server tab, live telemetry to the car server
- New Server tab: server URL, ingest token and installation id, with
  Save, Test connection (health, then a read-only token check) and the
  config-file import that upstream removed (app folder / USB stick first,
  document picker as fallback). Settings are kept encrypted and read on
  every use, so the service picks up a change without a restart. Each
  installation generates its own id; the inherited upstream UUID and
  the baked-in CONSUMPTION_API_* build fields are gone.
- Live telemetry restored from before ea8032a (TelemetryPayload,
  UploadCadence, UploadSettings, GPS) and sent in batches to
  POST /v1/telemetry instead of ABRP; the server forwards to ABRP. Adds
  ext_temp, voltage and current. Unsent samples queue in memory (about
  8 h) while offline.
- Upload tab: the live-telemetry switch and cadence controls work again;
  the dead service and autostart switches are removed.
- Manifest: location permissions and foreground service types.
- Unit tests compile again: 114 (stable) / 136 (unstable) pass. Tests
  for the deleted AbrpApi and HeadUnitWeather removed; new tests for
  ServerConfig and TelemetryQueue. Lint passes.

Not yet tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:23:04 +02:00
nilsandClaude Opus 5.5 c253876ed2 Add Kubernetes manifests for car.stinnesbeck.com
Namespace, InfluxDB 2.7.12 StatefulSet with PVC and a NetworkPolicy
that admits only the server, the server Deployment (non-root, read-only
root filesystem), a cert-manager Certificate from letsencrypt-prod and a
Traefik IngressRoute. Secrets are a template only. Nothing is applied;
k8s/README.md has the steps. Server version bumped to 1.1.0.

Validated offline with kustomize and kubeconform (including the
cert-manager and Traefik CRD schemas). The InfluxDB container was tested
locally as UID 1000 with no capabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:11:58 +02:00
nilsandClaude Opus 5.5 4ea66cf424 Server: InfluxDB 2 storage, live telemetry endpoint, ABRP forwarder
- MongoDB replaced by InfluxDB 2 (one bucket, installationId tag).
  Consumption is stored per chunk at its recordedAt and summed on read,
  so a chunk the car re-sends from its cache overwrites itself instead
  of being counted twice ($inc did count it twice). Lifetime is a
  running total derived on read.
- POST /v1/telemetry takes batches of samples in ABRP's tlm fields; every
  field is bounded, unknown fields are dropped, unread signals stay absent.
- After a successful write the newest fresh sample is forwarded to ABRP
  with ABRP_API_KEY / ABRP_USER_TOKEN from the server environment; rate
  limited, never queued. The car no longer needs ABRP credentials.
- Self-hosting: standalone output, Dockerfile (tests run in the build,
  runs as UID 10001), .env.example and README for the new setup.
- Dev shell gains Node 22 and InfluxDB 2.

Verified locally against InfluxDB 2.7.12: all endpoints, retry
idempotency, and the dashboard pages. Unit tests: 17 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:09:29 +02:00
nilsandClaude Opus 5.5 d4fb4ba3af Plan the car server (InfluxDB 2, ABRP forwarder, k8s manifests) in SPECS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:51:07 +02:00
nilsandClaude Opus 5.5 de2428786a Journal diagnostics and upstream ABRP removal in SPECS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:38:47 +02:00
nilsandClaude Opus 5.5 10029623e3 Remove on-screen diagnostics from the Logs tab
They served their purpose: vehicle values show on the car. This reverts
commit b2a5b32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:38:36 +02:00
nilsandClaude Opus 5.5 b2a5b32b08 Add on-screen diagnostics to the Logs tab
Without adb there was no way to see why vehicle values stay blank:
refreshVehicle() swallowed every exception. The Logs tab now shows the
package, version, UID (and whether it is the system UID), signer
SHA-256, firmware, car permission grants, the last snapshot and read
error, and the newest 200 entries of the vehicle library's AppLogger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:26:39 +02:00
nilsandClaude Opus 5.5 a707b590f7 Rename package and APKs to NilsMG4, credit Fatih, add SPECS.md
- applicationId com.evsuite.abrp -> com.stinnesbeck.nilsmg4 (the car
  build is .unstable); the Java namespace is unchanged. Installs next to
  upstream's app rather than over it.
- APK names NilsMG4-*.apk, debug builds included; build-car follows.
- OTA install-result action, cache/download names, User-Agent and log
  tags follow the new name.
- About credits fatihdonmezdev / FatihsMG4.
- SPECS.md journals what changed and what is still to do, starting
  with pointing the About link and QR code at git.stinnesbeck.com/nils/mg4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:08:09 +02:00
nilsandClaude Opus 5.5 3da3fa9f49 Rename app to "Nils' MG4"
Changes the launcher label, header brand caption, About title, logo
description and OTA download labels. Package ID, APK file names, log
tags and the repository link are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:02:18 +02:00
nilsandClaude Opus 5.5 3af337ca89 Disable OTA update checks by pointing at an unresolvable host
The updater stays in place, but its releases URL now uses the .invalid
TLD (RFC 2606), so checks fail instead of offering upstream's Turkish
builds over this one. Swap in a fork's releases API to re-enable it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:59:52 +02:00
nilsandClaude Opus 5.5 8e7d8b8627 Add build-car: unstable APK signed with the AOSP platform key
The MG4 head unit's platform certificate (c8a2e9bc…192ab8, per
tools/README.md) is the public AOSP test key. build-car fetches that key
into the gitignored tools/ directory from a pinned android-9.0.0_r1 tag,
builds assembleUnstableDebug, zipaligns and signs it, and refuses to
finish unless both the key's certificate and the resulting signer match
the expected SHA-256. Output lands in build/car/ with a .sha256 sidecar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:51:58 +02:00
nilsandClaude Opus 5.5 9d647f2ce1 Match dev shell hint to the documented build commands
Drops the unit-test hint, since the test sources do not compile upstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:49:17 +02:00
nilsandClaude Opus 5.5 bac887bfa6 Add Nix flake dev shell for building on NixOS
Provides JDK 21 and an Android SDK (platforms 36/36.1, build-tools
36.0.0) via `nix develop`. Points AGP at the SDK's aapt2 and Gradle at
the Nix JDK, since the binaries they would otherwise download do not
run on NixOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:46:37 +02:00
nilsandClaude Opus 5.5 864ac612da Translate app UI from Turkish to English
Translates string resources, hardcoded layout text and user-facing
strings in Java sources (toasts, dialogs, status labels, upload log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:38:00 +02:00
300 changed files with 17491 additions and 5514 deletions
+5 -6
View File
@@ -1,12 +1,7 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/
.DS_Store
/build
/captures
@@ -39,3 +34,7 @@ mise.local.toml
/EVHardware/build
/EVHardware/.gradle
/EVHardware/**/build
# Filled-in Kubernetes Secrets (copy of k8s/secrets/secrets.example.yaml). Never commit.
/k8s/secrets.yaml
/k8s/secrets/secrets.yaml
-3
View File
@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
-9
View File
@@ -1,9 +0,0 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
-1
View File
@@ -1 +0,0 @@
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":348,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
-1
View File
@@ -1 +0,0 @@
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":345,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
-15
View File
@@ -1,15 +0,0 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
}
-1
View File
@@ -1 +0,0 @@
{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411}
-1
View File
@@ -1 +0,0 @@
{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"}
-15
View File
@@ -1,15 +0,0 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
}
-1
View File
@@ -1 +0,0 @@
{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304}
-3
View File
@@ -1,3 +0,0 @@
{"timestamp":"00:00:01.802","source":"Server","level":"ERROR","message":"Turbopack build encountered 1 error:\n./app\nError: Could not find the Next.js package (next/package.json)\nResolved from: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader/app\nFilesystem root used for resolution: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader\n\nPossible causes:\n - node_modules is being reorganized by a concurrent install (e.g. pnpm adding a package with a `next` peer dependency). This is transient and should clear once the install completes.\n - node_modules/next was removed, renamed, or has a broken symlink.\n - The workspace root is incorrect — see turbopack.root in the Next.js config docs for how to configure it.\n - In a monorepo, the Next.js package may only exist in a directory above the closest directory containing a package manager lockfile. The workspace root is detected by locating the nearest package manager lockfile.\n - Next.js is installed globally rather than as a project dependency. This is not supported; install it locally.\n\nNote: To ensure a hermetic build and a portable cache, files outside of the workspace root are not compiled.\n\nhttps://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory\n\n"}
{"timestamp":"00:00:02.079","source":"Server","level":"LOG","message":""}
{"timestamp":"00:00:02.080","source":"Server","level":"LOG","message":"✓ Generated AGENTS.md for AI agents. Set `agentRules: false` in next.config to disable."}
-3
View File
@@ -1,3 +0,0 @@
{
"type": "commonjs"
}
-6
View File
@@ -1,6 +0,0 @@
{
"version": 4,
"routes": {},
"dynamicRoutes": {},
"notFoundRoutes": []
}
-1
View File
@@ -1 +0,0 @@
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"priority":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[],"onMatchHeaders":[]}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
@@ -1,15 +0,0 @@
globalThis.__BUILD_MANIFEST = {
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
};
@@ -1,6 +0,0 @@
{
"version": 3,
"middleware": {},
"sortedMiddleware": [],
"functions": {}
}
-1
View File
@@ -1 +0,0 @@
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
-6
View File
@@ -1,6 +0,0 @@
{
"app": {},
"appUsingSizeAdjust": false,
"pages": {},
"pagesUsingSizeAdjust": false
}
-1
View File
@@ -1 +0,0 @@
{}
-5
View File
@@ -1,5 +0,0 @@
{
"previewModeId": "a508c818777951bed52c0170453ef7e3",
"previewModeSigningKey": "591a19b72d1a845a81057e56fb15a2ced8409045c0ed580013f217b8c4a4ea61",
"previewModeEncryptionKey": "ded0359fbc3be263ec7ff240e7fb98fc5b836958070834f5bc954f1092afa91c"
}
@@ -1 +0,0 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=\"\n}"
@@ -1,5 +0,0 @@
{
"node": {},
"edge": {},
"encryptionKey": "UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM="
}
@@ -1,8 +0,0 @@
self.__BUILD_MANIFEST = {
"__rewrites": {
"afterFiles": [],
"beforeFiles": [],
"fallback": []
},
"sortedPages": []
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
@@ -1 +0,0 @@
self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()
@@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
-55
View File
@@ -1,55 +0,0 @@
// This file is generated automatically by Next.js
// Do not edit this file manually
type AppRoutes = never
type PageRoutes = never
type LayoutRoutes = never
type RedirectRoutes = never
type RewriteRoutes = never
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes
interface ParamMap {
}
export type ParamsOf<Route extends Routes> = ParamMap[Route]
interface LayoutSlotMap {
}
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap }
declare global {
/**
* Props for Next.js App Router page components
* @example
* ```tsx
* export default function Page(props: PageProps<'/blog/[slug]'>) {
* const { slug } = await props.params
* return <div>Blog post: {slug}</div>
* }
* ```
*/
interface PageProps<AppRoute extends AppRoutes> {
params: Promise<ParamMap[AppRoute]>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/**
* Props for Next.js App Router layout components
* @example
* ```tsx
* export default function Layout(props: LayoutProps<'/dashboard'>) {
* return <div>{props.children}</div>
* }
* ```
*/
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
params: Promise<ParamMap[LayoutRoute]>
children: React.ReactNode
} & {
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
}
}
+106 -109
View File
@@ -1,132 +1,129 @@
# 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,
muted gold and cyan accents. 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`. See `LICENSE.md` — the licence status is
**unresolved** and that is a real constraint, not paperwork.
## 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`), branch `main`. 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-<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`,
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:<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.
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.
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
| 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 |
+2 -2
View File
@@ -39,7 +39,7 @@
- Charging sessions are now metered on DC only (10 kW and above). An AC charge is an
overnight affair the head unit sleeps through, so its curve, duration and energy were
guesses; the charging screen labels one as `AC şarj (ölçülmüyor)` rather than showing
guesses; the charging screen labels one as `AC şarj (ölçülmüyor)` ("AC charging (not metered)") rather than showing
the previous DC session's figures next to it.
### Fixed
@@ -246,7 +246,7 @@ All notable changes to this project are documented here. Format follows
- OTA installs now go through the platform `PackageInstaller` instead of shelling out to
`pm install -r`. The old path handed the package manager service a file inside the app's
private cache, which it cannot read from its own process, and every such failure was
reported as `Kurulum reddedildi (imza uyuşmuyor?)` — a signature verdict on a correctly
reported as `Kurulum reddedildi (imza uyuşmuyor?)` ("installation refused (signature mismatch?)") — a signature verdict on a correctly
signed build. A session streams the archive from this process, so no second process has
to reach our cache. Approach taken from merthankaraman/DriveHub_Dort, which solved the
same problem on the same head unit.
+2 -3
View File
@@ -91,6 +91,5 @@ say what the failure looked like, so the next person recognises it.
## Licensing
Read [`LICENSE.md`](LICENSE.md) first. The project's licence status is unresolved because
the upstream fork source has none. Contributions are accepted on the understanding that
they are offered under MIT once that is settled.
Read [`LICENSE.md`](LICENSE.md) first. Contributions are accepted under the MIT Licence,
except changes to `EVHardware/`, which fall under its PolyForm Noncommercial licence.
+252
View File
@@ -8,6 +8,258 @@ All notable changes to this project are documented here. Format follows
### Added
- `diag.DiagnosticInbox`: posts one diagnostic artifact (a bundle zip or a text report) to an
HTTPS inbox the driver runs at home, as one JSON object shared by every app. No redirect
followed; an address with credentials or a query is refused; 1 MiB cap.
### Changed
- Hard acceleration and hard braking start at 2.5 m/s², up from 1.5. On the car, ordinary town
drives counted 7 to 16 hard stops every 10 km, and regeneration on High alone comes close to
the old line. Trips recorded before keep the counts they were given.
- `EnergySnapshot.climate.passengerTargetCelsius` is null when the car publishes no passenger
setpoint, instead of a copy of the driver's. A screen that shows one zone for both seats says so.
### Fixed
- Screen brightness rounds both ways: 10 % was written as native 25 and read back as 9 %.
- A trip that started on the head unit's factory clock is dated from the corrected one.
- `RuntimeInterfaceProbe` records ABSENT, not DENIED, when the hub hands over another interface:
ICarPowerService and IVehiclePropertyService answer as IVehicleSettingService on SWI68.
- The charge diagnostic line shows `pack_delta_kwh=unavailable` for an unwatched charge.
- The ACC/TJA and speed-limiter getters log the value they read.
## [1.18.0] - 2026-10-16
### Added
- `EnergySnapshot`, `TripSample` and `EnergyTripSummary` carry the raw drive mode and regen
codes (`getDriveModeRaw`, `getRegenLevelRaw`; 6 is one-pedal on SWI68). Raw, so an unknown code
stays unknown instead of reading NORMAL or MEDIUM; `DriveMode.ofRaw` and `RegenLevel.ofRaw`
decode exactly.
- `EnergyTripSummary` counts driving style at the live 1 Hz rate: moving and hard time, hard
accelerations and hard brakings as episodes. The 5 s track had smoothed short hard stops away.
- `EcoScore`: a trip out of 100 from anticipation (hard brakings per 10 km), smoothness (hard
share) and efficiency (charge against the driver's own expectation). Unmeasured parts drop out.
### Changed
- The eco verdict's TYPICAL zone is one residual either side of the expectation, half the
prediction band. At the full 1.96 band almost every drive read TYPICAL.
### Fixed
- `TripDetector` ends an automatic trip two seconds after the car reports P or a plugged cable,
instead of after the two-minute standstill. The SWI68 head unit powers off about twelve
seconds after P, so an automatic trip died with the process before the timer ran out.
- `SocConsumptionFitter` falls back to two coefficients when the thermal term comes out
negative, instead of refusing the fit. The 2026-10-16 bundle refused 87 segments for this
reason alone.
- `CarPropertyEvidence` no longer lists SWI68 battery power as never published: the vendor pack
fallback reads −32,9 to +32,4 kW on a town drive. Screens now say "not validated yet" instead
of "this vehicle never publishes battery power".
## [1.17.0] - 2026-10-14
### Changed
- `SET_CHARGE_LIMIT` is `writeProven = false`: SWI68 reads the limit back as `5`, a code, while
the write sends a percent. Hidden from editors and refused by executors until one write is
seen to set the limit on the car's screen.
### Added
- Probe-only vendor charge readings for the telemetry evidence: `BMS_CHRG_STS`, the AC and DC
plug states and the kWh-since-plug-in counter, ids from MG4Control's SWI133 readings and
unproven on SWI68. Recorded as `candidate.vendor*`; nothing reads them as values yet.
- `RuntimeInterfaceProbe.componentMap()`, part of `survey()`: every service the
`com.saicmotor.*` packages declare, with its exported flag and permission, from
PackageManager metadata only. Nothing is bound or called; capped at 64 packages and
40 services per package, with the cut recorded.
- `windows.widestPercent` in the telemetry evidence, from `EnergySnapshot.widestWindowPercent`.
- `SocConsumptionFitter.describe()` ends with the solve's outcome once both gates pass:
`fit=ready rmse=…` or `fit=refused(<guard>)`. The existing prefix is unchanged.
### Fixed
- **`SaicCharging.chargeLimitPercent()` answers null outside 40–100 %.** SWI68 answered `5`,
a code rather than a percent; callers now take their "limit unreadable" path. The setter
is unchanged.
- **The battery ledger ignores readings taken on the factory clock.** A head unit woken by a
night charge runs on its late-2018 clock until it syncs; one such entry sorted first in the
ledger and split the charge. `BatteryLedgerRecorder` now drops samples before 2025-01-01.
## [1.16.0] - 2026-10-11
### Added
- `EVHardware.probeFrontDoors()`: front-door evidence on firmwares the door feature does not
claim. Connects and subscribes to DLOCK_DOOR_OPEN_STS whatever `hasDoorVolumeFeature()` says
(MG4Control reads it through the change subscription on SWI68/69/131/165) and reports the
managers, the subscriptions, the on-demand read and the last event per front door.
`frontDoorOpenOrNull()` stays gated until a car confirms.
- `SaicClimate.passengerTempState()`: the passenger target as the service answers it, -1
included, for the diagnostic.
### Fixed
- `SET_PASSENGER_TEMP` no longer claims SWI68: the passenger target reads -1 there, so nothing
shows the write lands.
## [1.15.1] - 2026-10-11
### Fixed
- **`PASSENGER_TEMP` and `DOORS_LOCKED` no longer claim SWI68.** Both were declared from the
getters the SAIC services expose, never from a read on the car, and a SWI68 MG4 reported
both `NOT_READABLE` (2026-10-11). The passenger target reads a zone a single-zone car does not
have; the service answers -1. `getDoorLock` reads back the lock *request* property, and the
car answered 0, which is neither of the two named values. The editor now hides both there.
SWI165 keeps them until a car says otherwise.
## [1.15.0] - 2026-10-08
### Added
- **Exterior-light conditions and standstill actions (CR-046).** Conditions `HEADLIGHTS`
(off / on / daytime running), `HIGH_BEAM_ON` and `FOG_LIGHTS_ON`, plus gated actions
`SET_HEADLIGHTS` (ON / AUTO), `SET_HIGH_BEAM` (OFF / AUTO) and `SET_FOG_LIGHTS`. They use the
standard AAOS `HEADLIGHTS` / `HIGH_BEAM_LIGHTS` / `FOG_LIGHTS` `_STATE` and `_SWITCH` ids.
Writes happen only at 0 km/h. A rule can never switch the low beams OFF, and can never force
the high beam ON (`ExteriorLights`). **Proven on no generation yet**: `ExteriorLights.PROVEN`
and `@SupportedOn` are empty and `writeProven` is false, so the entries stay hidden and inert
until a capture proves a generation.
- **Read-only vendor-surface tools (CR-044, CR-045)** for an app's unstable capture.
`VendorSurfaceProbe.socSourcesReport()` reads every candidate source of state of charge side by
side. `VendorSurfaceProbe.capture()` together with `VendorSurfaceRules.diff()` reads every
`CarPropertyManager` property and every value getter of the vendor setting objects, before and
after a setting is changed on the car's own screen, and lists what changed, naming the light
properties. No setter is called: getter selection is pinned by `VendorSurfaceRulesTest`.
### Changed
- **`EcoDrivingMonitor.harshSharePercent` counts hard braking as well as hard acceleration.**
The live steadiness share read only positive steps past `HARSH_ACCELERATION_MS2`, while
EVChargePilot's trip review (`DrivingStyle`) has always counted both signs at the same threshold,
so the live gauge read lower than the summary of the same drive — roughly half in stop-and-go.
Thresholds are unchanged.
- **The trip history keeps four times as many tracks.** `EnergyTripHistoryStore`'s default
`maxBytes` goes from 512 KiB to 2 MiB. At about 145 KB per hour of driving, 512 KiB held roughly
fourteen short trips' tracks, so each new drive evicted older tracks and the consumption model's
segment count fell as trips were added (51 to 47 on SWI68, 2026-09-30). One long trip's
4 096-sample track alone did not fit. Every reader parses the file off the main thread and the
write stays atomic, so the cost is parse time and memory per read, not a new failure mode.
### Added
- **CUSTOM drive mode settings (CR-040).** `CustomDrive` reads and writes powertrain response,
steering weight and brake-pedal feel by index (0 Eco/Comfort, 1 Normal, 2 Sport) on the VSM
generations: `…Level` methods on SWI68/SWI165, `…Mode` methods on SWI69/SWI131/SWI132. The
index↔code tables live here only — the pedal's Normal is `0`, the A9 powertrain uses the
drive-mode scale. Writes are standstill-gated. SWI133 answers unavailable: its route is a raw
property id this project has not verified. Codes beyond RI-006 are leads until a car confirms.
- **`DrivingProfile` carries the CUSTOM settings and an optional `ProfileClimate` (CR-041).**
All new fields are nullable and default to null, so every saved profile and backup reads as
"not configured" and writes nothing new.
### Fixed
- **An overnight charge the head unit slept through is a charge.** `ChargeEnergy.plugged` required
the odometer to stand still between the two ledger entries around a rise; when the drive home
was never recorded either, the evening entry predates it, the odometer "moved", and a night's
fifty points were filed as regeneration and hidden from the battery page. A rise is now a charge
when the odometer stood still, **or** the car reported a non-zero charging status, **or** it is
at least 10 points and nobody watched the car move during it.
- **Each charge carries its curve.** `ChargeEnergy.steps` is pack power per watched ledger step
(one step per point of charge), with `peakPowerKw` and the outside temperature range;
`ChargeEnergyReport.powerBySocBand()` aggregates the steps of every charge into 10-point bands,
which is where the taper shows. Measured only; an unwatched charge has no steps. Both reach
`describe()`.
- **A calm drive is measured, not waiting.** `EcoDrivingMonitor.harshSharePercent()` returns the
share of hard acceleration once the window holds a minute of movement, below the advice
threshold included. `steadiness()` still answers only above it; callers can now tell "not
measured yet" from "measured, and calm".
- **The power model trains on town driving.** `EnergyModelTrainer` fitted every 5 s sample,
so an acceleration costing several times a cruise counted as model error and stop-and-go
history never passed the residual gate. It now fits one point per kilometre of driving, a
point per charge made rather than per instant, and `sampleCount` counts those kilometres
(twelve at least). A history without 5 °C of temperature spread fits speed alone, with no
weather term, instead of refusing.
- **The charge model trains on short drives.** `SocConsumptionFitter` waited for 2 % of charge
before cutting a segment; SWI68 publishes tenths of a percent, so every drive under about
ten kilometres gave nothing. The drop needed is now two steps of the track's own gauge,
capped at 2 %, and a stretch shorter than 2 km keeps accumulating instead of being dropped.
- **Hard keys are decoded per broadcast.** `com.saic.keyevent.hardkey.report` carries Android
key codes and `com.android.systemui.ACTION_HARD_KEY_EVENT` carries SAIC's own numbering, and
the same press arrives on both. Read as one space, SystemUI's next-track (5) decoded as the
report's phone (5), and a phone press (5 and 16) read as a double. `accept()` now takes a
`Source`; SystemUI contributes only the cluster pad (6–10), which the report does not carry.
A key neither table names is reported with an id of its own (`unknownKeyId`), so it can still
be bound. `Event` carries `keyId`; `Button.id` keeps the numbers rules already store.
- **Eco-driving trip replay now accepts persisted sample cadence.** Live monitoring still rejects
gaps over five seconds, while replay allows the expected delay between five-second track samples
and the next telemetry frame. Stored trips can therefore report measured steadiness again.
### Added
- **What a drive asked of the pack at the moments it minds most (CP-090).** `DrivingStress.of`
measures, per trip, the minutes of hard discharge (above 60 % of the trip's own peak) below
20 % charge or below 0 °C outside, and the energy regenerated below 0 °C. The pack power sign
is unvalidated (RI-002), so each trip orients itself from its falling charge; a trip that
cannot, or with more than a tenth of its time undecidable, gives null rather than zero.
`DrivingStress.monthly` sums the trips that could say, per local month. Measured quantities,
never a wear figure.
- **How long the rest of a charge takes (CP-089).** `ChargeDuration.estimate` sums, band by
10-point band, the minutes per point the driver's own watched charges took: the median, and the
fastest and slowest charge as the range. Charges at a similar outside temperature (±8 °C) are
tried first, all charges after. A band on the path that no watched charge crossed is refused
(`MISSING_BAND`), never extrapolated. `lastStretchMinutes` is the part above 80 %.
`ChargeDuration.curveCheck` compares one finished band with the same band in at least three
past charges; `slower` only when it took longer than the slowest of them.
- **A daily battery digest (CP-087).** `BatteryDigest` copies one day of the health, calibration,
exposure and charge analyses; `BatteryDigestStore` keeps a year of them, one per local day, with
the same atomic write and quarantine as the ledger. `BatteryDigest.changes` reports only what
moved past its own band from one day to the next — health beyond its uncertainty, a band that
narrowed by at least a point (or a first estimate), a calibration verdict that changed — so a
health figure wandering inside its band is never news.
- **Open windows are a consumption factor, like the climate (CP-086).** `EnergySnapshot` and
`TripSample` carry `widestWindowPercent`, read from `SaicVehicleControl.widestWindowPercent()`;
null where unread, never 0, and absent from older stored trips. The attribution gains
`ResidualContext.WINDOWS_OPEN`: an interval with a window open at either end gets its own
residual instead of joining a climate group, so the cabin share no longer absorbs the drag.
- **`EcoDriving`: the band, the levers and the refusals behind the eco coach.** `EcoDrivingMonitor`
keeps a bounded window of the speed channel and answers one question — is this drive cheaper or
dearer than what this driver's own fit expects at this mean speed and outside temperature —
against the fit's own residual rather than a threshold invented for the purpose. It offers at
most one lever: a cruising speed quoted from `SocConsumptionModel.predict`, or the share of
moving time spent accelerating hard, chosen by precedence because the two have no common unit.
Every silence carries its `UnavailableReason`: no fit, a speed outside the fitted envelope, a
distance recorded under the superseded speed conversion, too little distance, too little charge
drop, no outside temperature. `forReplay()` gives the same class an unbounded window, so a
finished track is judged whole rather than on its last three minutes.
### Added
- **The charge side of the battery ledger, which nothing read.** Phase 5 measures the pack from
its discharges alone: a rise in charge is only a boundary marker, so the energy going back in
was being recorded and never looked at — and the two facts RI-002 has been blocked on since
2026-09-13 live in exactly that gap. `ChargeEnergyAnalyzer` reads it, taking `BatteryExposure`'s
sessions rather than cutting its own so the two can never disagree about what a charge is. Per
charge: energy and mean power from the pack-pair integral, which way that integral moved
(`ENERGY_FELL` is `EnergySnapshot.batteryPowerKw`'s declared convention holding, `ENERGY_ROSE`
is it inverted, `CONTRADICTORY` when two charges disagree — never averaged), what the car's own
counters did (`RESET` is what "since the last charge" predicts, and one observed reset outranks
a charge that ended before the car clears them), and the odometer, because a long descent raises
the charge too and only a stationary session is a plug. A charge the app slept through keeps its
points and loses its power: the recorder earns an entry every quarter of an hour, so a longer
silence is this app having been down, and the integral does not move while nobody is
integrating. `describe()` puts the verdicts in a diagnostic bundle. Nothing is corrected and
nothing new is sampled — no new property, no new transaction, no new permission (CP-073).
- **A probe that says when it was never able to ask.** `RuntimeInterfaceProbe.adapterClientMap()`
swept nineteen service codes and recorded ABSENT for every one of them on a firmware where
`sCarAdapter` is never assigned — nineteen answers to a question that never reached a binder,
+34 -29
View File
@@ -5,13 +5,14 @@
[![Tests](https://github.com/malys/EVHardware/actions/workflows/tests.yml/badge.svg)](https://github.com/malys/EVHardware/actions/workflows/tests.yml)
[![Security](https://github.com/malys/EVHardware/actions/workflows/security.yml/badge.svg)](https://github.com/malys/EVHardware/actions/workflows/security.yml)
[![Publish](https://github.com/malys/EVHardware/actions/workflows/publish.yml/badge.svg)](https://github.com/malys/EVHardware/actions/workflows/publish.yml)
[![Release](https://img.shields.io/github/v/release/malys/EVHardware?include_prereleases&amp;sort=semver)](https://github.com/malys/EVHardware/releases)
[![Release](https://img.shields.io/github/v/release/malys/EVHardware?include_prereleases&sort=semver)](https://github.com/malys/EVHardware/releases)
[![License: PolyForm Noncommercial](https://img.shields.io/badge/license-PolyForm%20Noncommercial-blue.svg)](LICENSE)
[![Part of EVSuite](https://img.shields.io/badge/part%20of-EVSuite-2f81f7)](https://malys.github.io/EVSuite/)
> ⚠️ **This library reads and writes a car's settings.** It runs inside apps installed on
> an MG4 head unit. Read [DISCLAIMER.md](DISCLAIMER.md) before depending on it.
> MG and MG4 are third-party marks used only to identify compatibility; this project is
> independent and is not approved by SAIC Motor or MG Motor.
> MG and MG4 are third-party marks used only to identify compatibility; this independent
> project is not affiliated with or approved by SAIC Motor or MG Motor.
Shared vehicle-access layer for the SAIC MG4 (Android Automotive OS 9, MT2712). One
implementation of the reflection-based hardware layer, the 0 km/h safety gate, the driving
@@ -22,14 +23,34 @@ The design line: **the apps are thin. EVHardware provides the vehicle.** EVTaske
engine between shared conditions and actions; EVChargePilot and EVABRPUploader consume the
same energy snapshot instead of carrying their own property ids, units and fallback order.
Consumers: EVProfile (drive-profile manager; owns the signature-protected TaskerBridge), EVTasker, EVABRPUploader
and EVChargePilot. Each app consumes EVHardware as a git submodule (`EVHardware/lib` as the
`:evhardware` subproject). Common toolchain: **AGP 9.1.1 / Gradle 9.3.1 / compileSdk 36 / JDK 17**.
## Part of EVSuite
EVHardware is the shared vehicle library of [**EVSuite**](https://malys.github.io/EVSuite/), a family of independent,
offline-first apps for the MG4 head unit (Android Automotive OS 9). Each app installs on its
own — pick only what you need. User guides and install instructions:
<https://malys.github.io/EVSuite/>.
Discover the rest of the suite:
[![EVProfile](https://img.shields.io/badge/EVProfile-settings%20%26%20drive%20profiles-2f81f7?logo=github)](https://github.com/malys/EVProfile)
[![EVTasker](https://img.shields.io/badge/EVTasker-rule%20automation-2f81f7?logo=github)](https://github.com/malys/EVTasker)
[![EVABRPUploader](https://img.shields.io/badge/EVABRPUploader-ABRP%20telemetry-2f81f7?logo=github)](https://github.com/malys/EVABRPUploader)
[![EVChargePilot](https://img.shields.io/badge/EVChargePilot-energy%20%26%20trips-2f81f7?logo=github)](https://github.com/malys/EVChargePilot)
[![EVLauncher](https://img.shields.io/badge/EVLauncher-home%20launcher-2f81f7?logo=github)](https://github.com/malys/EVLauncher)
[![EVSwipe](https://img.shields.io/badge/EVSwipe-swipe%20shortcuts-2f81f7?logo=github)](https://github.com/malys/EVSwipe)
---
## Contents
- [Part of EVSuite](#part-of-evsuite)
- [Overview](#overview)
- [How it works](#how-it-works)
- [Install](#install)
- [The EVSuite](#the-evsuite)
- [Building](#building)
- [Project documents](#project-documents)
- [Security](#security)
@@ -114,9 +135,16 @@ permissions are `signature|privileged`); reads of standard AAOS properties work
The library itself declares no permissions and no `sharedUserId` — that is the consuming
app's decision.
---
## Install
### External projects: consume the AAR
EVHardware is a library, not an APK: it ships inside the consumer apps (EVProfile, EVTasker, EVABRPUploader,
EVChargePilot). Those apps are sideloaded on the head unit via the keyboard route: open a
text field, long-press `,` on the on-screen keyboard → **Language settings** → search
`backup` then press back to reach Android Settings → enable **Developer options** +
**Install unknown apps** → search `storage` and open the APK. See each app's README for
the full steps.
### Consume the AAR
Projects outside this org can depend on EVHardware as a binary instead of a submodule. A
version tag publishes the AAR to GitHub Packages (`com.evsuite:evhardware:<version>`), and
@@ -133,29 +161,6 @@ dependencies { implementation("com.evsuite:evhardware:0.1.0-SNAPSHOT") }
The AAR carries `consumer-rules.pro`, so a consumer's R8 keeps the reflected names. Vehicle
writes still require the consuming app to be signed with the ROM platform key.
## Install
EVHardware is a library — it ships inside the consumer apps (EVProfile, EVTasker), not
as its own APK. Those apps are sideloaded on the head unit via the keyboard route: open a
text field, long-press `,` on the on-screen keyboard → **Language settings** → search
`backup` then press back to reach Android Settings → enable **Developer options** +
**Install unknown apps** → search `storage` and open the APK. See each app's README for
the full steps.
## The EVSuite
Part of a small set of projects for the SAIC MG4 (AAOS 9, MT2712), all sharing the
**EVHardware** vehicle layer:
| Project | Role |
|---|---|
| [EVHardware](https://github.com/malys/EVHardware) | Shared vehicle-access layer: reflection hardware layer, 0 km/h safety gate, driving models, condition/action catalogue + firmware matrix |
| [EVProfile](https://github.com/malys/EVProfile) | Drive-profile manager; applies settings at startup; owns the signature-protected TaskerBridge |
| [EVTasker](https://github.com/malys/EVTasker) | Rule engine — *when* conditions *then* actions — driving the car through EVProfile |
| [EVABRPUploader](https://github.com/malys/EVABRPUploader) | Live telemetry uploader to A Better Route Planner |
| [EVChargePilot](https://github.com/malys/EVChargePilot) | Offline live energy dashboard and local trip analyser |
Common toolchain: **AGP 9.1.1 / Gradle 9.3.1 / compileSdk 36 / JDK 17**. Each app consumes
EVHardware as a git submodule (`EVHardware/lib` as the `:evhardware` subproject).
## Building
```bash
./gradlew :lib:testDebugUnitTest # unit tests + regenerates docs/firmware-matrix.md
+20 -4
View File
@@ -24,7 +24,7 @@ Every condition a rule can test and every action it can run, straight out of `Co
| Call in progress | `IN_CALL` | on / off | any |
| Drive duration | `DRIVE_DURATION` | 0…600 min (=, <, >) | any |
| Random chance | `RANDOM_CHANCE` | 1…100 % | any |
| Doors locked | `DOORS_LOCKED` | on / off | SWI68, SWI165 |
| Doors locked | `DOORS_LOCKED` | on / off | SWI165 |
| Front door open | `FRONT_DOOR_OPEN` | on / off | SWI133 |
| Data used today | `DATA_USED_TODAY` | 0…100000 MB (=, <, >) | any |
| Data used this month | `DATA_USED_MONTH` | 0…100000 MB (=, <, >) | any |
@@ -72,7 +72,7 @@ Every condition a rule can test and every action it can run, straight out of `Co
| Air recirculation on | `RECIRC` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Fan speed | `FAN_SPEED` | 0…11 (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Set temperature | `TEMPERATURE_SET` | 17…33 °C (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Passenger temperature | `PASSENGER_TEMP` | 17…33 °C (=, <, >) | SWI68, SWI165 |
| Passenger temperature | `PASSENGER_TEMP` | 17…33 °C (=, <, >) | SWI165 |
| ECON mode on | `ECON_MODE` | on / off | SWI68, SWI165 |
| Windscreen defroster on | `FRONT_DEFROST` | on / off | SWI68, SWI165 |
| Rear window defroster on | `REAR_DEFROST` | on / off | SWI68, SWI165 |
@@ -116,6 +116,14 @@ Every condition a rule can test and every action it can run, straight out of `Co
| Speed limit tone | `SPEED_LIMIT_TONE` | on / off | SWI133, SWI132 |
| ADAS sound warning | `SOUND_WARNING` | on / off | SWI68, SWI165, SWI69, SWI131, SWI132 |
### Lights
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Low beams | `HEADLIGHTS` | Off, On, Daytime running | none |
| High beam on | `HIGH_BEAM_ON` | on / off | none |
| Fog lights on | `FOG_LIGHTS_ON` | on / off | none |
A reading the car does not answer makes the rule **not evaluable**: it does not fire, and it does not count as false.
## Actions
@@ -151,7 +159,7 @@ A reading the car does not answer makes the rule **not evaluable**: it does not
|---|---|---|:--:|---|
| Climate on/off | `SET_CLIMATE_POWER` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Cabin temperature | `SET_CABIN_TEMP` | 17…33 °C | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Passenger temperature | `SET_PASSENGER_TEMP` | 17…33 °C | — | SWI68, SWI165 |
| Passenger temperature | `SET_PASSENGER_TEMP` | 17…33 °C | — | SWI165 |
| Air conditioning | `SET_AC` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
| ECON mode | `SET_ECON` | on / off | — | SWI68, SWI165 |
| Climate AUTO | `SET_CLIMATE_AUTO` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
@@ -170,7 +178,7 @@ A reading the car does not answer makes the rule **not evaluable**: it does not
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| Charge limit | `SET_CHARGE_LIMIT` | 40…100 % | — | SWI68, SWI165 |
| Charge limit ⚠️ | `SET_CHARGE_LIMIT` | 40…100 % | — | SWI68, SWI165 |
| Allow charging | `SET_CHARGING_ENABLED` | on / off | — | SWI68, SWI165 |
| Scheduled charging | `SET_CHARGE_SCHEDULE` | on / off | — | SWI68, SWI165 |
| Charging window | `SET_CHARGE_WINDOW` | a start and an end time | — | SWI68, SWI165 |
@@ -217,6 +225,14 @@ A reading the car does not answer makes the rule **not evaluable**: it does not
| Lane departure sound warning | `SET_LAS_WARNING_SOUND` | on / off | — | SWI132 |
| Lane departure steering vibration | `SET_LAS_WARNING_VIBRATION` | on / off | — | SWI132 |
### Lights
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| Low beams ⚠️ | `SET_HEADLIGHTS` | On, Automatic | yes | none |
| High beam ⚠️ | `SET_HIGH_BEAM` | Off, Automatic | yes | none |
| Fog lights ⚠️ | `SET_FOG_LIGHTS` | on / off | yes | none |
### System
| Action | Entry | Asks for | When stopped only | Firmware |
+10 -4
View File
@@ -45,7 +45,7 @@
| RECIRC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| FAN_SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| TEMPERATURE_SET | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| PASSENGER_TEMP | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| ECON_MODE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| FRONT_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| REAR_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
@@ -55,12 +55,15 @@
| WINDOW_PASSENGER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| WINDOW_REAR_LEFT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| WINDOW_REAR_RIGHT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| DOORS_LOCKED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| DOORS_LOCKED | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| FRONT_DOOR_OPEN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| HEADLIGHTS | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| HIGH_BEAM_ON | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| FOG_LIGHTS_ON | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| RADIO_PLAYING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -103,9 +106,12 @@
| SET_SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_HEADLIGHTS ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| SET_HIGH_BEAM ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| SET_FOG_LIGHTS ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| SET_CLIMATE_POWER | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_CABIN_TEMP | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_PASSENGER_TEMP | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| SET_AC | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_ECON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CLIMATE_AUTO | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -119,7 +125,7 @@
| SET_WINDOW_REAR_LEFT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_WINDOW_REAR_RIGHT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_DOOR_LOCK | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_LIMIT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_LIMIT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGING_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_SCHEDULE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_WINDOW | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
@@ -143,12 +143,12 @@ object CarPropertyEvidence {
* between them, zero readings for either signal, and `EV_INSTANTANEOUS_CHARGE_RATE` and
* `EV_BATTERY_AVG_TEMP` answering `no CarPropertyValue` on every property probe.
*
* Consequence worth stating plainly: the CP-030 energy model can never train from battery
* power on this generation, because there is no battery power to integrate. An arrival
* forecast on this car has to come from state of charge per kilometre instead.
* Battery power left this list once the vendor pack voltage × current fallback published:
* −32,9 to +32,4 kW on the urban drive of 2026-10-16, net agreeing with the charge drop.
* The standard property is still silent, but a screen asking "is there battery power"
* now gets "not validated yet", which is the true answer, instead of "never".
*/
private val neverPublishedByFirmware: Map<Signal, Set<FirmwareInfo.Gen>> = mapOf(
Signal.BATTERY_POWER_KW to setOf(FirmwareInfo.Gen.SWI68),
Signal.BATTERY_TEMPERATURE_CELSIUS to setOf(FirmwareInfo.Gen.SWI68),
)
}
@@ -0,0 +1,153 @@
package com.evsuite.hardware
import java.lang.reflect.Method
/**
* The three settings the car exposes only in [com.evsuite.hardware.model.DriveMode.CUSTOM]:
* powertrain response, steering weight and brake-pedal feel (CR-040).
*
* Callers speak in an **index** — 0 Eco/Comfort, 1 Normal, 2 Sport — and never in vehicle
* codes. The codes differ per setting and per platform, and one of them is a trap: the pedal's
* Normal is `0`, not the middle value. The conversion lives in [code] / [index] and nowhere else.
*
* **Per generation.** VSM-based firmwares only, through the same VSM instance [VsmGlass] uses:
*
* • [Family.VSM] — SWI68 / SWI165, `VehicleSettingManager` `…Level` methods
* • [Family.A9] — SWI69 / SWI131 / SWI132, `CarVehicleSettingClient` `…Mode` methods
*
* SWI133 and unknown firmware answer [isAvailable] false. Their route is a raw property id, and
* no id for these settings is verified on this project — writing an unverified id is exactly
* what the library refuses to do. A method name is different in kind: if it is wrong it does
* not resolve, and the setting reports unavailable instead of writing somewhere else.
*
* **Evidence.** The VSM read/write codes (powertrain and steering 1/2/3, pedal 0/1/2) agree
* with RI-006. Which pedal code is Comfort and Sport, and the A9 powertrain offset (2/3/4,
* the drive-mode scale), are community leads (MG4Control 2.6.7, behaviour reference only) and
* stay unproven until an on-vehicle read-after-write confirms them per generation.
*/
object CustomDrive {
enum class Setting { POWER, STEERING, PEDAL }
enum class Family { VSM, A9 }
/** Positions a caller may ask for. */
val INDICES = 0..2
/** Pedal codes by index: Comfort 1, Normal **0**, Sport 2. */
private val PEDAL_CODES = intArrayOf(1, 0, 2)
fun familyOf(gen: FirmwareInfo.Gen): Family? = when (gen) {
FirmwareInfo.Gen.SWI68, FirmwareInfo.Gen.SWI165 -> Family.VSM
FirmwareInfo.Gen.SWI69, FirmwareInfo.Gen.SWI131, FirmwareInfo.Gen.SWI132 -> Family.A9
else -> null
}
/** Vehicle code for [index], or null when [index] is not a position. */
fun code(setting: Setting, index: Int, family: Family): Int? {
if (index !in INDICES) return null
return when (setting) {
Setting.POWER -> index + if (family == Family.A9) 2 else 1
Setting.STEERING -> index + 1
Setting.PEDAL -> PEDAL_CODES[index]
}
}
/** Index for a code read from the car, or null when the code is no known position. */
fun index(setting: Setting, code: Int, family: Family): Int? =
INDICES.firstOrNull { code(setting, it, family) == code }
fun methodName(setting: Setting, family: Family, write: Boolean): String {
val stem = when (family) {
Family.VSM -> when (setting) {
Setting.POWER -> "ElectricPowertrainLevel"
Setting.STEERING -> "SteeringLevel"
Setting.PEDAL -> "BrakePedalLevel"
}
Family.A9 -> when (setting) {
Setting.POWER -> "DrivingPowerTrainMode"
Setting.STEERING -> "SteeringMode"
Setting.PEDAL -> "BrakePedalMode"
}
}
return (if (write) "set" else "get") + stem
}
private val INT = Int::class.javaPrimitiveType!!
// Resolved once per VSM class, like VsmGlass: a profile apply reads and writes all three.
private var owner: Class<*>? = null
private val methods = HashMap<String, Method?>()
@Synchronized
private fun method(vsm: Any, name: String, vararg types: Class<*>): Method? {
if (owner !== vsm.javaClass) {
owner = vsm.javaClass
methods.clear()
}
return methods.getOrPut(name) {
try {
vsm.javaClass.getMethod(name, *types)
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name absent — ${e.javaClass.simpleName}")
null
}
}
}
private fun family(): Family? = familyOf(FirmwareInfo.getGeneration())
/** True when this firmware's VSM carries the setter for [setting]. */
fun isAvailable(setting: Setting): Boolean {
val family = family() ?: return false
val vsm = EVHardware.vsmInstance() ?: return false
return method(vsm, methodName(setting, family, write = true), INT) != null
}
/**
* The car's current position for [setting], or null when it cannot tell.
*
* Null covers an absent method, a failed call, and a code outside the scale — the last one
* is logged raw, because it is the evidence a wrong scale would leave behind.
*/
fun read(setting: Setting): Int? {
val family = family() ?: return null
val vsm = EVHardware.vsmInstance() ?: return null
val name = methodName(setting, family, write = false)
val get = method(vsm, name) ?: return null
val raw = try {
(get.invoke(vsm) as? Number)?.toInt()
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name() exc: ${e.message}")
null
} ?: return null
return index(setting, raw, family).also {
if (it == null && raw >= 0) AppLogger.w(TAG, " $name() = $raw — outside the $family scale")
}
}
/**
* Sets [setting] to position [index]. The car obeys only while it is in CUSTOM; the caller
* decides when that is (EVProfile shows the controls only then).
*/
@RequiresStandstill
fun write(setting: Setting, index: Int): Boolean {
val family = family() ?: return false
val value = code(setting, index, family) ?: return false
val vsm = EVHardware.vsmInstance() ?: return false
val name = methodName(setting, family, write = true)
val set = method(vsm, name, INT) ?: return false
// [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable.
if (!VehicleWriteGate.allow("VSM $name")) return false
return try {
set.invoke(vsm, value)
AppLogger.i(TAG, " $name($value) ← index $index ($family)")
true
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name($value) exc: ${e.message}")
false
}
}
private const val TAG = "EV_CUSTOM"
}
@@ -335,18 +335,18 @@ object EVHardware {
}
/**
* État d'ignition courant, en valeurs [IgnitionState] (AAOS).
* Current ignition state, as [IgnitionState] (AAOS) values.
*
* CarPropertyManager d'abord. Mais SWI68 n'expose pas IGNITION_STATE via CPM : la
* property revient vide, et l'état d'allumage passait pour illisible alors que Katman5
* le rapporte correctement — le log le montre (`Katman5: state initial = 2 (RUN/READY)`)
* pendant que le diagnostic classait IGNITION_STATE en NOT_READABLE.
* CarPropertyManager first. But SWI68 does not expose IGNITION_STATE through CPM: the
* property comes back empty, and the ignition state looked unreadable although Katman5
* reports it correctly — the log shows it (`Katman5: state initial = 2 (RUN/READY)`) while
* the diagnostics classed IGNITION_STATE as NOT_READABLE.
*
* D'où le repli sur le dernier état livré par IVehicleConditionService, converti :
* Katman5 parle en [CarIgnitionItem], cette fonction rend du [IgnitionState], et les
* deux échelles ne coïncident pas (RUN vaut 2 chez l'un, OFF vaut 2 chez l'autre).
* Hence the fallback to the last state delivered by IVehicleConditionService, converted:
* Katman5 speaks [CarIgnitionItem], this function returns [IgnitionState], and the two
* scales do not line up (RUN is 2 in one, OFF is 2 in the other).
*
* Returns -1 si rien n'est lisible, 0 si la property existe mais ne dit rien.
* Returns -1 if nothing is readable, 0 if the property exists but says nothing.
*/
fun getCurrentIgnitionState(): Int {
val v0 = getIntPropertyCPM(PROP_IGNITION_STATE, 0)
@@ -356,7 +356,7 @@ object EVHardware {
return vcmIgnitionAsAaos() ?: global
}
/** Le dernier état Katman5 traduit en [IgnitionState], ou null s'il n'y en a pas eu. */
/** The last Katman5 state translated into [IgnitionState], or null if there was none. */
private fun vcmIgnitionAsAaos(): Int? = when (sLastVcmIgnitionState) {
CarIgnitionItem.OFF -> IgnitionState.OFF
CarIgnitionItem.ACCESSORY -> IgnitionState.ACC
@@ -418,12 +418,15 @@ object EVHardware {
/** Total mileage, the vendor's own integer — `PERF_ODOMETER` never publishes here. */
private const val PROP_VENDOR_TOTAL_MILEAGE = 0x21401566
/**
* Charging session state. The vehicle's own values, from the stock charging screen:
* 1 = AC charging, 10 = DC charging, 5 = connecting, 7 = connected but not charging,
* 8 = stopped. Anything else is not a session.
*/
private const val PROP_VENDOR_CHARGE_STATUS = 0x2140F409
// Ids from MG4Control's SWI133 readings; unproven on SWI68. The plug and energy ids are
// probe-only. PROP_BMS_CHRG_STS is also read by getVendorChargeStatus() — the charging session
// state, the vehicle's own values from the stock charging screen: 1 = AC charging,
// 10 = DC charging, 5 = connecting, 7 = connected but not charging, 8 = stopped. Anything
// else is not a session.
private const val PROP_BMS_CHRG_STS = 0x2140F409 // vendor int
private const val PROP_CCU_ONBD_PLUG = 0x2140F43E // vendor int, AC socket
private const val PROP_CCU_OFFBD_PLUG = 0x2140F43F // vendor int, DC socket
private const val PROP_ENERGY_SINCE_CHARGE = 0x2160A1BB // vendor float kWh
/**
* `HVAC_AMBIENT_TEMPERATURE`, the one in-car candidate left on this firmware.
@@ -479,6 +482,24 @@ object EVHardware {
?.takeIf { it.isFinite() && it >= 0f }
}
/**
* Probe-only vendor charge properties, read raw. MG4Control (a system-signed app) reads them
* on SWI133: `BMS_CHRG_STS` 1 while charging, the AC and DC plug states 0 off a charge and
* non-zero on the active socket, and a kWh counter that resets at plug-in. Whether this app
* may read them on SWI68, and what they say there, is what the capture is for.
*/
internal fun probeVendorChargeStatus(): Int? = probeVendorInt(PROP_BMS_CHRG_STS)
internal fun probeVendorPlugAc(): Int? = probeVendorInt(PROP_CCU_ONBD_PLUG)
internal fun probeVendorPlugDc(): Int? = probeVendorInt(PROP_CCU_OFFBD_PLUG)
internal fun probeVendorEnergySinceChargeKwh(): Float? = supportedTelemetryRead {
getFloatPropertyCPM(PROP_ENERGY_SINCE_CHARGE, AREA_GLOBAL)?.takeIf { it.isFinite() }
}
/** Null when unreadable, never the -1 [getIntPropertyCPM] stands in for a failure with. */
private fun probeVendorInt(propId: Int): Int? = supportedTelemetryRead {
readPropertyCPM(propId, AREA_GLOBAL, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int
}
fun getStandardRangeKm(): Float? = supportedTelemetryRead {
getFloatPropertyCPM(PROP_RANGE_REMAINING, AREA_GLOBAL)
?.takeIf { it.isFinite() && it >= 0f }
@@ -509,10 +530,10 @@ object EVHardware {
*
* The vendor property, read second: the charging service is the better source when it
* answers, and this is what is left on a unit where it does not.
* See [PROP_VENDOR_CHARGE_STATUS] for the values.
* See [PROP_BMS_CHRG_STS] for the values.
*/
fun getVendorChargeStatus(): Int? =
getIntPropertyCPM(PROP_VENDOR_CHARGE_STATUS, AREA_GLOBAL).takeIf { it >= 0 }
getIntPropertyCPM(PROP_BMS_CHRG_STS, AREA_GLOBAL).takeIf { it >= 0 }
fun getCabinTemperatureCelsius(): Float? = supportedTelemetryRead {
getFloatPropertyCPM(PROP_CABIN_TEMP, AREA_HVAC)
@@ -642,6 +663,15 @@ object EVHardware {
probeProperty("HVAC_AMBIENT_TEMPERATURE", PROP_HVAC_AMBIENT_TEMP_SWI68, AREA_GLOBAL, Float::class.javaObjectType),
)
/**
* The six exterior-light properties (CR-046), read raw whatever [ExteriorLights.PROVEN] says:
* this is the evidence that fills it. EVTasker's Diagnostic shows and exports it — read once
* with a light off and once with it on; a generation is proven when the state follows.
*/
fun probeExteriorLights(): List<PropertyReport> = ExteriorLights.NAMES.map { (id, name) ->
probeProperty(name, id, AREA_GLOBAL, Int::class.javaObjectType)
}
private fun probeProperty(name: String, propId: Int, areaId: Int, boxed: Class<*>): PropertyReport {
val cpm = sCarPropertyManager
?: return PropertyReport(name, propId, areaId, "CarPropertyManager not connected")
@@ -661,6 +691,118 @@ object EVHardware {
}
}
/**
* The vendor setting objects this library already holds, by the name a report prints.
* Probe use only ([com.evsuite.hardware.probe.VendorSurfaceProbe]); no production path
* reads this, and the probe calls nothing on them but value getters.
*/
internal fun vendorSettingHandles(): Map<String, Any> = listOfNotNull(
sVsm?.let { "VehicleSetting" to it }, // SWI68/165 manager, A9 CarVehicleSettingClient
sVsm133?.let { "VehicleSetting133" to it },
sVcontrol?.let { "VehicleControl" to it },
sGeneral?.let { "General" to it },
sCarGeneral?.let { "CarGeneral" to it },
sCarState?.let { "CarState" to it },
).toMap()
/**
* Every property the CarPropertyManager lists, read once per area, keyed `CPM 0x…/area`.
* Probe use only. Stops at [deadlineMs] and says so rather than blocking a diagnostic on a
* VHAL that answers slowly.
*/
internal fun readAllCarProperties(deadlineMs: Long): Map<String, String> {
val cpm = sCarPropertyManager ?: return mapOf("CPM" to "not connected")
cacheCarPropertyMethods(cpm)
val getProperty = sCpmGetPropertyMethod ?: return mapOf("CPM" to "no getProperty")
val configs = try {
cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*>
} catch (e: Exception) {
return mapOf("CPM" to "getPropertyList: ${e.readFailureReason()}")
} ?: return mapOf("CPM" to "getPropertyList: null")
val out = sortedMapOf<String, String>()
for (config in configs) {
if (config == null) continue
if (System.currentTimeMillis() > deadlineMs) {
out["CPM ~"] = "time budget spent after ${out.size} values"
break
}
val type = config.javaClass
val id = runCatching { type.getMethod("getPropertyId").invoke(config) as Int }.getOrNull() ?: continue
val valueType = runCatching { type.getMethod("getPropertyType").invoke(config) as Class<*> }
.getOrNull() ?: Any::class.java
val areas = runCatching { type.getMethod("getAreaIds").invoke(config) as IntArray }
.getOrNull()?.takeIf { it.isNotEmpty() } ?: intArrayOf(AREA_GLOBAL)
for (area in areas) {
val key = "CPM 0x%08X/%d".format(id, area)
out[key] = try {
val holder = getProperty.invoke(cpm, valueType, id, area)
if (holder == null) "no value" else {
val status = (holder.javaClass.getMethod("getStatus").invoke(holder) as? Int)
?.let(CarPropertyEvidence::describe) ?: "no status"
"$status ${com.evsuite.hardware.probe.VendorSurfaceRules.render(
holder.javaClass.getMethod("getValue").invoke(holder))}"
}
} catch (e: Exception) {
e.readFailureReason()
}
}
}
return out
}
// -------------------------------------------------------------------------
// Exterior lights (CR-046) — standard AAOS ids and policy in [ExteriorLights]
// -------------------------------------------------------------------------
/** Status-checked read, only on a proven generation: an unpublished 0 would read as OFF. */
private fun lightRead(propId: Int): Int? =
if (ExteriorLights.isProven(FirmwareInfo.getGeneration()))
readPropertyCPM(propId, AREA_GLOBAL, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int
else null
/** Low beams as VehicleLightState (0 off, 1 on, 2 daytime running); null when unreadable. */
fun headlightsStateOrNull(): Int? = lightRead(ExteriorLights.PROP_HEADLIGHTS_STATE)
fun highBeamOnOrNull(): Boolean? =
ExteriorLights.isOn(lightRead(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_STATE))
fun fogLightsOnOrNull(): Boolean? =
ExteriorLights.isOn(lightRead(ExteriorLights.PROP_FOG_LIGHTS_STATE))
/** Switch positions, for the action editor's current value. */
fun headlightsSwitchOrNull(): Int? = lightRead(ExteriorLights.PROP_HEADLIGHTS_SWITCH)
fun highBeamSwitchOrNull(): Int? = lightRead(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_SWITCH)
fun fogLightsSwitchOnOrNull(): Boolean? =
lightRead(ExteriorLights.PROP_FOG_LIGHTS_SWITCH)?.let { it == ExteriorLights.SWITCH_ON }
/** Low beams ON or AUTOMATIC — never OFF ([ExteriorLights.HEADLIGHT_SWITCH_ALLOWED]). */
@RequiresStandstill
fun setHeadlightsSwitch(mode: Int): Boolean =
lightWrite(ExteriorLights.PROP_HEADLIGHTS_SWITCH, mode, ExteriorLights.HEADLIGHT_SWITCH_ALLOWED)
/** High beam OFF or AUTOMATIC — never forced ON. */
@RequiresStandstill
fun setHighBeamSwitch(mode: Int): Boolean =
lightWrite(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_SWITCH, mode, ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED)
@RequiresStandstill
fun setFogLights(on: Boolean): Boolean = lightWrite(
ExteriorLights.PROP_FOG_LIGHTS_SWITCH,
if (on) ExteriorLights.SWITCH_ON else ExteriorLights.SWITCH_OFF,
ExteriorLights.FOG_SWITCH_ALLOWED,
)
/** Policy, then proven generation, then the 0 km/h gate inside [setIntPropertyCPM]. */
private fun lightWrite(propId: Int, value: Int, allowed: Set<Int>): Boolean {
ExteriorLights.writeRefusal(FirmwareInfo.getGeneration(), allowed, value)?.let {
AppLogger.w(TAG, " light 0x${Integer.toHexString(propId)} refused: $it")
return false
}
return setIntPropertyCPM(propId, AREA_GLOBAL, value)
}
private inline fun <T> supportedTelemetryRead(read: () -> T?): T? =
when (FirmwareInfo.getGeneration()) {
FirmwareInfo.Gen.SWI68,
@@ -1439,7 +1581,7 @@ object EVHardware {
return try {
val native = (g.javaClass.getMethod("getBrightness").invoke(g) as? Int) ?: return -1
if (native < 0) return -1
val pct = (native * 100 / BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
val pct = ((native * 100 + BRIGHTNESS_NATIVE_MAX / 2) / BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
AppLogger.d(TAG, " getBrightness native=$native → $pct%")
pct
} catch (e: Exception) {
@@ -1450,7 +1592,7 @@ object EVHardware {
private fun setBrightnessOldSdk(clampedPct: Int): Boolean {
val g = sGeneral ?: return false
val native = (clampedPct * BRIGHTNESS_NATIVE_MAX / 100).coerceIn(0, BRIGHTNESS_NATIVE_MAX)
val native = ((clampedPct * BRIGHTNESS_NATIVE_MAX + 50) / 100).coerceIn(0, BRIGHTNESS_NATIVE_MAX)
if (logEnabled) AppLogger.i(TAG, "setBrightness → $clampedPct% (native=$native/255)")
return try {
g.javaClass.getMethod("setBrightness", Int::class.javaPrimitiveType).invoke(g, native)
@@ -1477,12 +1619,12 @@ object EVHardware {
AppLogger.w(TAG, " A9 getBrightness Settings.System exc: ${e.message}"); return -1
}
if (native < 0) return -1
return (native * 100 / A9_BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
return ((native * 100 + A9_BRIGHTNESS_NATIVE_MAX / 2) / A9_BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
}
private fun setBrightnessA9(clampedPct: Int): Boolean {
val resolver = sAppContext?.contentResolver ?: return false
val native = (clampedPct.coerceIn(0, 100) * A9_BRIGHTNESS_NATIVE_MAX / 100).coerceIn(1, A9_BRIGHTNESS_NATIVE_MAX)
val native = ((clampedPct.coerceIn(0, 100) * A9_BRIGHTNESS_NATIVE_MAX + 50) / 100).coerceIn(1, A9_BRIGHTNESS_NATIVE_MAX)
return try {
// Manual mode, otherwise auto-brightness overwrites the value at once
android.provider.Settings.System.putInt(resolver,
@@ -2329,23 +2471,22 @@ object EVHardware {
fun getSeatHeatRight(): Int = seatHeatRightOrNull() ?: 0
fun isSteeringHeatOn(): Boolean = steeringHeatOnOrNull() ?: false
fun getDriveMode(): DriveMode? {
val cpm = sCarPropertyManager ?: return null
return try {
val raw = (cpm.javaClass
.getMethod("getIntProperty", Int::class.java, Int::class.java)
.invoke(cpm, PROP_DRIVE_MODE, AREA_GLOBAL) as? Int) ?: return null
DriveMode.fromValue(raw)
} catch (_: Exception) { null }
}
fun getDriveMode(): DriveMode? = getDriveModeRaw()?.let(DriveMode::fromValue)
fun getRegenLevel(): RegenLevel? {
/** The drive mode code as the car sends it; null when it could not be read. */
fun getDriveModeRaw(): Int? = readIntGlobal(PROP_DRIVE_MODE)
fun getRegenLevel(): RegenLevel? = getRegenLevelRaw()?.let(RegenLevel::fromValue)
/** The regen code as the car sends it: 6 is one-pedal on SWI68 (EVTasker CR-045 diff). */
fun getRegenLevelRaw(): Int? = readIntGlobal(PROP_REGEN_LEVEL)
private fun readIntGlobal(prop: Int): Int? {
val cpm = sCarPropertyManager ?: return null
return try {
val raw = (cpm.javaClass
cpm.javaClass
.getMethod("getIntProperty", Int::class.java, Int::class.java)
.invoke(cpm, PROP_REGEN_LEVEL, AREA_GLOBAL) as? Int) ?: return null
RegenLevel.fromValue(raw)
.invoke(cpm, prop, AREA_GLOBAL) as? Int
} catch (_: Exception) { null }
}
@@ -2464,8 +2605,9 @@ object EVHardware {
fun getAccTjaMode(): Int {
val useNewApi = FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132
val method = if (useNewApi) "getAccTjaState" else "getAccTjaMode"
if (logEnabled) AppLogger.i(TAG, "$method →")
return (callVsm(method) as? Int) ?: -1
val value = (callVsm(method) as? Int) ?: -1
if (logEnabled) AppLogger.i(TAG, "$method → $value")
return value
}
/** SWI68/SWI165 : setAccTjaMode(I)V SWI69/SWI131/SWI132 : setAccTjaState(I)V — void */
@@ -2491,8 +2633,9 @@ object EVHardware {
/** Lit le mode du limiteur de speed. Returns 0/2/3, ou -1 si unavailable. */
fun getSpeedLimiterMode(): Int {
val method = if (useSasApi()) "getSasMode" else "getSpeedAsstMode"
if (logEnabled) AppLogger.i(TAG, "$method →")
return (callVsm(method) as? Int) ?: -1
val value = (callVsm(method) as? Int) ?: -1
if (logEnabled) AppLogger.i(TAG, "$method → $value")
return value
}
/** Configure le mode du limiteur de speed. 0=Disabled, 2=Manual, 3=Intelligent. */
@@ -4316,6 +4459,30 @@ object EVHardware {
return if (answered) open else null
}
/**
* Front-door evidence for a firmware the door feature does not claim yet, for EVTasker's
* Diagnostic.
*
* MG4Control reads DLOCK_DOOR_OPEN_STS on SWI68/69/131/165 through the change
* subscription, because the on-demand read throws there. Nothing here has seen a car
* confirm that, so [frontDoorOpenOrNull] stays gated. This connects and subscribes
* regardless of [hasDoorVolumeFeature] — the first call starts the connection, which is
* asynchronous — and reports what came back: export with the front doors shut, open
* one, export again. Proven when the event value follows the door.
*/
fun probeFrontDoors(): String {
connectCarProperty()
registerDoorCallback()
val reads = DOOR_FRONT_AREAS.joinToString(" ") { area ->
"read[0x${area.toString(16)}]=${readDoorOpen(area) ?: "?"}"
}
val events = synchronized(this) {
DOOR_FRONT_AREAS.joinToString(" ") { area -> "event[0x${area.toString(16)}]=${sDoorReadLast[area] ?: "?"}" }
}
return "managers property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null} " +
"subscribed property=$sDoorSubProperty doorlock=$sDoorSubDoorlock $reads $events"
}
/** Diagnostic button probe: logs the volume + door states at click time. */
fun runDoorVolumeDiag() {
AppLogger.i(VOL_TAG, "── DIAG (bouton Diagnostic) ──")
@@ -0,0 +1,79 @@
package com.evsuite.hardware
/**
* Exterior lights (CR-046): the standard AAOS ids, their values, and what a rule may do with them.
*
* Kept free of Android so the policy is pinned by JVM tests. The ids are the public
* `VehicleProperty` ones (types.hal 2.0, available on AAOS 9); nothing here comes from a vendor
* catalogue. Whether the MG4 VHAL publishes them is a per-generation question: a generation joins
* [PROVEN] only with a capture showing the state follow the stalk — EVTasker's Diagnostic
* `EXTERIOR_LIGHTS` row ([EVHardware.probeExteriorLights]), exported off and on — in the same commit as the
* `@SupportedOn` of the light entries and their `writeProven`.
*
* Owner decisions (2026-10-08): writes only at 0 km/h like every vehicle write, and a rule may
* never switch the low beams OFF. Same reasoning for the high beam: a rule may set it OFF or
* AUTOMATIC, never force it ON — that dazzles, and the stalk stays the driver's.
*/
object ExteriorLights {
// VehicleProperty — SYSTEM | GLOBAL | INT32 | 0x0E0x.
const val PROP_HEADLIGHTS_STATE = 0x11400E00
const val PROP_HIGH_BEAM_LIGHTS_STATE = 0x11400E01
const val PROP_FOG_LIGHTS_STATE = 0x11400E02
const val PROP_HEADLIGHTS_SWITCH = 0x11400E10
const val PROP_HIGH_BEAM_LIGHTS_SWITCH = 0x11400E11
const val PROP_FOG_LIGHTS_SWITCH = 0x11400E12
// VehicleLightState
const val STATE_OFF = 0
const val STATE_ON = 1
const val STATE_DAYTIME_RUNNING = 2
// VehicleLightSwitch
const val SWITCH_OFF = 0
const val SWITCH_ON = 1
const val SWITCH_DAYTIME_RUNNING = 2
const val SWITCH_AUTOMATIC = 0x100
/** Generations where the ids above are proven on the car. Empty until a capture says so. */
val PROVEN: Set<FirmwareInfo.Gen> = emptySet()
/** Names for the capture diff, so a changed light property reads as itself. */
val NAMES: Map<Int, String> = mapOf(
PROP_HEADLIGHTS_STATE to "HEADLIGHTS_STATE",
PROP_HIGH_BEAM_LIGHTS_STATE to "HIGH_BEAM_LIGHTS_STATE",
PROP_FOG_LIGHTS_STATE to "FOG_LIGHTS_STATE",
PROP_HEADLIGHTS_SWITCH to "HEADLIGHTS_SWITCH",
PROP_HIGH_BEAM_LIGHTS_SWITCH to "HIGH_BEAM_LIGHTS_SWITCH",
PROP_FOG_LIGHTS_SWITCH to "FOG_LIGHTS_SWITCH",
)
/** Low beams: ON or AUTOMATIC. Never OFF. */
val HEADLIGHT_SWITCH_ALLOWED = setOf(SWITCH_ON, SWITCH_AUTOMATIC)
/** High beam: OFF or AUTOMATIC. Never forced ON. */
val HIGH_BEAM_SWITCH_ALLOWED = setOf(SWITCH_OFF, SWITCH_AUTOMATIC)
/** Fog lights: on or off. */
val FOG_SWITCH_ALLOWED = setOf(SWITCH_OFF, SWITCH_ON)
fun isProven(gen: FirmwareInfo.Gen): Boolean = gen in PROVEN
/**
* Why a light write must not be sent, or null when it may go on to the speed gate.
* Policy first, so a forbidden value is refused on every car, proven or not.
*/
fun writeRefusal(
gen: FirmwareInfo.Gen,
allowed: Set<Int>,
value: Int,
proven: Set<FirmwareInfo.Gen> = PROVEN,
): String? = when {
value !in allowed -> "value 0x${Integer.toHexString(value)} not allowed for a rule"
gen !in proven -> "exterior lights not proven on $gen"
else -> null
}
/** A light state read as "on"; daytime running lights are not headlights on. */
fun isOn(state: Int?): Boolean? = state?.let { it == STATE_ON }
}
@@ -3,20 +3,44 @@ package com.evsuite.hardware
import com.evsuite.hardware.catalog.SnapshotKeys
/**
* Decodes `com.saic.keyevent.hardkey.report` payloads.
* Decodes the head unit's two hard-key broadcasts.
*
* Codes 5/23/24/25/87/88/164/286/287 come from R69 EOL's
* PhysicalKeysBroadcastReceiver; 287 is also named VOICE_KEY_CODE by VrSpeechService.
* Codes 17 and 18 are firmware aliases already observed by EVProfile.
* They number their keys in two different spaces, so a code only means something together
* with the broadcast it came on ([Source]):
*
* - `com.saic.keyevent.hardkey.report` carries Android key codes. 5/23/24/25/87/88/164/286/287
* come from R69 EOL's PhysicalKeysBroadcastReceiver; 287 is also VrSpeechService's
* VOICE_KEY_CODE. 17 and 18 are firmware aliases already observed by EVProfile.
* - `com.android.systemui.ACTION_HARD_KEY_EVENT` carries SAIC's own numbering: 1/2 volume,
* 3 mute, 4/5 previous/next (mediaservice HardKeyMonitor, radioservice ReceiveUtils),
* 6–10 the instrument-cluster pad (engineermodeservice KeyEventReceiver, `_IPK`), 16 phone
* (btcallservice).
*
* The same physical press arrives on both. Read as one space, SystemUI's 5 (next track) was
* decoded as the report's 5 (phone), and a phone press arriving twice read as a double.
* So SystemUI keys the report already carries are dropped, and only the cluster pad — which
* the report does not carry — is read from SystemUI.
*
* A key neither list names is still reported, as an unknown key with its own [Event.keyId],
* so a driver can bind a button this table has not met yet.
*/
class PhysicalButtonEventDecoder {
enum class Button(val codes: Set<Int>, val shortKey: String, val longKey: String) {
PHONE(setOf(5, 16), SnapshotKeys.KEY_PHONE_SHORT, SnapshotKeys.KEY_PHONE_LONG),
UP(setOf(6), SnapshotKeys.KEY_UP_SHORT, SnapshotKeys.KEY_UP_LONG),
DOWN(setOf(7), SnapshotKeys.KEY_DOWN_SHORT, SnapshotKeys.KEY_DOWN_LONG),
OK(setOf(8), SnapshotKeys.KEY_OK_SHORT, SnapshotKeys.KEY_OK_LONG),
LEFT(setOf(9), SnapshotKeys.KEY_LEFT_SHORT, SnapshotKeys.KEY_LEFT_LONG),
RIGHT(setOf(10), SnapshotKeys.KEY_RIGHT_SHORT, SnapshotKeys.KEY_RIGHT_LONG),
enum class Source { HARDKEY_REPORT, SYSTEM_UI }
enum class Button(
/** On the report broadcast. */
val codes: Set<Int>,
val shortKey: String,
val longKey: String,
/** On the SystemUI broadcast, for keys the report does not carry. */
val systemUiCodes: Set<Int> = emptySet(),
) {
PHONE(setOf(5), SnapshotKeys.KEY_PHONE_SHORT, SnapshotKeys.KEY_PHONE_LONG),
UP(emptySet(), SnapshotKeys.KEY_UP_SHORT, SnapshotKeys.KEY_UP_LONG, setOf(6)),
DOWN(emptySet(), SnapshotKeys.KEY_DOWN_SHORT, SnapshotKeys.KEY_DOWN_LONG, setOf(7)),
OK(emptySet(), SnapshotKeys.KEY_OK_SHORT, SnapshotKeys.KEY_OK_LONG, setOf(8)),
LEFT(emptySet(), SnapshotKeys.KEY_LEFT_SHORT, SnapshotKeys.KEY_LEFT_LONG, setOf(9)),
RIGHT(emptySet(), SnapshotKeys.KEY_RIGHT_SHORT, SnapshotKeys.KEY_RIGHT_LONG, setOf(10)),
SOURCE(setOf(110), SnapshotKeys.KEY_SOURCE_SHORT, SnapshotKeys.KEY_SOURCE_LONG),
CENTER(setOf(23), SnapshotKeys.KEY_CENTER_SHORT, SnapshotKeys.KEY_CENTER_LONG),
VOLUME_UP(setOf(24), SnapshotKeys.KEY_VOLUME_UP_SHORT, SnapshotKeys.KEY_VOLUME_UP_LONG),
@@ -26,15 +50,28 @@ class PhysicalButtonEventDecoder {
MUTE(setOf(164), SnapshotKeys.KEY_MUTE_SHORT, SnapshotKeys.KEY_MUTE_LONG),
STAR_LEFT(setOf(17), SnapshotKeys.KEY_STAR_LEFT_SHORT, SnapshotKeys.KEY_STAR_LEFT_LONG),
STAR_RIGHT(setOf(286, 18), SnapshotKeys.KEY_STAR_RIGHT_SHORT, SnapshotKeys.KEY_STAR_RIGHT_LONG),
ASSISTANT(setOf(287), SnapshotKeys.KEY_ASSISTANT_SHORT, SnapshotKeys.KEY_ASSISTANT_LONG)
ASSISTANT(setOf(287), SnapshotKeys.KEY_ASSISTANT_SHORT, SnapshotKeys.KEY_ASSISTANT_LONG);
/** The number a rule stores for this button — the first code, as rules always stored. */
val id: Int get() = (codes + systemUiCodes).first()
companion object {
fun byId(id: Int): Button? = entries.firstOrNull { it.id == id }
}
}
enum class Press { SHORT, LONG, DOUBLE }
data class Event(val button: Button, val press: Press) {
val value: String get() = "${button.name}:${press.name}"
/**
* One decoded press. [keyId] is [Button.id] for a known button, and an [unknownKeyId]
* otherwise.
*/
data class Event(val keyId: Int, val press: Press) {
val button: Button? get() = Button.byId(keyId)
val value: String get() = "${keyName(keyId)}:${press.name}"
fun readings(): Map<String, Any> = mapOf(SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT to value)
}
private enum class State { DOWN, LONG_REPORTED }
private val states = mutableMapOf<Button, State>()
private val states = mutableMapOf<Int, State>()
/**
* How long a second press has to arrive within to count as a double.
@@ -42,7 +79,7 @@ class PhysicalButtonEventDecoder {
* The stock steering controls use the same window, and it is short on purpose: longer
* would make two deliberate single presses merge into one double.
*/
private val lastShortAt = mutableMapOf<Button, Long>()
private val lastShortAt = mutableMapOf<Int, Long>()
/**
* @param atMillis when the event happened. A monotonic default, and JVM-only so the
@@ -53,27 +90,28 @@ class PhysicalButtonEventDecoder {
down: Boolean,
longPress: Boolean,
atMillis: Long = System.nanoTime() / 1_000_000,
source: Source = Source.HARDKEY_REPORT,
): Event? {
val button = Button.entries.firstOrNull { keyCode in it.codes } ?: return null
val key = keyId(source, keyCode) ?: return null
return when {
down && longPress && states[button] != State.LONG_REPORTED -> {
states[button] = State.LONG_REPORTED
down && longPress && states[key] != State.LONG_REPORTED -> {
states[key] = State.LONG_REPORTED
// A long press ends the pairing: the release that follows must not become the
// second half of a double the driver never made.
lastShortAt.remove(button)
Event(button, Press.LONG)
lastShortAt.remove(key)
Event(key, Press.LONG)
}
down -> { states.putIfAbsent(button, State.DOWN); null }
states.remove(button) == State.DOWN -> {
val previous = lastShortAt[button]
down -> { states.putIfAbsent(key, State.DOWN); null }
states.remove(key) == State.DOWN -> {
val previous = lastShortAt[key]
if (previous != null && atMillis - previous <= DOUBLE_TAP_MS) {
// Consumed, so three presses read as one double and one single rather than
// as two overlapping doubles.
lastShortAt.remove(button)
Event(button, Press.DOUBLE)
lastShortAt.remove(key)
Event(key, Press.DOUBLE)
} else {
lastShortAt[button] = atMillis
Event(button, Press.SHORT)
lastShortAt[key] = atMillis
Event(key, Press.SHORT)
}
}
else -> null
@@ -91,5 +129,37 @@ class PhysicalButtonEventDecoder {
* double — see EVTasker's vehicle service.
*/
const val DOUBLE_TAP_MS = 300L
/** SystemUI codes for keys the report broadcast already carries — see the class doc. */
private val SYSTEM_UI_DUPLICATES = setOf(1, 2, 3, 4, 5, 16)
private const val UNKNOWN_REPORT_BASE = 10_000
private const val UNKNOWN_SYSTEM_UI_BASE = 20_000
/** Kept apart from every [Button.id] and from the other broadcast's codes. */
fun unknownKeyId(source: Source, keyCode: Int): Int = keyCode + when (source) {
Source.HARDKEY_REPORT -> UNKNOWN_REPORT_BASE
Source.SYSTEM_UI -> UNKNOWN_SYSTEM_UI_BASE
}
/** Where an unknown key id came from, as `(source, code)` — null for a known button. */
fun unknownKey(keyId: Int): Pair<Source, Int>? = when {
Button.byId(keyId) != null -> null
keyId >= UNKNOWN_SYSTEM_UI_BASE -> Source.SYSTEM_UI to keyId - UNKNOWN_SYSTEM_UI_BASE
keyId >= UNKNOWN_REPORT_BASE -> Source.HARDKEY_REPORT to keyId - UNKNOWN_REPORT_BASE
else -> null
}
fun keyName(keyId: Int): String = Button.byId(keyId)?.name ?: "KEY_$keyId"
private fun keyId(source: Source, keyCode: Int): Int? {
if (keyCode < 0) return null
return when (source) {
Source.HARDKEY_REPORT -> Button.entries.firstOrNull { keyCode in it.codes }?.id
Source.SYSTEM_UI -> {
if (keyCode in SYSTEM_UI_DUPLICATES) return null
Button.entries.firstOrNull { keyCode in it.systemUiCodes }?.id
}
} ?: unknownKeyId(source, keyCode)
}
}
}
@@ -22,6 +22,7 @@ enum class ActionGroup(@StringRes val labelRes: Int) {
ENERGY(R.string.group_energy),
AUDIO(R.string.group_audio),
ADAS(R.string.group_adas),
LIGHTS(R.string.group_lights),
SYSTEM(R.string.group_system)
}
@@ -165,6 +166,32 @@ enum class ActionType(
"SET_SCREEN_BRIGHTNESS", currentKey = SnapshotKeys.KEY_BRIGHTNESS
),
// ── Exterior lights (CR-046, gated) ──────────────────────────────────────
// Owner decisions: 0 km/h only, never low beams OFF, never high beam forced ON — the
// options below cannot express either, and ExteriorLights refuses them again at the write.
// Unproven everywhere: empty @SupportedOn and writeProven = false until a capture and an
// observed effect prove a generation.
@SupportedOn
SET_HEADLIGHTS(
R.string.act_headlights, ActionGroup.LIGHTS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HEADLIGHT_SWITCH_CHOICES),
"SET_HEADLIGHTS", gated = true, currentKey = SnapshotKeys.KEY_HEADLIGHTS_SWITCH,
writeProven = false
),
@SupportedOn
SET_HIGH_BEAM(
R.string.act_high_beam, ActionGroup.LIGHTS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HIGH_BEAM_SWITCH_CHOICES),
"SET_HIGH_BEAM", gated = true, currentKey = SnapshotKeys.KEY_HIGH_BEAM_SWITCH,
writeProven = false
),
@SupportedOn
SET_FOG_LIGHTS(
R.string.act_fog_lights, ActionGroup.LIGHTS,
ValueSpec.BOOL, "SET_FOG_LIGHTS", gated = true,
currentKey = SnapshotKeys.KEY_FOG_LIGHTS_SWITCH, writeProven = false
),
// ── Climate (vendor service — com.evsuite.hardware.saic.SaicClimate) ─────────
// These are the calls the car's own HVAC screen makes, so what they do is not in doubt.
// Not gated: changing the cabin temperature does not alter road behaviour, and a rule
@@ -191,8 +218,13 @@ enum class ActionType(
* A second action rather than a zone argument on the first: the driver's target is the
* one every existing rule sets, and giving it a zone would have made every saved rule
* carry a value it never chose.
*
* Not SWI68: the passenger target reads -1 there, the converter's answer for a zone
* the car does not have, so nothing shows the write lands anywhere. EVTasker's
* Diagnostic prints that raw read (`CLIMATE_ZONES`); a SWI68 that answers a real
* target there is what would bring the action back.
*/
@SupportedOn(SWI68, SWI165)
@SupportedOn(SWI165)
SET_PASSENGER_TEMP(
R.string.act_passenger_temp, ActionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
@@ -296,11 +328,15 @@ enum class ActionType(
),
// ── Energy (vendor service — com.evsuite.hardware.saic.SaicCharging) ─────────
// Unproven: the write sends a percent, but SWI68 reads the limit back as `5` (2026-10-13),
// a code from a table nobody has read. Writing 80 into a coded field is a guess, so the
// action is held back until one write is seen to set the limit shown on the car's screen.
@SupportedOn(SWI68, SWI165)
SET_CHARGE_LIMIT(
R.string.act_charge_limit, ActionGroup.ENERGY,
number(VehicleEnums.CHARGE_LIMIT_MIN, VehicleEnums.CHARGE_LIMIT_MAX, R.string.unit_percent),
"SET_CHARGE_LIMIT", currentKey = SnapshotKeys.KEY_CHARGE_LIMIT
"SET_CHARGE_LIMIT", currentKey = SnapshotKeys.KEY_CHARGE_LIMIT,
writeProven = false
),
@SupportedOn(SWI68, SWI165)
SET_CHARGING_ENABLED(
@@ -23,7 +23,8 @@ enum class ConditionGroup(@StringRes val labelRes: Int) {
CLIMATE(R.string.group_climate),
COMFORT(R.string.group_comfort),
AUDIO(R.string.group_audio),
ADAS(R.string.group_adas)
ADAS(R.string.group_adas),
LIGHTS(R.string.group_lights)
}
/**
@@ -362,8 +363,12 @@ enum class ConditionType(
* The two are the same number until dual zone is on, which is exactly when a rule needs
* to tell them apart — and there is no readable "dual zone" flag, so the honest way to
* ask is to compare the two targets.
*
* Not SWI68: the service reads the passenger zone (area 68) and answers -1, which is what
* its converter returns for a value out of range — a single-zone car has no such zone.
* Seen on a SWI68 MG4 that read the driver's target fine in the same pass.
*/
@SupportedOn(SWI68, SWI165)
@SupportedOn(SWI165)
PASSENGER_TEMP(
R.string.cond_passenger_temp, ConditionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
@@ -431,7 +436,12 @@ enum class ConditionType(
number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_WINDOW_REAR_RIGHT, comparable = true
),
@SupportedOn(SWI68, SWI165)
/**
* Not SWI68: `getDoorLock` reads back the property `setDoorLock` writes, a lock request
* rather than a lock state. A SWI68 MG4 in use answered 0, which is neither of the two
* named values, so the condition never had anything to say there.
*/
@SupportedOn(SWI165)
DOORS_LOCKED(
R.string.cond_doors_locked, ConditionGroup.CONTEXT,
ValueSpec.BOOL, SnapshotKeys.KEY_DOORS_LOCKED
@@ -473,6 +483,26 @@ enum class ConditionType(
SnapshotKeys.KEY_BRIGHTNESS, comparable = true
),
// ── Exterior lights (CR-046) ─────────────────────────────────────────────
// Standard AAOS ids (ExteriorLights). Proven on no generation yet: @SupportedOn stays empty
// until a capture shows the state follow the stalk, in the commit that fills
// ExteriorLights.PROVEN.
@SupportedOn
HEADLIGHTS(
R.string.cond_headlights, ConditionGroup.LIGHTS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HEADLIGHT_STATES), SnapshotKeys.KEY_HEADLIGHTS
),
@SupportedOn
HIGH_BEAM_ON(
R.string.cond_high_beam, ConditionGroup.LIGHTS,
ValueSpec.BOOL, SnapshotKeys.KEY_HIGH_BEAM
),
@SupportedOn
FOG_LIGHTS_ON(
R.string.cond_fog_lights, ConditionGroup.LIGHTS,
ValueSpec.BOOL, SnapshotKeys.KEY_FOG_LIGHTS
),
// ── Audio ────────────────────────────────────────────────────────────────
// What is coming out of the speakers, and how loud. The picker groups them together for
// the same reason the action catalogue does: a rule about the radio is written next to
@@ -60,6 +60,13 @@ object SnapshotKeys {
const val KEY_MEDIA_VOLUME = "mediaVolume"
const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax"
const val KEY_BRIGHTNESS = "brightnessPct"
// Exterior lights (CR-046): states for conditions, switch positions for the action editor.
const val KEY_HEADLIGHTS = "headlightsState"
const val KEY_HIGH_BEAM = "highBeamOn"
const val KEY_FOG_LIGHTS = "fogLightsOn"
const val KEY_HEADLIGHTS_SWITCH = "headlightsSwitch"
const val KEY_HIGH_BEAM_SWITCH = "highBeamSwitch"
const val KEY_FOG_LIGHTS_SWITCH = "fogLightsSwitchOn"
const val KEY_AEB_ENABLED = "aebEnabled"
const val KEY_AEB_MODE = "aebMode"
const val KEY_AEB_SENSITIVITY = "aebSensitivity"
@@ -176,4 +176,23 @@ object VehicleEnums {
EnumOption(2, R.string.radio_band_fm),
EnumOption(4, R.string.radio_band_dab)
)
/** VehicleLightState of the low beams — see ExteriorLights. */
val HEADLIGHT_STATES = listOf(
EnumOption(0, R.string.light_off),
EnumOption(1, R.string.light_on),
EnumOption(2, R.string.light_daytime_running)
)
/** What a rule may ask of the low beams: ON or AUTOMATIC, never OFF. */
val HEADLIGHT_SWITCH_CHOICES = listOf(
EnumOption(1, R.string.light_on),
EnumOption(0x100, R.string.light_auto)
)
/** What a rule may ask of the high beam: OFF or AUTOMATIC, never forced ON. */
val HIGH_BEAM_SWITCH_CHOICES = listOf(
EnumOption(0, R.string.light_off),
EnumOption(0x100, R.string.light_auto)
)
}
@@ -0,0 +1,101 @@
package com.evsuite.hardware.diag
import com.google.gson.JsonObject
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL
import java.util.Base64
/**
* Posts one diagnostic artifact to an inbox the driver runs at home, instead of a USB stick.
*
* The inbox is the driver's own server (a Node-RED flow on the house network, for one): the
* app holds no account and no key, and nothing is sent unless the driver taps Send and
* confirms the address. HTTPS only, no redirect followed, no credentials or query in the URL,
* so a typo cannot send a car's report somewhere else in clear.
*
* The body is one JSON object, the same for every app, so one script on the desk unpacks them
* all: `{"evsuite":1,"app","version","name","createdAtMs","contentBase64"}`. The content is the
* artifact exactly as it would land on the stick — a zip, or a text report — base64-encoded.
*
* No Android dependency: [poster] is injectable, so a test checks the wire without a network.
*/
object DiagnosticInbox {
const val FORMAT_VERSION = 1
/** Above what a bundle is allowed to be on the stick, with base64's third on top. */
const val MAX_CONTENT_BYTES = 1024 * 1024
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 20_000
data class Artifact(
val app: String,
val version: String,
val name: String,
val createdAtMs: Long,
val content: ByteArray,
)
sealed interface Outcome {
data class Sent(val status: Int) : Outcome
data class Failed(val reason: String) : Outcome
}
/** Why [url] is not an inbox address, or null when it is one. */
fun refuse(url: String): String? {
val uri = runCatching { URI(url.trim()) }.getOrNull() ?: return "not a URL"
if (!uri.isAbsolute) return "not an absolute URL"
if (!uri.scheme.equals("https", ignoreCase = true)) return "must be https"
if (uri.host.isNullOrBlank()) return "no host"
if (uri.userInfo != null) return "must not carry credentials"
if (uri.query != null || uri.fragment != null) return "must not carry a query"
return null
}
internal fun body(artifact: Artifact): String = JsonObject().apply {
addProperty("evsuite", FORMAT_VERSION)
addProperty("app", artifact.app)
addProperty("version", artifact.version)
addProperty("name", artifact.name)
addProperty("createdAtMs", artifact.createdAtMs)
addProperty("contentBase64", Base64.getEncoder().encodeToString(artifact.content))
}.toString()
fun send(
url: String,
artifact: Artifact,
poster: (String, String) -> Int = ::post,
): Outcome {
refuse(url)?.let { return Outcome.Failed(it) }
if (artifact.content.size > MAX_CONTENT_BYTES) {
return Outcome.Failed("artifact is ${artifact.content.size} bytes, over $MAX_CONTENT_BYTES")
}
return try {
val status = poster(url.trim(), body(artifact))
if (status in 200..299) Outcome.Sent(status) else Outcome.Failed("HTTP $status")
} catch (e: Exception) {
Outcome.Failed(e.javaClass.simpleName + (e.message?.let { ": $it" } ?: ""))
}
}
private fun post(url: String, body: String): Int {
val bytes = body.toByteArray(Charsets.UTF_8)
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
instanceFollowRedirects = false
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty("Content-Type", "application/json; charset=utf-8")
setFixedLengthStreamingMode(bytes.size)
}
return try {
connection.outputStream.use { it.write(bytes) }
connection.responseCode
} finally {
connection.disconnect()
}
}
}
@@ -12,5 +12,8 @@ enum class DriveMode(val value: Int, val label: String, @StringRes val labelRes:
companion object {
fun fromValue(v: Int): DriveMode = values().firstOrNull { it.value == v } ?: NORMAL
/** Exact decode: a code this enum does not know is null, not a default. */
fun ofRaw(v: Int?): DriveMode? = values().firstOrNull { it.value == v }
}
}
@@ -37,5 +37,29 @@ data class DrivingProfile(
val tsrEnabled: Boolean = false,
val isDefault: Boolean = false,
// [BT-PROFILES] MAC of the Bluetooth device tied to this profile (null = none)
val btDeviceMac: String? = null
val btDeviceMac: String? = null,
// CUSTOM drive mode (CR-040) — CustomDrive index: 0=Eco/Comfort, 1=Normal, 2=Sport.
// null = not configured, never written; profiles saved before this feature read as null.
// Written only when driveMode is CUSTOM — the car ignores them in any other mode.
val customPower: Int? = null,
val customSteering: Int? = null,
val customPedal: Int? = null,
// Climate (CR-041) — null = the profile does not touch climate (every older profile).
val climate: ProfileClimate? = null
)
/**
* The climate a profile applies (CR-041). A null switch means "unchanged": the profile leaves
* that control where the car has it, which is what lets a profile care about the setpoint
* without turning the defrost off.
*/
data class ProfileClimate(
val powerOn: Boolean = true,
val autoOn: Boolean? = null,
val acOn: Boolean? = null,
val tempCelsius: Int? = null,
val fanLevel: Int? = null,
val recirculation: Boolean? = null,
val frontDefrost: Boolean? = null,
val rearDefrost: Boolean? = null
)
@@ -13,5 +13,8 @@ enum class RegenLevel(val value: Int, val label: String, @StringRes val labelRes
companion object {
fun fromValue(v: Int): RegenLevel = values().firstOrNull { it.value == v } ?: MEDIUM
/** Exact decode: a code this enum does not know is null, not a default. */
fun ofRaw(v: Int?): RegenLevel? = values().firstOrNull { it.value == v }
}
}
@@ -1,6 +1,7 @@
package com.evsuite.hardware.probe
import android.content.Context
import android.content.pm.PackageManager
import android.os.IBinder
import com.evsuite.hardware.EVHardware
import com.evsuite.hardware.FirmwareInfo
@@ -61,6 +62,7 @@ object RuntimeInterfaceProbe {
* it from a capture that has been up for a while.
*/
fun connect(context: Context) {
packages = context.applicationContext.packageManager
SaicHub.connect(context)
engMode.connect(context)
}
@@ -276,6 +278,80 @@ object RuntimeInterfaceProbe {
add(vehicleProperty())
add(vehicleCharging())
add(carDiagnostics())
addAll(componentMap())
}
/** Kept from [connect], so [componentMap] needs no extra argument from the callers. */
@Volatile private var packages: PackageManager? = null
/**
* Every service the vendor packages declare in their manifests, as PackageManager lists it.
*
* The hub maps above can only name what someone already guessed to ask for. This is the
* other direction: what is installed, which services each package declares, and whether an
* unprivileged caller may reach them — RI-001, RI-003, RI-004, RI-007 and RI-008 each start
* by not knowing which component publishes their signal.
*
* Manifest metadata only. Nothing is bound, no transaction is sent and no vendor class is
* loaded, so this cannot reach a write on any interface. Visibility of other packages holds
* because the target is API 28; a later target needs `QUERY_ALL_PACKAGES`, and without it
* this would read as a head unit with no vendor packages at all.
*/
fun componentMap(): List<InterfaceProbe> {
val pm = packages ?: return listOf(
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.ABSENT, note = NOT_CONNECTED),
)
val names = try {
pm.getInstalledPackages(0).map { it.packageName }
.filter { name -> COMPONENT_PREFIXES.any { name.startsWith(it) } }.sorted()
} catch (e: RuntimeException) {
return listOf(
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.DENIED, note = e.javaClass.name),
)
}
return listOf(componentSummary(names)) + names.take(MAX_COMPONENT_PACKAGES).map { name ->
val services = try {
pm.getPackageInfo(name, PackageManager.GET_SERVICES).services.orEmpty()
.map { ServiceRow(it.name, it.exported, it.permission) }
} catch (e: PackageManager.NameNotFoundException) {
return@map InterfaceProbe("$COMPONENT_MAP:$name", null, BindVerdict.ABSENT)
} catch (e: RuntimeException) {
return@map InterfaceProbe(
"$COMPONENT_MAP:$name", null, BindVerdict.DENIED, note = e.javaClass.name,
)
}
componentEntry(name, services)
}
}
/** One declared service, as the manifest states it. */
internal data class ServiceRow(val name: String, val exported: Boolean, val permission: String?)
/** How many vendor packages there are, and how many of them the entries below list. */
internal fun componentSummary(names: List<String>): InterfaceProbe = InterfaceProbe(
COMPONENT_MAP, null, BindVerdict.ANSWERED,
mapOf(
"packages" to names.size.toString(),
"listed" to minOf(names.size, MAX_COMPONENT_PACKAGES).toString(),
),
COMPONENT_NOTE,
)
/**
* One package's services, keyed by class name, the value kept as the manifest's own words.
*
* A package that declares none is still ANSWERED: the manifest was read and said so.
*/
internal fun componentEntry(pkg: String, services: List<ServiceRow>): InterfaceProbe {
val values = LinkedHashMap<String, String?>()
values["services"] = services.size.toString()
services.take(MAX_COMPONENT_SERVICES).forEach {
values[it.name] = "exported=${it.exported} permission=${it.permission}"
}
if (services.size > MAX_COMPONENT_SERVICES) {
values["truncated"] = (services.size - MAX_COMPONENT_SERVICES).toString()
}
return InterfaceProbe("$COMPONENT_MAP:$pkg", null, BindVerdict.ANSWERED, values)
}
/**
@@ -348,6 +424,16 @@ object RuntimeInterfaceProbe {
): InterfaceProbe {
val target = binder
?: return InterfaceProbe(label, descriptor, BindVerdict.ABSENT, note = note)
// The hub may hand over a different interface at this bind point. Every call would then
// fail the remote descriptor check, which reads as a refusal and is not one: the
// interface is not here, so the entry is ABSENT and nothing is called.
val answersAs = runCatching { target.interfaceDescriptor }.getOrNull()
if (answersAs != null && answersAs != descriptor) {
return InterfaceProbe(
label, descriptor, BindVerdict.ABSENT,
note = "$note; the handle answers as $answersAs, so nothing was called",
)
}
val values = calls.mapValues { (_, call) ->
val args = call.args.toTypedArray()
when (call.readAs) {
@@ -365,6 +451,18 @@ object RuntimeInterfaceProbe {
*/
private val CODE_SWEEP = (0x0..0x12).toList()
private const val COMPONENT_MAP = "componentMap"
private val COMPONENT_PREFIXES = listOf("com.saicmotor.", "com.saicvehicleservice.")
/** A bundle is pasted and read; a few hundred lines of manifest is the useful end of it. */
internal const val MAX_COMPONENT_PACKAGES = 64
internal const val MAX_COMPONENT_SERVICES = 40
private const val NOT_CONNECTED = "connect() was not called, so there is no PackageManager"
private const val COMPONENT_NOTE =
"PackageManager GET_SERVICES only: nothing bound, called or loaded. exported=false or " +
"a permission we do not hold means unreachable from EVSuite, not absent."
/**
* What ABSENT means when there is no adapter client to ask. Carried on every swept entry
* rather than stated once at the top of the bundle: a later session reads one entry, not the
@@ -0,0 +1,81 @@
package com.evsuite.hardware.probe
import com.evsuite.hardware.AppLogger
import com.evsuite.hardware.EVHardware
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.saic.SaicCharging
import java.lang.reflect.InvocationTargetException
/**
* Two read-only diagnostic tools. EVTasker's stable diagnostic screen calls [capture] from two
* buttons (CR-045/CR-046, 2026-10-13), because the owner only installs stable builds; that is
* safe only because **no setter is called from this file** — only the getters
* [VendorSurfaceRules.readableGetters] admits.
*
* 1. [socSourcesReport] (CR-044): every place state of charge might come from, side by side,
* for the generations where the uploader reads none.
* 2. [capture] + [VendorSurfaceRules.diff] (CR-045): read everything, let the driver flip a
* setting on the car's own screen at standstill, read again, list what changed. That is how
* an unknown setting (Automatic High Beam) is found on a given firmware without taking an
* identifier from anybody else's code. It works for any setting, and across a SOC change
* too: capture, drive a few kilometres, capture again.
*
* Every value printed is a candidate. Only a lead proven per generation becomes a read path.
*/
object VendorSurfaceProbe {
private const val TAG = "EV_SURVEY"
/** Longest a capture may take before it stops and says so. */
const val BUDGET_MS = 20_000L
/** Getter-name words that may carry battery state, for the SOC survey. */
val SOC_WORDS = listOf(
"soc", "battery", "batt", "bms", "energy", "endurance", "range", "electric",
"charge", "mileage",
)
/**
* Everything readable, keyed by source: every CarPropertyManager property (when
* [words] is empty) and every value getter of the vendor setting objects EVHardware holds.
* Call off the main thread: these are binder calls.
*/
fun capture(words: List<String> = emptyList()): Map<String, String> {
val deadline = System.currentTimeMillis() + BUDGET_MS
val out = sortedMapOf<String, String>()
if (words.isEmpty()) out += EVHardware.readAllCarProperties(deadline)
for ((name, handle) in EVHardware.vendorSettingHandles()) {
for (getter in VendorSurfaceRules.readableGetters(handle.javaClass, words)) {
if (System.currentTimeMillis() > deadline) {
out["$name ~"] = "time budget spent"
break
}
out["$name.${getter.name}"] = try {
VendorSurfaceRules.render(getter.invoke(handle))
} catch (e: InvocationTargetException) {
"ERR ${e.targetException?.javaClass?.simpleName}"
} catch (e: Exception) {
"ERR ${e.javaClass.simpleName}"
}
}
}
AppLogger.i(TAG, "capture(${words.joinToString()}) → ${out.size} values")
return out
}
/** CR-044: where state of charge can come from on this car, and what each source says. */
fun socSourcesReport(): String = buildString {
appendLine("─── SOC sources (CR-044) — ${FirmwareInfo.getGeneration()} ───")
appendLine("SaicCharging hub: ${if (SaicCharging.isAvailable) "bound" else "absent or not bound"}" +
" → SOC ${SaicCharging.stateOfChargePercent() ?: "null"}")
appendLine("Vendor SOC property (SWI68 path) → ${EVHardware.getVendorBatterySocPercent() ?: "null"}")
EVHardware.probeTelemetryProperties()
.filter { it.name in setOf("EV_BATTERY_LEVEL", "INFO_EV_BATTERY_CAPACITY", "RANGE_REMAINING") }
.forEach { appendLine(it.toString()) }
val getters = capture(SOC_WORDS)
appendLine("Vendor getters with battery words: ${getters.size}")
getters.forEach { (key, value) -> appendLine(" $key = $value") }
appendLine("Candidates only. To find SOC among unnamed properties, use the before/after")
appendLine("capture across a SOC change (a few km, or a charge).")
}
}
@@ -0,0 +1,112 @@
package com.evsuite.hardware.probe
import java.lang.reflect.Method
import java.lang.reflect.Modifier
/**
* The decisions behind [VendorSurfaceProbe], kept free of Android so the JVM tests reach them.
*
* Two diagnostic tools stand on these rules (CR-044, CR-045). Both answer "where does this car
* publish X?" by READING what the runtime already exposes. Neither tool writes anything:
* - the SOC survey reads every candidate source of state of charge side by side;
* - the before/after capture reads everything twice — once before the driver flips a setting
* on the car's own screen, once after — and lists what changed.
*
* Getters are named by the runtime class itself, not by any external catalogue. What changed is
* a lead to prove per generation before it becomes a supported read or write.
*/
object VendorSurfaceRules {
/** Read-only name shapes: a getter answers, it does not act. */
private val GETTER_PREFIXES = listOf("get", "is", "has")
/**
* Words that make a "getter" something else: a call that starts, registers, resets or
* connects can move state even when its name begins with `get`, and one that hands back a
* service or a listener is plumbing, not a reading. Matched as whole camel-case words, so
* `getHighBeamSetting` and `isAutoEnabled` stay readings while `getAndReset` does not.
*/
private val ACTION_WORDS = setOf(
"set", "reset", "clear", "request", "start", "stop", "connect", "disconnect",
"register", "unregister", "bind", "unbind", "toggle", "send", "write", "apply", "click",
"and", "instance", "service", "binder", "context", "listener", "callback", "handler",
"looper",
)
/** `getAndResetCount` → [and, reset, count]. */
fun words(name: String): List<String> =
name.split(Regex("(?<=[a-z0-9])(?=[A-Z])|_")).filter { it.isNotEmpty() }
.map { it.lowercase() }
/** Only values: anything returning an object could hand back a binder or a context. */
private val VALUE_TYPES: Set<Class<*>> = setOf(
Int::class.javaPrimitiveType!!, Long::class.javaPrimitiveType!!,
Float::class.javaPrimitiveType!!, Double::class.javaPrimitiveType!!,
Boolean::class.javaPrimitiveType!!, Short::class.javaPrimitiveType!!,
Byte::class.javaPrimitiveType!!,
Int::class.javaObjectType, Long::class.javaObjectType, Float::class.javaObjectType,
Double::class.javaObjectType, Boolean::class.javaObjectType, Short::class.javaObjectType,
Byte::class.javaObjectType, String::class.java,
)
/** The getters of [cls] safe to call for a reading, sorted by name. */
fun readableGetters(cls: Class<*>, keywords: List<String> = emptyList()): List<Method> =
cls.methods
.filter { isReadableGetter(it) }
.filter { m -> keywords.isEmpty() || keywords.any { m.name.contains(it, ignoreCase = true) } }
.distinctBy { it.name }
.sortedBy { it.name }
fun isReadableGetter(m: Method): Boolean {
if (m.parameterCount != 0) return false
if (!Modifier.isPublic(m.modifiers) || Modifier.isStatic(m.modifiers)) return false
if (m.declaringClass == Any::class.java) return false
if (m.returnType !in VALUE_TYPES) return false
val name = m.name
val prefix = GETTER_PREFIXES.firstOrNull { name.startsWith(it) && name.length > it.length }
?: return false
if (!name[prefix.length].isUpperCase()) return false
return words(name.substring(prefix.length)).none { it in ACTION_WORDS }
}
data class Change(val key: String, val before: String?, val after: String?)
/**
* What differs between two readings. A key present on one side only is a change too: a
* property that starts answering when a feature is switched on is exactly the lead sought.
*/
fun diff(before: Map<String, String>, after: Map<String, String>): List<Change> =
(before.keys + after.keys).sorted()
.filter { before[it] != after[it] }
.map { Change(it, before[it], after[it]) }
/** One line per value, shaped like the rest of the diagnostic report. */
fun formatDiff(changes: List<Change>, beforeCount: Int, afterCount: Int): String = buildString {
appendLine("Values read: $beforeCount before, $afterCount after — ${changes.size} changed")
if (changes.isEmpty()) {
appendLine(" nothing changed: the setting is not in what this app can read, or the")
appendLine(" car had not applied it yet when the second capture was taken")
}
changes.forEach { appendLine(" ${label(it.key)}: ${it.before ?: "—"} → ${it.after ?: "—"}") }
}
private val CPM_KEY = Regex("""^CPM 0x([0-9A-Fa-f]{8})/""")
/** `CPM 0x11400E00/0` → `CPM 0x11400E00/0 (HEADLIGHTS_STATE)` for a standard id we name. */
fun label(key: String, names: Map<Int, String> = com.evsuite.hardware.ExteriorLights.NAMES): String {
val id = CPM_KEY.find(key)?.groupValues?.get(1)?.toLongOrNull(16)?.toInt() ?: return key
return names[id]?.let { "$key ($it)" } ?: key
}
/** Arrays print their content; everything else its own text. */
fun render(value: Any?): String = when (value) {
null -> "null"
is IntArray -> value.contentToString()
is LongArray -> value.contentToString()
is FloatArray -> value.contentToString()
is BooleanArray -> value.contentToString()
is ByteArray -> value.contentToString()
is Array<*> -> value.contentDeepToString()
else -> value.toString()
}
}
@@ -222,7 +222,7 @@ object SaicNavGuidance {
* A `List<EVRoutPoiInfo>` as `Parcel.writeTypedList` lays it out: the count, then per item a
* non-null marker and the bean's own three fields in the order its `writeToParcel` wrote
* them. Written by hand because the bean is the vendor's class and this module does not
* carry it — the layout is three lines and depending on a decompiled class would be worse.
* carry it — the layout is three lines and depending on an OEM class would be worse.
*/
internal fun Parcel.writeEvRoutPoiList(pois: List<NavigationHandoff.Poi>) {
writeInt(pois.size)
@@ -5,6 +5,7 @@ import android.os.IBinder
import com.evsuite.hardware.A9Climate
import com.evsuite.hardware.GlassProbe
import com.evsuite.hardware.VsmGlass
import com.evsuite.hardware.catalog.VehicleEnums
/**
* The vendor vehicle service the head unit's HVAC and charging screens talk to.
@@ -173,6 +174,15 @@ object SaicClimate {
fun fanLevelMax(): Int? = if (a9) null else level(TX_GET_MAX_AIR_VOLUME)
fun driverTemp(): Int? = if (a9) A9Climate.driverTemp() else level(TX_GET_DRV_TEMP)
fun passengerTemp(): Int? = if (a9) null else level(TX_GET_PSG_TEMP)
/**
* The passenger target exactly as the service answers it, for the diagnostic only.
*
* [passengerTemp] drops -1, which is also what the service's converter returns for a
* zone the car does not have: the raw number is what tells "single zone" from "the
* read did not answer" (null).
*/
fun passengerTempState(): Int? = if (a9) null else SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_PSG_TEMP)
fun frontDefrostOn(): Boolean? = if (a9) A9Climate.frontDefrostOn() else flag(TX_GET_FRONT_DEFROST)
fun rearDefrostOn(): Boolean? = if (a9) A9Climate.rearDefrostOn() else flag(TX_GET_BACK_DEFROST)
/** The service answers [TEMP_ERROR] for a reading it holds no value for, not an error. */
@@ -254,8 +264,18 @@ object SaicCharging {
fun chargingStatus(): Int? = read(TX_GET_CHARGING_STATUS)
/** Charge limit in percent — charging stops here. */
fun chargeLimitPercent(): Int? = read(TX_GET_CLOSE_SOC)
/**
* Charge limit in percent — charging stops here.
*
* Null outside the range the car accepts: SWI68 answered `5` on 2026-10-13, a code from a
* table nobody has read, not a percent. Null sends every caller down its "limit
* unreadable" path instead of planning a charge to 5 %.
*/
fun chargeLimitPercent(): Int? = chargeLimitOrNull(read(TX_GET_CLOSE_SOC))
internal fun chargeLimitOrNull(raw: Int?): Int? =
raw?.takeIf { it in VehicleEnums.CHARGE_LIMIT_MIN..VehicleEnums.CHARGE_LIMIT_MAX }
fun setChargeLimitPercent(percent: Int): Boolean =
SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CLOSE_SOC, percent.coerceIn(0, 100))
@@ -0,0 +1,163 @@
package com.evsuite.hardware.telemetry
import com.google.gson.Gson
import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs
import kotlin.math.max
/**
* One day of the battery analyses, kept so that tomorrow can be compared with today (CP-087).
*
* Nothing here is measured: every field is copied from [StateOfHealthEstimator],
* [CalibrationDrift] and [BatteryExposure] as they stood that day. A null is an analysis that
* refused, never a zero.
*/
data class BatteryDigest(
/** The local calendar day, `yyyy-MM-dd`. One record per day; the last write of a day wins. */
val day: String,
val healthPercent: Double?,
/** Half-width of the health band, in points. Null where the estimate refused. */
val healthBandPercent: Double?,
val healthWindows: Int,
val calibration: CalibrationVerdict?,
val daysSinceFullCharge: Double?,
val hoursAboveHighSoc: Double?,
val equivalentFullCycles: Double?,
val chargeCount: Int,
) {
companion object {
fun of(
day: String,
health: StateOfHealthResult,
drift: CalibrationDriftReport?,
exposure: BatteryExposureReport?,
charge: ChargeEnergyReport,
): BatteryDigest {
val estimate = (health as? StateOfHealthResult.Ready)?.estimate
return BatteryDigest(
day = day,
healthPercent = estimate?.stateOfHealthPercent?.value,
healthBandPercent = estimate?.stateOfHealthPercent?.uncertainty,
healthWindows = estimate?.windowCount ?: 0,
calibration = drift?.verdict,
daysSinceFullCharge = drift?.daysSinceFullCharge,
hoursAboveHighSoc = exposure?.hoursAboveHighSoc,
equivalentFullCycles = exposure?.equivalentFullCycles,
chargeCount = charge.charges.size,
)
}
/**
* What moved between two days by more than its own uncertainty, and only that.
*
* A health figure that wanders inside its band has not changed: saying so every morning
* is the notification a driver turns off in a week.
*/
fun changes(previous: BatteryDigest?, current: BatteryDigest): List<DigestChange> {
if (previous == null) return emptyList()
return listOfNotNull(
healthMoved(previous, current),
bandNarrowed(previous, current),
calibrationChanged(previous, current),
)
}
private fun healthMoved(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
val from = previous.healthPercent ?: return null
val to = current.healthPercent ?: return null
val band = max(previous.healthBandPercent ?: 0.0, current.healthBandPercent ?: 0.0)
if (abs(to - from) <= band) return null
return DigestChange.HealthMoved(from, to, current.healthBandPercent ?: band)
}
private fun bandNarrowed(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
val from = previous.healthBandPercent
val to = current.healthBandPercent ?: return null
// A first estimate is a narrowing from nothing, and the driver has never seen it.
if (from == null) {
val health = current.healthPercent ?: return null
return DigestChange.HealthBandNarrowed(null, to, health)
}
if (from - to < MIN_BAND_NARROWING_PERCENT) return null
return DigestChange.HealthBandNarrowed(from, to, current.healthPercent ?: return null)
}
private fun calibrationChanged(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
val to = current.calibration ?: return null
if (previous.calibration == to) return null
return DigestChange.CalibrationChanged(previous.calibration, to, current.daysSinceFullCharge)
}
/** A point of band is what one more watched window buys; less is rounding. */
const val MIN_BAND_NARROWING_PERCENT = 1.0
}
}
sealed interface DigestChange {
data class HealthMoved(val fromPercent: Double, val toPercent: Double, val bandPercent: Double) : DigestChange
data class HealthBandNarrowed(
val fromBandPercent: Double?,
val toBandPercent: Double,
val healthPercent: Double,
) : DigestChange
data class CalibrationChanged(
val from: CalibrationVerdict?,
val to: CalibrationVerdict,
val daysSinceFullCharge: Double?,
) : DigestChange
}
/** A year of days, newest last. Same atomic write and quarantine as [BatteryLedgerStore]. */
class BatteryDigestStore(
private val target: File,
private val maxDays: Int = MAX_DAYS,
private val gson: Gson = Gson(),
) {
fun read(): List<BatteryDigest> {
if (!target.exists()) return emptyList()
val text = runCatching { target.readText() }.getOrNull() ?: return emptyList()
val envelope = runCatching { gson.fromJson(text, Envelope::class.java) }.getOrNull()
if (envelope == null || envelope.schemaVersion != SCHEMA_VERSION) {
target.renameTo(File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}"))
return emptyList()
}
@Suppress("SENSELESS_COMPARISON")
return envelope.days.orEmpty().filter { it != null && it.day != null }.sortedBy { it.day }
}
/** Replaces the record of the same day, or adds it; returns the day before it, if any. */
fun upsert(digest: BatteryDigest): BatteryDigest? {
val others = read().filter { it.day != digest.day }
val previous = others.lastOrNull { it.day < digest.day }
write((others + digest).sortedBy { it.day }.takeLast(maxDays.coerceAtLeast(1)))
return previous
}
private fun write(days: List<BatteryDigest>): Boolean {
val bytes = gson.toJson(Envelope(SCHEMA_VERSION, days)).toByteArray(Charsets.UTF_8)
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "${target.name}.${System.nanoTime()}.tmp")
return try {
FileOutputStream(temp).use { output ->
output.write(bytes)
output.fd.sync()
}
if (temp.renameTo(target)) true else {
temp.delete()
false
}
} catch (_: Exception) {
temp.delete()
false
}
}
private data class Envelope(val schemaVersion: Int = 0, val days: List<BatteryDigest>? = null)
companion object {
const val SCHEMA_VERSION = 1
const val MAX_DAYS = 365
const val FILE_NAME = "battery-digest.json"
}
}
@@ -166,6 +166,9 @@ class BatteryLedgerRecorder(
/** @return true when this sample earned an entry and it was written. */
fun observe(snapshot: EnergySnapshot): Boolean {
// A head unit woken by a night charge runs on its factory clock (late 2018) until it syncs.
// The ledger is read in time order, so one such entry would sort first and split the charge.
if (snapshot.timestampMs < MIN_PLAUSIBLE_CLOCK_MS) return false
seed()
integrate(snapshot)
val soc = snapshot.socPercent?.takeIf { it.isFinite() } ?: return false
@@ -231,5 +234,8 @@ class BatteryLedgerRecorder(
const val SOC_STEP_PERCENT = 1.0f
const val QUIET_INTERVAL_MS = 15 * 60 * 1000L
const val MAX_SAMPLE_GAP_MS = 5_000L
/** 2025-01-01T00:00Z: no reading of this app can be older. Seen by MG4Control on the car. */
const val MIN_PLAUSIBLE_CLOCK_MS = 1_735_689_600_000L
}
}
@@ -0,0 +1,137 @@
package com.evsuite.hardware.telemetry
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
/**
* How long the rest of a charge takes, from the driver's own watched charges (CP-089).
*
* The unit is minutes per point of charge, measured on [ChargeEnergy.steps]: the wall clock
* between two ledger entries over the points between them. No kWh per point and no power sign
* stand between the history and the answer. A band of [BAND_PERCENT] points that no watched
* charge crossed is a refusal, never an extrapolation.
*/
data class ChargeDurationEstimate(
val fromPercent: Double,
val targetPercent: Int,
/** Median charge per band, summed. */
val minutes: Double,
/** Fastest and slowest past charge per band, summed: the range the history allows. */
val lowMinutes: Double,
val highMinutes: Double,
/** From [LAST_STRETCH_PERCENT] to the target, median; null when the path does not cross it. */
val lastStretchMinutes: Double?,
/** True when only charges at a similar outside temperature were used. */
val similarTemperature: Boolean,
)
sealed interface ChargeDurationResult {
data class Ready(val estimate: ChargeDurationEstimate) : ChargeDurationResult
data class Refused(val reason: ChargeDurationRefusal, val bandFromPercent: Int? = null) :
ChargeDurationResult
}
enum class ChargeDurationRefusal { AT_TARGET, NO_WATCHED_CHARGE, MISSING_BAND }
/** One band of the charge in progress against the same band in past charges. */
data class ChargeCurveCheck(
val bandFromPercent: Int,
val minutesPerPoint: Double,
val usualLowMinutesPerPoint: Double,
val usualHighMinutesPerPoint: Double,
val pastCharges: Int,
) {
/** Slower than the slowest past charge in that band: less power than the history's low edge. */
val slower: Boolean get() = minutesPerPoint > usualHighMinutesPerPoint
}
object ChargeDuration {
fun estimate(
charges: List<ChargeEnergy>,
socPercent: Double,
targetPercent: Int,
outsideCelsius: Double?,
): ChargeDurationResult {
if (socPercent >= targetPercent) return ChargeDurationResult.Refused(ChargeDurationRefusal.AT_TARGET)
if (outsideCelsius != null) {
val similar = charges.filter { charge ->
val low = charge.minOutsideTempCelsius
val high = charge.maxOutsideTempCelsius
low != null && high != null && abs((low + high) / 2.0 - outsideCelsius) <= SIMILAR_TEMPERATURE_CELSIUS
}
val ready = along(history(similar), socPercent, targetPercent, similar = true)
if (ready is ChargeDurationResult.Ready) return ready
}
return along(history(charges), socPercent, targetPercent, similar = false)
}
fun curveCheck(charges: List<ChargeEnergy>, bandFromPercent: Int, minutesPerPoint: Double): ChargeCurveCheck? {
val past = history(charges)[bandFromPercent / BAND_PERCENT].orEmpty()
if (past.size < MIN_PAST_CHARGES) return null
return ChargeCurveCheck(bandFromPercent, minutesPerPoint, past.min(), past.max(), past.size)
}
/** Band index to one minutes-per-point figure per past charge that crossed it. */
fun history(charges: List<ChargeEnergy>): Map<Int, List<Double>> {
val byBand = HashMap<Int, MutableList<Double>>()
charges.forEach { charge ->
charge.steps.filter { it.toSocPercent > it.fromSocPercent && it.durationMs > 0 }
.groupBy { band(it.midSocPercent) }
.forEach { (band, steps) ->
val points = steps.sumOf { it.toSocPercent - it.fromSocPercent }
byBand.getOrPut(band) { ArrayList() } += steps.sumOf { it.durationMs } / MINUTE_MS / points
}
}
return byBand
}
private fun along(
history: Map<Int, List<Double>>,
socPercent: Double,
targetPercent: Int,
similar: Boolean,
): ChargeDurationResult {
if (history.isEmpty()) return ChargeDurationResult.Refused(ChargeDurationRefusal.NO_WATCHED_CHARGE)
var minutes = 0.0
var low = 0.0
var high = 0.0
var lastStretch = 0.0
val first = band(socPercent)
val last = ceil(targetPercent / BAND_PERCENT.toDouble()).toInt() - 1
for (band in first..last) {
val past = history[band]?.sorted()
?: return ChargeDurationResult.Refused(ChargeDurationRefusal.MISSING_BAND, band * BAND_PERCENT)
val from = maxOf(socPercent, band * BAND_PERCENT.toDouble())
val to = minOf(targetPercent.toDouble(), (band + 1) * BAND_PERCENT.toDouble())
val median = past[past.size / 2]
minutes += (to - from) * median
low += (to - from) * past.first()
high += (to - from) * past.last()
val stretchFrom = maxOf(from, LAST_STRETCH_PERCENT.toDouble())
if (to > stretchFrom) lastStretch += (to - stretchFrom) * median
}
val crosses = socPercent < LAST_STRETCH_PERCENT && targetPercent > LAST_STRETCH_PERCENT
return ChargeDurationResult.Ready(
ChargeDurationEstimate(
fromPercent = socPercent,
targetPercent = targetPercent,
minutes = minutes,
lowMinutes = low,
highMinutes = high,
lastStretchMinutes = if (crosses) lastStretch else null,
similarTemperature = similar,
),
)
}
private fun band(socPercent: Double): Int =
floor(socPercent / BAND_PERCENT).toInt().coerceIn(0, 100 / BAND_PERCENT - 1)
const val BAND_PERCENT = 10
const val LAST_STRETCH_PERCENT = 80
const val SIMILAR_TEMPERATURE_CELSIUS = 8.0
const val MIN_PAST_CHARGES = 3
private const val MINUTE_MS = 60_000.0
}
@@ -0,0 +1,405 @@
package com.evsuite.hardware.telemetry
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
/**
* Which way this app's own integral moved while charge was entering the pack.
*
* [EnergySnapshot.batteryPowerKw] declares positive as energy leaving the battery, so an
* integral that **falls** during a charge is the declared convention holding. One that rises is
* the same convention inverted, and it matters well beyond a sign on a dashboard: the state of
* health estimator reads `PACK_PAIR_INTEGRAL` windows as energy that *left* the pack, so an
* inverted source would hand it discharge windows with the sign of a charge and the estimator
* would refuse every one of them.
*
* Nothing here corrects anything. RI-002 asks what the car actually does and this reports it.
*/
enum class ChargePackSign {
/** The integral fell, which is the convention [EnergySnapshot.batteryPowerKw] declares. */
ENERGY_FELL,
/** The integral rose, so the property signs a charge the other way round. */
ENERGY_ROSE,
/** Readable and level: the pack pair said nothing moved while the gauge said it did. */
FLAT,
/** No charge was watched closely enough, or no pack reading was integrated at all. */
UNREADABLE,
/** Two charges answered differently, which settles nothing and must not be averaged. */
CONTRADICTORY,
}
/**
* What the car's own kWh counters did across a charge.
*
* `SaicCharging` transactions 75 and 77 are documented as *since the last charge*, so the
* expected behaviour is [RESET] — and "expected" is the whole reason to measure it. A counter
* that resets is what lets [StateOfHealthEstimator] tell one discharge window from the next; a
* counter that never moves is a source that will never produce a window at all.
*/
enum class ChargeCounterBehaviour {
/** A counter went backwards: the car cleared it, which is what a charge is said to do. */
RESET,
/**
* A counter rose during the charge, which "since the last charge" does not predict.
*/
ROSE,
/** Read at both ends and unchanged. */
STATIC,
/** Never published on either end of any charge. */
ABSENT,
}
/**
* One charge session measured in energy rather than in points of charge.
*
* [BatteryExposure] already segments the ledger into sessions and says how fast each one moved
* the gauge. This adds the half nothing reads today: how much energy went in, which way the
* pack pair signed it, and what the car's own counters did while it happened.
*/
data class ChargeEnergy(
val session: BatteryChargeSession,
/**
* Whether this app was sampling throughout.
*
* The ledger earns an entry every quarter of an hour whatever else happens, so a longer
* silence than that is the app having been down — a head unit asleep on a charger overnight
* is the ordinary case, not an edge one. An unwatched charge still gained the charge it
* gained; what it cannot carry is a power, because the integral stopped moving while nobody
* was integrating.
*/
val watched: Boolean,
val maxEntryGapMs: Long,
/** Signed, as the integral moved. Negative is the declared convention; see [ChargePackSign]. */
val packDeltaKwh: Double?,
/** Magnitude over the wall clock, and only where the whole session was watched. */
val meanPowerKw: Double?,
val counterBehaviour: ChargeCounterBehaviour,
val consumedDeltaKwh: Double?,
val regeneratedDeltaKwh: Double?,
/**
* Odometer moved, when the car published one at both ends.
*
* A rise in charge is how a charge is detected here, and a long descent raises the charge
* too. Distance is what tells the two apart, so a moving session is regeneration and is
* never offered to the driver as "your last charge".
*/
val movedKm: Double?,
/** Every distinct value the charging status held, in the order the ledger saw them. */
val chargingStatuses: List<Int>,
/**
* Pack power between each pair of watched ledger entries, which the recorder writes once
* per point of charge: this is the charge curve, one step per point. Empty when unwatched.
*/
val steps: List<ChargePowerStep> = emptyList(),
/** Outside temperature over the session, named as such: the pack's own is unpublished. */
val minOutsideTempCelsius: Double? = null,
val maxOutsideTempCelsius: Double? = null,
) {
/** The fastest watched step, measured; null when no step was watched. */
val peakPowerKw: Double? get() = steps.maxOfOrNull { it.powerKw }
/**
* Whether this rise was a charge rather than regeneration.
*
* Three pieces of evidence, any one of them enough:
* - the odometer stood still;
* - the car reported a non-zero charging status during the rise (every drive recorded so far
* read zero throughout, 1 473 samples on SWI68);
* - the rise is too large to be regeneration **and** nobody watched the car move during it.
* A head unit asleep on the charger is the ordinary overnight case: the ledger then holds
* the evening entry and the morning one, the odometer may have moved between them because
* the drive home was never recorded either, and requiring it to stand still is what made
* a whole night's charge read as a descent and vanish from the battery page.
*/
val plugged: Boolean
get() {
val moved = movedKm
if (moved != null && moved <= STATIONARY_TOLERANCE_KM) return true
if (chargingStatuses.any { it != 0 }) return true
return session.gainedPercent >= UNAMBIGUOUS_CHARGE_PERCENT && (moved == null || !watched)
}
val sign: ChargePackSign
get() {
val delta = packDeltaKwh
if (!watched || delta == null) return ChargePackSign.UNREADABLE
return when {
delta <= -ChargeEnergyAnalyzer.MIN_ENERGY_KWH -> ChargePackSign.ENERGY_FELL
delta >= ChargeEnergyAnalyzer.MIN_ENERGY_KWH -> ChargePackSign.ENERGY_ROSE
else -> ChargePackSign.FLAT
}
}
companion object {
/** The odometer reads in whole kilometres, so anything under one is standing still. */
const val STATIONARY_TOLERANCE_KM = 0.5
/**
* A rise no descent produces. Regeneration on the steepest ordinary pass returns a few
* points; ten points of a 61,7 kWh pack is over 6 kWh of potential energy recovered.
*/
const val UNAMBIGUOUS_CHARGE_PERCENT = 10.0
}
}
/** One watched step of a charge: from one ledger entry to the next, usually one point. */
data class ChargePowerStep(
val fromSocPercent: Double,
val toSocPercent: Double,
val durationMs: Long,
/** Magnitude of the pack-pair integral's move over the step's wall clock. */
val powerKw: Double,
) {
val midSocPercent: Double get() = (fromSocPercent + toSocPercent) / 2.0
}
/** Time-weighted mean power over every watched step whose midpoint fell in one charge band. */
data class SocBandPower(
val fromPercent: Int,
val toPercent: Int,
val meanPowerKw: Double,
val hours: Double,
)
/** Every charge the ledger holds, and the two questions the set of them settles. */
data class ChargeEnergyReport(
val charges: List<ChargeEnergy>,
val packSign: ChargePackSign,
val counterBehaviour: ChargeCounterBehaviour,
val chargingStatuses: List<Int>,
) {
/** The most recent charge that stood still, which is the one a driver means by "my charge". */
val lastPluggedCharge: ChargeEnergy? get() = charges.lastOrNull { it.plugged }
/** Every charge that was a charge, oldest first. */
val pluggedCharges: List<ChargeEnergy> get() = charges.filter { it.plugged }
/**
* The charge curve across every watched charge: mean power per band of [bandPercent]
* points of charge. This is where a taper shows — the pack accepting less as it fills —
* and it is measured, one step per point, never modelled.
*/
fun powerBySocBand(bandPercent: Int = SOC_BAND_PERCENT): List<SocBandPower> =
pluggedCharges.flatMap { it.steps }
.groupBy { (it.midSocPercent / bandPercent).toInt().coerceIn(0, 100 / bandPercent - 1) }
.toSortedMap()
.map { (band, steps) ->
val ms = steps.sumOf { it.durationMs }.toDouble()
SocBandPower(
fromPercent = band * bandPercent,
toPercent = (band + 1) * bandPercent,
meanPowerKw = steps.sumOf { it.powerKw * it.durationMs } / ms,
hours = ms / 3_600_000.0,
)
}
val watchedCount: Int get() = charges.count { it.watched }
/**
* The report as lines, because the answer belongs in the diagnostic bundle rather than
* only on a screen. Same reason [com.evsuite.hardware.telemetry.model.SocConsumptionFitter]
* describes itself: a ticket is settled by a bundle a driver brought back, not by a UI.
*/
fun describe(): List<String> {
if (charges.isEmpty()) return listOf("charges=0")
val lines = ArrayList<String>()
lines += "charges=${charges.size} watched=$watchedCount"
lines += "pack_pair_sign=${packSign.name}"
lines += "vehicle_counters=${counterBehaviour.name}"
lines += "charging_status_values=" + chargingStatuses.joinToString(",").ifEmpty { "none" }
charges.takeLast(MAX_DESCRIBED_CHARGES).forEach { charge ->
lines += "charge started_epoch_ms=${charge.session.startedAtMs}" +
" gained_percent=${format(charge.session.gainedPercent)}" +
" hours=${format(charge.session.durationHours)}" +
" watched=${charge.watched}" +
" max_entry_gap_ms=${charge.maxEntryGapMs}" +
// Unwatched, the integral only moved during the few seconds the app was up: a
// number there reads as a measured charge of nearly nothing.
" pack_delta_kwh=${format(charge.packDeltaKwh?.takeIf { charge.watched })}" +
" mean_power_kw=${format(charge.meanPowerKw)}" +
" consumed_delta_kwh=${format(charge.consumedDeltaKwh)}" +
" regenerated_delta_kwh=${format(charge.regeneratedDeltaKwh)}" +
" moved_km=${format(charge.movedKm)}" +
" counters=${charge.counterBehaviour.name}" +
" sign=${charge.sign.name}" +
" plugged=${charge.plugged}" +
" peak_power_kw=${format(charge.peakPowerKw)}" +
" steps=${charge.steps.size}"
}
powerBySocBand().forEach {
lines += "soc_band ${it.fromPercent}-${it.toPercent} mean_power_kw=${format(it.meanPowerKw)}" +
" hours=${format(it.hours)}"
}
return lines
}
private fun format(value: Double?): String =
if (value == null) "unavailable" else String.format(Locale.ROOT, "%.2f", value)
companion object {
/** A bundle is bounded; the newest charges are the ones a question is asked about. */
const val MAX_DESCRIBED_CHARGES = 8
const val SOC_BAND_PERCENT = 10
}
}
/**
* The energy side of a charge, read from the ledger that is already being written.
*
* Nothing new is sampled and no new vehicle call is made. [BatteryLedgerRecorder] has been
* recording the charge gauge, the car's counters, the integrated pack energy and the odometer
* since CP-070; [BatteryExposure] already cuts that stream into charge sessions. What was
* missing is a reader: the pack's health is estimated from discharges alone, so the energy
* *entering* the pack — and with it the two facts RI-002 has been blocked on — was recorded and
* never looked at.
*
* **Sessions are not re-segmented here.** They arrive from [BatteryExposure], so the two
* screens can never disagree about what counts as a charge.
*/
class ChargeEnergyAnalyzer(
private val maxWatchedGapMs: Long = MAX_WATCHED_GAP_MS,
) {
fun analyse(
entries: List<BatteryLedgerEntry>,
sessions: List<BatteryChargeSession>,
): ChargeEnergyReport {
val ordered = entries.sortedBy { it.atMs }
val charges = sessions.map { measure(ordered, it) }
return ChargeEnergyReport(
charges = charges,
packSign = consensusSign(charges),
counterBehaviour = mostInformative(charges.map { it.counterBehaviour }),
chargingStatuses = charges.flatMap { it.chargingStatuses }.distinct().sorted(),
)
}
private fun measure(
ordered: List<BatteryLedgerEntry>,
session: BatteryChargeSession,
): ChargeEnergy {
val slice = ordered.filter { it.atMs in session.startedAtMs..session.endedAtMs }
var gap = 0L
for (index in 1 until slice.size) gap = max(gap, slice[index].atMs - slice[index - 1].atMs)
val watched = slice.size >= 2 && gap <= maxWatchedGapMs
val packDelta = span(slice.mapNotNull { it.packEnergyKwh })
val consumed = span(slice.mapNotNull { it.vehicleConsumedKwh?.toDouble() })
val regenerated = span(slice.mapNotNull { it.vehicleRegeneratedKwh?.toDouble() })
val hours = session.durationHours
val temps = slice.mapNotNull { it.outsideTempCelsius?.toDouble() }
return ChargeEnergy(
session = session,
watched = watched,
maxEntryGapMs = gap,
packDeltaKwh = packDelta,
meanPowerKw = if (watched && packDelta != null && hours > 0.0) {
abs(packDelta) / hours
} else {
null
},
counterBehaviour = behaviourOf(consumed, regenerated),
consumedDeltaKwh = consumed,
regeneratedDeltaKwh = regenerated,
movedKm = span(slice.mapNotNull { it.odometerKm?.toDouble() }),
chargingStatuses = slice.mapNotNull { it.chargingStatus }.distinct(),
steps = if (watched) steps(slice) else emptyList(),
minOutsideTempCelsius = temps.minOrNull(),
maxOutsideTempCelsius = temps.maxOrNull(),
)
}
/** Adjacent pairs that both carry the integral; a pair with none says nothing and is dropped. */
private fun steps(slice: List<BatteryLedgerEntry>): List<ChargePowerStep> =
slice.zipWithNext().mapNotNull { (a, b) ->
val from = a.packEnergyKwh ?: return@mapNotNull null
val to = b.packEnergyKwh ?: return@mapNotNull null
val ms = b.atMs - a.atMs
if (ms <= 0L || !from.isFinite() || !to.isFinite()) return@mapNotNull null
ChargePowerStep(
fromSocPercent = a.socPercent.toDouble(),
toSocPercent = b.socPercent.toDouble(),
durationMs = ms,
powerKw = abs(to - from) / (ms / 3_600_000.0),
)
}
/** Last reading minus first, or null when fewer than two of them were published. */
private fun span(values: List<Double>): Double? {
val finite = values.filter { it.isFinite() }
return if (finite.size < 2) null else finite.last() - finite.first()
}
private fun behaviourOf(consumed: Double?, regenerated: Double?): ChargeCounterBehaviour {
val deltas = listOfNotNull(consumed, regenerated)
if (deltas.isEmpty()) return ChargeCounterBehaviour.ABSENT
if (deltas.any { it <= -COUNTER_EPSILON_KWH }) return ChargeCounterBehaviour.RESET
if (deltas.any { it >= COUNTER_EPSILON_KWH }) return ChargeCounterBehaviour.ROSE
return ChargeCounterBehaviour.STATIC
}
/**
* One answer from several charges, or an admission that there is not one.
*
* Two charges that signed the pack pair differently is not a figure to average: it says the
* property means something this app has not understood, and reporting either half of it as
* the answer would settle RI-002 wrongly.
*/
private fun consensusSign(charges: List<ChargeEnergy>): ChargePackSign {
val signs = charges.map { it.sign }
val decided = signs.filter {
it == ChargePackSign.ENERGY_FELL || it == ChargePackSign.ENERGY_ROSE
}.distinct()
return when {
decided.size > 1 -> ChargePackSign.CONTRADICTORY
decided.size == 1 -> decided.first()
signs.contains(ChargePackSign.FLAT) -> ChargePackSign.FLAT
else -> ChargePackSign.UNREADABLE
}
}
/**
* A reset seen once is a fact about the car; a static reading elsewhere only says that
* charge did not end where the car clears its counters. So the set is reported by its most
* informative member rather than refused as a contradiction.
*/
private fun mostInformative(behaviours: List<ChargeCounterBehaviour>): ChargeCounterBehaviour =
INFORMATIVENESS.firstOrNull { behaviours.contains(it) } ?: ChargeCounterBehaviour.ABSENT
companion object {
/**
* How long the ledger may go quiet inside a charge before it stops being watched.
*
* Twice [BatteryLedgerRecorder.QUIET_INTERVAL_MS]: the recorder earns an entry every
* quarter of an hour on its own, so a longer silence is this app having been down rather
* than a slow charge. The margin covers a sampler that stopped for one interval.
*/
const val MAX_WATCHED_GAP_MS = 2 * BatteryLedgerRecorder.QUIET_INTERVAL_MS
/**
* Below this a charge says nothing about the sign.
*
* A watched charge of any length moves the integral by kilowatt-hours; half of one is
* comfortably below anything real and comfortably above the drift of a pack pair read
* at rest.
*/
const val MIN_ENERGY_KWH = 0.5
/** The car's counters step by a whole kilowatt-hour, so anything above noise is a move. */
const val COUNTER_EPSILON_KWH = 0.05
private val INFORMATIVENESS = listOf(
ChargeCounterBehaviour.RESET,
ChargeCounterBehaviour.ROSE,
ChargeCounterBehaviour.STATIC,
ChargeCounterBehaviour.ABSENT,
)
}
}
@@ -0,0 +1,108 @@
package com.evsuite.hardware.telemetry
import java.time.Instant
import java.time.YearMonth
import java.time.ZoneId
/**
* What a drive asked of the pack at the moments a pack minds most (CP-090): hard discharge at a
* low charge or in the cold, and regeneration pushed into a cold pack.
*
* Measured quantities only, never a wear figure. "Hard" is the trip's own: discharge above
* [HARD_SHARE] of that trip's peak, so a gentle driver's hardest moments count as well as a
* heavy one's.
*
* [TripSample.batteryPowerKw]'s sign is unvalidated on this car (RI-002), so each trip orients
* itself: the charge fell, so the way the power integral moved over the trip is discharge. A trip
* whose charge did not fall, or whose integral did not move, cannot say which way is which and
* gives no figure.
*/
data class DrivingStress(
/** Minutes of hard discharge below [LOW_SOC_PERCENT] charge or below [COLD_CELSIUS] outside. */
val stressedMinutes: Double,
/** Energy regenerated below [COLD_CELSIUS] outside; null when the temperature was never read. */
val coldRegeneratedKwh: Double?,
) {
companion object {
/**
* Null when the trip cannot say: no orientation, or more than [MAX_UNDECIDED_SHARE] of
* its time with an unread power, or a hard moment with an unread charge or temperature.
*/
fun of(samples: List<TripSample>): DrivingStress? {
val socs = samples.mapNotNull { it.socPercent }
if (socs.size < 2 || socs.first() - socs.last() < MIN_SOC_DROP) return null
val segments = samples.zipWithNext().filter { (a, b) -> b.atMs - a.atMs in 1..MAX_GAP_MS }
val net = segments.sumOf { (a, b) -> (a.batteryPowerKw ?: 0f) * hours(a, b) }
val orientation = when {
net >= MIN_NET_KWH -> 1.0
net <= -MIN_NET_KWH -> -1.0
else -> return null
}
val peak = samples.mapNotNull { it.batteryPowerKw }.maxOfOrNull { it * orientation } ?: return null
if (peak <= 0.0) return null
var totalMs = 0L
var undecidedMs = 0L
var stressedMs = 0L
var coldKnown = false
var coldRegen = 0.0
segments.forEach { (a, b) ->
val ms = b.atMs - a.atMs
totalMs += ms
// An unread power could have been a hard one.
val power = a.batteryPowerKw ?: run { undecidedMs += ms; return@forEach }
val discharge = power * orientation
val cold = a.outsideTempCelsius?.let { it < COLD_CELSIUS }
val low = a.socPercent?.let { it < LOW_SOC_PERCENT }
if (cold != null) coldKnown = true
if (cold == true && discharge < 0.0) coldRegen += -discharge * hours(a, b)
if (discharge <= HARD_SHARE * peak) return@forEach
when {
cold == true || low == true -> stressedMs += ms
cold == null || low == null -> undecidedMs += ms
}
}
if (totalMs == 0L || undecidedMs > MAX_UNDECIDED_SHARE * totalMs) return null
return DrivingStress(stressedMs / MINUTE_MS, coldRegen.takeIf { coldKnown })
}
/** Per local month, over the trips that could say; a month none could say is absent. */
fun monthly(trips: List<StoredTrip>, zone: ZoneId): List<MonthlyDrivingStress> = trips
.mapNotNull { trip -> of(trip.samples.orEmpty())?.let { trip.summary.startedAtMs to it } }
.groupBy { (atMs, _) -> YearMonth.from(Instant.ofEpochMilli(atMs).atZone(zone)) }
.map { (month, list) ->
val regen = list.mapNotNull { it.second.coldRegeneratedKwh }
MonthlyDrivingStress(
month = month,
stressedMinutes = list.sumOf { it.second.stressedMinutes },
coldRegeneratedKwh = regen.takeIf { it.isNotEmpty() }?.sum(),
trips = list.size,
)
}
.sortedBy { it.month }
private fun hours(a: TripSample, b: TripSample) = (b.atMs - a.atMs) / HOUR_MS
const val HARD_SHARE = 0.6
const val LOW_SOC_PERCENT = 20f
const val COLD_CELSIUS = 0f
/** A trip that spent less than a point cannot orient its power. */
const val MIN_SOC_DROP = 1f
const val MIN_NET_KWH = 0.1
/** A longer hole in the track is a pause, not a stretch of known power. */
const val MAX_GAP_MS = 5 * 60_000L
/** A dropped reading here and there is not a blind trip; more than this is. */
const val MAX_UNDECIDED_SHARE = 0.1
private const val MINUTE_MS = 60_000.0
private const val HOUR_MS = 3_600_000.0
}
}
data class MonthlyDrivingStress(
val month: YearMonth,
val stressedMinutes: Double,
val coldRegeneratedKwh: Double?,
/** Trips that could say; the others are not counted as zero. */
val trips: Int,
)
@@ -0,0 +1,384 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
import java.util.Locale
import kotlin.math.abs
/** How this drive compares with what the driver's own fitted history expected of it. */
enum class EcoBand {
/** Below the expectation by more than the fit's own band. */
BETTER,
/** Inside the band. A fit that does not know much says this often, and should. */
TYPICAL,
/** Above the expectation by more than the band. */
WORSE,
}
/**
* The one thing worth saying, chosen from the two the recorded signals can measure.
*
* The climate system is deliberately not here. Cabin state is passive state — an on/off flag and
* a fan level — and turning either into kilowatts is exactly what `AGENTS.md` forbids. The cabin's
* share is a fitted number that only exists after the trip, where `EnergyAttribution` produces it.
*/
enum class EcoLever {
/**
* Drag rises with the square of speed, and the fitted model carries that term, so the saving
* is arithmetic on a fit this car trained rather than a rule of thumb.
*/
CRUISE_SPEED,
/**
* Time spent accelerating or braking hard. Measured from the speed channel alone, which
* matters: on a firmware where battery power is derived rather than measured this lever is
* still a measurement.
*/
STEADINESS,
}
/**
* One piece of advice, with the number that earned it.
*
* Exactly one of the two number pairs is populated, decided by [lever]. There is no common
* currency between "14 % less at 110" and "a fifth of this drive was hard acceleration", and
* inventing one to rank them would be inventing the more important of the two figures.
*/
data class EcoAdvice(
val lever: EcoLever,
/** [EcoLever.CRUISE_SPEED]: what the fit says the slower speed costs, as a percentage saved. */
val savingPercent: Double? = null,
val fromSpeedKmh: Double? = null,
val toSpeedKmh: Double? = null,
/** [EcoLever.STEADINESS]: the share of moving time spent beyond the threshold, either sign. */
val harshSharePercent: Double? = null,
)
/**
* What this drive is costing, against what this driver's own history expected it to cost.
*
* Everything here is in **percent of charge per 100 km**, never kilowatt-hours, and that is a
* decision rather than a convenience: `EnergySnapshot.batteryPowerKw`'s sign convention is still
* unvalidated (RI-002, CP-073), so every kWh figure in this app inherits an open question. Charge
* and speed are the two signals CP-003 actually proved on this car, and a verdict the driver is
* asked to act on is built on those.
*/
data class EcoVerdict(
/**
* The verdict, and — as its band — the width of the model's own `TYPICAL` zone in percent
* per 100 km: how far [observedPercentPer100Km] would have had to move to say something
* else. A fit that knows little carries a wide one and says `TYPICAL` for almost any drive,
* which is the honest answer from a fit that knows little.
*/
val band: Provenanced<EcoBand>,
val observedPercentPer100Km: Double? = null,
val expectedPercentPer100Km: Double? = null,
/** Signed: negative is below the expectation, which is the good direction. */
val deltaPercent: Double? = null,
val meanSpeedKmh: Double? = null,
val advice: EcoAdvice? = null,
) {
/** For the diagnostic bundle: the verdict, its inputs, and any refusal, on one line each. */
fun describe(): List<String> {
val lines = ArrayList<String>()
lines += "band=" + (band.value?.name ?: "unavailable(${band.reason?.name})")
lines += "observed_percent_per_100km=${format(observedPercentPer100Km)}"
lines += "expected_percent_per_100km=${format(expectedPercentPer100Km)}"
lines += "delta_percent=${format(deltaPercent)}"
lines += "mean_speed_kmh=${format(meanSpeedKmh)}"
val advice = advice
lines += if (advice == null) {
"advice=none"
} else {
"advice=${advice.lever.name}" +
" saving_percent=${format(advice.savingPercent)}" +
" from_speed_kmh=${format(advice.fromSpeedKmh)}" +
" to_speed_kmh=${format(advice.toSpeedKmh)}" +
" harsh_share_percent=${format(advice.harshSharePercent)}"
}
return lines
}
private fun format(value: Double?): String =
if (value == null) "unavailable" else String.format(Locale.ROOT, "%.2f", value)
}
/**
* The eco verdict and its one piece of advice, live on the dashboard and replayed after the trip.
*
* **One analyzer, two callers.** The dashboard feeds it the 1 Hz speed channel as it arrives; the
* trip review replays a stored [TripSample] track through the same instance. That is the whole
* reason it lives here rather than in the app: the number the driver saw at 90 km/h and the number
* they read afterwards come out of the same code, and cannot disagree.
*
* **It costs nothing to run.** No new sampling, no vehicle call, no allocation per frame beyond one
* bounded deque of speeds. The verdict is arithmetic over a trip summary the recorder already keeps
* and a model CP-052 already trained.
*
* **It refuses rather than guesses.** No fit, a speed outside the envelope the fit was trained in,
* too little distance to divide by, too small a charge drop to read against a gauge that moves in
* whole percent — each of those is an [UnavailableReason] the rest of the app already renders, not
* a number with a shrug attached.
*/
class EcoDrivingMonitor(
private val windowMs: Long = WINDOW_MS,
private val harshAccelerationMs2: Double = HARSH_ACCELERATION_MS2,
private val maxSamples: Int = MAX_WINDOW_SAMPLES,
private val maxGapMs: Long = MAX_GAP_MS,
) {
private data class Speed(val atMs: Long, val kmh: Double)
private val window = ArrayDeque<Speed>()
init {
require(windowMs > 0L) { "the lever window must be positive" }
require(harshAccelerationMs2 > 0.0) { "the acceleration threshold must be positive" }
require(maxSamples > 1) { "a window of one sample measures nothing" }
require(maxGapMs > 0L) { "the sampling gap must be positive" }
}
/** Feed one frame. A null or negative speed is a gap in the window, never a zero. */
fun add(atMs: Long, speedKmh: Float?) {
if (window.lastOrNull()?.let { atMs <= it.atMs } == true) reset()
val speed = speedKmh?.toDouble()?.takeIf { it.isFinite() && it >= 0.0 }
if (speed != null) window.addLast(Speed(atMs, speed))
while (window.size > maxSamples) window.removeFirst()
while (window.size > 1 && atMs - window.first().atMs > windowMs) window.removeFirst()
}
fun reset() = window.clear()
/** Replays a stored track, so a finished trip is judged by the same code that judged it live. */
fun replay(samples: List<TripSample>): EcoDrivingMonitor {
reset()
samples.forEach { add(it.atMs, it.speedKmh) }
return this
}
/**
* The share of moving time spent accelerating or braking hard, whatever the cruise lever said.
*
* Live, the two levers compete for one line and the cruise lever wins when it has a measured
* saving. A trip review has no such contest: the motorway question is answered there by
* `SpeedWhatIfCalculator` from the trip's own intervals, and this is the other half.
*/
fun steadiness(): EcoAdvice? = steadinessAdvice()
/**
* @param trip the trip so far, live, or the finished one. Its charge drop and its distance are
* what the verdict is measured from.
* @param outsideTempCelsius the fit's second input; without it the model cannot be asked.
*/
fun verdict(
model: SocConsumptionModel?,
trip: EnergyTripSummary?,
outsideTempCelsius: Float?,
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
): EcoVerdict {
val advice = advice(model, outsideTempCelsius)
if (model == null) return refuse(UnavailableReason.MODEL_NOT_TRAINED, advice)
// A distance the current speed conversion did not produce may carry a 3.6x error, and a
// verdict built on it would be wrong by the same factor without saying so.
val distanceKm = trip?.modellableDistanceKm(generation)
val start = trip?.startSocPercent?.toDouble()
val end = trip?.endSocPercent?.toDouble()
val hours = trip?.durationMs?.let { it / MILLIS_PER_HOUR }
if (distanceKm == null || distanceKm < SocConsumptionFitter.MIN_SEGMENT_KM ||
start == null || end == null || hours == null || hours <= 0.0
) {
return refuse(UnavailableReason.INSUFFICIENT_SAMPLES, advice)
}
// The gauge moves in whole percent. Below the fitter's own floor the quantisation is
// larger than the thing being measured.
val drop = start - end
if (drop < SocConsumptionFitter.MIN_SOC_DROP_PERCENT) {
return refuse(UnavailableReason.INSUFFICIENT_SAMPLES, advice)
}
val meanSpeedKmh = distanceKm / hours
val temp = outsideTempCelsius?.toDouble()
?: return refuse(UnavailableReason.SIGNAL_ABSENT, advice, meanSpeedKmh)
val expected = model.predict(meanSpeedKmh, temp)
val expectedValue = expected.value
?: return refuse(
expected.reason ?: UnavailableReason.MODEL_NOT_TRAINED, advice, meanSpeedKmh
)
val observed = drop / distanceKm * PER_100_KM
// Compared against the fit's *own* band rather than a threshold invented here: a fit with
// a wide residual says TYPICAL more often, which is the correct behaviour for a fit that
// does not know much yet. Half of it, though, not all: the prediction band is 1.96
// residuals wide, so TYPICAL covered 95 % of drives and the verdict almost never said
// anything else. One residual either side leaves about a third of drives outside it.
val band = (expected.uncertainty ?: 0.0) * VERDICT_BAND_SHARE
val verdict = when {
observed < expectedValue - band -> EcoBand.BETTER
observed > expectedValue + band -> EcoBand.WORSE
else -> EcoBand.TYPICAL
}
return EcoVerdict(
band = Provenanced.estimated(verdict, band),
observedPercentPer100Km = observed,
expectedPercentPer100Km = expectedValue,
deltaPercent = (observed - expectedValue) / expectedValue * PERCENT,
meanSpeedKmh = meanSpeedKmh,
advice = advice,
)
}
private fun refuse(
reason: UnavailableReason,
advice: EcoAdvice?,
meanSpeedKmh: Double? = null,
) = EcoVerdict(
band = Provenanced.unavailable(reason),
meanSpeedKmh = meanSpeedKmh,
advice = advice,
)
/**
* At most one lever, and by precedence rather than by a ranking that would have to invent a
* comparison. The two are near-disjoint in practice anyway: cruise speed cannot fire below
* [CRUISE_MIN_KMH], and a drive spent above it is not a drive spent accelerating.
*/
private fun advice(model: SocConsumptionModel?, outsideTempCelsius: Float?): EcoAdvice? =
cruiseAdvice(model, outsideTempCelsius) ?: steadinessAdvice()
private fun cruiseAdvice(
model: SocConsumptionModel?,
outsideTempCelsius: Float?,
): EcoAdvice? {
if (model == null) return null
val temp = outsideTempCelsius?.toDouble() ?: return null
val from = windowMeanSpeedKmh() ?: return null
if (from < CRUISE_MIN_KMH) return null
val to = from - CRUISE_STEP_KMH
val at = model.predict(from, temp).value ?: return null
val slower = model.predict(to, temp).value ?: return null
val saving = (at - slower) / at * PERCENT
if (!saving.isFinite() || saving < MIN_ADVICE_SAVING_PERCENT) return null
return EcoAdvice(
lever = EcoLever.CRUISE_SPEED,
savingPercent = saving,
fromSpeedKmh = from,
toSpeedKmh = to,
)
}
/**
* The share of moving time spent accelerating or braking hard, below the advice threshold
* included.
*
* Braking counts because every hard stop throws away what regeneration could not take back,
* and because the trip review (`DrivingStyle` in EVChargePilot) has always counted it: a live
* gauge that saw only half the habit read lower than the summary of the same drive.
*
* Null only while the window holds less than [MIN_LEVER_WINDOW_MS] of movement, so a caller
* can tell "not measured yet" from "measured, and calm" — the two read the same through
* [steadiness], which answers only when there is something to say.
*/
fun harshSharePercent(): Double? {
var movingMs = 0L
var harshMs = 0L
window.zipWithNext { previous, current ->
val deltaMs = current.atMs - previous.atMs
// Beyond the gap this app allows itself to integrate over, the missing minutes are
// unknown motion. Reading them as one enormous step would invent an acceleration
// out of the app having been asleep.
if (deltaMs !in 1..maxGapMs) return@zipWithNext
if (current.kmh <= 0.0 && previous.kmh <= 0.0) return@zipWithNext
movingMs += deltaMs
val acceleration = (current.kmh - previous.kmh) / KMH_PER_MS2 / (deltaMs / 1000.0)
if (abs(acceleration) >= harshAccelerationMs2) harshMs += deltaMs
}
if (movingMs < MIN_LEVER_WINDOW_MS) return null
return harshMs.toDouble() / movingMs * PERCENT
}
private fun steadinessAdvice(): EcoAdvice? {
val share = harshSharePercent() ?: return null
if (share < MIN_HARSH_SHARE_PERCENT) return null
return EcoAdvice(lever = EcoLever.STEADINESS, harshSharePercent = share)
}
private fun windowMeanSpeedKmh(): Double? {
if (window.size < 2) return null
var weightedMs = 0L
var distanceKmh = 0.0
window.zipWithNext { previous, current ->
val deltaMs = current.atMs - previous.atMs
if (deltaMs !in 1..maxGapMs) return@zipWithNext
weightedMs += deltaMs
distanceKmh += (previous.kmh + current.kmh) / 2.0 * deltaMs
}
if (weightedMs < MIN_LEVER_WINDOW_MS) return null
return distanceKmh / weightedMs
}
companion object {
/**
* The whole of a finished drive, rather than the last three minutes of a live one.
*
* A review that judged steadiness on the final window would grade the driver on their
* arrival, and the bound that keeps the live window cheap has no purpose over a track
* that is already on the disk.
*/
fun forReplay() = EcoDrivingMonitor(
windowMs = Long.MAX_VALUE,
maxSamples = Int.MAX_VALUE,
maxGapMs = REPLAY_MAX_GAP_MS,
)
/** The share of the prediction band that counts as TYPICAL: about one residual. */
const val VERDICT_BAND_SHARE = 0.5
/** Long enough that a single roundabout is not a driving style. */
const val WINDOW_MS = 180_000L
/**
* Brisk rather than emergency. Eco-driving practice puts the useful line around here, and
* the number is a calibration knob rather than physics: a heavier car, a different tyre or
* a driver who simply disagrees moves it, and nothing else in this class assumes its value.
*
* 1.5 m/s² was too low on the car: ordinary town drives counted 7 to 16 hard stops every
* 10 km (bundle of 2026-10-18), and regeneration on High alone comes close to that line
* with no brake pedal at all. 2.5 sits above lift-off, where the brakes do the work.
*/
const val HARSH_ACCELERATION_MS2 = 2.5
/** Below this the levers are measuring a traffic light, not a drive. */
const val MIN_LEVER_WINDOW_MS = 60_000L
/** Under this share, saying anything would be nagging about noise. */
const val MIN_HARSH_SHARE_PERCENT = 12.0
/** Motorway, where the square term is worth more than everything else on this list. */
const val CRUISE_MIN_KMH = 95.0
/** The step the advice proposes: large enough to matter, small enough to be plausible. */
const val CRUISE_STEP_KMH = 10.0
/** A saving smaller than this is not worth a line the driver has to read. */
const val MIN_ADVICE_SAVING_PERCENT = 3.0
/** The app's own integration rule: past five seconds, the motion in between is unknown. */
const val MAX_GAP_MS = 5_000L
/** Stored samples cross the five-second boundary on the next live telemetry frame. */
private const val REPLAY_MAX_GAP_MS = TripSampleTrack.DEFAULT_INTERVAL_MS * 2
/** Three minutes at 1 Hz, with room for a faster caller. */
const val MAX_WINDOW_SAMPLES = 1_024
private const val MILLIS_PER_HOUR = 3_600_000.0
private const val PER_100_KM = 100.0
private const val PERCENT = 100.0
/** 1 m/s² is 3.6 km/h per second. */
private const val KMH_PER_MS2 = 3.6
}
}
@@ -0,0 +1,81 @@
package com.evsuite.hardware.telemetry
import kotlin.math.roundToInt
/**
* A trip out of 100, and the three measured parts it is made of.
*
* Every part is a measurement this car is known to give: hard braking and hard acceleration
* from the speed channel, counted at 1 Hz by [EnergyTripAccumulator], and the charge against
* the driver's own fitted expectation from [EcoVerdict]. A part that could not be measured is
* null and the total is taken over the others; a trip where none could be measured has no score
* at all, never a default one.
*
* The scales are calibration knobs rather than physics. They are set so a calm town drive at
* the driver's usual consumption lands in the sixties and the eighties need effort: a score that
* starts near 100 has nothing left to reward.
*/
data class EcoScore(
val total: Int,
/** Hard braking per 10 km: braking hard throws away what regeneration could not take back. */
val anticipation: Int?,
/** Share of moving time beyond the hard-acceleration threshold, either sign. */
val smoothness: Int?,
/** Charge against the driver's own expectation at this speed and temperature. */
val efficiency: Int?,
) {
companion object {
fun of(summary: EnergyTripSummary, verdict: EcoVerdict?): EcoScore? {
val distanceKm = summary.recordedDistanceKm
val anticipation = summary.harshBrakings?.let { brakings ->
distanceKm?.takeIf { it >= MIN_DISTANCE_KM }?.let { km ->
scale(brakings / km * PER_10_KM, best = 0.0, worst = BRAKINGS_PER_10_KM_AT_ZERO)
}
}
val smoothness = summary.harshMs?.let { harsh ->
summary.movingMs?.takeIf { it >= MIN_MOVING_MS }?.let { moving ->
scale(harsh.toDouble() / moving * PERCENT, best = 0.0, worst = HARSH_SHARE_AT_ZERO)
}
}
val efficiency = verdict?.takeIf { it.band.value != null }?.deltaPercent?.let {
scale(it, best = DELTA_AT_FULL, worst = DELTA_AT_ZERO)
}
val parts = listOf(
anticipation to ANTICIPATION_WEIGHT,
smoothness to SMOOTHNESS_WEIGHT,
efficiency to EFFICIENCY_WEIGHT,
).filter { it.first != null }
if (parts.isEmpty()) return null
val total = parts.sumOf { it.first!! * it.second } / parts.sumOf { it.second }
return EcoScore(total.roundToInt(), anticipation, smoothness, efficiency)
}
/** 100 at [best], 0 at [worst], straight in between and clamped outside. */
private fun scale(value: Double, best: Double, worst: Double): Int? {
if (!value.isFinite()) return null
val share = ((worst - value) / (worst - best)).coerceIn(0.0, 1.0)
return (share * PERCENT).roundToInt()
}
/** Shorter than the fitter's own segment, a single junction would be a habit. */
const val MIN_DISTANCE_KM = 2.0
const val MIN_MOVING_MS = 60_000L
/** Six hard stops every 10 km is a drive with no anticipation left to measure. */
const val BRAKINGS_PER_10_KM_AT_ZERO = 6.0
/** A quarter of moving time spent hard is the bottom of the scale. */
const val HARSH_SHARE_AT_ZERO = 25.0
/** 15 % under the driver's own expectation is full marks; 25 % over is none. */
const val DELTA_AT_FULL = -15.0
const val DELTA_AT_ZERO = 25.0
const val ANTICIPATION_WEIGHT = 0.35
const val SMOOTHNESS_WEIGHT = 0.25
const val EFFICIENCY_WEIGHT = 0.40
private const val PER_10_KM = 10.0
private const val PERCENT = 100.0
}
}
@@ -1,5 +1,8 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.model.DriveMode
import com.evsuite.hardware.model.RegenLevel
import com.evsuite.hardware.FirmwareInfo
/** One immutable, nullable, read-only view of the vehicle's energy state. */
@@ -33,6 +36,18 @@ data class EnergySnapshot(
val tirePressures: TirePressureSnapshot,
val batteryVoltageV: Float? = null,
val batteryCurrentA: Float? = null,
/**
* CP-086. The widest-open window, 0 closed to 100 open; null where the car does not answer,
* never 0 — a closed window and an unread one are different facts to the attribution.
*/
val widestWindowPercent: Int? = null,
/**
* The vendor's own drive mode and regen codes, unmapped: [DriveMode.ofRaw] and
* [RegenLevel.ofRaw] decode them, and a code neither knows stays a code rather than
* becoming NORMAL or MEDIUM. Null where the car did not answer.
*/
val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null,
) {
val hasVehicleData: Boolean
get() = socPercent != null || rangeKm != null || speedKmh != null ||
@@ -40,7 +55,8 @@ data class EnergySnapshot(
batteryTempCelsius != null || batteryEnergyKwh != null || batteryCapacityKwh != null ||
odometerKm != null || chargePortConnected != null || chargingStatus != null ||
vehicleConsumedKwh != null || vehicleRegeneratedKwh != null ||
parked != null || climate.hasData || tirePressures.hasData
parked != null || climate.hasData || tirePressures.hasData ||
widestWindowPercent != null || driveModeRaw != null || regenLevelRaw != null
}
data class TirePressureSnapshot(
@@ -6,6 +6,7 @@ import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.saic.SaicCharging
import com.evsuite.hardware.saic.SaicClimate
import com.evsuite.hardware.saic.SaicHub
import com.evsuite.hardware.saic.SaicVehicleControl
/** Injectable signal seam: production uses [EvHardwareEnergySignalSource], tests use a fake. */
interface EnergySignalSource {
@@ -28,6 +29,11 @@ interface EnergySignalSource {
fun parked(): Boolean?
fun batteryVoltageV(): Float? = null
fun batteryCurrentA(): Float? = null
/** Widest window opening in percent; null where the firmware has no read. */
fun widestWindowPercent(): Int? = null
/** Raw vendor codes; null where the firmware has no read. */
fun driveModeRaw(): Int? = null
fun regenLevelRaw(): Int? = null
fun climate(): ClimateSnapshot
fun tirePressures(): TirePressureSnapshot
/** Probe-only values excluded from [EnergySnapshot] and normal consumers. */
@@ -70,6 +76,9 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
override fun parked() = EVHardware.isVehicleInPark()
override fun batteryVoltageV() = EVHardware.getVendorBatteryVoltageV()
override fun batteryCurrentA() = EVHardware.getVendorBatteryCurrentA()
override fun widestWindowPercent() = SaicVehicleControl.widestWindowPercent()
override fun driveModeRaw() = EVHardware.getDriveModeRaw()
override fun regenLevelRaw() = EVHardware.getRegenLevelRaw()
override fun tirePressures() = TirePressureSnapshot(
frontLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_LEFT),
frontRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_RIGHT),
@@ -80,7 +89,23 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
EVHardware.probeCurrentBatteryCapacityWh()?.toDouble(),
)
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_CHARGE_STATUS,
EVHardware.probeVendorChargeStatus()?.toDouble(),
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_PLUG_AC,
EVHardware.probeVendorPlugAc()?.toDouble(),
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_PLUG_DC,
EVHardware.probeVendorPlugDc()?.toDouble(),
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_ENERGY_SINCE_CHARGE_KWH,
EVHardware.probeVendorEnergySinceChargeKwh()?.toDouble(),
),
)
override fun climate() = ClimateSnapshot(
powerOn = SaicClimate.powerOn(),
@@ -94,11 +119,9 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
?: EVHardware.getTemperatureSetCelsius(),
// The passenger setpoint is its own signal only when the car publishes one: the vendor
// service reads it with a default of 68, which its own converter maps to -1, and -1 is
// dropped upstream. With a single zone the driver's setpoint is what both seats get,
// so that is what the passenger reads — the same number, because it is the same air.
passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat()
?: SaicClimate.driverTemp()?.toFloat()
?: EVHardware.getTemperatureSetCelsius(),
// dropped upstream. Null then, not the driver's number: a screen that shows both seats
// the same air says so itself, and evidence captured here must not call a copy a reading.
passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat(),
)
}
@@ -128,6 +151,9 @@ class EnergyTelemetryReader(private val source: EnergySignalSource) {
tirePressures = source.tirePressures(),
batteryVoltageV = source.batteryVoltageV()?.takeIf { it.isFinite() },
batteryCurrentA = source.batteryCurrentA()?.takeIf { it.isFinite() },
widestWindowPercent = source.widestWindowPercent()?.takeIf { it in 0..100 },
driveModeRaw = source.driveModeRaw(),
regenLevelRaw = source.regenLevelRaw(),
)
/** Reads CP-004 candidates only when an unstable evidence capture explicitly requests them. */
@@ -32,6 +32,20 @@ data class EnergyTripSummary(
* train on it. See [VehicleSpeedEvidence].
*/
val speedEvidence: VehicleSpeedEvidence? = null,
/**
* How the trip was driven, counted at the live sampling rate over the whole trip.
*
* The stored track is one sample per five seconds, and a two-second hard stop averaged over
* five reads as a gentle one: replayed, the same drive came out calmer than it was. Null on
* trips recorded before these were counted, which a reader treats as "not counted", never 0.
*/
val movingMs: Long? = null,
val harshMs: Long? = null,
val harshAccelerations: Int? = null,
val harshBrakings: Int? = null,
/** The vendor drive mode and regen codes held for the longest share of the trip. */
val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null,
) {
/**
* The distance, only when the conversion behind it is the one believed correct today.
@@ -75,6 +89,13 @@ class EnergyTripAccumulator(
private var hasPowerInterval = false
private var recordedMs = 0L
private var latestSocPercent = startSocPercent
private var movingMs = 0L
private var harshMs = 0L
private var harshAccelerations = 0
private var harshBrakings = 0
private var harshSign = 0
private val modeMs = HashMap<Int, Long>()
private val regenMs = HashMap<Int, Long>()
fun add(sample: EnergySnapshot) {
track.add(sample)
@@ -90,6 +111,7 @@ class EnergyTripAccumulator(
hasSpeedInterval = true
distanceKm += ((previous.speedKmh + sample.speedKmh) / 2.0) * hours
}
countStyle(previous, sample, gapMs)
if (previous.batteryPowerKw != null && sample.batteryPowerKw != null) {
hasPowerInterval = true
val intervalKwh =
@@ -99,10 +121,36 @@ class EnergyTripAccumulator(
}
}
/**
* One interval of driving style. A hard stretch counts once however many seconds it lasts:
* a driver asked to brake less hard three times wants three to be the number, not eleven.
*/
private fun countStyle(previous: EnergySnapshot, sample: EnergySnapshot, gapMs: Long) {
sample.driveModeRaw?.let { modeMs.merge(it, gapMs, Long::plus) }
sample.regenLevelRaw?.let { regenMs.merge(it, gapMs, Long::plus) }
val from = previous.speedKmh ?: return
val to = sample.speedKmh ?: return
if (from <= 0f && to <= 0f) {
harshSign = 0
return
}
movingMs += gapMs
val acceleration = (to - from) / KMH_PER_MS2 / (gapMs / 1000.0)
val sign = when {
acceleration >= EcoDrivingMonitor.HARSH_ACCELERATION_MS2 -> 1
acceleration <= -EcoDrivingMonitor.HARSH_ACCELERATION_MS2 -> -1
else -> 0
}
if (sign != 0) harshMs += gapMs
if (sign == 1 && harshSign != 1) harshAccelerations++
if (sign == -1 && harshSign != -1) harshBrakings++
harshSign = sign
}
fun samples(): List<TripSample> = track.samples()
fun snapshot(nowMs: Long) = EnergyTripSummary(
startedAtMs = startedAtMs,
startedAtMs = startedAtMs(nowMs),
endedAtMs = nowMs,
durationMs = recordedMs,
distanceKm = distanceKm,
@@ -113,9 +161,30 @@ class EnergyTripAccumulator(
distanceAvailable = hasSpeedInterval,
speedEvidence = speedEvidence.takeIf { hasSpeedInterval },
batteryPowerEvidence = batteryPowerEvidence.takeIf { hasPowerInterval },
movingMs = movingMs.takeIf { hasSpeedInterval },
harshMs = harshMs.takeIf { hasSpeedInterval },
harshAccelerations = harshAccelerations.takeIf { hasSpeedInterval },
harshBrakings = harshBrakings.takeIf { hasSpeedInterval },
driveModeRaw = modeMs.maxByOrNull { it.value }?.key,
regenLevelRaw = regenMs.maxByOrNull { it.value }?.key,
)
private companion object { const val MAX_SAMPLE_GAP_MS = 5_000L }
/**
* A trip that started before the head unit's clock was set (it boots on its factory date and
* syncs later) would sort years back. Once the clock is right, its start is the end minus the
* time driven; the jump itself is a gap [add] already refuses to count.
*/
private fun startedAtMs(nowMs: Long): Long =
if (startedAtMs < BatteryLedgerRecorder.MIN_PLAUSIBLE_CLOCK_MS &&
nowMs >= BatteryLedgerRecorder.MIN_PLAUSIBLE_CLOCK_MS
) nowMs - recordedMs else startedAtMs
private companion object {
const val MAX_SAMPLE_GAP_MS = 5_000L
/** 1 m/s² is 3.6 km/h per second. */
const val KMH_PER_MS2 = 3.6
}
}
/** Process-local recording state shared by any energy dashboard in the same app process. */
@@ -43,7 +43,7 @@ data class TripHistoryFile(
class EnergyTripHistoryStore(
private val target: File,
private val maxTrips: Int = 200,
private val maxBytes: Int = 512 * 1024,
private val maxBytes: Int = 2 * 1024 * 1024,
private val gson: Gson = Gson(),
) {
/** Trips with whatever tracks survived eviction, newest first. */
@@ -104,6 +104,7 @@ class TelemetryEvidenceRecorder {
record(CHARGING_STATUS, snapshot.chargingStatus?.toDouble(), at)
record(CHARGE_PORT_CONNECTED, snapshot.chargePortConnected, at)
record(PARKED, snapshot.parked, at)
record(WINDOWS_WIDEST_PERCENT, snapshot.widestWindowPercent?.toDouble(), at)
val climate = snapshot.climate
record(CLIMATE_POWER_ON, climate.powerOn, at)
@@ -250,6 +251,7 @@ class TelemetryEvidenceRecorder {
const val CHARGING_STATUS = "chargingStatus"
const val CHARGE_PORT_CONNECTED = "chargePortConnected"
const val PARKED = "parked"
const val WINDOWS_WIDEST_PERCENT = "windows.widestPercent"
const val CLIMATE_POWER_ON = "climate.powerOn"
const val CLIMATE_AC_ON = "climate.acOn"
const val CLIMATE_AUTO_ON = "climate.autoOn"
@@ -261,6 +263,10 @@ class TelemetryEvidenceRecorder {
const val CLIMATE_PASSENGER_TARGET_C = "climate.passengerTargetCelsius"
const val CANDIDATE_CURRENT_BATTERY_CAPACITY_WH =
"candidate.evCurrentBatteryCapacityWh"
const val CANDIDATE_VENDOR_CHARGE_STATUS = "candidate.vendorChargeStatus"
const val CANDIDATE_VENDOR_PLUG_AC = "candidate.vendorPlugAc"
const val CANDIDATE_VENDOR_PLUG_DC = "candidate.vendorPlugDc"
const val CANDIDATE_VENDOR_ENERGY_SINCE_CHARGE_KWH = "candidate.vendorEnergySinceChargeKwh"
}
}
@@ -73,7 +73,8 @@ class TripDetector {
transition(State.RECORDING, timestampMs)
Result(state)
}
elapsed(timestampMs) >= STOP_DEBOUNCE_MS && endConfirmed(snapshot) -> {
elapsed(timestampMs) >= STOP_DEBOUNCE_MS && endConfirmed(snapshot) ||
elapsed(timestampMs) >= PARKED_STOP_DEBOUNCE_MS && endObserved(snapshot) -> {
transition(State.IDLE, timestampMs)
Result(state, Event.STOP)
}
@@ -117,9 +118,13 @@ class TripDetector {
private fun endConfirmed(snapshot: EnergySnapshot): Boolean {
val confirmationAvailable = snapshot.parked != null || snapshot.chargePortConnected != null
return !confirmationAvailable || snapshot.parked == true || snapshot.chargePortConnected == true
return !confirmationAvailable || endObserved(snapshot)
}
/** The car said so itself: a gear in P or a cable in the port is not a traffic stop. */
private fun endObserved(snapshot: EnergySnapshot): Boolean =
snapshot.parked == true || snapshot.chargePortConnected == true
companion object {
/** Ignores parking creep and noisy zero readings; 5 km/h is unambiguous road motion. */
const val START_SPEED_KMH = 5.0
@@ -133,6 +138,13 @@ class TripDetector {
/** Longer than the ticket's 90-second traffic stop while still practical at arrival. */
const val STOP_DEBOUNCE_MS = 120_000L
/**
* The SWI68 head unit powers off about twelve seconds after P (2026-10-16: P at
* 12:13:54, off at 12:14:06), so the two-minute timer never ran out and every
* automatic trip died with the process. A confirmed P needs no traffic allowance.
*/
const val PARKED_STOP_DEBOUNCE_MS = 2_000L
/** Five seconds tolerates scheduler jitter but cannot bridge a meaningful telemetry hole. */
const val MAX_OBSERVATION_GAP_MS = 5_000L
}
@@ -24,6 +24,14 @@ data class TripSample(
val climatePowerOn: Boolean?,
val climateAcOn: Boolean?,
val climateFanLevel: Int?,
/** CP-086. Absent from trips stored before it, which Gson reads back as null: unknown. */
val widestWindowPercent: Int? = null,
/**
* Raw vendor drive mode and regen codes, as [EnergySnapshot] carries them. Ints rather than
* the enums: Gson writes an enum by its constant name, which a release build may rename.
*/
val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null,
) {
companion object {
fun of(snapshot: EnergySnapshot) = TripSample(
@@ -37,6 +45,9 @@ data class TripSample(
climatePowerOn = snapshot.climate.powerOn,
climateAcOn = snapshot.climate.acOn,
climateFanLevel = snapshot.climate.fanLevel,
widestWindowPercent = snapshot.widestWindowPercent,
driveModeRaw = snapshot.driveModeRaw,
regenLevelRaw = snapshot.regenLevelRaw,
)
}
}
@@ -7,7 +7,14 @@ import com.evsuite.hardware.telemetry.UnavailableReason
import kotlin.math.abs
import kotlin.math.max
enum class ResidualContext { CLIMATE_ACTIVE, CLIMATE_INACTIVE, CLIMATE_UNKNOWN }
/**
* Which auxiliary load an interval's residual may belong to.
*
* CP-086: [WINDOWS_OPEN] takes an interval with a window open at either end out of the climate
* groups, so the drag of open glass never reads as cabin energy. Its residual may still carry
* the climate system if that ran at the same time; its average fan level says whether it did.
*/
enum class ResidualContext { CLIMATE_ACTIVE, CLIMATE_INACTIVE, CLIMATE_UNKNOWN, WINDOWS_OPEN }
enum class ResidualFinding {
DISTINGUISHABLE,
@@ -115,7 +122,11 @@ object EnergyAttributionCalculator {
val modelled = predictedConsumption * distanceKm / 100.0
val uncertainty = predictedUncertainty * distanceKm / 100.0
val measured = (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * hours
val context = climateContext(previous, current)
val context = if (windowOpen(previous) || windowOpen(current)) {
ResidualContext.WINDOWS_OPEN
} else {
climateContext(previous, current)
}
tractionKwh += modelled
tractionUncertaintyKwh += uncertainty
@@ -159,6 +170,9 @@ object EnergyAttributionCalculator {
}
}
private fun windowOpen(sample: TripSample): Boolean =
sample.widestWindowPercent?.let { it > 0 } == true
private fun climateActive(sample: TripSample): Boolean? = when {
sample.climatePowerOn == true || sample.climateAcOn == true ||
sample.climateFanLevel?.let { it > 0 } == true -> true
@@ -6,6 +6,7 @@ import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.telemetry.StoredTrip
import com.evsuite.hardware.telemetry.UnavailableReason
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt
sealed interface EnergyModelTrainingResult {
@@ -40,29 +41,33 @@ class EnergyModelTrainer(
trips: List<StoredTrip>,
evidence: BatteryPowerEvidence,
): EnergyModelTrainingResult {
// Both fits are accumulated in the one pass; the temperature span picks which to solve.
val equations = LeastSquares3()
val narrowed = LeastSquares3(2)
var minSpeed = Double.POSITIVE_INFINITY
var maxSpeed = Double.NEGATIVE_INFINITY
var minTemp = Double.POSITIVE_INFINITY
var maxTemp = Double.NEGATIVE_INFINITY
val count = forEachUsable(trips, evidence) { speed, temp, consumption ->
equations.add(speed, temp, consumption)
narrowed.add(speed, temp, consumption)
minSpeed = minOf(minSpeed, speed)
maxSpeed = maxOf(maxSpeed, speed)
minTemp = minOf(minTemp, temp)
maxTemp = maxOf(maxTemp, temp)
}
if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH ||
maxTemp - minTemp < MIN_TEMPERATURE_SPAN_CELSIUS
) {
if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH) {
return EnergyModelTrainingResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
val coefficients = equations.solve()
// As in SocConsumptionFitter: one kind of weather is no evidence about weather, so two
// coefficients are fitted and the narrow envelope refuses the cold instead of guessing.
val features = if (maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
val coefficients = (if (features == 3) equations else narrowed).solve()
?: return EnergyModelTrainingResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
val rolling = coefficients[0]
val aero = coefficients[1]
val thermal = coefficients[2]
val thermal = if (features == 3) coefficients[2] else 0.0
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 ||
coefficients.any { !it.isFinite() }
) {
@@ -93,7 +98,19 @@ class EnergyModelTrainer(
return EnergyModelTrainingResult.Ready(model)
}
private inline fun forEachUsable(
/**
* Calls [block] once per kilometre of driving, with its mean speed, mean temperature and
* gross consumption, and returns how many kilometres it reported.
*
* A 5-second sample is not a consumption. The one taken while pulling away from a light
* costs several times the one taken cruising, and a fit made per sample read that spread
* as model error and refused town driving for good. Over a kilometre the accelerations
* average into what the road actually cost, which is also what the attribution predicts.
* Standing still is skipped without ending the window: it has no speed to be modelled at.
* A hole in the track does end it, as in SocConsumptionFitter, so no window spans
* kilometres nobody recorded.
*/
private fun forEachUsable(
trips: List<StoredTrip>,
evidence: BatteryPowerEvidence,
block: (speedKmh: Double, outsideTempCelsius: Double, consumption: Double) -> Unit,
@@ -101,22 +118,55 @@ class EnergyModelTrainer(
var accepted = 0
for (trip in trips) {
if (trip.summary.batteryPowerEvidence != evidence) continue
for (sample in trip.samples.orEmpty()) {
val speed = sample.speedKmh?.toDouble()
val power = sample.batteryPowerKw?.toDouble()
val temp = sample.outsideTempCelsius?.toDouble()
if (speed == null || power == null || temp == null ||
!speed.isFinite() || speed !in MIN_SPEED_KMH..MAX_SPEED_KMH ||
!power.isFinite() || power <= 0.0 ||
!temp.isFinite() || temp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS
val samples = trip.samples.orEmpty()
var km = 0.0
var hours = 0.0
var kwh = 0.0
var tempSum = 0.0
var tempCount = 0
for (index in 1 until samples.size) {
val previous = samples[index - 1]
val current = samples[index]
val durationMs = current.atMs - previous.atMs
val speed = mean(previous.speedKmh, current.speedKmh)
val previousPower = previous.batteryPowerKw?.toDouble()
val currentPower = current.batteryPowerKw?.toDouble()
val temp = mean(previous.outsideTempCelsius, current.outsideTempCelsius)
if (speed == null || temp == null || previousPower == null ||
currentPower == null || !previousPower.isFinite() ||
!currentPower.isFinite() || durationMs !in 1..MAX_SAMPLE_GAP_MS
) {
km = 0.0
hours = 0.0
kwh = 0.0
tempSum = 0.0
tempCount = 0
continue
}
if (speed < MIN_SPEED_KMH) continue
val intervalHours = durationMs / MILLIS_PER_HOUR
km += speed * intervalHours
hours += intervalHours
// Gross, as the attribution measures it: regeneration is a separate ledger line.
kwh += (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * intervalHours
tempSum += temp
tempCount++
if (km < WINDOW_KM) continue
val meanSpeed = km / hours
val meanTemp = tempSum / tempCount
val consumption = kwh * 100.0 / km
km = 0.0
hours = 0.0
kwh = 0.0
tempSum = 0.0
tempCount = 0
if (meanSpeed !in MIN_SPEED_KMH..MAX_SPEED_KMH ||
meanTemp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS ||
consumption !in 0.0..MAX_CONSUMPTION_KWH_PER_100_KM
) {
continue
}
val consumption = power * 100.0 / speed
if (!consumption.isFinite() || consumption !in 0.0..MAX_CONSUMPTION_KWH_PER_100_KM) {
continue
}
block(speed, temp, consumption)
block(meanSpeed, meanTemp, consumption)
accepted++
if (accepted == maxSamples) return accepted
}
@@ -124,9 +174,19 @@ class EnergyModelTrainer(
return accepted
}
private fun mean(a: Float?, b: Float?): Double? {
val first = a?.toDouble()?.takeIf { it.isFinite() } ?: return null
val second = b?.toDouble()?.takeIf { it.isFinite() } ?: return null
return (first + second) / 2.0
}
companion object {
const val MIN_TRAINING_SAMPLES = 60
const val MAX_TRAINING_SAMPLES = 50_000
/** Kilometre windows, not samples: a dozen, as SocConsumptionFitter asks segments. */
const val MIN_TRAINING_SAMPLES = 12
const val MAX_TRAINING_SAMPLES = 20_000
const val WINDOW_KM = 1.0
private const val MAX_SAMPLE_GAP_MS = 120_000L
private const val MILLIS_PER_HOUR = 3_600_000.0
const val MAX_RESIDUAL_RMSE_KWH_PER_100_KM = 12.0
private const val MIN_SPEED_KMH = 10.0
private const val MAX_SPEED_KMH = 180.0
@@ -6,6 +6,7 @@ import com.evsuite.hardware.telemetry.Provenanced
import com.evsuite.hardware.telemetry.StoredTrip
import com.evsuite.hardware.telemetry.TripSample
import com.evsuite.hardware.telemetry.UnavailableReason
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
@@ -148,8 +149,8 @@ class SocConsumptionFitter(
* altogether — a trip skipped for a speed conversion that is no longer believed leaves no
* trace at all, and a history of them looks exactly like a history of short drives.
*
* Diagnostic only: it runs the same collection pass as [fit] a second time, so call it
* from a probe and not from a screen.
* Diagnostic only: it runs the same collection pass and solve as [fit] a second time, so
* call it from a probe and not from a screen.
*/
fun describe(
trips: List<StoredTrip>,
@@ -161,9 +162,20 @@ class SocConsumptionFitter(
"segments=${segments.size}/$MIN_SEGMENTS"
if (segments.isEmpty()) return head
val span = segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh }
return "$head span=${span.roundToInt()}/${MIN_SPEED_SPAN_KMH.roundToInt()} km/h"
val line = "$head span=${span.roundToInt()}/${MIN_SPEED_SPAN_KMH.roundToInt()} km/h"
if (segments.size < MIN_SEGMENTS || span < MIN_SPEED_SPAN_KMH) return line
// Past both gates the line used to stop, and a bundle could not say whether a model
// came out of the solve. Appended, so a parser of the prefix above still matches.
val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
val solved = solve(segments, evidence)
val model = solved.model ?: return "$line fit=refused(${solved.refusal})"
val rmse = model.residualRmsePercentPer100Km
return line + String.format(Locale.ROOT, " fit=ready rmse=%.2f", rmse)
}
/** What [solve] produced: a model, or the name of the guard that refused one. */
private class Solved(val model: SocConsumptionModel?, val refusal: String? = null)
fun fit(
trips: List<StoredTrip>,
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
@@ -180,18 +192,30 @@ class SocConsumptionFitter(
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
if (segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh } <
MIN_SPEED_SPAN_KMH
) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
return solve(segments, evidence).model?.let { SocConsumptionFitResult.Ready(it) }
?: SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
}
/** The least-squares solve behind [fit], shared with [describe] so both report one answer. */
private fun solve(
segments: List<Segment>,
evidence: VehicleSpeedEvidence,
withWeather: Boolean = true,
): Solved {
val minSpeed = segments.minOf { it.meanSpeedKmh }
val maxSpeed = segments.maxOf { it.meanSpeedKmh }
val minTemp = segments.minOf { it.meanOutsideTempCelsius }
val maxTemp = segments.maxOf { it.meanOutsideTempCelsius }
if (maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
// A driver who has only recorded one kind of weather has no evidence about weather.
// Fitting two coefficients is the honest answer there; the envelope keeps the narrow
// temperature range, so this model refuses the cold instead of guessing at it.
val features = if (maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
val features = if (withWeather && maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
val equations = LeastSquares3(features)
for (segment in segments) {
equations.add(
@@ -200,13 +224,19 @@ class SocConsumptionFitter(
segment.percentPer100Km,
)
}
val coefficients = equations.solve()
?: return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
val coefficients = equations.solve() ?: return Solved(null, "singular")
val rolling = coefficients[0]
val aero = coefficients[1]
val thermal = if (features == 3) coefficients[2] else 0.0
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 || coefficients.any { !it.isFinite() }) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
when {
coefficients.any { !it.isFinite() } -> return Solved(null, "nonfinite")
rolling <= 0.0 -> return Solved(null, "rolling")
aero < 0.0 -> return Solved(null, "aero")
// The weather recorded so far costs nothing measurable: the bundle of 2026-10-16
// refused 87 segments over 77 km/h of span on this alone, and the model never
// came out on the car. That is the single-weather case again — two coefficients,
// and the envelope still refuses a temperature the history never saw.
thermal < 0.0 -> return solve(segments, evidence, withWeather = false)
}
var squaredError = 0.0
@@ -221,7 +251,7 @@ class SocConsumptionFitter(
}
val rmse = sqrt(squaredError / segments.size)
if (!rmse.isFinite() || rmse > maxResidualRmse) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
return Solved(null, String.format(Locale.ROOT, "rmse=%.2f", rmse))
}
val model = SocConsumptionModel(
@@ -233,11 +263,7 @@ class SocConsumptionFitter(
segmentCount = segments.size,
envelope = EnergyModelEnvelope(minSpeed, maxSpeed, minTemp, maxTemp),
)
return if (model.isValid()) {
SocConsumptionFitResult.Ready(model)
} else {
SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
}
return if (model.isValid()) Solved(model) else Solved(null, "invalid")
}
/**
@@ -247,6 +273,11 @@ class SocConsumptionFitter(
* refuses to build a segment from is most of what makes the fit trustworthy.
*/
internal fun collect(samples: List<TripSample>, out: MutableList<Segment>) {
// Two steps of this track's own gauge, never more than [minSocDropPercent]. SWI68
// publishes tenths of a percent, and waiting for two whole points there threw away
// every town drive shorter than ten kilometres — most of what the car is used for.
val minDrop = gaugeStep(samples)?.let { minOf(minSocDropPercent, 2.0 * it) }
?: minSocDropPercent
var startSoc: Double? = null
var distanceKm = 0.0
var hours = 0.0
@@ -302,13 +333,29 @@ class SocConsumptionFitter(
continue
}
val drop = start - soc
if (drop < minSocDropPercent) continue
// A short stretch keeps accumulating rather than being thrown away: on a fine gauge
// the drop arrives before the distance does.
if (drop < minDrop || distanceKm < MIN_SEGMENT_KM) continue
emit(distanceKm, hours, tempSum, tempCount, drop)?.let(out::add)
reset(soc)
if (out.size >= maxSegments) return
}
}
/** The smallest move this track's gauge made, or null when it never moved. */
private fun gaugeStep(samples: List<TripSample>): Double? {
var step: Double? = null
var previous: Double? = null
for (sample in samples) {
val soc = sample.socPercent?.toDouble()?.takeIf { it.isFinite() } ?: continue
val move = previous?.let { abs(soc - it) }
// Float storage turns 0.1 into 0.0999…; anything below a hundredth is that noise.
if (move != null && move >= 0.01) step = minOf(step ?: move, move)
previous = soc
}
return step
}
private fun emit(
distanceKm: Double,
hours: Double,
@@ -7,6 +7,17 @@
<string name="group_comfort">Komfort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Fahrerassistenz</string>
<string name="group_lights">Beleuchtung</string>
<string name="cond_headlights">Abblendlicht</string>
<string name="cond_high_beam">Fernlicht an</string>
<string name="cond_fog_lights">Nebelscheinwerfer an</string>
<string name="act_headlights">Abblendlicht</string>
<string name="act_high_beam">Fernlicht</string>
<string name="act_fog_lights">Nebelscheinwerfer</string>
<string name="light_off">Aus</string>
<string name="light_on">An</string>
<string name="light_daytime_running">Tagfahrlicht</string>
<string name="light_auto">Automatisch</string>
<string name="group_climate">Klima</string>
<string name="group_profile">Profil</string>
<string name="group_system">System</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Confort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Asistencia a la conducción</string>
<string name="group_lights">Iluminación</string>
<string name="cond_headlights">Luces de cruce</string>
<string name="cond_high_beam">Luces largas encendidas</string>
<string name="cond_fog_lights">Antiniebla encendidas</string>
<string name="act_headlights">Luces de cruce</string>
<string name="act_high_beam">Luces largas</string>
<string name="act_fog_lights">Antiniebla</string>
<string name="light_off">Apagadas</string>
<string name="light_on">Encendidas</string>
<string name="light_daytime_running">Luces diurnas</string>
<string name="light_auto">Automático</string>
<string name="group_climate">Climatización</string>
<string name="group_profile">Perfil</string>
<string name="group_system">Sistema</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Confort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Aides à la conduite</string>
<string name="group_lights">Éclairage</string>
<string name="cond_headlights">Feux de croisement</string>
<string name="cond_high_beam">Feux de route allumés</string>
<string name="cond_fog_lights">Antibrouillards allumés</string>
<string name="act_headlights">Feux de croisement</string>
<string name="act_high_beam">Feux de route</string>
<string name="act_fog_lights">Antibrouillards</string>
<string name="light_off">Éteints</string>
<string name="light_on">Allumés</string>
<string name="light_daytime_running">Feux de jour</string>
<string name="light_auto">Automatique</string>
<string name="group_climate">Climatisation</string>
<string name="group_profile">Profil</string>
<string name="group_system">Système</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Comfort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Assistenza alla guida</string>
<string name="group_lights">Luci</string>
<string name="cond_headlights">Anabbaglianti</string>
<string name="cond_high_beam">Abbaglianti accesi</string>
<string name="cond_fog_lights">Fendinebbia accesi</string>
<string name="act_headlights">Anabbaglianti</string>
<string name="act_high_beam">Abbaglianti</string>
<string name="act_fog_lights">Fendinebbia</string>
<string name="light_off">Spente</string>
<string name="light_on">Accese</string>
<string name="light_daytime_running">Luci diurne</string>
<string name="light_auto">Automatico</string>
<string name="group_climate">Clima</string>
<string name="group_profile">Profilo</string>
<string name="group_system">Sistema</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Conforto</string>
<string name="group_audio">Áudio</string>
<string name="group_adas">Assistência à condução</string>
<string name="group_lights">Iluminação</string>
<string name="cond_headlights">Médios</string>
<string name="cond_high_beam">Máximos ligados</string>
<string name="cond_fog_lights">Faróis de nevoeiro ligados</string>
<string name="act_headlights">Médios</string>
<string name="act_high_beam">Máximos</string>
<string name="act_fog_lights">Faróis de nevoeiro</string>
<string name="light_off">Desligados</string>
<string name="light_on">Ligados</string>
<string name="light_daytime_running">Luzes diurnas</string>
<string name="light_auto">Automático</string>
<string name="group_climate">Climatização</string>
<string name="group_profile">Perfil</string>
<string name="group_system">Sistema</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Comfort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Driver assistance</string>
<string name="group_lights">Lights</string>
<string name="cond_headlights">Low beams</string>
<string name="cond_high_beam">High beam on</string>
<string name="cond_fog_lights">Fog lights on</string>
<string name="act_headlights">Low beams</string>
<string name="act_high_beam">High beam</string>
<string name="act_fog_lights">Fog lights</string>
<string name="light_off">Off</string>
<string name="light_on">On</string>
<string name="light_daytime_running">Daytime running</string>
<string name="light_auto">Automatic</string>
<string name="group_climate">Climate</string>
<string name="group_profile">Profile</string>
<string name="group_system">System</string>
@@ -127,13 +127,28 @@ class ReadFailureLogTest {
class NeverPublishedCatalogueTest {
@Test fun `SWI68 never publishes battery power or battery temperature`() {
CarPropertyEvidence.Signal.entries.forEach { signal ->
@Test fun `SWI68 never publishes battery temperature`() {
assertTrue(
"$signal is proven absent on SWI68",
CarPropertyEvidence.isNeverPublished(signal, FirmwareInfo.Gen.SWI68),
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
)
}
@Test fun `SWI68 battery power publishes through the vendor pack and is not validated yet`() {
assertFalse(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
assertFalse(
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
}
@Test fun `absent is not the same claim as validated`() {
@@ -141,13 +156,13 @@ class NeverPublishedCatalogueTest {
// never be validated. Both must be false-for-validated and true-for-absent at once.
assertFalse(
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
)
assertTrue(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
)
@@ -0,0 +1,61 @@
package com.evsuite.hardware
import com.evsuite.hardware.CustomDrive.Family
import com.evsuite.hardware.CustomDrive.Setting
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CustomDriveTest {
private fun codes(setting: Setting, family: Family) =
CustomDrive.INDICES.map { CustomDrive.code(setting, it, family) }
@Test fun `VSM scale matches the RI-006 read and write codes`() {
assertEquals(listOf(1, 2, 3), codes(Setting.POWER, Family.VSM))
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.VSM))
assertEquals(listOf(1, 0, 2), codes(Setting.PEDAL, Family.VSM))
}
@Test fun `pedal Normal is zero on every family`() {
Family.values().forEach { assertEquals(0, CustomDrive.code(Setting.PEDAL, 1, it)) }
}
@Test fun `A9 powertrain reuses the drive-mode scale`() {
assertEquals(listOf(2, 3, 4), codes(Setting.POWER, Family.A9))
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.A9))
}
@Test fun `every code reads back to its index`() {
Family.values().forEach { family ->
Setting.values().forEach { setting ->
CustomDrive.INDICES.forEach { i ->
assertEquals(i, CustomDrive.index(setting, CustomDrive.code(setting, i, family)!!, family))
}
}
}
}
@Test fun `out of range is refused both ways`() {
assertNull(CustomDrive.code(Setting.POWER, 3, Family.VSM))
assertNull(CustomDrive.code(Setting.PEDAL, -1, Family.A9))
assertNull(CustomDrive.index(Setting.POWER, 0, Family.VSM))
assertNull(CustomDrive.index(Setting.POWER, 1, Family.A9))
assertNull(CustomDrive.index(Setting.STEERING, -1, Family.VSM))
}
@Test fun `only VSM generations have a route`() {
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI68))
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI165))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI69))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI131))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI132))
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.SWI133))
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.UNKNOWN))
}
@Test fun `method names follow the family`() {
assertEquals("setBrakePedalLevel", CustomDrive.methodName(Setting.PEDAL, Family.VSM, write = true))
assertEquals("getDrivingPowerTrainMode", CustomDrive.methodName(Setting.POWER, Family.A9, write = false))
}
}
@@ -0,0 +1,97 @@
package com.evsuite.hardware
import com.evsuite.hardware.catalog.ActionType
import com.evsuite.hardware.catalog.ConditionType
import com.evsuite.hardware.probe.VendorSurfaceRules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* CR-046. Owner decisions pinned here, off the car: a rule never switches the low beams OFF,
* never forces the high beam ON, and touches no light on a generation nobody has proven.
*/
class ExteriorLightsTest {
private val gen = FirmwareInfo.Gen.SWI68
private val proven = setOf(gen)
@Test
fun `low beams can be set ON or AUTOMATIC, never OFF`() {
val allowed = ExteriorLights.HEADLIGHT_SWITCH_ALLOWED
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
}
@Test
fun `high beam can be set OFF or AUTOMATIC, never forced ON`() {
val allowed = ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
}
@Test
fun `daytime running is never a value a rule can write`() {
listOf(
ExteriorLights.HEADLIGHT_SWITCH_ALLOWED,
ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED,
ExteriorLights.FOG_SWITCH_ALLOWED,
).forEach { assertFalse(ExteriorLights.SWITCH_DAYTIME_RUNNING in it) }
}
@Test
fun `an unproven generation refuses every light write`() {
assertNotNull(
ExteriorLights.writeRefusal(
FirmwareInfo.Gen.SWI69, ExteriorLights.FOG_SWITCH_ALLOWED, ExteriorLights.SWITCH_ON, proven
)
)
// Until a capture proves one, no generation is proven at all.
assertTrue(ExteriorLights.PROVEN.isEmpty())
FirmwareInfo.Gen.entries.forEach { assertFalse(ExteriorLights.isProven(it)) }
}
@Test
fun `daytime running lights are not headlights on`() {
assertEquals(true, ExteriorLights.isOn(ExteriorLights.STATE_ON))
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_DAYTIME_RUNNING))
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_OFF))
assertNull(ExteriorLights.isOn(null))
}
@Test
fun `the editor cannot even express a forbidden light value`() {
val lowBeam = ActionType.SET_HEADLIGHTS.spec.options.map { it.value }.toSet()
val highBeam = ActionType.SET_HIGH_BEAM.spec.options.map { it.value }.toSet()
assertEquals(ExteriorLights.HEADLIGHT_SWITCH_ALLOWED, lowBeam)
assertEquals(ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED, highBeam)
}
@Test
fun `light actions are gated and unproven, light entries supported nowhere yet`() {
val actions = listOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
actions.forEach {
assertTrue("${it.name} must be gated", it.gated)
assertFalse("${it.name} must stay unproven until a capture", it.writeProven)
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
}
listOf(ConditionType.HEADLIGHTS, ConditionType.HIGH_BEAM_ON, ConditionType.FOG_LIGHTS_ON).forEach {
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
}
}
@Test
fun `a light property is named in the capture diff`() {
assertEquals(
"CPM 0x11400E00/0 (HEADLIGHTS_STATE)",
VendorSurfaceRules.label("CPM 0x11400E00/0"),
)
assertEquals("CPM 0x2160F404/0", VendorSurfaceRules.label("CPM 0x2160F404/0"))
assertEquals("VehicleSetting.getX", VendorSurfaceRules.label("VehicleSetting.getX"))
}
}
@@ -8,12 +8,6 @@ class PhysicalButtonEventDecoderTest {
@Test fun `every R69 hardkey code decodes`() {
val expected = mapOf(
5 to PhysicalButtonEventDecoder.Button.PHONE,
6 to PhysicalButtonEventDecoder.Button.UP,
7 to PhysicalButtonEventDecoder.Button.DOWN,
8 to PhysicalButtonEventDecoder.Button.OK,
9 to PhysicalButtonEventDecoder.Button.LEFT,
10 to PhysicalButtonEventDecoder.Button.RIGHT,
16 to PhysicalButtonEventDecoder.Button.PHONE,
23 to PhysicalButtonEventDecoder.Button.CENTER,
24 to PhysicalButtonEventDecoder.Button.VOLUME_UP,
25 to PhysicalButtonEventDecoder.Button.VOLUME_DOWN,
@@ -32,6 +26,72 @@ class PhysicalButtonEventDecoderTest {
assertEquals(button, decoder.accept(code, false, false)?.button)
}
}
@Test fun `the SystemUI broadcast adds only the cluster pad`() {
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
val expected = mapOf(
6 to PhysicalButtonEventDecoder.Button.UP,
7 to PhysicalButtonEventDecoder.Button.DOWN,
8 to PhysicalButtonEventDecoder.Button.OK,
9 to PhysicalButtonEventDecoder.Button.LEFT,
10 to PhysicalButtonEventDecoder.Button.RIGHT
)
expected.forEach { (code, button) ->
val decoder = PhysicalButtonEventDecoder()
decoder.accept(code, true, false, source = systemUi)
assertEquals(button, decoder.accept(code, false, false, source = systemUi)?.button)
}
}
@Test fun `a press on both broadcasts is one press`() {
// Next track: 87 on the report, 5 on SystemUI. Read as one space, SystemUI's 5 was a
// phone press; and the phone itself (5 and 16) arrived twice, i.e. as a double.
val decoder = PhysicalButtonEventDecoder()
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
val events = listOfNotNull(
decoder.accept(87, true, false, atMillis = 0),
decoder.accept(5, true, false, atMillis = 0, source = systemUi),
decoder.accept(87, false, false, atMillis = 50),
decoder.accept(5, false, false, atMillis = 50, source = systemUi),
decoder.accept(5, true, false, atMillis = 1_000),
decoder.accept(16, true, false, atMillis = 1_000, source = systemUi),
decoder.accept(5, false, false, atMillis = 1_050),
decoder.accept(16, false, false, atMillis = 1_050, source = systemUi)
)
assertEquals(listOf("MEDIA_NEXT:SHORT", "PHONE:SHORT"), events.map { it.value })
}
@Test fun `an unlisted key is reported with an id of its own`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(3, true, false)
val event = decoder.accept(3, false, false)!!
assertNull(event.button)
assertEquals(
PhysicalButtonEventDecoder.Source.HARDKEY_REPORT to 3,
PhysicalButtonEventDecoder.unknownKey(event.keyId)
)
// Same code, other broadcast, other key.
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
decoder.accept(30, true, false, source = systemUi)
val other = decoder.accept(30, false, false, source = systemUi)!!
assertEquals(PhysicalButtonEventDecoder.Source.SYSTEM_UI to 30, PhysicalButtonEventDecoder.unknownKey(other.keyId))
assertEquals("KEY_${event.keyId}:SHORT", event.value)
}
@Test fun `stored button ids are unchanged`() {
// Rules on disk hold these numbers; changing one silently rebinds a rule.
val ids = PhysicalButtonEventDecoder.Button.entries.associate { it.name to it.id }
assertEquals(
mapOf(
"PHONE" to 5, "UP" to 6, "DOWN" to 7, "OK" to 8, "LEFT" to 9, "RIGHT" to 10,
"SOURCE" to 110, "CENTER" to 23, "VOLUME_UP" to 24, "VOLUME_DOWN" to 25,
"MEDIA_NEXT" to 87, "MEDIA_PREVIOUS" to 88, "MUTE" to 164, "STAR_LEFT" to 17,
"STAR_RIGHT" to 286, "ASSISTANT" to 287
),
ids
)
}
@Test fun `two releases inside the window make one double`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(17, true, false, atMillis = 0)
@@ -108,7 +108,8 @@ class CatalogConsistencyTest {
ActionType.SET_ELK_MODE, ActionType.SET_ELK_SENSITIVITY,
ActionType.SET_ACC_TJA_MODE, ActionType.SET_LIMITER_MODE,
ActionType.SET_TSR, ActionType.SET_OVERSPEED_ALARM,
ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING
ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING,
ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS
)
mustBeGated.forEach { type ->
@@ -325,7 +326,15 @@ class CatalogConsistencyTest {
}
// Exactly these: an unproven write is invisible to the user, so adding one elsewhere
// silently removes an action from every app that carries this catalogue.
assertEquals(glass.toSet(), ActionType.entries.filter { !it.writeProven }.toSet())
// CR-046: the exterior-light actions are unproven too, on purpose, until a capture
// proves a generation (see ExteriorLightsTest).
val lights = setOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
// CP-088: the charge limit reads back as a code on SWI68 while the write sends a percent.
val chargeLimit = setOf(ActionType.SET_CHARGE_LIMIT)
assertEquals(
glass.toSet() + lights + chargeLimit,
ActionType.entries.filter { !it.writeProven }.toSet()
)
}
@Test
@@ -0,0 +1,46 @@
package com.evsuite.hardware.diag
import com.google.gson.JsonParser
import java.util.Base64
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DiagnosticInboxTest {
private val artifact = DiagnosticInbox.Artifact(
app = "EVTasker", version = "2.17.0", name = "evtasker-diagnostic.txt",
createdAtMs = 1_792_323_000_000L, content = "report ✓".toByteArray(),
)
@Test fun `only a plain https address is an inbox`() {
assertNull(DiagnosticInbox.refuse("https://nodered.example/evsuite"))
assertNotNull(DiagnosticInbox.refuse("http://192.0.2.10:1880/evsuite"))
assertNotNull(DiagnosticInbox.refuse("https://user:pw@host/evsuite"))
assertNotNull(DiagnosticInbox.refuse("https://host/evsuite?token=1"))
assertNotNull(DiagnosticInbox.refuse("evsuite"))
}
@Test fun `the body carries the artifact byte for byte`() {
var sent: String? = null
val outcome = DiagnosticInbox.send("https://host/evsuite", artifact) { _, body -> sent = body; 200 }
assertEquals(DiagnosticInbox.Outcome.Sent(200), outcome)
val json = JsonParser.parseString(sent).asJsonObject
assertEquals(1, json["evsuite"].asInt)
assertEquals("EVTasker", json["app"].asString)
assertEquals("report ✓", String(Base64.getDecoder().decode(json["contentBase64"].asString)))
}
@Test fun `a refused address never reaches the network, a server error is a failure`() {
var called = false
val refused = DiagnosticInbox.send("http://host/evsuite", artifact) { _, _ -> called = true; 200 }
assertTrue(refused is DiagnosticInbox.Outcome.Failed && !called)
assertEquals(
DiagnosticInbox.Outcome.Failed("HTTP 500"),
DiagnosticInbox.send("https://host/evsuite", artifact) { _, _ -> 500 },
)
}
}
@@ -91,4 +91,25 @@ class RuntimeInterfaceProbeTest {
val calls = RuntimeInterfaceProbe.CONFIG_CALLS
assertEquals(calls.size, calls.values.map { it.code }.toSet().size)
}
@Test
fun `the component map keeps the manifest's words and says when it stopped listing`() {
val rows = (0 until RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 3).map {
RuntimeInterfaceProbe.ServiceRow("com.saicmotor.x.Service$it", it == 0, null)
}
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.x", rows)
assertEquals("componentMap:com.saicmotor.x", entry.name)
assertEquals(BindVerdict.ANSWERED, entry.verdict)
assertEquals((rows.size).toString(), entry.values["services"])
assertEquals("exported=true permission=null", entry.values["com.saicmotor.x.Service0"])
assertEquals("3", entry.values["truncated"])
assertEquals(RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 2, entry.values.size)
}
@Test
fun `a package that declares no service was still read`() {
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.y", emptyList())
assertEquals(BindVerdict.ANSWERED, entry.verdict)
assertEquals(mapOf("services" to "0"), entry.values)
}
}
@@ -0,0 +1,106 @@
package com.evsuite.hardware.probe
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The before/after capture and the SOC survey call getters by reflection on vendor objects, in a
* car with a driver in it. The one thing that must never happen is a call that changes something,
* so the selection is pinned here, off the car, where a wrong answer is a failing test rather
* than a setting that moved.
*/
class VendorSurfaceRulesTest {
@Suppress("unused")
class FakeVendorClient {
fun getAutoHighBeamSetting(): Int = 1
fun isAutoHighBeamEnabled(): Boolean = true
fun getBatterySoc(): Float = 63f
fun hasRainSensor(): Boolean = false
fun getVersionName(): String = "x"
fun getStatusBoxed(): Int? = 2
fun setAutoHighBeamSetting(value: Int) = Unit
fun getAndResetCounter(): Int = 0
fun getInstance(): FakeVendorClient = this
fun getService(): Any = Any()
fun getListenerCount(): Int = 0
fun getLevel(area: Int): Int = area
fun requestState(): Int = 0
fun getter(): Int = 0
fun gettable(): Int = 0
fun getValues(): IntArray = intArrayOf()
fun startCharging(): Boolean = true
companion object {
@JvmStatic fun getStaticValue(): Int = 0
}
}
private val names = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java).map { it.name }
@Test
fun `value getters are read, including Setting and Enabled names`() {
assertEquals(
listOf(
"getAutoHighBeamSetting", "getBatterySoc", "getStatusBoxed", "getVersionName",
"hasRainSensor", "isAutoHighBeamEnabled",
),
names,
)
}
@Test
fun `nothing that acts, takes an argument or returns an object is called`() {
listOf(
"setAutoHighBeamSetting", "getAndResetCounter", "getInstance", "getService",
"getListenerCount", "getLevel", "requestState", "getter", "gettable", "getValues",
"startCharging", "getStaticValue", "getClass", "hashCode", "toString",
).forEach { assertFalse(it, it in names) }
}
@Test
fun `keywords narrow the survey, case-insensitively`() {
val soc = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java, listOf("SOC", "battery"))
assertEquals(listOf("getBatterySoc"), soc.map { it.name })
}
@Test
fun `camel-case words split on case and underscore`() {
assertEquals(listOf("and", "reset", "counter"), VendorSurfaceRules.words("AndResetCounter"))
assertEquals(listOf("high", "beam", "setting"), VendorSurfaceRules.words("High_BeamSetting"))
}
@Test
fun `the diff lists changed, appeared and vanished values, and nothing else`() {
val before = mapOf("a" to "1", "b" to "2", "gone" to "x")
val after = mapOf("a" to "1", "b" to "3", "new" to "y")
assertEquals(
listOf(
VendorSurfaceRules.Change("b", "2", "3"),
VendorSurfaceRules.Change("gone", "x", null),
VendorSurfaceRules.Change("new", null, "y"),
),
VendorSurfaceRules.diff(before, after),
)
assertTrue(VendorSurfaceRules.diff(before, before).isEmpty())
}
@Test
fun `an empty diff says why it may be empty`() {
val text = VendorSurfaceRules.formatDiff(emptyList(), 10, 10)
assertTrue(text.contains("0 changed"))
assertTrue(text.contains("nothing changed"))
val one = VendorSurfaceRules.formatDiff(listOf(VendorSurfaceRules.Change("k", null, "1")), 1, 2)
assertTrue(one.contains("k: — → 1"))
}
@Test
fun `arrays print their content`() {
assertEquals("[1, 2]", VendorSurfaceRules.render(intArrayOf(1, 2)))
assertEquals("[0.5]", VendorSurfaceRules.render(floatArrayOf(0.5f)))
assertEquals("null", VendorSurfaceRules.render(null))
}
}
@@ -0,0 +1,25 @@
package com.evsuite.hardware.saic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** The charge limit read refuses what the car could never have been set to. */
class SaicChargeLimitTest {
@Test fun `the code SWI68 answers is not a percent`() {
assertNull(SaicCharging.chargeLimitOrNull(5))
}
@Test fun `the range the car accepts reads through, ends included`() {
assertEquals(40, SaicCharging.chargeLimitOrNull(40))
assertEquals(80, SaicCharging.chargeLimitOrNull(80))
assertEquals(100, SaicCharging.chargeLimitOrNull(100))
}
@Test fun `outside it, and unread, is unknown`() {
assertNull(SaicCharging.chargeLimitOrNull(39))
assertNull(SaicCharging.chargeLimitOrNull(101))
assertNull(SaicCharging.chargeLimitOrNull(null))
}
}
@@ -0,0 +1,84 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
class BatteryDigestTest {
private fun day(
day: String,
health: Double? = 94.0,
band: Double? = 3.0,
calibration: CalibrationVerdict? = CalibrationVerdict.SETTLED,
) = BatteryDigest(
day = day,
healthPercent = health,
healthBandPercent = band,
healthWindows = if (health == null) 0 else 5,
calibration = calibration,
daysSinceFullCharge = 4.0,
hoursAboveHighSoc = 10.0,
equivalentFullCycles = 12.0,
chargeCount = 7,
)
@Test
fun `movement inside the band is not a change`() {
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 92.0)).isEmpty())
}
@Test
fun `movement past the band is one change`() {
val change = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 90.0)).single()
assertEquals(DigestChange.HealthMoved(94.0, 90.0, 3.0), change)
}
@Test
fun `a band that narrows by a point is said, less is rounding`() {
val narrowed = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.0))
assertEquals(listOf(DigestChange.HealthBandNarrowed(3.0, 2.0, 94.0)), narrowed)
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.5)).isEmpty())
}
@Test
fun `the first estimate is news, and the first day has nothing to compare`() {
val first = BatteryDigest.changes(day("2026-09-28", health = null, band = null), day("2026-09-29"))
assertEquals(listOf(DigestChange.HealthBandNarrowed(null, 3.0, 94.0)), first)
assertTrue(BatteryDigest.changes(null, day("2026-09-29")).isEmpty())
}
@Test
fun `a calibration verdict that changes is a change`() {
val change = BatteryDigest.changes(
day("2026-09-28"),
day("2026-09-29", calibration = CalibrationVerdict.WATCH),
).single() as DigestChange.CalibrationChanged
assertEquals(CalibrationVerdict.WATCH, change.to)
}
@Test
fun `the store keeps one record a day, a bounded year, and returns the day before`() {
val dir = Files.createTempDirectory("digest").toFile()
val store = BatteryDigestStore(File(dir, BatteryDigestStore.FILE_NAME), maxDays = 2)
assertNull(store.upsert(day("2026-09-27")))
assertEquals("2026-09-27", store.upsert(day("2026-09-28"))?.day)
assertEquals("2026-09-27", store.upsert(day("2026-09-28", health = 80.0))?.day)
store.upsert(day("2026-09-29"))
assertEquals(listOf("2026-09-28", "2026-09-29"), store.read().map { it.day })
assertEquals(80.0, store.read().first().healthPercent!!, 1e-9)
dir.deleteRecursively()
}
@Test
fun `an unreadable file is set aside, not trusted`() {
val dir = Files.createTempDirectory("digest").toFile()
val file = File(dir, BatteryDigestStore.FILE_NAME).apply { writeText("{not json") }
assertTrue(BatteryDigestStore(file).read().isEmpty())
assertTrue(dir.listFiles()!!.any { it.name.contains("quarantine") })
dir.deleteRecursively()
}
}
@@ -72,6 +72,16 @@ class BatteryLedgerTest {
assertTrue(recorder.observe(snapshot(atMs = 1_000L, soc = 50f, chargingStatus = 1)))
}
@Test fun `a reading taken on the factory clock earns nothing`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
val factoryClock = 1_546_214_400_000L - NOW_MS // 2018-12-31, as the woken head unit reads
assertFalse(recorder.observe(snapshot(atMs = factoryClock, soc = 80f)))
assertTrue(recorder.observe(snapshot(atMs = 0L, soc = 80f)))
assertEquals(listOf(NOW_MS), store.read().map { it.atMs })
}
@Test fun `the integrals are cumulative and bounded by the same five seconds a trip uses`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
@@ -134,7 +144,7 @@ class BatteryLedgerTest {
consumedKwh: Float? = null,
regeneratedKwh: Float? = null,
) = EnergySnapshot(
timestampMs = atMs,
timestampMs = NOW_MS + atMs,
firmware = FirmwareInfo.Gen.SWI68,
socPercent = soc,
rangeKm = null,
@@ -156,4 +166,9 @@ class BatteryLedgerTest {
)
private fun tempDirectory(): File = Files.createTempDirectory("ledger").toFile()
private companion object {
/** 2026-10-11, past the recorder's clock floor. */
const val NOW_MS = 1_791_728_806_645L
}
}
@@ -0,0 +1,95 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChargeDurationTest {
/** A watched charge from [from] to [to] %, one step per point, [minutesOf] minutes for each. */
private fun charge(from: Int, to: Int, outside: Double? = 15.0, minutesOf: (Int) -> Double) = ChargeEnergy(
session = BatteryChargeSession(0L, 1L, from.toDouble(), to.toDouble(), 0.0, 0.0, outside),
watched = true,
maxEntryGapMs = 0L,
packDeltaKwh = null,
meanPowerKw = null,
counterBehaviour = ChargeCounterBehaviour.RESET,
consumedDeltaKwh = null,
regeneratedDeltaKwh = null,
movedKm = 0.0,
chargingStatuses = emptyList(),
steps = (from until to).map {
ChargePowerStep(it.toDouble(), it + 1.0, (minutesOf(it) * 60_000).toLong(), 7.0)
},
minOutsideTempCelsius = outside,
maxOutsideTempCelsius = outside,
)
/** Three minutes a point below 80 %, six above: the taper. */
private val tapered = { soc: Int -> if (soc < 80) 3.0 else 6.0 }
@Test
fun `duration sums the bands on the path, and the last stretch is its own figure`() {
val history = listOf(charge(20, 100, minutesOf = tapered))
val estimate = (ChargeDuration.estimate(history, 50.0, 100, null) as ChargeDurationResult.Ready).estimate
assertEquals(30 * 3.0 + 20 * 6.0, estimate.minutes, 1e-6)
assertEquals(120.0, estimate.lastStretchMinutes!!, 1e-6)
assertFalse(estimate.similarTemperature)
}
@Test
fun `the range is the fastest and the slowest past charge`() {
val history = listOf(
charge(40, 80) { 2.0 },
charge(40, 80) { 3.0 },
charge(40, 80) { 5.0 },
)
val estimate = (ChargeDuration.estimate(history, 60.0, 80, null) as ChargeDurationResult.Ready).estimate
assertEquals(60.0, estimate.minutes, 1e-6)
assertEquals(40.0, estimate.lowMinutes, 1e-6)
assertEquals(100.0, estimate.highMinutes, 1e-6)
assertNull(estimate.lastStretchMinutes)
}
@Test
fun `a band nobody watched is refused, never extrapolated`() {
val refused = ChargeDuration.estimate(listOf(charge(20, 80, minutesOf = tapered)), 50.0, 100, null)
assertEquals(ChargeDurationResult.Refused(ChargeDurationRefusal.MISSING_BAND, 80), refused)
assertEquals(
ChargeDurationResult.Refused(ChargeDurationRefusal.NO_WATCHED_CHARGE),
ChargeDuration.estimate(emptyList(), 50.0, 100, null),
)
assertEquals(
ChargeDurationResult.Refused(ChargeDurationRefusal.AT_TARGET),
ChargeDuration.estimate(emptyList(), 80.0, 80, null),
)
}
@Test
fun `a similar outside temperature is preferred, and all charges are the fallback`() {
val history = listOf(
charge(40, 80, outside = 0.0) { 5.0 },
charge(40, 80, outside = 20.0) { 3.0 },
)
val cold = (ChargeDuration.estimate(history, 60.0, 80, 2.0) as ChargeDurationResult.Ready).estimate
assertTrue(cold.similarTemperature)
assertEquals(100.0, cold.minutes, 1e-6)
val hot = (ChargeDuration.estimate(history, 60.0, 80, 35.0) as ChargeDurationResult.Ready).estimate
assertFalse(hot.similarTemperature)
assertEquals(20 * 5.0, hot.minutes, 1e-6) // the median of two is the upper one
}
@Test
fun `the curve check needs three past charges and flags only slower than all of them`() {
val two = listOf(charge(80, 90) { 6.0 }, charge(80, 90) { 7.0 })
assertNull(ChargeDuration.curveCheck(two, 80, 20.0))
val three = two + charge(80, 90) { 8.0 }
assertTrue(ChargeDuration.curveCheck(three, 80, 9.0)!!.slower)
val usual = ChargeDuration.curveCheck(three, 80, 7.5)!!
assertFalse(usual.slower)
assertEquals(6.0, usual.usualLowMinutesPerPoint, 1e-9)
assertEquals(3, usual.pastCharges)
}
}
@@ -0,0 +1,237 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChargeEnergyTest {
@Test fun `a watched charge carries its power and the sign the integral moved`() {
// Three quarters of an hour at sixteen points an hour, with the pack pair integral
// falling by 8,25 kWh: the declared convention — positive leaves the battery — holding
// across a charge, and 11 kW of it.
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
entry(15 * MINUTE, 44f, pack = 97.25, odometer = 10_000f),
entry(30 * MINUTE, 48f, pack = 94.5, odometer = 10_000f),
entry(45 * MINUTE, 52f, pack = 91.75, odometer = 10_000f),
)
val report = analyse(entries)
assertEquals(1, report.charges.size)
val charge = report.charges.single()
assertTrue(charge.watched)
assertTrue(charge.plugged)
assertEquals(-8.25, charge.packDeltaKwh!!, 1e-6)
assertEquals(11.0, charge.meanPowerKw!!, 1e-6)
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
assertEquals(charge, report.lastPluggedCharge)
}
@Test fun `an integral that rises during a charge is reported as the other convention`() {
val entries = listOf(
entry(0L, 40f, pack = 10.0),
entry(15 * MINUTE, 45f, pack = 17.0),
entry(30 * MINUTE, 50f, pack = 24.0),
)
assertEquals(ChargePackSign.ENERGY_ROSE, analyse(entries).packSign)
}
@Test fun `two charges that disagree settle nothing`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0),
entry(15 * MINUTE, 45f, pack = 96.5),
entry(30 * MINUTE, 50f, pack = 93.0),
// A drive in between, so what follows is a second session rather than the same one.
entry(45 * MINUTE, 30f, pack = 99.0),
entry(60 * MINUTE, 35f, pack = 102.5),
entry(75 * MINUTE, 40f, pack = 106.0),
)
assertEquals(ChargePackSign.CONTRADICTORY, analyse(entries).packSign)
}
@Test fun `a charge the app slept through carries no power and no sign`() {
// The recorder earns an entry every quarter of an hour while it is running, so a
// six-hour silence is the head unit having been off — and the integral did not move
// during it, which would otherwise read as a charge that took no energy at all.
val entries = listOf(
entry(0L, 30f, pack = 100.0),
entry(6 * HOUR, 90f, pack = 100.0),
)
val report = analyse(entries)
val charge = report.charges.single()
assertFalse(charge.watched)
assertNull(charge.meanPowerKw)
assertEquals(ChargePackSign.UNREADABLE, charge.sign)
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
}
@Test fun `a counter that goes backwards across a charge is the car resetting it`() {
val entries = listOf(
entry(0L, 40f, consumed = 12f, regenerated = 3f),
entry(15 * MINUTE, 55f, consumed = 12f, regenerated = 3f),
entry(30 * MINUTE, 70f, consumed = 0f, regenerated = 0f),
)
val report = analyse(entries)
assertEquals(ChargeCounterBehaviour.RESET, report.counterBehaviour)
assertEquals(-12.0, report.charges.single().consumedDeltaKwh!!, 1e-6)
}
@Test fun `counters read at both ends and unchanged are static, never absent`() {
val entries = listOf(
entry(0L, 40f, consumed = 12f, regenerated = 3f),
entry(20 * MINUTE, 70f, consumed = 12f, regenerated = 3f),
)
assertEquals(ChargeCounterBehaviour.STATIC, analyse(entries).counterBehaviour)
}
@Test fun `a car with no counters at all says absent rather than static`() {
val entries = listOf(entry(0L, 40f), entry(20 * MINUTE, 70f))
assertEquals(ChargeCounterBehaviour.ABSENT, analyse(entries).counterBehaviour)
}
@Test fun `a reset seen once outranks a charge that ended before the reset`() {
val entries = listOf(
entry(0L, 40f, consumed = 9f),
entry(15 * MINUTE, 60f, consumed = 9f),
entry(30 * MINUTE, 30f, consumed = 14f),
entry(45 * MINUTE, 70f, consumed = 0f),
)
assertEquals(ChargeCounterBehaviour.RESET, analyse(entries).counterBehaviour)
}
@Test fun `a rise while the odometer moves is regeneration, never the last charge`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
entry(20 * MINUTE, 48f, pack = 96.0, odometer = 10_020f),
)
val report = analyse(entries)
val charge = report.charges.single()
assertEquals(20.0, charge.movedKm!!, 1e-6)
assertFalse(charge.plugged)
assertNull(report.lastPluggedCharge)
// The sign is still evidence: energy entering the pack is energy entering the pack.
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
}
@Test fun `a night the head unit slept through is a charge even when the drive home was not recorded`() {
// The reported case: plugged in overnight, battery page empty the next morning. The last
// entry before sleep was written before the unrecorded drive home, so the odometer moved
// between the two ends and the old rule read fifty points as a descent.
val entries = listOf(
entry(0L, 38f, pack = 100.0, odometer = 10_000f, temp = 12f),
entry(11 * HOUR, 88f, pack = 100.0, odometer = 10_014f, temp = 7f),
)
val charge = analyse(entries).lastPluggedCharge!!
assertFalse(charge.watched)
assertTrue(charge.plugged)
assertNull(charge.meanPowerKw)
assertTrue(charge.steps.isEmpty())
assertEquals(7.0, charge.minOutsideTempCelsius!!, 1e-6)
assertEquals(12.0, charge.maxOutsideTempCelsius!!, 1e-6)
}
@Test fun `the car saying it charges is enough without an odometer`() {
val entries = listOf(
entry(0L, 40f, status = 1),
entry(10 * MINUTE, 43f, status = 1),
)
assertTrue(analyse(entries).charges.single().plugged)
}
@Test fun `a small rise across a gap while the car moved stays unclaimed`() {
val entries = listOf(
entry(0L, 40f, odometer = 10_000f),
entry(3 * HOUR, 46f, odometer = 10_030f),
)
assertNull(analyse(entries).lastPluggedCharge)
}
@Test fun `a watched charge carries its curve, its peak and its taper by band`() {
// 7 kW up to 80 %, then the pack takes 3,5 kW above it.
val entries = listOf(
entry(0L, 77f, pack = 100.0, odometer = 10_000f),
entry(6 * MINUTE, 78f, pack = 99.3, odometer = 10_000f),
entry(12 * MINUTE, 79f, pack = 98.6, odometer = 10_000f),
entry(18 * MINUTE, 80f, pack = 97.9, odometer = 10_000f),
entry(30 * MINUTE, 81f, pack = 97.2, odometer = 10_000f),
)
val report = analyse(entries)
val charge = report.charges.single()
assertEquals(4, charge.steps.size)
assertEquals(7.0, charge.peakPowerKw!!, 1e-6)
val bands = report.powerBySocBand()
assertEquals(listOf(70, 80), bands.map { it.fromPercent })
assertEquals(7.0, bands[0].meanPowerKw, 1e-6)
assertEquals(3.5, bands[1].meanPowerKw, 1e-6)
assertTrue(report.describe().any { it.startsWith("soc_band 80-90 mean_power_kw=3.50") })
}
@Test fun `the description names every question and stays one line per charge`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f, status = 2),
entry(20 * MINUTE, 55f, pack = 86.0, odometer = 10_000f, status = 2),
)
val lines = analyse(entries).describe()
assertTrue(lines.any { it == "pack_pair_sign=ENERGY_FELL" })
assertTrue(lines.any { it == "vehicle_counters=ABSENT" })
assertTrue(lines.any { it == "charging_status_values=2" })
assertEquals(1, lines.count { it.startsWith("charge ") })
}
@Test fun `an empty ledger describes itself without inventing a verdict`() {
val report = ChargeEnergyAnalyzer().analyse(emptyList(), emptyList())
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
assertEquals(ChargeCounterBehaviour.ABSENT, report.counterBehaviour)
assertEquals(listOf("charges=0"), report.describe())
}
/** The sessions are the exposure report's, never re-segmented — the analyzer's own rule. */
private fun analyse(entries: List<BatteryLedgerEntry>): ChargeEnergyReport {
val sessions = BatteryExposure().analyse(entries)?.sessions.orEmpty()
return ChargeEnergyAnalyzer().analyse(entries, sessions)
}
private fun entry(
atMs: Long,
soc: Float,
pack: Double? = null,
consumed: Float? = null,
regenerated: Float? = null,
odometer: Float? = null,
status: Int? = null,
temp: Float? = null,
) = BatteryLedgerEntry(
atMs = atMs,
socPercent = soc,
odometerKm = odometer,
vehicleConsumedKwh = consumed,
vehicleRegeneratedKwh = regenerated,
packEnergyKwh = pack,
chargingStatus = status,
outsideTempCelsius = temp,
)
private companion object {
const val MINUTE = 60_000L
const val HOUR = 3_600_000L
}
}
@@ -0,0 +1,72 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DrivingStressTest {
/**
* Ten minutes at one sample every ten seconds, the charge falling two points. Minutes 0, 3
* and 6 pull 60 kW, minute 9 regenerates 10 kW, the rest cruise at 20 kW.
*/
private fun track(
soc: Float?,
outside: Float?,
sign: Float = 1f,
power: (Int) -> Float? = { i ->
when (i / 6) {
0, 3, 6 -> 60f
9 -> -10f
else -> 20f
}
},
) = (0 until 60).map { i ->
TripSample(
atMs = i * 10_000L,
speedKmh = 50f,
batteryPowerKw = power(i)?.times(sign),
socPercent = soc?.minus(i / 30f),
outsideTempCelsius = outside,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
@Test
fun `hard power in the cold is counted, and cold regeneration summed`() {
val stress = DrivingStress.of(track(soc = 60f, outside = -5f))!!
assertEquals(3.0, stress.stressedMinutes, 1e-9)
assertEquals(50 * 10.0 / 3600.0, stress.coldRegeneratedKwh!!, 1e-9)
}
@Test
fun `the trip orients its own power sign`() {
assertEquals(DrivingStress.of(track(soc = 60f, outside = -5f)), DrivingStress.of(track(60f, -5f, sign = -1f)))
}
@Test
fun `a warm drive at a good charge is a measured zero`() {
assertEquals(DrivingStress(0.0, 0.0), DrivingStress.of(track(soc = 60f, outside = 15f)))
}
@Test
fun `low charge alone is enough, and an unread temperature leaves the regeneration unknown`() {
val stress = DrivingStress.of(track(soc = 19f, outside = null))!!
assertEquals(3.0, stress.stressedMinutes, 1e-9)
assertNull(stress.coldRegeneratedKwh)
}
@Test
fun `unknown is never zero`() {
// Hard moments at a good charge with no temperature: they cannot be decided.
assertNull(DrivingStress.of(track(soc = 60f, outside = null)))
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { null }))
assertNull(DrivingStress.of(track(soc = null, outside = -5f)))
// A third of the power unread.
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { i -> if (i % 3 == 0) null else 20f }))
}
}
@@ -0,0 +1,289 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.VehicleSpeedEvidence
import com.evsuite.hardware.telemetry.model.EnergyModelEnvelope
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class EcoDrivingTest {
private val generation = FirmwareInfo.Gen.SWI68
private val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
/** Predicts 18.0 % per 100 km at 100 km/h and 20 °C, with a band of 0.98. */
private val model = SocConsumptionModel(
speedEvidence = evidence,
rollingPercentPer100Km = 10.0,
aeroPercentPer100KmPerSpeedSquared = 0.0008,
thermalPercentPer100KmPerDegree = 0.2,
residualRmsePercentPer100Km = 0.5,
segmentCount = SocConsumptionFitter.MIN_SEGMENTS,
envelope = EnergyModelEnvelope(20.0, 160.0, -10.0, 35.0),
)
/** 50 km in half an hour, so the mean speed the model is asked about is 100 km/h. */
private fun trip(
startSoc: Float? = 90f,
endSoc: Float? = 81f,
distanceKm: Double = 50.0,
durationMs: Long = 1_800_000L,
speedEvidence: VehicleSpeedEvidence? = evidence,
) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = durationMs,
durationMs = durationMs,
distanceKm = distanceKm,
startSocPercent = startSoc,
endSocPercent = endSoc,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
speedEvidence = speedEvidence,
)
private fun verdict(
monitor: EcoDrivingMonitor = EcoDrivingMonitor(),
trip: EnergyTripSummary? = trip(),
model: SocConsumptionModel? = this.model,
tempCelsius: Float? = 20f,
) = monitor.verdict(model, trip, tempCelsius, generation)
/** A steady cruise, one sample a second, from which no lever can fire. */
private fun cruise(speedKmh: Float, seconds: Int, fromMs: Long = 0L): EcoDrivingMonitor {
val monitor = EcoDrivingMonitor()
(0..seconds).forEach { monitor.add(fromMs + it * 1_000L, speedKmh) }
return monitor
}
@Test
fun `a drive below the expectation is better than it`() {
// 7 % over 50 km is 14 per 100 km, below the 18.0 the fit expects by more than its band.
val result = verdict(trip = trip(endSoc = 83f))
assertEquals(EcoBand.BETTER, result.band.value)
assertEquals(14.0, result.observedPercentPer100Km!!, 1e-9)
assertEquals(18.0, result.expectedPercentPer100Km!!, 1e-9)
assertTrue(result.deltaPercent!! < 0.0)
assertEquals(100.0, result.meanSpeedKmh!!, 1e-9)
}
@Test
fun `a drive inside the fit's own band is typical`() {
assertEquals(EcoBand.TYPICAL, verdict().band.value)
}
@Test
fun `a drive above the expectation is worse than it`() {
val result = verdict(trip = trip(endSoc = 78f))
assertEquals(EcoBand.WORSE, result.band.value)
assertTrue(result.deltaPercent!! > 0.0)
}
@Test
fun `the band widens with the fit's residual rather than a threshold of our own`() {
// The same 24 per 100 km that is WORSE against a tight fit is TYPICAL against a loose
// one: a model that knows little must not be allowed to accuse the driver.
val loose = model.copy(residualRmsePercentPer100Km = 7.0)
assertEquals(EcoBand.TYPICAL, verdict(trip = trip(endSoc = 78f), model = loose).band.value)
}
@Test
fun `a sixth over the expectation is worse, not typical, against a two-point residual`() {
// 21 per 100 km against 18: the 95 % band (±3.9) called this TYPICAL, one residual does not.
val fit = model.copy(residualRmsePercentPer100Km = 2.0)
assertEquals(EcoBand.WORSE, verdict(trip = trip(endSoc = 79.5f), model = fit).band.value)
}
@Test
fun `no model is a refusal, not a verdict`() {
val result = verdict(model = null)
assertNull(result.band.value)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
}
@Test
fun `a speed outside the fitted envelope is the model's refusal, carried through`() {
// 200 km/h over the 160 the envelope saw.
val result = verdict(trip = trip(distanceKm = 100.0))
assertNull(result.band.value)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
assertEquals(200.0, result.meanSpeedKmh!!, 1e-9)
}
@Test
fun `a distance too short to divide by is refused`() {
val result = verdict(trip = trip(distanceKm = 1.0, durationMs = 36_000L))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `a charge drop below the gauge's own step is refused`() {
val result = verdict(trip = trip(endSoc = 89f))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `a distance from the superseded speed conversion is refused`() {
val stale = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
val result = verdict(trip = trip(speedEvidence = stale))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `no outside temperature is a missing signal, not a comfortable assumption`() {
val result = verdict(tempCelsius = null)
assertEquals(UnavailableReason.SIGNAL_ABSENT, result.band.reason)
}
@Test
fun `the cruise lever quotes the fit rather than a rule of thumb`() {
val advice = verdict(monitor = cruise(110f, 120)).advice
assertEquals(EcoLever.CRUISE_SPEED, advice?.lever)
assertEquals(110.0, advice!!.fromSpeedKmh!!, 1e-9)
assertEquals(100.0, advice.toSpeedKmh!!, 1e-9)
// predict(110) is 19.68 and predict(100) is 18.0: 8.5 % less.
assertEquals(8.54, advice.savingPercent!!, 0.01)
assertNull(advice.harshSharePercent)
}
@Test
fun `a town speed leaves the cruise lever alone`() {
assertNull(verdict(monitor = cruise(50f, 120)).advice)
}
@Test
fun `the steadiness lever fires from the speed channel alone`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 0f
// Five stop-and-go cycles: 6 s of hard acceleration, then 18 s of cruising.
repeat(5) {
repeat(6) { speed += 10f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(18) { monitor.add(atMs, speed); atMs += 1_000L }
speed = 0f
}
val advice = verdict(monitor = monitor, model = null).advice
assertEquals(EcoLever.STEADINESS, advice?.lever)
assertTrue(advice!!.harshSharePercent!! >= EcoDrivingMonitor.MIN_HARSH_SHARE_PERCENT)
assertNull(advice.savingPercent)
}
@Test
fun `a calm drive is measured, not waiting`() {
val monitor = EcoDrivingMonitor()
(0..30).forEach { monitor.add(it * 1_000L, 50f) }
assertNull(monitor.harshSharePercent())
(31..90).forEach { monitor.add(it * 1_000L, 50f) }
assertEquals(0.0, monitor.harshSharePercent()!!, 1e-9)
assertNull(monitor.steadiness())
}
@Test
fun `hard braking counts like hard acceleration`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 0f
// Gentle launches (0.69 m/s²) and abrupt stops (2.8 m/s²): only the braking is harsh.
repeat(5) {
repeat(24) { speed += 2.5f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(6) { speed -= 10f; monitor.add(atMs, speed); atMs += 1_000L }
}
// 6 hard seconds in every 30: the trip review's `DrivingStyle` reads the same 20 %.
assertEquals(20.0, monitor.harshSharePercent()!!, 0.5)
}
@Test
fun `a sampling gap is a gap, not an acceleration`() {
val monitor = EcoDrivingMonitor()
(0..70).forEach { monitor.add(it * 1_000L, 50f) }
// The app was asleep for half a minute and the car is now at 120: unknown motion, and
// reading it as one step would invent 3.9 m/s² out of the process having been paused.
monitor.add(100_000L, 120f)
assertNull(verdict(monitor = monitor, model = null).advice)
}
@Test
fun `replay accepts the stored track cadence without relaxing live gaps`() {
val samples = (0..24).map { index ->
TripSample(
atMs = index * 5_500L,
speedKmh = if (index % 2 == 0) 0f else 60f,
batteryPowerKw = null,
socPercent = null,
outsideTempCelsius = null,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
assertEquals(EcoLever.STEADINESS, EcoDrivingMonitor.forReplay().replay(samples).steadiness()?.lever)
assertNull(EcoDrivingMonitor().replay(samples).steadiness())
}
@Test
fun `a window too short to judge says nothing`() {
assertNull(verdict(monitor = cruise(110f, 30)).advice)
}
@Test
fun `the cruise lever wins when both are measurable`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 100f
// Motorway, and driven unevenly: both levers have something to say and only one is said.
repeat(5) {
repeat(4) { speed += 8f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(20) { monitor.add(atMs, speed); atMs += 1_000L }
speed = 100f
}
assertEquals(EcoLever.CRUISE_SPEED, verdict(monitor = monitor).advice?.lever)
}
@Test
fun `replaying a stored track is the same monitor the dashboard fed`() {
val samples = (0..120).map { index ->
TripSample(
atMs = index * 1_000L,
speedKmh = 110f,
batteryPowerKw = null,
socPercent = null,
outsideTempCelsius = 20f,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
val replayed = EcoDrivingMonitor().replay(samples)
assertEquals(
verdict(monitor = cruise(110f, 120)).advice,
verdict(monitor = replayed).advice,
)
}
@Test
fun `describe names the refusal it made`() {
val lines = verdict(model = null).describe()
assertTrue(lines.any { it == "band=unavailable(MODEL_NOT_TRAINED)" })
assertTrue(lines.any { it == "observed_percent_per_100km=unavailable" })
assertTrue(lines.any { it == "advice=none" })
}
@Test
fun `describe states the verdict and its inputs`() {
val lines = verdict(monitor = cruise(110f, 120)).describe()
assertTrue(lines.any { it == "band=TYPICAL" })
assertTrue(lines.any { it == "observed_percent_per_100km=18.00" })
assertTrue(lines.any { it == "expected_percent_per_100km=18.00" })
assertNotNull(lines.firstOrNull { it.startsWith("advice=CRUISE_SPEED") })
}
}
@@ -0,0 +1,61 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class EcoScoreTest {
private fun trip(
distanceKm: Double = 10.0,
brakings: Int? = 3,
harshMs: Long? = 60_000L,
movingMs: Long? = 1_200_000L,
) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = 1_200_000L,
durationMs = 1_200_000L,
distanceKm = distanceKm,
startSocPercent = 80f,
endSocPercent = 78f,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
movingMs = movingMs,
harshMs = harshMs,
harshAccelerations = 2,
harshBrakings = brakings,
)
private fun verdict(delta: Double) = EcoVerdict(
band = com.evsuite.hardware.telemetry.Provenanced.estimated(EcoBand.TYPICAL, 1.0),
deltaPercent = delta,
)
@Test fun `a calm town drive at the usual consumption sits in the sixties, not near 100`() {
// 3 hard stops per 10 km, 5 % of moving time hard, consumption exactly as expected.
val score = EcoScore.of(trip(), verdict(0.0))!!
assertEquals(50, score.anticipation)
assertEquals(80, score.smoothness)
assertEquals(63, score.efficiency)
assertEquals(63, score.total)
}
@Test fun `a part that cannot be measured drops out instead of scoring zero or full`() {
val score = EcoScore.of(trip(), verdict = null)!!
assertNull(score.efficiency)
// (50 × 0.35 + 80 × 0.25) / 0.60
assertEquals(63, score.total)
}
@Test fun `a trip recorded before the counters existed has no score at all`() {
assertNull(EcoScore.of(trip(brakings = null, harshMs = null, movingMs = null), null))
}
@Test fun `scales clamp at both ends`() {
val best = EcoScore.of(trip(brakings = 0, harshMs = 0L), verdict(-40.0))!!
assertEquals(100, best.total)
val worst = EcoScore.of(trip(brakings = 20, harshMs = 600_000L), verdict(60.0))!!
assertEquals(0, worst.total)
}
}
@@ -31,6 +31,17 @@ class EnergyTelemetryTest {
assertEquals(18f, snapshot.batteryPowerKw)
}
@Test fun `one pedal reaches the track as the car's own code, and an unknown code stays unknown`() {
val signals = FakeSignals(80f, null, null).apply { mode = 9; regen = 6 }
val sample = TripSample.of(EnergyTelemetryReader(signals).read(1_000L))
assertEquals(6, sample.regenLevelRaw)
assertEquals(com.evsuite.hardware.model.RegenLevel.ONE_PEDAL,
com.evsuite.hardware.model.RegenLevel.ofRaw(sample.regenLevelRaw))
assertEquals(9, sample.driveModeRaw)
assertNull(com.evsuite.hardware.model.DriveMode.ofRaw(sample.driveModeRaw))
}
@Test fun `candidate probes exist only in the explicit evidence read`() {
val source = FakeSignals(soc = 80f, range = 200f, power = 18f)
source.probes = listOf(
@@ -70,6 +81,50 @@ class EnergyTelemetryTest {
assertEquals(0.001666, result.regeneratedKwh!!, 0.000001)
}
@Test fun `a trip started on the factory clock is dated from the corrected one`() {
val factory = 1_546_300_800_000L
val synced = 1_792_323_000_000L
val trip = EnergyTripAccumulator(factory, 80f)
trip.add(snapshot(factory, 30f, 5f))
trip.add(snapshot(factory + 1_000L, 30f, 5f))
trip.add(snapshot(synced, 30f, 5f))
trip.add(snapshot(synced + 1_000L, 30f, 5f))
val result = trip.snapshot(synced + 1_000L)
assertEquals(2_000L, result.durationMs)
assertEquals(synced - 1_000L, result.startedAtMs)
}
@Test fun `lifting off into regeneration is not a hard stop`() {
val trip = EnergyTripAccumulator(0L, 80f)
// 7 km/h a second is about 1.9 m/s², what regeneration on High does with no brake pedal.
listOf(50f, 43f, 36f, 29f, 22f, 15f).forEachIndexed { second, speed ->
trip.add(snapshot(second * 1_000L, speed, -10f))
}
val result = trip.snapshot(5_000L)
assertEquals(0, result.harshBrakings)
assertEquals(0L, result.harshMs)
}
@Test fun `a hard stretch counts once, braking and accelerating apart, at the live rate`() {
val trip = EnergyTripAccumulator(0L, 80f)
listOf(0f, 30f, 60f, 60f, 40f, 20f, 20f, 20f, 0f).forEachIndexed { second, speed ->
trip.add(
snapshot(second * 1_000L, speed, 5f)
.copy(driveModeRaw = if (second < 6) 4 else 2, regenLevelRaw = 6)
)
}
val result = trip.snapshot(8_000L)
assertEquals(1, result.harshAccelerations)
assertEquals(2, result.harshBrakings)
assertEquals(8_000L, result.movingMs)
assertEquals(5_000L, result.harshMs)
assertEquals(4, result.driveModeRaw)
assertEquals(6, result.regenLevelRaw)
}
@Test fun `trip keeps distance unknown without a usable speed interval`() {
val trip = EnergyTripAccumulator(0L, 80f)
trip.add(snapshot(0L, null, 18f))
@@ -124,6 +179,10 @@ class EnergyTelemetryTest {
private val power: Float?,
) : EnergySignalSource {
var probes: List<TelemetryEvidenceProbe> = emptyList()
var mode: Int? = null
var regen: Int? = null
override fun driveModeRaw() = mode
override fun regenLevelRaw() = regen
override fun firmware() = FirmwareInfo.Gen.SWI68
override fun socPercent() = soc
override fun rangeKm() = range
@@ -65,6 +65,20 @@ class TelemetryEvidenceTest {
assertEquals(1, parked.samples)
}
@Test fun `the widest window is a quantity, recorded as the snapshot carries it`() {
val recorder = TelemetryEvidenceRecorder()
recorder.record(snapshot(at = 0L, power = null).copy(widestWindowPercent = 40))
recorder.record(snapshot(at = 1_000L, power = null))
val windows = recorder.evidence()
.single { it.signal == TelemetryEvidenceRecorder.WINDOWS_WIDEST_PERCENT }
assertEquals("windows.widestPercent", windows.signal)
assertEquals(SignalKind.NUMERIC, windows.kind)
assertEquals(1, windows.samples)
assertEquals(1, windows.nulls)
assertEquals(40.0, windows.max!!, 0.0)
}
@Test fun `the update period follows value changes, not sampling ticks`() {
val recorder = TelemetryEvidenceRecorder()
// One second between reads, five seconds between the values the vehicle publishes.

Some files were not shown because too many files have changed in this diff Show More