Fatih'sMG4 — ABRP telemetry for an MG4 EH32 on SWI69

A fork of malys/EVABRPUploader (itself a fork of Leon Kernan's ABRP_Uploader)
carrying the changes that make it work on this car. History starts here: the
tree is what matters, and the upstream projects are where the earlier work
belongs.

EVHardware is vendored rather than tracked as a submodule, so one clone
builds.

Telemetry. SOC, battery power and range read as unavailable on SWI69 because
the vendor fallbacks were gated to SWI68 alone, while the property ids they
use are the ones this car publishes — the gates now admit SWI69. Tyre
pressure, the odometer and charge status come from the vendor block too,
since the generic properties for all three are declared here and never
publish. Tyre pressure is confirmed on the vehicle; the other two are not
yet.

Vehicle page. Every signal the car answers, in a table. One that it does not
publish shows a dash: an unimplemented VHAL property returns zero, and
"0 °C" would read as a measurement.

Wi-Fi page. Joins a network the unit already has saved, so a phone hotspot
does not need a trip into Settings on every drive. Tried on the upload tick
and from a button, skipped while already associated. This leans on the unit
being API 28 and the build being platform-signed — getConfiguredNetworks and
enableNetwork are closed to ordinary apps from API 29 — and fails quietly
where that does not hold.

The OTA updater points at this repository. An APK from upstream is signed
with a different key, which Android will not install over a platform-signed
build.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-09-22 10:45:00 +03:00
co-authored by Claude
commit 2fd7b2dde1
245 changed files with 35594 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Build output — regenerated, enormous, never worth reading.
build/
app/build/
.gradle/
.idea/
*.iml
# Local environment, secrets and signing material.
local.properties
mise.toml
mise.local.toml
*.keystore
*.jks
*.p12
# Binary artefacts.
*.apk
*.aab
*.dex
*.so
*.png
*.jpg
*.webp
*.ttf
# Generated reports.
app/build/reports/
*.sarif
+30
View File
@@ -0,0 +1,30 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
# mise : mise.toml est COMMITÉ (il décrit le build). Seules les surcharges
# locales, machine-spécifiques, sont ignorées.
mise.local.toml
.mise.local.toml
# Signed build output. Rebuilt from source by ./gradlew assembleUnstableDebug and
# signed locally — a checked-in APK is stale the moment anything above it changes.
*.apk
*.idsig
# Build dirs of the vendored EVHardware library.
/EVHardware/build
/EVHardware/.gradle
/EVHardware/**/build
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<?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
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?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
@@ -0,0 +1,18 @@
<?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
@@ -0,0 +1,9 @@
<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
@@ -0,0 +1,17 @@
<?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
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+114
View File
@@ -0,0 +1,114 @@
# AGENTS.md — EVABRPUploader
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.
## 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. **Read-only towards the car.** This app must never write a vehicle property. The
sibling EVProfile project writes settings and gates every write on 0 km/h; this one has
no business writing at all. A write path is a bug.
2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null`
means "could not read". Null fields are omitted from the payload. Sending 0 for SOC
tells ABRP the battery is empty and wrecks a live route plan.
3. **Fail closed.** Update origin, APK signature, unknown vehicle state — refuse rather
than assume.
4. **Never let a credential reach a URL, a log line, or a crash report.**
5. **AAOS 9 must stay stable.** A third-party app crashing the head unit is unacceptable.
Every car call goes through reflection with try/catch; keep it that way.
6. **Power matters.** The head unit is awake whenever the car is on. No wake locks, no
alarms. The GPS subscription and the upload tick both cost power — keep them in step.
## Architecture
```
AbrpUploadService foreground service; scheduler thread does all IPC and HTTP
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
```
**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.
## Source sets
| Set | Contents | Ships in |
|---|---|---|
| `main` | everything shared | all builds |
| `stable` | `UpdateHook` no-op | stable channel |
| `unstable` | updater policy code; trigger suspended during the suite audit | unstable channel |
| `debug` | `VhalProbe` — enumerates the whole VHAL | debug builds only |
| `testUnstable` | OTA policy tests | test only |
The stable channel does not merely disable self-update — the updater is **not in the
APK**.
## Firmware generations
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.
**Two read paths, split by whether the signal is firmware-agnostic:**
| 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 |
`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.
**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.
## Working here
```bash
mise run check # what CI runs: permission gate + lint + unit tests
mise run build
mise run logs # adb logcat, filtered to this app
```
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.
## Things that will bite you
- **`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.
+290
View File
@@ -0,0 +1,290 @@
# Changelog
All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.2.2] - 2026-09-17
### Changed
- Updated EVHardware to the shared `master` catalogue and telemetry implementation.
## [2.2.1] - 2026-09-04
### Fixed
- **Speed was uploaded 3.6× too high.** `PERF_VEHICLE_SPEED` reports km/h on SWI68, not the
metres per second AAOS specifies, and EVHardware converted it a second time. Every telemetry
upload from this generation therefore carried a speed 3.6 times the real one, and ABRP's
consumption and arrival models were fed it. Proven on SWI68-29958-1300R67 by two recordings of
a 2.4 km route that came out at 7.69 km and 8.46 km — 129 and 124 km/h average over a few
minutes of town driving. The conversion is now evidence-gated per firmware generation in
EVHardware. Telemetry already sent to ABRP is not corrected by this.
## [2.2.0] - 2026-08-27
### Changed
- Energy acquisition now consumes EVHardware's coherent `EnergySnapshot`; duplicate SOC,
range, power, temperature, odometer and charge property ids were removed from the app.
- Charging-session integration and tyre-pressure reads moved to EVHardware. The local
car-property adapter is now unused by production and supports debug diagnostics only.
## [2.1.8] - 2026-08-26
### Fixed
- **The weather answers arrived all along; they were being thrown away.** EVHardware read the
reply parcel one int too early — a nullable parcelable argument is written as a presence flag
and then the object, so every field landed in the next one's place, the payload's type tag
never matched, and a reading that had arrived was reported as unreadable. That is precisely
the `map=bound` with nothing behind it that 2.1.7 timed the query to explain. Tracking
EVHardware 1.6.0 fixes it, and `ext_temp` has a working source that does not depend on
catching a broadcast at the right moment.
## [2.1.7] - 2026-08-26
### Fixed
- **The weather broadcast is real but rare, and the app forgot it between restarts.** The action
is confirmed: the head unit's weather app sends `com.saicmotor.weather` when *it* refreshes,
not on any cadence this app can ask for. 2.1.6 listened and gave up between refreshes, and the
app is restarted every time the driver opens it to look at the log — which is the worst moment
to start waiting. The last reading is now kept across restarts for two hours, and the platform
is asked at start-up and on every temperature-less upload for a held broadcast, which costs
nothing and answers immediately if the weather app sends its update sticky.
### Changed
- **`map=bound` in 2.1.6 corrected the release note that shipped with it.** That release said the
map service never bound; the log showed the binder alive and the *query* returning nothing,
which is a different failure with a different fix. The map query is now timed, and the elapsed
time appears in the log — the query is bounded by a two-second wait inside EVHardware, so a
null returning at once was refused or answered undecodably while a null returning at the bound
is a service that did not answer in time. Nothing outside the library distinguishes them, and
guessing which one it was is what produced the wrong note.
- The broadcast reading now carries its age in the log (`bcast=Toulouse/12min`): where a value
came from does not say whether it still describes the day.
## [2.1.6] - 2026-08-26
### Fixed
- **`ext_temp` was still absent: the weather service 2.1.5 added never bound.** The log added in
that release is what showed it — every upload listed `ext_temp` among the omitted fields, with
no place name beside the fix. Meanwhile the head unit's own status bar shows a temperature
continuously, and it does not get it from that service: it gets it from a broadcast. The
uploader now listens for the same one (`com.saicmotor.weather`) and prefers it, falling back
to the map service query and, before either, to the vehicle sensor on a car that has one.
The payload's shape is unverified, so it is read by searching for any key that names a
temperature at any depth rather than by a fixed layout, in either unit, whether the value
arrives as a number or as a string. A parser written against a guessed layout fails silently
and looks exactly like the bug it was meant to fix.
### Added
- **The temperature's source, on every log entry** — `[temp car]`, `[temp bcast:Toulouse]`,
`[temp map:...]`, or when nothing answered, why each one did not: `[temp none bcast=no
broadcast yet map=unbound]`. A payload that arrives but carries no readable temperature is
reported with a sample of what it did carry, which is what a fixed parser would have hidden.
### Notes
- Position is not the problem, and the log settled it: fixes arrive from the `fused` provider,
accurate to about a metre and seconds old, with the coordinates the car is actually at. An
address that looks wrong on ABRP is not coming from what this app sends.
## [2.1.5] - 2026-08-26
### Fixed
- **`ext_temp` reached ABRP as 0 degC, and 2.1.4 did not change that.** Guarding the value only
stopped the app sending a zero; ABRP shows 0 for an absent `ext_temp` too, so the symptom was
identical. The cause is the car: this VHAL does not implement `ENV_OUTSIDE_TEMPERATURE`, so
there was never a vehicle reading to correct. The outside temperature now comes from the head
unit's own weather service (`com.saicmotor.mapservice`, already bound by EVTasker through
`SaicWeather`), queried for the car's position and cached for 10 minutes — the query can reach
the network from inside the head unit and it blocks the upload thread while it does. The
vehicle sensor is still preferred whenever a car does implement it.
- **A GPS fix could be dropped for being stale when its age was never knowable.** 2.1.4 aged
fixes on the monotonic clock, but a provider that does not stamp `getElapsedRealtimeNanos()`
leaves it at zero, which dated every fix to the last boot and would have discarded all of
them — a staleness guard turning into a position blackout. An unstamped fix is now kept, and
a fix from a clock that jumped forward is no longer aged negatively.
### Added
- **The payload summary is in the app's Log tab, not only in logcat.** 2.1.4 put it where a car
with no adb attached cannot show it. Each attempt now carries the fields it sent and the ones
it left out, which is the only thing that separates "ABRP shows 0" from "the app sent 0".
- **Position diagnostics on each entry:** the fix's age, its accuracy, the provider that
supplied it, and the place name the weather service answered for. ABRP reverse-geocodes the
address it displays from the same coordinates, so a place name that disagrees with it means
the coordinates are wrong, and one that agrees means ABRP is showing something older.
## [2.1.4] - 2026-08-25
### Fixed
- **The car was reported where it used to be, not where it is.** A GPS fix had no expiry, so
`lastLocation` kept whatever it last held. Two paths turned that into a wrong position:
start-up seeded it from `getLastKnownLocation()` unconditionally — with a cold receiver that
is the *previous* drive's fix, uploaded stamped with the current time for the first minutes
of every trip — and once the receiver stopped delivering (garage, tunnel) the same fix was
resent forever. A fix is now dropped past `max(5 min, 3 x upload interval)`, measured on the
monotonic clock rather than the wall clock, which the head unit adjusts from GPS itself.
Position is then omitted and ABRP keeps its own last point instead of being moved backwards.
- **`ext_temp` could be sent as a freezing day the car never measured.** It was the only
temperature without a plausibility guard, while `cabin_temp`, `batt_temp` and `hvac_setpoint`
all had one. A VHAL that does not implement `ENV_OUTSIDE_TEMPERATURE` returns `0.0` rather
than throwing, so the read looked successful and `ext_temp: 0` went to ABRP's consumption
model. Now guarded like the others (-50..80 degC, exactly 0.0 excluded). A genuine 0 degC is
lost with it — the cheaper of the two errors, and the same trade-off already made three times.
### Added
- **A one-line summary of every upload in the log.** `TelemetryPayload.summarize()` renders
what actually went on the wire, derived from the JSON itself so it cannot drift from the
payload it describes, and names the fields that were left out. That is what separates a read
that failed from a read that returned something wrong — the question this release started from.
Dropped stale fixes are logged with their age.
## [2.1.3] - 2026-08-25
### Removed
- `android.car.permission.CAR_POWERTRAIN` — declared since the first release and never used.
The app reads no powertrain property: park state comes from the vendor gear services through
EVHardware, and nothing here touches the standard AAOS gear or ignition properties the
permission guards. Dropped from the manifest and from the security allowlist.
## [2.1.2] - 2026-08-25
### Fixed
- **The uploader starts on a car that has not granted position yet.** The service declares the
`location` foreground type, and since Android 14 every declared type is checked against the
permissions held at the moment it enters the foreground — so a fresh install, or a boot
before the app had ever been opened, killed the service in its own `onCreate` before the
first tick. The type is now claimed only while the permission behind it is held, and the
activity asks for the permission *before* starting the service rather than after.
- **Outside and cabin temperature reach ABRP again.** Both were declared and read since 2.1.0
but never requested at runtime, so both properties answered as unreadable and were dropped
from every payload.
- **The service notification is visible.** `POST_NOTIFICATIONS` was declared and never asked
for; the only status the service has was being dropped silently on Android 13 and later.
- A permission granted while the service is already running now takes effect at once instead
of at the next boot.
## [2.1.1] - 2026-08-23
### Fixed
- Restored stable release builds by approving the read-only odometer, tyre-pressure and
battery-capacity permissions in the security allowlist.
## [2.1.0] - 2026-08-23
### Added
- The telemetry payload now carries every ABRP field this vehicle can actually answer:
pack temperature, usable capacity, state of energy, odometer, climate setpoint, the four
tyre pressures, and `kwh_charged` for the running charge session. `kwh_charged` has no
counter in the VHAL, so it is integrated from the instantaneous charge rate on every
sample tick — including the ticks the cadence policy throttles away — and resets when a
new session starts.
- Range falls back to the standard AAOS `RANGE_REMAINING` property when the SWI68 vendor
cluster answers nothing, so range now reaches ABRP on other firmware generations too.
### Fixed
- Three vehicle property IDs were off by one and had silently disabled their fields: the
charge-port read was the flap (`EV_CHARGE_PORT_OPEN`) rather than the cable
(`EV_CHARGE_PORT_CONNECTED`), and the cabin-temperature ID was not a VehiclePropertyIds
value at all, so `cabin_temp` had never been sent.
## [2.0.1] - 2026-08-22
### Fixed
- Telemetry no longer stops while the car is charging. Charge detection relied solely on
the standard AAOS charge-port property, which this VHAL need not implement: a plugged-in
car reading "unplugged" was treated as parked and idle and throttled to one sample every
15 minutes, so ABRP never saw the SOC curve it needs to size a charging stop. The charge
rate and, failing that, a rising SOC on a parked car are now used as fallbacks.
- `power` is now signed ABRP's way — positive leaving the battery, negative charging. The
vehicle's charge rate uses the opposite convention and was being forwarded unchanged, so
ABRP saw a fast-charging car as discharging.
## [2.0.0] - 2026-08-15
### ⚠️ Breaking — existing users must install once more
- Renamed from MG4ABRPUploader to **EVABRPUploader**, and the application id changed from
`com.mg4.abrptelemetry` to **`com.evsuite.abrp`**. Android treats this as a different app,
so it does not update an existing install: it is added next to it and starts with no ABRP
credentials and no permissions. Re-enter your ABRP token here, confirm telemetry reaches
your account, and only then uninstall the old app.
## [1.1.0] - 2026-08-10
### Changed
- Reworked the main screen as three swipeable ABRP, Service and Log pages with a fixed,
high-contrast top navigation bar and consistent selected-state accessibility.
- Updated the shared EVHardware dependency to the current catalogue and diagnostics layer.
## [1.0.3] - 2026-08-09
### Fixed
- Corrected stable release packaging and metadata.
## [1.0.2] - 2026-08-04
### Added
- Add an in-app About surface with project information and a repository QR code.
### Security
- Keep unstable OTA downloads in private cache, validate every redirect, verify the APK
signing certificate, and require an explicit successful `pm install` result.
- Remove the public-download update path and its storage permission exposure.
## [1.0.1] - 2026-08-03
### Changed
- Prefill the ABRP API-key input with the public open-source telemetry key while
preserving any custom key already saved by the user.
## [1.0.0] - 2026-08-02
### Added
- API key and token inputs are masked by default, with a password visibility
toggle sized for in-car touch targets.
- CONTRIBUTING.md, DESIGN.md and this CHANGELOG.
- Security issue template.
### Changed
- Adopted the EVSuite design system. Colour, spacing, type and component styles now
come from shared tokens (`values/colors.xml`, `values/dimens.xml`,
`values/styles_ev.xml`), generated by `tools/sync-tokens.mjs` and specified in
[DESIGN.md](DESIGN.md).
- Theme is now `Theme.Material3.DayNight.NoActionBar`: the app follows the vehicle's
day/night setting, and the light palette is held to the same 7:1 contrast floor as
the dark one.
- `ev_outline` raised from `#4A525B` to `#7A8492`. The old value was 2.25:1 against
`ev_surface`, below the 3:1 floor for non-text UI, so the card border it was meant
to provide effectively was not there.
- Launcher icon replaced with the EVSuite adaptive icon: charcoal tile, white glyph
with a single red accent, product caption. Vector only — the five legacy density
bitmap buckets are gone.
- README restructured to the shared EVSuite skeleton; table of contents is now
generated by `tools/sync-docs.mjs`.
- Repaired bug/feature issue templates (duplicate YAML key, field ordering).
+95
View File
@@ -0,0 +1,95 @@
# Contributing
This app runs on a car. That single fact drives everything below.
## Ground rules
1. **The app never writes to the vehicle.** It is read-only towards the car. A patch that
introduces a write will be rejected regardless of how useful the feature is.
2. **A property that cannot be read is omitted, never defaulted.** Sending 0 for an
unreadable battery level tells ABRP the car is empty and corrupts the user's route
plan. Nullable in, absent from the payload.
3. **Fail closed on anything security-shaped.** Unknown update origin, mismatched
signature, unreadable state: refuse, do not guess.
4. **Say what you did not verify.** Most of this code can only be fully tested on a
vehicle. "Builds and unit tests pass, not tried on the car" is a perfectly good PR
note. Silence implying it was road-tested is not.
## Before opening a PR
```bash
mise run check # permission gate + lint + unit tests
```
or, without mise:
```bash
bash .github/security/check-permissions.sh
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
```
New behaviour needs a unit test. The pure-logic classes — `TelemetryPayload`,
`UploadCadence`, `UploadLog`, `OtaUpdater` — are deliberately free of Android types so
they can be tested on the JVM; put testable logic there rather than in the service.
## Permissions
Any new `uses-permission` fails CI until it is added to
`.github/security/permission-allowlist.txt` **with a justification comment**. This is
deliberate: an app on someone's car should not gain capabilities quietly.
## Channels
- `stable` — tagged releases, no self-update, no updater code in the APK.
- `unstable` — automated pre-releases that update themselves.
If you touch the updater, remember it installs code on a vehicle: the origin allowlist and
the signature check are the whole defence.
## Ways to help
Not every useful contribution is code:
- **Bug reports** — firmware generation, app version, what you did, what happened.
- **Feature requests** — describe the problem before the solution.
- **Documentation** — README, this guide, translations.
- **Pull requests** — see below.
- **Testing pre-releases** — install an `unstable` build and report what broke.
- **Sponsorship** — see below.
## Contributing efficiently
Maintainer time and AI quota are the scarce resources here, ideas are not. If you have
access to Claude Opus or another capable coding model, a finished pull request is worth
much more than a feature request: someone still has to design, write, test and verify the
request, and that someone has a limited quota too.
A pull request that lands quickly usually carries:
- The problem, in one or two sentences.
- The proposed solution, and what you rejected.
- The implementation, scoped to one concern.
- Tests — `mise run check` passes.
- Documentation updated: README, CHANGELOG, this guide where relevant.
Generate the change locally with whatever model you have, then read every line yourself
before opening the PR. You are the author, not the model. Unreviewed generated code on a
path that reaches the vehicle will be sent back.
## Sponsorship
Maintaining EVSuite costs development time, test hardware and AI usage. If it is useful in
your daily driving, consider sponsoring through
[GitHub Sponsors](https://github.com/sponsors/malys). Sponsorship covers those costs and
gets fixes and features out faster.
## Commit messages
Explain **why**, not what — the diff already says what. If you fixed something subtle,
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.
+463
View File
@@ -0,0 +1,463 @@
---
name: EVSuite
description: One design system for the EVSuite — a driver-facing instrument surface, not a phone UI parked in a car.
colors:
background: "#F2F4F7"
background-dark: "#0B0D0F"
surface: "#FFFFFF"
surface-dark: "#15181C"
surface-raised: "#E4E8ED"
surface-raised-dark: "#272C33"
outline: "#737F8F"
outline-dark: "#7A8492"
text-primary: "#101418"
text-primary-dark: "#F4F7FA"
text-secondary: "#414B55"
text-secondary-dark: "#C6CFD8"
accent: "#044C63"
accent-dark: "#7FD4FA"
on-accent: "#FFFFFF"
on-accent-dark: "#04121A"
ok: "#09501F"
ok-dark: "#8FE6A6"
warn: "#6B3A00"
warn-dark: "#FFC978"
error: "#8F0F18"
error-dark: "#FFACAC"
icon-tile: "#2A2A2A"
icon-glyph: "#F4F7FA"
icon-mark: "#E0424C"
typography:
display:
fontFamily: "Roboto, sans-serif"
fontSize: "32sp"
fontWeight: 500
title:
fontFamily: "Roboto, sans-serif"
fontSize: "26sp"
fontWeight: 500
body:
fontFamily: "Roboto, sans-serif"
fontSize: "20sp"
fontWeight: 400
secondary:
fontFamily: "Roboto, sans-serif"
fontSize: "17sp"
fontWeight: 400
label:
fontFamily: "Roboto, sans-serif"
fontSize: "16sp"
fontWeight: 700
letterSpacing: "0.12"
rounded:
md: "12dp"
spacing:
xs: "4dp"
sm: "8dp"
md: "16dp"
lg: "24dp"
touch: "72dp"
components:
button-primary:
backgroundColor: "{colors.accent}"
textColor: "{colors.on-accent}"
typography: "{typography.body}"
rounded: "{rounded.md}"
height: "{spacing.touch}"
padding: "0 24dp"
button-outlined:
backgroundColor: "transparent"
textColor: "{colors.accent}"
typography: "{typography.body}"
rounded: "{rounded.md}"
height: "{spacing.touch}"
padding: "0 24dp"
card:
backgroundColor: "{colors.surface}"
textColor: "{colors.text-primary}"
rounded: "{rounded.md}"
padding: "16dp"
section-header:
textColor: "{colors.accent}"
typography: "{typography.label}"
---
# Design System: EVSuite
Applies to **EVProfile**, **EVTasker**, **EVABRPUploader**, **EVChargePilot**,
**EVLauncher**, **EVSwipe** and the shared **EVHardware** library. This file is the normative
source; a copy lives at the root of every suite repository and they are kept identical.
## Overview
**Creative North Star: "The Instrument Binnacle"**
These are not phone apps that happen to run in a car. They are extensions of the
instrument panel, and they are judged the way a gauge is judged: can the driver read it
in one glance, at 70 cm, through a windscreen reflection, without thinking about it. Every
decision below follows from that. The suite is quiet, dense with information but never
busy, and it never asks for attention it hasn't earned — an MG4 app that animates,
celebrates, or decorates is competing with the road.
The visual language is flat, high-contrast and typographic. Depth comes from tonal
layering and a hairline outline, never from shadow. Colour carries meaning — accent,
ok, warn, error — and is never decorative. The one place personality is allowed is the
launcher icon, which is why the icon system gets its own section.
The suite is deliberately unlike consumer Android: bigger type, bigger targets, fewer
elements per screen, no gesture-only affordances, no ambient motion. Where Material 3
defaults conflict with legibility in daylight, legibility wins and the override is
commented in the theme.
**Key characteristics**
- Every text colour clears **7:1 (WCAG AAA)** on every surface it can land on. 4.5:1 is
not enough once sunlight hits the glass.
- Minimum touch target **72dp**, everywhere, including dialog buttons and switches.
- Minimum type size **16sp**. There is no "small" tier.
- Flat surfaces, tonal layering, one hairline outline. No shadows, no gradients.
- Colour is never the only carrier of meaning; a text label always accompanies it.
- Landscape-first. Portrait is not a supported orientation on the head unit.
## Colors
A neutral blue-grey ladder carrying one cyan accent and three status hues. The suite is
**DayNight**: `values/` holds the light palette, `values-night/` the dark one, and both
are held to the same 7:1 floor. Token names are identical in both files — a layout never
references a light or dark colour, only a role.
Every ratio below is measured, not estimated. Re-run the check after any change:
`node tools/contrast.mjs` (see *Do's and Don'ts*).
### Neutral
| Token | Light | Dark | Role |
|---|---|---|---|
| `ev_background` | `#F2F4F7` | `#0B0D0F` | The window. Never holds content directly. |
| `ev_surface` | `#FFFFFF` | `#15181C` | Cards, panes, list rows. |
| `ev_surface_raised` | `#E4E8ED` | `#272C33` | Buttons, chips, selected rows, dialogs. |
| `ev_outline` | `#737F8F` | `#7A8492` | Hairline on every card. 4.07:1 / 4.70:1. |
| `ev_text_primary` | `#101418` | `#F4F7FA` | 18.5:1 / 16.6:1 on surface. |
| `ev_text_secondary` | `#414B55` | `#C6CFD8` | 7.2:1 worst case (on `surface_raised`). |
### Primary
- **Instrument Cyan** (`ev_accent`, `#044C63` light / `#7FD4FA` dark): section headers,
selected state, links, the active value in a control. 7.7:1 worst case in light, 10.8:1
in dark. Light mode uses a deep cyan rather than tinting the dark one — a pale cyan on
white is 1.5:1 and disappears entirely in daylight.
- **On-accent** (`ev_on_accent`, `#FFFFFF` / `#04121A`): the only colour permitted on a
filled accent surface.
### Status
Never used alone. Each is paired with a text label, because a driver glancing sideways
reads shape and text faster than hue, and because ~8% of male drivers cannot separate
these three by colour at all.
- **OK** (`ev_ok`, `#09501F` / `#8FE6A6`) — the rule ran, the write landed, the upload succeeded.
- **Warn / refused** (`ev_warn`, `#6B3A00` / `#FFC978`) — the speed gate refused a write,
a deferred action is queued, firmware support is unknown.
- **Error** (`ev_error`, `#8F0F18` / `#FFACAC`) — the operation failed.
### Pressed state
- `ev_pressed` (`#14000000` / `#1FFFFFFF`) and `ev_pressed_strong` (`#29000000` /
`#33FFFFFF`). Alpha overlays on the surface beneath, not a separate hue, so a pressed
card still reads as the same card.
### Named rules
**The Daylight Rule.** 7:1 or it does not ship. AA (4.5:1) is a phone-in-a-hand
standard; the MG4 screen is read through a reflection with the sun behind the driver, and
4.5:1 collapses to unreadable there. This is the single constraint that has forced the
most rewrites in this codebase — `#9EA6AE` secondary text (6:1) and the original
`#4A525B` outline (2.25:1) both shipped and both had to be replaced.
**The Meaning Rule.** A colour outside the neutral ladder must encode state. If it is
there to look nice, delete it. This is why the suite has one accent and not a palette.
**The Role Rule.** Layouts reference roles (`ev_surface`), never values and never
theme-specific names. A layout that needs to know whether it is day or night is a bug.
## Typography
**Family:** Roboto — the AAOS 9 system face. No bundled fonts: an APK on a vehicle earns
every kilobyte, and a missing glyph in a six-locale app is a worse failure than a generic face.
**Character:** Neutral to the point of invisibility. The type is a readout, not a voice.
Weight and size carry hierarchy; italics and letterspacing are used once each, on purpose.
### Hierarchy
| Role | Size | Weight | Purpose |
|---|---|---|---|
| **Display** (`text_display`) | 32sp | Medium | A single number or word that is the whole point of the screen — a battery percentage, a drawer title. At most one per screen. |
| **Title** (`text_title`) | 26sp | Medium | Screen and card titles. |
| **Body** (`text_body`) | 20sp | Regular | Everything the driver actually reads. Also the size of every button label. |
| **Secondary** (`text_secondary`) | 17sp | Regular | Supporting detail, timestamps, units. |
| **Label** (`text_label`) | 16sp | Bold, `0.12` tracking | Section headers, in accent, uppercase-adjacent. The floor. |
### Named rules
**The 16sp Floor.** Nothing renders below 16sp. Not captions, not units, not footnotes.
Below that, contrast stops compensating for reflection and viewing distance, and the
information may as well not be on screen. If content does not fit at 16sp, there is too
much content.
**The One Display Rule.** One `text_display` element per screen. Two competing 32sp
numbers is two glances, and a glance is the budget.
## Layout
**Target.** MG4 Electric head unit: 12.8" HD centre display, AAOS 9 on MT2712, landscape,
viewed at ~70 cm. DPI is user-adjustable per app on this platform, so everything is
expressed in `dp`/`sp` and nothing assumes a pixel size. Portrait is not supported and is
not laid out for.
**Spacing scale.** `4 / 8 / 16 / 24dp` (`spacing_xs` … `spacing_lg`). Screen edges get
`spacing_lg`. Nothing between two rows is smaller than `spacing_sm` — tighter than that
and two targets read as one.
**Touch.** `touch_target` = **72dp** minimum for anything tappable, including dialog
buttons, switches and list rows. Android's 48dp guidance assumes a phone held still in a
hand; this screen is at arm's length in a moving vehicle. Adjacent targets are separated
by at least `spacing_sm`, so a missed tap is a miss, not a wrong action.
**Two-pane landscape.** Wide screens use a list/detail split rather than a stack — the
horizontal space exists, and a driver who has to navigate back to see context has lost
the context. `pane_list_width` = 380dp (wide enough for a full rule name), `pane_rail_width`
= 300dp for label-only rails. The detail pane takes the remainder and is the primary area.
**Grids.** Tile grids compute their column count from `catalog_tile_min_width` (240dp)
rather than hardcoding a number, so a DPI change re-flows instead of clipping. Tiles are
`catalog_tile_height` = 128dp — twice the touch minimum, so a three-line label plus a
qualifier fits without pushing the next row.
**Density.** Fewer, larger elements. If a screen needs a scroll before the fifth item, the
screen is wrong, not the scroll.
**App-local dimensions are allowed** where a surface genuinely differs (launcher tiles,
settings margins) — but they are declared in the app's `dimens.xml` with a comment saying
why, and they never redefine a suite token.
## Elevation & Depth
**There are no shadows.** Not one, in any app. A shadow on this display is a grey smear
that costs a composite pass and reads as dirt under sunlight. Depth is tonal:
`background` → `surface` → `surface_raised`, three steps, plus a 1dp `ev_outline`
hairline on every card.
The hairline is not decoration. `surface` and `surface_raised` differ by roughly one
tonal step; in direct sun that difference vanishes and the card boundary goes with it.
The outline is what survives.
State is expressed by the pressed overlay, never by lift.
## Shapes
One radius: **12dp** (`ev_corner`), on cards, buttons, chips, tiles and dialogs. Not two,
not a scale. A single radius is what makes five apps by different hands read as one suite,
and it is the cheapest consistency there is.
Borders are 1dp `ev_outline`. No dividers where a gap will do — `spacing_md` between two
cards separates them better than a line, and costs no ink.
The launcher icon is the one place a different silhouette appears: a squircle tile, which
is the platform's shape, not ours.
## Components
Component styles are named identically in every app so a screen can be moved between
repositories without a rename: `Theme.EV<App>`, `Widget.EV.*`, `Text.EV.*`,
`ThemeOverlay.EV.*`.
### Buttons
- **Shape:** 12dp radius.
- **Primary** (`Widget.EV.Button`): filled accent, `on_accent` label, `text_body` (20sp),
min height 72dp, `spacing_lg` horizontal padding.
- **Outlined** (`Widget.EV.Button.Outlined`): 1dp accent border, accent label, same metrics.
- **Dialog** (`Widget.EV.DialogButton`): text button, but pulled up to the same 72dp
target and 140dp min width. Dialog buttons are the smallest thing in any app and the
most likely to be mis-tapped.
- **Pressed:** `ev_pressed_strong` overlay. No ripple travel, no scale, no lift.
### Cards / containers
- 12dp radius, `ev_surface`, 1dp `ev_outline`, `spacing_md` internal padding.
- Selected state: background steps to `ev_surface_raised` **and** the leading label
turns accent. Two signals, because one is a colour.
### Switches
- `Widget.EV.Switch`, 72dp min height, `text_body` label. A switch that needs a precise
tap is a switch that gets missed at 90 km/h.
### Section headers
- `Text.EV.SectionHeader`: 16sp, bold, `0.12` tracking, accent. The only tracked type in
the suite; the tracking is what lets it work at the size floor without shouting.
### Dialogs
- `ThemeOverlay.EV.Dialog` on `ev_surface_raised`. Material's default dialog palette is
a grey-mauve tuned for indoors and it is the first thing to wash out; every app overrides it.
- A picker that fills the screen (`Theme.EV.Picker`) is not floating and does not dim:
`windowIsFloating=false`, `backgroundDimEnabled=false`. In landscape, a bottom sheet
leaves a third of the height, which means one column and a scrollbar by the fifth entry.
There is nothing behind a picker worth seeing.
- A confirmation or warning with explanatory content is a **dedicated full-screen
window, never a popup**. Put its explicit confirm and cancel actions together at the
upper-right, consistently with the suite's editor windows, keep both at the 72dp touch
minimum, and let the content use all remaining space. Reserve a compact dialog for a
short, single-decision interruption whose complete message remains immediately readable
at 70 cm.
### Lists
- Row height ≥ 72dp, `ev_surface`, `spacing_sm` gaps. No swipe-to-delete: a gesture with
no visible affordance and no undo does not belong in a vehicle. Destructive actions are
a button plus a confirmation.
## Iconography
The launcher icon is the suite's signature and the one place it is allowed to have fun.
**The system.** Adaptive icon, 108dp, safe zone 66dp centred. Three fixed elements:
1. **Tile** — flat `#2A2A2A` charcoal. No gradient. Identical in every app, and identical
in light and dark: this is the tile EVProfile has always had, and it is what makes a
row of suite apps read as a row.
2. **Glyph** — `#F4F7FA`, geometric, 5dp stroke, round caps and joins, with exactly one
`#E0424C` red element as the accent (3.45:1 on the tile — it is a mark, not text).
The glyph says what the app does at 48px. EVProfile's glyph is the `MG⁴` lockup: it
is the flagship, so it wears the suite mark.
3. **Caption** — the product word beneath the glyph, `#C6CFD8`, small caps, `0.18` tracking.
**The glyphs**
| App | Glyph |
|---|---|
| EVProfile | `MG⁴` lockup — white letters, red superscript 4 |
| EVTasker | A branch: one input, a condition, two outcomes |
| EVABRPUploader | An upward arrow leaving two broadcast arcs |
| EVChargePilot | A battery crossed by a measured energy trace |
| EVLauncher | A four-tile grid, one tile red |
| EVSwipe | Two chevrons and a swipe trail |
**Rules.** Vector (`VectorDrawable`) only — the same file serves every density and costs
one file instead of five PNGs. Every icon ships a `<monochrome>` layer for themed icons.
No photographic content, no bevel, no drop shadow, no stock clipart. If a glyph is not
legible at 48px in greyscale, it is the wrong glyph.
## Nomenclature
**Product names** are `EV<Word>` — space, title case: *EVProfile*, *EVTasker*,
*EVABRPUploader*, *EVLauncher*, *EVSwipe*. This is what
`app_name` holds and what the driver sees under the icon.
**Repositories, modules and directories** are `EV<Word>` — no space: `EVProfile`,
`EVTasker`, `EVABRPUploader`, `EVChargePilot`, `EVLauncher`, `EVSwipe`, `EVHardware`.
**Package ids** are `com.evsuite.<lowercase>`; a family shares a segment
(`com.evsuite.launcher`, `com.evsuite.swipe`).
**Resources** are `snake_case` with a suite prefix when the token is shared
(`ev_surface`, `ev_corner`) and unprefixed when app-local (`catalog_tile_height`).
Styles are `Theme.EV<App>`, `Widget.EV.<Thing>`, `Text.EV.<Role>`.
**The suite** is *EVSuite* — one word, when referring to all of it.
## Documentation
Every repository carries the same files, in the same order, with the same headings.
| File | Contents |
|---|---|
| `README.md` | The one below. English. |
| `DESIGN.md` | This file, verbatim. |
| `AGENTS.md` | Repo-specific agent context. |
| `CONTRIBUTING.md` | How to build, test, submit. |
| `SECURITY.md` | Threat model, disclosure. |
| `DISCLAIMER.md` | Vehicle-safety disclaimer. |
| `CHANGELOG.md` | Keep a Changelog format. |
| `LICENSE.md` | Licence text. |
**README skeleton** — sections in this order, omitting any that do not apply:
```
# EV<Name>
<one-line description>
<badges>
## Contents
## Screenshots
## Overview
## Features
## Requirements
## How it works
… app-specific detail sections belong here, and only here
## Install
## Configuration
## Building
## Project layout
## Project documents
## Security
## Contributing
## Legal
```
The `## Contents` table of contents is generated from the headings, not hand-written:
`node tools/sync-docs.mjs`. A hand-written TOC goes stale the first time a heading
is renamed, and a dead anchor in a README is a small lie about the project.
Translations live in `README.<lang>.md` and are linked from the top of the English
README — never interleaved into it. A bilingual README doubles the size of every future edit.
## Constraint history
Why the rules above are the shape they are. Each of these was paid for.
- **7:1, not 4.5:1.** Secondary text shipped at `#9EA6AE` (6:1) and was unreadable in
daylight. The floor moved to AAA and the whole ladder was re-derived.
- **The outline exists because tonal layering alone failed.** `surface` and
`surface_raised` are one step apart and merge under sun. The border was added — then had
to be raised from `#4A525B` (2.25:1) to `#7A8492` (4.70:1), because a border below the
3:1 non-text floor is a border that isn't there.
- **72dp, not 48dp.** Google's target assumes a hand-held phone. Missed taps on a moving
vehicle drove it to 72dp, and dialog buttons and switches were the last holdouts.
- **16sp floor.** Anything smaller stopped being legible at 70 cm through a reflection.
- **Full-screen pickers.** A bottom sheet in landscape left a third of the height: one
column, scrolling by the fifth entry. The picker became a full-screen non-floating window.
- **No shadows.** Cost a composite pass on MT2712 and read as smudge in sun.
- **Dark-first, DayNight second.** The suite ran dark-only for a long time because a light
background facing the driver at night is glare. It is now DayNight across all five apps
by explicit decision — which means the light palette carries the same 7:1 obligation
the dark one always had, and the pale dark-mode accent could not simply be reused.
- **Colour never carries meaning alone.** Verdict rows show a word as well as a hue.
- **Vector icons, no PNG sets.** Five density buckets per app, for an icon, on a vehicle
where APK size is a stability concern.
- **AAOS 9, MT2712, must not destabilise the vehicle.** Everything above is downstream of
this. When a design choice and stability disagree, stability wins and the design choice
gets a comment explaining what it gave up.
## Do's and Don'ts
### Do
- **Do** verify contrast numerically before committing a colour. Every ratio in this file
is measured; new ones must be too.
- **Do** put the reason in the resource file. Every override of a Material default in this
suite carries a comment saying what would break without it. That comment is why the rule
survives the next refactor.
- **Do** reference roles (`@color/ev_surface`, `@dimen/touch_target`), never literals.
A hex value in a layout is a bug.
- **Do** give every state two signals — colour plus text, or colour plus position.
- **Do** design landscape first, and check that nothing needs a scroll before the fifth item.
- **Do** name new resources with the suite prefix if another app could want them.
### Don't
- **Don't** add a shadow, gradient, or elevation. Depth is tonal.
- **Don't** introduce a second accent hue. One accent, three status colours, that is the
whole palette.
- **Don't** ship type below 16sp or a target below 72dp, including in dialogs.
- **Don't** use `Theme.Material3.Dark` or `.Light` directly — always `.DayNight`, so both
palettes stay wired.
- **Don't** rely on a gesture with no button fallback. No swipe-to-delete, no long-press-only.
- **Don't** animate anything ambient. Motion is confirmation of a touch, or it is absent.
- **Don't** put a light background behind a full-screen surface at night without checking
it against the dark palette — the driver's night vision is a safety property.
- **Don't** hardcode a pixel size or assume a DPI. This platform lets the user change it.
+38
View File
@@ -0,0 +1,38 @@
# Disclaimer — no warranty, no liability
**Use this software entirely at your own risk.**
This project is provided **"as is"**, without warranty of any kind, express or implied,
including but not limited to the warranties of merchantability, fitness for a particular
purpose and non-infringement. In no event shall the authors or contributors be liable for
any claim, damages or other liability, whether in an action of contract, tort or
otherwise, arising from, out of or in connection with the software or its use.
## What that means concretely
- The app runs on a **vehicle**. Installing third-party software on a car is your
decision and your responsibility, including any effect on the head unit's stability,
your warranty, your insurance, or your vehicle's roadworthiness.
- **Do not operate the app while driving.** Configure it parked. Nothing in it should
require attention on the move, and looking at a screen instead of the road is on you.
- Telemetry may be **wrong, delayed or missing**. Never rely on it for range or charging
decisions where being wrong matters — check the car's own instruments.
- The **unstable channel is untested on vehicles** by definition. It builds automatically
from `main`; its OTA trigger is suspended during the suite safety and legal audit.
- Vehicle data is uploaded to **A Better Route Planner**, a third-party service governed
by its own terms and privacy policy. This project has no control over what happens to
data once sent.
- Reading vehicle properties uses **undocumented runtime interfaces**. They can change or
disappear with any firmware update.
## Not affiliated
This project is **not affiliated with, endorsed by, or supported by** SAIC Motor, MG
Motor, Iternio (A Better Route Planner), or Google. MG, MG4 and related names and logos are
trademarks of their respective owners. They are used solely to identify compatibility with
certain vehicles; no official origin, certification or approval is claimed.
## Contributors
Contributors provide their work on the same terms: no warranty, and no liability for how
anyone uses it.
+24
View File
@@ -0,0 +1,24 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build
/app/build
/captures
.externalNativeBuild
.cxx
local.properties
# Surcharge locale de mise (machine-spécifique) — mise.toml, lui, est commité.
mise.local.toml
.mise.local.toml
# Secrets de signature : jamais dans le dépôt.
keystore.properties
*.keystore
*.jks
# Android library module build output
lib/build/
build/
+65
View File
@@ -0,0 +1,65 @@
# AGENTS.md — EVHardware
Shared vehicle-access library for the SAIC MG4 (AAOS 9, MT2712), consumed by EVProfile,
EVTasker, EVABRPUploader and EVChargePilot via git submodule + Gradle subproject.
EVHardware is the shared vehicle layer for **EVSuite**. The workspace `AGENTS.md` and
normative workspace `DESIGN.md` apply; this file defines library-specific invariants.
Commit author: malys.training@gmail.com
License exception: EVHardware's own sources use PolyForm Noncommercial 1.0.0, not the
workspace MIT default.
## Why this exists
One implementation of the reflection hardware layer, the 0 km/h gate, the models, and the
condition/action catalogue — so the apps do not each re-derive vehicle access and drift.
The apps are thin; the vehicle lives here.
## Non-negotiables
- **The 0 km/h gate is here.** `VehicleWriteGate` decides every vehicle-setting write inside
the low-level write primitives. Every setter carries `@RequiresStandstill`; coverage keeps
the set honest. Unknown or negative speed fails closed, and a moving reading is never
rescued by anything.
The threshold is fixed at a *readable* 0 km/h, with no caller override and no park rescue.
- **Per-firmware routing, never universal.** SWI68/69/131/132/133/165 differ; dispatch
through `FirmwareInfo`. Any new vehicle call must branch per generation.
- **Unreadable ≠ a value.** Getters return null / -1 when the layer is not ready or the
property is absent; callers must treat that as "unknown", never as 0/false.
- **Energy semantics are shared.** Property ids, units, sign convention, physical validation,
fallback order, trip integration and charging-session integration belong in `telemetry/`.
Consumers own their UI, sampling cadence and network payload only.
- **`@SupportedOn` is the source of truth** for `docs/firmware-matrix.md` (generated, never
hand-edited) and for consumers' runtime filters. A vehicle catalogue entry without it
fails the tests.
- **Never write an unverified AOSP id.** The climate and window ids currently known are
used for reads only. Writes go through `saic.*`, with every constant documented and
validated per supported firmware generation.
- **English only** — code, comments, commits, docs. User strings in `values/` (English) +
`values-fr/`.
## Consuming it
Submodule at `./EVHardware`; `settings.gradle.kts` includes `EVHardware/lib` as
`:evhardware`; app depends on `project(":evhardware")`. The library declares no
permissions and no `sharedUserId` — vehicle-write capability is the app's platform-signing
decision, not the library's.
**Every consuming app tracks the HEAD of `master`.** Never pin an older commit, and never
commit on a detached submodule HEAD: the catalogue is shared, so an entry removed here must
disappear from every app at once, and two apps on two lines of this library means the same
change written twice and reconciled by hand afterwards. Land library work in the standalone
checkout, push it, then move each app's pointer forward and run that app's tests.
## Reflection + R8
All vehicle access reflects `android.car` (absent from the compile SDK). `consumer-rules.pro`
keeps the reflected names (android.car / SAIC / models / catalogue enums / `@SupportedOn`)
so a consumer's R8 does not strip them. Verify a minified consumer on a vehicle.
## Build
`mise run test | build | check`. JDK 17. Tests are JVM-only (no emulator): gate decision,
firmware annotations, catalogue consistency, matrix generation.
+947
View File
@@ -0,0 +1,947 @@
# Changelog
All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **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,
and RI-002 was nearly closed on them. `EVHardware.hasCarAdapterClient()` is checked once before
the sweep and every swept entry now carries the caveat on itself rather than in the bundle
preamble, because a later session reads one entry and not the header.
- **`SocConsumptionFitter.describe()` tells the two refusals apart.** Too few segments and a speed
span too narrow to separate rolling from aero both answer `INSUFFICIENT_SAMPLES`, and the two
ask the driver for opposite things — more kilometres, or one motorway leg. A third cause was
silent altogether: a trip skipped for a speed conversion no longer believed leaves no trace, and
a history of them looks exactly like a history of short drives. One diagnostic line now states
used-trips, segments and speed span against their thresholds. Diagnostic only — it runs the
collection pass a second time, so it belongs in a probe and not on a screen.
- **The pack's health, as a ratio rather than a reading.** `INFO_EV_BATTERY_CAPACITY`,
`EV_BATTERY_LEVEL` and the `EV_CURRENT_BATTERY_CAPACITY` candidate are declared and never
published on SWI68 — null in all 612 snapshots of the 2026-09-13 drive — so the capacity behind
every kWh figure in EVSuite has been a specification sheet the driver retypes. It does not have
to be: the energy that left the pack, over the charge it cost, *is* a capacity, and both halves
are now recorded. `BatteryLedgerStore` keeps the entries a trip cannot — the parking, the
standing, the charge between two drives — and `BatteryLedgerRecorder` turns the 1 Hz sample
stream into one entry per point of charge, change of charging state, or quarter of an hour,
carrying the car's own kWh counters (`SaicCharging` transactions 75 and 77), this app's running
integral of `BMS_PACK_VOL × BMS_PACK_CRNT`, the odometer and the outside temperature.
`StateOfHealthEstimator` assembles the discharges between two charges and reports the median of
at least six of them, with the source named on the estimate and the two sources never averaged
together. A charge step on this car is about one point, so a 25-point window carries ±4 % before
anything else goes wrong and a year of real degradation is two or three points: one window is
never a health figure, the band never claims to be tighter than the quantisation allows, and the
figure worth reading is the trend, which cancels whatever node the energy was counted at. Cold
windows are excluded and counted rather than believed. Every output is `ESTIMATED` with its band.
- **When the charge gauge last had anything to anchor itself on.** `CalibrationDrift` reads the
same ledger for a different question: how long since the pack saw a full charge, how many
equivalent cycles since, and whether any charge step went unexplained — a car that stood still,
drove nowhere and woke up several points lower did not consume that charge, the BMS re-estimated
it. The odometer has to read identical at both ends for a step to count at all, and a
self-discharge allowance keeps a fortnight of standing still from being reported as a fault.
It notices; it changes nothing, and no figure is corrected against an observed step.
- **What a pack has been subjected to.** `BatteryExposure` reports hours above 80 % and below
10 % of charge (interpolated across each segment, so a night that ended at 85 % counts its hours
where they were spent), the mean charge, equivalent full cycles, kilometres per cycle, and one
row per charge session with its mean and peak points per hour — the rate being what separates a
wall box from a fast charger on a car that publishes no charger type.
- **The head unit can now be asked what it publishes.** `RuntimeInterfaceProbe` walks the
adapter's service codes and the vehicle hub's service names, reads back the interface
descriptor of every binder that answers, and records one of three verdicts per interface:
absent, reached-and-refused, or answered. The distinction is the whole point. Half the `RI-`
questions are currently open because nobody knows whether an interface is missing from this
head unit or merely closed to an unprivileged caller, and those two answers lead to opposite
decisions. Reading a binder's own descriptor is not a call into it, so nothing of unknown
meaning is invoked, and no setter is referenced anywhere in the file — several of these
binders would move a car. Typed reads follow only for the two bind points already known: the
vehicle option getters and the head unit's power state. The screen sub-service is bound but
never read: it publishes no getter at all, and guessing a code to find one is exactly what
this file exists not to do. There is a second hub behind engineering mode, mapped the same
way, and the survey asks it for the four part numbers the head unit states about itself —
identity the vehicle asserts, where the generation label is something the project inferred.
It also reads the second speed source, which reports a float where the source the write gate
trusts is integral. Nothing in production calls any of it; it exists to be run once from an
unstable capture and turned into answers.
- **A leg is not over on one reading.** `NavLegChain` needs three consecutive answers of "not
guiding" before it treats a leg as finished. The drive of 2026-09-09 16:34 is why: a route
handed over through `startNavFromEVRout` guided for twenty-five minutes while the adapter's
remaining distance went to nothing seven minutes in and its notification listener heard nothing
at all. Whether the guiding flag is as shaky as its siblings is not yet known, and the cost of
being wrong is a car sent onward from a charger it is still driving to. Three readings cost
seconds; a real arrival stays false for the rest of the day.
- **A trip can now be driven one leg at a time.** `NavLegChain` holds the points of a plan and
hands the next one over when the current one is done — done being the navigation app's own
`isMapNavigating` flag going false after having been true. It exists because the only call ever
seen to *start* guidance on this head unit, `goTo`, carries a single point: a plan with a
charging stop on the way has to be sent as the stop, then the destination. Pure and
Android-free, so the whole of its behaviour is proven on the JVM rather than on a road. Its
ceiling is written down in its own documentation: a driver who cancels guidance mid-leg looks
the same as one who arrived, so the next leg starts under them — and cancelling that one too
ends the chain.
- **The car can now say whether guidance actually started.** `SaicNavGuidance.isMapNavigating` is
`IGeneralService` transaction 18, and what it returns is not an inference: `MapService` holds
the flag and the navigation app sets it itself through `IMapService.isMapNavigating(boolean)`.
A command accepted by the adapter only ever meant the adapter took it — the fan-out swallows a
refusal — so this is the difference between "sent" and "started", and it lets a caller try one
channel, ask the car, and move to the next.
- **Guidance can be started, not only drawn.** The route handoff put the destination on the map
and stopped there: the driver saw the place and no guidance ever ran. `SaicNavGuidance.goTo` is
the command that runs it — `IGeneralService` transaction 23, which `GeneralService` turns into
`MapService.goToPoi(null, address, null, latitude, longitude)`, the same call the head unit's
voice assistant makes when someone says "take me there". One point and no pathway, because the
interface has room for nothing else: a plan with a charging stop guides to the stop, which is
the leg being driven. Same boundary as `startNavFromEvRoute` — a navigation write, not a
vehicle write — and the same two cautions: the adapter logs the point, and the fan-out is
synchronous, so it must not run on the main thread.
- **A planned route can be handed to the car's own navigation, waypoints included.** Two drives
proved that nothing on SWI68 declares a filter for `geo:` or `google.navigation:`: the map
opened at its default view and the driver typed the address in again. The head unit has its own
channel and it was hiding in plain sight — `IGeneralService` transaction 48,
`startNavFromEVRout(pathway, destination)`, on the same `GeneralService` this module already
binds for guidance. It forwards to `MapService`, which fans the points out over its
`RemoteCallbackList` to whichever navigation app registered. `SaicNavGuidance.startNavFromEvRoute`
is that call.
**It is not an impersonation and not a vehicle write.** `IMapNotificationListener` is the
channel the head unit uses to *command* the navigation app; registering on it to send one
destination would mean claiming to be a navigation provider, and this is the other end of that
wire. `VehicleWriteGate` is untouched and stays untouched: it exists for the settings that
change how the car behaves under the driver — AEB, ELK, ACC/TJA, the drive mode — and a
destination is no more one of those than the comfort writes it already exempts.
**What travels is three fields.** `NavigationHandoff.poi` validates a point the same way the
`geo:` form does — a swap of latitude and longitude routes to another continent while looking
like an ordinary number — and keeps the name as a parcel field rather than a URI label, because
a parenthesis cannot end a parcel field early and stripping it would only mangle what the driver
reads on the map. `NavigationHandoff.pathway` caps the waypoints. The list is written by hand,
since this module does not carry the vendor's `EVRoutPoiInfo`, and `EvRoutPoiParcelTest` proves
the framing byte-for-byte against what Android's own `writeTypedList` produces for a bean with
the same three fields.
**Two things a caller has to know.** The transaction is not `oneway` and the service fans out
synchronously while holding a lock, so it must not be called on the main thread; and
`MapService` writes both point lists to the head unit's own logcat before fanning out, which is
the vendor's log rather than this app's and the one place a destination appears in text.
- **The guidance census records the size of the last parcel on each transaction code.** The
2026-09-07 capture is why: with guidance running on the car's own navigation, code 13 decoded
as a remaining distance of zero and code 12 as a remaining time of zero, while an undecoded
code 11 carried 98 callbacks in the same window. Nothing in the artifact could tell a genuine
zero from a payload of a shape this build reads as an int. A parcel of one int is the
descriptor plus four bytes; a larger one settles it without decoding a field, and a byte count
is not a place, which is why it can travel on the stick.
### Changed
- `EnergySnapshot` carries `vehicleConsumedKwh` and `vehicleRegeneratedKwh`, read through
`EnergyTelemetryReader` from the charging service's own counters. Both are defaulted: every
existing caller builds that snapshot by name, and a counter this car may not keep should not
become a parameter each of them has to pass as null.
- **`PlanDrift` now measures the charge gauge at a tenth of a percent, not a whole one.** The
2026-09-05 drive on SWI68 recorded 457 samples stepping between 53,9 % and 52,9 % in tenths and
refreshing every 25 s or so, which is ten times finer than the figure the arithmetic started
with. Every band it derives narrows by the same ten, and the odometer's whole kilometre — not
the gauge — is now what sets `MIN_DISTANCE_KM`. An arrival two points above the reserve no
longer reads as short on quantisation alone.
- **A charge en route is separated from regeneration.** The guard used to reuse the gauge's own
step, so any rise above the departure figure looked like a plug-in. It now has its own
`MAX_REGEN_RECOVERY_PERCENT`: a col's worth of regen is not a charging session, and the
shortest plausible plug-in gives back far more than a descent can.
### Added
- **`PlanDrift` — whether the plan the driver chose is still the plan they are driving.** Charge
and the odometer are already read once a second and the plan was written down when it was
picked, so noticing that a trip has stopped matching it is a division: no request, no quota, no
network. The alarm is deliberately hard to trip. It fires only when the rate actually being
spent has left the band the plan admitted to *and* the arrival no longer clears the reserve,
because a driver going faster than the row they chose and still arriving at 30 % does not need
telling, and a companion that speaks then is one nobody reads when it matters. Both instruments
quantise — a gauge that steps a whole percent, an odometer that answers whole kilometres — so
the measurement carries its own band, which is also why it refuses under 15 km of road.
- `ChargeStopPlan.effectiveRate` is public. It was the private fold of the climb into the rate;
`PlanDrift` has to compare a drive against the rate the plan was *actually* made with, and a
second copy of that arithmetic would have drifted away from the one that planned.
- **`NavigationHandoff` — the first write this library builds towards the car.** Everything else
here reads. This composes a `geo:` URI so a route the driver has chosen can be handed to the
car's own navigation instead of retyped, and it is separated from the sending because the
format is the part that can be wrong and the part a JVM test can settle without a head unit.
It refuses coordinates that are not a place on Earth — latitude and longitude are two doubles
of the same type and a swap routes to the wrong continent while looking like an ordinary
number — and it formats in `Locale.ROOT`, because a head unit set to French writes 43.5 as
`43,5` and a comma is a field separator in this URI. A label from a geocoder or a charger
dataset is reduced to letters, digits, spaces and hyphens: it is text this project did not
write, and a parenthesis in it would end the label early. `geo:` carries one point and no
waypoints, which is a limit the caller has to show the driver rather than paper over.
### Fixed
- **A momentarily unreadable battery power no longer blanks a settled range estimate.**
`AdaptiveRangeEstimator` gated on the live power reading, so one sample the firmware failed to
answer erased a figure computed from stored trips and the pack's charge — neither of which had
moved. The question the gate asks is "can this firmware measure power at all", and that answer
does not change between two polls, so it is latched: a firmware that has never published power
still refuses to borrow another firmware's history, and one that published it a second ago is
not treated as having lost the ability since.
- **An estimate no longer claims a band of exactly zero.** Eight of one driver's trips that
happened to agree produced "266,7 ± 0,0 km", which reads as a measurement — the one thing
provenance exists to stop an estimate doing. The spread of a handful of trips is not the error
of extrapolating to a whole pack: terrain, load, weather and speed all sit outside the fit. The
band now has a floor of ten percent of the estimate.
- **A corrupt trip history no longer multiplies itself.** `EnergyTripHistoryStore.quarantine`
copied the unreadable file aside and left the original in place, so the next read failed to
parse it again and wrote another copy — one file per read, unbounded, in storage the driver
cannot see. The original now goes, by rename or by copy-then-delete, and a copy that itself
fails is removed rather than left behind.
- **Ending a drive no longer re-encodes the whole history once per evicted track.** Bounding the
file measured every remaining trip again after each step, over as many as two hundred trips of
four thousand samples, on the path that closes a trip. The envelope's size is arithmetic —
empty envelope, plus each trip, plus one comma between each pair — so eviction now subtracts as
it goes and each trip is serialised once.
- **Decimating a track no longer throws away the newest sample.** `TripSampleTrack` halves itself
when the sample just added overflows it, and on an even-sized track that sample sits at an odd
index: taking every second one from the start discarded the most recent moment of the trip at
every doubling. It is now kept explicitly.
### Added
- **`SaicNav.probeTransaction` — what a navigation transaction replies, recorded rather than
interpreted.** CP-040 saw transactions 38 and 39 declared on `IMapService` and never called
them; CP-051 wants to know whether they carry the destination the driver has already entered
in the car's own navigation, because that destination costs no keyboard and no geocoding
request. The probe returns the reply's bytes and its length, and refuses to read them as a
typed value: a latitude read as a longitude routes to the wrong continent while looking like
an ordinary number. Read-only, capped at 128 bytes, and it answers "unbound" or "no answer"
as plainly as it answers with a payload. Called only by the unstable channel's validation
probe (CP-055).
- **A consumption model for a car that publishes no battery power — which is this car.**
`CarPropertyEvidence` has recorded since 2026-09-04 that SWI68 never publishes battery power:
three diagnostic bundles, 51 samples, zero readings. The consequence was that the kWh model
could never train on it, and three finished screens stayed blank. `SocConsumptionModel` fits
the same shape — rolling plus aero times speed squared plus a thermal term — in **percent of
charge per 100 km**, from the state of charge the car does publish and the distance the speed
integral already gives. No pack capacity enters it, which matters because the capacity here is
a specification sheet rather than a measurement: fitting in percent removes an unmeasured
multiplier from every number instead of carrying it. Training points are whole segments of
road, accumulated until the charge has moved by two points, because a state of charge read
sample by sample is quantisation and a fit built on it would be fitting the quantiser. A hole
in the track ends a segment rather than being driven through, a distance recorded under a
conversion no longer believed contributes nothing, and a history that has only seen one
temperature fits two coefficients instead of inventing a third.
- **The col between here and there costs charge, and the forecast knows it.** CP-031 refused a
grade source on good grounds — no vehicle signal, no proven barometric reference, and
power-derived grade is circular — but every candidate it weighed described the road *behind*
the car. A route's elevation profile describes the road ahead, which is the only kind a
forecast can use. `RouteGrade` turns cumulative climb and descent into points of charge by
physics rather than by a fit: potential energy for a stated mass, a stated drivetrain
efficiency going up, a deliberately pessimistic regeneration efficiency coming down, each one
a declared constant inside a 40 % band and none of them tuned against data that does not
exist yet. A descent gives charge back and always less than the same climb spent, so a round
trip over a col is never a free lunch. `ChargeStopPlan` takes the term as an optional
argument and folds it into the rate, which means the band widens with the profile and a
missing profile is `null` rather than a zero quietly claiming the road is flat — a plan
without one is byte-for-byte the plan it was before.
- **An arrival state of charge, built on what this car actually publishes.** The obvious route
runs through the energy model — kWh/100 km, a distance, a pack capacity — and it is a dead end
here, because the model trains on energy integrated from a battery power that never publishes.
State of charge per kilometre needs no energy unit at all, and both its inputs read.
`SocRateEstimator` takes the rate from the vehicle's own range on the first reading and from
recorded trips once there are enough, preferring what the driver has actually driven; trips
whose distance the current speed conversion did not produce are never averaged in.
`ArrivalSocForecast` never returns a bare number: the band widens with distance, a band wider
than 15 points is refused rather than shown, and a negative arrival is left negative because
"the destination is beyond this charge" is the most useful thing it can say.
- **A declared battery capacity bridges kWh and state of charge.** `INFO_EV_BATTERY_CAPACITY` is
declared and never published on the MG4, and deriving the pack size from an energy total needs
battery power, which is unvalidated — so nothing could turn a model's kWh/100 km into a state
of charge. `BatteryCapacityConfig` takes the figure from whoever knows it. It asks for the
**usable** capacity, not the nameplate one, because state of charge spans the usable window
and the buffers at each end sit outside it: entering 64 kWh where 61.7 is usable overstates a
full pack by 2.2 kWh, in the optimistic direction. Everything derived from it is `ESTIMATED`
with a 5 % band covering a dealer's state-of-health reading and a specification figure that
was never a measurement of this pack. Out-of-range input is refused, never clamped.
- **A recorded distance says which speed conversion produced it.** `VehicleSpeedEvidence` is the
counterpart of `BatteryPowerEvidence` on the distance side, and it exists because the
conversion has already been wrong once: every trip stored before the km/h fix carries a
distance 3.6 times too long, and nothing in the number said so — 8.46 km for a 2.4 km route
reads exactly like 8.46 km for an 8.46 km route. `EnergyTripSummary.modellableDistanceKm`
answers only for a distance produced by the conversion believed correct today, so a model
refuses history it cannot vouch for instead of quietly training on it. The reported distance
is unchanged: a driver's own record does not silently rewrite itself.
- **The odometer reads again.** `PERF_ODOMETER` is declared and never published on the MG4, so
total mileage showed as unavailable while the head unit knew it. The navigation adapter
answers it, and `getOdometerKm` now falls back to it — the same reading that gave the 3.6x
speed error its independent reference.
- **Guidance can be read, not only waited for.** The five listener callbacks are change
notifications, so a car standing still with a guidance already running publishes nothing —
which made a parked capture unable to tell "the adapter says nothing" from "nothing happened
to say". `IGeneralService` answers `getGuideStatus`, `getRemainingTimes`, `getRemainingDistance`
and `getRoadName` synchronously on the binder already bound, so `SaicNavGuidance.readNow`
reports the current trip on demand. `SaicNav`'s note that the head unit answers no synchronous
question about a trip was read off `IMapService`; the general service does answer. A distance
or time of zero is treated as unreadable, as the service returns zero before any guidance has
run.
### Fixed
- **`PERF_VEHICLE_SPEED` already reports km/h on SWI68, and was being converted a second time.**
AAOS specifies the property in metres per second, so every read was multiplied by 3.6 — and
every distance integrated from it was scaled by the same factor. A 2.4 km drive was recorded
as 8.46 km and, on a second run, 7.69 km: 124 and 129 km/h average over four minutes of town
driving. Divided by 3.6 the same trips read 2.35 km at 34 km/h, which is the drive that
happened. `VehicleSpeedScale` makes the conversion evidence-gated per generation like every
other firmware-dependent read here. SWI68 is listed on the 2026-09-04 bundle from
SWI68-29958-1300R67; every other generation keeps the specified conversion, unproven but wrong
in the conservative direction, since a speed read too high makes `VehicleWriteGate` refuse
more often rather than less.
### Fixed
- **Repeated vehicle states no longer flood the diagnostic log.** The ignition condition
arrives about ten times a second and the gear is read once a second; both were logged every
time, so a 400-line retained log held under a minute of history and anything older had already
been evicted from the bundle a driver sends back. `StateChangeLog` lets a state through only
when it changed, keeping the transitions a diagnostic actually reads and dropping the repeats
between them. Only logging is gated — every callback is still dispatched.
## [1.14.0] - 2026-09-04
### Changed
- **`WEATHER_NOW` is an `ENUM`, not free text.** The condition compared the weather service's
own phrase, in the head unit's language, against a string the driver had to type — a value
nobody can guess and that changes with the display language. It now carries one of the seven
states in the new `WeatherConditions`, which classifies the phrase by keyword fragments in
the six languages the apps ship. `SnapshotKeys.KEY_WEATHER_TEXT` became
`KEY_WEATHER_CONDITION` and carries that state; a phrase nothing recognises writes no key at
all, so the condition is unavailable rather than reported as fine weather.
- **`ConditionGroup.AUDIO`**, the counterpart of `ActionGroup.AUDIO`. `MEDIA_PLAYING`,
`RADIO_PLAYING` and `MEDIA_VOLUME` moved into it from `CONTEXT` and `COMFORT`.
## [1.13.0] - 2026-09-03
### Changed
- **Radio band, frequency and playback became one catalogue entry.** `SELECT_RADIO_BAND` is
gone; `TUNE_RADIO` now carries the band in `number`, the frequency in `text` and playback in
`flag`, behind a new `ValueKind.RADIO`. The split was the wrong seam: a band could not carry a
station, a station could not reach DAB, and "put the radio on FM 103.5 and play it" — one
instruction — took two entries that each had half of it. `number = 0` is what every rule saved
before the merge carries and still means "read the band off the frequency", so nothing that
worked stopped working. `RadioFrequency.parse` takes the picked band and refuses text that
disagrees with it rather than resolving the disagreement, for the same reason it already
refused `AM 103.5`. `SaicRadio.selectBand` gained `andPlay`, so a band switch can leave the
car silent the way a tune already could.
### Added
- **A generated catalogue document.** `docs/catalogue.md` lists every `ConditionType` and every
`ActionType` with its English label, what it asks for, whether it is refused while moving and
the generations it is declared on. Rendered from the enums by `CatalogueDocTest`, which
rewrites it on every test run exactly as `FirmwareSupportTest` does for the matrix — the
hand-written list it replaces had gone a year without mentioning twenty entries and still
promised an "AM or FM only" tune action.
- **Read-only route guidance listener for the head unit's own navigation.** The map adapter
answers no synchronous question about a trip, but its general service fans remaining distance,
remaining time, guidance status and road name out to registered listeners. `SaicNavGuidance`
registers for that fan-out and folds it into a nullable `NavGuidance`. The callbacks are not
`oneway` and the adapter holds its callback-list lock while broadcasting, so the listener does
one parcel read and one volatile write and nothing else — a slow listener would stall the
instrument cluster. Registration is additive and displaces no other consumer. Distances carry
no proven unit and status codes no proven meaning, so nothing is converted and nothing is
displayable: this is CP-040 evidence, not a value. Costs no Android permission and no network.
- **Post-trip energy attribution preserves uncertainty and gaps.** Measured trip consumption is
reconciled against modelled traction, regeneration, climate-conditioned residual groups and an
explicit unmodelled discrepancy. Every attributed value requires a band; values inside model
noise are marked indistinguishable from zero, and negative residuals remain visible as model
error. No per-consumer vehicle counter is claimed.
- **Driver-specific energy modelling is bounded and evidence-gated.** A compact least-squares
fit learns rolling, aerodynamic speed-squared and outside-temperature terms from retained trip
samples only when their exact firmware/conversion evidence matches. It uses constant working
memory, caps training at 50,000 samples, rejects sparse/noisy fits and all extrapolation, and
returns only provenance-labelled estimates with residual-derived uncertainty. One atomic
16 KiB model file stores coefficients and envelope; raw tracks are never duplicated.
## [1.12.0] - 2026-08-31
### Added
- **Vehicle-validated telemetry is catalogued per signal and firmware generation.** Battery
power starts unvalidated everywhere; CP-003 must add each generation only after its unit,
scale, sign and cadence have on-vehicle evidence. Consumers can therefore distinguish an
unproven signal from a property that is genuinely absent. Trip energy integrated from a
validated signal carries the exact firmware and conversion version; legacy or mismatched
totals can therefore be excluded from later models without growing the stored sample track.
## [1.11.0] - 2026-08-29
### Added
- **A rule can put the tuner on a band, DAB included.** `SELECT_RADIO_BAND` is the answer to
the one gap the radio work left: "tune the radio" takes a frequency a driver typed, and a DAB
service is addressed by an ensemble and service id, so there was nothing to type and no way
to ask for DAB. The action guesses no transaction code — the vendor service exposes no
band-only call that anyone here has observed, and a wrong code on a vehicle binder does
something unknown to a car. It composes calls that are already proven instead: read the
current band from `RadioBean`, then `tune` into the requested one, since `tune` carries the
band as its first argument. It lands on the station this car was last heard on for that band,
and when it has never been there it steps to the first station the tuner lists rather than
leaving the driver on an empty frequency. An unreadable current band sends nothing at all.
### Changed
- **The window actions ask to open or close, instead of carrying a raw command.** They used to
take a number in 0..7 — the vendor service's own command range, which nothing in the app or
the car explains to the person writing the rule. An on-vehicle capture showed what that
costs: a rule asking for `7` ran five times, was reported as applied five times, and left the
driver's window fully open throughout, because the service accepts a command it does not
implement and drops it. Closed and open are the only two states the glass can be asked for,
so those are the two the editor now offers.
Which raw command reaches each is a property of the *car*, established by the window probe and
stored per car, so it stays out of the catalogue and out of saved rules — a rule exported from
one car must not carry that car's command numbers.
- **"Windows" is now "All windows".** It commands four windows at once, and the bare plural read
as the family of window actions rather than the one that moves them together.
## [1.10.0] - 2026-08-29
### Fixed
- **A property the firmware declares and never publishes no longer reads as a measurement.**
`CarPropertyManager.getFloatProperty` answers such a property with a plain zero, and nothing
in the number says the vehicle never put a value behind it — an MG4 on SWI68 reporting 31 °C
outside showed a 0 °C traction battery and a 0 kW pack current on the dashboard next to it.
Reads now go through `getProperty`, whose `CarPropertyValue` carries the VHAL's status for
the property, and only `STATUS_AVAILABLE` is treated as a reading; anything else stays null
and reaches the screen as "unknown", which is what it is. A measured zero is still a zero.
Runtimes that do not expose `getProperty` fall back to the typed getters unchanged, so no
generation loses a property it was already answering.
### Added
- **Automatic trip boundaries are now one fail-closed telemetry state machine.**
`TripDetector` requires five seconds above 5 km/h to start and two minutes below 1 km/h
to end, using park or charge-port confirmation when those signals exist. Missing speed
cancels partial evidence and can never start or stop a recording; all timers and threshold
bounce behaviour are covered without Android or a vehicle.
- **Adaptive range is now an explicit estimate with evidence-sized uncertainty.**
`AdaptiveRangeEstimator` fits up to eight recent trip-consumption averages, requires three
usable observations, and converts their sample spread into a kilometre uncertainty band.
It prefers measured usable energy, falls back to SOC times capacity, and refuses to estimate
when the current firmware does not publish battery power; the vehicle's own range is untouched.
- **Consumption is now one shared, nullable derivation instead of UI arithmetic.**
`ConsumptionCalculator` exposes raw and five-second exponentially smoothed instantaneous
kWh/100 km plus a two-minute energy/distance average. It masks missing inputs and speeds
below 5 km/h, preserves negative regeneration, rejects unobserved gaps over five seconds,
and bounds its working set independently of trip length. Every output is provenance-labelled;
the existing trip accumulator continues to integrate raw power and speed unchanged.
- **Trip history can be pruned without bypassing its atomic persistence boundary.**
`EnergyTripHistoryStore` can remove one start-time-identified trip or replace the history with
a valid empty v2 envelope; both operations preserve the same unique-temp-file rewrite used by
appends, so a UI never deletes the only valid file before its replacement exists. The default
summary bound is 200 trips; the existing byte ceiling still drops old sample tracks first.
- **A public post-AAOS 9 capacity signal can be tested without becoming product data.**
`EV_CURRENT_BATTERY_CAPACITY` describes real-time usable capacity rather than the pack's nominal
new capacity, but a public id does not prove that an MG4 firmware publishes it. The new
status-aware, firmware-branched reader is therefore internal to explicit evidence capture,
stays null when absent or invalid, and is carried beside — never inside — `EnergySnapshot`.
[The candidate review](docs/energy-signal-candidates.md) records exact AOSP provenance and the
negative search results for HVAC, battery-heater, accessory and cumulative energy counters.
- **Three radio behaviours the library could already perform are now things a rule can ask for.**
`srcPauseRadio`, the tuner's own play state and `startRadioActivity` were implemented and
unreachable: the catalogue offered play, tune and station stepping, so "silence the radio"
had to be approximated with `MEDIA_CONTROL`, which addresses whichever source owns the audio
and therefore stops Bluetooth when Bluetooth is the one playing. `PAUSE_RADIO`,
`RADIO_PLAY_PAUSE` and `OPEN_RADIO_SCREEN` name the tuner instead. The toggle reads
`RadioBean.state` rather than `AudioManager.isMusicActive`, which is **false while the radio
plays** — a toggle driven by it would answer a playing radio with another "play", forever —
and when even that cannot be read it sends nothing and says so: a wheel button can be pressed
twice, an unattended rule cannot, and a guessed direction leaves the car silent or playing
with nothing in the history admitting the direction was invented. Opening the radio screen is
the one of the three that is not audio-only, so it takes the standstill gate, unreadable
speed included; skipping a station does not, and did not gain it. Band selection and direct
DAB service selection were considered and rejected — see
[docs/radio-action-gap.md](docs/radio-action-gap.md), which records every candidate including
the refused ones. DAB remains reachable through station stepping, which is what the head
unit's own launcher calls.
- **A trip now keeps its shape, not only its totals.** A summary cannot be un-summarised, and
every model this project will grow — what a slower motorway speed would have saved, what the
climate system took, the state of charge on arrival — is fitted from the shape of past drives.
`TripSampleTrack` records one sample per five seconds of speed, power, state of charge,
temperatures and climate state, all nullable, so a car that publishes no pack temperature
stores no pack temperature rather than a column of zeros a fit would believe. A drive that
outlasts the track's length is decimated rather than truncated: every second sample goes and
the interval doubles, so the motorway stretch at the end survives at a coarser resolution
instead of being cut off.
### Changed
- **The trip history file states its own version.** The first shape was a bare JSON array with
no room to say what it was; it is read as before and rewritten into the v2 envelope on the
next append, losing nothing. A file this build cannot read — truncated, or written by a
build that knows something this one does not — is quarantined under a new name rather than
deleted, because it is the only copy of whatever it holds. Three bounds now apply in the
order that keeps the most meaning per byte: at most fifty trips, then sample tracks dropped
oldest-first to stay under the size ceiling, and only then whole trips — a trip without its
track is still a trip, a missing trip is not.
- `EnergyTripSession.stop` returns a `RecordedTrip` (summary plus track) rather than a bare
summary, and `EnergyTripHistoryStore.read()` returns stored trips; `readSummaries()` is the
old view.
- **`TelemetryEvidenceRecorder` — what a signal actually did on the car, instead of what the
code assumes it does.** Every energy figure past a state of charge divides by a number whose
unit, sign and update rate are currently a hypothesis: the battery power reader divides the raw
property by -1,000,000 because that is what the value looked like, not because a vehicle
confirmed it. The recorder consumes the same snapshots the dashboard renders and keeps, per
signal, how often it answered and how often it did not, its range and mean, its positive and
negative split, and the interval between the values it actually published — measured from value
*changes*, so a 1 Hz sampler over a signal republished every five seconds reports five seconds
rather than one. A signal the car never answered keeps no statistics at all: no minimum of
zero, no mean of zero, nothing. Captures serialise to a versioned schema for the next build and
render as a Markdown table for the person writing the conclusion.
- **`Provenanced` — a value that carries what kind of claim it is.** The MG4 publishes no
per-consumer energy counters, so anything past traction totals — the climate system's share,
what a slower motorway speed would have saved, the state of charge on arrival — can only be
inferred, and an inference rendered in the same typeface as a vehicle reading is a
measurement as far as the driver is concerned. `Provenance` names the four kinds a figure can
be (measured, derived, estimated, unavailable) and `Provenanced<T>` enforces them at
construction: an estimate cannot exist without its uncertainty band, an unavailable value
cannot carry a number, and a measurement cannot carry a band the vehicle never published.
`Provenanced.derive` propagates gaps rather than filling them, so consumption over an
unreadable speed is unknown — with `UnavailableReason` saying whether the firmware lacks the
signal, has not been validated for it, or simply is not publishing it right now.
- **`EVHardware.probeTelemetryProperties()` — what each energy property actually answers on the
car in front of you.** An unsupported property, one declared and never published, and one
this runtime cannot reach all look identical from the driver's seat, where every unusable
signal is one em dash. The probe reports status, raw value and publication timestamp per
property, unfiltered and outside the supported-generation gate, so a generation's telemetry
surface can be recorded from the vehicle instead of inferred from a blank field.
### Changed
- **A read that fails the same way every second is worth one log line, not one per sample.**
One unreadable speed property wrote two identical lines a second, filled the 400-entry buffer
and pushed everything else out of it, so the diagnostics screen was least useful exactly when
something was wrong. A repeated failure is logged again only when its reason changes.
Reflected failures also name their cause: `InvocationTargetException` carries no message of
its own, which is why these lines used to read `exc: null`.
## [1.9.0] - 2026-08-28
### Fixed
- **The glass probe refused to run on a car whose ignition was on.** It read
`getCurrentIgnitionState()`, which answers on the AAOS `IgnitionState` scale, against
`CarIgnitionItem.RUN` — and the two scales do not line up: RUN is 2 for Katman5, where 2 is
OFF. Contact mis read as 4 and was refused; contact coupé read as 2 and was let through.
`setEsc` compared the same two scales and now refuses only a *known* non-RUN ignition, as its
own documentation always said it did.
- **Window actions moved nothing.** They were written through the vendor hub's
`setDriveWindow`, a setter no application on the head unit calls, whose accepted values
nothing explains and whose dropped writes are indistinguishable from obeyed ones.
### Added
- **`VsmGlass` — the windows through the interface the car's own window switch uses.**
`setVehicleWindowStatus(area, command)` and `getVehicleWindowValue(area)`, on the VSM already
bound for ADAS. The commands are established rather than guessed: `STOP`, `UP` and `AUTO_UP`.
`UP` is a switch held down and not an order — the glass travels only while the command keeps
arriving, which is why a single write looked exactly like a command that does nothing, and
why `hold()` re-sends it and then releases it. Reads answer 0–255, not the hub's 0–100, and a
motor without a position sensor answers off that scale and is reported as no reading rather
than as a closed window. VSM-based firmwares only; the hub stays the fallback.
### Changed
- **`SaicVehicleControl` prefers the VSM for every glass read and write**, falling back to the
hub only where the VSM does not carry the methods. `GlassProbe` holds each command down for
`HOLD_MS` instead of writing it once, which is the only way a momentary command shows up in a
position read at all.
## [1.8.0] - 2026-08-27
### Added
- **`SaicMediaPlayer` — next track, previous track and play/pause sent to the source that is
actually playing.** The car publishes a single Android media session
(`com.android.bluetooth`), so a media key either reached nothing or reached Bluetooth, woke a
phone that was not playing and changed the audio source while the radio was on. The new
object asks the vendor service which source owns the audio and commands that source's own
player — radio, Bluetooth, USB, online, CarPlay/Android Auto — with **no cascade between
sources**: a player that is not playing is never addressed, because it would answer "yes" and
start. The A9 generations have no such service and drive the framework's media sessions
instead, then allgo's `IRemoteUIService` for projection, then a media key only while
something is audible.
- **`SaicRadio.nextStation()` / `previousStation()` / `isPlaying()`.** Stepping through the
tuner's stations is the one radio call that reaches **DAB**: a DAB service is addressed by
ensemble and service id, so `tune` has no frequency to take. `isPlaying` unwinds the vendor
`RadioBean` because `AudioManager.isMusicActive` is false while the radio plays — its stream
is not the music one — which made play/pause answer a playing radio with another "play".
- **`ActionType.RADIO_NEXT_STATION` / `RADIO_PREV_STATION`** (AUDIO, SWI68/SWI165).
- **`GlassProbe` — settles on a car what the catalogue could not settle in the project.**
Which of the eight window commands opens a window is written down nowhere and the vendor
service reports success whatever it is given, so the glass actions ship refused
(`writeProven = false`). The probe sends each command with the position read either side of
it, in **two passes** — from a closed window a closing command is indistinguishable from an
inert one, so the second pass retries exactly those once the glass is open — and stops as
soon as both directions are known. Standstill is re-checked before every command, not once
at the start, and the glass is put back where it was found or the caller is told it was not.
- **`GlassEvidence`** records what the probe watched happen and unlocks the five window
actions **on that car only**. Evidence is scoped to the firmware generation it was observed
on: a command proven on one generation proves nothing about another.
- **ESC and the drowsiness warning (UDW), on every generation.** Three different routes —
VPM properties on SWI133, named methods on SWI68/SWI165, and the carapi client on A9, where
ESC is spelled "Eps". ESC on SWI68/SWI165 needed a manager the library did not bind at all:
`get/setEspSwitch` live on **VehicleControlManager**, not the settings one, so looking for
them on `sVsm` failed without a sound and left ESC inert.
Two guards come with the write, both earned on a car: it is a **toggle** driven by a read, so
it runs only on an ignition known to be in RUN — getting in while the cluster is dark, the
property lies, and aiming at ON from a false OFF disables an ESC that was on, in silence —
and only on three agreeing readings, with the result checked afterwards rather than assumed.
- **`ActionType.SET_ESC`, `SET_DROWSINESS`, `SET_DROWSINESS_SENSITIVITY`** (ADAS, gated).
The drowsiness switch is UDW and not the camera-based DMS one: both exist, their labels read
alike, and writing the camera one changed nothing visible.
- **`A9Climate` — climate on SWI69, SWI131 and SWI132.** The vendor hub `SaicClimate` talks
to does not exist on those generations, so eight climate actions were marked SWI68/SWI165
only on cars whose own HVAC screen works fine. They now go through `carapi`'s
`CarHvacClient` (`queryClient(0x7)`), the same door `queryClient(0x8)` already opens for the
vehicle-settings client. Several A9 settings are argument-less `switch…()` calls that step to
the next state, so aiming at one is read-step-read, bounded — which makes those writes
blocking and unfit for the main thread. ECON and the passenger's own target stay off A9: the
client exposes neither, and refusing is more honest than writing to the driver's side and
calling it the passenger's.
- **`ActionType.ADJUST_MEDIA_VOLUME` and `EVHardware.adjustMediaVolume(delta)`** — a step
rather than a target, for a wheel button or a rule that must not throw away the level the
driver chose. Falls back to `AudioManager` when the vendor level is unreadable, and reports
either end of the range as done rather than refused.
- **`PhysicalButtonEventDecoder` reads a double press.** Two releases of the same button
within `DOUBLE_TAP_MS` make one `Press.DOUBLE`; a long press breaks the pairing so the
release that follows it cannot become half of a double nobody made, and a third press starts
over rather than producing a second overlapping double.
- **`DataUsage`** — what the head unit's connection has carried, today and this month, from
Android's own counters. The modem presents as **Ethernet**, and the public
`querySummaryForDevice(int, …)` only builds MOBILE and WIFI templates: it answers zero here,
which reads exactly like a car that used no data. The hidden `NetworkTemplate` overload
answers, with `TrafficStats` since boot as the fallback, and an unreadable counter is null
rather than zero.
- **`ConditionType.DATA_USED_TODAY` / `DATA_USED_MONTH`** (context, in MB).
- **`ActionType.effectProven`** — `writeProven` answers "has anyone ever seen this work",
`effectProven` answers "does it work here". Everything user-facing asks the second.
### Changed
- **An action whose write was never shown to do anything is no longer offered anywhere.**
`ActionType.writeProven` separates two questions a single ✅ used to answer at once: whether
the firmware carries the property (`@SupportedOn`) and whether writing it changes anything.
The five glass actions are the case that forced the distinction — the service accepts a
command in `0..7`, no head-unit application sends one, and which command raises a window is
written down nowhere, so the write is accepted, dropped, and reported as applied. They ship
`writeProven = false` until one command is observed to move a window; the generated firmware
matrix marks them ⚠️ and says what the mark means.
## [1.7.0] - 2026-08-27
### Added
- A shared, read-only energy telemetry API: one nullable `EnergySnapshot` combines SOC,
range, speed, battery power and temperatures, pack energy/capacity, odometer, charge state
tyre pressures and climate state with explicit firmware dispatch and unit conversion.
- Reusable trip and charging-session integration plus bounded atomic trip-history storage.
Both skip unreadable samples and long gaps rather than inventing energy.
### Changed
- Reflected `CarPropertyManager` getters are cached when the service connects instead of
resolving a `Method` for every sample. The cache is keyed on "resolved", not on "found",
so a getter this `CarPropertyManager` does not expose is no longer looked up again on
every sample.
- A trip's reported duration is the time actually covered by usable samples, not wall clock.
A sampler suspended for ten minutes no longer adds ten minutes to a trip whose distance
and energy did not move — the consumption average now divides comparable numbers.
### Fixed
- The Car-service binding now retries without a bound. The previous fixed retry ladder gave
up after one minute, so a process started before the SAIC Car service was ready never saw
a vehicle again for its whole lifetime, and a service that died and came back was only
recovered if the `ServiceConnection` callback fired. Consumers carried their own reconnect
loops to work around this; the watchdog now lives next to the binding it repairs.
## [1.6.0] - 2026-08-26
### Fixed
- **Glass writes never moved a window.** `IVehicleControlService` validates a window write
against `max_vehicle_window_set = 7` and drops anything above it without an error — the
setter returns void, so a dropped write and an applied one are indistinguishable from the
caller. The library was sending a position in percent, so every write above 7 was
discarded while the history recorded it as applied. Reading is unchanged and stays a
percentage (`max_vehicle_window_get = 100`): the two directions are not the same scale.
`setWindow` / `setAllWindows` now take a **command in 0..7** and refuse anything else
rather than hand it over to be dropped. Which command opens and which closes is not
written down on the firmware and no head-unit application sends one, so the mapping is
still open — a command sent and a position read back is what identifies it.
- **Current weather always came back unreadable.** The `ICallBack.onResult` parcel carries
the nullable `Result` argument behind a presence flag, and the decoder started at the
object's first field instead. Every field then landed in the previous one's place, the
payload tag no longer matched, and an answer that had arrived was reported as "cannot
tell". The flag is read now, and a null `Result` is a null reading.
### Changed
- Glass actions are standstill-gated outright, and `ActionType.gatedWhenOpening` is gone
with the directional gate it fed. That gate compared the target against the position, and
the target is no longer a position: with the direction of a command unknown, the safe
reading of an unknown direction is that it is not one to allow at speed.
- `SaicVehicleControl.doorLockState()` exposes the raw lock status for the diagnostic. The
service answers `0..7` and only 1 and 2 are named, so `doorsLocked()` still discards the
rest — but "unreadable" without the number left no way to tell an unnamed state from a
read that never answered.
### Removed
- The daily forecast — `WEATHER_TOMORROW`, `TEMP_MAX_TODAY`, `TEMP_MIN_TOMORROW` and
`SaicWeather.forecastAt`. `ISaicService` declares seven transactions on this firmware and
none of them answers a forecast; the `WeatherFuture` parcelable its `Result` can carry is
never filled by anything in the map service. The transaction 1.5.0 called does not exist,
which is what the `transact returned false` in the log was. The head unit's own weather
application fetches the outlook itself with credentials that are not reachable from here
and broadcasts only current conditions, so there is no second source either. Three
conditions that could never be readable are worse than none.
## [1.5.0] - 2026-08-25
### Added
- Per-window control. `SaicVehicleControl.Window` names the four windows and
`windowPercent` / `setWindow` address one at a time, alongside the group read and
`setAllWindows` — which stay, because closing the glass is one gesture that must not be
able to leave three shut and one open. Catalogue: `ConditionType.WINDOW_DRIVER`,
`WINDOW_PASSENGER`, `WINDOW_REAR_LEFT`, `WINDOW_REAR_RIGHT` and the four matching actions.
- `SaicCharging.rangeKm` and `ConditionType.RANGE_ESTIMATE` — the cluster's own remaining
range. A rule that diverts to a charger is about distance, which a battery percentage does
not answer on a cold motorway.
- `SaicNav`, the head unit's navigation adapter, and `ConditionType.ODOMETER`. Trip distance
is deliberately absent: remaining distance and time only arrive as callbacks into that
service from the running navigation app, so reading them is a subscription, not a getter.
- `SaicWeather` with `ConditionType.WEATHER_NOW`, `WEATHER_TOMORROW`, `TEMP_MAX_TODAY` and
`TEMP_MIN_TOMORROW` — the weather service the head unit's own map stack queries, asked for
the car's position. The outlook is **daily**: the service answers one entry per day, so
"rain tomorrow" is a question it takes and "rain in three hours" is not, and the catalogue
offers only what the data supports. The only asynchronous service here: the answer
arrives on a callback binder and is awaited with a bounded wait, so a slow query comes back
unreadable instead of holding a rule cycle open.
- `SaicAidl` now marshals `Double` and `IBinder` arguments, for that callback.
Neither `ODOMETER` nor `WEATHER_NOW` carries `@SupportedOn`: they come from the head unit's
own applications rather than from the vehicle SDK, so nothing routes them per generation —
they answer or they do not, the same as the message action.
## [1.4.0] - 2026-08-25
### Added
- Context entries that read Android rather than the car, so they carry no firmware
annotation: `ConditionType.MEDIA_PLAYING`, `WIFI_SSID`, `IN_CALL`, `DRIVE_DURATION`,
`RANDOM_CHANCE`. `IN_CALL` is "a call is routed through the car", not "the phone is
ringing" — the difference is stated rather than papered over.
- `ActionType.ENABLE_RULE` / `DISABLE_RULE` with `ValueKind.RULE`: a rule can now switch
another on or off, which is what turns a catalogue of one-shot rules into chains.
- `ActionType.MEDIA_CONTROL` (`MediaCommand`, the platform's own media key codes) and
`SET_BLUETOOTH` / `SET_WIFI`.
- Climate readings that had a vendor getter and no catalogue entry: `ConditionType.ECON_MODE`,
`PASSENGER_TEMP`, `FRONT_DEFROST`, `REAR_DEFROST`. The last two also become the `currentKey`
of the defroster actions, which until now opened on a state nothing could read back.
- `ActionType.SET_ECON` and `ActionType.SET_PASSENGER_TEMP`. The passenger target is a second
action rather than a zone argument on `SET_CABIN_TEMP`: a zone would have made every rule
saved before it carry a value it never chose.
- Charging schedule readings: `ConditionType.CHARGE_SCHEDULE_ENABLED`, `CHARGE_WINDOW_START`,
`CHARGE_WINDOW_STOP`, `BATTERY_PREHEAT` — the read side of actions that could already write
them.
- `ConditionType.CHARGING_STATUS`, the charging state itself. `CHARGING` stays a boolean and
keeps its meaning; the state is what can say "plugged in but not charging", which a boolean
cannot.
- `ConditionType.FRONT_DOOR_OPEN` (SWI133), reading the door status the volume-drop watcher
already polls, through the new `EVHardware.frontDoorOpenOrNull()`. Front doors only — the
rear ones are not claimed rather than guessed at.
- `ValueKind.TIME`: one time of day, minutes since midnight. The charging window's ends are
clock times, and a 0–1439 slider is unanswerable at the wheel.
### Fixed
- `VehicleEnums.CHARGING_ACTIVE_STATES` names the states in which current actually flows into
the battery. Consumers filling `KEY_CHARGING` derived it from `chargingStatus != 0`, which
made a finished charge, a stopped one and a charging fault all report as charging.
## [1.3.0] - 2026-08-23
### Added
- `ActionType.SEND_SMS`: send a text message through the paired phone, with `ValueKind.SMS`
(recipient in `text`, contact label in `displayName`, message in `payload`). No
`@SupportedOn`: the message leaves over the Bluetooth Message Access Profile, not over a
SAIC service, so nothing is routed per firmware generation — availability is a bind, read at
runtime. The vendor Bluetooth call interface exposes no message transaction, so the
message cannot travel that way; the head unit's Bluetooth stack does expose a MAP client
profile at runtime, and that is the route taken.
- `ValueKind.CONFIRM`: a yes/no question plus the seconds it waits for an answer. The editor
draws one control per kind, so the wait had to become part of the kind rather than a
special case for one action.
### Changed
- `ActionType.ASK_CONFIRM` now carries `ValueKind.CONFIRM` (5–60 s) instead of plain text.
`Action.number` is the wait; `0` — every rule saved before the field existed — means
`ActionType.ASK_CONFIRM_DEFAULT_SECONDS` (10), not the bottom of the range.
## [1.2.0] - 2026-08-22
### Added
- `SaicRadio.pause`, the vendor service's `srcPauseRadio` — the counterpart of `play`. It
mutes the tuner and marks the radio stopped; it does not hand the audio focus back, which
the vendor service only does when it loses it.
### Changed
- `SaicRadio.tune` takes an `andPlay` flag and is responsible for the outcome. 1.1.0 removed
the `srcPlayRadio` call and assumed that was enough to tune in silence; it is not. The
vendor's `tune` calls `AudioController.requestMuted(false)` itself, which requests the
audio focus and unmutes the tuner, so changing station always starts the radio. Not
playing is therefore something that has to be undone after the fact: `andPlay = false`
pauses the radio as soon as it is tuned. Callers must pass the flag — there is no default.
### Known limitation
- Tuning with `andPlay = false` leaves the radio holding the audio focus, silent. Whatever
was playing before does not resume: the vendor service exposes no way to give the focus
back for AM/FM, only for DAB scanning.
## [1.1.0] - 2026-08-22
### Changed
- `SaicRadio.tune` no longer starts playback. It used to call `srcPlayRadio` after setting
the frequency, which made the radio the current audio source every time a station was
tuned, so a caller that wanted the station changed silently had no way to ask for it.
Tuning and playing are now two calls: `tune` sets the frequency, `play` switches the
source. Callers that relied on the combined behaviour must call `play` themselves.
## [1.0.1] - 2026-08-21
### Fixed
- `SaicClimate.outsideTempCelsius` no longer returns the vendor's error value as a
temperature. `AirConditionBinder.getOutCarTemp` answers `TEMPERATURE_ERROR_CONST`
(-10000f) for a signal it holds no value for, which is not a reading; it now maps to
`null`, same rule as every other getter here. A caller that stored it would have had
every "below N degrees" comparison come out true.
## [1.0.0] - 2026-08-15
### ⚠️ Breaking
- Renamed from MG4Hardware to **EVHardware**, and the package from `com.mg4.hardware` to
**`com.evsuite.hardware`**. Every import has to be updated; there is no compatibility
alias.
- `VehicleWriteGate.decide` no longer takes `allowUpToKmh` or `parked`. The gate is fixed at
a readable 0 km/h, with no caller threshold and no park rescue, so callers that passed
either argument must drop it. Consumers using `decideNow()` or `decide(speed)` are
unaffected.
### Added
- `FirmwareInfo.generationOf` as a pure parser, and `isDetectedGenerationSupported`, so an
unforced unknown firmware fails closed.
- Catalogue entry `ASK_CONFIRM`, with its localised strings and firmware-matrix row.
### Changed
- Consolidated the four copies of this library that had drifted apart while it was vendored
into EVProfile, EVTasker and EVABRPUploader.
## [0.3.0] - 2026-08-10
### Added
- **Shared diagnostic engine** in `com.evsuite.hardware.diag`, so the apps stop each carrying
their own copy:
- `CrashLogger` writes an uncaught exception, its cause chain and the `AppLogger` buffer
to `filesDir/last_crash.txt`, chaining the previous handler and truncating from the tail
so the exception survives. It replaces the two divergent copies in EVProfile and
EVTasker, each of which carried a fix the other was missing.
- `PrivateBin` uploads a report to a PrivateBin instance, zero-knowledge — the key never
leaves the URL fragment. Moved verbatim from EVTasker.
No UI: rendering a report and deciding when to paste it stay in the apps.
### Changed
- **One "Call" action.** `CALL_NUMBER` now carries a `CONTACT` value, so the same entry takes
either a typed number or a name from the phone book. `CALL_CONTACT` is deprecated and no
longer offered by `ActionType.byGroup()`: it stays in the enum only so rules saved by a
release that exposed it still load and still call, since enum entries are persisted by name
and a removed one deserialises to null.
## [0.2.0] - 2026-08-02
### Added
- Vehicle access to climate, charging, radio and calls through the vendor's own
service, alongside the existing reflection layer.
- `SPEAK_TEXT` local action: text-to-speech through the vehicle's own voice service.
- Window and door-lock control through the vendor control service.
- Park-based fallback for the write gate, and radio tuning.
### Fixed
- Unreadable vehicle signals were being reported as "off"; they're now reported
as unknown.
### Changed
- Adopted the EVSuite documentation structure: shared README skeleton, generated table
of contents, and [DESIGN.md](DESIGN.md) carried in every suite repository.
## [0.1.0] - 2026-07-23
Initial tagged release.
+75
View File
@@ -0,0 +1,75 @@
# Contributing
This library reads and writes a car. That single fact drives everything below.
## Ground rules
1. **The 0 km/h gate is load-bearing.** Any road-behaviour write must pass through
`VehicleWriteGate` and carry `@RequiresStandstill`. It fails closed on unreadable speed —
keep it that way.
2. **Branch every vehicle call per firmware generation** via `FirmwareInfo`. Never assume a
method or property id is universal across SWI68/69/131/132/133/165.
3. **Unreadable is not a value.** Return null / -1 when the layer is not ready or the
property is absent; never fabricate 0/false.
4. **Property ids come from evidence.** Confirm ids/transaction codes against the R69 OEM
sources (or on-vehicle logs) and document the source inline. An unverified id is
read-only until confirmed — no write path.
## Before a PR
```bash
mise run check # JVM unit tests + firmware-matrix freshness
```
New vehicle catalogue entries need a `@SupportedOn(...)`; a test fails without it, and
`docs/firmware-matrix.md` regenerates on the test run — commit it, do not hand-edit it.
## Language
English only — code, comments, commit messages, docs. User strings in `values/` (English)
with a `values-fr/` translation.
## Consumers
Changing a public signature ripples into EVProfile, EVTasker and EVABRPUploader (submodule
consumers). Note the affected apps in the PR; a minified consumer must be verified on a
vehicle because vehicle access is reflection-based.
## Ways to help
Not every useful contribution is code:
- **Bug reports** — firmware generation, app version, what you did, what happened.
- **Feature requests** — describe the problem before the solution.
- **Documentation** — README, this guide, the firmware matrix, translations.
- **Pull requests** — see below.
- **Testing pre-releases** — install an `unstable` build and report what broke.
- **Sponsorship** — see below.
## Contributing efficiently
Maintainer time and AI quota are the scarce resources here, ideas are not. If you have
access to Claude Opus or another capable coding model, a finished pull request is worth
much more than a feature request: someone still has to design, write, test and verify the
request, and that someone has a limited quota too.
A pull request that lands quickly usually carries:
- The problem, in one or two sentences.
- The proposed solution, and what you rejected.
- The implementation, scoped to one concern.
- Tests — `mise run check` passes.
- Documentation updated: README, CHANGELOG, this guide where relevant.
Generate the change locally with whatever model you have, then read every line yourself
before opening the PR. You are the author, not the model. Unreviewed generated code on a
path that reaches the vehicle will be sent back.
## Sponsorship
Maintaining EVSuite costs development time, test hardware and AI usage. If it is useful in
your daily driving, consider sponsoring through
[GitHub Sponsors](https://github.com/sponsors/malys). Sponsorship covers those costs and
gets fixes and features out faster.
Contributions are accepted under MIT (see [`LICENSE.md`](LICENSE.md)).
+463
View File
@@ -0,0 +1,463 @@
---
name: EVSuite
description: One design system for the EVSuite — a driver-facing instrument surface, not a phone UI parked in a car.
colors:
background: "#F2F4F7"
background-dark: "#0B0D0F"
surface: "#FFFFFF"
surface-dark: "#15181C"
surface-raised: "#E4E8ED"
surface-raised-dark: "#272C33"
outline: "#737F8F"
outline-dark: "#7A8492"
text-primary: "#101418"
text-primary-dark: "#F4F7FA"
text-secondary: "#414B55"
text-secondary-dark: "#C6CFD8"
accent: "#044C63"
accent-dark: "#7FD4FA"
on-accent: "#FFFFFF"
on-accent-dark: "#04121A"
ok: "#09501F"
ok-dark: "#8FE6A6"
warn: "#6B3A00"
warn-dark: "#FFC978"
error: "#8F0F18"
error-dark: "#FFACAC"
icon-tile: "#2A2A2A"
icon-glyph: "#F4F7FA"
icon-mark: "#E0424C"
typography:
display:
fontFamily: "Roboto, sans-serif"
fontSize: "32sp"
fontWeight: 500
title:
fontFamily: "Roboto, sans-serif"
fontSize: "26sp"
fontWeight: 500
body:
fontFamily: "Roboto, sans-serif"
fontSize: "20sp"
fontWeight: 400
secondary:
fontFamily: "Roboto, sans-serif"
fontSize: "17sp"
fontWeight: 400
label:
fontFamily: "Roboto, sans-serif"
fontSize: "16sp"
fontWeight: 700
letterSpacing: "0.12"
rounded:
md: "12dp"
spacing:
xs: "4dp"
sm: "8dp"
md: "16dp"
lg: "24dp"
touch: "72dp"
components:
button-primary:
backgroundColor: "{colors.accent}"
textColor: "{colors.on-accent}"
typography: "{typography.body}"
rounded: "{rounded.md}"
height: "{spacing.touch}"
padding: "0 24dp"
button-outlined:
backgroundColor: "transparent"
textColor: "{colors.accent}"
typography: "{typography.body}"
rounded: "{rounded.md}"
height: "{spacing.touch}"
padding: "0 24dp"
card:
backgroundColor: "{colors.surface}"
textColor: "{colors.text-primary}"
rounded: "{rounded.md}"
padding: "16dp"
section-header:
textColor: "{colors.accent}"
typography: "{typography.label}"
---
# Design System: EVSuite
Applies to **EVProfile**, **EVTasker**, **EVABRPUploader**, **EVChargePilot**,
**EVLauncher**, **EVSwipe** and the shared **EVHardware** library. This file is the normative
source; a copy lives at the root of every suite repository and they are kept identical.
## Overview
**Creative North Star: "The Instrument Binnacle"**
These are not phone apps that happen to run in a car. They are extensions of the
instrument panel, and they are judged the way a gauge is judged: can the driver read it
in one glance, at 70 cm, through a windscreen reflection, without thinking about it. Every
decision below follows from that. The suite is quiet, dense with information but never
busy, and it never asks for attention it hasn't earned — an MG4 app that animates,
celebrates, or decorates is competing with the road.
The visual language is flat, high-contrast and typographic. Depth comes from tonal
layering and a hairline outline, never from shadow. Colour carries meaning — accent,
ok, warn, error — and is never decorative. The one place personality is allowed is the
launcher icon, which is why the icon system gets its own section.
The suite is deliberately unlike consumer Android: bigger type, bigger targets, fewer
elements per screen, no gesture-only affordances, no ambient motion. Where Material 3
defaults conflict with legibility in daylight, legibility wins and the override is
commented in the theme.
**Key characteristics**
- Every text colour clears **7:1 (WCAG AAA)** on every surface it can land on. 4.5:1 is
not enough once sunlight hits the glass.
- Minimum touch target **72dp**, everywhere, including dialog buttons and switches.
- Minimum type size **16sp**. There is no "small" tier.
- Flat surfaces, tonal layering, one hairline outline. No shadows, no gradients.
- Colour is never the only carrier of meaning; a text label always accompanies it.
- Landscape-first. Portrait is not a supported orientation on the head unit.
## Colors
A neutral blue-grey ladder carrying one cyan accent and three status hues. The suite is
**DayNight**: `values/` holds the light palette, `values-night/` the dark one, and both
are held to the same 7:1 floor. Token names are identical in both files — a layout never
references a light or dark colour, only a role.
Every ratio below is measured, not estimated. Re-run the check after any change:
`node tools/contrast.mjs` (see *Do's and Don'ts*).
### Neutral
| Token | Light | Dark | Role |
|---|---|---|---|
| `ev_background` | `#F2F4F7` | `#0B0D0F` | The window. Never holds content directly. |
| `ev_surface` | `#FFFFFF` | `#15181C` | Cards, panes, list rows. |
| `ev_surface_raised` | `#E4E8ED` | `#272C33` | Buttons, chips, selected rows, dialogs. |
| `ev_outline` | `#737F8F` | `#7A8492` | Hairline on every card. 4.07:1 / 4.70:1. |
| `ev_text_primary` | `#101418` | `#F4F7FA` | 18.5:1 / 16.6:1 on surface. |
| `ev_text_secondary` | `#414B55` | `#C6CFD8` | 7.2:1 worst case (on `surface_raised`). |
### Primary
- **Instrument Cyan** (`ev_accent`, `#044C63` light / `#7FD4FA` dark): section headers,
selected state, links, the active value in a control. 7.7:1 worst case in light, 10.8:1
in dark. Light mode uses a deep cyan rather than tinting the dark one — a pale cyan on
white is 1.5:1 and disappears entirely in daylight.
- **On-accent** (`ev_on_accent`, `#FFFFFF` / `#04121A`): the only colour permitted on a
filled accent surface.
### Status
Never used alone. Each is paired with a text label, because a driver glancing sideways
reads shape and text faster than hue, and because ~8% of male drivers cannot separate
these three by colour at all.
- **OK** (`ev_ok`, `#09501F` / `#8FE6A6`) — the rule ran, the write landed, the upload succeeded.
- **Warn / refused** (`ev_warn`, `#6B3A00` / `#FFC978`) — the speed gate refused a write,
a deferred action is queued, firmware support is unknown.
- **Error** (`ev_error`, `#8F0F18` / `#FFACAC`) — the operation failed.
### Pressed state
- `ev_pressed` (`#14000000` / `#1FFFFFFF`) and `ev_pressed_strong` (`#29000000` /
`#33FFFFFF`). Alpha overlays on the surface beneath, not a separate hue, so a pressed
card still reads as the same card.
### Named rules
**The Daylight Rule.** 7:1 or it does not ship. AA (4.5:1) is a phone-in-a-hand
standard; the MG4 screen is read through a reflection with the sun behind the driver, and
4.5:1 collapses to unreadable there. This is the single constraint that has forced the
most rewrites in this codebase — `#9EA6AE` secondary text (6:1) and the original
`#4A525B` outline (2.25:1) both shipped and both had to be replaced.
**The Meaning Rule.** A colour outside the neutral ladder must encode state. If it is
there to look nice, delete it. This is why the suite has one accent and not a palette.
**The Role Rule.** Layouts reference roles (`ev_surface`), never values and never
theme-specific names. A layout that needs to know whether it is day or night is a bug.
## Typography
**Family:** Roboto — the AAOS 9 system face. No bundled fonts: an APK on a vehicle earns
every kilobyte, and a missing glyph in a six-locale app is a worse failure than a generic face.
**Character:** Neutral to the point of invisibility. The type is a readout, not a voice.
Weight and size carry hierarchy; italics and letterspacing are used once each, on purpose.
### Hierarchy
| Role | Size | Weight | Purpose |
|---|---|---|---|
| **Display** (`text_display`) | 32sp | Medium | A single number or word that is the whole point of the screen — a battery percentage, a drawer title. At most one per screen. |
| **Title** (`text_title`) | 26sp | Medium | Screen and card titles. |
| **Body** (`text_body`) | 20sp | Regular | Everything the driver actually reads. Also the size of every button label. |
| **Secondary** (`text_secondary`) | 17sp | Regular | Supporting detail, timestamps, units. |
| **Label** (`text_label`) | 16sp | Bold, `0.12` tracking | Section headers, in accent, uppercase-adjacent. The floor. |
### Named rules
**The 16sp Floor.** Nothing renders below 16sp. Not captions, not units, not footnotes.
Below that, contrast stops compensating for reflection and viewing distance, and the
information may as well not be on screen. If content does not fit at 16sp, there is too
much content.
**The One Display Rule.** One `text_display` element per screen. Two competing 32sp
numbers is two glances, and a glance is the budget.
## Layout
**Target.** MG4 Electric head unit: 12.8" HD centre display, AAOS 9 on MT2712, landscape,
viewed at ~70 cm. DPI is user-adjustable per app on this platform, so everything is
expressed in `dp`/`sp` and nothing assumes a pixel size. Portrait is not supported and is
not laid out for.
**Spacing scale.** `4 / 8 / 16 / 24dp` (`spacing_xs` … `spacing_lg`). Screen edges get
`spacing_lg`. Nothing between two rows is smaller than `spacing_sm` — tighter than that
and two targets read as one.
**Touch.** `touch_target` = **72dp** minimum for anything tappable, including dialog
buttons, switches and list rows. Android's 48dp guidance assumes a phone held still in a
hand; this screen is at arm's length in a moving vehicle. Adjacent targets are separated
by at least `spacing_sm`, so a missed tap is a miss, not a wrong action.
**Two-pane landscape.** Wide screens use a list/detail split rather than a stack — the
horizontal space exists, and a driver who has to navigate back to see context has lost
the context. `pane_list_width` = 380dp (wide enough for a full rule name), `pane_rail_width`
= 300dp for label-only rails. The detail pane takes the remainder and is the primary area.
**Grids.** Tile grids compute their column count from `catalog_tile_min_width` (240dp)
rather than hardcoding a number, so a DPI change re-flows instead of clipping. Tiles are
`catalog_tile_height` = 128dp — twice the touch minimum, so a three-line label plus a
qualifier fits without pushing the next row.
**Density.** Fewer, larger elements. If a screen needs a scroll before the fifth item, the
screen is wrong, not the scroll.
**App-local dimensions are allowed** where a surface genuinely differs (launcher tiles,
settings margins) — but they are declared in the app's `dimens.xml` with a comment saying
why, and they never redefine a suite token.
## Elevation & Depth
**There are no shadows.** Not one, in any app. A shadow on this display is a grey smear
that costs a composite pass and reads as dirt under sunlight. Depth is tonal:
`background` → `surface` → `surface_raised`, three steps, plus a 1dp `ev_outline`
hairline on every card.
The hairline is not decoration. `surface` and `surface_raised` differ by roughly one
tonal step; in direct sun that difference vanishes and the card boundary goes with it.
The outline is what survives.
State is expressed by the pressed overlay, never by lift.
## Shapes
One radius: **12dp** (`ev_corner`), on cards, buttons, chips, tiles and dialogs. Not two,
not a scale. A single radius is what makes five apps by different hands read as one suite,
and it is the cheapest consistency there is.
Borders are 1dp `ev_outline`. No dividers where a gap will do — `spacing_md` between two
cards separates them better than a line, and costs no ink.
The launcher icon is the one place a different silhouette appears: a squircle tile, which
is the platform's shape, not ours.
## Components
Component styles are named identically in every app so a screen can be moved between
repositories without a rename: `Theme.EV<App>`, `Widget.EV.*`, `Text.EV.*`,
`ThemeOverlay.EV.*`.
### Buttons
- **Shape:** 12dp radius.
- **Primary** (`Widget.EV.Button`): filled accent, `on_accent` label, `text_body` (20sp),
min height 72dp, `spacing_lg` horizontal padding.
- **Outlined** (`Widget.EV.Button.Outlined`): 1dp accent border, accent label, same metrics.
- **Dialog** (`Widget.EV.DialogButton`): text button, but pulled up to the same 72dp
target and 140dp min width. Dialog buttons are the smallest thing in any app and the
most likely to be mis-tapped.
- **Pressed:** `ev_pressed_strong` overlay. No ripple travel, no scale, no lift.
### Cards / containers
- 12dp radius, `ev_surface`, 1dp `ev_outline`, `spacing_md` internal padding.
- Selected state: background steps to `ev_surface_raised` **and** the leading label
turns accent. Two signals, because one is a colour.
### Switches
- `Widget.EV.Switch`, 72dp min height, `text_body` label. A switch that needs a precise
tap is a switch that gets missed at 90 km/h.
### Section headers
- `Text.EV.SectionHeader`: 16sp, bold, `0.12` tracking, accent. The only tracked type in
the suite; the tracking is what lets it work at the size floor without shouting.
### Dialogs
- `ThemeOverlay.EV.Dialog` on `ev_surface_raised`. Material's default dialog palette is
a grey-mauve tuned for indoors and it is the first thing to wash out; every app overrides it.
- A picker that fills the screen (`Theme.EV.Picker`) is not floating and does not dim:
`windowIsFloating=false`, `backgroundDimEnabled=false`. In landscape, a bottom sheet
leaves a third of the height, which means one column and a scrollbar by the fifth entry.
There is nothing behind a picker worth seeing.
- A confirmation or warning with explanatory content is a **dedicated full-screen
window, never a popup**. Put its explicit confirm and cancel actions together at the
upper-right, consistently with the suite's editor windows, keep both at the 72dp touch
minimum, and let the content use all remaining space. Reserve a compact dialog for a
short, single-decision interruption whose complete message remains immediately readable
at 70 cm.
### Lists
- Row height ≥ 72dp, `ev_surface`, `spacing_sm` gaps. No swipe-to-delete: a gesture with
no visible affordance and no undo does not belong in a vehicle. Destructive actions are
a button plus a confirmation.
## Iconography
The launcher icon is the suite's signature and the one place it is allowed to have fun.
**The system.** Adaptive icon, 108dp, safe zone 66dp centred. Three fixed elements:
1. **Tile** — flat `#2A2A2A` charcoal. No gradient. Identical in every app, and identical
in light and dark: this is the tile EVProfile has always had, and it is what makes a
row of suite apps read as a row.
2. **Glyph** — `#F4F7FA`, geometric, 5dp stroke, round caps and joins, with exactly one
`#E0424C` red element as the accent (3.45:1 on the tile — it is a mark, not text).
The glyph says what the app does at 48px. EVProfile's glyph is the `MG⁴` lockup: it
is the flagship, so it wears the suite mark.
3. **Caption** — the product word beneath the glyph, `#C6CFD8`, small caps, `0.18` tracking.
**The glyphs**
| App | Glyph |
|---|---|
| EVProfile | `MG⁴` lockup — white letters, red superscript 4 |
| EVTasker | A branch: one input, a condition, two outcomes |
| EVABRPUploader | An upward arrow leaving two broadcast arcs |
| EVChargePilot | A battery crossed by a measured energy trace |
| EVLauncher | A four-tile grid, one tile red |
| EVSwipe | Two chevrons and a swipe trail |
**Rules.** Vector (`VectorDrawable`) only — the same file serves every density and costs
one file instead of five PNGs. Every icon ships a `<monochrome>` layer for themed icons.
No photographic content, no bevel, no drop shadow, no stock clipart. If a glyph is not
legible at 48px in greyscale, it is the wrong glyph.
## Nomenclature
**Product names** are `EV<Word>` — space, title case: *EVProfile*, *EVTasker*,
*EVABRPUploader*, *EVLauncher*, *EVSwipe*. This is what
`app_name` holds and what the driver sees under the icon.
**Repositories, modules and directories** are `EV<Word>` — no space: `EVProfile`,
`EVTasker`, `EVABRPUploader`, `EVChargePilot`, `EVLauncher`, `EVSwipe`, `EVHardware`.
**Package ids** are `com.evsuite.<lowercase>`; a family shares a segment
(`com.evsuite.launcher`, `com.evsuite.swipe`).
**Resources** are `snake_case` with a suite prefix when the token is shared
(`ev_surface`, `ev_corner`) and unprefixed when app-local (`catalog_tile_height`).
Styles are `Theme.EV<App>`, `Widget.EV.<Thing>`, `Text.EV.<Role>`.
**The suite** is *EVSuite* — one word, when referring to all of it.
## Documentation
Every repository carries the same files, in the same order, with the same headings.
| File | Contents |
|---|---|
| `README.md` | The one below. English. |
| `DESIGN.md` | This file, verbatim. |
| `AGENTS.md` | Repo-specific agent context. |
| `CONTRIBUTING.md` | How to build, test, submit. |
| `SECURITY.md` | Threat model, disclosure. |
| `DISCLAIMER.md` | Vehicle-safety disclaimer. |
| `CHANGELOG.md` | Keep a Changelog format. |
| `LICENSE.md` | Licence text. |
**README skeleton** — sections in this order, omitting any that do not apply:
```
# EV<Name>
<one-line description>
<badges>
## Contents
## Screenshots
## Overview
## Features
## Requirements
## How it works
… app-specific detail sections belong here, and only here
## Install
## Configuration
## Building
## Project layout
## Project documents
## Security
## Contributing
## Legal
```
The `## Contents` table of contents is generated from the headings, not hand-written:
`node tools/sync-docs.mjs`. A hand-written TOC goes stale the first time a heading
is renamed, and a dead anchor in a README is a small lie about the project.
Translations live in `README.<lang>.md` and are linked from the top of the English
README — never interleaved into it. A bilingual README doubles the size of every future edit.
## Constraint history
Why the rules above are the shape they are. Each of these was paid for.
- **7:1, not 4.5:1.** Secondary text shipped at `#9EA6AE` (6:1) and was unreadable in
daylight. The floor moved to AAA and the whole ladder was re-derived.
- **The outline exists because tonal layering alone failed.** `surface` and
`surface_raised` are one step apart and merge under sun. The border was added — then had
to be raised from `#4A525B` (2.25:1) to `#7A8492` (4.70:1), because a border below the
3:1 non-text floor is a border that isn't there.
- **72dp, not 48dp.** Google's target assumes a hand-held phone. Missed taps on a moving
vehicle drove it to 72dp, and dialog buttons and switches were the last holdouts.
- **16sp floor.** Anything smaller stopped being legible at 70 cm through a reflection.
- **Full-screen pickers.** A bottom sheet in landscape left a third of the height: one
column, scrolling by the fifth entry. The picker became a full-screen non-floating window.
- **No shadows.** Cost a composite pass on MT2712 and read as smudge in sun.
- **Dark-first, DayNight second.** The suite ran dark-only for a long time because a light
background facing the driver at night is glare. It is now DayNight across all five apps
by explicit decision — which means the light palette carries the same 7:1 obligation
the dark one always had, and the pale dark-mode accent could not simply be reused.
- **Colour never carries meaning alone.** Verdict rows show a word as well as a hue.
- **Vector icons, no PNG sets.** Five density buckets per app, for an icon, on a vehicle
where APK size is a stability concern.
- **AAOS 9, MT2712, must not destabilise the vehicle.** Everything above is downstream of
this. When a design choice and stability disagree, stability wins and the design choice
gets a comment explaining what it gave up.
## Do's and Don'ts
### Do
- **Do** verify contrast numerically before committing a colour. Every ratio in this file
is measured; new ones must be too.
- **Do** put the reason in the resource file. Every override of a Material default in this
suite carries a comment saying what would break without it. That comment is why the rule
survives the next refactor.
- **Do** reference roles (`@color/ev_surface`, `@dimen/touch_target`), never literals.
A hex value in a layout is a bug.
- **Do** give every state two signals — colour plus text, or colour plus position.
- **Do** design landscape first, and check that nothing needs a scroll before the fifth item.
- **Do** name new resources with the suite prefix if another app could want them.
### Don't
- **Don't** add a shadow, gradient, or elevation. Depth is tonal.
- **Don't** introduce a second accent hue. One accent, three status colours, that is the
whole palette.
- **Don't** ship type below 16sp or a target below 72dp, including in dialogs.
- **Don't** use `Theme.Material3.Dark` or `.Light` directly — always `.DayNight`, so both
palettes stay wired.
- **Don't** rely on a gesture with no button fallback. No swipe-to-delete, no long-press-only.
- **Don't** animate anything ambient. Motion is confirmation of a touch, or it is absent.
- **Don't** put a light background behind a full-screen surface at night without checking
it against the dark palette — the driver's night vision is a safety property.
- **Don't** hardcode a pixel size or assume a DPI. This platform lets the user change it.
+32
View File
@@ -0,0 +1,32 @@
# Disclaimer — no warranty, no liability
**Use this software entirely at your own risk.**
This project is provided **"as is"**, without warranty of any kind, express or implied,
including but not limited to the warranties of merchantability, fitness for a particular
purpose and non-infringement. In no event shall the authors or contributors be liable for
any claim, damages or other liability, whether in an action of contract, tort or otherwise,
arising from, out of or in connection with the software or its use.
## What that means concretely
- This library **reads and writes settings on a vehicle**. Any app that embeds it can
change how a car behaves. Installing such an app is your decision and your
responsibility, including any effect on the head unit's stability, your warranty, your
insurance, or your vehicle's roadworthiness.
- The safety gate refuses road-behaviour writes above 0 km/h and fails closed on an
unreadable speed. It is a guard, **not** a guarantee — it does not make an ill-considered
write safe, it only prevents it landing while moving.
- **Compatibility is inferred, not tested.** `docs/firmware-matrix.md` is derived from this
code, not from running on cars. Consumers must check what a given vehicle actually
exposes at runtime.
- Vehicle access uses **undocumented runtime interfaces**. They can change or disappear
with any firmware update.
- Climate and window signals are **read-only and unverified** on MG4 hardware.
## Not affiliated
This project is **not affiliated with, endorsed by, or supported by** SAIC Motor, MG Motor,
or Google. MG, MG4 and related names and logos are trademarks of their respective owners.
They are used solely to identify compatibility with certain vehicles; no official origin,
certification or approval is claimed.
+74
View File
@@ -0,0 +1,74 @@
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
## Acceptance
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
## Copyright License
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
## Changes and New Works License
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
## Patent License
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
## Fair Use
You may have "fair use" rights for the software under the law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
## Patent Defense
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
## No Liability
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
**You** refers to the individual or entity agreeing to these terms.
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the software under these terms.
**Use** means anything you do with the software requiring one of your licenses.
Required Notice: Copyright (c) 2026 EVHardware contributors
+15
View File
@@ -0,0 +1,15 @@
# Licence
Source-available under the **PolyForm Noncommercial License 1.0.0** — see
[`LICENSE`](LICENSE). Commercial use is not permitted.
EVHardware is the shared vehicle layer for the EVSuite apps (EVProfile, EVTasker,
EVABRPUploader). Parts of it originate in EVProfile, itself a fork whose upstream licence
situation is unresolved. PolyForm Noncommercial applies only to rights held by EVHardware
contributors. If an upstream author ever objects to a specific piece, the right response is
to honour that rather than argue.
## No warranty
The licence disclaims warranty and liability, and this software reads and writes a **vehicle** —
see [`DISCLAIMER.md`](DISCLAIMER.md) for what that means concretely.
+205
View File
@@ -0,0 +1,205 @@
# EVHardware
<p align="center"><img src="docs/logo.svg" width="440" alt="EVHardware"></p>
[![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)
[![License: PolyForm Noncommercial](https://img.shields.io/badge/license-PolyForm%20Noncommercial-blue.svg)](LICENSE)
> ⚠️ **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.
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
models, and the **condition/action catalogue** — consumed by the EVSuite apps so they do not
each re-derive it and drift apart.
The design line: **the apps are thin. EVHardware provides the vehicle.** EVTasker is a rule
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.
---
## Contents
- [Overview](#overview)
- [How it works](#how-it-works)
- [Install](#install)
- [The EVSuite](#the-evsuite)
- [Building](#building)
- [Project documents](#project-documents)
- [Security](#security)
- [Contributing](#contributing)
- [Legal](#legal)
## Overview
| Area | Types |
|---|---|
| Vehicle access | `EVHardware` — Katman1/4/5 reflection over `android.car` + SAIC SDK, per-firmware routing |
| Safety | `VehicleWriteGate` (0 km/h, fail-closed) + `@RequiresStandstill` on every gated setter |
| Vendor services | `saic.*` — the AIDL the head unit's own HVAC, charging, radio, hands-free and TTS apps use |
| Radio | `SaicRadio` — play, silence, fail-closed play/pause toggle, FM/AM tune, station stepping (DAB included), screen |
| Energy telemetry | `EnergyTelemetryReader`, `EnergySnapshot` — coherent nullable SOC, range, power, temperature, charging, tyre and climate reads |
| Energy accounting | `TripDetector`, `EnergyTripAccumulator`, `ChargingSessionMeter`, `EnergyTripHistoryStore` — tested automatic boundaries, integration and atomic local persistence |
| Firmware | `FirmwareInfo` (detection + capability helpers), `FirmwareGen` |
| Models | `DrivingProfile`, `DriveMode`, `RegenLevel`, `ProfileBackup` |
| Catalogue | `ConditionType`, `ActionType`, `ValueSpec`, `VehicleEnums`, `SnapshotKeys` |
| Compatibility | `@SupportedOn`, `FirmwareSupport`, `FirmwareMatrix` → [docs/firmware-matrix.md](docs/firmware-matrix.md) |
| Diagnostics | `AppLogger` (ring buffer, no ADB needed), `diag.CrashLogger`, `diag.PrivateBin` |
### The safety gate and `@RequiresStandstill`
`VehicleWriteGate` refuses any road-behaviour write above 0 km/h, and fails closed when the
speed is unreadable. Every gated setter in `EVHardware` carries `@RequiresStandstill`, and
a unit test asserts that exact set — a setter cannot silently gain or lose gating. Comfort
writes (seat/steering heating, volume, brightness, audio) are not gated and not annotated.
The threshold is fixed at exactly 0 km/h. It is not configurable, and no secondary signal
(including park) can rescue an unreadable speed. Every decision reads the speed again so a
sequence is cancelled as soon as the vehicle moves or the signal disappears.
### Firmware compatibility is generated, not written
Each vehicle catalogue entry carries `@SupportedOn(...)`, derived from `FirmwareInfo` and
`EVHardware`'s per-generation routing. [docs/firmware-matrix.md](docs/firmware-matrix.md)
is rendered from those annotations by `FirmwareMatrix` and checked by a test — never edited
by hand. Consumers use the same annotations to hide entries unsupported on the connected
car.
> The matrix is derived from code, **not** on-vehicle testing. Consumers should surface a
> diagnostic that reads each signal live; unreadable ≠ unsupported.
### Climate, glass and charging go through the vendor services
The AOSP property ids for HVAC and window position match the R69 runtime names but are not
confirmed on every MG4 generation. They are still used for *reads*, where a wrong id returns null
and nothing else happens — but nothing is written through them, because writing an unverified
id to a vehicle is a different kind of mistake.
Writes go to `saic.*` instead: the binder interfaces the car's own HVAC, charging and vehicle
apps call. The integration contract is known; whether a
given firmware answers is a bind, which consumers report live rather than tabulate. The
electric tailgate stays out — the launcher defines OPEN and CLOSE as the same value, so it is
a pulse whose direction depends on state this cannot read.
---
## How it works
EVHardware is consumed as a **git submodule** exposed as a Gradle subproject. In a
consumer app:
```bash
git submodule add https://github.com/malys/EVHardware EVHardware
```
`settings.gradle.kts`:
```kotlin
include(":evhardware")
project(":evhardware").projectDir = file("EVHardware/lib")
```
`app/build.gradle.kts`:
```kotlin
dependencies { implementation(project(":evhardware")) }
```
Vehicle writes require the app to be signed with the ROM **platform key** (the car
permissions are `signature|privileged`); reads of standard AAOS properties work with less.
The library itself declares no permissions and no `sharedUserId` — that is the consuming
app's decision.
---
### External projects: 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
`./gradlew :lib:publishToMavenLocal` installs it to `~/.m2` for local experimentation:
```kotlin
repositories {
mavenLocal()
maven { url = uri("https://maven.pkg.github.com/malys/EVHardware") } // GitHub Packages
}
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
./gradlew :lib:assembleDebug # the AAR
```
JDK 17 (AGP 9.1.1 / Gradle 9.3.1 / compileSdk 36). `android.car` is not on the compile SDK, so all vehicle access is
reflection — there is nothing to test on an emulator; the JVM tests cover the decision
logic (gate, firmware annotations, catalogue consistency, telemetry validation and energy
integration).
### Adding a condition or action
One enum entry in `catalog/ConditionType` or `catalog/ActionType`, one string in
`lib/src/main/res/values/strings.xml` (+ `values-fr/`), and — for a vehicle entry — one
`@SupportedOn(...)`. A test fails if a vehicle entry lacks it; the matrix regenerates on the
next test run.
---
## Project documents
| Document | What it covers |
|---|---|
| [DESIGN.md](DESIGN.md) | The EVSuite design system — colour, type, touch targets, icons |
| [AGENTS.md](AGENTS.md) | Context for AI agents working in this repository |
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to build, test and submit a change |
| [SECURITY.md](SECURITY.md) | Threat model and vulnerability disclosure |
| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer — read before installing |
| [docs/radio-action-gap.md](docs/radio-action-gap.md) | Which radio behaviours the catalogue carries, and which were rejected |
| [CHANGELOG.md](CHANGELOG.md) | Release history |
| [LICENSE.md](LICENSE.md) | Licence text |
## Security
See [SECURITY.md](SECURITY.md). The library is the one place vehicle writes happen for its
consumers, so the gate, the closed action vocabulary, and the per-firmware routing all live
here rather than being reimplemented per app.
## Contributing
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. In short: this
code runs in a moving vehicle, so changes stay small, carry tests, and say in the diff
what would break without them. Anything touching the interface follows
[DESIGN.md](DESIGN.md).
## Legal
Source-available under the PolyForm Noncommercial License 1.0.0. Commercial use is not
permitted. See [LICENSE](LICENSE) and [LICENSE.md](LICENSE.md). Runs on a vehicle; see
[DISCLAIMER.md](DISCLAIMER.md).
+40
View File
@@ -0,0 +1,40 @@
# Security policy
This library is the one place its consumer apps read and write a car. That makes it
security-relevant even though it holds no credentials and opens no network.
## Reporting a vulnerability
Please **do not** open a public issue for a vulnerability. Use GitHub's
[private vulnerability reporting](../../security/advisories/new) instead. Say what you were
able to do, on which firmware generation, and whether the vehicle was moving.
## In scope
- A road-behaviour write reaching the vehicle above 0 km/h, or on an unreadable speed
(the gate must fail closed).
- A gated setter missing `@RequiresStandstill`, or the gate being bypassable.
- A catalogue entry that maps to a vehicle capability outside its declared `@SupportedOn`
set, or a wrong property id that writes an unintended vehicle property.
- Deserialization of a crafted `DrivingProfile`/`ProfileBackup` causing unintended writes.
## Not in scope
- The library declaring no permissions and no `sharedUserId`. Granting vehicle access is
the **consuming app's** decision (platform signature / privileged install); report those
choices against the app, not here.
- Climate/window reads returning null on a firmware that does not expose them — that is the
unverified-and-read-only design.
- OEM firmware vulnerabilities — report to SAIC.
## Design decisions
- **The 0 km/h gate lives here**, applied inside the low-level write primitives, so every
consumer inherits it in one place. `@RequiresStandstill` documents each gated setter and
a test asserts the set.
- **No raw "write property 0xNNNN" API** is exposed to consumers beyond the typed setters;
the action catalogue is a closed vocabulary.
- **Climate/window writes are intentionally absent.** Reads are unverified; a write path
waits on on-vehicle confirmation.
- **Reflection targets are kept via `consumer-rules.pro`** so R8 in a consumer app cannot
strip the `android.car`/SAIC/model/catalogue names resolved by name at runtime.
+4
View File
@@ -0,0 +1,4 @@
plugins {
// AGP 9 provides built-in Kotlin compilation; no separate Kotlin plugin is applied.
alias(libs.plugins.android.library) apply false
}
+242
View File
@@ -0,0 +1,242 @@
<!-- GENERATED by CatalogueDocTest; do not edit by hand. -->
# The EVTasker catalogue
Every condition a rule can test and every action it can run, straight out of `ConditionType` and `ActionType`. *Firmware* is what the entry is annotated for; "any" means it depends on no vehicle firmware at all, not that it needs nothing — a notification still needs the permission, a call still needs a paired phone. The car's own Diagnostic screen stays the only answer for the car in front of you.
## Conditions
### Context
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Bluetooth phone connected | `BT_DEVICE_CONNECTED` | a paired phone | any |
| Any Bluetooth device connected | `ANY_BT_CONNECTED` | on / off | any |
| Phone on board (Bluetooth) | `BT_DEVICE_ONBOARD` | a paired phone | any |
| Bluetooth hands-free phone | `BT_DEVICE_HANDSFREE` | a paired phone | any |
| Time of day | `TIME_OF_DAY` | a start and an end time | any |
| Day of week | `DAY_OF_WEEK` | weekdays | any |
| Exact date | `DATE` | a calendar date | any |
| Firmware generation | `FIRMWARE_GEN` | a list the app fills in | any |
| Near a place | `LOCATION_WITHIN` | a point and a radius (m) | any |
| Physical buttons | `PHYSICAL_BUTTON` | a button, short or long press | any |
| Wi-Fi network | `WIFI_SSID` | free text | any |
| 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 |
| 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 |
### Environment
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Outside temperature | `OUTSIDE_TEMP` | -30…50 °C (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Weather | `WEATHER_NOW` | Clear, Cloudy, Rain, Snow, Thunderstorm, Fog, Wind | any |
### Driving
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Ignition state | `IGNITION_STATE` | Locked, Off, Accessory, Running, Starting | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Gear in P | `IN_PARK` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Speed | `SPEED` | 0…200 km/h (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Drive mode | `DRIVE_MODE` | Eco, Normal, Sport, Snow, Custom | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Regeneration | `REGEN_LEVEL` | Low, Medium, High, Adaptive, Off, One pedal | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Odometer | `ODOMETER` | 0…500000 km (=, <, >) | any |
| Energy saving | `ENERGY_SAVING` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
### Energy
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Battery level | `BATTERY_LEVEL` | 0…100 % (=, <, >) | SWI68, SWI165 |
| Charging | `CHARGING` | on / off | SWI68, SWI165 |
| Charge limit | `CHARGE_LIMIT` | 0…100 % (=, <, >) | SWI68, SWI165 |
| Charging state | `CHARGING_STATUS` | Unplugged, Charging (AC), Charging (DC), Plugged in, not charging, Charge complete, Charging fault | SWI68, SWI165 |
| Scheduled charging on | `CHARGE_SCHEDULE_ENABLED` | on / off | SWI68, SWI165 |
| Charging starts at | `CHARGE_WINDOW_START` | a time of day (=, <, >) | SWI68, SWI165 |
| Charging stops at | `CHARGE_WINDOW_STOP` | a time of day (=, <, >) | SWI68, SWI165 |
| Remaining range | `RANGE_ESTIMATE` | 0…600 km (=, <, >) | SWI68, SWI165 |
| Battery pre-heating on | `BATTERY_PREHEAT` | on / off | SWI68, SWI165 |
### Climate
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Climate on | `CLIMATE_ON` | on / off | SWI68, SWI165 |
| Air conditioning on | `AC_ON` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Climate AUTO on | `HVAC_AUTO` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| 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 |
| 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 |
| A window is open | `WINDOW_OPEN` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Window opening | `WINDOW_POSITION` | 0…100 % (=, <, >) | SWI68, SWI165 |
| Driver window opening | `WINDOW_DRIVER` | 0…100 % (=, <, >) | SWI68, SWI165 |
| Passenger window opening | `WINDOW_PASSENGER` | 0…100 % (=, <, >) | SWI68, SWI165 |
| Rear left window opening | `WINDOW_REAR_LEFT` | 0…100 % (=, <, >) | SWI68, SWI165 |
| Rear right window opening | `WINDOW_REAR_RIGHT` | 0…100 % (=, <, >) | SWI68, SWI165 |
### Comfort
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Left seat heating | `SEAT_HEAT_LEFT` | 0…3 (=, <, >) | SWI133, SWI68, SWI165 |
| Right seat heating | `SEAT_HEAT_RIGHT` | 0…3 (=, <, >) | SWI133, SWI68, SWI165 |
| Steering wheel heating | `STEERING_HEAT` | on / off | SWI133, SWI68, SWI165 |
| Screen brightness | `SCREEN_BRIGHTNESS` | 5…100 % (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
### Audio
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Media playing | `MEDIA_PLAYING` | on / off | any |
| Radio playing | `RADIO_PLAYING` | on / off | SWI68, SWI165 |
| Media volume | `MEDIA_VOLUME` | 0…max (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
### Driver assistance
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Emergency braking (AEB) | `AEB_ENABLED` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| AEB mode | `AEB_MODE` | Alert only, Alert + braking | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| AEB sensitivity | `AEB_SENSITIVITY` | Low, Standard, High | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| ELK mode | `ELK_MODE` | Off, Warning (LDW), Assist (LDP), Emergency keeping (ELK) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| ELK sensitivity | `ELK_SENSITIVITY` | Low, Standard, High | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| ACC / TJA mode | `ACC_TJA_MODE` | Off, Adaptive cruise (ACC), Traffic jam assist (TJA) | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Speed limiter | `LIMITER_MODE` | Off, Manual, Intelligent | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Traffic sign recognition (TSR) | `TSR` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Overspeed alarm | `OVERSPEED_ALARM` | on / off | SWI133, SWI132 |
| Speed limit tone | `SPEED_LIMIT_TONE` | on / off | SWI133, SWI132 |
| ADAS sound warning | `SOUND_WARNING` | on / off | SWI68, SWI165, SWI69, SWI131, SWI132 |
A reading the car does not answer makes the rule **not evaluable**: it does not fire, and it does not count as false.
## Actions
### Profile
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| Apply a profile | `APPLY_PROFILE` | an EVProfile profile | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Ask for a profile | `SHOW_PROFILE_PICKER` | — | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
### Driving
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| Drive mode | `SET_DRIVE_MODE` | Eco, Normal, Sport, Snow, Custom | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Regeneration | `SET_REGEN_LEVEL` | Low, Medium, High, Adaptive, Off, One pedal | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| One pedal driving | `SET_ONE_PEDAL` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Energy saving | `SET_ENERGY_SAVING` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
### Comfort
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| Left seat heating | `SET_SEAT_HEAT_LEFT` | 0…3 | — | SWI133, SWI68, SWI165 |
| Right seat heating | `SET_SEAT_HEAT_RIGHT` | 0…3 | — | SWI133, SWI68, SWI165 |
| Steering wheel heating | `SET_STEERING_HEAT` | on / off | — | SWI133, SWI68, SWI165 |
| Screen brightness | `SET_SCREEN_BRIGHTNESS` | 5…100 % | — | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
### Climate
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| 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 |
| 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 |
| Air recirculation | `SET_RECIRCULATION` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Fan level | `SET_FAN_LEVEL` | 0…11 | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Windscreen defroster | `SET_FRONT_DEFROST` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Rear window defroster | `SET_REAR_DEFROST` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
| All windows ⚠️ | `SET_WINDOWS` | Closed, Open | yes | SWI68, SWI165 |
| Driver window ⚠️ | `SET_WINDOW_DRIVER` | Closed, Open | yes | SWI68, SWI165 |
| Passenger window ⚠️ | `SET_WINDOW_PASSENGER` | Closed, Open | yes | SWI68, SWI165 |
| Rear left window ⚠️ | `SET_WINDOW_REAR_LEFT` | Closed, Open | yes | SWI68, SWI165 |
| Rear right window ⚠️ | `SET_WINDOW_REAR_RIGHT` | Closed, Open | yes | SWI68, SWI165 |
| Door locks | `SET_DOOR_LOCK` | on / off | — | SWI68, SWI165 |
### Energy
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| 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 |
| Battery pre-heating | `SET_BATTERY_PREHEAT` | on / off | — | SWI68, SWI165 |
### Audio
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| Media volume | `SET_MEDIA_VOLUME` | 0…max | — | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Media volume, step | `ADJUST_MEDIA_VOLUME` | -30…30 | — | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Left / right balance | `SET_AUDIO_BALANCE` | -9…9 | — | SWI69, SWI131, SWI132 |
| Front / rear fader | `SET_AUDIO_FADER` | -9…9 | — | SWI69, SWI131, SWI132 |
| Tone | `SET_TONE_CONTROL` | -9…9 | — | SWI69, SWI131, SWI132 |
| Bose sound profile | `SET_BOSE_SOUND_TYPE` | 0…3 | — | SWI69, SWI131, SWI132 |
| 3D effect | `SET_3D_EFFECT` | 0…3 | — | SWI69, SWI131, SWI132 |
| Speed-adaptive volume | `SET_SPEED_VOLUME` | 0…3 | — | SWI69, SWI131, SWI132 |
| Play the radio | `PLAY_RADIO` | — | — | SWI68, SWI165 |
| Radio band and station | `TUNE_RADIO` | band (AM, FM, DAB), frequency, play | — | SWI68, SWI165 |
| Next radio station | `RADIO_NEXT_STATION` | — | — | SWI68, SWI165 |
| Previous radio station | `RADIO_PREV_STATION` | — | — | SWI68, SWI165 |
| Silence the radio | `PAUSE_RADIO` | — | — | SWI68, SWI165 |
| Radio play/pause | `RADIO_PLAY_PAUSE` | — | — | SWI68, SWI165 |
| Open the radio screen | `OPEN_RADIO_SCREEN` | — | yes | SWI68, SWI165 |
### Driver assistance
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| Emergency braking (AEB) | `SET_AEB_ENABLED` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| AEB mode | `SET_AEB_MODE` | Alert only, Alert + braking | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| AEB sensitivity | `SET_AEB_SENSITIVITY` | Low, Standard, High | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Stability control (ESC) | `SET_ESC` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Drowsiness warning | `SET_DROWSINESS` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Drowsiness warning sensitivity | `SET_DROWSINESS_SENSITIVITY` | Low, Standard, High | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| ELK mode | `SET_ELK_MODE` | Off, Warning (LDW), Assist (LDP), Emergency keeping (ELK) | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| ELK sensitivity | `SET_ELK_SENSITIVITY` | Low, Standard, High | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| ACC / TJA mode | `SET_ACC_TJA_MODE` | Off, Adaptive cruise (ACC), Traffic jam assist (TJA) | yes | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Speed limiter | `SET_LIMITER_MODE` | Off, Manual, Intelligent | yes | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Traffic sign recognition (TSR) | `SET_TSR` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Overspeed alarm | `SET_OVERSPEED_ALARM` | on / off | yes | SWI133, SWI132 |
| Speed limit tone | `SET_SPEED_LIMIT_TONE` | on / off | yes | SWI133, SWI132 |
| ADAS sound warning | `SET_SOUND_WARNING` | on / off | yes | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Lane departure sound warning | `SET_LAS_WARNING_SOUND` | on / off | — | SWI132 |
| Lane departure steering vibration | `SET_LAS_WARNING_VIBRATION` | on / off | — | SWI132 |
### System
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| Launch an app | `LAUNCH_APP` | an installed app | — | any |
| Show a notification | `SHOW_NOTIFICATION` | free text | — | any |
| Speak a message | `SPEAK_TEXT` | free text | — | any |
| Navigate to | `NAVIGATE_TO` | an address or coordinates | — | any |
| Ask for confirmation | `ASK_CONFIRM` | a question, and how long to wait | — | any |
| Send a text message | `SEND_SMS` | a number or a contact, and a message | — | any |
| Enable a rule | `ENABLE_RULE` | one of your rules | — | any |
| Disable a rule | `DISABLE_RULE` | one of your rules | — | any |
| Media control | `MEDIA_CONTROL` | Play / pause, Next track, Previous track | — | any |
| Bluetooth | `SET_BLUETOOTH` | on / off | — | any |
| Wi-Fi | `SET_WIFI` | on / off | — | any |
| Call webhook | `WEBHOOK` | a URL, and an optional body | — | any |
| Wait | `DELAY` | 1…60 s | — | any |
| Call | `CALL_NUMBER` | a number or a contact | — | SWI68, SWI165 |
| Call a contact | `CALL_CONTACT` | a number or a contact | — | SWI68, SWI165 |
⚠️ — the firmware carries the property, but nothing establishes that writing it does anything (`ActionType.writeProven`). Those actions are hidden from the editor and refused by the executor until one is observed to work.
The per-generation grid is in [firmware-matrix.md](firmware-matrix.md), generated from the same annotations.
@@ -0,0 +1,48 @@
# Public energy-signal candidates
Reviewed 2026-08-28 for CP-004. This review uses only public AOSP catalogues and the
project's existing runtime read seam. It does not use proprietary artifacts or non-public
property catalogues.
## Safety and validation boundary
Every entry below is read-only and starts with `validated: no`. A public property number proves
only that AOSP assigned a number and semantics; it does not prove that an MG4 VHAL implements or
publishes it. No candidate may enter `EnergySnapshot`, a production fallback or a stable UI until
CP-003 records availability, unit, scale, sign and update cadence for every supported firmware
generation. An unavailable or non-finite read stays null.
## Public catalogue findings
| Signal | Public declaration | Type / area / unit / mode | AOSP coverage | EVSuite disposition | validated |
|---|---|---|---|---|---|
| `EV_CURRENT_BATTERY_CAPACITY` (`291504909`, `0x1160030D`) | [AOSP snapshot `c80b787`](https://android.googlesource.com/platform/packages/services/Car/+/c80b787d499243d120c2738eb68e7c0520c7238d/car-lib/src/android/car/VehiclePropertyIds.java) | Float / global / Wh / on-change / read-only | Public post-AAOS 9 API; the declaration requires Car UDC with Android 13 or later | Highest-value new candidate: status-aware probe in unstable evidence capture only | no |
| `EV_BATTERY_AVERAGE_TEMPERATURE` (`291504910`, `0x1160030E`) | [AOSP change `5b96f99`](https://android.googlesource.com/platform/packages/services/Car/+/5b96f99271e9209774a312c8f0fc1279dccf0678/car-lib/src/android/car/VehiclePropertyIds.java) | Float / global / °C / continuous / read-only | Added to the public API in December 2023 | Already read by the CP-001 baseline as nullable battery temperature; CP-004 adds no duplicate candidate | no |
| `EV_BATTERY_INSTANTANEOUS_CHARGE_RATE` (`291504908`, `0x1160030C`) | [Android 9 AOSP snapshot `8686c86`](https://android.googlesource.com/platform/packages/services/Car/+/8686c86b38a590712412fea2dd083181aceba4f2/car-lib/src/android/car/VehiclePropertyIds.java) | Float / global / mW / continuous / read-only | Android 9 public catalogue | Existing CP-001 battery-power input; its live scale and sign still require CP-003 evidence | no |
All cited files are Copyright Android Open Source Project and licensed Apache-2.0 at the cited
commit. The review records public declarations, not copied implementation code. Vehicle coverage
remains unknown for SWI68, SWI69, SWI131, SWI132, SWI133 and SWI165 until CP-003 runs on each one.
## Explicit negative results
| Requested quantity | Public catalogue result | Consequence |
|---|---|---|
| HVAC, compressor or PTC electrical power | No Android 9 or reviewed later public vehicle property. `HVAC_POWER_ON` is only a boolean subsystem state. | Climate energy can only be estimated from validated pack power and controlled trials. |
| Battery-heater power | No public power or energy property found. Battery temperature is a temperature, not heater consumption. | No direct measurement candidate. |
| DC-DC or accessory load | No public power or energy property found. | No direct measurement candidate. |
| Cumulative battery discharge | No public cumulative counter found. `EV_BATTERY_LEVEL` is current stored energy. | Integrate validated instantaneous power, retaining uncertainty and gaps. |
| Cumulative regeneration | No public cumulative counter found. Later regenerative-braking properties describe a state, not recovered energy. | Integrate only after CP-003 proves the power sign convention. |
| Per-trip energy | No public per-trip energy property found. | EVSuite trip totals remain derived from validated samples. |
The search deliberately stops here: sweeping arbitrary property ids would produce ambiguous data
and broaden the runtime surface without provenance.
## Probe wiring
`EV_CURRENT_BATTERY_CAPACITY` is read through the same status-aware reflected
`CarPropertyManager.getProperty` path as existing telemetry. The read is explicitly branched over
the supported firmware generations and returns null for unknown firmware, unavailable status,
negative values, exceptions and non-finite values. It is exposed only by
`EnergyTelemetryReader.readEvidence()` and recorded as
`candidate.evCurrentBatteryCapacityWh`; ordinary `read()` and `EnergySnapshot` are unchanged.
+161
View File
@@ -0,0 +1,161 @@
<!-- GENERATED by FirmwareMatrix.render(); do not edit by hand. -->
### Conditions
| Entry | SWI133 | SWI68 | SWI165 | SWI69 | SWI131 | SWI132 |
|---|:--:|:--:|:--:|:--:|:--:|:--:|
| BT_DEVICE_CONNECTED | — | — | — | — | — | — |
| ANY_BT_CONNECTED | — | — | — | — | — | — |
| BT_DEVICE_ONBOARD | — | — | — | — | — | — |
| BT_DEVICE_HANDSFREE | — | — | — | — | — | — |
| TIME_OF_DAY | — | — | — | — | — | — |
| DAY_OF_WEEK | — | — | — | — | — | — |
| DATE | — | — | — | — | — | — |
| FIRMWARE_GEN | — | — | — | — | — | — |
| LOCATION_WITHIN | — | — | — | — | — | — |
| PHYSICAL_BUTTON | — | — | — | — | — | — |
| WIFI_SSID | — | — | — | — | — | — |
| IN_CALL | — | — | — | — | — | — |
| DRIVE_DURATION | — | — | — | — | — | — |
| RANDOM_CHANCE | — | — | — | — | — | — |
| WEATHER_NOW | — | — | — | — | — | — |
| ODOMETER | — | — | — | — | — | — |
| MEDIA_PLAYING | — | — | — | — | — | — |
| DATA_USED_TODAY | — | — | — | — | — | — |
| DATA_USED_MONTH | — | — | — | — | — | — |
| OUTSIDE_TEMP | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| IGNITION_STATE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| IN_PARK | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| DRIVE_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| REGEN_LEVEL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| ENERGY_SAVING | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| BATTERY_LEVEL | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| CHARGING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| CHARGE_LIMIT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| CHARGING_STATUS | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| CHARGE_SCHEDULE_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| CHARGE_WINDOW_START | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| CHARGE_WINDOW_STOP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| RANGE_ESTIMATE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| BATTERY_PREHEAT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| CLIMATE_ON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| AC_ON | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| HVAC_AUTO | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| RECIRC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| FAN_SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| TEMPERATURE_SET | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| ECON_MODE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| FRONT_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| REAR_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| WINDOW_OPEN | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| WINDOW_POSITION | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| WINDOW_DRIVER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| WINDOW_PASSENGER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| WINDOW_REAR_LEFT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| WINDOW_REAR_RIGHT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| DOORS_LOCKED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| FRONT_DOOR_OPEN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| RADIO_PLAYING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| AEB_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| AEB_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| ELK_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| ELK_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| ACC_TJA_MODE | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| LIMITER_MODE | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| TSR | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| OVERSPEED_ALARM | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| SPEED_LIMIT_TONE | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| SOUND_WARNING | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
### Actions
| Entry | SWI133 | SWI68 | SWI165 | SWI69 | SWI131 | SWI132 |
|---|:--:|:--:|:--:|:--:|:--:|:--:|
| LAUNCH_APP | — | — | — | — | — | — |
| SHOW_NOTIFICATION | — | — | — | — | — | — |
| SPEAK_TEXT | — | — | — | — | — | — |
| NAVIGATE_TO | — | — | — | — | — | — |
| ASK_CONFIRM | — | — | — | — | — | — |
| SEND_SMS | — | — | — | — | — | — |
| ENABLE_RULE | — | — | — | — | — | — |
| DISABLE_RULE | — | — | — | — | — | — |
| MEDIA_CONTROL | — | — | — | — | — | — |
| SET_BLUETOOTH | — | — | — | — | — | — |
| SET_WIFI | — | — | — | — | — | — |
| WEBHOOK | — | — | — | — | — | — |
| DELAY | — | — | — | — | — | — |
| APPLY_PROFILE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SHOW_PROFILE_PICKER | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_DRIVE_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_REGEN_LEVEL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_ONE_PEDAL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_ENERGY_SAVING | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_CLIMATE_POWER | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_CABIN_TEMP | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_AC | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_ECON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CLIMATE_AUTO | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_RECIRCULATION | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_FAN_LEVEL | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_FRONT_DEFROST | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_REAR_DEFROST | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_WINDOWS ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_WINDOW_DRIVER ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_WINDOW_PASSENGER ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_WINDOW_REAR_LEFT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_WINDOW_REAR_RIGHT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_DOOR_LOCK | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_LIMIT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGING_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_SCHEDULE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_WINDOW | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_BATTERY_PREHEAT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| ADJUST_MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_AUDIO_BALANCE | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| SET_AUDIO_FADER | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| SET_TONE_CONTROL | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| SET_BOSE_SOUND_TYPE | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| SET_3D_EFFECT | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| SET_SPEED_VOLUME | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| PLAY_RADIO | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| TUNE_RADIO | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| RADIO_NEXT_STATION | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| RADIO_PREV_STATION | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| PAUSE_RADIO | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| RADIO_PLAY_PAUSE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| OPEN_RADIO_SCREEN | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_AEB_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_AEB_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_ESC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_DROWSINESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_DROWSINESS_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_ELK_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_ELK_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_ACC_TJA_MODE | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_LIMITER_MODE | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_TSR | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_OVERSPEED_ALARM | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| SET_SPEED_LIMIT_TONE | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| SET_SOUND_WARNING | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_LAS_WARNING_SOUND | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| SET_LAS_WARNING_VIBRATION | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| CALL_NUMBER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| CALL_CONTACT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
⚠️ — the firmware carries the property, but nothing establishes that writing it has any effect (`ActionType.writeProven`). These actions are hidden from the editors and refused by the executors until one is observed to work.
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="120" viewBox="0 0 480 120" role="img" aria-label="EVHardware">
<rect x="1" y="1" width="478" height="118" rx="16" fill="#12161a" stroke="#232a31" stroke-width="2"/>
<!-- icon: shared launcher style — vehicle hub with four connections -->
<g transform="translate(31,27)" fill="none" stroke="#F4F7FA" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="32" cy="32" r="16"/>
<path d="M32 2V12 M32 52V62 M2 32H12 M52 32H62"/>
</g>
<circle cx="63" cy="59" r="5" fill="#E0424C"/>
<text x="128" y="56" font-family="'Segoe UI',Roboto,Helvetica,Arial,sans-serif" font-size="34" font-weight="700" fill="#eef2f5">EV<tspan fill="#E0424C">Hardware</tspan></text>
<text x="129" y="82" font-family="'Segoe UI',Roboto,Helvetica,Arial,sans-serif" font-size="15" fill="#9aa4ad">Shared vehicle-access layer · safety gate · catalogue</text>
<text x="129" y="101" font-family="'Segoe UI',Roboto,Helvetica,Arial,sans-serif" font-size="12" fill="#5b6670">SAIC MG4 · AAOS 9 · MT2712</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+102
View File
@@ -0,0 +1,102 @@
# Radio action gap — community references vs the EVSuite catalogue
Decides every radio candidate raised by the 2026-08-28 community baseline (CR-020). The
comparison is against what `SaicRadio`, `SaicMediaPlayer` and `ActionType` already do, not
against the initial community audit, which under-counted the existing catalogue.
**Provenance.** CarMediaPlayer (MIT) and the community DAB+ fix (no explicit license at the
baseline review, and it replaces an OEM application) are read as *behaviour* only. No code,
resource or replacement strategy from either is used here. What the head unit's own radio
app does is the evidence that matters, and the binding contract below comes from the vendor
SDK already documented in `SaicMedia.kt`.
## What already existed
| Capability | Where | Reachable from a rule |
| --- | --- | --- |
| Play / resume the tuner (`srcPlayRadio`, tx 27) | `SaicRadio.play` | `PLAY_RADIO` |
| Tune FM/AM by frequency (tx 8) | `SaicRadio.tune` | `TUNE_RADIO` |
| Next / previous station (tx 13 / 14) | `SaicRadio.nextStation` / `previousStation` | `RADIO_NEXT_STATION`, `RADIO_PREV_STATION` |
| Read the tuner's play state (tx 19, `RadioBean.state`) | `SaicRadio.isPlaying` | — (internal) |
| Pause / silence the tuner (`srcPauseRadio`, tx 28) | `SaicRadio.pause` | — (internal) |
| Open the radio screen (tx 26) | `SaicRadio.openScreen` | — (internal) |
| Play/pause of *whatever owns the audio* | `SaicMediaPlayer.command` | `MEDIA_CONTROL` |
Three primitives existed in the library with no way to ask for them from a rule. That, not
a missing service call, is the actual gap.
## Decisions
| # | Candidate | Decision | Why |
| --- | --- | --- | --- |
| A | Pause / stop the radio | **add** — `PAUSE_RADIO` | `srcPauseRadio` is implemented and unreachable. `MEDIA_CONTROL` is not a substitute: it addresses whichever source owns the audio, so it silences Bluetooth when Bluetooth is playing. "Silence the tuner" was not expressible. |
| B | Explicit radio play/pause toggle on readable state | **add** — `RADIO_PLAY_PAUSE` | One shortcut for a driver who wants the tuner specifically, whatever the current source. Built on `isPlaying()` (`RadioBean.state`), because `AudioManager.isMusicActive` is **false while the radio plays** — a toggle driven by it would send "play" to a playing radio forever. Unknown state fails closed (see below). |
| C | Open the radio screen | **add, gated** — `OPEN_RADIO_SCREEN` | `startRadioActivity` is implemented and unreachable. It is the one radio action that is not audio-only: throwing a full-screen app in front of a driver at speed is a distraction, so it takes the standstill gate and is refused when speed is unreadable. |
| D | Select a band | **added** (2026-08-29, reversed), **merged into `TUNE_RADIO`** (2026-09-03) | Originally rejected because the binding contract exposes no band-only call. The owner asked for DAB support anyway, and the rejection turned out to be answering the wrong question: a band action does not need a band-only *call*, because `tune(band, frequencyKhz)` already carries the band as its first argument. `SELECT_RADIO_BAND` composes what is established — read the current band from `RadioBean`, tune into the requested one — and guesses no transaction code. The frequency it lands on is the station this car was last heard on for that band (`RadioBandMemory`), or the band floor followed by one `nextStation()` when it has never been there, so it does not strand the driver on an empty frequency. It shipped as its own `SELECT_RADIO_BAND` entry and was folded into `TUNE_RADIO` a week later — see *Merge* below. |
| E | Select a DAB service | **reject** (unchanged) | A DAB service is addressed by ensemble id + service id, not by a frequency, and neither is a stable identifier a driver could enter or a rule could store across a re-scan. No on-vehicle evidence establishes a portable contract. DAB is now reachable as a **band** (candidate D, inside `TUNE_RADIO`) and stepped through with `RADIO_NEXT_STATION` / `RADIO_PREV_STATION`; what stays out is selecting one named service directly. A DAB *block* is a real frequency in Band III, which is what a band switch tunes; a DAB *service* is not. |
| F | Restore the last audible radio state at ignition | **already covered** | Composition, not a new action: an ignition trigger plus `PLAY_RADIO`, whose `srcPlayRadio` resumes the last station by itself. A persistent service that remembered and re-asserted state would be a background watchdog the app does not have and does not want. |
| G | Radio artwork caching, station metadata display | **reject as an action** | Presentation, not automation. A rule cannot usefully "cache artwork"; a screen can show it. If it is ever wanted it belongs to an app's UI, reading `RadioBean`, not to the action catalogue. |
| H | Replace or patch the OEM radio APK (the DAB+ fix's strategy) | **reject** | Out of scope by the ticket and by the suite's boundary: EVSuite talks to the vendor services the car ships. It does not swap the car's applications, and the upstream fix carries no license that would permit reuse anyway. |
## Failure behaviour of what was added
- **Missing service.** All three call `SaicRadio`, whose `service.binder()` is null when the
radio service is absent or its bind has not landed. Every call reports false; the executor
turns that into an error rather than a silent success. EVTasker's diagnostic screen answers
`NO_VENDOR_SERVICE` for the whole radio family — including `RADIO_NEXT_STATION` and
`RADIO_PREV_STATION`, which were answering for the AOSP car layer instead.
- **Unreadable play state.** `RADIO_PLAY_PAUSE` reads `isPlaying()` first and sends
**nothing** when it answers null: `ToggleResult.STATE_UNKNOWN`, reported as an error with
its reason. It does not fall back to "assume playing" the way `SaicMediaPlayer.radioCommand`
does — that fallback is right for a media-key shortcut a driver can press twice, and wrong
for a rule that fires unattended and would toggle the audio to the state nobody chose.
- **Unsupported firmware.** `@SupportedOn(SWI68, SWI165)` on all three, the generations where
the radio service is bound. The A9 generations (SWI69, SWI131, SWI132) have no such service;
`ActionCompatibility` refuses the action there before it is attempted, and the editor does
not offer it.
## Reversal, 2026-08-29
Candidate D was reopened at the owner's request after an on-vehicle diagnostic (SWI68). The
original rejection assumed a band action required a band-only transaction, and that assumption
was wrong: `tune` takes the band as an argument, so the band is reachable through a call that
was already proven. The rejection of candidate E stands unchanged — a DAB service still has no
identifier stable across a re-scan, and none was guessed.
## Merge, 2026-09-03
Candidate D shipped as an action of its own, and that was one entry too many. Band, frequency
and playback are three parts of one instruction — "put the radio on FM 103.5 and play it" — and
splitting them left each part unable to say the whole thing: `SELECT_RADIO_BAND` could not name
a station, `TUNE_RADIO` could not reach DAB, and a driver who wanted both wrote two rows with a
delay between them. They are now one entry, `TUNE_RADIO`, carrying the band in `number`, the
frequency in `text` and playback in `flag`.
Nothing about the vehicle contract changed: a frequency still goes through `tune`, a band with
no frequency still goes through `selectBand`, and DAB is still reachable only as a band. What
changed is which of the two a rule asks for, and that is now read off the rule rather than off
which of two entries it picked. `selectBand` gained `andPlay` so that the merged action's
"enable radio" switch means the same thing on both paths.
Two things are refused rather than resolved. A typed frequency that disagrees with the picked
band (`AM 103.5` under an FM picker) parses to nothing, as `AM 103.5` already did on its own —
two things were said, and silently keeping one is how a rule lands on a station nobody chose.
And DAB takes no frequency at all: the editor removes the field rather than accept a number it
would have to ignore.
Rules and exported files that still name `SELECT_RADIO_BAND` are rewritten on load by
EVTasker's `LegacyRuleJson`, keeping their band and their playback — the old band switch always
took the audio focus, so dropping playback would have silenced a rule that used to make a
sound.
## On-vehicle evidence
_(pending — CR-020 requires a run. Record, per firmware: `SaicRadio.isAvailable`, whether
`isPlaying()` answered on FM, AM and DAB, whether `PAUSE_RADIO` silenced the tuner, whether
`RADIO_PLAY_PAUSE` toggled in the direction the state predicted, whether
`OPEN_RADIO_SCREEN` was correctly refused while moving, and whether `TUNE_RADIO` reached **DAB**
with its frequency left empty and came back to FM on the station it had left.)_
Availability is evidenced on SWI68: the 2026-08-29 diagnostic reported every radio action
`[ OK ]`, with the radio service bound. What is still missing is the behavioural half — that
each one does what its label says.
+2
View File
@@ -0,0 +1,2 @@
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+25
View File
@@ -0,0 +1,25 @@
# Aligned with EVProfile / EVTasker: the three share signing and this shared library,
# so there is no reason for the toolchains to diverge.
[versions]
agp = "9.1.1"
kotlin = "2.0.21"
coreKtx = "1.13.1"
annotation = "1.8.0"
gson = "2.10.1"
coroutines = "1.8.1"
junit = "4.13.2"
robolectric = "4.14.1"
androidxTestCore = "1.6.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
[plugins]
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+92
View File
@@ -0,0 +1,92 @@
// AGP 9 provides built-in Kotlin compilation, so no separate Kotlin plugin is applied.
// Plugin ids and dependency coordinates are hardcoded rather than pulled from a version
// catalog: this module is included as a subproject by multiple app repos, and must not
// depend on any of them defining the same catalog aliases.
plugins {
id("com.android.library")
// Publishes a release AAR (GitHub Packages / mavenLocal) so EXTERNAL projects can
// depend on EVHardware as a binary. The EVSuite apps keep the git-submodule
// source dependency; the AAR is purely for outside consumers.
id("maven-publish")
}
// Coordinates for the published AAR. Version tracks git tags in CI (see publish workflow);
// the default here is for local publishing.
group = "com.evsuite"
version = System.getenv("EVHARDWARE_VERSION") ?: "0.1.0-SNAPSHOT"
android {
namespace = "com.evsuite.hardware"
compileSdk = 36
defaultConfig {
minSdk = 27
consumerProguardFiles("consumer-rules.pro")
}
// Required for maven-publish to expose a component from an Android library.
publishing {
singleVariant("release") {
withSourcesJar()
}
}
buildFeatures {
// The catalogue's string labels (cond_*/act_*) resolve against this library's own R,
// which is why EVHardware is a real Android library and not a shared source set.
resValues = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
// AGP builds the "release" component only after evaluation, so wire the publication there.
publishing {
publications {
register<MavenPublication>("release") {
groupId = "com.evsuite"
artifactId = "evhardware"
afterEvaluate { from(components["release"]) }
}
}
repositories {
// GitHub Packages when the CI env vars are present; otherwise ./gradlew
// :lib:publishToMavenLocal works for local/external experimentation.
val ghUser = System.getenv("GITHUB_ACTOR")
val ghToken = System.getenv("GITHUB_TOKEN")
if (ghUser != null && ghToken != null) {
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/malys/EVHardware")
credentials { username = ghUser; password = ghToken }
}
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.annotation:annotation:1.8.0")
implementation("com.google.code.gson:gson:2.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.14.1")
testImplementation("androidx.test:core:1.6.1")
}
+24
View File
@@ -0,0 +1,24 @@
# Rules applied to any app that consumes EVHardware. The library is reflection-heavy on
# both sides — vehicle access reflects android.car, and the catalogue is read by name.
# EVHardware reflects android.car / SAIC SDK / ServiceManager targets by name.
-keep class com.evsuite.hardware.EVHardware { *; }
-keep class android.car.** { *; }
-keep class com.saicmotor.** { *; }
# Serialized models (Gson) — fields and constructors read reflectively; keep generic
# signatures so TypeToken<List<DrivingProfile>> does not decay to LinkedTreeMap.
-keep class com.evsuite.hardware.model.** { *; }
-keepclassmembers class com.evsuite.hardware.model.** { <fields>; <init>(...); }
-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*
# Catalogue enums: FirmwareSupport reads @SupportedOn off the enum fields by reflection,
# and enums are persisted by name — obfuscating either breaks the firmware filter and any
# stored rules.
-keep class com.evsuite.hardware.catalog.** { *; }
-keep class com.evsuite.hardware.FirmwareGen { *; }
-keep @interface com.evsuite.hardware.SupportedOn
-keepclassmembers enum com.evsuite.hardware.** {
public static **[] values();
public static ** valueOf(java.lang.String);
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,191 @@
package com.evsuite.hardware
import android.os.IBinder
/**
* Climate on the A9 platform (SWI69, SWI131, SWI132), where the vehicle-settings SDK is absent.
*
* `SaicClimate` talks to `IAirConditionService` through the vendor hub. That hub does not
* exist on A9 — which is why every climate action shipped marked SWI68/SWI165 only, on cars
* whose own HVAC screen works perfectly well. It works through `carapi` instead:
* `queryClient(0x7)` hands back the HVAC binder and `CarHvacClient` wraps it, exactly as
* `queryClient(0x8)` already builds the vehicle-settings client in [EVHardware].
*
* Reflection rather than raw AIDL here, unlike the rest of `saic`: the transaction codes of
* `ICarHvacService` are not established anywhere, while the client class' method names are —
* they are what the launcher calls. Guessing a code writes to the wrong signal; naming a
* method that does not exist fails loudly and reads as unsupported.
*
* ## Switches, not assignments
*
* Several A9 settings are `switch…()` with no argument: they step to the next state. Aiming at
* one is therefore read-step-read until it matches, the same shape as the seat heating, and
* bounded so a firmware where the call does nothing gives up instead of looping. That makes
* every such write **blocking** — up to a second or so — so none of this belongs on the main
* thread.
*/
internal object A9Climate {
private const val TAG = "EV_CLIMATE_A9"
private const val SERVICE_CODE = 0x7
private const val CLIENT_CLASS = "com.saicmotor.carapi.client.CarHvacClient"
/** Time given to the HVAC controller to apply one step before the state is read back. */
private const val STEP_SETTLE_MS = 400L
@Volatile
private var client: Any? = null
/** True on the generations whose climate can only be reached this way. */
val isPlatform: Boolean
get() = when (FirmwareInfo.getGeneration()) {
FirmwareInfo.Gen.SWI69, FirmwareInfo.Gen.SWI131, FirmwareInfo.Gen.SWI132 -> true
else -> false
}
val isAvailable: Boolean get() = isPlatform && client() != null
private fun client(): Any? {
client?.let { return it }
val loader = EVHardware.a9ClassLoader() ?: return null
val binder: IBinder = EVHardware.a9ClientBinder(SERVICE_CODE) ?: return null
return try {
loader.loadClass(CLIENT_CLASS)
.getConstructor(IBinder::class.java)
.newInstance(binder)
.also {
client = it
AppLogger.i(TAG, "CarHvacClient obtained via queryClient(0x7) ✓")
}
} catch (e: Exception) {
AppLogger.d(TAG, "CarHvacClient unavailable: ${(e.cause ?: e).message}")
null
}
}
// ── Reads ────────────────────────────────────────────────────────────────
fun powerOn(): Boolean? = get("getHvacPowerStatus") as? Boolean
fun acOn(): Boolean? = get("getACStatus") as? Boolean
fun autoOn(): Boolean? = get("getAutoStatus") as? Boolean
fun frontDefrostOn(): Boolean? = get("getFrontDefrostStatus") as? Boolean
fun rearDefrostOn(): Boolean? = get("getRearDefrostStatus") as? Boolean
fun fanLevel(): Int? = (get("getFanSpeed") as? Int)?.takeIf { it >= 0 }
/** 0 outside air, 1 recirculated, 2 auto — the same encoding the property carries. */
fun loopMode(): Int? = (get("getAirCirculationStatus") as? Int)?.takeIf { it >= 0 }
fun recirculationOn(): Boolean? = loopMode()?.let { it == LOOP_INNER }
/** A float on this platform, where the old SDK answered whole degrees. */
fun driverTemp(): Int? =
(get("getDriverTemperature") as? Float)?.takeIf { !it.isNaN() && it > 0f }?.toInt()
// ── Writes ───────────────────────────────────────────────────────────────
fun setPower(on: Boolean): Boolean =
cycleTo("power", "getHvacPowerStatus", if (on) 1 else 0, maxSteps = 2, advance = "switchHvacPowerStatus")
fun setAc(on: Boolean): Boolean =
cycleTo("ac", "getACStatus", if (on) 1 else 0, maxSteps = 2, advance = "switchACStatus")
fun setAuto(on: Boolean): Boolean =
cycleTo("auto", "getAutoStatus", if (on) 1 else 0, maxSteps = 2, advance = "switchAutoStatus")
fun setFrontDefrost(on: Boolean): Boolean =
cycleTo("frontDefrost", "getFrontDefrostStatus", if (on) 1 else 0, 2, "switchFrontDefrostStatus")
fun setRearDefrost(on: Boolean): Boolean =
cycleTo("rearDefrost", "getRearDefrostStatus", if (on) 1 else 0, 2, "switchRearDefrostStatus")
/** Three modes, so two steps reach any of them from any other. */
fun setLoopMode(target: Int): Boolean {
if (target !in LOOP_OUTSIDE..LOOP_AUTO) return false
return cycleTo("loopMode", "getAirCirculationStatus", target, 3, "switchAirCirculationStatus")
}
fun setRecirculation(on: Boolean): Boolean = setLoopMode(if (on) LOOP_INNER else LOOP_OUTSIDE)
fun setFanLevel(level: Int): Boolean = set("setFanSpeed", level)
fun setDriverTemp(celsius: Int): Boolean = set("setDriverTemperature", celsius.toFloat())
const val LOOP_OUTSIDE = 0
const val LOOP_INNER = 1
const val LOOP_AUTO = 2
// ── Reflection plumbing ──────────────────────────────────────────────────
private fun get(method: String): Any? {
val c = client() ?: return null
return try {
c.javaClass.getMethod(method).invoke(c)
} catch (e: Exception) {
AppLogger.d(TAG, "$method(): ${(e.cause ?: e).message}")
null
}
}
private fun call(method: String): Boolean {
val c = client() ?: return false
return try {
c.javaClass.getMethod(method).invoke(c)
true
} catch (e: Exception) {
AppLogger.w(TAG, "$method(): ${(e.cause ?: e).message}")
false
}
}
private fun set(method: String, value: Any): Boolean {
val c = client() ?: return false
val type = if (value is Float) Float::class.javaPrimitiveType else Int::class.javaPrimitiveType
return try {
c.javaClass.getMethod(method, type).invoke(c, value)
AppLogger.i(TAG, "$method($value) ✓")
true
} catch (e: Exception) {
AppLogger.w(TAG, "$method($value): ${(e.cause ?: e).message}")
false
}
}
/**
* Steps [advance] until [getter] reads [target].
*
* Bounded, because a firmware where the switch does nothing would otherwise spin forever;
* and an unreadable state stops it at once rather than stepping blind, which on a
* three-way switch would leave the setting somewhere nobody asked for.
*/
private fun cycleTo(
label: String,
getter: String,
target: Int,
maxSteps: Int,
advance: String,
sleep: (Long) -> Unit = { Thread.sleep(it) },
): Boolean {
var steps = 0
while (steps <= maxSteps) {
val current = when (val v = get(getter)) {
is Boolean -> if (v) 1 else 0
is Int -> v
else -> -1
}
if (current == target) {
AppLogger.i(TAG, "$label = $target reached in $steps step(s)")
return true
}
if (current < 0) {
AppLogger.w(TAG, "$label: state unreadable — not stepping blind")
return false
}
if (!call(advance)) return false
steps++
sleep(STEP_SETTLE_MS)
}
AppLogger.w(TAG, "$label = $target not reached in $maxSteps step(s)")
return false
}
}
@@ -0,0 +1,118 @@
package com.evsuite.hardware
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.CopyOnWriteArrayList
/**
* In-app log buffer — mirrors every Log.* call to an in-memory ring buffer
* so the ConsoleFragment can display them without ADB.
*
* The buffer is a lock-guarded ArrayDeque: the previous CopyOnWriteArrayList copied all
* 400 entries twice per log line, on the calling thread — i.e. while a profile was being
* applied. Listeners are notified off the hot path.
*/
object AppLogger {
enum class Level { DEBUG, INFO, WARN, ERROR }
data class Entry(
val time: String,
val tag: String,
val level: Level,
val msg: String
)
private const val MAX_ENTRIES = 400
private val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
private val lock = Any()
private val buffer = ArrayDeque<Entry>(MAX_ENTRIES)
/**
* Total entries added since process start — eviction does not decrement it. Lets the UI
* tell what is new without comparing sizes (size stops moving once the buffer is full).
*/
@Volatile
var totalCount: Long = 0L
private set
/** Instant snapshot of the buffer, oldest to newest entry. */
val entries: List<Entry>
get() = synchronized(lock) { buffer.toList() }
/** Whole buffer as text, for sharing or attaching to a crash report. */
fun dump(): String = entries.joinToString("\n") { "[${it.time}] ${it.level}/${it.tag}: ${it.msg}" }
private val listeners = CopyOnWriteArrayList<() -> Unit>()
private val mainHandler = Handler(Looper.getMainLooper())
/** Notification already scheduled: a burst of logs yields a single UI wake-up. */
@Volatile
private var notifyPending = false
// ---- Public log methods (mirror android.util.Log) ----
fun d(tag: String, msg: String) { add(tag, Level.DEBUG, msg); Log.d(tag, msg) }
fun i(tag: String, msg: String) { add(tag, Level.INFO, msg); Log.i(tag, msg) }
fun w(tag: String, msg: String) { add(tag, Level.WARN, msg); Log.w(tag, msg) }
fun e(tag: String, msg: String) { add(tag, Level.ERROR, msg); Log.e(tag, msg) }
fun clear() {
synchronized(lock) {
buffer.clear()
totalCount = 0L
}
notifyListeners()
}
/**
* Entries added since the caller last saw [sinceTotal], or null if the missing entries
* were already evicted — in which case the caller must redraw everything.
*/
fun entriesSince(sinceTotal: Long): List<Entry>? = synchronized(lock) {
val missing = totalCount - sinceTotal
when {
missing < 0L -> null // buffer cleared in the meantime
missing == 0L -> emptyList()
missing > buffer.size -> null // too late: entries evicted
else -> buffer.toList().takeLast(missing.toInt())
}
}
// ---- Listener for live UI updates ----
fun addListener(l: () -> Unit) { listeners.add(l) }
fun removeListener(l: () -> Unit) { listeners.remove(l) }
// ---- Internal ----
private fun add(tag: String, level: Level, msg: String) {
val entry = Entry(sdf.format(Date()), tag, level, msg)
synchronized(lock) {
// while, not if: the cap holds even under concurrent adds.
while (buffer.size >= MAX_ENTRIES) buffer.removeFirst()
buffer.addLast(entry)
totalCount++
}
notifyListeners()
}
/**
* Wakes the UI on the main thread, at most once per burst: the notification must not
* run on the logging thread.
*/
private fun notifyListeners() {
if (listeners.isEmpty() || notifyPending) return
notifyPending = true
mainHandler.post {
notifyPending = false
listeners.forEach { runCatching { it.invoke() } }
}
}
}
@@ -0,0 +1,183 @@
package com.evsuite.hardware
/** Exact interpretation used when a trip integrates a validated battery-power signal. */
data class BatteryPowerEvidence(
val firmware: FirmwareInfo.Gen,
val conversionVersion: Int,
) {
init {
require(firmware != FirmwareInfo.Gen.UNKNOWN) { "power evidence names a known firmware" }
require(conversionVersion > 0) { "power conversion version is positive" }
}
companion object {
/** EV_INSTANTANEOUS_CHARGE_RATE mW × -1e-6: battery output positive. */
const val OUTPUT_POSITIVE_MW_V1 = 1
/**
* `BMS_PACK_VOL` × `BMS_PACK_CRNT` ÷ 1000, discharge positive — the pack pair, not a
* property a drive has confirmed.
*
* A separate version because it is a separate interpretation. A model trained on this
* must not be mistaken for one trained on a validated signal, and must be thrown away
* the day one arrives.
*/
const val PACK_PAIR_DERIVED_V2 = 2
}
}
/**
* What a `CarPropertyValue` proves, and what it only appears to prove.
*
* `CarPropertyManager.getFloatProperty` hands back a bare number. A generation that declares
* a property in its VHAL and never publishes a value for it answers that call with a zero
* shaped exactly like a measured zero — which is how an MG4 reporting 31 °C outside came to
* show a 0 °C traction battery on the dashboard, and a 0 kW pack current next to it.
*
* `getProperty` answers with the value *and* the status the VHAL attaches to it. Only
* [STATUS_AVAILABLE] means the vehicle stands behind the number; the other two states are the
* platform saying it has nothing to offer, and this library's contract is that "nothing" stays
* null all the way to the screen instead of arriving there as a zero.
*
* The rule lives apart from the reflection in [EVHardware] so it can be tested on the JVM:
* `android.car` is not on the compile classpath and cannot be exercised by a unit test.
*/
object CarPropertyEvidence {
/** Signals whose scale and semantics require per-firmware vehicle evidence. */
enum class Signal {
BATTERY_POWER_KW,
BATTERY_TEMPERATURE_CELSIUS,
}
/** `CarPropertyValue.STATUS_AVAILABLE` — the vehicle stands behind this value. */
const val STATUS_AVAILABLE = 0
/** `CarPropertyValue.STATUS_UNAVAILABLE` — the property is declared but not published. */
const val STATUS_UNAVAILABLE = 1
/** `CarPropertyValue.STATUS_ERROR` — the vehicle reports the signal itself as faulty. */
const val STATUS_ERROR = 2
/**
* The value when the vehicle stands behind it, null otherwise.
*
* The publication timestamp is deliberately not part of the decision. Some properties are
* published once and never again — a battery capacity is the same number at every sample —
* so an age rule would discard perfectly good readings. The timestamp is carried into the
* diagnostics report instead, where a person reads it against the rest of the picture.
*/
fun accept(value: Any?, status: Int): Any? = value?.takeIf { status == STATUS_AVAILABLE }
fun describe(status: Int): String = when (status) {
STATUS_AVAILABLE -> "available"
STATUS_UNAVAILABLE -> "unavailable"
STATUS_ERROR -> "error"
else -> "status $status"
}
/**
* True only after CP-003 has captured and reviewed this signal on this exact generation.
*
* The catalogue deliberately starts empty. A public property declaration and a finite
* runtime value do not prove the scale or sign on an MG4, and evidence from one generation
* must never be generalized to another. CP-003 adds generations here one by one.
*/
fun isValidated(signal: Signal, firmware: FirmwareInfo.Gen): Boolean = when (signal) {
Signal.BATTERY_POWER_KW -> batteryPowerEvidence(firmware) != null
Signal.BATTERY_TEMPERATURE_CELSIUS -> firmware in batteryTemperatureFirmware
}
/** Null until CP-003 proves the exact generation and conversion. */
fun batteryPowerEvidence(firmware: FirmwareInfo.Gen): BatteryPowerEvidence? =
batteryPowerEvidenceByFirmware[firmware]
private val batteryPowerEvidenceByFirmware: Map<FirmwareInfo.Gen, BatteryPowerEvidence> =
emptyMap()
/**
* What a model may be trained on — a different question from what a screen may call measured.
*
* [batteryPowerEvidence] stays empty until a drive proves a conversion, and that is what
* keeps the power figure on the dashboard labelled derived rather than measured. But on
* SWI68 that figure is pack volts times pack amps, off the same vendor block as the state of
* charge this app already trusts, and the product of two readings is arithmetic rather than
* an interpretation — no vehicle has to prove that watts are volts times amps.
*
* Refusing to train on it left the speed comparison and the model-based energy breakdown
* permanently empty on the only generation this project runs on. An estimate whose
* provenance says what it is made of is a better answer to the driver than a dash that never
* resolves.
*
* The version number is the rail. A stored model carries the evidence that produced it and
* is discarded when the two stop matching, so the day a validated conversion arrives the
* pack-pair model is retrained from scratch instead of quietly surviving underneath it.
*/
fun powerModelEvidence(firmware: FirmwareInfo.Gen): BatteryPowerEvidence? =
batteryPowerEvidence(firmware) ?: packPairEvidenceByFirmware[firmware]
private val packPairEvidenceByFirmware: Map<FirmwareInfo.Gen, BatteryPowerEvidence> = mapOf(
FirmwareInfo.Gen.SWI68 to
BatteryPowerEvidence(FirmwareInfo.Gen.SWI68, BatteryPowerEvidence.PACK_PAIR_DERIVED_V2),
)
/** Exact generations whose battery-temperature unit and semantics CP-003 has proved. */
private val batteryTemperatureFirmware: Set<FirmwareInfo.Gen> = emptySet()
/**
* True where CP-003 observed the property declared by the VHAL and never published.
*
* This is a different answer from "not validated yet", and the difference is what a driver
* reads. An unvalidated signal may start working once someone captures it; one the vehicle
* never publishes will not, and saying "not yet validated" about it promises something that
* is not coming.
*
* A generation is listed only after repeated captures with no reading at all, never after
* a single quiet session.
*/
fun isNeverPublished(signal: Signal, firmware: FirmwareInfo.Gen): Boolean =
firmware in neverPublishedByFirmware[signal].orEmpty()
/**
* SWI68: three diagnostic bundles from SWI68-29958-1300R67 on 2026-09-04, 51 samples
* 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.
*/
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),
)
}
/**
* One line per distinct read failure instead of one per sample.
*
* A dashboard samples every second, and a property the firmware does not answer fails the same
* way every single time. One unreadable speed property wrote two identical lines a second and
* pushed everything else out of the 400-entry buffer, so the diagnostics screen showed that one
* failure and nothing else — the log was busiest exactly when it was least useful. A repeat
* earns a line only when what it says has changed.
*/
object ReadFailureLog {
private val lock = Any()
private val lastReason = HashMap<String, String>()
/** True the first time [key] fails this way, and again whenever the reason changes. */
fun isNew(key: String, reason: String): Boolean = synchronized(lock) {
lastReason.put(key, reason) != reason
}
/** A property that starts answering again must not silence its next failure. */
fun clear(key: String) {
synchronized(lock) { lastReason.remove(key) }
}
fun reset() {
synchronized(lock) { lastReason.clear() }
}
}
@@ -0,0 +1,98 @@
package com.evsuite.hardware
import android.app.usage.NetworkStats
import android.app.usage.NetworkStatsManager
import android.content.Context
import android.net.TrafficStats
import java.util.Calendar
/**
* How much data the head unit's connection has carried.
*
* These are Android's own counters — the kernel counts per interface and `NetworkStatsService`
* keeps the history — so they are the same numbers the Settings screen shows, not a
* measurement of our own.
*
* The catch is the interface. The head unit's modem presents itself as **Ethernet**, and the
* public `querySummaryForDevice(int, …)` only knows how to build MOBILE and WIFI templates: it
* answers zero here, which reads exactly like a car that used no data. The hidden overload
* that takes a `NetworkTemplate` does answer, so that is the one used, by reflection, with
* `TrafficStats` since boot as the fallback.
*/
object DataUsage {
private const val TAG = "EV_DATA"
/** Bytes in and out over a window, with the source that answered — for the diagnostic. */
data class Usage(val rxBytes: Long, val txBytes: Long, val source: String) {
val totalBytes: Long get() = rxBytes + txBytes
}
private fun Calendar.atMidnight(): Calendar = apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
fun startOfDay(): Long = Calendar.getInstance().atMidnight().timeInMillis
/** The first of the current month at midnight — the bound the Settings screen defaults to. */
fun startOfMonth(): Long = Calendar.getInstance().atMidnight()
.apply { set(Calendar.DAY_OF_MONTH, 1) }
.timeInMillis
/**
* Ethernet traffic over the window, or null when no route answered.
*
* Null rather than zero, deliberately: "used nothing" and "could not read" must not look
* alike, or a missing permission passes for a quiet month.
*/
fun ethernet(context: Context, start: Long, end: Long): Usage? {
val manager = try {
context.applicationContext.getSystemService(Context.NETWORK_STATS_SERVICE)
as? NetworkStatsManager
} catch (_: Exception) {
null
} ?: return null
val template = ethernetTemplate() ?: return null
return try {
val method = manager.javaClass.getMethod(
"querySummaryForDevice", template.javaClass,
Long::class.javaPrimitiveType, Long::class.javaPrimitiveType
)
val bucket = method.invoke(manager, template, start, end) as? NetworkStats.Bucket
if (bucket == null) {
AppLogger.w(TAG, "querySummaryForDevice(template) → null bucket")
null
} else {
Usage(bucket.rxBytes, bucket.txBytes, "NetworkStatsManager/Ethernet")
}
} catch (e: Exception) {
AppLogger.w(TAG, "hidden route unavailable: ${e.javaClass.simpleName} ${e.message}")
null
}
}
/** Every interface, but only since the last boot — the fallback that always answers. */
fun sinceBoot(): Usage = Usage(
TrafficStats.getTotalRxBytes().coerceAtLeast(0),
TrafficStats.getTotalTxBytes().coerceAtLeast(0),
"TrafficStats/since boot"
)
/** Megabytes over the window, or null when nothing could be read. */
fun megabytesSince(context: Context, start: Long): Int? =
ethernet(context, start, System.currentTimeMillis())
?.let { (it.totalBytes / 1_048_576L).toInt() }
private fun ethernetTemplate(): Any? = try {
Class.forName("android.net.NetworkTemplate")
.getMethod("buildTemplateEthernet")
.invoke(null)
} catch (e: Exception) {
AppLogger.w(TAG, "buildTemplateEthernet unavailable: ${e.javaClass.simpleName}")
null
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
package com.evsuite.hardware
/**
* MG4 firmware generations, mirroring `FirmwareInfo.Gen` in EVProfile.
*
* The names match the strings EVProfile sends in the snapshot
* ([com.evsuite.hardware.catalog.SnapshotKeys.KEY_FIRMWARE_GEN]), so
* `FirmwareGen.valueOf(snapshotString)` round-trips.
*/
enum class FirmwareGen { SWI133, SWI132, SWI68, SWI69, SWI131, SWI165 }
@@ -0,0 +1,148 @@
package com.evsuite.hardware
import android.content.Context
/**
* Detects the firmware generation from ro.build.mt2712.version.
*
* SWI133 : "SWI133-xxxxx" — ADAS via getMixProperty(0x32), 5 modes, 2 alerts, seat + steering heating
* SWI132 : "SWI132-xxxxx" — ADAS via CarVehicleSettingClient (acc/tja getAccTjaState), alerts via direct
* IVehicleSettingService binder (TX 0x128/0x12a), same UI section as SWI68
* SWI68 : "SWI68-xxxxx" — ADAS via VehicleSettingManager (acc/tja), seat + steering heating
* SWI69 : "SWI69-xxxxx" — ADAS via "new-gen" VehicleSettingManager, no seat/steering heating
* SWI131 : "SWI131-xxxxx" — Same as SWI69 (same package, same API), no seat/steering heating
* SWI165 : "SWI165-xxxxx" — ADAS via VehicleSettingManager (same SDK as SWI68), AEB via setFcwAlarmMode,
* seat + steering heating available
* UNKNOWN : unrecognised firmware — the user can force a compatibility mode
*/
object FirmwareInfo {
enum class Gen { SWI133, SWI132, SWI68, SWI69, SWI131, SWI165, UNKNOWN }
private const val PREF_NAME = "ev_settings"
private const val PREF_FORCED_GEN = "forced_firmware_gen"
@Volatile private var cached: Gen? = null
@Volatile private var detectedString: String? = null
/**
* Call first in MainActivity.onCreate(), before any fragment inflation.
* Reads the forced choice (if any) from SharedPreferences and applies it to the cache.
*/
fun initWithContext(context: Context) {
val forced = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getString(PREF_FORCED_GEN, null) ?: return
cached = runCatching { Gen.valueOf(forced) }.getOrDefault(Gen.UNKNOWN)
}
/** Returns the active generation (forced or auto-detected). */
fun getGeneration(): Gen {
cached?.let { return it }
val version = readProp("ro.build.mt2712.version")
?: readProp("ro.build.version.incremental")
detectedString = version
val gen = generationOf(version)
cached = gen
return gen
}
/** Pure generation parser, shared by detection and the fail-closed write policy. */
internal fun generationOf(version: String?): Gen =
when {
version == null -> Gen.UNKNOWN
version.startsWith("SWI165") -> Gen.SWI165
version.startsWith("SWI133") -> Gen.SWI133
version.startsWith("SWI132") -> Gen.SWI132 // before SWI131 — startsWith("SWI13") would be ambiguous
version.startsWith("SWI131") -> Gen.SWI131
version.startsWith("SWI68") -> Gen.SWI68
version.startsWith("SWI69") -> Gen.SWI69
else -> Gen.UNKNOWN
}
/**
* True only when the firmware string read from the vehicle identifies a supported
* generation. A forced compatibility choice does not turn an unknown vehicle into a
* verified one and therefore cannot open the write gate.
*/
fun isDetectedGenerationSupported(): Boolean =
generationOf(getDetectedString().takeUnless { it == "?" }) != Gen.UNKNOWN
/**
* Raw string read from system properties (e.g. "SWI131-12345-xxx").
* Useful to show the exact version to the user in the warning dialog.
*
* Read here rather than through [getGeneration], which returns early on a forced
* generation and so never reaches the property. Forcing a compatibility mode changes
* which code path runs; it does not change which firmware the head unit is running,
* and that is the one thing this function exists to report.
*/
fun getDetectedString(): String {
detectedString?.let { return it }
val version = readProp("ro.build.mt2712.version")
?: readProp("ro.build.version.incremental")
detectedString = version
return version ?: "?"
}
/**
* Force the compatibility mode manually.
* The choice is persisted in SharedPreferences and survives reboots.
*/
fun forceGeneration(context: Context, gen: Gen) {
cached = gen
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(PREF_FORCED_GEN, gen.name).apply()
}
/**
* Returns true if the compatibility mode was forced manually (as opposed to a
* successful auto-detection).
*/
fun isForced(context: Context): Boolean =
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.contains(PREF_FORCED_GEN)
// ── Capability helpers ────────────────────────────────────────────────────
/**
* SWI69 and SWI131 use the same "new generation" VSM API:
* package com.saicmotor.vehiclesetting.service
* direct-IBinder constructor
* methods: setAccTjaState, setFcwState, setLasWarningSound…
*/
fun isNewGenVsm(): Boolean {
val gen = getGeneration()
return gen == Gen.SWI69 || gen == Gen.SWI131
}
/**
* SWI68, SWI69, SWI131 and SWI165 all use the SAIC VehicleSettingManager for ADAS
* (ACC/TJA) and sound alerts. SWI133 uses VehiclePropertyManager (getMixProperty).
*/
fun isVsmBased(): Boolean {
val gen = getGeneration()
return gen == Gen.SWI68 || gen == Gen.SWI69 || gen == Gen.SWI131 || gen == Gen.SWI132 || gen == Gen.SWI165
}
/**
* SWI133, SWI68 and SWI165 have heated seats and a heated steering wheel.
* SWI69 and SWI131 are Standard/SE trims without that equipment.
*/
fun hasHeatFeatures(): Boolean {
val gen = getGeneration()
return gen == Gen.SWI133 || gen == Gen.SWI68 || gen == Gen.SWI165
}
/**
* SWI165 uses the same VehicleSettingManager SDK as SWI68.
* AEB is controlled by setFcwAlarmMode() (same API as SWI68).
* setAutoEmergencyBraking() exists in the SDK but is not used by the official app.
*/
fun isSWI165(): Boolean = getGeneration() == Gen.SWI165
private fun readProp(key: String): String? = try {
val sp = Class.forName("android.os.SystemProperties")
val get = sp.getMethod("get", String::class.java, String::class.java)
(get.invoke(null, key, "") as? String)?.takeIf { it.isNotBlank() && it != "0" }
} catch (_: Exception) { null }
}
@@ -0,0 +1,61 @@
package com.evsuite.hardware
import com.evsuite.hardware.catalog.ConditionType
import com.evsuite.hardware.catalog.ActionType
/**
* Generates the firmware compatibility matrix from the [SupportedOn] annotations.
*
* This is the single source of truth for the README table: rather than a hand-maintained
* grid that drifts from the code, the table is rendered from the same annotations that
* drive the runtime filter. A test writes it out and checks the README against it.
*
* Entry labels come from resource ids, which are not available on the JVM, so the matrix
* uses the enum constant names — stable identifiers that also appear in the code.
*/
object FirmwareMatrix {
/** Column order for the table. */
private val COLUMNS = listOf(
FirmwareGen.SWI133, FirmwareGen.SWI68, FirmwareGen.SWI165,
FirmwareGen.SWI69, FirmwareGen.SWI131, FirmwareGen.SWI132
)
/** Marks an action the firmware carries but whose write was never shown to do anything. */
private const val UNPROVEN = "⚠️"
fun render(): String = buildString {
appendLine("<!-- GENERATED by FirmwareMatrix.render(); do not edit by hand. -->")
appendLine()
appendLine(section("Conditions", ConditionType.entries.map { it.name to FirmwareSupport.gensOf(it) }))
appendLine()
append(section("Actions", ActionType.entries.map {
it.name + (if (it.writeProven) "" else " $UNPROVEN") to FirmwareSupport.gensOf(it)
}))
// A ✅ says the firmware carries the property, which is not the same as the write
// doing something. Without this the table promises actions the apps refuse to run.
if (ActionType.entries.any { !it.writeProven }) {
appendLine()
appendLine(
"$UNPROVEN — the firmware carries the property, but nothing establishes that " +
"writing it has any effect (`ActionType.writeProven`). These actions are " +
"hidden from the editors and refused by the executors until one is observed " +
"to work."
)
}
}
private fun section(title: String, rows: List<Pair<String, Set<FirmwareGen>?>>): String = buildString {
appendLine("### $title")
appendLine()
appendLine("| Entry | " + COLUMNS.joinToString(" | ") { it.name } + " |")
appendLine("|---|" + COLUMNS.joinToString("") { ":--:|" })
// Firmware-independent entries (no annotation) are listed once, not per column.
rows.filter { it.second == null }.forEach { (name, _) ->
appendLine("| $name | " + COLUMNS.joinToString(" | ") { "—" } + " |")
}
rows.filter { it.second != null }.forEach { (name, gens) ->
val cells = COLUMNS.joinToString(" | ") { if (it in gens!!) "✅" else "❌" }
appendLine("| $name | $cells |")
}
}
}
@@ -0,0 +1,43 @@
package com.evsuite.hardware
import com.evsuite.hardware.catalog.ConditionType
import com.evsuite.hardware.catalog.ActionType
/**
* Reads [SupportedOn] off catalogue entries and answers "does this run on that car?".
*
* The reflection is done once per entry and memoised: enum entries are a closed set, and
* re-reading annotations on every editor keystroke would be waste.
*/
object FirmwareSupport {
private val conditionCache = HashMap<ConditionType, Set<FirmwareGen>?>()
private val actionCache = HashMap<ActionType, Set<FirmwareGen>?>()
/** Supported generations, or null when the entry is firmware-independent (works on all). */
fun gensOf(type: ConditionType): Set<FirmwareGen>? =
conditionCache.getOrPut(type) { read(ConditionType::class.java, type.name) }
fun gensOf(type: ActionType): Set<FirmwareGen>? =
actionCache.getOrPut(type) { read(ActionType::class.java, type.name) }
/**
* Is [type] usable on [gen]? A null [gen] means the firmware is unknown (no bridge, or
* EVProfile did not report it) — in that case nothing is filtered out, because hiding
* entries on a guess would be worse than showing one that later refuses.
*/
fun isSupported(type: ConditionType, gen: FirmwareGen?): Boolean =
supported(gensOf(type), gen)
fun isSupported(type: ActionType, gen: FirmwareGen?): Boolean =
supported(gensOf(type), gen)
/** Parses the snapshot firmware string; null when absent or unrecognised. */
fun parse(gen: String?): FirmwareGen? =
gen?.let { runCatching { FirmwareGen.valueOf(it.uppercase()) }.getOrNull() }
private fun supported(gens: Set<FirmwareGen>?, gen: FirmwareGen?): Boolean =
gens == null || gen == null || gen in gens
private fun <T> read(enumClass: Class<T>, name: String): Set<FirmwareGen>? =
enumClass.getField(name).getAnnotation(SupportedOn::class.java)?.gens?.toSet()
}
@@ -0,0 +1,111 @@
package com.evsuite.hardware
import android.content.Context
import com.evsuite.hardware.catalog.ActionType
/**
* What this car was *observed* to do with a glass command.
*
* [ActionType.writeProven] is a claim about the catalogue: nobody has ever seen one of the
* eight window commands move a window, so the actions ship refused everywhere. That claim is
* true of the project, not of a given car — and it can only ever be settled on a car, by
* sending a command and watching the position read back change.
*
* [GlassProbe] does exactly that, and what it saw is recorded here. From then on this car
* knows which command opens and which closes, and the window actions stop being refused
* **on this car only**: the evidence never leaves it, and it is dropped as soon as the
* firmware generation changes, since a command proven on one generation proves nothing about
* another.
*
* Loaded once at [EVHardware.init] and cached, so the catalogue predicate stays a pure
* property read — the editors and the diagnostic ask it for every entry they draw.
*/
object GlassEvidence {
private const val PREFS = "ev_settings"
private const val KEY_OPEN = "glass_open_command"
private const val KEY_CLOSE = "glass_close_command"
private const val KEY_GEN = "glass_proof_generation"
private const val KEY_AT = "glass_proof_at"
private const val NONE = -1
/** The actions this evidence unlocks — the ones [ActionType.writeProven] holds back. */
val ACTIONS: Set<ActionType> = setOf(
ActionType.SET_WINDOWS,
ActionType.SET_WINDOW_DRIVER,
ActionType.SET_WINDOW_PASSENGER,
ActionType.SET_WINDOW_REAR_LEFT,
ActionType.SET_WINDOW_REAR_RIGHT,
)
@Volatile
private var cached: Proof? = null
/**
* @param generation the firmware the commands were observed on. Evidence from another
* generation is ignored rather than deleted: a car that is rolled back to its previous
* firmware finds its proof again.
*/
data class Proof(
val openCommand: Int,
val closeCommand: Int,
val generation: String,
val observedAtMillis: Long,
)
/** The proof for the firmware now running, or null while nothing has been observed. */
val proof: Proof? get() = cached?.takeIf { it.generation == FirmwareInfo.getGeneration().name }
val isProven: Boolean get() = proof != null
fun load(context: Context) {
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val open = prefs.getInt(KEY_OPEN, NONE)
val close = prefs.getInt(KEY_CLOSE, NONE)
val gen = prefs.getString(KEY_GEN, null)
cached = if (open == NONE || close == NONE || gen == null) null else {
Proof(open, close, gen, prefs.getLong(KEY_AT, 0L))
}
AppLogger.i(TAG, "glass evidence: ${cached ?: "none"}")
}
/**
* Records what the probe watched happen. Both directions or nothing: a car that can be
* opened and not closed is worse than one the actions never touch.
*/
fun record(context: Context, openCommand: Int, closeCommand: Int) {
val gen = FirmwareInfo.getGeneration().name
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putInt(KEY_OPEN, openCommand)
.putInt(KEY_CLOSE, closeCommand)
.putString(KEY_GEN, gen)
.putLong(KEY_AT, System.currentTimeMillis())
.apply()
cached = Proof(openCommand, closeCommand, gen, System.currentTimeMillis())
AppLogger.i(TAG, "glass evidence recorded: open=$openCommand close=$closeCommand on $gen")
}
fun clear(context: Context) {
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.remove(KEY_OPEN).remove(KEY_CLOSE).remove(KEY_GEN).remove(KEY_AT)
.apply()
cached = null
}
/** Test seam: sets the cache without touching storage. */
internal fun setForTest(proof: Proof?) { cached = proof }
private const val TAG = "EV_GLASS"
}
/**
* Whether writing this action has been shown to do something *here*.
*
* [ActionType.writeProven] answers it for every car at once, from what the project knows.
* This answers it for the car the code is running on, which is the only place the glass
* question could ever be settled. Everything user-facing — the picker, the diagnostic, the
* executor — asks this one rather than the raw property.
*/
val ActionType.effectProven: Boolean
get() = writeProven || (this in GlassEvidence.ACTIONS && GlassEvidence.isProven)
@@ -0,0 +1,198 @@
package com.evsuite.hardware
import com.evsuite.hardware.saic.SaicVehicleControl
import com.evsuite.hardware.saic.SaicVehicleControl.Window
import kotlin.math.abs
/**
* Finds out which glass command opens a window and which closes it, by trying them.
*
* The window actions ship refused because nothing on the firmware says what the eight
* commands in `0..`[SaicVehicleControl.WINDOW_COMMAND_MAX] do: the vendor service accepts a
* value, drops what it does not like, and reports the same success either way. No amount of
* reading settles it. Sending one and watching the position read back does.
*
* ## What it does
*
* Commands are tried one at a time, with the position read before and after each. Two passes,
* because a single one cannot see both directions: from a closed window only an *opening*
* command moves anything, and a closing command looks identical to a command that does
* nothing. The second pass retries exactly those — the window is open by then, so a closing
* command now shows. It stops as soon as both directions are known.
*
* ## What it refuses
*
* Standstill is re-checked before **every** command, not once at the start: a sweep takes
* half a minute, and a window that opens as the car pulls away is the accident this whole
* gate exists to prevent. An unreadable position ends it too — without the read-back there is
* no evidence, only a command sent into the dark, which is the situation this exists to get
* out of.
*
* It puts the glass back where it found it when it can, and says so when it could not.
*/
object GlassProbe {
/**
* How much the position must change to count as movement, in percent.
*
* The read is a percentage that jitters by a point or so while the motor settles;
* anything smaller than this would let noise pass for proof.
*/
const val MOVEMENT_PERCENT = 3
/** Time given to a window to finish travelling before the position is read back. */
const val SETTLE_MS = 3_000L
/** The car, or a stand-in for it in tests. */
interface Glass {
fun position(window: Window): Int?
fun send(window: Window, command: Int): Boolean
}
/** One command sent, with the position on either side of it. */
data class Attempt(val command: Int, val before: Int, val after: Int) {
val delta: Int get() = after - before
val moved: Boolean get() = abs(delta) >= MOVEMENT_PERCENT
}
enum class Refusal {
/** The car was moving, or its speed was unreadable. */
NOT_STOPPED,
/** Ignition is not in RUN: the windows have no power and prove nothing. */
IGNITION_NOT_RUN,
/** The position stopped answering — no read, no evidence. */
UNREADABLE,
/** Every command was sent and the glass never budged. */
NOTHING_MOVED,
/** One direction found, the other never appeared. */
ONE_DIRECTION_ONLY,
}
data class Result(
val window: Window,
val attempts: List<Attempt>,
val openCommand: Int?,
val closeCommand: Int?,
val refusal: Refusal?,
/** True when the glass ended where the probe found it. */
val restored: Boolean,
) {
val proven: Boolean get() = openCommand != null && closeCommand != null
}
/**
* Runs the sweep on [window]. Blocking, and long — call it off the main thread.
*
* Every dependency is a parameter so the sequence itself can be tested without a car:
* what makes this worth testing is the pass logic and the refusals, not the AIDL call.
*/
fun run(
window: Window,
glass: Glass = VehicleGlass,
stopped: () -> Boolean = { VehicleWriteGate.decideNow() == VehicleWriteGate.Decision.ALLOWED },
// [IgnitionState], not [CarIgnitionItem]: getCurrentIgnitionState() answers on the AAOS
// scale, and the two do not line up — RUN is 2 for Katman5, where 2 is OFF here. Read
// against the wrong one, a car with the ignition on refused the probe as if it were off.
ignitionRun: () -> Boolean = { EVHardware.getCurrentIgnitionState() == EVHardware.IgnitionState.ON },
sleep: (Long) -> Unit = { Thread.sleep(it) },
): Result {
val attempts = mutableListOf<Attempt>()
fun result(open: Int?, close: Int?, refusal: Refusal?, restored: Boolean = false) =
Result(window, attempts.toList(), open, close, refusal, restored)
if (!ignitionRun()) return result(null, null, Refusal.IGNITION_NOT_RUN)
if (!stopped()) return result(null, null, Refusal.NOT_STOPPED)
val start = glass.position(window) ?: return result(null, null, Refusal.UNREADABLE)
var open: Int? = null
var close: Int? = null
outer@ for (pass in 0..1) {
for (command in 0..SaicVehicleControl.WINDOW_COMMAND_MAX) {
if (open != null && close != null) break@outer
// A command already shown to move the glass has nothing left to tell us, and
// re-sending it would only travel the window again.
if (command == open || command == close) continue
if (!stopped()) return result(open, close, Refusal.NOT_STOPPED)
val before = glass.position(window) ?: return result(open, close, Refusal.UNREADABLE)
// A command the service refuses outright is not evidence of anything: it was
// never sent, so it is left out of the record rather than logged as inert.
if (!glass.send(window, command)) continue
sleep(SETTLE_MS)
val after = glass.position(window) ?: return result(open, close, Refusal.UNREADABLE)
val attempt = Attempt(command, before, after)
attempts += attempt
AppLogger.i(
TAG,
"pass $pass ${window.name} command $command: $before% → $after% " +
if (attempt.moved) "(moved ${attempt.delta})" else "(no movement)"
)
when {
attempt.delta >= MOVEMENT_PERCENT -> if (open == null) open = command
attempt.delta <= -MOVEMENT_PERCENT -> if (close == null) close = command
}
}
}
val restored = restore(window, glass, start, close, open, sleep)
val refusal = when {
open != null && close != null -> null
open == null && close == null -> Refusal.NOTHING_MOVED
else -> Refusal.ONE_DIRECTION_ONLY
}
return result(open, close, refusal, restored)
}
/**
* Puts the glass back, when a command in the needed direction is known.
*
* Best effort by nature: the commands travel the window to an end, so "back where it was"
* means closed again after a probe that started closed — which is the normal case, and the
* one that matters. Anything else is reported as not restored rather than papered over,
* because a window left open is something the driver has to be told about.
*/
private fun restore(
window: Window,
glass: Glass,
start: Int,
close: Int?,
open: Int?,
sleep: (Long) -> Unit,
): Boolean {
val now = glass.position(window) ?: return false
if (abs(now - start) < MOVEMENT_PERCENT) return true
val command = if (now > start) close else open
if (command == null) return false
glass.send(window, command)
sleep(SETTLE_MS)
val settled = glass.position(window) ?: return false
return abs(settled - start) < MOVEMENT_PERCENT
}
/**
* How long each command is held down before the position is read back.
*
* Shorter than a close, because the sweep sends up to sixteen of them and the driver is
* sitting through all of it — long enough for a window to travel further than
* [MOVEMENT_PERCENT], which is all the probe has to see.
*/
const val HOLD_MS = 1_500L
/** The real car. */
object VehicleGlass : Glass {
override fun position(window: Window): Int? = SaicVehicleControl.windowPercent(window)
// Held, not sent once: the commands that move this glass are switches rather than
// orders, and a single write travels the window by less than the read can resolve.
// Holding a command that *is* an order costs nothing — it simply arrives again.
override fun send(window: Window, command: Int): Boolean =
SaicVehicleControl.holdWindow(window, command, HOLD_MS)
}
private const val TAG = "EV_GLASS"
}
@@ -0,0 +1,95 @@
package com.evsuite.hardware
import com.evsuite.hardware.catalog.SnapshotKeys
/**
* Decodes `com.saic.keyevent.hardkey.report` payloads.
*
* 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.
*/
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),
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),
VOLUME_DOWN(setOf(25), SnapshotKeys.KEY_VOLUME_DOWN_SHORT, SnapshotKeys.KEY_VOLUME_DOWN_LONG),
MEDIA_NEXT(setOf(87), SnapshotKeys.KEY_MEDIA_NEXT_SHORT, SnapshotKeys.KEY_MEDIA_NEXT_LONG),
MEDIA_PREVIOUS(setOf(88), SnapshotKeys.KEY_MEDIA_PREVIOUS_SHORT, SnapshotKeys.KEY_MEDIA_PREVIOUS_LONG),
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)
}
enum class Press { SHORT, LONG, DOUBLE }
data class Event(val button: Button, val press: Press) {
val value: String get() = "${button.name}:${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>()
/**
* How long a second press has to arrive within to count as a double.
*
* 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>()
/**
* @param atMillis when the event happened. A monotonic default, and JVM-only so the
* decoder stays testable without a device — the caller may pass its own clock.
*/
fun accept(
keyCode: Int,
down: Boolean,
longPress: Boolean,
atMillis: Long = System.nanoTime() / 1_000_000,
): Event? {
val button = Button.entries.firstOrNull { keyCode in it.codes } ?: return null
return when {
down && longPress && states[button] != State.LONG_REPORTED -> {
states[button] = 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)
}
down -> { states.putIfAbsent(button, State.DOWN); null }
states.remove(button) == State.DOWN -> {
val previous = lastShortAt[button]
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)
} else {
lastShortAt[button] = atMillis
Event(button, Press.SHORT)
}
}
else -> null
}
}
companion object {
/**
* The double-tap window, in milliseconds.
*
* A [Press.SHORT] is emitted as soon as the button is released, so a double tap
* produces a SHORT and then a DOUBLE. Holding every single press back for this long
* would tax the common case to serve the rare one; suppressing the leading SHORT is
* the caller's business, and only worth doing when a rule is actually waiting on the
* double — see EVTasker's vehicle service.
*/
const val DOUBLE_TAP_MS = 300L
}
}
@@ -0,0 +1,22 @@
package com.evsuite.hardware
/**
* Marks an [EVHardware] write that changes road behaviour and is therefore permitted
* **only at 0 km/h**.
*
* This is documentation with teeth: it does not itself enforce anything — enforcement is
* [VehicleWriteGate], applied inside the low-level write primitives — but it names, at the
* method, exactly which writes are speed-gated. A test in this module asserts the set of
* annotated methods, so a setter cannot silently lose or gain gating without the change
* being noticed.
*
* Comfort writes (seat/steering heating via CarHvacManager, media volume, screen
* brightness, audio tuning) are deliberately NOT annotated: they do not alter how the car
* behaves on the road, and the gate does not apply to them.
*
* SOURCE retention: the annotation is a compile-time contract read by the guard test via
* the method list, not needed at runtime.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class RequiresStandstill
@@ -0,0 +1,38 @@
package com.evsuite.hardware
import java.util.concurrent.atomic.AtomicReference
/**
* Lets a repeated state through only when it actually changed.
*
* Two vehicle callbacks report a state rather than an event: the ignition condition arrives
* about ten times a second and the gear is read once a second, and both were logged every
* time. On a 400-line retained diagnostic log that is under a minute of history — the bundle
* a driver sends back is then almost entirely one repeated line, and whatever went wrong
* before that minute has already been evicted. It also spends the head unit's CPU and log
* bandwidth continuously to say nothing new.
*
* A state worth logging is a state that changed. The transitions are what a diagnostic reads
* anyway; the repeats between them carry no information the timestamp of the next transition
* does not already give.
*
* Only logging is gated. Callers must still dispatch every callback they receive — dropping a
* dispatch would change behaviour, dropping a log line changes only noise.
*/
class StateChangeLog<T> {
private val last = AtomicReference<Any?>(NOTHING)
/**
* @return true when [value] differs from the previous accepted one, or nothing was seen yet.
*/
fun accept(value: T): Boolean = last.getAndSet(value) != value
/** Forgets what was seen, so the next value logs as if it were the first. */
fun reset() = last.set(NOTHING)
private companion object {
/** Distinct from null, so a first value of null still counts as a change. */
val NOTHING = Any()
}
}
@@ -0,0 +1,22 @@
package com.evsuite.hardware
/**
* Declares which firmware generations a catalogue entry works on.
*
* Placed on the entries of [ConditionType] and [ActionType]. It is the single source of
* truth for three things:
* • self-documenting source — the support set sits next to the entry
* • the README compatibility matrix — generated from these annotations, never
* hand-maintained (see `FirmwareMatrix`)
* • the runtime filter — the editor hides an entry on a car whose firmware is not
* listed (see `FirmwareSupport`)
*
* A catalogue entry with NO `@SupportedOn` is firmware-independent (time, day, Bluetooth,
* launch app, notify): it works everywhere and is never filtered out.
*
* RUNTIME retention because the filter and the generator read it by reflection.
* FIELD target because Kotlin enum entries are compiled to fields.
*/
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class SupportedOn(vararg val gens: FirmwareGen)
@@ -0,0 +1,54 @@
package com.evsuite.hardware
/**
* Which speed conversion produced a recorded distance.
*
* A trip distance is the integral of speed, so it inherits whatever the speed conversion was
* at the time — and that conversion has already been wrong once. Every trip recorded before
* [KMH_DIRECT_V2] on a generation that reports km/h carries a distance 3.6 times too long, and
* nothing in the stored number says so: 8.46 km for a 2.4 km route reads exactly like 8.46 km
* for an 8.46 km route.
*
* [BatteryPowerEvidence] exists for the same reason on the energy side. This is its
* counterpart for distance, so a model can refuse a trip whose distance it cannot vouch for
* instead of quietly training on it.
*/
data class VehicleSpeedEvidence(
val firmware: FirmwareInfo.Gen,
val conversionVersion: Int,
) {
init {
require(firmware != FirmwareInfo.Gen.UNKNOWN) { "speed evidence names a known firmware" }
require(conversionVersion > 0) { "speed conversion version is positive" }
}
/**
* True when this distance was produced by the conversion currently believed correct.
*
* The generation is a parameter rather than a read of [FirmwareInfo.getGeneration] so the
* check can be exercised off a vehicle. A correctness gate that only runs on a car is a
* gate nobody has tested.
*/
fun matchesCurrent(generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration()): Boolean =
conversionVersion == CURRENT && firmware == generation
companion object {
/** `PERF_VEHICLE_SPEED` read as m/s and multiplied by 3.6, as AAOS specifies. */
const val MPS_TIMES_3_6_V1 = 1
/** The property read as the km/h it already carries, per generation evidence. */
const val KMH_DIRECT_V2 = 2
/** What a distance recorded now is produced by. */
const val CURRENT = KMH_DIRECT_V2
/**
* The evidence for a distance recorded right now, or null on an unknown firmware —
* where no claim about the conversion can honestly be made.
*/
fun current(): VehicleSpeedEvidence? =
FirmwareInfo.getGeneration()
.takeIf { it != FirmwareInfo.Gen.UNKNOWN }
?.let { VehicleSpeedEvidence(it, CURRENT) }
}
}
@@ -0,0 +1,44 @@
package com.evsuite.hardware
import kotlin.math.abs
/**
* What `PERF_VEHICLE_SPEED` is actually in, per firmware generation.
*
* AAOS defines the property as metres per second, so every read was multiplied by 3.6. On
* SWI68 that is wrong: the property already reports km/h, and multiplying a second time
* scaled every derived distance by the same factor. A 2.4 km drive was recorded twice as
* 8.46 km and 7.69 km — 124 and 129 km/h average over four minutes of town driving — and
* dividing by 3.6 gives 2.35 km at 34 km/h, which is the drive that happened.
*
* The conversion is therefore evidence-gated like every other firmware-dependent read here.
* A generation is listed only once a vehicle has shown it, and an unlisted generation keeps
* the specified conversion: unproven, but wrong in the conservative direction, because a
* speed read too high makes [VehicleWriteGate] refuse more often rather than less.
*/
object VehicleSpeedScale {
/** Metres per second to km/h, for generations that really do report the specified unit. */
private const val MPS_TO_KMH = 3.6f
/**
* Generations proven on a vehicle to report km/h directly from `PERF_VEHICLE_SPEED`.
*
* SWI68: bundle of 2026-09-04, firmware SWI68-29958-1300R67. Two recorded trips over a
* route of known length, both over by the same 3.5x, with the adapter odometer as the
* independent reference. Add a generation here only with the same kind of evidence.
*/
private val REPORTS_KMH = setOf(FirmwareInfo.Gen.SWI68)
/** True when this generation needs no conversion at all. */
fun reportsKmh(generation: FirmwareInfo.Gen): Boolean = generation in REPORTS_KMH
/**
* The raw property value as km/h.
*
* The property is signed — negative in reverse — and every consumer here asks whether the
* car is moving, so the magnitude is what comes back.
*/
fun toKmh(raw: Float, generation: FirmwareInfo.Gen): Float =
if (reportsKmh(generation)) abs(raw) else abs(raw) * MPS_TO_KMH
}
@@ -0,0 +1,112 @@
package com.evsuite.hardware
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
/**
* [T-904] Policy: a vehicle setting write is permitted **only when stopped** (0 km/h).
*
* Changing AEB, ELK, ACC/TJA or the drive mode while moving alters how the car behaves
* under the driver. The rule is therefore: 0 km/h or refuse — and refuse ALSO when the
* speed is unreadable (fail closed), because an unknown speed could be any speed.
*
* Comfort writes (seat/steering heating via CarHvacManager) are NOT affected: they do not
* change road behaviour. Setters that are gated carry [RequiresStandstill].
*
* This object lives in the shared `mg4-hardware` module, compiled into both EVProfile and
* EVTasker. It carries no dependency on either app's resources: the refusal message shown
* to the driver comes from [messageProvider], which an app may set to its own localized
* strings; the English fallback keeps the module self-contained.
*/
object VehicleWriteGate {
private const val TAG = "EV_GATE"
/** Anti-spam on the user message: at most one refusal toast per second. */
private const val TOAST_THROTTLE_MS = 1_000L
@Volatile
private var lastToastMs = 0L
/**
* Optional localized message source. An app sets this at init to surface its own
* strings; when null, the English fallback below is used. Returning null suppresses the
* toast for that decision.
*/
@Volatile
var messageProvider: ((Decision) -> String?)? = null
enum class Decision {
/** Vehicle stopped — write allowed. */
ALLOWED,
/** Vehicle moving — write refused. */
REFUSED_MOVING,
/** Speed unreadable — write refused (fail closed). */
REFUSED_UNKNOWN_SPEED
}
/**
* Pure decision from a speed in km/h, [speedKmh] null when unreadable.
*
* A negative speed is treated as unreadable: the VHAL does not produce a negative speed
* moving forward, and an aberrant value must never open the gate.
*/
fun decide(speedKmh: Float?): Decision =
when {
speedKmh == null || speedKmh.isNaN() -> Decision.REFUSED_UNKNOWN_SPEED
speedKmh < 0f -> Decision.REFUSED_UNKNOWN_SPEED
speedKmh == 0f -> Decision.ALLOWED
else -> Decision.REFUSED_MOVING
}
/**
* The decision for the vehicle as it is right now — what [allow] enforces, exposed so a
* caller can report the same verdict it is about to get instead of guessing at it.
*
* The speed is read immediately for this decision. No other signal can rescue an
* unreadable value: park may be stale, so using it would weaken the fail-closed rule.
*/
fun decideNow(): Decision = decide(EVHardware.getVehicleSpeedKmh())
/**
* True if the [operation] write is allowed now. On refusal it logs and notifies the
* user — a silent refusal would make the setting look applied when it was not.
*/
fun allow(operation: String): Boolean {
if (!FirmwareInfo.isDetectedGenerationSupported()) {
AppLogger.w(TAG, "Write refused ($operation): unsupported firmware")
notifyUser("Setting change refused: firmware is not supported")
return false
}
val decision = decideNow()
if (decision == Decision.ALLOWED) return true
AppLogger.w(TAG, "Write refused ($operation): $decision")
notifyUser(decision)
return false
}
private fun notifyUser(decision: Decision) {
val message = messageProvider?.invoke(decision) ?: defaultMessage(decision) ?: return
notifyUser(message)
}
private fun notifyUser(message: String) {
val context: Context = EVHardware.appContext() ?: return
val now = System.currentTimeMillis()
if (now - lastToastMs < TOAST_THROTTLE_MS) return
lastToastMs = now
Handler(Looper.getMainLooper()).post {
runCatching { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
}
}
private fun defaultMessage(decision: Decision): String? = when (decision) {
Decision.REFUSED_MOVING -> "Setting change refused: vehicle is moving"
Decision.REFUSED_UNKNOWN_SPEED -> "Setting change refused: speed unreadable"
Decision.ALLOWED -> null
}
}
@@ -0,0 +1,208 @@
package com.evsuite.hardware
import java.lang.reflect.Method
/**
* The glass, through the VSM — the interface the car's own window switch goes through.
*
* EVHardware reached the windows through the vendor hub until now
* ([com.evsuite.hardware.saic.SaicVehicleControl]), and that path is the whole reason the
* glass actions shipped refused: its setter takes a value no application on the head unit
* ever writes, so what the eight accepted values do was never more than a guess, and a value
* the service drops looks exactly like one it obeys.
*
* The VSM carries the same glass under two methods whose meaning is settled by use:
*
* • `setVehicleWindowStatus(area, command)` — the commands in [Command]
* • `getVehicleWindowValue(area)` — a position, 0 closed to [VALUE_MAX] open
*
* Areas are `0..3`: front left, front right, rear left, rear right.
*
* **Per generation.** VSM-based firmwares only. SWI68 and SWI165 reach the VSM through
* `VehicleSettingManager`, SWI69, SWI131 and SWI132 through `CarVehicleSettingClient`, and
* both expose these names. SWI133 is VPM-based and carries neither, so [isAvailable] answers
* false there and the caller keeps the hub instead of writing into nothing.
*/
object VsmGlass {
/** The scale `getVehicleWindowValue` answers on: 0 fully closed, 255 fully open. */
const val VALUE_MAX = 255
/** The largest command `setVehicleWindowStatus` carries. */
const val COMMAND_MAX = 7
/** How often a held command is re-sent while the glass travels. */
const val PULSE_MS = 120L
/** How long [hold] keeps a command down when the caller names no duration. */
const val HOLD_MS = 5_000L
/**
* The commands whose effect is established.
*
* [UP] is a switch held down, not an order: the window travels while the command keeps
* arriving and stops the instant it does not — which is why sending it once and waiting
* looked exactly like a command that does nothing at all. [AUTO_UP] is the one-shot close
* the door switch performs, and only the trims whose motors carry a position sensor obey
* it; [hold] works on both, so it is what a close falls back to.
*
* No opening command is named here. Nothing on the head unit sends one, so any value
* written down would be a guess — [GlassProbe] finds them on the car instead.
*/
object Command {
const val STOP = 0
const val UP = 1
const val AUTO_UP = 3
}
private val INT = Int::class.javaPrimitiveType!!
private val AREAS = 0..3
// Looked up once per VSM class rather than per call: a hold writes eight times a second,
// and `getMethod` on every one of them is forty lookups for a single close.
@Volatile private var owner: Class<*>? = null
@Volatile private var setter: Method? = null
@Volatile private var getter: Method? = null
/** The VSM with its two methods resolved, or null when there is nothing to call. */
@Synchronized
private fun vsm(): Any? {
val vsm = EVHardware.vsmInstance() ?: return null
if (owner !== vsm.javaClass) {
owner = vsm.javaClass
setter = method(vsm, "setVehicleWindowStatus", INT, INT)
getter = method(vsm, "getVehicleWindowValue", INT)
}
return vsm
}
private fun method(vsm: Any, name: String, vararg types: Class<*>): Method? = try {
vsm.javaClass.getMethod(name, *types)
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name absent — ${e.javaClass.simpleName}")
null
}
/** True when this firmware's VSM carries the glass setter. */
val isAvailable: Boolean
get() = FirmwareInfo.isVsmBased() && vsm() != null && setter != null
/**
* One window's position, 0 closed to [VALUE_MAX] open, or null when there is no reading.
*
* Null means the method is absent, the call threw, or the answer fell outside `0..`
* [VALUE_MAX] — which is how the sensorless trims seen so far report having no position.
*
* **What this cannot rule out.** A motor that answers a constant *inside* the scale is
* indistinguishable from one reporting a real position, from a single read, and 0 is the
* obvious candidate for such a constant — it would read as "fully closed" on a window that
* is wide open. Nothing here can settle that, and guessing at it would be the same mistake
* the raw hub setter made. [GlassProbe] is what settles it on a car: it holds a command and
* watches this value change, so a car with [GlassEvidence] recorded has been shown to
* report positions that move. A car without it has not, and a caller deciding whether to
* skip a close should weigh the reading accordingly.
*/
fun value(area: Int): Int? {
val vsm = vsm() ?: return null
val get = getter ?: return null
if (area !in AREAS) return null
return try {
(get.invoke(vsm, area) as? Number)?.toInt()?.takeIf { it in 0..VALUE_MAX }
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: getVehicleWindowValue($area) exc: ${e.message}")
null
}
}
/** The same position in percent — the scale the catalogue and the rules speak in. */
fun percent(area: Int): Int? = value(area)?.let { it * 100 / VALUE_MAX }
/** Sends [command] once to each of [areas]. One write, for the commands that are orders. */
fun send(areas: List<Int>, command: Int): Boolean =
gate(areas, command)?.let { targets -> targets.map { write(it, command) }.all { it } } ?: false
/**
* Holds [command] down on [areas] for [durationMs], then releases it with [Command.STOP].
*
* This is what [Command.UP] needs: it moves the glass only while it keeps arriving, so a
* single write travels the window by whatever it manages before the next frame — nothing
* a position read can see. A command that is an order rather than a switch is unharmed by
* being repeated, which is what lets [GlassProbe] sweep both kinds the same way.
*
* Blocking for [durationMs] — call it off the main thread.
*
* The gate is re-checked before every pulse, not only before the first. Glass is
* standstill-gated outright on this project (see the 1.6.0 note: with the direction of a
* command unknown, an unknown direction is not one to allow at speed), and a hold that
* checked once wrote for five more seconds after the car started moving — five seconds in
* which the policy said no and the glass kept travelling. On refusal the command is
* released with [Command.STOP] rather than simply abandoned, so the motor stops where it
* is instead of continuing on the last command it received.
*/
fun hold(
areas: List<Int>,
command: Int,
durationMs: Long = HOLD_MS,
sleep: (Long) -> Unit = { Thread.sleep(it) },
): Boolean {
val targets = gate(areas, command) ?: return false
return runHold(
targets = targets,
command = command,
durationMs = durationMs,
nowMs = System::currentTimeMillis,
allowed = { VehicleWriteGate.allow("VSM setVehicleWindowStatus (hold)") },
write = ::write,
sleep = sleep,
)
}
/** Hardware-free seam proving a moving/unreadable transition stops and releases the glass. */
internal fun runHold(
targets: List<Int>,
command: Int,
durationMs: Long,
nowMs: () -> Long,
allowed: () -> Boolean,
write: (Int, Int) -> Boolean,
sleep: (Long) -> Unit,
): Boolean {
var ok = true
val deadline = nowMs() + durationMs
while (nowMs() < deadline) {
if (!allowed()) {
ok = false
break
}
targets.forEach { if (!write(it, command)) ok = false }
sleep(PULSE_MS)
}
targets.forEach { write(it, Command.STOP) }
return ok
}
/** The areas worth writing to, or null when the call is refused before any write. */
private fun gate(areas: List<Int>, command: Int): List<Int>? {
if (command !in 0..COMMAND_MAX) return null
val targets = areas.filter { it in AREAS }
if (targets.isEmpty()) return null
if (vsm() == null || setter == null) return null
// [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable.
if (!VehicleWriteGate.allow("VSM setVehicleWindowStatus")) return null
return targets
}
private fun write(area: Int, command: Int): Boolean {
val vsm = EVHardware.vsmInstance() ?: return false
val set = setter ?: return false
return try {
set.invoke(vsm, area, command)
true
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: setVehicleWindowStatus($area, $command) exc: ${e.message}")
false
}
}
private const val TAG = "EV_GLASS"
}
@@ -0,0 +1,852 @@
package com.evsuite.hardware.catalog
import androidx.annotation.StringRes
import com.evsuite.hardware.R
import com.evsuite.hardware.catalog.SnapshotKeys
import com.evsuite.hardware.catalog.VehicleEnums
import com.evsuite.hardware.SupportedOn
import com.evsuite.hardware.FirmwareGen.SWI131
import com.evsuite.hardware.FirmwareGen.SWI132
import com.evsuite.hardware.FirmwareGen.SWI133
import com.evsuite.hardware.FirmwareGen.SWI165
import com.evsuite.hardware.FirmwareGen.SWI68
import com.evsuite.hardware.FirmwareGen.SWI69
import com.evsuite.hardware.catalog.ValueSpec.Companion.number
/** Grouping in the action picker. */
enum class ActionGroup(@StringRes val labelRes: Int) {
PROFILE(R.string.group_profile),
DRIVING(R.string.group_driving),
COMFORT(R.string.group_comfort),
CLIMATE(R.string.group_climate),
ENERGY(R.string.group_energy),
AUDIO(R.string.group_audio),
ADAS(R.string.group_adas),
SYSTEM(R.string.group_system)
}
/**
* Catalogue of executable actions.
*
* [bridgeAction] is the identifier sent to `TaskerBridgeService.applyAction`. It is null
* for actions handled locally by EVTasker (launch an app, notify), which never touch the
* vehicle and therefore have no business in the bridge.
*
* [gated] marks writes that change road behaviour. EVProfile refuses them while the car
* is moving or when its speed is unreadable. The editor shows the mark so the user knows
* up front that such an action only applies when stopped, instead of discovering a
* refusal in the history afterwards.
*
* The [SupportedOn] annotation declares firmware support (from EVProfile routing); it
* drives the generated README matrix and the editor's runtime filter. Local actions carry
* no annotation — they are firmware-independent.
*
* Climate and charging writes go through the SAIC vendor services
* (`com.evsuite.hardware.saic`), not through AOSP property ids. That distinction is the reason
* they exist at all: the AOSP climate ids EVHardware reads are standard ids that no MG4
* confirmed, so writing them would have been a guess. The vendor calls
* are the ones the car's own HVAC and charging screens make.
*
* Window and door-lock writes go through `vehiclecontrol` on the same hub. The glass carries
* a **command** in 0..7, not a position: the service reads back a percentage but accepts
* nothing above 7 on the way in, and drops what it does not accept without saying so (see
* `SaicVehicleControl`). Which command moves which way is not established by anything on the
* firmware, so every glass write takes the standstill gate — an unknown direction cannot be
* the one that is safe at speed, and every glass write is [writeProven] `= false` until one
* command is observed to move a window. The electric tailgate stays out — the launcher defines its
* OPEN and CLOSE as the same value, so it is a pulse whose direction depends on state this
* cannot read.
*
* Also deliberately absent:
* • `VEHICLE_POWER_OFF` — cutting the vehicle must stay an explicit human gesture.
* • `SET_SOUND_FIELD` — EVHardware.getSoundFieldType() always returns -1, so the
* state is never readable back and a rule's effect would be
* unverifiable.
*/
enum class ActionType(
@StringRes val labelRes: Int,
val group: ActionGroup,
val spec: ValueSpec,
val bridgeAction: String?,
val gated: Boolean = false,
/**
* Snapshot key holding what this action controls, right now, when the car can report it.
*
* The editor opens the control on that value instead of on the bottom of its range. A
* brightness slider that starts at 5% invites the driver to confirm 5% by accident; one
* that starts where the screen already is makes the rule an edit of the present state,
* which is what the user is thinking about. Null where nothing reads it back.
*/
val currentKey: String? = null,
/**
* False when nothing establishes that this write does what its label says.
*
* Firmware support and effect are two different questions. `@SupportedOn` says the
* property exists on this generation; this says whether writing it has ever been shown
* to move anything. The glass is the case that made the distinction necessary: the
* service accepts a command in 0..7, no head-unit application sends one, and which of
* the eight raises a window is written down nowhere — so a write returns success while
* the glass stays where it was.
*
* An unproven action is offered nowhere and executed nowhere: EVTasker's diagnostic
* blocks it, the rule editor does not list it, and the executor refuses it rather than
* report a success it cannot back. Flip the flag back to true in the same commit that
* records the evidence — a command sent, the position read back, the glass observed to
* have moved.
*/
val writeProven: Boolean = true
) {
// ── Profile ──────────────────────────────────────────────────────────────
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
APPLY_PROFILE(
R.string.act_apply_profile, ActionGroup.PROFILE,
ValueSpec(ValueKind.PROFILE), bridgeAction = "APPLY_PROFILE", gated = true
),
/**
* Opens EVProfile's profile picker and leaves the choice to the driver.
*
* The counterpart of [APPLY_PROFILE], for the rules that must not decide alone: arriving
* at a charger, the profile to apply depends on what the driver is about to do, which no
* condition reads. Gated like the profile it leads to — EVProfile refuses to put the
* picker in front of a moving driver, so offering it without the mark would promise a
* dialog that never appears.
*/
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SHOW_PROFILE_PICKER(
R.string.act_show_profile_picker, ActionGroup.PROFILE,
ValueSpec.NONE, bridgeAction = "SHOW_PROFILE_PICKER", gated = true
),
// ── Driving (gated) ──────────────────────────────────────────────────────
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_DRIVE_MODE(
R.string.act_drive_mode, ActionGroup.DRIVING,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.DRIVE_MODES),
"SET_DRIVE_MODE", gated = true, currentKey = SnapshotKeys.KEY_DRIVE_MODE
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_REGEN_LEVEL(
R.string.act_regen, ActionGroup.DRIVING,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.REGEN_LEVELS),
"SET_REGEN_LEVEL", gated = true, currentKey = SnapshotKeys.KEY_REGEN_LEVEL
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_ONE_PEDAL(
R.string.act_one_pedal, ActionGroup.DRIVING,
ValueSpec.BOOL, "SET_ONE_PEDAL", gated = true
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_ENERGY_SAVING(
R.string.act_energy_saving, ActionGroup.DRIVING,
ValueSpec.BOOL, "SET_ENERGY_SAVING", gated = true, currentKey = SnapshotKeys.KEY_ENERGY_SAVING
),
// ── Comfort (not gated: does not alter road behaviour) ───────────────────
@SupportedOn(SWI133, SWI68, SWI165)
SET_SEAT_HEAT_LEFT(
R.string.act_seat_heat_l, ActionGroup.COMFORT,
number(0, 3), "SET_SEAT_HEAT_LEFT", currentKey = SnapshotKeys.KEY_SEAT_HEAT_L
),
@SupportedOn(SWI133, SWI68, SWI165)
SET_SEAT_HEAT_RIGHT(
R.string.act_seat_heat_r, ActionGroup.COMFORT,
number(0, 3), "SET_SEAT_HEAT_RIGHT", currentKey = SnapshotKeys.KEY_SEAT_HEAT_R
),
@SupportedOn(SWI133, SWI68, SWI165)
SET_STEERING_HEAT(
R.string.act_steering_heat, ActionGroup.COMFORT,
ValueSpec.BOOL, "SET_STEERING_HEAT", currentKey = SnapshotKeys.KEY_STEERING_HEAT
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_SCREEN_BRIGHTNESS(
R.string.act_brightness, ActionGroup.COMFORT,
number(VehicleEnums.BRIGHTNESS_MIN, VehicleEnums.BRIGHTNESS_MAX, R.string.unit_percent),
"SET_SCREEN_BRIGHTNESS", currentKey = SnapshotKeys.KEY_BRIGHTNESS
),
// ── 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
// that pre-heats on the motorway is a legitimate one.
//
// Two paths behind one name: the vendor hub on SWI68/SWI165, and `carapi`'s CarHvacClient
// on the A9 generations, which have no such hub (`A9Climate`). The exceptions are ECON and
// the passenger's own target — the A9 client exposes neither, so those two stay where they
// were rather than being offered on a car that would refuse them.
@SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132)
SET_CLIMATE_POWER(
R.string.act_climate_power, ActionGroup.CLIMATE,
ValueSpec.BOOL, "SET_CLIMATE_POWER", currentKey = SnapshotKeys.KEY_CLIMATE_ON
),
@SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132)
SET_CABIN_TEMP(
R.string.act_cabin_temp, ActionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
"SET_CABIN_TEMP", currentKey = SnapshotKeys.KEY_TEMPERATURE_SET
),
/**
* The passenger side's target, where [SET_CABIN_TEMP] is the driver's.
*
* 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.
*/
@SupportedOn(SWI68, SWI165)
SET_PASSENGER_TEMP(
R.string.act_passenger_temp, ActionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
"SET_PASSENGER_TEMP", currentKey = SnapshotKeys.KEY_PASSENGER_TEMP
),
@SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132)
SET_AC(
R.string.act_ac, ActionGroup.CLIMATE,
ValueSpec.BOOL, "SET_AC", currentKey = SnapshotKeys.KEY_AC_ON
),
@SupportedOn(SWI68, SWI165)
SET_ECON(
R.string.act_econ, ActionGroup.CLIMATE,
ValueSpec.BOOL, "SET_ECON", currentKey = SnapshotKeys.KEY_ECON
),
@SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132)
SET_CLIMATE_AUTO(
R.string.act_climate_auto, ActionGroup.CLIMATE,
ValueSpec.BOOL, "SET_CLIMATE_AUTO", currentKey = SnapshotKeys.KEY_HVAC_AUTO
),
@SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132)
SET_RECIRCULATION(
R.string.act_recirc, ActionGroup.CLIMATE,
ValueSpec.BOOL, "SET_RECIRCULATION", currentKey = SnapshotKeys.KEY_RECIRC
),
@SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132)
SET_FAN_LEVEL(
R.string.act_fan_level, ActionGroup.CLIMATE,
number(0, VehicleEnums.FAN_LEVEL_MAX), "SET_FAN_LEVEL", currentKey = SnapshotKeys.KEY_FAN_SPEED
),
@SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132)
SET_FRONT_DEFROST(
R.string.act_front_defrost, ActionGroup.CLIMATE,
ValueSpec.BOOL, "SET_FRONT_DEFROST", currentKey = SnapshotKeys.KEY_FRONT_DEFROST
),
@SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132)
SET_REAR_DEFROST(
R.string.act_rear_defrost, ActionGroup.CLIMATE,
ValueSpec.BOOL, "SET_REAR_DEFROST", currentKey = SnapshotKeys.KEY_REAR_DEFROST
),
/**
* Sends one glass command to all four windows.
*
* **The value is a command in 0..7, not a position.** The service accepts nothing above 7
* and drops what it will not accept in silence, so the percentage this used to carry was
* discarded on every write above 7 while the history said the action had been applied.
* The reading stays a percentage — [SnapshotKeys.KEY_WINDOW_PERCENT] is what the car
* reports back — which is what makes a command identifiable: send one, read the position.
*
* Which command opens and which closes is not written down anywhere on the firmware and
* no head-unit application sends one, so the whole action is standstill-gated: a write
* whose direction is unknown is not a write to allow at speed.
*/
@SupportedOn(SWI68, SWI165)
SET_WINDOWS(
R.string.act_windows, ActionGroup.CLIMATE,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOWS",
currentKey = null,
gated = true, writeProven = false
),
/**
* One window's glass command — the counterpart of [SET_WINDOWS], same 0..7 scale.
*
* Both are worth having. Commanding the glass as one gesture belongs in one action that
* cannot leave three answered and one forgotten; addressing the driver's window alone
* cannot be written with an all-or-nothing call.
*/
@SupportedOn(SWI68, SWI165)
SET_WINDOW_DRIVER(
R.string.act_window_driver, ActionGroup.CLIMATE,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOW_DRIVER",
currentKey = null,
gated = true, writeProven = false
),
@SupportedOn(SWI68, SWI165)
SET_WINDOW_PASSENGER(
R.string.act_window_passenger, ActionGroup.CLIMATE,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOW_PASSENGER",
currentKey = null,
gated = true, writeProven = false
),
@SupportedOn(SWI68, SWI165)
SET_WINDOW_REAR_LEFT(
R.string.act_window_rear_left, ActionGroup.CLIMATE,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOW_REAR_LEFT",
currentKey = null,
gated = true, writeProven = false
),
@SupportedOn(SWI68, SWI165)
SET_WINDOW_REAR_RIGHT(
R.string.act_window_rear_right, ActionGroup.CLIMATE,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOW_REAR_RIGHT",
currentKey = null,
gated = true, writeProven = false
),
@SupportedOn(SWI68, SWI165)
SET_DOOR_LOCK(
R.string.act_door_lock, ActionGroup.CLIMATE,
ValueSpec.BOOL, "SET_DOOR_LOCK", currentKey = SnapshotKeys.KEY_DOORS_LOCKED
),
// ── Energy (vendor service — com.evsuite.hardware.saic.SaicCharging) ─────────
@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
),
@SupportedOn(SWI68, SWI165)
SET_CHARGING_ENABLED(
R.string.act_charging_enabled, ActionGroup.ENERGY,
ValueSpec.BOOL, "SET_CHARGING_ENABLED"
),
@SupportedOn(SWI68, SWI165)
SET_CHARGE_SCHEDULE(
R.string.act_charge_schedule, ActionGroup.ENERGY,
ValueSpec.BOOL, "SET_CHARGE_SCHEDULE", currentKey = SnapshotKeys.KEY_CHARGE_SCHEDULE
),
/**
* The scheduled charging window. A range rather than two actions: start and stop are
* four separate vehicle signals, and setting only half of them leaves the car with a
* window nobody intended.
*/
@SupportedOn(SWI68, SWI165)
SET_CHARGE_WINDOW(
R.string.act_charge_window, ActionGroup.ENERGY,
ValueSpec(ValueKind.TIME_RANGE), "SET_CHARGE_WINDOW"
),
@SupportedOn(SWI68, SWI165)
SET_BATTERY_PREHEAT(
R.string.act_battery_preheat, ActionGroup.ENERGY,
ValueSpec.BOOL, "SET_BATTERY_PREHEAT", currentKey = SnapshotKeys.KEY_BATTERY_PREHEAT
),
// ── Audio ────────────────────────────────────────────────────────────────
// Everything below the media volume goes through the SAIC `caradapter` audio helper,
// which EVHardware binds only on the A9 platform (SWI69 / SWI131 / SWI132). These
// entries used to be annotated SWI133 + SWI132, read off `hasAudioControl()` — but that
// predicate describes the door-volume feature, a different thing. The effect was an
// action offered on SWI133, where the helper is never bound and the write silently
// returns false, and hidden on SWI69 / SWI131, where it works.
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_MEDIA_VOLUME(
R.string.act_media_volume, ActionGroup.AUDIO,
ValueSpec.dynamicNumber(0, VehicleEnums.MEDIA_VOLUME_FALLBACK_MAX),
"SET_MEDIA_VOLUME", currentKey = SnapshotKeys.KEY_MEDIA_VOLUME
),
/**
* Moves the volume by a number of steps instead of setting one.
*
* The two are not interchangeable. A target is what a rule wants at ignition — "start the
* day at 12". A step is what a *button* wants, and what a rule wants when it must not
* discard what the driver chose: "two quieter when the phone rings" keeps their setting,
* where a target throws it away. Negative goes down, and either end of the range is
* reported as done rather than refused.
*/
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
ADJUST_MEDIA_VOLUME(
R.string.act_adjust_media_volume, ActionGroup.AUDIO,
number(-VehicleEnums.MEDIA_VOLUME_FALLBACK_MAX, VehicleEnums.MEDIA_VOLUME_FALLBACK_MAX),
"ADJUST_MEDIA_VOLUME"
),
@SupportedOn(SWI69, SWI131, SWI132)
SET_AUDIO_BALANCE(
R.string.act_balance, ActionGroup.AUDIO,
number(VehicleEnums.AUDIO_LEVEL_MIN, VehicleEnums.AUDIO_LEVEL_MAX),
"SET_AUDIO_BALANCE"
),
@SupportedOn(SWI69, SWI131, SWI132)
SET_AUDIO_FADER(
R.string.act_fader, ActionGroup.AUDIO,
number(VehicleEnums.AUDIO_LEVEL_MIN, VehicleEnums.AUDIO_LEVEL_MAX),
"SET_AUDIO_FADER"
),
@SupportedOn(SWI69, SWI131, SWI132)
SET_TONE_CONTROL(
R.string.act_tone, ActionGroup.AUDIO,
number(VehicleEnums.AUDIO_LEVEL_MIN, VehicleEnums.AUDIO_LEVEL_MAX),
"SET_TONE_CONTROL"
),
@SupportedOn(SWI69, SWI131, SWI132)
SET_BOSE_SOUND_TYPE(
R.string.act_bose, ActionGroup.AUDIO,
number(VehicleEnums.AUDIO_TYPE_MIN, VehicleEnums.AUDIO_TYPE_MAX),
"SET_BOSE_SOUND_TYPE"
),
@SupportedOn(SWI69, SWI131, SWI132)
SET_3D_EFFECT(
R.string.act_3d, ActionGroup.AUDIO,
number(VehicleEnums.AUDIO_TYPE_MIN, VehicleEnums.AUDIO_TYPE_MAX),
"SET_3D_EFFECT"
),
@SupportedOn(SWI69, SWI131, SWI132)
SET_SPEED_VOLUME(
R.string.act_speed_volume, ActionGroup.AUDIO,
number(VehicleEnums.AUDIO_TYPE_MIN, VehicleEnums.AUDIO_TYPE_MAX),
"SET_SPEED_VOLUME"
),
/**
* Makes radio the current audio source, resuming the last station.
*
* Nothing to configure: `srcPlayRadio` takes no argument, and it resumes the last
* station. [TUNE_RADIO] is the one to use when the station matters.
*
* It resumes rather than opening the radio screen: a rule firing at ignition wants the
* sound, not a screen thrown in front of the driver.
*
* Vendor service, so it carries firmware support like any other vehicle entry — the
* radio is not an ordinary Android app that could be launched instead.
*/
@SupportedOn(SWI68, SWI165)
PLAY_RADIO(
R.string.act_play_radio, ActionGroup.AUDIO,
ValueSpec.NONE, "PLAY_RADIO"
),
/**
* Puts the tuner where the rule says: a **band**, an optional **frequency**, and whether
* the radio then **plays**.
*
* The three used to be two actions and a switch, and that split was the wrong seam. A
* band alone could not carry a station, a frequency alone could not reach **DAB**, and a
* driver saying "put the radio on FM 103.5" was made to write two rows. They are one
* instruction, so they are one entry.
*
* `Action.number` is the band ([VehicleEnums.RADIO_BANDS], the vendor `RadioType` values),
* `Action.text` the frequency, and `Action.flag` the "enable radio" switch. `0` is the
* band of every rule saved before the merge: it means "whatever the frequency says", which
* is exactly what those rules did.
*
* The frequency is free text rather than a slider: the driver knows their station as
* "103.5", and an FM slider covering 87.5–108.0 in 50 kHz steps is 410 positions to drag
* past on a touchscreen. It is parsed leniently — "103.5", "FM 103.5", "103,5", "1080 AM"
* all land — and a value that parses to nothing is reported as unsupported with what was
* typed, so the history names the typo. Bands are AM (522–1620 kHz) and FM (87.5–108.0
* MHz), from `RadioConstants.AM_RANGE` / `FM_RANGE`.
*
* **Leaving the frequency empty is not an omission**, it is the band-only instruction —
* `SaicRadio.selectBand` reads where the tuner is and moves it, landing on the station
* this car was last heard on for that band. It is the only form DAB has: `tuneDab` takes
* an ensemble and a service id, not a frequency, so there is nothing there for a driver to
* type, and the editor stops asking once DAB is chosen.
*
* Audio-only, so it is not gated: changing station or band is what the wheel's own buttons
* already do at speed.
*/
@SupportedOn(SWI68, SWI165)
TUNE_RADIO(
R.string.act_tune_radio, ActionGroup.AUDIO,
ValueSpec(
ValueKind.RADIO,
options = VehicleEnums.RADIO_BANDS,
hintRes = R.string.value_radio_hint
),
"TUNE_RADIO"
),
/**
* Steps through the tuner's stations, on whichever band it is already on.
*
* The one way to choose a **DAB** station: a DAB service is addressed by ensemble and
* service id, so there is no frequency for a driver to type. [TUNE_RADIO] reaches the DAB
* *band*, and this is what then moves along it — the tuner's own list is the right one.
*
* Separate from [ActionType.MEDIA_CONTROL] on purpose. That one skips a *track* on
* whatever owns the audio; this one changes *station* whether or not the radio is the
* current source, which is a different intent and a different service call.
*/
@SupportedOn(SWI68, SWI165)
RADIO_NEXT_STATION(
R.string.act_radio_next_station, ActionGroup.AUDIO,
ValueSpec.NONE, "RADIO_NEXT_STATION"
),
@SupportedOn(SWI68, SWI165)
RADIO_PREV_STATION(
R.string.act_radio_prev_station, ActionGroup.AUDIO,
ValueSpec.NONE, "RADIO_PREV_STATION"
),
/**
* Silences the tuner — `srcPauseRadio`, the counterpart of [PLAY_RADIO].
*
* Not [MEDIA_CONTROL]'s play/pause, which addresses whichever source owns the audio and
* would therefore stop Bluetooth when Bluetooth is the one playing. This one names the
* radio, so a rule that silences the news on arrival silences the news.
*
* It mutes rather than handing the audio focus back: the vendor service only abandons
* focus when it loses it, so whatever played before the radio does not resume on its own.
* Silence is the most the service offers, and the label says no more than that.
*/
@SupportedOn(SWI68, SWI165)
PAUSE_RADIO(
R.string.act_pause_radio, ActionGroup.AUDIO,
ValueSpec.NONE, "PAUSE_RADIO"
),
/**
* Toggles the tuner between playing and silent, on the state the tuner reports.
*
* One entry for a driver who has one button and wants the radio specifically — again not
* [MEDIA_CONTROL], which follows the current source wherever it went.
*
* The direction is read, never assumed. `AudioManager.isMusicActive` is **false while the
* radio plays**, its stream not being the music one, so the obvious substitute would send
* "play" to a playing radio forever; `SaicRadio.isPlaying` reads `RadioBean.state`
* instead. When even that cannot be read the action sends nothing and reports why: a
* media shortcut can be pressed a second time, an unattended rule cannot, and a toggle
* that guessed wrong leaves the car silent — or playing — with nothing saying the
* direction was invented.
*/
@SupportedOn(SWI68, SWI165)
RADIO_PLAY_PAUSE(
R.string.act_radio_play_pause, ActionGroup.AUDIO,
ValueSpec.NONE, "RADIO_PLAY_PAUSE"
),
/**
* Brings the radio screen up — `startRadioActivity`, a different intent from playing.
*
* The one radio action that is not audio-only, and the only one that is [gated]. Skipping
* a station changes what comes out of the speakers; this replaces what is on the screen,
* and a full-screen app thrown in front of a driver at 110 km/h takes their eyes off the
* road for as long as it takes to understand what happened. It follows the standstill
* gate for the same reason the glass does, refusal on an unreadable speed included —
* which is the gate the rest of the audio family is deliberately outside of, and stays
* outside of.
*/
@SupportedOn(SWI68, SWI165)
OPEN_RADIO_SCREEN(
R.string.act_open_radio_screen, ActionGroup.AUDIO,
ValueSpec.NONE, "OPEN_RADIO_SCREEN", gated = true
),
// ── ADAS (gated) ─────────────────────────────────────────────────────────
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_AEB_ENABLED(
R.string.act_aeb, ActionGroup.ADAS,
ValueSpec.BOOL, "SET_AEB_ENABLED", gated = true, currentKey = SnapshotKeys.KEY_AEB_ENABLED
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_AEB_MODE(
R.string.act_aeb_mode, ActionGroup.ADAS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.AEB_MODES),
"SET_AEB_MODE", gated = true, currentKey = SnapshotKeys.KEY_AEB_MODE
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_AEB_SENSITIVITY(
R.string.act_aeb_sensitivity, ActionGroup.ADAS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.SENSITIVITIES),
"SET_AEB_SENSITIVITY", gated = true, currentKey = SnapshotKeys.KEY_AEB_SENSITIVITY
),
/**
* Electronic stability control.
*
* Gated like every driving write, and refused by EVHardware for a second reason of its
* own: the write is a **toggle** driven by a read, so it only runs on an ignition known to
* be in RUN and on three agreeing readings. Getting in while the cluster is still dark,
* the property does not yet reflect reality, and aiming at ON from a false OFF turns off
* an ESC that was on — silently. A rule that wants it off is simply honoured a moment
* later, once the car is awake.
*/
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_ESC(
R.string.act_esc, ActionGroup.ADAS,
ValueSpec.BOOL, "SET_ESC", gated = true, currentKey = SnapshotKeys.KEY_ESC
),
/**
* The drowsiness warning — UDW, the one the car raises when the driving gets unsteady.
*
* Deliberately the UDW switch and not the camera-based DMS one: both exist, their labels
* read alike, and writing the camera one changed nothing visible.
*/
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_DROWSINESS(
R.string.act_drowsiness, ActionGroup.ADAS,
ValueSpec.BOOL, "SET_DROWSINESS", gated = true, currentKey = SnapshotKeys.KEY_DROWSINESS
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_DROWSINESS_SENSITIVITY(
R.string.act_drowsiness_sensitivity, ActionGroup.ADAS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.SENSITIVITIES),
"SET_DROWSINESS_SENSITIVITY", gated = true,
currentKey = SnapshotKeys.KEY_DROWSINESS_SENSITIVITY
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_ELK_MODE(
R.string.act_elk_mode, ActionGroup.ADAS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.ELK_MODES),
"SET_ELK_MODE", gated = true, currentKey = SnapshotKeys.KEY_ELK_MODE
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_ELK_SENSITIVITY(
R.string.act_elk_sensitivity, ActionGroup.ADAS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.SENSITIVITIES),
"SET_ELK_SENSITIVITY", gated = true, currentKey = SnapshotKeys.KEY_ELK_SENSITIVITY
),
@SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165)
SET_ACC_TJA_MODE(
R.string.act_acc_tja, ActionGroup.ADAS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.ACC_TJA_MODES),
"SET_ACC_TJA_MODE", gated = true, currentKey = SnapshotKeys.KEY_ACC_TJA_MODE
),
@SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165)
SET_LIMITER_MODE(
R.string.act_limiter, ActionGroup.ADAS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.LIMITER_MODES),
"SET_LIMITER_MODE", gated = true, currentKey = SnapshotKeys.KEY_LIMITER_MODE
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SET_TSR(
R.string.act_tsr, ActionGroup.ADAS,
ValueSpec.BOOL, "SET_TSR", gated = true, currentKey = SnapshotKeys.KEY_TSR
),
@SupportedOn(SWI133, SWI132)
SET_OVERSPEED_ALARM(
R.string.act_overspeed, ActionGroup.ADAS,
ValueSpec.BOOL, "SET_OVERSPEED_ALARM", gated = true, currentKey = SnapshotKeys.KEY_OVERSPEED_ALARM
),
@SupportedOn(SWI133, SWI132)
SET_SPEED_LIMIT_TONE(
R.string.act_speed_limit_tone, ActionGroup.ADAS,
ValueSpec.BOOL, "SET_SPEED_LIMIT_TONE", gated = true, currentKey = SnapshotKeys.KEY_SPEED_LIMIT_TONE
),
@SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165)
SET_SOUND_WARNING(
R.string.act_sound_warning, ActionGroup.ADAS,
ValueSpec.BOOL, "SET_SOUND_WARNING", gated = true, currentKey = SnapshotKeys.KEY_SOUND_WARNING
),
@SupportedOn(SWI132)
SET_LAS_WARNING_SOUND(
R.string.act_las_sound, ActionGroup.ADAS,
ValueSpec.BOOL, "SET_LAS_WARNING_SOUND"
),
@SupportedOn(SWI132)
SET_LAS_WARNING_VIBRATION(
R.string.act_las_vibration, ActionGroup.ADAS,
ValueSpec.BOOL, "SET_LAS_WARNING_VIBRATION"
),
// ── System (local, no vehicle access, firmware-independent) ──────────────
LAUNCH_APP(
R.string.act_launch_app, ActionGroup.SYSTEM,
ValueSpec(ValueKind.APP), bridgeAction = null
),
SHOW_NOTIFICATION(
R.string.act_notify, ActionGroup.SYSTEM,
ValueSpec(ValueKind.TEXT), bridgeAction = null
),
/**
* Speaks the text through the platform TTS engine. Local like the two above: nothing
* is written to the vehicle, so no firmware annotation and no bridge action.
*/
SPEAK_TEXT(
R.string.act_speak, ActionGroup.SYSTEM,
ValueSpec(ValueKind.TEXT), bridgeAction = null
),
/**
* Hands a destination to whatever navigation app the head unit has, through the standard
* `geo:` intent. Local and firmware-independent: no vendor service is involved, and the
* MG4's navigation is not part of the SAIC vehicle SDK.
*
* [Action.text] is an address or "latitude,longitude".
*/
NAVIGATE_TO(
R.string.act_navigate, ActionGroup.SYSTEM,
ValueSpec(ValueKind.DESTINATION), bridgeAction = null
),
/**
* Puts a yes/no question on screen and stops the rule there when the answer is no.
*
* The only action whose result decides whether the rest of the rule runs. That is the
* point of it: a rule that opens the windows or unlocks the doors on arrival is right
* most of the time and wrong the once, and the driver is the only one who knows which
* this is. Placed first, it turns an automatic rule into a proposed one.
*
* `Action.text` is the question and `Action.number` how many seconds it waits. `0` means
* [ASK_CONFIRM_DEFAULT_SECONDS]: rules saved before the wait was configurable carry no
* value, and the field they never set must not read as an instant refusal.
*
* Silence counts as **no** by default: the actions behind a confirmation are the ones the
* user did not want applied unattended. `Action.yesOnNoAnswer` turns that around for the
* other half of the need — a rule that acts unless it is stopped, where the question is a
* chance to object rather than a permission to ask for. It says nothing about a
* *deliberate* no: tapping "no", or leaving the question, still stops the rule, and a
* question that could never be put on screen still counts as no whatever the setting.
*/
ASK_CONFIRM(
R.string.act_ask_confirm, ActionGroup.SYSTEM,
ValueSpec(
ValueKind.CONFIRM,
min = 5, max = 60,
unitRes = R.string.unit_second, hintRes = R.string.act_ask_confirm_hint
),
bridgeAction = null
),
/**
* Sends a text message through the paired phone.
*
* Same reasoning as [CALL_NUMBER], one profile further out: the head unit has no SIM, so
* there is nothing here to send from. The vendor hands-free service does not help either
* — its `IBtCall` interface is calls only, with no message transaction to address — so
* the message goes out over the Bluetooth Message Access Profile, which the car's own
* Bluetooth settings manage as "MAP Client". Whether that profile is up is a bind, not a
* table: the action reports itself unsupported on a car or a phone that does not carry it.
*
* `Action.text` is the number, `Action.displayName` the contact it was picked from, and
* `Action.payload` the message.
*
* Not standstill-gated, for the reason a call is not: it writes nothing to the vehicle,
* and the message was written when the rule was, not at the wheel.
*/
SEND_SMS(
R.string.act_send_sms, ActionGroup.SYSTEM,
ValueSpec(ValueKind.SMS, hintRes = R.string.act_send_sms_hint), bridgeAction = null
),
/**
* Turns another of the user's rules on, or off.
*
* The cheapest thing in the catalogue that changes what rules can express: a rule that
* only applies during a trip is one rule enabling a second at departure and disabling it
* on arrival, with no state to store and nothing new to evaluate. `Action.text` is the
* target rule's id, and a rule that no longer exists is reported as unsupported — never
* silently skipped, because a chain whose middle link vanished is exactly what a user
* needs told.
*
* A rule cannot disable itself into a state it can never leave: the switch is the same one
* the rule list shows, so the user can always put it back.
*/
ENABLE_RULE(
R.string.act_enable_rule, ActionGroup.SYSTEM,
ValueSpec(ValueKind.RULE), bridgeAction = null
),
DISABLE_RULE(
R.string.act_disable_rule, ActionGroup.SYSTEM,
ValueSpec(ValueKind.RULE), bridgeAction = null
),
/**
* Play/pause, next or previous — sent to the source that is actually playing.
*
* Not a media key, which is the implementation this started as. The car publishes one
* Android media session (`com.android.bluetooth`), so the key either reaches nothing or
* reaches Bluetooth, wakes a phone that was not playing and **changes the audio source**
* while the radio was on. `SaicMediaPlayer` asks the vendor service which source owns the
* audio and commands that source's own player, falling back to media sessions on the A9
* generations, which have no such service.
*
* The values are unchanged (85/87/88, the Android key codes): saved rules store the
* number, and repurposing them would silently redefine every existing rule.
*/
MEDIA_CONTROL(
R.string.act_media_control, ActionGroup.SYSTEM,
ValueSpec(ValueKind.ENUM, options = MediaCommand.OPTIONS), bridgeAction = null
),
/**
* The head unit's own radios.
*
* Turning Bluetooth off ends the hands-free link, and with it every Bluetooth condition —
* a rule that switches it off and then asks which phone is on board gets "unreadable",
* which is the truth.
*/
SET_BLUETOOTH(
R.string.act_set_bluetooth, ActionGroup.SYSTEM,
ValueSpec.BOOL, bridgeAction = null
),
SET_WIFI(
R.string.act_set_wifi, ActionGroup.SYSTEM,
ValueSpec.BOOL, bridgeAction = null
),
/**
* Calls an HTTP(S) endpoint. [Action.flag] carries the verb — false for GET, true for
* POST — and [Action.payload] is the POST body, ignored for GET.
*/
WEBHOOK(
R.string.act_webhook, ActionGroup.SYSTEM,
ValueSpec(ValueKind.WEBHOOK), bridgeAction = null
),
/**
* Waits, in seconds, before the rule's next action runs.
*
* A rule's actions are executed one after the other, so a rule that switches the climate
* on and then sets the fan level asks for the second value while the car is still acting
* on the first — and the vehicle answers from the state it had. This is the pause between
* the two, placed by the user where they know one write needs to land before the next.
*
* Bounded to a minute: the wait holds the cycle thread, and a rule that pauses longer
* than the ignition transition it reacts to is a rule whose later actions may never run.
*/
DELAY(
R.string.act_delay, ActionGroup.SYSTEM,
number(1, 60, R.string.unit_second), bridgeAction = null
),
/**
* Calls a number through the car's own hands-free stack.
*
* A vehicle action despite looking like a phone one: the head unit has no SIM and no
* dialer, so the call is placed by the vendor Bluetooth service on the paired handset.
* `ACTION_CALL` would find nothing to handle it.
*/
@SupportedOn(SWI68, SWI165)
CALL_NUMBER(
R.string.act_call, ActionGroup.SYSTEM,
ValueSpec(ValueKind.CONTACT), "CALL_NUMBER"
),
/**
* Kept only so rules saved by releases that exposed a separate contact action still load.
* New rules use [CALL_NUMBER], whose editor accepts either a number or a contact.
*/
@Deprecated("Compatibility alias for saved rules; use CALL_NUMBER")
@SupportedOn(SWI68, SWI165)
CALL_CONTACT(
R.string.act_call_contact, ActionGroup.SYSTEM,
ValueSpec(ValueKind.CONTACT), "CALL_CONTACT"
);
companion object {
@Suppress("DEPRECATION")
fun byGroup(): Map<ActionGroup, List<ActionType>> =
entries.filterNot { it == CALL_CONTACT }.groupBy { it.group }
/**
* The wait [ASK_CONFIRM] uses when its action carries none.
*
* The spec's own floor (5 s) is not zero: a question that closes before it can be
* read is a refusal dressed as a choice. Its ceiling (60 s) holds a rule's cycle for
* at most a minute, the same order as the delay budget the engine already enforces.
*/
const val ASK_CONFIRM_DEFAULT_SECONDS = 10
}
}
/**
* What [ActionType.MEDIA_CONTROL] sends.
*
* The values are the platform's own media key codes, so the runner has nothing to translate
* and a wrong constant cannot become a different key by accident. Only the three a driver
* asks a rule for: stop and rewind are the wheel's job, not a rule's.
*/
object MediaCommand {
const val PLAY_PAUSE = 85
const val NEXT = 87
const val PREVIOUS = 88
val OPTIONS = listOf(
EnumOption(PLAY_PAUSE, R.string.media_play_pause),
EnumOption(NEXT, R.string.media_next),
EnumOption(PREVIOUS, R.string.media_previous)
)
}
@@ -0,0 +1,596 @@
package com.evsuite.hardware.catalog
import androidx.annotation.StringRes
import com.evsuite.hardware.R
import com.evsuite.hardware.catalog.SnapshotKeys
import com.evsuite.hardware.catalog.VehicleEnums
import com.evsuite.hardware.SupportedOn
import com.evsuite.hardware.FirmwareGen.SWI131
import com.evsuite.hardware.FirmwareGen.SWI132
import com.evsuite.hardware.FirmwareGen.SWI133
import com.evsuite.hardware.FirmwareGen.SWI165
import com.evsuite.hardware.FirmwareGen.SWI68
import com.evsuite.hardware.FirmwareGen.SWI69
import com.evsuite.hardware.catalog.ValueSpec.Companion.enum
import com.evsuite.hardware.catalog.ValueSpec.Companion.number
/** Grouping in the condition picker. */
enum class ConditionGroup(@StringRes val labelRes: Int) {
CONTEXT(R.string.group_context),
ENVIRONMENT(R.string.group_environment),
DRIVING(R.string.group_driving),
ENERGY(R.string.group_energy),
CLIMATE(R.string.group_climate),
COMFORT(R.string.group_comfort),
AUDIO(R.string.group_audio),
ADAS(R.string.group_adas)
}
/**
* Catalogue of evaluable conditions.
*
* [snapshotKey] is the key read from the snapshot EVProfile returns. When it is absent
* from the snapshot the condition is UNAVAILABLE — not false. The distinction is
* deliberate: on a firmware that does not expose outside temperature, a weather rule must
* show as "cannot be evaluated" and do nothing, never behave as if it were 0 °C.
*
* [snapshotKey] is null for conditions computed locally (time, day, Bluetooth), which do
* not depend on the vehicle.
*
* The [SupportedOn] annotation on each vehicle entry declares which firmware generations
* it works on, derived from EVProfile's `FirmwareInfo` and `EVHardware` routing. It
* drives the README matrix (generated) and the editor's runtime filter. Context entries
* carry no annotation — they are firmware-independent.
*/
enum class ConditionType(
@StringRes val labelRes: Int,
val group: ConditionGroup,
val spec: ValueSpec,
val snapshotKey: String?,
/** true when </> comparison is meaningful (otherwise only equality is offered). */
val comparable: Boolean = false,
/** True when the condition itself supplies the event that addresses the rule. */
val eventDriven: Boolean = false
) {
// ── Context (firmware-independent) ───────────────────────────────────────
BT_DEVICE_CONNECTED(
R.string.cond_bt_device, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.BT_DEVICE), snapshotKey = null
),
ANY_BT_CONNECTED(
R.string.cond_bt_any, ConditionGroup.CONTEXT,
ValueSpec.BOOL, snapshotKey = null
),
/**
* A phone that is *in the car*, not merely within radio range of it.
*
* [BT_DEVICE_CONNECTED] answers "the link is up", which a phone left in the house
* answers just as well when the car is parked ten metres away — and it is what made
* arrival rules fire on the driveway. This one is only true of a device still connected
* once the car has actually moved, so a phone that stayed behind drops out of it.
*
* Unknowable until the car has driven: before that the condition is UNAVAILABLE, never
* false, and rules that must act at ignition should gate on [BT_DEVICE_CONNECTED] plus
* a vehicle signal instead.
*/
BT_DEVICE_ONBOARD(
R.string.cond_bt_onboard, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.BT_DEVICE), snapshotKey = null
),
/**
* The phone the head unit has made its hands-free device.
*
* The head unit picks one, whatever the number of phones in range, and that choice is
* available from the first second of the drive — which is what [BT_DEVICE_ONBOARD]
* cannot offer. With two phones both in range it is the head unit's answer, not a
* measurement, so it is a hint about which phone is driving, not proof.
*/
BT_DEVICE_HANDSFREE(
R.string.cond_bt_handsfree, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.BT_DEVICE), snapshotKey = null
),
TIME_OF_DAY(
R.string.cond_time, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.TIME_RANGE), snapshotKey = null
),
DAY_OF_WEEK(
R.string.cond_day, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.DAYS), snapshotKey = null
),
DATE(
R.string.cond_date, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.DATE), snapshotKey = null
),
FIRMWARE_GEN(
R.string.cond_firmware, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.ENUM), snapshotKey = SnapshotKeys.KEY_FIRMWARE_GEN
),
/**
* Inside a radius of a saved point. Context, not a vehicle signal: the fix comes from
* the platform location provider, so it works the same whatever the firmware exposes.
*/
LOCATION_WITHIN(
R.string.cond_location, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.LOCATION, min = 50, max = 2000, unitRes = R.string.unit_metre),
snapshotKey = null
),
PHYSICAL_BUTTON(
R.string.cond_physical_button, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.PHYSICAL_BUTTON), SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT,
eventDriven = true
),
/**
* The network the head unit is joined to, by name.
*
* The one context signal that says "the car is at a place it knows" without a satellite:
* a home network answers the instant the car is on the drive, where a GPS fix does not.
* Unreadable — radio off, or the platform declining to name it — leaves the condition
* unavailable rather than saying "some other network".
*/
WIFI_SSID(
R.string.cond_wifi_ssid, ConditionGroup.CONTEXT,
ValueSpec(ValueKind.TEXT, hintRes = R.string.cond_wifi_ssid_hint),
SnapshotKeys.KEY_WIFI_SSID
),
/**
* A call is in progress through the car.
*
* "In a call", not "ringing": what this reads is the audio route the head unit has taken,
* which a ringing phone has not taken yet. A rule that must not talk over a call is served
* by it; one that wants to react to an incoming call is not, and nothing here pretends
* otherwise.
*/
IN_CALL(
R.string.cond_in_call, ConditionGroup.CONTEXT,
ValueSpec.BOOL, SnapshotKeys.KEY_IN_CALL
),
/**
* Minutes since the ignition came on.
*
* Counted from the transition the service already listens for, so it costs no poll. It is
* unreadable until that transition has been seen — a rule cycle that starts on a car
* already running does not know when the drive began, and must not answer 0.
*/
DRIVE_DURATION(
R.string.cond_drive_duration, ConditionGroup.CONTEXT,
number(0, 600, R.string.unit_minute), SnapshotKeys.KEY_DRIVE_MINUTES, comparable = true
),
/**
* Matches with the given probability, in percent.
*
* Reads nothing, so it has no snapshot key: it is drawn at evaluation time. It exists for
* the rules whose point is not to be the same every day — one greeting in five, a
* different radio station now and then.
*/
RANDOM_CHANCE(
R.string.cond_random_chance, ConditionGroup.CONTEXT,
number(1, 100, R.string.unit_percent), snapshotKey = null
),
// ── Environment ──────────────────────────────────────────────────────────
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
OUTSIDE_TEMP(
R.string.cond_outside_temp, ConditionGroup.ENVIRONMENT,
number(-30, 50, R.string.unit_celsius),
SnapshotKeys.KEY_OUTSIDE_TEMP, comparable = true
),
/**
* What the head unit's weather service says the sky is doing where the car is, as one of
* [WeatherConditions]' seven states.
*
* A list, not free text, and the reason is that the free-text version could not be
* written: the service answers a phrase of its own choosing ("Light rain", "Rain
* showers") in the head unit's own language, so a rule had to guess the provider's exact
* wording and stopped matching the day the display language changed. The phrase is
* classified on the way into the snapshot instead, and the rule stores a state the
* catalogue owns.
*
* A phrase no fragment recognises leaves the key absent, so the condition is
* **unavailable** rather than "some other weather" — same as a service that does not
* answer at all.
*
* Like [ODOMETER], no firmware annotation: it is an app service, available or not.
*/
WEATHER_NOW(
R.string.cond_weather, ConditionGroup.ENVIRONMENT,
ValueSpec(ValueKind.ENUM, options = WeatherConditions.OPTIONS),
SnapshotKeys.KEY_WEATHER_CONDITION
),
// ── Driving ──────────────────────────────────────────────────────────────
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
IGNITION_STATE(
R.string.cond_ignition, ConditionGroup.DRIVING,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.IGNITION_STATES),
SnapshotKeys.KEY_IGNITION
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
IN_PARK(
R.string.cond_in_park, ConditionGroup.DRIVING,
ValueSpec.BOOL, SnapshotKeys.KEY_IN_PARK
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SPEED(
R.string.cond_speed, ConditionGroup.DRIVING,
number(0, 200, R.string.unit_kmh),
SnapshotKeys.KEY_SPEED_KMH, comparable = true
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
DRIVE_MODE(
R.string.cond_drive_mode, ConditionGroup.DRIVING,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.DRIVE_MODES),
SnapshotKeys.KEY_DRIVE_MODE
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
REGEN_LEVEL(
R.string.cond_regen, ConditionGroup.DRIVING,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.REGEN_LEVELS),
SnapshotKeys.KEY_REGEN_LEVEL
),
/**
* The odometer, in kilometres.
*
* No firmware annotation, and it is not an oversight: this comes from the head unit's
* navigation adapter, an app service rather than part of the vehicle SDK, so nothing
* routes it per generation — it answers or it does not, exactly like the message action.
*/
ODOMETER(
R.string.cond_odometer, ConditionGroup.DRIVING,
number(0, 500_000, R.string.unit_km), SnapshotKeys.KEY_ODOMETER_KM, comparable = true
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
ENERGY_SAVING(
R.string.cond_energy_saving, ConditionGroup.DRIVING,
ValueSpec.BOOL, SnapshotKeys.KEY_ENERGY_SAVING
),
// ── Energy (vendor charging service — see com.evsuite.hardware.saic.SaicCharging) ──
@SupportedOn(SWI68, SWI165)
BATTERY_LEVEL(
R.string.cond_battery, ConditionGroup.ENERGY,
number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_BATTERY_PERCENT, comparable = true
),
@SupportedOn(SWI68, SWI165)
CHARGING(
R.string.cond_charging, ConditionGroup.ENERGY,
ValueSpec.BOOL, SnapshotKeys.KEY_CHARGING
),
@SupportedOn(SWI68, SWI165)
CHARGE_LIMIT(
R.string.cond_charge_limit, ConditionGroup.ENERGY,
number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_CHARGE_LIMIT, comparable = true
),
/**
* The charging state itself, where [CHARGING] is only "current is flowing".
*
* The two answer different questions and both are worth asking: a rule that pre-heats the
* cabin wants "charging", while a rule that warns before the driver walks away wants
* "plugged in but not charging" — a state the boolean cannot name.
*/
@SupportedOn(SWI68, SWI165)
CHARGING_STATUS(
R.string.cond_charging_status, ConditionGroup.ENERGY,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.CHARGING_STATUSES),
SnapshotKeys.KEY_CHARGING_STATUS
),
@SupportedOn(SWI68, SWI165)
CHARGE_SCHEDULE_ENABLED(
R.string.cond_charge_schedule, ConditionGroup.ENERGY,
ValueSpec.BOOL, SnapshotKeys.KEY_CHARGE_SCHEDULE
),
/**
* The scheduled window's two ends, read back from the car.
*
* Two conditions rather than the one range [ActionType.SET_CHARGE_WINDOW] writes: the
* write has to move both ends together or the car is left with a window nobody intended,
* while a rule reading them asks about one end at a time ("charging starts after
* midnight"). Comparable, so "before" and "after" are available and not only equality.
*/
@SupportedOn(SWI68, SWI165)
CHARGE_WINDOW_START(
R.string.cond_charge_window_start, ConditionGroup.ENERGY,
ValueSpec(ValueKind.TIME), SnapshotKeys.KEY_CHARGE_WINDOW_START, comparable = true
),
@SupportedOn(SWI68, SWI165)
CHARGE_WINDOW_STOP(
R.string.cond_charge_window_stop, ConditionGroup.ENERGY,
ValueSpec(ValueKind.TIME), SnapshotKeys.KEY_CHARGE_WINDOW_STOP, comparable = true
),
/**
* Remaining range, in kilometres.
*
* The cluster's own number rather than a percentage turned into distance: a rule that
* diverts to a charger is about distance, and battery percentage does not answer it on a
* cold motorway.
*/
@SupportedOn(SWI68, SWI165)
RANGE_ESTIMATE(
R.string.cond_range, ConditionGroup.ENERGY,
number(0, 600, R.string.unit_km), SnapshotKeys.KEY_RANGE_KM, comparable = true
),
@SupportedOn(SWI68, SWI165)
BATTERY_PREHEAT(
R.string.cond_battery_preheat, ConditionGroup.ENERGY,
ValueSpec.BOOL, SnapshotKeys.KEY_BATTERY_PREHEAT
),
// ── Climate + windows (read only, unverified on MG4 — see EVHardware) ────
/**
* Whether the climate system is running, from the vendor service rather than an AOSP
* property id — the same source the car's own HVAC screen reads.
*/
@SupportedOn(SWI68, SWI165)
CLIMATE_ON(
R.string.cond_climate_on, ConditionGroup.CLIMATE,
ValueSpec.BOOL, SnapshotKeys.KEY_CLIMATE_ON
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
AC_ON(
R.string.cond_ac, ConditionGroup.CLIMATE,
ValueSpec.BOOL, SnapshotKeys.KEY_AC_ON
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
HVAC_AUTO(
R.string.cond_hvac_auto, ConditionGroup.CLIMATE,
ValueSpec.BOOL, SnapshotKeys.KEY_HVAC_AUTO
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
RECIRC(
R.string.cond_recirc, ConditionGroup.CLIMATE,
ValueSpec.BOOL, SnapshotKeys.KEY_RECIRC
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
// Bounds from the vendor HMI (HvacConst.AIR_VOLUME_MAX, and the 17…33 clamp in
// HvacActivity where the ends read as LO and HI), not from the AOSP property range.
FAN_SPEED(
R.string.cond_fan_speed, ConditionGroup.CLIMATE,
number(0, VehicleEnums.FAN_LEVEL_MAX), SnapshotKeys.KEY_FAN_SPEED, comparable = true
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
TEMPERATURE_SET(
R.string.cond_temperature_set, ConditionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
SnapshotKeys.KEY_TEMPERATURE_SET, comparable = true
),
/**
* The passenger side's own target, where [TEMPERATURE_SET] is the driver's.
*
* 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.
*/
@SupportedOn(SWI68, SWI165)
PASSENGER_TEMP(
R.string.cond_passenger_temp, ConditionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
SnapshotKeys.KEY_PASSENGER_TEMP, comparable = true
),
@SupportedOn(SWI68, SWI165)
ECON_MODE(
R.string.cond_econ, ConditionGroup.CLIMATE,
ValueSpec.BOOL, SnapshotKeys.KEY_ECON
),
@SupportedOn(SWI68, SWI165)
FRONT_DEFROST(
R.string.cond_front_defrost, ConditionGroup.CLIMATE,
ValueSpec.BOOL, SnapshotKeys.KEY_FRONT_DEFROST
),
@SupportedOn(SWI68, SWI165)
REAR_DEFROST(
R.string.cond_rear_defrost, ConditionGroup.CLIMATE,
ValueSpec.BOOL, SnapshotKeys.KEY_REAR_DEFROST
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
WINDOW_OPEN(
R.string.cond_window_open, ConditionGroup.CLIMATE,
ValueSpec.BOOL, SnapshotKeys.KEY_WINDOW_OPEN
),
/**
* How far the widest-open window is, in percent — the vendor read, unlike [WINDOW_OPEN]
* which is an unverified AOSP property. "Any window open" is `> 0`; a rule that closes
* them cares about the worst case, which is what this reports.
*/
@SupportedOn(SWI68, SWI165)
WINDOW_POSITION(
R.string.cond_window_position, ConditionGroup.CLIMATE,
number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_WINDOW_PERCENT, comparable = true
),
/**
* One window's own opening, where [WINDOW_POSITION] is the widest of the four.
*
* Four entries rather than one with a chooser: the same shape [SEAT_HEAT_LEFT] and
* [SEAT_HEAT_RIGHT] already use, and it keeps the editor free of a control that would
* exist for this one family.
*/
@SupportedOn(SWI68, SWI165)
WINDOW_DRIVER(
R.string.cond_window_driver, ConditionGroup.CLIMATE,
number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_WINDOW_DRIVER, comparable = true
),
@SupportedOn(SWI68, SWI165)
WINDOW_PASSENGER(
R.string.cond_window_passenger, ConditionGroup.CLIMATE,
number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_WINDOW_PASSENGER, comparable = true
),
@SupportedOn(SWI68, SWI165)
WINDOW_REAR_LEFT(
R.string.cond_window_rear_left, ConditionGroup.CLIMATE,
number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_WINDOW_REAR_LEFT, comparable = true
),
@SupportedOn(SWI68, SWI165)
WINDOW_REAR_RIGHT(
R.string.cond_window_rear_right, ConditionGroup.CLIMATE,
number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_WINDOW_REAR_RIGHT, comparable = true
),
@SupportedOn(SWI68, SWI165)
DOORS_LOCKED(
R.string.cond_doors_locked, ConditionGroup.CONTEXT,
ValueSpec.BOOL, SnapshotKeys.KEY_DOORS_LOCKED
),
/**
* Either front door standing open.
*
* Front doors only, and only where the door status property answers: those are the two
* the library reads today, for the volume-drop feature. The rear doors are not claimed
* here rather than guessed at, and a firmware that does not answer leaves the condition
* UNAVAILABLE instead of reporting the doors shut.
*/
@SupportedOn(SWI133)
FRONT_DOOR_OPEN(
R.string.cond_front_door_open, ConditionGroup.CONTEXT,
ValueSpec.BOOL, SnapshotKeys.KEY_FRONT_DOOR_OPEN
),
// ── Comfort ──────────────────────────────────────────────────────────────
@SupportedOn(SWI133, SWI68, SWI165)
SEAT_HEAT_LEFT(
R.string.cond_seat_heat_l, ConditionGroup.COMFORT,
number(0, 3), SnapshotKeys.KEY_SEAT_HEAT_L, comparable = true
),
@SupportedOn(SWI133, SWI68, SWI165)
SEAT_HEAT_RIGHT(
R.string.cond_seat_heat_r, ConditionGroup.COMFORT,
number(0, 3), SnapshotKeys.KEY_SEAT_HEAT_R, comparable = true
),
@SupportedOn(SWI133, SWI68, SWI165)
STEERING_HEAT(
R.string.cond_steering_heat, ConditionGroup.COMFORT,
ValueSpec.BOOL, SnapshotKeys.KEY_STEERING_HEAT
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
SCREEN_BRIGHTNESS(
R.string.cond_brightness, ConditionGroup.COMFORT,
number(VehicleEnums.BRIGHTNESS_MIN, VehicleEnums.BRIGHTNESS_MAX, R.string.unit_percent),
SnapshotKeys.KEY_BRIGHTNESS, comparable = true
),
// ── 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
// the one about the volume, not three groups apart.
/** Something is coming out of the speakers, whichever app is playing it. */
MEDIA_PLAYING(
R.string.cond_media_playing, ConditionGroup.AUDIO,
ValueSpec.BOOL, SnapshotKeys.KEY_MEDIA_PLAYING
),
/**
* The **tuner** is playing — not "something is".
*
* [MEDIA_PLAYING] cannot answer this and never could: it reads
* `AudioManager.isMusicActive`, which is **false while the radio plays**, the tuner's
* stream not being the music one. So a car with the radio on reads as silent there, and
* "only if the radio is already on" was not expressible. This reads `RadioBean.state`, the
* same value [ActionType.RADIO_PLAY_PAUSE] refuses to guess at.
*
* The pair is worth having: a rule that must not talk over the driver's music wants
* [MEDIA_PLAYING], and a rule that changes station, silences the news on arrival or steps
* to the next station wants this one — those are wrong on a car playing Bluetooth.
*
* Unreadable leaves it unavailable rather than false: a radio service that has not bound
* is not a radio that is off.
*/
@SupportedOn(SWI68, SWI165)
RADIO_PLAYING(
R.string.cond_radio_playing, ConditionGroup.AUDIO,
ValueSpec.BOOL, SnapshotKeys.KEY_RADIO_PLAYING
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
MEDIA_VOLUME(
R.string.cond_media_volume, ConditionGroup.AUDIO,
ValueSpec.dynamicNumber(0, VehicleEnums.MEDIA_VOLUME_FALLBACK_MAX),
SnapshotKeys.KEY_MEDIA_VOLUME, comparable = true
),
// ── Driver assistance ────────────────────────────────────────────────────
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
AEB_ENABLED(
R.string.cond_aeb, ConditionGroup.ADAS,
ValueSpec.BOOL, SnapshotKeys.KEY_AEB_ENABLED
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
AEB_MODE(
R.string.cond_aeb_mode, ConditionGroup.ADAS,
enum(*VehicleEnums.AEB_MODES.toTypedArray()), SnapshotKeys.KEY_AEB_MODE
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
AEB_SENSITIVITY(
R.string.cond_aeb_sensitivity, ConditionGroup.ADAS,
enum(*VehicleEnums.SENSITIVITIES.toTypedArray()), SnapshotKeys.KEY_AEB_SENSITIVITY
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
ELK_MODE(
R.string.cond_elk_mode, ConditionGroup.ADAS,
enum(*VehicleEnums.ELK_MODES.toTypedArray()), SnapshotKeys.KEY_ELK_MODE
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
ELK_SENSITIVITY(
R.string.cond_elk_sensitivity, ConditionGroup.ADAS,
enum(*VehicleEnums.SENSITIVITIES.toTypedArray()), SnapshotKeys.KEY_ELK_SENSITIVITY
),
@SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165)
ACC_TJA_MODE(
R.string.cond_acc_tja, ConditionGroup.ADAS,
enum(*VehicleEnums.ACC_TJA_MODES.toTypedArray()), SnapshotKeys.KEY_ACC_TJA_MODE
),
@SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165)
LIMITER_MODE(
R.string.cond_limiter, ConditionGroup.ADAS,
enum(*VehicleEnums.LIMITER_MODES.toTypedArray()), SnapshotKeys.KEY_LIMITER_MODE
),
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
TSR(
R.string.cond_tsr, ConditionGroup.ADAS,
ValueSpec.BOOL, SnapshotKeys.KEY_TSR
),
@SupportedOn(SWI133, SWI132)
OVERSPEED_ALARM(
R.string.cond_overspeed, ConditionGroup.ADAS,
ValueSpec.BOOL, SnapshotKeys.KEY_OVERSPEED_ALARM
),
@SupportedOn(SWI133, SWI132)
SPEED_LIMIT_TONE(
R.string.cond_speed_limit_tone, ConditionGroup.ADAS,
ValueSpec.BOOL, SnapshotKeys.KEY_SPEED_LIMIT_TONE
),
@SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165)
SOUND_WARNING(
R.string.cond_sound_warning, ConditionGroup.ADAS,
ValueSpec.BOOL, SnapshotKeys.KEY_SOUND_WARNING
),
// ── Connection ───────────────────────────────────────────────────────────
/**
* Data the head unit has carried, in megabytes — today, and since the first of the month.
*
* The counters are Android's own, the ones the Settings screen shows, so a rule that stops
* a heavy upload late in the month is reading the same number the driver would.
*
* No `@SupportedOn`: this is the head unit's connection, not a vehicle signal, and it does
* not vary by firmware generation. It varies by *permission* instead — without
* READ_NETWORK_USAGE_HISTORY there is no reading at all, and the condition reports
* unavailable rather than zero, which would read as a car that used no data.
*/
DATA_USED_TODAY(
R.string.cond_data_today, ConditionGroup.CONTEXT,
number(0, 100_000, R.string.unit_megabyte), SnapshotKeys.KEY_DATA_TODAY_MB,
comparable = true
),
DATA_USED_MONTH(
R.string.cond_data_month, ConditionGroup.CONTEXT,
number(0, 100_000, R.string.unit_megabyte), SnapshotKeys.KEY_DATA_MONTH_MB,
comparable = true
);
companion object {
fun byGroup(): Map<ConditionGroup, List<ConditionType>> = entries.groupBy { it.group }
}
}
@@ -0,0 +1,169 @@
package com.evsuite.hardware.catalog
/**
* Stable string keys identifying each vehicle signal a [ConditionType] reads.
*
* These are the canonical identifiers for the catalogue, owned by this library. A consumer
* that snapshots the vehicle (e.g. over IPC) must key its bundle with exactly these
* strings; keeping them here — not in an app — is what lets the catalogue live in the
* shared library without depending on any app's transport.
*
* A key absent from a snapshot means "unreadable", never a value: the whole evaluation
* model rests on that distinction.
*/
object SnapshotKeys {
const val KEY_PHYSICAL_BUTTON_EVENT = "physicalButtonEvent"
const val KEY_FIRMWARE_GEN = "firmwareGen"
const val KEY_STAR_LEFT_SHORT = "starLeftShortPress"
const val KEY_STAR_LEFT_LONG = "starLeftLongPress"
const val KEY_STAR_RIGHT_SHORT = "starRightShortPress"
const val KEY_STAR_RIGHT_LONG = "starRightLongPress"
const val KEY_ASSISTANT_SHORT = "assistantShortPress"
const val KEY_ASSISTANT_LONG = "assistantLongPress"
const val KEY_PHONE_SHORT = "phoneShortPress"
const val KEY_PHONE_LONG = "phoneLongPress"
const val KEY_CENTER_SHORT = "centerShortPress"
const val KEY_CENTER_LONG = "centerLongPress"
const val KEY_VOLUME_UP_SHORT = "volumeUpShortPress"
const val KEY_VOLUME_UP_LONG = "volumeUpLongPress"
const val KEY_VOLUME_DOWN_SHORT = "volumeDownShortPress"
const val KEY_VOLUME_DOWN_LONG = "volumeDownLongPress"
const val KEY_MEDIA_NEXT_SHORT = "mediaNextShortPress"
const val KEY_MEDIA_NEXT_LONG = "mediaNextLongPress"
const val KEY_MEDIA_PREVIOUS_SHORT = "mediaPreviousShortPress"
const val KEY_MEDIA_PREVIOUS_LONG = "mediaPreviousLongPress"
const val KEY_MUTE_SHORT = "muteShortPress"
const val KEY_MUTE_LONG = "muteLongPress"
const val KEY_UP_SHORT = "upShortPress"; const val KEY_UP_LONG = "upLongPress"
const val KEY_DOWN_SHORT = "downShortPress"; const val KEY_DOWN_LONG = "downLongPress"
const val KEY_OK_SHORT = "okShortPress"; const val KEY_OK_LONG = "okLongPress"
const val KEY_LEFT_SHORT = "leftShortPress"; const val KEY_LEFT_LONG = "leftLongPress"
const val KEY_RIGHT_SHORT = "rightShortPress"; const val KEY_RIGHT_LONG = "rightLongPress"
const val KEY_SOURCE_SHORT = "sourceShortPress"; const val KEY_SOURCE_LONG = "sourceLongPress"
const val KEY_OUTSIDE_TEMP = "outsideTempC"
const val KEY_IGNITION = "ignition"
const val KEY_IN_PARK = "inPark"
const val KEY_SPEED_KMH = "speedKmh"
const val KEY_SPEED_READABLE = "speedReadable"
const val KEY_DRIVE_MODE = "driveMode"
const val KEY_REGEN_LEVEL = "regenLevel"
const val KEY_ENERGY_SAVING = "energySaving"
const val KEY_AC_ON = "acOn"
const val KEY_HVAC_AUTO = "hvacAuto"
const val KEY_RECIRC = "recirc"
const val KEY_FAN_SPEED = "fanSpeed"
const val KEY_TEMPERATURE_SET = "temperatureSetC"
const val KEY_WINDOW_OPEN = "windowOpen"
const val KEY_SEAT_HEAT_L = "seatHeatLeft"
const val KEY_SEAT_HEAT_R = "seatHeatRight"
const val KEY_STEERING_HEAT = "steeringHeat"
const val KEY_MEDIA_VOLUME = "mediaVolume"
const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax"
const val KEY_BRIGHTNESS = "brightnessPct"
const val KEY_AEB_ENABLED = "aebEnabled"
const val KEY_AEB_MODE = "aebMode"
const val KEY_AEB_SENSITIVITY = "aebSensitivity"
const val KEY_ELK_MODE = "elkMode"
const val KEY_ELK_SENSITIVITY = "elkSensitivity"
const val KEY_ACC_TJA_MODE = "accTjaMode"
const val KEY_LIMITER_MODE = "limiterMode"
const val KEY_TSR = "tsr"
const val KEY_OVERSPEED_ALARM = "overspeedAlarm"
const val KEY_SPEED_LIMIT_TONE = "speedLimitTone"
const val KEY_SOUND_WARNING = "soundWarning"
const val KEY_ESC = "esc"
const val KEY_DROWSINESS = "drowsiness"
const val KEY_DROWSINESS_SENSITIVITY = "drowsinessSensitivity"
// ── Head-unit connection ────────────────────────────────────────────────
const val KEY_DATA_TODAY_MB = "dataTodayMb"
const val KEY_DATA_MONTH_MB = "dataMonthMb"
// ── Vendor vehicle service (com.evsuite.hardware.saic) ───────────────────────
// Read from the same service the car's own HVAC and charging screens use, so unlike
// the AOSP climate ids above these are confirmed rather than inferred.
const val KEY_CLIMATE_ON = "climateOn"
const val KEY_BATTERY_PERCENT = "batteryPct"
const val KEY_CHARGING = "charging"
const val KEY_CHARGE_LIMIT = "chargeLimitPct"
/** Widest-open window, 0–100 %. Replaces the unverified AOSP window read where it answers. */
const val KEY_WINDOW_PERCENT = "windowPct"
const val KEY_DOORS_LOCKED = "doorsLocked"
const val KEY_ECON = "econOn"
const val KEY_PASSENGER_TEMP = "passengerTempC"
const val KEY_FRONT_DEFROST = "frontDefrost"
const val KEY_REAR_DEFROST = "rearDefrost"
/**
* The vendor charging state, kept as its own number next to [KEY_CHARGING].
*
* [KEY_CHARGING] is a boolean and has to stay one — every rule already written against it
* reads it as such. But "plugged in" and "charging" are not the same state, and the
* boolean cannot tell a driver which of the two the car is in. This is the state itself,
* so a rule can ask.
*/
const val KEY_CHARGING_STATUS = "chargingStatus"
const val KEY_CHARGE_SCHEDULE = "chargeScheduleOn"
/** Scheduled charging window, minutes since midnight. */
const val KEY_CHARGE_WINDOW_START = "chargeWindowStart"
const val KEY_CHARGE_WINDOW_STOP = "chargeWindowStop"
const val KEY_BATTERY_PREHEAT = "batteryPreheat"
/** Remaining range in kilometres, as the cluster shows it. */
const val KEY_RANGE_KM = "rangeKm"
/**
* Each window's own position, 0–100 %.
*
* Alongside [KEY_WINDOW_PERCENT], not instead of it: the widest-open window is the one
* number a "close the windows" rule wants, and these four are what a rule about one
* window needs.
*/
const val KEY_WINDOW_DRIVER = "windowDriverPct"
const val KEY_WINDOW_PASSENGER = "windowPassengerPct"
const val KEY_WINDOW_REAR_LEFT = "windowRearLeftPct"
const val KEY_WINDOW_REAR_RIGHT = "windowRearRightPct"
// ── Head-unit app services (bind, not firmware routing) ──────────────────
// The navigation adapter and the weather service belong to the head unit's own apps, not
// to the vehicle SDK, so nothing routes them per generation: they answer or they do not.
/** Odometer in kilometres. */
const val KEY_ODOMETER_KM = "odometerKm"
/**
* The sky where the car is, as one of [WeatherConditions]' states.
*
* The service answers a phrase in the head unit's language; what travels here is the
* state that phrase was classified into, because a rule cannot be written against wording
* the driver has never seen. A phrase nothing recognises leaves the key **absent**, which
* reads as "cannot tell" rather than as fine weather.
*
* Now, and only now. There is no forecast key: the map service answers current conditions
* and nothing else on this firmware — see `SaicWeather`.
*/
const val KEY_WEATHER_CONDITION = "weatherCondition"
/**
* Whether the *tuner* is playing, from `RadioBean.state`.
*
* Not a duplicate of [KEY_MEDIA_PLAYING], and the difference is the reason this exists:
* `AudioManager.isMusicActive` is **false while the radio plays**, the tuner's stream not
* being the music one. So the media key answers "no" on a car with the radio on, and no
* rule could ask about the radio at all.
*
* A vendor service reading, so it carries a firmware annotation where the media one does
* not. Absent when the service will not answer — never false, which would read as a radio
* that is off.
*/
const val KEY_RADIO_PLAYING = "radioPlaying"
// ── Platform context (no vehicle involved) ───────────────────────────────
// Read from Android, not from the car, so they carry no firmware annotation. Absent
// still means unreadable: a head unit whose audio state cannot be read leaves the
// media condition unavailable rather than claiming silence.
const val KEY_MEDIA_PLAYING = "mediaPlaying"
const val KEY_WIFI_SSID = "wifiSsid"
const val KEY_IN_CALL = "inCall"
/** Minutes since the ignition came on, absent before the first transition is seen. */
const val KEY_DRIVE_MINUTES = "driveMinutes"
// ── AOSP door status (SWI133) ────────────────────────────────────────────
/** True while either front door reads as open. Absent where the property is unreadable. */
const val KEY_FRONT_DOOR_OPEN = "frontDoorOpen"
}
@@ -0,0 +1,140 @@
package com.evsuite.hardware.catalog
import androidx.annotation.StringRes
/**
* The kind of value a condition or action manipulates.
*
* This is what keeps the UI small: the editor knows no individual condition or action, it
* only knows how to draw one control per [ValueKind]. Adding a catalogue entry is
* therefore one enum line, not another screen.
*/
enum class ValueKind {
/** On/off switch. */
BOOL,
/** Bounded slider (see [ValueSpec.min] / [ValueSpec.max]). */
NUMBER,
/** Closed list of named values ([ValueSpec.options]). */
ENUM,
/** Paired Bluetooth device, identified by MAC address. */
BT_DEVICE,
/** Start → end time range. */
TIME_RANGE,
/**
* One time of day, stored as minutes since midnight in `number`.
*
* Not a [NUMBER] with a 0…1439 range: the charging window the car answers is a clock
* time, and a slider asking for "1 380" is unanswerable at the wheel. The editor draws
* the same picker [TIME_RANGE] uses, minus the second half.
*/
TIME,
/** Day-of-week selection. */
DAYS,
/** One exact local calendar date, stored as ISO-8601 (yyyy-MM-dd). */
DATE,
/** EVProfile driving profile, identified by id. */
PROFILE,
/**
* Another of the user's own rules, identified by id.
*
* Not [APP] with a rule id in it: the chooser has to list rules, and a rule that names a
* rule which no longer exists must be reported as such rather than silently doing nothing.
*/
RULE,
/** Installed application, identified by package name. */
APP,
/** Phone-book entry; the rule stores the selected number, not a mutable contact id. */
CONTACT,
/** Free text (notification message). */
TEXT,
/**
* A navigation destination: an address or "latitude,longitude", in `text`.
*
* Free text would have been enough to store it, and was — but nobody types coordinates
* at the wheel. The kind exists so the editor can offer the two things that make the
* field answerable there: the car's own position, and the places already saved.
*/
DESTINATION,
/** Webhook URL plus an optional POST body. */
WEBHOOK,
/**
* A recipient and a message: the number in `text`, the contact it was picked from in
* `displayName`, and the message itself in the field a webhook body already uses.
*
* Two controls rather than one, and no new model field — a message has an addressee, and
* it is the same phone-book field [CONTACT] offers, so a number can still be typed where
* no contact exists.
*/
SMS,
/**
* A yes/no question in `text`, and how long it waits for an answer in `number` seconds.
*
* The wait belongs to the rule, not to the app: a question asked before the doors unlock
* is answered at once or not at all, while one asked at the end of a drive has to survive
* the driver looking away. `0` — every rule saved before the field existed — means the
* default wait.
*/
CONFIRM,
/**
* A point and a radius: `text` holds "latitude,longitude", `number` the radius in
* metres. Two controls rather than one, but no new model field — the flat union in
* `Condition` already has both.
*/
LOCATION,
/**
* A tuner setting: the band in `number` ([ValueSpec.options]), an optional frequency in
* `text`, and whether the radio starts playing in `flag`.
*
* One kind rather than three actions, because they are three halves of the same sentence:
* "put the radio on FM 103.5 and play it" is what a driver means, and splitting it made
* the band reachable only through an action that could not carry a station. The frequency
* is optional — naming a band alone is a complete instruction, and on **DAB** it is the
* only one possible, a DAB service being addressed by ensemble and service id rather than
* by anything a driver could type.
*/
RADIO,
/** Physical button + short/long event, edited with two dropdowns. */
PHYSICAL_BUTTON,
/** Nothing to enter. */
NONE
}
/** One named value of a [ValueKind.ENUM]. */
data class EnumOption(val value: Int, @StringRes val labelRes: Int)
/**
* Description of the input control.
*
* [max] is -1 when the bound is only known at runtime — the maximum media volume depends
* on the firmware and is readable only from the vehicle snapshot. The editor substitutes
* the real value, falling back to [fallbackMax] when the car does not answer.
*/
data class ValueSpec(
val kind: ValueKind,
val min: Int = 0,
val max: Int = 0,
@StringRes val unitRes: Int = 0,
val options: List<EnumOption> = emptyList(),
val fallbackMax: Int = 0,
/**
* Placeholder for a free-text field, when the field alone does not say what to type.
* "103.5" is obvious once you know the box wants a frequency and impossible to guess
* before that.
*/
@StringRes val hintRes: Int = 0
) {
companion object {
val NONE = ValueSpec(ValueKind.NONE)
val BOOL = ValueSpec(ValueKind.BOOL)
fun number(min: Int, max: Int, @StringRes unitRes: Int = 0) =
ValueSpec(ValueKind.NUMBER, min = min, max = max, unitRes = unitRes)
/** Upper bound resolved at runtime from the vehicle snapshot. */
fun dynamicNumber(min: Int, fallbackMax: Int, @StringRes unitRes: Int = 0) =
ValueSpec(ValueKind.NUMBER, min = min, max = -1, unitRes = unitRes, fallbackMax = fallbackMax)
fun enum(vararg options: EnumOption) =
ValueSpec(ValueKind.ENUM, options = options.toList())
}
}
@@ -0,0 +1,179 @@
package com.evsuite.hardware.catalog
import com.evsuite.hardware.R
/**
* Numeric values the vehicle expects, copied from EVProfile.
*
* These are protocol values, not display indices: reordering or renumbering them changes
* the setting applied to the car. Sources in EVProfile: `model/DriveMode.kt`,
* `model/RegenLevel.kt`, and `hardware/EVHardware.kt` (objects `AebMode`, `Swi68Mode`,
* plus the ELK / SAS comments).
*/
object VehicleEnums {
/** DriveMode.value — EVProfile model/DriveMode.kt */
val DRIVE_MODES = listOf(
EnumOption(2, R.string.drive_eco),
EnumOption(3, R.string.drive_normal),
EnumOption(4, R.string.drive_sport),
EnumOption(6, R.string.drive_snow),
EnumOption(7, R.string.drive_custom)
)
/** RegenLevel.value — EVProfile model/RegenLevel.kt */
val REGEN_LEVELS = listOf(
EnumOption(0, R.string.regen_low),
EnumOption(1, R.string.regen_medium),
EnumOption(2, R.string.regen_high),
EnumOption(3, R.string.regen_adaptive),
EnumOption(5, R.string.regen_off),
EnumOption(6, R.string.regen_one_pedal)
)
/** EVHardware.AebMode */
val AEB_MODES = listOf(
EnumOption(1, R.string.aeb_alert_only),
EnumOption(2, R.string.aeb_alert_brake)
)
/** AEB / ELK sensitivity — 1 low, 2 standard, 3 high (0 means "not configured"). */
val SENSITIVITIES = listOf(
EnumOption(1, R.string.sensitivity_low),
EnumOption(2, R.string.sensitivity_standard),
EnumOption(3, R.string.sensitivity_high)
)
/** ELK — see EVHardware.getElkMode / setElkMode. */
val ELK_MODES = listOf(
EnumOption(1, R.string.elk_off),
EnumOption(2, R.string.elk_warn),
EnumOption(3, R.string.elk_assist),
EnumOption(5, R.string.elk_full)
)
/** EVHardware.Swi68Mode — ACC/TJA. OFF is 0x4, not 0. */
val ACC_TJA_MODES = listOf(
EnumOption(4, R.string.acc_off),
EnumOption(1, R.string.acc_acc),
EnumOption(2, R.string.acc_tja)
)
/** SAS speed limiter — 0 off, 2 manual, 3 intelligent. */
val LIMITER_MODES = listOf(
EnumOption(0, R.string.limiter_off),
EnumOption(2, R.string.limiter_manual),
EnumOption(3, R.string.limiter_smart)
)
/** VehicleIgnitionState (standard AAOS) — useful values only. */
val IGNITION_STATES = listOf(
EnumOption(1, R.string.ignition_lock),
EnumOption(2, R.string.ignition_off),
EnumOption(3, R.string.ignition_acc),
EnumOption(4, R.string.ignition_on),
EnumOption(5, R.string.ignition_start)
)
/** Firmware generations — FirmwareInfo.Gen in EVProfile. */
val FIRMWARE_GENS = listOf("SWI133", "SWI132", "SWI68", "SWI69", "SWI131", "SWI165")
// Audio bounds — EVHardware AUDIO_TYPE_MIN/MAX and AUDIO_LEVEL_MIN/MAX.
const val AUDIO_TYPE_MIN = 0
const val AUDIO_TYPE_MAX = 3
const val AUDIO_LEVEL_MIN = -9
const val AUDIO_LEVEL_MAX = 9
/** EVProfile's brightness floor: never black out the vehicle screen. */
const val BRIGHTNESS_MIN = 5
const val BRIGHTNESS_MAX = 100
/** Fallback when the vehicle does not report its real maximum volume. */
const val MEDIA_VOLUME_FALLBACK_MAX = 30
// ── Climate — from the vendor HMI, not from the AOSP property ranges ─────
// HvacConst.AIR_VOLUME_MAX and the 17…33 clamp in
// HvacActivity.onLongTouch where the two ends display as LO and HI.
const val FAN_LEVEL_MAX = 11
const val CABIN_TEMP_MIN = 17
const val CABIN_TEMP_MAX = 33
/** Charge limit the vehicle accepts, in percent. */
const val CHARGE_LIMIT_MIN = 40
const val CHARGE_LIMIT_MAX = 100
/**
* The charging states the vendor service reports, as the car's own charging screen
* groups them.
*
* Only the states a rule can act on are named. The service answers others — bookkeeping
* values a driver has no rule for — and a condition comparing against one of those simply
* does not match, which is the honest outcome for a state the catalogue does not claim.
*
* [CHARGING_UNPLUGGED] is why this exists as a number rather than as the boolean
* [SnapshotKeys.KEY_CHARGING]: a cable plugged in with charging stopped is neither
* "charging" nor "unplugged", and the boolean has to pick one.
*/
const val CHARGING_UNPLUGGED = 0
const val CHARGING_AC = 1
const val CHARGING_DONE = 2
const val CHARGING_FAULT = 4
const val CHARGING_PLUGGED_IDLE = 7
const val CHARGING_DC = 10
val CHARGING_STATUSES = listOf(
EnumOption(CHARGING_UNPLUGGED, R.string.charging_unplugged),
EnumOption(CHARGING_AC, R.string.charging_ac),
EnumOption(CHARGING_DC, R.string.charging_dc),
EnumOption(CHARGING_PLUGGED_IDLE, R.string.charging_plugged_idle),
EnumOption(CHARGING_DONE, R.string.charging_done),
EnumOption(CHARGING_FAULT, R.string.charging_fault)
)
/**
* The states in which current is actually flowing into the battery.
*
* What [SnapshotKeys.KEY_CHARGING] means, and the reason it is derived from this set
* rather than from `status != 0`: a cable plugged in with charging stopped
* ([CHARGING_PLUGGED_IDLE]), a finished charge ([CHARGING_DONE]) and a fault
* ([CHARGING_FAULT]) are all non-zero, and all three would make a "when charging" rule
* fire on a car that is not charging.
*/
val CHARGING_ACTIVE_STATES = setOf(CHARGING_AC, CHARGING_DC)
/**
* What a glass action asks for: open the window, or close it. Nothing in between.
*
* The vendor service takes a **command**, not a position — it reads a percentage back
* and accepts nothing above 7 on the way in — so the only two states a rule can reach are
* fully open and fully closed. A percentage control would offer a hundred and one values
* of which ninety-nine are unreachable, and the history would report "applied" for a
* position the glass never went to.
*
* Which raw command opens and which closes is **not** encoded here, because it is not a
* property of the catalogue: it is a property of the car, established by `GlassProbe` and
* stored in `GlassEvidence`. The executor translates. That is why these values are 0 and 1
* rather than the observed commands — a rule saved on one car must not carry another car's
* command numbers.
*/
const val WINDOW_CLOSE = 0
const val WINDOW_OPEN = 1
val WINDOW_COMMANDS = listOf(
EnumOption(WINDOW_CLOSE, R.string.window_close),
EnumOption(WINDOW_OPEN, R.string.window_open)
)
/**
* The tuner's bands, as `RadioType` numbers them.
*
* DAB is in the list because stepping already reaches it and the band is what a rule
* cannot otherwise change: `TUNE_RADIO` takes a frequency, and a DAB service is addressed
* by ensemble and service id, so there is no DAB station a driver could type.
*/
val RADIO_BANDS = listOf(
EnumOption(1, R.string.radio_band_am),
EnumOption(2, R.string.radio_band_fm),
EnumOption(4, R.string.radio_band_dab)
)
}
@@ -0,0 +1,110 @@
package com.evsuite.hardware.catalog
import com.evsuite.hardware.R
import java.text.Normalizer
/**
* The sky, as a short list a rule can be written against.
*
* The head unit's weather service answers a **phrase**, in the head unit's language: "Light
* rain", "Rain showers", "Pluie faible". That phrase was once what a rule compared itself
* against, and it made the condition unwritable — the driver had to guess the provider's
* exact wording, in the exact language the car was set to, and a rule written on a French car
* stopped matching the day the display language changed. Nobody can type a value they have
* never seen.
*
* So the phrase is classified here, once, into one of seven states the picker can offer as a
* list. The provider's wording stays the provider's business; the catalogue's vocabulary is
* what a rule stores, which is why a rule keeps meaning "when it rains" whatever language the
* car speaks afterwards.
*
* **Keywords, not a table of phrases.** The service is free to say "Heavy rain" today and
* "Rain, heavy" tomorrow, and it says it in six languages; matching a fragment survives both,
* where an exact list would need every phrase the provider owns. A phrase that matches
* nothing is **not** classified — [classify] answers null and the condition stays
* unavailable, which is the same answer a car that will not talk to its weather service
* gives. Guessing "clear" from a phrase we did not recognise would be the one wrong answer:
* it reads as good weather.
*/
object WeatherConditions {
const val CLEAR = 1
const val CLOUDY = 2
const val RAIN = 3
const val SNOW = 4
const val STORM = 5
const val FOG = 6
const val WIND = 7
val OPTIONS = listOf(
EnumOption(CLEAR, R.string.weather_clear),
EnumOption(CLOUDY, R.string.weather_cloudy),
EnumOption(RAIN, R.string.weather_rain),
EnumOption(SNOW, R.string.weather_snow),
EnumOption(STORM, R.string.weather_storm),
EnumOption(FOG, R.string.weather_fog),
EnumOption(WIND, R.string.weather_wind)
)
/**
* Fragments per state, in the languages the apps ship, **in the order they are tried**.
*
* The order is the whole design: real phrases name more than one thing ("thunderstorms
* with rain", "snow showers", "Schneeschauer"), and the first match wins, so the states
* are tried from the most specific weather to the least. A thunderstorm is a storm before
* it is rain; falling snow is snow before it is a shower; the sky being clear is what is
* left when nothing else matched.
*/
private val KEYWORDS: List<Pair<Int, List<String>>> = listOf(
STORM to listOf(
"thunder", "storm", "squall",
"orage", "tempete", "gewitter", "sturm", "tormenta", "temporale", "tempesta",
"trovoada", "tempestade"
),
SNOW to listOf(
"snow", "sleet", "blizzard", "hail",
"neige", "grele", "verglas", "schnee", "hagel", "nieve", "granizo", "aguanieve",
"neve", "grandine"
),
RAIN to listOf(
"rain", "drizzle", "shower",
"pluie", "bruine", "averse", "ondee", "regen", "niesel", "schauer", "lluvia",
"llovizna", "chubasco", "piogg", "rovesc", "chuva", "chuvisco", "aguaceiro"
),
FOG to listOf(
"fog", "mist", "haze",
"brouillard", "brume", "nebel", "dunst", "niebla", "neblina", "calima", "nebbia",
"foschia", "nevoeiro"
),
WIND to listOf(
"wind", "gale", "breeze",
"vent", "boen", "viento"
),
CLOUDY to listOf(
"cloud", "overcast",
"nuage", "couvert", "wolk", "bedeckt", "nub", "cubierto", "nuvol", "coperto",
"nuvem", "encoberto"
),
CLEAR to listOf(
"clear", "sun", "fair",
"soleil", "clair", "degage", "sonn", "klar", "heiter", "sol", "despejado",
"sereno", "limpo"
)
)
/**
* The state [phrase] describes, or null when nothing here recognises it.
*
* Accents are stripped before matching, so "Pluie éparse" and "Bewölkt" are read by the
* same fragments that a head unit writing them without accents would produce.
*/
fun classify(phrase: String): Int? {
val normalised = normalise(phrase)
if (normalised.isBlank()) return null
return KEYWORDS.firstOrNull { (_, fragments) -> fragments.any { it in normalised } }?.first
}
private fun normalise(phrase: String): String =
Normalizer.normalize(phrase.lowercase(), Normalizer.Form.NFD)
.replace(Regex("\\p{Mn}+"), "")
}
@@ -0,0 +1,163 @@
package com.evsuite.hardware.diag
import android.content.Context
import androidx.annotation.VisibleForTesting
import com.evsuite.hardware.AppLogger
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Captures uncaught exceptions to a file in `filesDir`, surfaced on the next launch.
*
* On a vehicle there is no crash reporter and usually no cable. Without this, a crash at
* ignition leaves nothing behind: the process dies, the user sees the app "not working",
* and there is no way to find out why after the fact.
*
* It lives here rather than in each app because every app in the suite crashes on the same
* head unit for the same reasons, and the two copies that preceded this one had each fixed
* a bug the other still carried — one chained the previous handler, the other counted bytes
* instead of characters when truncating.
*
* The previous handler is always chained, so the system still gets to do its job.
*/
object CrashLogger {
private const val TAG = "Crash"
private const val FILE_NAME = "last_crash.txt"
/**
* Reports are truncated by keeping the HEAD, not the tail.
*
* The exception and its top frames are at the top of the report — that is the part
* that identifies the bug. Cutting from the start would discard exactly what is
* needed and keep the least interesting frames.
*/
private const val MAX_BYTES = 64 * 1024
/** How far down the `cause` chain to walk, and how many frames to keep per cause. */
private const val MAX_CAUSE_DEPTH = 5
private const val CAUSE_FRAMES = 20
/**
* Installs the handler. [appName] only titles the report — it is read by a human, and
* the package name alone does not say which app of the suite produced it.
*/
fun install(context: Context, appName: String) {
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
// Logging is inside the guard too: AppLogger formats through a shared
// SimpleDateFormat, so a concurrent log call can make this throw — and a
// throw here would cost both the report and the previous handler.
runCatching {
AppLogger.e(TAG, "uncaught exception on thread '${thread.name}': $throwable")
}
runCatching { write(appContext, appName, thread, throwable) }
} finally {
// Chain, never swallow: the platform still needs to terminate the process,
// and swallowing here would leave the app in an undefined state instead.
previous?.uncaughtException(thread, throwable)
}
}
}
fun hasReport(context: Context): Boolean = file(context).exists()
fun read(context: Context): String? =
file(context).takeIf { it.exists() }?.runCatching { readText() }?.getOrNull()
fun clear(context: Context) {
runCatching { file(context).delete() }
}
@VisibleForTesting
internal fun write(context: Context, appName: String, thread: Thread, throwable: Throwable) {
val stackTrace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString()
val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())
val report = buildString {
appendLine("$appName crash report")
appendLine("time : $timestamp")
appendLine("thread : ${thread.name}")
appendLine("version : ${version(context)}")
appendLine("android : ${android.os.Build.VERSION.SDK_INT}")
appendLine("device : ${android.os.Build.DEVICE}")
appendLine()
appendLine("-- Stack trace --")
appendLine(stackTrace)
appendCauses(throwable)
appendLine("-- Recent log (AppLogger) --")
appendLine(AppLogger.dump())
}
writeAtomically(file(context), truncate(report))
}
/**
* `printStackTrace` already prints the causes, but abbreviates them to the frames that
* differ from the enclosing trace. On a reflection layer the interesting frame is
* usually one of the common ones, so the chain is spelled out again in full.
*/
private fun StringBuilder.appendCauses(throwable: Throwable) {
var cause = throwable.cause
var depth = 0
while (cause != null && depth < MAX_CAUSE_DEPTH) {
appendLine("-- Caused by --")
appendLine(cause.toString())
cause.stackTrace.take(CAUSE_FRAMES).forEach { appendLine(" at $it") }
appendLine()
cause = cause.cause
depth++
}
}
/**
* Truncates to [MAX_BYTES] keeping the BEGINNING, and counts bytes rather than
* characters: the log buffer is full of non-ASCII, which is two bytes in UTF-8, so a
* character count writes up to twice the announced ceiling.
*/
@VisibleForTesting
internal fun truncate(content: String): ByteArray {
val bytes = content.toByteArray(Charsets.UTF_8)
if (bytes.size <= MAX_BYTES) return bytes
val marker = "\n... report truncated (${bytes.size} bytes) ...\n".toByteArray(Charsets.UTF_8)
// Never cut mid-character: back up over UTF-8 continuation bytes (10xxxxxx).
var end = MAX_BYTES - marker.size
while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
return bytes.copyOfRange(0, end) + marker
}
/**
* Temp file then rename over the target.
*
* Never delete-then-write: that leaves a window with no file at all, and this runs
* while the process is already dying — a second failure mid-write would destroy the
* previous report without producing a new one.
*/
private fun writeAtomically(target: File, content: ByteArray) {
val temp = File(target.parentFile, "${target.name}.${System.nanoTime()}.tmp")
try {
temp.writeBytes(content)
if (!temp.renameTo(target)) {
temp.delete()
AppLogger.w(TAG, "could not rename crash report into place")
}
} catch (e: Exception) {
temp.delete()
AppLogger.w(TAG, "could not write crash report: ${e.message}")
}
}
private fun version(context: Context): String = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "?"
private fun file(context: Context) = File(context.filesDir, FILE_NAME)
}
@@ -0,0 +1,206 @@
package com.evsuite.hardware.diag
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.io.ByteArrayOutputStream
import java.math.BigInteger
import java.net.HttpURLConnection
import java.net.URL
import java.security.SecureRandom
import java.util.Base64
import java.util.zip.Deflater
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Uploads a report to a PrivateBin instance.
*
* PrivateBin is zero-knowledge: the server stores ciphertext and never sees the key, which
* travels in the URL fragment and is therefore never sent in an HTTP request. That is what
* makes pasting a vehicle diagnostic — VIN-free, but full of firmware and rule detail —
* acceptable at all. The extra password means the link alone is not enough either.
*
* The format is PrivateBin's v2 API and every constant here is load-bearing:
*
* - the key is 32 random bytes, base58 in the fragment;
* - PBKDF2-HMAC-SHA256 over `key ‖ password`, 100 000 iterations, 8-byte salt;
* - AES-256-GCM, 16-byte IV, 128-bit tag;
* - the authenticated data is the **exact** JSON text of `adata` as it is sent, because the
* browser that later decrypts re-serialises the array it received with `JSON.stringify`.
* Reordering it, adding a space, or letting a JSON library format it breaks decryption on
* the reader's side while the upload still succeeds — so `adata` is built as a string
* here and embedded verbatim;
* - the plaintext is raw DEFLATE (no zlib header) of `{"paste":"…"}`.
*
* No Android dependency: the whole encoding is unit-testable on the JVM, and [poster] is
* injectable so a test can assert the wire format without a network.
*/
object PrivateBin {
/** PrivateBin's own defaults; the instance may shorten [expire], never lengthen it. */
data class Config(
val baseUrl: String,
val password: String,
val expire: String,
val formatter: String,
)
sealed interface Outcome {
/** The paste URL, key fragment included — the only form that can be read back. */
data class Ok(val url: String) : Outcome
data class Failed(val reason: String) : Outcome
}
private const val ITERATIONS = 100_000
private const val KEY_BITS = 256
private const val TAG_BITS = 128
private const val KEY_BYTES = 32
private const val IV_BYTES = 16
private const val SALT_BYTES = 8
private const val CONNECT_TIMEOUT_MS = 15_000
private const val READ_TIMEOUT_MS = 30_000
fun paste(
text: String,
config: Config,
random: SecureRandom = SecureRandom(),
poster: (String, String) -> String = ::post,
): Outcome {
val key = ByteArray(KEY_BYTES).also(random::nextBytes)
val iv = ByteArray(IV_BYTES).also(random::nextBytes)
val salt = ByteArray(SALT_BYTES).also(random::nextBytes)
val adata = adataJson(iv, salt, config.formatter)
val derived = deriveKey(key, config.password, salt)
// Built element by element rather than reflected off a map: a consumer has already
// been bitten once by R8 stripping the generic signatures Gson's reflective path
// needs, and a payload that only fails in a minified build is the worst kind.
val payload = JsonObject().apply { addProperty("paste", text) }.toString()
val plain = rawDeflate(payload.toByteArray())
val cipherText = encrypt(derived, iv, plain, adata.toByteArray())
val body = """{"v":2,"adata":$adata,"ct":"${base64(cipherText)}",""" +
""""meta":{"expire":"${config.expire}"}}"""
val response = try {
poster(config.baseUrl, body)
} catch (e: Exception) {
return Outcome.Failed(e.message ?: e.javaClass.simpleName)
}
val json = runCatching { JsonParser.parseString(response).asJsonObject }.getOrNull()
?: return Outcome.Failed("unreadable response")
if (json.get("status")?.asInt != 0) {
return Outcome.Failed(json.get("message")?.asString ?: "refused by the server")
}
val path = json.get("url")?.asString ?: return Outcome.Failed("no url in the response")
return Outcome.Ok(
config.baseUrl.trimEnd('/') + "/" + path.trimStart('/') + "#" + base58(key)
)
}
// -------------------------------------------------------------------------
/**
* `JSON.stringify` of the metadata array, byte for byte: no spaces, this field order.
* Both the server's signature check and the reader's decryption depend on it.
*/
private fun adataJson(iv: ByteArray, salt: ByteArray, formatter: String): String =
"""[["${base64(iv)}","${base64(salt)}",$ITERATIONS,$KEY_BITS,$TAG_BITS,""" +
""""aes","gcm","zlib"],"$formatter",0,0]"""
/**
* PBKDF2-HMAC-SHA256 over the raw key bytes with the password appended.
*
* Hand-rolled rather than `SecretKeyFactory`, which takes a `char[]` and would have to
* encode 32 random bytes as text to accept them — there is no encoding that survives
* that round trip, and PrivateBin hashes the bytes.
*/
private fun deriveKey(key: ByteArray, password: String, salt: ByteArray): ByteArray {
val secret = key + password.toByteArray()
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret, "HmacSHA256")) }
val out = ByteArray(KEY_BITS / 8)
var offset = 0
var block = 1
while (offset < out.size) {
var u = mac.doFinal(salt + byteArrayOf(0, 0, 0, block.toByte()))
val acc = u.copyOf()
repeat(ITERATIONS - 1) {
u = mac.doFinal(u)
for (i in acc.indices) acc[i] = (acc[i].toInt() xor u[i].toInt()).toByte()
}
val take = minOf(acc.size, out.size - offset)
acc.copyInto(out, offset, 0, take)
offset += take
block++
}
return out
}
private fun encrypt(key: ByteArray, iv: ByteArray, plain: ByteArray, aad: ByteArray): ByteArray =
Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, iv))
updateAAD(aad)
doFinal(plain)
}
/** Raw DEFLATE — no zlib wrapper, which is what PrivateBin's "zlib" compression means. */
private fun rawDeflate(data: ByteArray): ByteArray {
val deflater = Deflater(Deflater.BEST_COMPRESSION, true)
return try {
deflater.setInput(data)
deflater.finish()
val out = ByteArrayOutputStream(data.size)
val buffer = ByteArray(8 * 1024)
while (!deflater.finished()) out.write(buffer, 0, deflater.deflate(buffer))
out.toByteArray()
} finally {
deflater.end()
}
}
private fun base64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
/** Bitcoin alphabet, as PrivateBin's fragment encoding uses. */
private fun base58(data: ByteArray): String {
val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
var value = BigInteger(1, data)
val fifty8 = BigInteger.valueOf(58)
val out = StringBuilder()
while (value.signum() > 0) {
val (quotient, remainder) = value.divideAndRemainder(fifty8)
out.append(alphabet[remainder.toInt()])
value = quotient
}
// Leading zero bytes carry no magnitude, so they must be re-added explicitly.
for (byte in data) {
if (byte.toInt() != 0) break
out.append(alphabet[0])
}
return out.reverse().toString()
}
private fun post(url: String, body: String): String {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty("Content-Type", "application/json")
// PrivateBin answers HTML to anything that does not claim to be its own client.
setRequestProperty("X-Requested-With", "JSONHttpRequest")
}
return try {
connection.outputStream.use { it.write(body.toByteArray()) }
val stream = if (connection.responseCode in 200..299) connection.inputStream
else connection.errorStream
stream?.bufferedReader()?.use { it.readText() }
?: throw IllegalStateException("HTTP ${connection.responseCode}, empty body")
} finally {
connection.disconnect()
}
}
}
@@ -0,0 +1,16 @@
package com.evsuite.hardware.model
import androidx.annotation.StringRes
import com.evsuite.hardware.R
enum class DriveMode(val value: Int, val label: String, @StringRes val labelRes: Int) {
ECO(2, "Eco", R.string.drive_eco),
NORMAL(3, "Normal", R.string.drive_normal),
SPORT(4, "Sport", R.string.drive_sport),
SNOW(6, "Snow", R.string.drive_snow),
CUSTOM(7, "Custom", R.string.drive_custom);
companion object {
fun fromValue(v: Int): DriveMode = values().firstOrNull { it.value == v } ?: NORMAL
}
}
@@ -0,0 +1,41 @@
package com.evsuite.hardware.model
import java.util.UUID
data class DrivingProfile(
val id: String = UUID.randomUUID().toString(),
val name: String,
val driveMode: DriveMode,
val regenLevel: RegenLevel,
val steeringHeat: Boolean = false,
val seatHeatLeft: Int = 0, // 0=off, 1, 2, 3
val seatHeatRight: Int = 0,
// ADAS SWI133 (Katman4) — default OFF for compatibility with existing profiles
val overspeedAlarm: Boolean = false,
val speedLimitTone: Boolean = false,
val adasMode: Int = 0, // 0=Off, 1=Limiter, 2=Auto, 3=ACC, 4=ICA
// ADAS SWI68 — separate fields to isolate per-firmware configurations
val soundWarning: Boolean = false,
val swi68AdasMode: Int = 0x4, // ACC/TJA mode (CarAccTja): 0x4=Off, 0x1=ACC, 0x2=TJA/ICA
// Speed limiter (SAS) — INDEPENDENT of the ACC/TJA mode (SWI132).
// swi132LimiterConfigured=false (default + profiles created before this feature) → the
// limiter is NOT touched when the profile is applied (no regression on car state).
val swi132LimiterConfigured: Boolean = false,
val swi132SasMode: Int = 0, // SAS: 0=Disabled, 2=Manual, 3=Intelligent
// AEB — front collision avoidance system (common to SWI133 + SWI68)
val aebEnabled: Boolean = false, // false=OFF, true=ON
val aebMode: Int = 1, // 1=Alert only, 2=Alert + auto braking
val aebSensitivity: Int = 0, // 0=not configured, 1=Low, 2=Standard, 3=High (SWI133)
// ELK — lane departure assist
val elkMode: Int = 0, // 0=not configured, 1=OFF, 2=Warn(LDW), 3=Assist(LDP), 5=ELK
val elkSensitivity: Int = 0, // 0=not configured, 1=Low, 2=Standard, 3=High
// ELK SWI132 — sound alert + vibration (SWI132-specific)
val lasAudibleWarning: Boolean = true, // true=ON (default ON in the car)
val lasVibrationReminder: Boolean = true, // true=ON (default ON in the car)
// Energy saving + TSR
val energySaving: Boolean = false,
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
)
@@ -0,0 +1,17 @@
package com.evsuite.hardware.model
/**
* Serialized format of the profile backup (a file in the car's storage).
*
* [schemaVersion] lets the format evolve without breaking older backups (Gson ignores
* unknown fields and applies defaults for missing ones).
*/
data class ProfileBackup(
val schemaVersion: Int = CURRENT_SCHEMA_VERSION,
val defaultId: String? = null,
val profiles: List<DrivingProfile> = emptyList()
) {
companion object {
const val CURRENT_SCHEMA_VERSION = 1
}
}
@@ -0,0 +1,17 @@
package com.evsuite.hardware.model
import androidx.annotation.StringRes
import com.evsuite.hardware.R
enum class RegenLevel(val value: Int, val label: String, @StringRes val labelRes: Int) {
LOW(0, "Low", R.string.regen_low),
MEDIUM(1, "Medium", R.string.regen_medium),
HIGH(2, "High", R.string.regen_high),
ADAPTIVE(3, "Adaptive", R.string.regen_adaptive),
OFF(5, "Off", R.string.regen_off),
ONE_PEDAL(6, "One Pedal", R.string.regen_one_pedal);
companion object {
fun fromValue(v: Int): RegenLevel = values().firstOrNull { it.value == v } ?: MEDIUM
}
}
@@ -0,0 +1,535 @@
package com.evsuite.hardware.probe
import android.content.Context
import android.os.IBinder
import com.evsuite.hardware.EVHardware
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.saic.SaicAidl
import com.evsuite.hardware.saic.SaicHub
import com.evsuite.hardware.saic.SaicService
/**
* What happened when we asked a vendor interface whether it exists.
*
* The three outcomes are not interchangeable and the `RI-` survey turns on telling them apart.
* A service that is not on this head unit is a closed question. A service that is there and
* refuses an unprivileged caller is an open one with a different answer — the capability exists
* and EVSuite is not allowed it, worth knowing before anyone spends a drive on it. Only a
* service that answers is worth reading values from.
*/
enum class BindVerdict { ABSENT, DENIED, ANSWERED }
/**
* One interface, surveyed.
*
* [values] holds raw reads keyed by the method that produced them, as strings, because a probe
* that converts before it records has already made the assumption it exists to test. A null
* value means the call reached the service and came back empty — a reading, and a different one
* from the method not being called at all.
*/
data class InterfaceProbe(
val name: String,
val descriptor: String?,
val verdict: BindVerdict,
val values: Map<String, String?> = emptyMap(),
val note: String? = null,
)
/**
* The `RI-` survey: which of the interfaces the tickets name does this head unit publish, and
* what do they say on this car.
*
* Nothing here is production. Every entry point returns a record rather than a value a caller
* could act on, and none is referenced from any production path in this library — the isolation
* CP-004 settled on for exactly this. Call it only from an app's unstable capture. A probe
* result must never become a fallback: the point is to find out whether a value is real, and
* code consuming it before the answer arrives has skipped the question.
*
* **No setter is called from this file, on any interface.** Several of these binders carry
* writes that would move a car — charge control, screen sleep, focus requests, a vehicle power
* switch. None is referenced here, so no later edit reaches one by accident from a probe path.
*/
object RuntimeInterfaceProbe {
/**
* Bind both hubs, then let them come up before surveying.
*
* Without this every hub name reads [BindVerdict.ABSENT], which is the one false negative
* this exercise cannot afford: it looks exactly like "this head unit does not publish the
* interface" and would close questions that were never asked. Binding is asynchronous, so
* a survey run in the same breath as this call still reads absent — leave a moment, or run
* it from a capture that has been up for a while.
*/
fun connect(context: Context) {
SaicHub.connect(context)
engMode.connect(context)
}
/**
* `queryClient(code)` across [CODE_SWEEP], recording the interface descriptor of whatever
* comes back.
*
* This is the prerequisite the whole series shares. EVHardware uses a handful of codes
* because a handful were needed and the rest were never established, which is why RI-002
* knows what it wants to ask the battery service and has nowhere to send it. Asking each
* code for a binder and reading back its descriptor settles the map on the actual car.
*
* Safe in a way a transaction sweep would not be: `queryClient` hands back a handle and
* [IBinder.getInterfaceDescriptor] asks that handle its own name. Neither is a call *into*
* the interface, so nothing of unknown meaning is invoked. That distinction is why a sweep
* is acceptable here and was refused in CP-004.
*/
fun adapterClientMap(): List<InterfaceProbe> {
// Asked once, ahead of the sweep: without an adapter client every code below fails
// before it reaches a binder, and nineteen ABSENT entries then look exactly like a head
// unit that publishes nothing. That is the false negative RI-002 was nearly closed on.
val note = if (EVHardware.hasCarAdapterClient()) null else NO_ADAPTER_CLIENT
return CODE_SWEEP.map { code ->
describe("queryClient(0x${code.toString(16)})", EVHardware.a9ClientBinder(code), note)
}
}
/**
* The same question of the vehicle-service hub, which resolves sub-services by name.
*
* EVHardware already uses `vehiclesetting` and `aircondition`. `vehiclescreen` is the one
* RI-004 wants and that nothing in EVSuite has ever asked for.
*/
fun hubServiceMap(): List<InterfaceProbe> = HUB_NAMES.map { name ->
describe("hub:$name", SaicHub.service(name))
}
/**
* RI-001: the vehicle option getters, on the config client.
*
* Raw integers and nothing else. `-1` comes back from these getters when the call itself
* fails, so it cannot be read as a not-fitted code — a probe that mapped these to booleans
* would turn a dead service into "no options fitted".
*/
fun carConfig(): InterfaceProbe = read(
label = "ICarConfigService",
descriptor = DESC_CONFIG,
binder = EVHardware.a9ClientBinder(CODE_CONFIG),
calls = CONFIG_CALLS,
note = "queryClient(0x2); -1 is the vendor wrapper's error value, not a not-fitted code",
)
/**
* RI-004: the head unit's own power state, on the hub's `carPower` sub-service.
*
* Three reads and no more. This binder also carries a shutdown request, a restart request
* and a backlight switch; none is named here. The backlight *status* is a boolean over the
* wire and arrives as `0` or `1`, which is why it reads through the same integer path as
* the other two.
*
* The screen sub-service is deliberately not probed. It publishes no getter at all — every
* transaction it answers either moves the screen or registers a callback — so there is
* nothing on it a read-only survey may call, and guessing a code to find out is the exact
* thing this file must not do. Observing screen state needs a listener, which is a larger
* change than a survey and belongs to RI-004's follow-up rather than here.
*/
fun carPower(): InterfaceProbe = read(
label = "ICarPowerService",
descriptor = DESC_CAR_POWER,
binder = SaicHub.service(HUB_CAR_POWER),
calls = POWER_CALLS,
note = "hub:carPower; reads only, the shutdown, restart and backlight writes are not called",
)
/**
* RI-005: what the head unit says its own part numbers are.
*
* `FirmwareGen` keys every branch in this library off a generation the project inferred
* from a build string. These four are identity the vehicle asserts instead, and the reason
* to read them is not to replace the generation model but to find out whether the two move
* together. Two cars on one inferred generation with different software part numbers would
* mean the label is coarser than the behaviour it gates.
*
* Reached through the engineering-mode hub, which is a plausible place for an unprivileged
* caller to be refused. [BindVerdict.DENIED] is a legitimate answer here, not a failure.
*/
fun deviceIdentity(): InterfaceProbe = read(
label = "IDIDManager",
descriptor = DESC_DID,
binder = engModeService(ENG_DID),
calls = DID_CALLS,
note = "engmode hub:did; an engineering interface may legitimately refuse us",
)
/**
* RI-008: a second opinion on vehicle speed.
*
* [VehicleWriteGate][com.evsuite.hardware.VehicleWriteGate] refuses writes on a moving car
* and fails closed when speed is unreadable, off one source whose scale CP-003 settled. One
* settled source is not a corroborated one. This one reports speed as a float where the
* existing source is integral, which either confirms the scale independently or exposes a
* rounding assumption nobody has had to question.
*
* The other three reads come along because they cost one transaction each and describe the
* same moment the speed was taken in — a speed without its gear and power state is harder
* to interpret afterwards than one with them.
*/
fun evsVehicleState(): InterfaceProbe = read(
label = "ICarEvsService",
descriptor = DESC_EVS,
binder = EVHardware.a9ClientBinder(CODE_EVS),
calls = EVS_CALLS,
note = "queryClient(0x3); speed is a float here and integral in the existing source",
)
/**
* RI-003: the typed property channel, reached by name instead of by reflection.
*
* The ticket opens by asking whether the SWI133 acquisition sequence works on SWI68. The
* hub answers sub-services by name, `vehicleproperty` is one of the names it resolves, and
* this asks that way round — if it answers here, the reflection path was never the question.
*
* The ids are chosen so the capture can be read without a second drive. Four are values the
* app already reads through `CarPropertyManager`, so agreement or disagreement is checkable
* against a known good number rather than taken on faith. `EV_BATTERY_AVG_TEMP` is the one
* SWI68 declares and has never published — if it answers *here*, the battery temperature the
* energy screen leaves blank has a source. `POWER_MODE_SWITCH` is the SWI133 id, read to find
* out whether the numbering carries over, and read only: no setter on this interface is
* named anywhere in this file. [PROP_NOT_PUBLISHED] is an id nothing publishes, and exists
* to answer scope item 4 — whether absence is distinguishable from a property reading zero.
*
* The batch `getPropertys` is not covered: its argument is an int array and its reply a typed
* list, neither of which the vararg convention carries, and one transaction per id settles
* the same questions.
*/
fun vehicleProperty(): InterfaceProbe = read(
label = "IVehiclePropertyService",
descriptor = DESC_VEHICLE_PROPERTY,
binder = SaicHub.service(HUB_VEHICLE_PROPERTY),
calls = VEHICLE_PROPERTY_CALLS,
note = "hub:vehicleproperty; getters only, and 0x7FFFFFFF is an id nothing publishes",
)
/**
* RI-002: the charging service's own energy counters, with the validity flags beside them.
*
* RI-002 stalled on a unit nobody could prove: a counter read 25.1 where the state of charge
* said about 18.4, and a number whose unit is a guess must not reach a screen. The interface
* publishes a `…V` flag next to several of these values, which is the vehicle's own statement
* about whether the number beside it means anything — reading the pair is what turns the
* question from an argument into a measurement.
*
* Voltage and current come along because the energy screen now derives battery power from the
* property pair, and this service publishes the same two quantities through a different path.
* Two routes to one number is how a sign convention gets settled without a lab.
*
* Reads only. This binder also carries charge start and stop, the charge current limit and
* the reservation schedule — a driver could be left plugged in and not charging by a careless
* transaction code — and none of them is named here.
*/
fun vehicleCharging(): InterfaceProbe = read(
label = "IVehicleChargingService",
descriptor = DESC_VEHICLE_CHARGING,
binder = SaicHub.service(HUB_VEHICLE_CHARGING),
calls = CHARGING_CALLS,
note = "hub:vehiclecharging; the V suffixes are the vehicle's own validity flags",
)
/**
* Can this head unit read the car's diagnostics — the OBD question, asked of the platform.
*
* There is no OBD or UDS service anywhere in the head unit's own applications; the vendor
* side of that question is closed. What is left is `CarDiagnosticManager`, the only
* OBD-shaped surface AAOS itself defines, and it is gated behind a privileged permission
* this app will not hold. The expected answer is therefore a refusal — and a recorded
* refusal is worth more than an assumed one, because it is the difference between "we
* checked" and "we decided not to look".
*/
fun carDiagnostics(): InterfaceProbe {
val bound = EVHardware.isCarBound()
val manager = EVHardware.carManagerNamed(CAR_DIAGNOSTIC_SERVICE)?.javaClass?.name
return InterfaceProbe(
name = "CarDiagnosticManager",
descriptor = DESC_CAR_DIAGNOSTIC,
verdict = verdictFor(bound, listOf(manager)),
values = mapOf("car_bound" to bound.toString(), "diagnostic_manager" to manager),
note = "android.car DIAGNOSTIC_SERVICE; a privileged permission we do not hold",
)
}
/**
* Everything above in one pass, for an unstable capture to write out.
*
* The two maps come first: on the first car that runs this they are what make the rest of
* the series answerable, and they are worth having even if every typed read below them
* comes back empty.
*/
fun survey(): List<InterfaceProbe> = buildList {
add(
InterfaceProbe(
"firmware", null, BindVerdict.ANSWERED,
mapOf("generation" to FirmwareInfo.getGeneration().name),
)
)
addAll(adapterClientMap())
addAll(hubServiceMap())
addAll(engModeMap())
add(carConfig())
add(carPower())
add(deviceIdentity())
add(evsVehicleState())
add(vehicleProperty())
add(vehicleCharging())
add(carDiagnostics())
}
/**
* Reached, and said something?
*
* The one rule the enum exists for: a binder that came back and then answered nothing at
* all is [BindVerdict.DENIED], not [BindVerdict.ANSWERED] with a page of nulls. In practice
* that is a permission refusal or a descriptor mismatch, and recording it as "answered,
* empty" would let a later session read absence of data as data. One answer is enough to
* call an interface alive.
*/
fun verdictFor(reached: Boolean, answers: Collection<String?>): BindVerdict = when {
!reached -> BindVerdict.ABSENT
answers.any { it != null } -> BindVerdict.ANSWERED
else -> BindVerdict.DENIED
}
/**
* The engineering-mode hub's own sub-services, surveyed the same way as the vehicle hub's.
*
* A second hub, with the same shape and a different bind point. Only `did` is asked for a
* value; the rest are named so that the map records whether they are there, which is what
* makes a later ticket about any of them answerable without another drive.
*/
fun engModeMap(): List<InterfaceProbe> = ENG_NAMES.map { name ->
describe("engmode:$name", engModeService(name))
}
/** The engineering hub resolves sub-services by name, exactly as the vehicle hub does. */
private fun engModeService(name: String): IBinder? =
SaicAidl.callBinder(engMode.binder(), DESC_ENG_MODE, TX_ENG_GET_SERVICE, name)
private val engMode = SaicService(ENG_PACKAGE, ENG_ACTION, "EV_RI_ENGMODE")
/** A binder that will not name itself was reached and refused, which is not the same as absent. */
private fun describe(label: String, binder: IBinder?, note: String? = null): InterfaceProbe {
val descriptor = binder?.let { runCatching { it.interfaceDescriptor }.getOrNull() }
return InterfaceProbe(
label, descriptor, verdictFor(binder != null, listOf(descriptor)), note = note,
)
}
/** How a reply is laid out. A wrong choice here reads a valid parcel as nonsense. */
internal enum class ReadAs { INT, FLOAT, STRING }
/**
* One transaction worth calling, how to read what it sends back, and what it takes.
*
* [args] exists for the property channel, where the transaction is `getIntProperty` and the
* question is the id written into it. Everywhere else it stays empty.
*/
internal data class Call(
val code: Int,
val readAs: ReadAs = ReadAs.INT,
val args: List<Any> = emptyList(),
)
/**
* Call each named transaction and record what came back, as text.
*
* Booleans go through [ReadAs.INT] on purpose: AIDL puts them on the wire as `0` or `1`,
* and recording the wire value keeps the capture one step away from an interpretation.
*/
private fun read(
label: String,
descriptor: String,
binder: IBinder?,
calls: Map<String, Call>,
note: String,
): InterfaceProbe {
val target = binder
?: return InterfaceProbe(label, descriptor, BindVerdict.ABSENT, note = note)
val values = calls.mapValues { (_, call) ->
val args = call.args.toTypedArray()
when (call.readAs) {
ReadAs.INT -> SaicAidl.callInt(target, descriptor, call.code, *args)?.toString()
ReadAs.FLOAT -> SaicAidl.callFloat(target, descriptor, call.code, *args)?.toString()
ReadAs.STRING -> SaicAidl.callString(target, descriptor, call.code, *args)
}
}
return InterfaceProbe(label, descriptor, verdictFor(true, values.values), values, note)
}
/**
* Adapter service codes to sweep. The low range, because the map is the point — kept
* explicit and short rather than open-ended.
*/
private val CODE_SWEEP = (0x0..0x12).toList()
/**
* 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
* preamble, and closing RI-001, RI-002 or RI-008 on a field that was never set is the exact
* mistake this sentence exists to prevent.
*/
internal const val NO_ADAPTER_CLIENT =
"no CarAdapterClient on this firmware — sCarAdapter is assigned only by " +
"initKatman4Swi69, so this call failed before it reached a binder. ABSENT here is " +
"not a reading of the vehicle."
/**
* Every sub-service name the hub is known to resolve.
*
* `vehicleproperty` is the one to watch: a typed property channel reached by name rather
* than through the reflection path EVHardware uses on SWI133, which is the question RI-003
* opens with. Whether it answers on the VSM generations is the whole of that ticket.
*/
private val HUB_NAMES = listOf(
"aircondition", "carPower", "vehicleAudio", "vehiclecharging", "vehiclecondition",
"vehiclecontrol", "vehicleproperty", "vehiclescreen", "vehiclesetting", "vehicleTbox",
)
/** The engineering hub's sub-services. Only `did` is read; the rest are mapped. */
private val ENG_NAMES = listOf(
"avm", "did", "log", "system_hardware", "system_setting", "tuner",
)
private const val ENG_PACKAGE = "com.saicmotor.service.engmode"
private const val ENG_ACTION = "com.saicmotor.service.engmode.EngineeringModeService"
private const val ENG_DID = "did"
private const val TX_ENG_GET_SERVICE = 1
private const val HUB_CAR_POWER = "carPower"
private const val HUB_VEHICLE_PROPERTY = "vehicleproperty"
private const val HUB_VEHICLE_CHARGING = "vehiclecharging"
private const val CODE_CONFIG = 0x2
private const val CODE_EVS = 0x3
private const val CAR_DIAGNOSTIC_SERVICE = "DIAGNOSTIC_SERVICE"
private const val DESC_CONFIG = "com.saicmotor.carapi.config.ICarConfigService"
private const val DESC_CAR_POWER = "com.saicmotor.sdk.vehiclesettings.ICarPowerService"
private const val DESC_ENG_MODE = "com.saicmotor.sdk.engmode.IEngineeringMode"
private const val DESC_DID = "com.saicmotor.sdk.engmode.IDIDManager"
private const val DESC_EVS = "com.saicmotor.carapi.evs.ICarEvsService"
private const val DESC_VEHICLE_PROPERTY =
"com.saicmotor.sdk.vehiclesettings.IVehiclePropertyService"
private const val DESC_VEHICLE_CHARGING =
"com.saicmotor.sdk.vehiclesettings.IVehicleChargingService"
private const val DESC_CAR_DIAGNOSTIC = "android.car.diagnostic.CarDiagnosticManager"
/** The two typed getters RI-003 needs; every setter on this interface stays unnamed. */
private const val TX_PROPERTY_GET_INT = 6
private const val TX_PROPERTY_GET_FLOAT = 5
/**
* The transactions on the property interface that write. Named only so a test can prove
* [VEHICLE_PROPERTY_CALLS] stays clear of them — nothing calls them.
*/
internal val PROPERTY_WRITES = setOf(1, 2, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24)
/**
* The transactions on the charging interface that change something: charge and discharge
* control, the current limit, the reservation schedule, the target states of charge and the
* pile list edits. Named only so a test can prove [CHARGING_CALLS] stays clear of them.
*/
internal val CHARGING_WRITES = setOf(
1, 2, 12, 14, 16, 18, 20, 22, 24, 26, 28, 38, 40, 44, 47, 50, 51, 53, 54, 55, 57, 58,
59, 60, 81,
)
/** An id nothing on this firmware publishes, so absence has something to look like. */
private const val PROP_NOT_PUBLISHED = 0x7FFFFFFF
/**
* The transactions on the power interface that change something: the backlight switch, a
* shutdown request and a restart request. Named here only so a test can prove [POWER_CALLS]
* stays clear of them — nothing calls them.
*/
internal val POWER_WRITES = setOf(1, 4, 5)
/** RI-004's reads. Every other transaction on this interface changes something. */
internal val POWER_CALLS = mapOf(
"getBackLightStatus" to Call(2),
"getBootReason" to Call(3),
"getCurrentPowerMode" to Call(11),
)
/** RI-005's four identity strings. */
private val DID_CALLS = mapOf(
"getAssemblyPartNum" to Call(1, ReadAs.STRING),
"getHardwarePartNum" to Call(2, ReadAs.STRING),
"getSoftwarePartNum" to Call(3, ReadAs.STRING),
"getProductSerialNum" to Call(4, ReadAs.STRING),
)
/** RI-008's speed, and the three reads that say what moment it was taken in. */
internal val EVS_CALLS = mapOf(
"getVehicleSpeed" to Call(38, ReadAs.FLOAT),
"getReverseReq" to Call(13),
"getSteeringAngle" to Call(16),
"getSystemPowerState" to Call(37),
)
/**
* RI-003's reads, keyed by the property each one asks for.
*
* The type per id is the one `CarPropertyManager` uses for the same id where the app already
* reads it, so a disagreement is about the channel and not about how the reply was decoded.
*/
internal val VEHICLE_PROPERTY_CALLS = mapOf(
// Four the app already reads another way: the comparison RI-003 scope item 2 asks for.
"EV_BATTERY_PCT 0x2160F404" to Call(TX_PROPERTY_GET_INT, args = listOf(0x2160F404)),
"EV_RANGE_KM 0x2140F41C" to Call(TX_PROPERTY_GET_INT, args = listOf(0x2140F41C)),
"BMS_PACK_VOL 0x2160F406" to Call(TX_PROPERTY_GET_FLOAT, ReadAs.FLOAT, listOf(0x2160F406)),
"BMS_PACK_CRNT 0x2160F407" to Call(TX_PROPERTY_GET_FLOAT, ReadAs.FLOAT, listOf(0x2160F407)),
// The two the energy screen has no source for, asked of a channel that has never been tried.
"EV_BATTERY_AVG_TEMP 0x1160030E" to
Call(TX_PROPERTY_GET_FLOAT, ReadAs.FLOAT, listOf(0x1160030E)),
"HVAC_AMBIENT_TEMP 0x1560252A" to
Call(TX_PROPERTY_GET_FLOAT, ReadAs.FLOAT, listOf(0x1560252A)),
// Scope item 3: an id that means something on SWI133, read to see what it means here.
"POWER_MODE_SWITCH 0x6030021" to Call(TX_PROPERTY_GET_INT, args = listOf(0x6030021)),
// Scope item 4: what absence looks like, so a zero can be told from a silence.
"unpublished id" to Call(TX_PROPERTY_GET_INT, args = listOf(PROP_NOT_PUBLISHED)),
)
/**
* RI-002's reads: the counters, the validity flag beside each one that has one, and the two
* quantities the energy screen already derives battery power from.
*/
internal val CHARGING_CALLS = mapOf(
"getChargingStatus" to Call(9),
"getCurrentElectricQuantity" to Call(3, ReadAs.FLOAT),
"getCurrentElectricQuantityV" to Call(29),
"getPowerBatteryVol" to Call(64, ReadAs.FLOAT),
"getPowerBatteryVolV" to Call(65),
"getChargingCurrent" to Call(11),
"getAcVoltage" to Call(91, ReadAs.FLOAT),
"getAcCurrent" to Call(90, ReadAs.FLOAT),
"getAccConsumptionAfterStart" to Call(74, ReadAs.FLOAT),
"getTotalConsumptionAfterStart" to Call(76, ReadAs.FLOAT),
"getTotalRegenEnrgAfterStart" to Call(78, ReadAs.FLOAT),
"getTotalRegenRngAfterStart" to Call(80, ReadAs.FLOAT),
"getElecCsumpPerKm" to Call(66, ReadAs.FLOAT),
"getElecCsumpPerKmV" to Call(67),
"getElectricityLevel" to Call(92),
"getDrivingBatteryHeat" to Call(37),
"getEnergyFlowInfo" to Call(72),
// RI-009, for the price of one transaction: a typed list puts its element count on the
// wire first, so reading the reply as an integer says how many charging stations the
// vehicle's own dataset has here — -1 for a null list, 0 for an empty one. Whether the
// regional variant answers in Europe at all is the whole of that ticket's first question.
"getChargingPileList (count)" to Call(61),
)
/** The option getters RI-001 names as decisive, with their transaction codes. */
internal val CONFIG_CALLS = mapOf(
"getTpmsConfigData" to Call(1),
"getHvacConfigData" to Call(7),
"getCarModelConfigData" to Call(8),
"getWheelPosition" to Call(9),
"getSeatHeatingConfigData" to Call(23),
"getCarRegionConfig" to Call(28),
"getAmbientLightConfig" to Call(41),
"getOnePedalConfig" to Call(43),
)
}
@@ -0,0 +1,183 @@
package com.evsuite.hardware.saic
import com.evsuite.hardware.FirmwareInfo
/**
* What the head unit's navigation stack last said about the trip in progress.
*
* Every field is nullable and every field is raw. The adapter service logs these values
* without naming a unit, and no unit has been proven on a vehicle yet, so nothing here is
* converted, scaled or rounded — a kilometre read as a metre is the mistake that turns an
* arrival forecast into a stranding. Consumers must treat the distances as unvalidated until
* an on-vehicle run says otherwise, which is what CP-040 exists to collect.
*
* Absence is absence: a field that never arrived stays null rather than becoming zero.
*/
data class NavGuidance(
/** Raw `guideStatusChange` code. The mapping to "guiding"/"idle" is not yet proven. */
val guideStatus: Int? = null,
/** Raw `remainingDistanceChange` value — distance to destination, unit unproven. */
val remainingDistanceRaw: Int? = null,
/** Raw `remainingTimesChange` value. The service logs it as `remainingMinutes`. */
val remainingMinutes: Int? = null,
/** Raw `guideInfosChange` distance — to the next manoeuvre, not to the destination. */
val nextTurnDistanceRaw: Int? = null,
/** Raw `guideInfosChange` icon code. */
val nextTurnIcon: Int? = null,
/** `guideInfosChange` direction text, as the navigation app worded it. */
val direction: String? = null,
/** `roadInfoChange` text. */
val road: String? = null,
/** How many callbacks have been folded in. The liveness signal a capture needs. */
val events: Int = 0,
/** Monotonic time of the last accepted callback, or null before the first one. */
val updatedAtElapsedMs: Long? = null,
) {
/**
* Remaining distance to the destination in km, when the raw value's unit is known.
*
* The adapter names no unit. A capture on 2026-09-04, SWI68-29958-1300R67, read 9788
* against a remaining time of 25 — and `MapService` logs that second value as
* `remainingMinutes`, so 9788 metres is 23,5 km/h to the destination and 9788 kilometres
* is not a number about a car. Metres is the only reading that survives.
*
* One observation, so this is gated per generation like every other unit here: an
* unlisted firmware keeps the raw value and no kilometres are claimed. A drive confirms
* it by comparing against the odometer span.
*/
fun remainingDistanceKm(generation: FirmwareInfo.Gen): Double? =
remainingDistanceRaw
?.takeIf { generation in REPORTS_METRES }
?.let { it / 1000.0 }
companion object {
val EMPTY = NavGuidance()
/** Generations observed to report `getRemainingDistance` in metres. */
private val REPORTS_METRES = setOf(FirmwareInfo.Gen.SWI68)
}
}
/**
* Folds one adapter-service callback into [NavGuidance].
*
* Pure and Android-free so the transaction map is testable on the JVM: the binder shell does
* the parcel reading, this decides what the numbers mean. Transaction codes come from
* `IGeneralNotificationListener` in `saicadapterservice_overseas_eh32` (R69 EH32); an
* unrecognised code leaves the state untouched rather than guessing at its payload.
*/
object NavGuidanceReducer {
const val TX_GUIDE_STATUS_CHANGE = 1
const val TX_GUIDE_INFOS_CHANGE = 2
const val TX_ROAD_INFO_CHANGE = 3
const val TX_REMAINING_TIMES_CHANGE = 12
const val TX_REMAINING_DISTANCE_CHANGE = 13
/** Every code this decoder claims to understand. Anything else is left alone. */
val KNOWN_TRANSACTIONS = setOf(
TX_GUIDE_STATUS_CHANGE,
TX_GUIDE_INFOS_CHANGE,
TX_ROAD_INFO_CHANGE,
TX_REMAINING_TIMES_CHANGE,
TX_REMAINING_DISTANCE_CHANGE,
)
/**
* @param first the callback's first int argument, when it has one.
* @param second the second int argument — only `guideInfosChange` carries one.
* @param text the string argument, for the two callbacks that carry one.
* @return the new state, or [state] unchanged when [code] is not one this understands.
*/
fun fold(
state: NavGuidance,
code: Int,
first: Int? = null,
second: Int? = null,
text: String? = null,
atElapsedMs: Long,
): NavGuidance {
val folded = when (code) {
TX_GUIDE_STATUS_CHANGE -> state.copy(guideStatus = first)
// icon, then distance to the next manoeuvre, then the direction wording.
TX_GUIDE_INFOS_CHANGE -> state.copy(
nextTurnIcon = first,
nextTurnDistanceRaw = second,
direction = text,
)
TX_ROAD_INFO_CHANGE -> state.copy(road = text)
TX_REMAINING_TIMES_CHANGE -> state.copy(remainingMinutes = first)
TX_REMAINING_DISTANCE_CHANGE -> state.copy(remainingDistanceRaw = first)
else -> return state
}
return folded.copy(events = state.events + 1, updatedAtElapsedMs = atElapsedMs)
}
}
/**
* How many times each transaction code arrived, decoded or not.
*
* The transaction map was read from an R69 build; a vehicle may run another revision of the
* same generation. If SAIC inserted a method into `IGeneralNotificationListener` between two
* revisions, every later code shifts by one — and a shifted map does not fail, it silently
* reports the wrong number as a distance. That is the one failure this instrument must not
* have, so the census records *every* code the adapter sends. A capture whose census shows
* traffic on codes this build does not decode, or silence on the ones it does, has caught the
* shift before anyone builds an arrival forecast on it.
*
* Counting is a bare array write on the adapter's fan-out thread: no lock, no allocation, no
* blocking. Callbacks may arrive on several binder threads at once and a lost increment is
* acceptable here — this counts what kind of traffic exists, not how much of it exactly.
*/
class TransactionCensus(private val ceiling: Int = DEFAULT_CEILING) {
private val counts = IntArray(ceiling)
/**
* The size of the last parcel seen on each code, or -1 for a code never seen.
*
* A count says a code exists; a size says whether this build reads it correctly. The
* 2026-09-07 capture is why: with guidance running, code 13 decoded as a remaining distance
* of zero, and nothing in the artifact could tell a genuine zero from a payload of the wrong
* shape read as an int. A parcel carrying one int is the descriptor plus four bytes, and
* anything larger settles it without decoding a single field.
*
* A byte count is not a place. This is the whole reason it can be exported.
*/
private val lastBytes = IntArray(ceiling) { UNSEEN }
/** Codes at or above [ceiling]. Kept separate rather than growing the array. */
@Volatile
var beyondCeiling: Int = 0
private set
fun record(code: Int, payloadBytes: Int = UNSEEN) {
if (code !in 0 until ceiling) {
beyondCeiling++
return
}
counts[code]++
if (payloadBytes >= 0) lastBytes[code] = payloadBytes
}
/** Codes seen at least once, with their counts. Allocates — for reporting, not the hot path. */
fun snapshot(): Map<Int, Int> =
counts.indices.filter { counts[it] > 0 }.associateWith { counts[it] }
/** The same codes, with the size of the last parcel each carried. Absent when never measured. */
fun payloadBytes(): Map<Int, Int> =
lastBytes.indices.filter { lastBytes[it] >= 0 }.associateWith { lastBytes[it] }
fun clear() {
counts.fill(0)
lastBytes.fill(UNSEEN)
beyondCeiling = 0
}
private companion object {
/** `IGeneralNotificationListener` declares well under this many methods on R69. */
const val DEFAULT_CEILING = 128
const val UNSEEN = -1
}
}
@@ -0,0 +1,117 @@
package com.evsuite.hardware.saic
/**
* A trip driven one leg at a time, because the command that starts guidance carries one point.
*
* **Why a chain exists at all.** A plan with a charging stop is a stop and then a destination, and
* nothing on this head unit has been seen to drive both from one handover.
* [SaicNavGuidance.goTo] takes a single point by construction. [SaicNavGuidance.startNavFromEvRoute]
* takes a pathway and a destination and, on SWI68 on 2026-09-09, is the channel that actually
* starts guidance — but whether Telenav drives *through* the pathway or straight to the last point
* is not known, and no drive with a charging stop has happened yet to say. The chain covers both:
* if the map drives the whole route, guidance never stops early and this sends nothing.
*
* **What "over" means here.** The navigation app's own flag — `isMapNavigating` — going false
* after having been true for this leg. That is arrival in every ordinary case, and it is the only
* signal available without a network request, a coordinate comparison or a second permission.
* A driver who *cancels* guidance mid-leg produces the same signal, so the next leg starts under
* them; they cancel that one too and the chain gives up. That is the known ceiling of this
* design, and the alternative — comparing the car's position against the leg's — would put
* coordinates on a background timer for a case the driver can undo with one tap.
*
* **Nothing here is persisted.** The chain lives in memory for the length of the drive. It holds
* place names and coordinates, which is precisely why it is never written down: the followed-plan
* store on the other side of this trip keeps none of that, and a chain on disk would be the same
* data by another route. A process that dies loses the chain, and the driver hands over the
* remaining leg from the screen, which is what they did before this class existed.
*
* Pure and Android-free: the caller does the binder reads and this decides what they mean.
*/
class NavLegChain(legs: List<NavigationHandoff.Poi>) {
/** What the caller should do after a tick. */
sealed interface Step {
/** Nothing to do: the leg is still being driven, or guidance has yet to appear. */
data object Wait : Step
/**
* Hand this point over — the previous leg ended.
*
* [number] and [count] are one-based and are the only things about a leg that may be
* written to a probe: a name or a coordinate on a diagnostic file leaves the car.
*/
data class Send(
val poi: NavigationHandoff.Poi,
val number: Int,
val count: Int,
) : Step
/** The chain is over and every later tick returns this. [arrived] tells why. */
data class Done(val arrived: Boolean) : Step
}
private val legs = legs.toList()
/** The leg the car was last sent to. Zero is the one the tap itself handed over. */
private var index = 0
/** Whether guidance has been seen running for [index]. Nothing chains before it has. */
private var sawGuidance = false
/** Consecutive readings with no guidance — the grace before a start, the settle after one. */
private var silentTicks = 0
private var done: Step.Done? = null
/**
* Folds one reading of the navigation app's flag.
*
* @param guiding what `isMapNavigating` answered. Do not call with a guess: a caller that
* could not reach the adapter should skip the tick entirely rather than pass false, or the
* grace below expires against a question that was never asked.
*/
fun tick(guiding: Boolean): Step {
done?.let { return it }
if (legs.size < 2) return finish(arrived = true)
if (guiding) {
sawGuidance = true
silentTicks = 0
return Step.Wait
}
if (!sawGuidance) {
// Guidance never started for this leg. Chaining onto a leg that was refused would
// send the car to the destination while it is still standing at the charger.
silentTicks++
return if (silentTicks >= GIVE_UP_TICKS) finish(arrived = false) else Step.Wait
}
// One false reading is not an arrival. The drive of 2026-09-09 16:34 watched a route
// handed over through `startNavFromEVRout` run for twenty-five minutes while the adapter
// stopped answering a remaining distance seven minutes in and its notification listener
// heard nothing at all. Whether the guiding flag itself is that shaky is not yet known,
// and the cost of being wrong is a car sent onward from a charger it is still driving to.
// Consecutive readings cost seconds; a genuine arrival stays false for the rest of the day.
if (++silentTicks < SETTLE_TICKS) return Step.Wait
index++
if (index > legs.lastIndex) return finish(arrived = true)
sawGuidance = false
silentTicks = 0
return Step.Send(legs[index], number = index + 1, count = legs.size)
}
private fun finish(arrived: Boolean): Step.Done =
Step.Done(arrived).also { done = it }
companion object {
/**
* How many silent ticks before a leg that never started is abandoned.
*
* The caller ticks every few seconds, so this is a couple of minutes: long enough for a
* navigation app to come up and start drawing, short enough that a chain armed by a tap
* nobody followed does not sit waiting for the rest of the drive.
*/
const val GIVE_UP_TICKS = 24
/** Consecutive readings with no guidance before a leg that was running counts as over. */
const val SETTLE_TICKS = 3
}
}
@@ -0,0 +1,115 @@
package com.evsuite.hardware.saic
import java.util.Locale
/**
* A destination, in the one form every Android navigation app has agreed to understand.
*
* **This is the library's first write towards the car.** Everything else here reads: the hub,
* the nav adapter, the guidance getters all answer questions and change nothing. This builds a
* string that will move the screen a driver is about to follow at 130 km/h, and it is separated
* from the sending on purpose — the format is the part that can be wrong, and the part a JVM
* test can prove right without a head unit.
*
* **Two forms, because the car answers one of them.** `geo:` is the platform's own answer and
* every Android map understands it; on SWI68 nothing declares a filter for it, so two drives
* produced a map that opened at its default view with the destination left behind. [poi] is the
* other form — the points as the head unit's own adapter carries them, sent by
* [SaicNavGuidance.startNavFromEvRoute]. Both are built here, and only here, because the format
* is the part that can be wrong and the part a JVM test can prove right without a head unit.
*
* **What `geo:` cannot do.** It carries one point. There are no waypoints in it, so a route
* planned *through* somewhere cannot be pinned by handing over its endpoint — the caller hands
* over the charging stop instead, which is the leg the forecast is about, and says so on screen.
* The adapter form has a pathway list and does not have that limit.
*/
object NavigationHandoff {
/**
* The destination as a `geo:` URI, or null when the coordinates are not a place on Earth.
*
* Refusing out-of-range coordinates is not defensive padding: latitude and longitude are two
* doubles of the same type and a swap routes to the wrong continent while looking like an
* ordinary number the whole way. Half the swaps land outside ±90 and this catches those.
*
* [Locale.ROOT] because a head unit set to French formats 43.5 as `43,5`, and a comma is a
* field separator in this URI. The destination would parse, silently, as somewhere else.
*/
fun geoUri(latitude: Double, longitude: Double, label: String? = null): String? {
if (!latitude.isFinite() || !longitude.isFinite()) return null
if (latitude < -90.0 || latitude > 90.0) return null
if (longitude < -180.0 || longitude > 180.0) return null
val point = String.format(Locale.ROOT, "%.6f,%.6f", latitude, longitude)
val name = label?.let(::sanitise)?.takeIf { it.isNotEmpty() }
// The `q=` form is what carries a name; the leading point is what an app that ignores
// `q=` still shows. Both are the same place, so neither reading is wrong.
return if (name == null) "geo:$point?q=$point" else "geo:$point?q=$point($name)"
}
/**
* A label reduced to what cannot change the meaning of the URI around it.
*
* The name comes from a geocoder or a charger dataset — text this project did not write and
* cannot constrain. A parenthesis, an ampersand or a percent in it would end the label early
* or introduce a parameter, so the allowlist is letters, digits, spaces and hyphens, and
* everything else becomes a space. Accented letters are kept: a French place name without
* them is a different name.
*/
private fun sanitise(label: String): String =
label.take(MAX_LABEL_CHARS)
.map { if (it.isLetterOrDigit() || it == ' ' || it == '-') it else ' ' }
.joinToString("")
.trim()
.replace(SPACES, "%20")
/** Long enough for "Ionity Béziers Est", short enough not to be a payload. */
private const val MAX_LABEL_CHARS = 48
private val SPACES = Regex(" +")
/**
* One point of a route, in the shape the adapter's own `EVRoutPoiInfo` bean carries:
* latitude, longitude, name. Nothing else fits in it, which is the whole of what leaves.
*/
data class Poi(val latitude: Double, val longitude: Double, val name: String)
/**
* A validated point, or null when the coordinates are not a place on Earth.
*
* The same range check as [geoUri] and for the same reason — two doubles of one type, and a
* swap that routes to another continent while looking like an ordinary number.
*
* The name is *not* sanitised the way a URI label is. This one is a parcel field, not part
* of a URI, so a parenthesis or an ampersand cannot end it early and stripping them would
* only mangle the name the driver reads on the map. What is removed is what a text field
* has no business carrying: control characters, and runs of whitespace that would make two
* different names look alike. An empty name falls back to the coordinates, because
* `EVRoutPoiInfo` has nowhere to put "unnamed" and a map showing a blank label is worse
* than one showing a point.
*/
fun poi(latitude: Double, longitude: Double, label: String? = null): Poi? {
if (!latitude.isFinite() || !longitude.isFinite()) return null
if (latitude < -90.0 || latitude > 90.0) return null
if (longitude < -180.0 || longitude > 180.0) return null
val name = label.orEmpty()
.take(MAX_LABEL_CHARS)
.map { if (it.isISOControl()) ' ' else it }
.joinToString("")
.replace(SPACES, " ")
.trim()
.ifEmpty { String.format(Locale.ROOT, "%.6f,%.6f", latitude, longitude) }
return Poi(latitude, longitude, name)
}
/**
* The pathway list, capped.
*
* A charging plan has one stop today and may have three; a list long enough to matter to a
* binder transaction is a list this app never built. The cap is what keeps a malformed plan
* from becoming a parcel the adapter has to fan out to every listener it holds a lock over.
*/
fun pathway(stops: List<Poi>): List<Poi> = stops.take(MAX_PATHWAY_POINTS)
/** More waypoints than a charging plan has ever produced, and few enough to stay a message. */
const val MAX_PATHWAY_POINTS = 8
}
@@ -0,0 +1,70 @@
package com.evsuite.hardware.saic
/**
* Turns what a driver types into the (band, kHz) pair `IRadioAppService.tune` wants.
*
* Nobody types "87500". People type "103.5", and about as often "FM 103,5" or "1080 AM".
* All of those name exactly one station, so all of them are accepted; anything that names
* no station at all is rejected rather than guessed at, because tuning the wrong frequency
* is a worse answer than saying the text made no sense.
*
* Android-free on purpose: this is the part worth testing, and it runs on the JVM.
*
* Bounds and steps follow the project's radio contract: `RadioConstants.AM_RANGE` /
* `FM_RANGE`, and the tuning scale's own step (`NormalRadioInfoFragment`: 9 kHz on AM,
* 50 kHz on FM).
*/
object RadioFrequency {
const val AM_MIN_KHZ = 522
const val AM_MAX_KHZ = 1620
const val FM_MIN_KHZ = 87_500
const val FM_MAX_KHZ = 108_000
/** A tuned station: the band constant [SaicRadio] expects, and a frequency in kHz. */
data class Station(val band: Int, val frequencyKhz: Int)
private val NUMBER = Regex("""\d+(?:[.,]\d+)?""")
/**
* @param text what the driver typed.
* @param band the band the rule picked, or null when it picked none.
* @return the station, or null when [text] names none on [band].
*
* The band is taken from the rule when it names one, then from the text when the text says
* one, and inferred from the number otherwise — the two ranges do not overlap in any unit,
* so "103.5" can only be FM and "1080" can only be AM.
*
* A band that disagrees with the text (`AM 103.5`, or a picker on AM over a typed "103.5")
* is rejected rather than resolved: two things were said, and silently picking one of them
* is how a rule ends up on a station nobody chose. Only AM and FM name a frequency at all,
* so a [band] of DAB never parses — the caller reaches DAB by naming the band alone.
*/
fun parse(text: String, band: Int? = null): Station? {
val upper = text.uppercase()
val typed = when {
"FM" in upper -> SaicRadio.BAND_FM
"AM" in upper -> SaicRadio.BAND_AM
else -> null
}
// Both said, and not the same thing: the disagreement is the answer.
if (band != null && typed != null && band != typed) return null
val stated = band ?: typed
val raw = NUMBER.find(upper)?.value?.replace(',', '.')?.toDoubleOrNull() ?: return null
// Megahertz or kilohertz, both written by real people: 103.5, 103500, 1080, 1.08.
val candidates = listOf(
Station(SaicRadio.BAND_FM, Math.round(raw * 1000).toInt()),
Station(SaicRadio.BAND_FM, Math.round(raw).toInt()),
Station(SaicRadio.BAND_AM, Math.round(raw).toInt()),
Station(SaicRadio.BAND_AM, Math.round(raw * 1000).toInt()),
)
return candidates.firstOrNull { it.inRange() && (stated == null || it.band == stated) }
}
private fun Station.inRange(): Boolean = when (band) {
SaicRadio.BAND_FM -> frequencyKhz in FM_MIN_KHZ..FM_MAX_KHZ
SaicRadio.BAND_AM -> frequencyKhz in AM_MIN_KHZ..AM_MAX_KHZ
else -> false
}
}
@@ -0,0 +1,219 @@
package com.evsuite.hardware.saic
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.os.Parcel
import com.evsuite.hardware.AppLogger
/**
* Talking to the SAIC vendor services the head unit's own apps use.
*
* Those apps ship an SDK (`com.saicmotor.sdk.*`) that wraps each service, but the SDK lives
* inside their APKs, not on the boot classpath — reflection cannot reach it from here. What
* *is* reachable is the AIDL underneath: a service to bind, an interface descriptor, and a
* transaction code per method. Keep every constant documented here as an implementation
* detail and validate compatibility on each supported firmware generation.
*
* The binding is asynchronous and survives for the process' lifetime. A call made before the
* service is up returns null rather than blocking: the rule engine has a cycle to finish, and
* a vendor service that is slow to come up must not hold it.
*/
class SaicService private constructor(
private val packageName: String,
private val action: String?,
private val className: String?,
private val tag: String,
) {
/** Most vendor services publish an action; this is the usual way in. */
constructor(packageName: String, action: String, tag: String) :
this(packageName, action, null, tag)
@Volatile
private var binder: IBinder? = null
@Volatile
private var connecting = false
val isReady: Boolean get() = binder?.isBinderAlive == true
fun binder(): IBinder? = binder?.takeIf { it.isBinderAlive }.also {
// A service that died leaves a stale handle behind; drop it so the next connect()
// rebinds instead of transacting into nothing.
if (it == null && binder != null) binder = null
}
/** Idempotent: binds once, and again only after the service has died. */
fun connect(context: Context) {
if (isReady || connecting) return
connecting = true
val intent = (if (action != null) Intent(action) else Intent()).apply {
if (className != null) setClassName(packageName, className) else setPackage(packageName)
}
val bound = runCatching {
context.applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}.getOrDefault(false)
if (!bound) {
connecting = false
AppLogger.w(TAG, "$tag: bindService($packageName/${className ?: action}) refused")
}
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
connecting = false
binder = service
AppLogger.i(TAG, "$tag: connected")
}
override fun onServiceDisconnected(name: ComponentName?) {
binder = null
AppLogger.w(TAG, "$tag: disconnected")
}
}
companion object {
private const val TAG = "EV_SAIC"
/**
* For a service with no intent filter, reached by explicit component — how the car's
* own apps bind the TTS service, and the only way it answers.
*/
fun byComponent(packageName: String, className: String, tag: String) =
SaicService(packageName, null, className, tag)
/**
* For one component publishing several interfaces, each behind its own action — the
* media service, where the action selects which player answers.
*
* Both halves are needed: the action alone would leave the platform to resolve the
* component, and this service exports several; the component alone would bind but
* hand back whichever interface the service returns for a null action, which is not
* the one asked for.
*/
fun byActionAndComponent(
packageName: String,
action: String,
className: String,
tag: String,
) = SaicService(packageName, action, className, tag)
}
}
/**
* The AIDL calling convention the generated proxies use, written once.
*
* Every SAIC interface follows the same shape: interface token, arguments in declaration
* order, `transact(code, …, 0)`, then `readException()` before the result. Nothing here is
* SAIC-specific — it is what `aidl` generates — but having it in one place is what keeps the
* typed wrappers below down to a line each.
*/
object SaicAidl {
private const val TAG = "EV_SAIC"
/** @return true when the call reached the service and it did not throw. */
fun callVoid(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): Boolean =
transact(binder, descriptor, code, args) { true } ?: false
fun callInt(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): Int? =
transact(binder, descriptor, code, args) { it.readInt() }
fun callBoolean(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): Boolean? =
transact(binder, descriptor, code, args) { it.readInt() != 0 }
fun callFloat(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): Float? =
transact(binder, descriptor, code, args) { it.readFloat() }
fun callString(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): String? =
transact(binder, descriptor, code, args) { it.readString() }
fun callBinder(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): IBinder? =
transact(binder, descriptor, code, args) { it.readStrongBinder() }
/**
* For the replies a typed helper cannot cover: a vendor bean, which has to be read field
* by field in the order its own `writeToParcel` wrote them.
*
* Coupling to a vendor class' serialisation is a liability, so it is confined here and to
* its callers: a bean that changes shape throws inside [transact] and the call reads as
* unanswered, never as a guessed value.
*/
fun <T> callParcel(
binder: IBinder?,
descriptor: String,
code: Int,
readReply: (Parcel) -> T,
): T? = transact(binder, descriptor, code, {}, readReply)
/**
* For the arguments a vararg cannot carry: a typed list of a vendor bean, which has to be
* written field by field in the order that bean's own `writeToParcel` reads them.
*
* The mirror image of [callParcel] and the same liability, confined the same way: the
* caller owns the layout, a mismatch throws inside [transact], and the call reads as
* unanswered rather than as a half-written argument the service tries to interpret.
*
* @return true when the call reached the service and it did not throw.
*/
fun callWriting(
binder: IBinder?,
descriptor: String,
code: Int,
writeData: (Parcel) -> Unit,
): Boolean = transact(binder, descriptor, code, writeData) { true } ?: false
private fun <T> transact(
binder: IBinder?,
descriptor: String,
code: Int,
args: Array<out Any?>,
readReply: (Parcel) -> T,
): T? = transact(binder, descriptor, code, { data ->
args.forEach { arg ->
when (arg) {
is Int -> data.writeInt(arg)
is Boolean -> data.writeInt(if (arg) 1 else 0)
is Float -> data.writeFloat(arg)
is String -> data.writeString(arg)
is Double -> data.writeDouble(arg)
// A callback binder, for the one service here that answers asynchronously.
is IBinder -> data.writeStrongBinder(arg)
else -> error("unsupported AIDL argument: $arg")
}
}
}, readReply)
private fun <T> transact(
binder: IBinder?,
descriptor: String,
code: Int,
writeData: (Parcel) -> Unit,
readReply: (Parcel) -> T,
): T? {
val target = binder ?: return null
val data = Parcel.obtain()
val reply = Parcel.obtain()
return try {
data.writeInterfaceToken(descriptor)
writeData(data)
if (!target.transact(code, data, reply, 0)) {
AppLogger.d(TAG, "$descriptor#$code: transact returned false")
return null
}
reply.readException()
readReply(reply)
} catch (e: Exception) {
AppLogger.d(TAG, "$descriptor#$code: ${e.message}")
null
} finally {
reply.recycle()
data.recycle()
}
}
}
@@ -0,0 +1,355 @@
package com.evsuite.hardware.saic
import android.content.Context
/**
* The head unit's radio, as its own radio app drives it.
*
* Binding contract: `RadioOptionManager` (bind target, from
* `RadioConstants.SERVICE_PACKAGE` / `SERVICE_ACTION`), `IRadioAppService` (transaction
* codes) and `RadioType` (the band values).
*/
object SaicRadio {
private const val PACKAGE = "com.saicmotor.service.radio"
private const val ACTION = "com.saicmotor.service.radio.radioservice"
private const val DESCRIPTOR = "com.saicmotor.sdk.radio.IRadioAppService"
private const val TX_TUNE = 8
private const val TX_NEXT = 13
private const val TX_PREVIOUS = 14
private const val TX_CURRENT_INFO = 19
private const val TX_START_ACTIVITY = 26
private const val TX_SRC_PLAY = 27
private const val TX_SRC_PAUSE = 28
/** `RadioType`: 1 = AM, 2 = FM, 4 = DAB. */
const val BAND_AM = 1
const val BAND_FM = 2
const val BAND_DAB = 4
private val service = SaicService(PACKAGE, ACTION, "radio")
val isAvailable: Boolean get() = service.isReady
fun connect(context: Context) = service.connect(context)
/**
* Makes radio the current audio source, resuming the last station.
*
* `srcPlayRadio` rather than launching the activity: a rule that fires at ignition wants
* the sound, not a screen thrown in front of whatever the driver was looking at.
*/
fun play(): Boolean = SaicAidl.callBoolean(service.binder(), DESCRIPTOR, TX_SRC_PLAY) ?: false
/** Brings the radio screen up — separate, because it is a different intent entirely. */
fun openScreen(): Boolean =
SaicAidl.callBoolean(service.binder(), DESCRIPTOR, TX_START_ACTIVITY) ?: false
/**
* Silences the radio and marks it stopped — `srcPauseRadio`, the counterpart of [play].
*
* It mutes the tuner rather than handing the audio focus back: the vendor service only
* abandons focus when it loses it, so whatever was playing before the radio took over
* does not resume on its own. Silence is the most this service offers.
*/
fun pause(): Boolean = SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_SRC_PAUSE)
/**
* Tunes a frequency in kHz (FM 87500–108000), playing it or leaving it silent.
*
* [andPlay] is not optional decoration on top of the tune: `tune` itself calls
* `AudioController.requestMuted(false)` inside the vendor service, which requests the
* audio focus and unmutes the tuner, so the radio starts playing whether or not anyone
* asked it to. A caller that wants the station set without the sound has to undo that,
* which is why the choice is made here rather than left to a second call the caller can
* forget: `andPlay = false` pauses the radio again as soon as it is tuned.
*/
fun tune(band: Int, frequencyKhz: Int, andPlay: Boolean): Boolean {
if (!SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_TUNE, band, frequencyKhz)) return false
return if (andPlay) play() else pause()
}
/**
* Steps to the next station of the band the tuner is already on.
*
* This is the one way to reach a **DAB** station from a rule: [tune] takes a frequency,
* and a DAB service is addressed by ensemble and service id, not by one — so there is
* nothing for a driver to type. Stepping needs neither, and the tuner's own list is
* already the right one.
*/
fun nextStation(): Boolean = SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_NEXT)
fun previousStation(): Boolean = SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_PREVIOUS)
/** What [playPause] did, or why it did nothing. */
enum class ToggleResult {
/** The tuner was silent and was asked to play. */
PLAYED,
/** The tuner was playing and was asked to stop. */
PAUSED,
/**
* The tuner would not say whether it was playing, so nothing was sent.
*
* The distinction from [REFUSED] is the one a caller has to report: the service
* refused nothing, it was never asked.
*/
STATE_UNKNOWN,
/** The state was read, and the play or pause call that followed was not accepted. */
REFUSED
}
/**
* Toggles the tuner between playing and silent, addressing the radio whatever owns the
* audio right now.
*
* Deliberately not [SaicMediaPlayer]'s play/pause, which commands the *current source* —
* that one silences Bluetooth when Bluetooth is playing, which is right for a media
* shortcut and wrong for "toggle the radio".
*
* **Fails closed on an unreadable state.** [SaicMediaPlayer.radioCommand] treats a null
* as playing, and is right to: a driver pressing a wheel button sees nothing happen and
* presses again. A rule has no second press. Guessing wrong there leaves the car silent
* on a morning commute, or playing at a hospital car park, with nothing in the history
* saying the direction was invented — so an unreadable state sends nothing at all.
*/
fun playPause(): ToggleResult {
val playing = isPlaying() ?: return ToggleResult.STATE_UNKNOWN
val ok = if (playing) pause() else play()
return when {
!ok -> ToggleResult.REFUSED
playing -> ToggleResult.PAUSED
else -> ToggleResult.PLAYED
}
}
/**
* Is the tuner playing? Null when the question could not be asked.
*
* `isPlaying()` in the vendor SDK is `getCurrentRadioInfo().getRadioState() == 1`, so the
* `RadioBean` has to be unwound in its `writeToParcel` order: enable(byte), name(String),
* rds(byte), cover(String), frequency(int), band(int), **state(int)**.
*
* Worth the coupling because the obvious substitute is wrong: `AudioManager.isMusicActive`
* is **false while the radio plays**, its stream not being the music one. A play/pause
* shortcut driven by it would send "play" to a radio that is already playing, forever.
*/
fun isPlaying(): Boolean? = currentStation()?.playing
/** What the tuner is on right now, or null when the question could not be put. */
data class Tuned(val band: Int, val frequencyKhz: Int, val playing: Boolean)
/**
* The whole `RadioBean`, not just its play state.
*
* [Tuned.band] is what makes a band action possible without a new transaction: the service
* exposes no "switch band" call, but it says which band it is on, and [tune] can put it on
* another one. See [selectBand].
*/
fun currentStation(): Tuned? =
SaicAidl.callParcel(service.binder(), DESCRIPTOR, TX_CURRENT_INFO) { reply ->
if (reply.readInt() == 0) return@callParcel null // null bean: nothing to conclude
reply.readByte(); reply.readString() // enable, station name
reply.readByte(); reply.readString() // rds, cover art
val frequency = reply.readInt()
val band = reply.readInt()
Tuned(band = band, frequencyKhz = frequency, playing = reply.readInt() == 1)
}
/** Whether [band] is one this class knows how to reach. */
fun isKnownBand(band: Int) = band == BAND_AM || band == BAND_FM || band == BAND_DAB
/** What [selectBand] did, or why it did nothing. */
enum class BandResult {
/** The tuner moved to the requested band. */
SWITCHED,
/** It was already there; nothing was sent. */
ALREADY_ON_BAND,
/** The tuner would not say which band it is on, so nothing was sent. */
STATE_UNKNOWN,
/** The band is not one the tuner offers. */
UNSUPPORTED_BAND,
/** The tune call was not accepted. */
REFUSED
}
/**
* Puts the tuner on [band], including **DAB**.
*
* `IRadioAppService` exposes no band-only call that this project has observed, and
* guessing a transaction code is the one thing the vehicle layer must never do — a wrong
* code on a vendor binder does something unknown to a car. So this composes calls that are
* already established instead: read the current band from `RadioBean`, and if it is not the
* one asked for, [tune] to a frequency **inside** the requested band. Tuning implies the
* band, because `tune` takes the band as its first argument.
*
* Which frequency: the last one this car was seen on for that band, remembered by
* [RadioBandMemory] every time the tuner is read. With no memory yet it lands on the
* bottom of the band and steps to the first real station, because the bottom of a band is
* usually noise — a driver asking for DAB wants a programme, not 174.928 MHz of silence.
*
* DAB is addressed here by its **block frequency**, which is a real frequency in Band III,
* not by an ensemble and service id. That is why a band switch can reach DAB while
* [tune] from a driver's typed text cannot.
*
* [andPlay] carries the caller's "enable radio" the same way [tune] does, and for the same
* reason: the vendor `tune` takes the audio focus from inside itself, so a caller that
* wants the band without the sound has to say so here rather than by not asking to play.
* [BandResult.ALREADY_ON_BAND] sends nothing at all, playing or silent included — the
* tuner is where it was asked to be, and a rule that also wants sound has [play] behind it.
*/
fun selectBand(band: Int, andPlay: Boolean = true): BandResult {
if (!isKnownBand(band)) return BandResult.UNSUPPORTED_BAND
val current = currentStation() ?: return BandResult.STATE_UNKNOWN
RadioBandMemory.remember(current.band, current.frequencyKhz)
if (current.band == band) return BandResult.ALREADY_ON_BAND
val remembered = RadioBandMemory.frequencyFor(band)
val target = remembered ?: bandFloorKhz(band)
if (!tune(band, target, andPlay = andPlay)) return BandResult.REFUSED
// Nothing was remembered, so the tuner is sitting at the bottom of the band, which is
// where stations are not. Stepping finds the first one the car itself lists.
if (remembered == null) nextStation()
return BandResult.SWITCHED
}
/**
* The bottom of each band, used only when this car has never been seen on it.
*
* FM and AM come from `RadioConstants`; DAB is block 5A at 174.928 MHz, the bottom of
* Band III as the standard defines it — not a value read off this firmware, which is why
* a band switch that lands there immediately steps to a real station rather than staying.
*/
private fun bandFloorKhz(band: Int): Int = when (band) {
BAND_AM -> RadioFrequency.AM_MIN_KHZ
BAND_DAB -> DAB_BAND_III_MIN_KHZ
else -> RadioFrequency.FM_MIN_KHZ
}
const val DAB_BAND_III_MIN_KHZ = 174_928
}
/**
* The last frequency this car was seen on, per band.
*
* Switching band needs a frequency, and the least surprising one is the station the driver
* was last listening to on that band. Kept beside the glass evidence in the same preferences,
* and for the same reason: it is an observation about this car, worth nothing anywhere else
* and never sent anywhere.
*/
object RadioBandMemory {
private const val PREFS = "ev_settings"
private const val KEY_PREFIX = "radio_last_khz_band_"
@Volatile
private var prefs: android.content.SharedPreferences? = null
/** Cache so a read on the sampling path is not a disk hit. */
private val cache = java.util.concurrent.ConcurrentHashMap<Int, Int>()
fun load(context: Context) {
prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
fun frequencyFor(band: Int): Int? =
cache[band] ?: prefs?.getInt(KEY_PREFIX + band, -1)?.takeIf { it > 0 }?.also {
cache[band] = it
}
/** Records where the tuner was, ignoring a band or frequency that says nothing. */
fun remember(band: Int, frequencyKhz: Int) {
if (!SaicRadio.isKnownBand(band) || frequencyKhz <= 0) return
if (cache[band] == frequencyKhz) return
cache[band] = frequencyKhz
prefs?.edit()?.putInt(KEY_PREFIX + band, frequencyKhz)?.apply()
}
}
/**
* Placing a call over the car's own hands-free stack.
*
* Not `ACTION_CALL`: the head unit has no SIM and no dialer of its own — the call is placed
* by the vehicle's Bluetooth hands-free service through the paired phone, which is what its
* `placeCall` does. Going through the platform telephony intent would find nothing to handle
* it.
*
* Binding contract: `BtCallManager` (bind target, from
* `BtConstant.SERVICE_PACKAGE` / `SERVICE_ACTION`) and `IBtCall` (transaction codes).
*/
object SaicPhone {
private const val PACKAGE = "com.saicmotor.service.btcall"
private const val ACTION = "com.saicmotor.service.btcall.BtCallService"
private const val DESCRIPTOR = "com.saicmotor.sdk.btcall.IBtCall"
private const val TX_PLACE_CALL = 2
private const val TX_GET_BLUETOOTH_STATE = 12
private const val TX_GET_CONN_DEVICE_NAME = 14
private val service = SaicService(PACKAGE, ACTION, "btcall")
val isAvailable: Boolean get() = service.isReady
fun connect(context: Context) = service.connect(context)
/** @return true when the hands-free stack accepted the number. */
fun placeCall(number: String): Boolean =
SaicAidl.callBoolean(service.binder(), DESCRIPTOR, TX_PLACE_CALL, number) ?: false
fun bluetoothState(): Int? = SaicAidl.callInt(service.binder(), DESCRIPTOR, TX_GET_BLUETOOTH_STATE)
/** Name of the phone the car is paired with, for the diagnostic report. */
fun connectedDeviceName(): String? =
SaicAidl.callString(service.binder(), DESCRIPTOR, TX_GET_CONN_DEVICE_NAME)
?.takeIf { it.isNotBlank() }
}
/**
* The vehicle's own voice — the one that says "left front tyre abnormality".
*
* This is what makes the "speak" action work on a head unit with no Android TTS engine
* installed, which is the normal case: the car talks, but through a vendor service rather
* than through `android.speech.tts`. Probing for a platform engine and finding none was
* therefore the right answer to the wrong question.
*
* Bound by component name, not by action — the service declares no intent filter, and the
* car's own voice app reaches it exactly this way.
*
* Binding contract: `VoiceAnnounceModel` (component name and
* the call it makes) and `ITtsService` (descriptor, transaction codes).
*/
object SaicTts {
private const val PACKAGE = "com.saicmotor.voicetts"
private const val CLASS = "com.saicmotor.voicetts.TtsService"
private const val DESCRIPTOR = "com.saicmotor.voicetts.ITtsService"
private const val TX_PROMPT = 1
private const val TX_STOP = 3
private val service = SaicService.byComponent(PACKAGE, CLASS, "voicetts")
val isAvailable: Boolean get() = service.isReady
fun connect(context: Context) = service.connect(context)
/**
* Speaks [text].
*
* [interrupt] true stops whatever the car is currently announcing — false queues behind
* it, which is what a rule message should do: a tyre warning outranks "profile applied".
* The tag is the caller's package, as the vendor apps pass their own.
*/
fun speak(text: String, interrupt: Boolean = false, tag: String = "com.evsuite.tasker"): Boolean =
SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_PROMPT, text, interrupt, tag)
fun stop(): Boolean = SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_STOP)
}
@@ -0,0 +1,475 @@
package com.evsuite.hardware.saic
import android.content.Context
import android.media.AudioManager
import android.media.session.MediaController
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import android.os.SystemClock
import android.view.KeyEvent
import com.evsuite.hardware.AppLogger
/**
* Next track, previous track, play/pause — addressed to the source that is actually playing.
*
* ## Why this is not one `dispatchMediaKeyEvent`
*
* The obvious implementation is the wrong one on this head unit, and quietly so. The car
* publishes a single Android media session (`com.android.bluetooth`), so a media key either
* reaches nothing — radio, USB and the projection stacks do not listen for one — or it
* reaches Bluetooth, **wakes a phone that was not playing, and changes the audio source under
* the driver's hands** while the radio was on. A key sent from a rule at 110 km/h is exactly
* the wrong place to discover that.
*
* What the car's own launcher does instead is ask which source is current and talk to that
* source's player. That is what this does.
*
* ## The rule that keeps it safe
*
* **No cascade between sources.** Trying each player in turn looks robust and is the bug: a
* player that is not playing answers "yes" to `play` and starts, which is the source change
* again. One source is chosen, one player is addressed, and a failure is reported as a
* failure. Fallbacks exist only *within* the chosen source, or where nothing at all could be
* identified.
*
* ## Firmware split
*
* SWI68/SWI165 answer through the SAIC media service below. The A9 generations (SWI69,
* SWI131, SWI132) have no such service — their launcher drives the framework's media
* sessions, so [sessionCommand] is the normal path there, not a consolation prize. Both ends
* fall back to the media key, and only while something is audible.
*
* Binding contract: `MediaService` (bind target and the per-player actions),
* `IMediaPlayerBinderInterface` and its siblings (transaction codes), `MediaConstants`
* (source and player-status values), and `com.allgo.rui.IRemoteUIService` for the projection
* stack, which is not part of the SAIC SDK at all.
*/
object SaicMediaPlayer {
private const val TAG = "EV_MEDIA"
private const val MEDIA_PACKAGE = "com.saicmotor.service.media"
private const val MEDIA_CLASS = "com.saicmotor.service.media.MediaService"
private const val ACTION_MEDIA = "com.saicmotor.service.media.MEDIA_PLAYER_ACTION"
private const val ACTION_CPAA = "com.saicmotor.service.media.CPAA_PLAYER_ACTION"
private const val ACTION_BT = "com.saicmotor.service.media.BT_MUSIC_ACTION"
private const val ACTION_USB = "com.saicmotor.service.media.MUSIC_PLAYER_ACTION"
private const val ACTION_ONLINE = "com.saicmotor.service.media.ONLINE_MUSIC_ACTION"
private const val ACTION_STATUS = "com.saicmotor.service.media.PLAY_STATUS_ACTION"
private const val DESC_MEDIA = "com.saicmotor.sdk.media.IMediaPlayerBinderInterface"
private const val DESC_CPAA = "com.saicmotor.sdk.media.ICpAaBinderInterface"
private const val DESC_BT = "com.saicmotor.sdk.media.IBtMusicBinderInterface"
private const val DESC_USB = "com.saicmotor.sdk.media.IMusicPlayerBinderInterface"
private const val DESC_ONLINE = "com.saicmotor.sdk.media.IOnlineMusicBinderInterface"
private const val DESC_STATUS = "com.saicmotor.sdk.media.IPlayStatusBinderInterface"
/**
* `MediaConstants` source codes. 1..4 are the tuner's own bands — 1 radio, 2 FM, 3 AM,
* **4 DAB** — which is why DAB needs no separate handling here: it is the radio.
*/
private const val SRC_RADIO_MIN = 1
private const val SRC_RADIO_MAX = 4
private const val SRC_BT = 5
private const val SRC_ONLINE = 6
private const val SRC_USB = 7
private const val SRC_USB_VIDEO = 0x12
private const val SRC_CARPLAY = 0x32
private const val SRC_AA = 0x46
/** Of the player statuses, only START means "playing". */
private const val PLAYER_STATUS_START = 3
// Status interface — which source owns the audio right now.
private const val TX_CURRENT_SOURCE = 0x9
// Per-player transaction codes, each in its own interface.
private const val TX_BT_PLAY_STATE = 0x9
private const val TX_BT_PAUSE = 1
private const val TX_BT_PLAY = 2
private const val TX_BT_PREVIOUS = 4
private const val TX_BT_NEXT = 5
private const val TX_USB_PLAY_STATE = 0x1e
private const val TX_USB_PLAY_PAUSE = 0xc
private const val TX_USB_PREVIOUS = 0x19
private const val TX_USB_NEXT = 0x1a
private const val TX_ONLINE_STATUS = 0x6
private const val TX_ONLINE_PAUSE = 1
private const val TX_ONLINE_PLAY = 2
private const val TX_ONLINE_PREVIOUS = 3
private const val TX_ONLINE_NEXT = 4
private const val TX_CPAA_INFO = 0x7
private const val TX_CPAA_PLAY = 1
private const val TX_CPAA_PAUSE = 2
private const val TX_CPAA_PREVIOUS = 3
private const val TX_CPAA_NEXT = 4
private const val TX_GENERIC_PLAY = 2
private const val TX_GENERIC_PAUSE = 3
private const val TX_GENERIC_PREVIOUS = 4
private const val TX_GENERIC_NEXT = 5
// The projection stack: allgo's service, outside the SAIC SDK entirely.
private const val RUI_PACKAGE = "com.allgo.rui"
private const val RUI_CLASS = "com.allgo.rui.RemoteUIService"
private const val DESC_RUI = "com.allgo.rui.IRemoteUIService"
private const val TX_RUI_MEDIA_KEY = 0x12
/**
* `sendMediaPlayControlKey` values. Skip carries a press and a release: leaving a key
* held reads as fast-forward, which is what the two codes are for.
*/
private const val RUI_PLAY = 1
private const val RUI_PAUSE = 2
private const val RUI_NEXT_DOWN = 7
private const val RUI_PREVIOUS_DOWN = 8
private const val RUI_NEXT_UP = 11
private const val RUI_PREVIOUS_UP = 12
enum class Command { PLAY_PAUSE, NEXT, PREVIOUS }
private val status = mediaService(ACTION_STATUS, "media-status")
private val generic = mediaService(ACTION_MEDIA, "media-generic")
private val bluetooth = mediaService(ACTION_BT, "media-bt")
private val usb = mediaService(ACTION_USB, "media-usb")
private val online = mediaService(ACTION_ONLINE, "media-online")
private val projection = mediaService(ACTION_CPAA, "media-cpaa")
private val remoteUi = SaicService.byComponent(RUI_PACKAGE, RUI_CLASS, "media-rui")
private fun mediaService(action: String, tag: String) =
SaicService.byActionAndComponent(MEDIA_PACKAGE, action, MEDIA_CLASS, tag)
@Volatile
private var appContext: Context? = null
/**
* Binds every player interface, and the projection service beside them.
*
* All of them, up front, rather than on demand: a bind is asynchronous, and a shortcut
* pressed on the steering wheel cannot wait for one. On a firmware where a given service
* does not exist the bind is refused once, logged, and that interface simply never
* answers — which is the same thing the dispatch below already handles.
*/
fun connect(context: Context) {
appContext = context.applicationContext
listOf(status, generic, bluetooth, usb, online, projection, remoteUi)
.forEach { it.connect(context) }
}
val isAvailable: Boolean get() = status.isReady
fun next(): Boolean = command(Command.NEXT)
fun previous(): Boolean = command(Command.PREVIOUS)
fun playPause(): Boolean = command(Command.PLAY_PAUSE)
/**
* Sends [cmd] to whatever is playing.
*
* A source code below zero means the SAIC service did not answer — either it does not
* exist (A9) or its bind has not landed yet. Both take the session path, which is safe
* whichever it was: it commands only a session that declares itself playing.
*/
fun command(cmd: Command): Boolean {
val source = currentSource()
if (source < 0) {
AppLogger.i(TAG, "no SAIC media source — driving media sessions instead")
if (sessionCommand(cmd)) return true
// Sessions cover radio, Bluetooth and USB here. What they do not cover is the
// projection stack, which declares neither skip action — hence allgo, the path
// the A9 launcher takes for exactly this.
if (projectionCommand(cmd, musicActive())) return true
// Last resort, when enumerating sessions is refused outright. Only while
// something is audible: sent while the radio plays (`isMusicActive` false, as
// measured) it would wake the Bluetooth session and change the source — the very
// defect this class exists to avoid, let back in through the side door. The
// accepted cost is that it cannot resume a playback that had stopped.
if (!musicActive()) {
AppLogger.i(TAG, "no usable session and nothing playing — no key sent")
return false
}
return sendMediaKey(
when (cmd) {
Command.NEXT -> KeyEvent.KEYCODE_MEDIA_NEXT
Command.PREVIOUS -> KeyEvent.KEYCODE_MEDIA_PREVIOUS
Command.PLAY_PAUSE -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
}
)
}
val target = if (isKnown(source)) source else playingSource()?.also {
AppLogger.i(TAG, "source $source not in the table — player answering: ${sourceName(it)}")
} ?: source
return when {
target in SRC_RADIO_MIN..SRC_RADIO_MAX -> radioCommand(cmd)
target == SRC_BT -> transact(bluetooth, DESC_BT, when (cmd) {
Command.NEXT -> TX_BT_NEXT
Command.PREVIOUS -> TX_BT_PREVIOUS
Command.PLAY_PAUSE -> if (isPlaying(target)) TX_BT_PAUSE else TX_BT_PLAY
})
target == SRC_USB -> transact(usb, DESC_USB, when (cmd) {
Command.NEXT -> TX_USB_NEXT
Command.PREVIOUS -> TX_USB_PREVIOUS
// A real toggle, so the state never has to be read for this one.
Command.PLAY_PAUSE -> TX_USB_PLAY_PAUSE
})
target == SRC_ONLINE -> transact(online, DESC_ONLINE, when (cmd) {
Command.NEXT -> TX_ONLINE_NEXT
Command.PREVIOUS -> TX_ONLINE_PREVIOUS
Command.PLAY_PAUSE -> if (isPlaying(target)) TX_ONLINE_PAUSE else TX_ONLINE_PLAY
})
target == SRC_CARPLAY || target == SRC_AA -> {
// Read the state only where the command depends on it: a skip has no use for
// a binder round trip.
val playing = cmd == Command.PLAY_PAUSE && isPlaying(target)
// SWI133 answers through the SAIC SDK, every other firmware through allgo.
// The two are exclusive by generation; the absent one fails without effect.
transact(projection, DESC_CPAA, when (cmd) {
Command.NEXT -> TX_CPAA_NEXT
Command.PREVIOUS -> TX_CPAA_PREVIOUS
Command.PLAY_PAUSE -> if (playing) TX_CPAA_PAUSE else TX_CPAA_PLAY
}) || projectionCommand(cmd, playing)
}
else -> {
AppLogger.i(TAG, "source ${sourceName(target)} has no known player — generic facade")
genericCommand(cmd, musicActive())
}
}
}
private fun isKnown(source: Int) =
source in SRC_RADIO_MIN..SRC_RADIO_MAX ||
source == SRC_BT || source == SRC_ONLINE || source == SRC_USB ||
source == SRC_CARPLAY || source == SRC_AA
/**
* The tuner has its own service: skip changes station, and `srcPlayRadio` /
* `srcPauseRadio` are what the launcher calls.
*
* An unreadable state counts as playing — the normal state of a tuner that owns the
* audio. One pause too many is undone by a second press; the opposite reading would leave
* the shortcut doing nothing at all.
*/
private fun radioCommand(cmd: Command): Boolean {
val playing = SaicRadio.isPlaying() != false
val ok = when (cmd) {
Command.NEXT -> SaicRadio.nextStation()
Command.PREVIOUS -> SaicRadio.previousStation()
Command.PLAY_PAUSE -> if (playing) SaicRadio.pause() else SaicRadio.play()
}
// Fallback *within* the source: the generic facade addresses whichever source is
// current, so it cannot wake another one.
return ok || genericCommand(cmd, playing)
}
private fun genericCommand(cmd: Command, playing: Boolean): Boolean =
transact(generic, DESC_MEDIA, when (cmd) {
Command.NEXT -> TX_GENERIC_NEXT
Command.PREVIOUS -> TX_GENERIC_PREVIOUS
Command.PLAY_PAUSE -> if (playing) TX_GENERIC_PAUSE else TX_GENERIC_PLAY
})
/** Which source owns the audio, or -1 when the service did not answer. */
private fun currentSource(): Int {
val v = SaicAidl.callInt(status.binder(), DESC_STATUS, TX_CURRENT_SOURCE) ?: return -1
AppLogger.i(TAG, "current source = $v (${sourceName(v)})")
return v
}
/**
* Is [source] playing?
*
* The **source** is asked wherever it can answer, and `isMusicActive` only when none can:
* that one stays true for a second or two after a stop, which is what made a second press
* send a second pause instead of a play.
*/
private fun isPlaying(source: Int): Boolean {
val reported = when (source) {
SRC_BT -> readInt(bluetooth, DESC_BT, TX_BT_PLAY_STATE)?.let { it != 0 }
SRC_ONLINE -> readInt(online, DESC_ONLINE, TX_ONLINE_STATUS)?.let { it == PLAYER_STATUS_START }
SRC_CARPLAY, SRC_AA -> projectionPlaying()
else -> null
}
if (reported != null) {
AppLogger.i(TAG, "${sourceName(source)} reports playing = $reported")
return reported
}
val fallback = musicActive()
AppLogger.i(TAG, "${sourceName(source)} does not report state — isMusicActive = $fallback")
return fallback
}
/**
* Which player declares itself playing, when the source code is one this does not know.
*
* Asking the players is what keeps the dispatch honest despite an incomplete table: only
* a player that says it is playing gets commanded, so a sleeping source is never woken.
*/
private fun playingSource(): Int? {
if (readInt(bluetooth, DESC_BT, TX_BT_PLAY_STATE)?.let { it != 0 } == true) return SRC_BT
if (readInt(usb, DESC_USB, TX_USB_PLAY_STATE)?.let { it != 0 } == true) return SRC_USB
if (readInt(online, DESC_ONLINE, TX_ONLINE_STATUS) == PLAYER_STATUS_START) return SRC_ONLINE
return null
}
/**
* Is CarPlay / Android Auto playing? Null when nothing can be concluded.
*
* `getLastCpAaAudioInfoBean` returns an `AudioInfoBean` whose play state is
* [PLAYER_STATUS_START] while playing — the launcher's own media card compares it to that
* exact value. The bean is unwound in its `writeToParcel` order: id(long), duration(long),
* seven strings, two longs, one string, then **state(int)**.
*
* Without this read the projection fell back to `isMusicActive`, which stays true for two
* or three seconds after a pause: the shortcut answered a pause with another pause, and
* the driver had to wait before the toggle worked again.
*/
private fun projectionPlaying(): Boolean? =
SaicAidl.callParcel(projection.binder(), DESC_CPAA, TX_CPAA_INFO) { reply ->
if (reply.readInt() == 0) return@callParcel null // null bean: nothing to conclude
reply.readLong(); reply.readLong() // id, duration
repeat(7) { reply.readString() } // name, art, path, artist, user, avatar, album
reply.readLong(); reply.readLong() // added, last played
reply.readString() // readable position
reply.readInt() == PLAYER_STATUS_START
}
/**
* Drives the framework's media sessions — the Android path, and the A9 launcher's own.
*
* Safe by construction where a media key is not: only a session whose state *declares* it
* is playing gets commanded, so this cannot wake a sleeping source. The single exception
* is resuming, which acts only when no session is playing at all.
*
* A session also declares what it supports, and ignoring that declaration is talking into
* the void: `skipToNext()` on a session without `ACTION_SKIP_TO_NEXT` throws nothing and
* does nothing. Reporting success there would stop the caller trying the path that works.
*/
private fun sessionCommand(cmd: Command): Boolean {
val ctx = appContext ?: return false
val manager = ctx.getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager
?: return false
val sessions: List<MediaController> = try {
manager.getActiveSessions(null)
} catch (e: SecurityException) {
AppLogger.w(TAG, "sessions unreadable — MEDIA_CONTENT_CONTROL not held: ${e.message}")
return false
} catch (e: Exception) {
AppLogger.w(TAG, "sessions unreadable: ${(e.cause ?: e).message}")
return false
}
val playing = sessions.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING }
val target = playing
?: if (cmd == Command.PLAY_PAUSE) {
sessions.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PAUSED }
} else null
if (target == null) {
AppLogger.i(TAG, "no usable session among ${sessions.size}")
return false
}
val declared = target.playbackState?.actions ?: 0L
val required = when (cmd) {
Command.NEXT -> PlaybackState.ACTION_SKIP_TO_NEXT
Command.PREVIOUS -> PlaybackState.ACTION_SKIP_TO_PREVIOUS
Command.PLAY_PAUSE -> PlaybackState.ACTION_PLAY_PAUSE or
(if (playing != null) PlaybackState.ACTION_PAUSE else PlaybackState.ACTION_PLAY)
}
if (declared and required == 0L) {
AppLogger.i(TAG, "${cmd.name} not declared by ${target.packageName} — session path given up")
return false
}
return try {
when (cmd) {
Command.NEXT -> target.transportControls.skipToNext()
Command.PREVIOUS -> target.transportControls.skipToPrevious()
Command.PLAY_PAUSE ->
if (playing != null) target.transportControls.pause()
else target.transportControls.play()
}
AppLogger.i(TAG, "${cmd.name} sent to session ${target.packageName}")
true
} catch (e: Exception) {
AppLogger.w(TAG, "session refused ${cmd.name}: ${(e.cause ?: e).message}")
false
}
}
private fun projectionCommand(cmd: Command, playing: Boolean): Boolean = when (cmd) {
Command.NEXT -> remoteUiKey(RUI_NEXT_DOWN) && remoteUiKey(RUI_NEXT_UP)
Command.PREVIOUS -> remoteUiKey(RUI_PREVIOUS_DOWN) && remoteUiKey(RUI_PREVIOUS_UP)
Command.PLAY_PAUSE -> remoteUiKey(if (playing) RUI_PAUSE else RUI_PLAY)
}
private fun remoteUiKey(value: Int): Boolean =
SaicAidl.callVoid(remoteUi.binder(), DESC_RUI, TX_RUI_MEDIA_KEY, value).also {
AppLogger.i(TAG, "projection: sendMediaPlayControlKey($value) → $it")
}
private fun sendMediaKey(keyCode: Int): Boolean {
val audio = appContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
if (audio == null) {
AppLogger.w(TAG, "no AudioManager — media key not sent")
return false
}
// Both halves or nothing: an app given a key-down and never the matching key-up can
// sit on a held key.
return try {
val now = SystemClock.uptimeMillis()
audio.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0))
audio.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0))
AppLogger.i(TAG, "sent ${KeyEvent.keyCodeToString(keyCode)}")
true
} catch (e: Exception) {
AppLogger.w(TAG, "media key refused: ${(e.cause ?: e).message}")
false
}
}
private fun musicActive(): Boolean =
(appContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.isMusicActive == true
/**
* Calls a no-argument player method.
*
* Deliberately outside the vehicle write gate: changing track is not a driving setting,
* and blocking it above a given speed would make no sense. A true here means the call was
* *received*, not that it had an effect — which is why every step is logged.
*/
private fun transact(service: SaicService, descriptor: String, code: Int): Boolean =
SaicAidl.callVoid(service.binder(), descriptor, code).also {
AppLogger.i(TAG, "${descriptor.substringAfterLast('.')} tx=0x${Integer.toHexString(code)} → $it")
}
/** A null here is not a zero: it means the question could not be put. */
private fun readInt(service: SaicService, descriptor: String, code: Int): Int? =
SaicAidl.callInt(service.binder(), descriptor, code)
private fun sourceName(v: Int): String = when (v) {
in SRC_RADIO_MIN..SRC_RADIO_MAX -> "radio/DAB"
SRC_BT -> "bluetooth"
SRC_ONLINE -> "online"
SRC_USB -> "usb"
SRC_USB_VIDEO -> "usb-video"
SRC_CARPLAY -> "carplay"
SRC_AA -> "android-auto"
else -> "unknown($v)"
}
}
@@ -0,0 +1,89 @@
package com.evsuite.hardware.saic
import android.content.Context
import android.os.IBinder
/**
* The head unit's navigation adapter — the service its map, cluster and voice apps share.
*
* A different bind from [SaicHub]: the vehicle hub answers about the car's own systems,
* while this one sits between the head unit and whichever navigation app is installed. What
* is useful here is the small set of things it answers *synchronously*; the rest of its
* surface is a stream of callbacks from the running navigation app, which is a subscription
* rather than a reading and is deliberately not wired up.
*
* That distinction is the reason trip distance is absent. Remaining distance and time only
* arrive as notifications *into* this service from the navigation app, so reading them means
* registering as a listener and holding that registration for the life of the process.
* [totalMileageKm] is a getter, and the odometer is what a "service due" rule actually wants.
*/
object SaicNav {
private const val PACKAGE = "com.saicmotor.adapterservice"
private const val CLASS = "com.saicmotor.adapterservice.services.MapService"
private const val DESCRIPTOR = "com.saicmotor.adapterservice.IMapService"
private const val TX_GET_TOTAL_MILEAGE = 11
private val service = SaicService.byComponent(PACKAGE, CLASS, "nav-adapter")
val isAvailable: Boolean get() = binder() != null
fun connect(context: Context) = service.connect(context)
private fun binder(): IBinder? = service.binder()
/**
* The odometer, in kilometres.
*
* The service answers 0 when its own energy manager is not up, which is the same answer a
* brand-new car would give — so 0 is treated as unreadable. A car that has genuinely
* driven nothing is not a case any rule needs, and reporting "unknown" there is the
* cheaper mistake.
*
* Kilometres because that is the signal; the head unit's distance-unit setting changes
* what its screens display, not what the bus carries.
*/
fun totalMileageKm(): Int? =
SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_TOTAL_MILEAGE)?.takeIf { it > 0 }
/**
* The two transactions CP-040 saw declared on this interface and never called.
*
* They are the candidates for "where is the driver already going", which would remove the
* typed address, the keyboard and a geocoding request from the charge-stop screen.
*/
val DESTINATION_TRANSACTIONS = listOf(38, 39)
/**
* What a transaction actually replies, recorded rather than interpreted.
*
* CP-051 exists because the transaction numbers are known and the answer is not. Reading
* the reply as a typed value would be guessing the shape; a latitude read as a longitude
* routes to the wrong continent and looks like an ordinary number while doing it. So this
* returns the bytes, and a session at a desk decides what they mean.
*
* **The reply may contain the destination that is set in the car's navigation.** That is
* the point of the probe, and it is why the artifact carrying this says so — the file
* leaves the car on a USB stick.
*
* @return one line, always: unbound, unanswered, or the payload in hex.
*/
fun probeTransaction(code: Int, maxBytes: Int = MAX_PROBE_BYTES): String {
val target = binder() ?: return "tx=$code unbound"
return SaicAidl.callParcel(target, DESCRIPTOR, code) { reply ->
val start = reply.dataPosition()
val available = reply.dataAvail()
val hex = runCatching {
reply.marshall()
.drop(start)
.take(maxBytes)
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
}.getOrElse { "unmarshallable(${it.javaClass.simpleName})" }
"tx=$code answered bytes=$available hex=$hex"
} ?: "tx=$code no answer"
}
/** Enough for a coordinate pair, a name and their headers; not enough to be a log dump. */
private const val MAX_PROBE_BYTES = 128
}
@@ -0,0 +1,325 @@
package com.evsuite.hardware.saic
import android.content.Context
import android.os.Binder
import android.os.IBinder
import android.os.Parcel
import android.os.SystemClock
import com.evsuite.hardware.AppLogger
/**
* Remaining distance and time to the destination, as the head unit's own navigation reports it.
*
* [SaicNav] explains why the map adapter answers no synchronous question about a trip: the
* navigation app *pushes* guidance into the adapter service, which fans it out to whoever
* registered. This is that registration, and it is the only read-only route source found on
* the vehicle that costs no Android permission and no network.
*
* It is also, since the owner scoped the read-only rule to the car's driving and safety
* settings, where a route goes *out* to the navigation app: [startNavFromEvRoute] is the one
* call in this object that sends. Everything else here listens or asks.
*
* **The service does not wait politely.** `IGeneralNotificationListener` is not `oneway` —
* the proxy calls `transact(code, data, reply, 0)` and then `readException()`, and the
* adapter fans out to every listener while holding the lock on its callback list. A slow
* listener therefore stalls the instrument cluster, not just this app. Everything
* [onTransact][ListenerBinder.onTransact] does is one parcel read and one volatile write; it
* must stay that way. No I/O, no lock, no allocation beyond the state object, ever.
*
* **Registration is additive.** The adapter keeps a `RemoteCallbackList`, so registering here
* neither displaces the navigation app nor the cluster, and the list drops this listener on
* its own if the process dies.
*
* **Development instrument.** Nothing here is validated on a vehicle: the distance callbacks
* carry no unit and the status codes carry no documented meaning, so [latest] is evidence to
* be captured and read, not a value to display. CP-040 is the ticket that turns it into
* either a decision or a deletion.
*/
object SaicNavGuidance {
private const val TAG = "EV_SAIC"
private const val PACKAGE = "com.saicmotor.adapterservice"
private const val CLASS = "com.saicmotor.adapterservice.services.GeneralService"
private const val DESCRIPTOR = "com.saicmotor.adapterservice.IGeneralService"
private const val LISTENER_DESCRIPTOR =
"com.saicmotor.adapterservice.IGeneralNotificationListener"
private const val TX_REGISTER_LISTENER = 1
private const val TX_UNREGISTER_LISTENER = 2
// Synchronous getters on the same interface. SaicNav's note that the head unit answers no
// synchronous question about a trip was read off IMapService; IGeneralService does answer.
private const val TX_IS_MAP_NAVIGATING = 18
private const val TX_GET_ROAD_NAME = 29
private const val TX_GET_GUIDE_STATUS = 31
private const val TX_GET_REMAINING_TIMES = 33
private const val TX_GET_REMAINING_DISTANCE = 34
// The two transactions here that send rather than ask. See [goTo], [startNavFromEvRoute].
private const val TX_GO_TO = 23
private const val TX_START_NAV_FROM_EV_ROUTE = 48
private val service = SaicService.byComponent(PACKAGE, CLASS, "nav-guidance")
/**
* The folded state. Written only from a binder thread, read from anywhere, so it is
* volatile and the value it points at is immutable.
*/
@Volatile
private var state: NavGuidance = NavGuidance.EMPTY
@Volatile
private var registered: Boolean = false
/**
* Every transaction code the adapter sent, decoded or not.
*
* The transaction map came from an R69 build and a vehicle may run another revision, so
* this is what proves the map still lines up. It is read by a capture, never by a value.
*/
private val census = TransactionCensus()
val isAvailable: Boolean get() = binder() != null
/** True once the adapter accepted this listener. */
val isListening: Boolean get() = registered
fun connect(context: Context) = service.connect(context)
private fun binder(): IBinder? = service.binder()
/**
* The last guidance seen, or [NavGuidance.EMPTY] before anything arrived.
*
* `events == 0` means the listener registered but the navigation app has said nothing —
* which is the answer when no guidance is running, and is itself the finding CP-040 needs.
*/
fun latest(): NavGuidance = state
/**
* The current guidance, read rather than waited for.
*
* The five callbacks are change notifications: a car standing still with a guidance
* already running publishes nothing, because nothing changed. That made a parked capture
* unable to tell "the adapter says nothing" from "nothing happened to say", which is the
* distinction CP-040 turns on. These getters answer on demand instead.
*
* Values seen so far by the listener are kept where a getter answers nothing, so a caller
* gets the best of both without choosing between them.
*/
fun readNow(): NavGuidance {
val target = binder() ?: return state
fun int(code: Int) = SaicAidl.callInt(target, DESCRIPTOR, code)
// The service initialises these to 0 and answers 0 before any guidance has run, which
// is the same answer an arrived route would give. Unknown is the cheaper reading: a
// remaining distance of exactly zero is not a case any forecast needs.
val distance = int(TX_GET_REMAINING_DISTANCE)?.takeIf { it > 0 }
val minutes = int(TX_GET_REMAINING_TIMES)?.takeIf { it > 0 }
val status = int(TX_GET_GUIDE_STATUS)
val road = SaicAidl.callString(target, DESCRIPTOR, TX_GET_ROAD_NAME)?.takeIf {
it.isNotBlank()
}
val seen = state
return seen.copy(
guideStatus = status ?: seen.guideStatus,
remainingDistanceRaw = distance ?: seen.remainingDistanceRaw,
remainingMinutes = minutes ?: seen.remainingMinutes,
road = road ?: seen.road,
)
}
/**
* Whether the head unit is guiding, as the navigation app itself says so.
*
* Not an inference from a distance or a road name: `MapService` keeps this flag and the
* navigation app sets it through `IMapService.isMapNavigating(boolean)`, its own report of
* its own state. That makes it the one thing on this interface that can confirm a handoff
* worked — a command accepted by the adapter says only that the adapter took it, and the
* fan-out swallows a refusal, so this flag is the difference between "sent" and "started".
*
* @return null when the adapter is not there to ask.
*/
fun isMapNavigating(): Boolean? =
SaicAidl.callBoolean(binder(), DESCRIPTOR, TX_IS_MAP_NAVIGATING)
/**
* Asks the navigation app to guide to one point — the vendor's own "take me there".
*
* **Why this exists beside [startNavFromEvRoute].** The route handoff put the destination on
* the map and left it there: the driver saw the place and no guidance ever started. `goTo` is
* the other command, and it is the one the head unit's voice assistant uses — `GeneralService`
* turns it into `MapService.goToPoi(null, address, null, latitude, longitude)`, the same call
* `IVoiceVuiService` makes when someone says "emmène-moi à". Where the route handoff hands
* over points to draw, this one names a destination to drive to.
*
* **One point, no pathway.** The interface has room for nothing else, so a plan with a
* charging stop must choose: guide to the stop, which is the leg being driven, and leave the
* rest of the plan to the screen that planned it.
*
* Same boundary as [startNavFromEvRoute] — a navigation write, not a vehicle write — and the
* same two cautions: the adapter logs the point, and this is a synchronous fan-out that must
* not run on the main thread.
*
* @return true when the adapter took the call, which is not the map confirming it obeyed.
*/
fun goTo(point: NavigationHandoff.Poi): Boolean =
SaicAidl.callWriting(binder(), DESCRIPTOR, TX_GO_TO) { data -> data.writeGoTo(point) }
/**
* `goTo(String address, double latitude, double longitude)`, in the order the adapter's stub
* reads it. Latitude before longitude, and the name first: swap any two and the car drives to
* a place nobody asked for, which no compiler and no crash would have said.
*/
internal fun Parcel.writeGoTo(point: NavigationHandoff.Poi) {
writeString(point.name)
writeDouble(point.latitude)
writeDouble(point.longitude)
}
/**
* Hands a planned route to whichever navigation app this head unit runs.
*
* **The one write in this object, and it is not a vehicle write.** `VehicleWriteGate` exists
* for the settings that change how the car behaves under the driver — AEB, ELK, ACC/TJA, the
* drive mode — and gates them on standstill. A destination is not one of those any more than
* the comfort writes that gate already exempts: it moves a map, and the driver asked for it.
*
* **It is a call in, not an impersonation.** `IMapNotificationListener` is the channel the
* head unit uses to *command* the navigation app, and registering on it to send one
* destination would mean claiming to be a navigation provider. This is the other end of the
* same wire: `IGeneralService` transaction 48 hands the points to `GeneralService`, which
* forwards to `MapService`, which fans them out over its `RemoteCallbackList` to the app
* that did register. The navigation app stays the navigation app.
*
* **What leaves the car.** Two lists of latitude, longitude and name — the pathway, then the
* destination — and nothing else; `EVRoutPoiInfo` has no other field. They do not leave the
* *car*, in fact: this is one binder transaction to a service on the same head unit.
* **The adapter logs them.** `MapService.startNavFromEVRout` writes the whole of both lists
* to the head unit's own logcat before fanning out. That is the vendor's log, not this
* app's, and it is the one place a destination appears in text — worth knowing, not worth
* refusing a working handoff over.
*
* **Call it off the main thread.** The transaction is not `oneway`, and the service fans out
* to every registered listener, synchronously, while holding the lock on its callback list.
*
* @param destination where the driver is going.
* @param pathway points to pass through on the way — the charging stop, in this app's case.
* Capped by [NavigationHandoff.pathway] before it gets here.
* @return true when the adapter took the call. It does not mean the map drew a route: the
* fan-out swallows whatever the navigation app throws, so a listener that refused the
* points is indistinguishable from one that followed them. Only the driver can say.
*/
fun startNavFromEvRoute(
destination: NavigationHandoff.Poi,
pathway: List<NavigationHandoff.Poi> = emptyList(),
): Boolean = SaicAidl.callWriting(binder(), DESCRIPTOR, TX_START_NAV_FROM_EV_ROUTE) { data ->
data.writeEvRoutPoiList(pathway)
data.writeEvRoutPoiList(listOf(destination))
}
/**
* 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.
*/
internal fun Parcel.writeEvRoutPoiList(pois: List<NavigationHandoff.Poi>) {
writeInt(pois.size)
pois.forEach { poi ->
writeInt(1)
writeDouble(poi.latitude)
writeDouble(poi.longitude)
writeString(poi.name)
}
}
/** Transaction codes seen so far, with counts. See [TransactionCensus]. */
fun census(): Map<Int, Int> = census.snapshot()
/** Codes the census could not index. Non-zero means the interface is not the one expected. */
fun censusBeyondCeiling(): Int = census.beyondCeiling
/** The last parcel size seen on each code. See [TransactionCensus.payloadBytes]. */
fun censusPayloadBytes(): Map<Int, Int> = census.payloadBytes()
/**
* Starts listening. Idempotent.
*
* @return true when the adapter accepted the registration.
*/
fun start(): Boolean {
if (registered) return true
val target = binder() ?: return false
val accepted = SaicAidl.callVoid(target, DESCRIPTOR, TX_REGISTER_LISTENER, listener)
registered = accepted
if (!accepted) AppLogger.w(TAG, "nav-guidance: registration refused")
return accepted
}
/**
* Stops listening and forgets what was seen.
*
* Callers must reach this on the way out of any screen that called [start]: a listener
* left registered keeps this process on the adapter's fan-out path for no reason.
*/
fun stop() {
val target = binder()
if (registered && target != null) {
SaicAidl.callVoid(target, DESCRIPTOR, TX_UNREGISTER_LISTENER, listener)
}
registered = false
state = NavGuidance.EMPTY
census.clear()
}
/**
* The callback the adapter calls.
*
* Unrecognised transactions are answered without being parsed. Letting them fall through
* to `super.onTransact` would return false, which the adapter turns into an exception it
* logs for every unrelated callback it makes — noise on the head unit's log for no gain.
*/
private val listener = object : Binder() {
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
// The size before anything is read: `dataPosition` is zero here, so this is the whole
// parcel. Recorded for every code, decoded or not — the undecoded ones are exactly
// the ones whose shape nobody knows.
census.record(code, runCatching { data.dataSize() }.getOrDefault(-1))
if (code !in NavGuidanceReducer.KNOWN_TRANSACTIONS) {
reply?.writeNoException()
return true
}
runCatching {
data.enforceInterface(LISTENER_DESCRIPTOR)
state = when (code) {
NavGuidanceReducer.TX_GUIDE_INFOS_CHANGE -> NavGuidanceReducer.fold(
state, code,
first = data.readInt(),
second = data.readInt(),
text = data.readString(),
atElapsedMs = SystemClock.elapsedRealtime(),
)
NavGuidanceReducer.TX_ROAD_INFO_CHANGE -> NavGuidanceReducer.fold(
state, code,
text = data.readString(),
atElapsedMs = SystemClock.elapsedRealtime(),
)
else -> NavGuidanceReducer.fold(
state, code,
first = data.readInt(),
atElapsedMs = SystemClock.elapsedRealtime(),
)
}
}.onFailure {
// A payload shaped differently on another firmware is a null reading, not a
// crash on the adapter's fan-out thread.
AppLogger.d(TAG, "nav-guidance#$code: undecodable: ${it.message}")
}
reply?.writeNoException()
return true
}
override fun getInterfaceDescriptor(): String = LISTENER_DESCRIPTOR
}
}
@@ -0,0 +1,591 @@
package com.evsuite.hardware.saic
import android.content.Context
import android.os.IBinder
import com.evsuite.hardware.A9Climate
import com.evsuite.hardware.GlassProbe
import com.evsuite.hardware.VsmGlass
/**
* The vendor vehicle service the head unit's HVAC and charging screens talk to.
*
* One bound service exposes a *hub*: `getService(name)` hands back the binder for a named
* sub-service. Both consumers below go through it, so the bind happens once.
*
* Binding contract: `com.saicmotor.sdk.vehiclesettings.manager.BaseManager`
* (bind target), `IHubService` (the lookup), `AirConditionManager` and
* `VehicleChargingManager` (the two names used here).
*/
object SaicHub {
private const val PACKAGE = "com.saicmotor.service.vehicle"
private const val ACTION = "com.saicmotor.service.vehicle.VehicleService"
private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IHubService"
private const val TX_GET_SERVICE = 1
private val service = SaicService(PACKAGE, ACTION, "vehicle-hub")
private val resolved = HashMap<String, IBinder>()
val isReady: Boolean get() = service.isReady
fun connect(context: Context) = service.connect(context)
/** Sub-service binder, resolved once and re-resolved after the hub reconnects. */
@Synchronized
fun service(name: String): IBinder? {
val hub = service.binder() ?: run { resolved.clear(); return null }
resolved[name]?.takeIf { it.isBinderAlive }?.let { return it }
val sub = SaicAidl.callBinder(hub, DESCRIPTOR, TX_GET_SERVICE, name) ?: return null
resolved[name] = sub
return sub
}
}
/**
* Climate control, as the stock HVAC app performs it.
*
* This is the write path the project deliberately did without until now: EVHardware could
* only *read* climate through standard AOSP property ids that no MG4 confirmed, so there
* was no honest way to change anything. These calls are the ones
* the car's own HVAC screen makes, so what they do is not in doubt — only whether the
* service answers on a given firmware, which the Diagnostic tab now reports.
*
* Temperatures are whole degrees Celsius. The stock UI clamps to 17–33, where the ends read
* as LO and HI (`HvacActivity.onLongTouch`), so the same bounds apply here.
*/
object SaicClimate {
private const val NAME = "aircondition"
private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IAirConditionService"
const val TEMP_MIN = 17
const val TEMP_MAX = 33
// Transaction codes used by the supported runtime IAirConditionService contract.
private const val TX_SET_POWER = 4
private const val TX_OPEN_POWER = 5
private const val TX_CLOSE_POWER = 6
private const val TX_SET_AUTO = 7
private const val TX_SET_AC = 8
private const val TX_SET_LOOP_MODE = 9
private const val TX_SET_ECON = 13
private const val TX_SET_AIR_VOLUME = 15
private const val TX_SET_DRV_TEMP = 41
private const val TX_SET_PSG_TEMP = 42
private const val TX_OPEN_FRONT_DEFROST = 48
private const val TX_CLOSE_FRONT_DEFROST = 49
private const val TX_OPEN_BACK_DEFROST = 50
private const val TX_CLOSE_BACK_DEFROST = 51
private const val TX_GET_POWER = 53
private const val TX_GET_AUTO = 54
private const val TX_GET_AC = 55
private const val TX_GET_LOOP_MODE = 56
private const val TX_GET_ECON = 57
private const val TX_GET_AIR_VOLUME = 59
private const val TX_GET_MAX_AIR_VOLUME = 60
private const val TX_GET_DRV_TEMP = 81
private const val TX_GET_PSG_TEMP = 82
private const val TX_GET_OUT_CAR_TEMP = 88
private const val TX_GET_FRONT_DEFROST = 92
private const val TX_GET_BACK_DEFROST = 93
/** `AirConditionBinder.TEMPERATURE_ERROR_CONST`: the signal is not available. */
private const val TEMP_ERROR = -10000f
/** Loop mode: 0 = fresh air, 1 = recirculation (`openLoopInner` / `openLoopOutside`). */
const val LOOP_OUTSIDE = 0
const val LOOP_INNER = 1
/**
* Available when either path answers.
*
* Two paths, because the vendor hub this object talks to does not exist on the A9
* generations, whose climate is reached through `carapi` instead — see [A9Climate]. The
* split is made here, once, so nothing above has to know which car it is running on.
*/
val isAvailable: Boolean get() = if (A9Climate.isPlatform) A9Climate.isAvailable else binder() != null
private fun binder(): IBinder? = SaicHub.service(NAME)
private val a9: Boolean get() = A9Climate.isPlatform
// ── Writes ───────────────────────────────────────────────────────────────
fun setPower(on: Boolean): Boolean =
if (a9) A9Climate.setPower(on)
else SaicAidl.callVoid(binder(), DESCRIPTOR, if (on) TX_OPEN_POWER else TX_CLOSE_POWER)
fun setAc(on: Boolean): Boolean =
if (a9) A9Climate.setAc(on)
else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_AC, if (on) 1 else 0)
fun setAuto(on: Boolean): Boolean =
if (a9) A9Climate.setAuto(on)
else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_AUTO, if (on) 1 else 0)
/**
* ECON has no counterpart on A9: the HVAC client exposes no such switch, and there is
* nothing to substitute — reporting the write as refused is the truth.
*/
fun setEcon(on: Boolean): Boolean =
if (a9) false
else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_ECON, if (on) 1 else 0)
fun setRecirculation(on: Boolean): Boolean =
if (a9) A9Climate.setRecirculation(on)
else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_LOOP_MODE, if (on) LOOP_INNER else LOOP_OUTSIDE)
fun setFanLevel(level: Int): Boolean =
if (a9) A9Climate.setFanLevel(level)
else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_AIR_VOLUME, level)
/** Driver-side target. The passenger side follows unless dual zone is on. */
fun setDriverTemp(celsius: Int): Boolean =
if (a9) A9Climate.setDriverTemp(celsius.coerceIn(TEMP_MIN, TEMP_MAX))
else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_DRV_TEMP, celsius.coerceIn(TEMP_MIN, TEMP_MAX))
/**
* The passenger's own target, which the A9 client does not expose separately — its
* `setDriverTemperature` is the whole cabin unless dual zone is on, and there is no
* honest way to set one side alone.
*/
fun setPassengerTemp(celsius: Int): Boolean =
if (a9) false
else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_PSG_TEMP, celsius.coerceIn(TEMP_MIN, TEMP_MAX))
fun setFrontDefrost(on: Boolean): Boolean =
if (a9) A9Climate.setFrontDefrost(on)
else SaicAidl.callVoid(binder(), DESCRIPTOR, if (on) TX_OPEN_FRONT_DEFROST else TX_CLOSE_FRONT_DEFROST)
fun setRearDefrost(on: Boolean): Boolean =
if (a9) A9Climate.setRearDefrost(on)
else SaicAidl.callVoid(binder(), DESCRIPTOR, if (on) TX_OPEN_BACK_DEFROST else TX_CLOSE_BACK_DEFROST)
// ── Reads (null = the service did not answer) ────────────────────────────
// The service answers -1 for a signal it holds no value for, which is not a state.
fun powerOn(): Boolean? = if (a9) A9Climate.powerOn() else flag(TX_GET_POWER)
fun acOn(): Boolean? = if (a9) A9Climate.acOn() else flag(TX_GET_AC)
fun autoOn(): Boolean? = if (a9) A9Climate.autoOn() else flag(TX_GET_AUTO)
fun econOn(): Boolean? = if (a9) null else flag(TX_GET_ECON)
fun recirculationOn(): Boolean? =
if (a9) A9Climate.recirculationOn() else level(TX_GET_LOOP_MODE)?.let { it == LOOP_INNER }
fun fanLevel(): Int? = if (a9) A9Climate.fanLevel() else level(TX_GET_AIR_VOLUME)
/** A9 states no maximum of its own; the catalogue's is the measured one. */
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)
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. */
fun outsideTempCelsius(): Float? =
SaicAidl.callFloat(binder(), DESCRIPTOR, TX_GET_OUT_CAR_TEMP)?.takeIf { it != TEMP_ERROR }
private fun level(code: Int): Int? =
SaicAidl.callInt(binder(), DESCRIPTOR, code)?.takeIf { it >= 0 }
private fun flag(code: Int): Boolean? = level(code)?.let { it == 1 }
}
/**
* Charging and battery settings, as the stock charging screen performs them.
*
* `ChargingCloseSoc` is the charge limit in percent, `ReserChrg*` the scheduled window, and
* `DrivingBatteryHeat` the battery pre-heat switch — the three things the vehicle's own
* screen offers.
*
* The same binder also answers the energy counters the vehicle keeps for itself, which is the
* only measured kilowatt-hour on a car that publishes no battery power. They are reads; this
* object writes nothing but the charging settings the stock screen already writes.
*
* Binding contract: `VehicleChargingManager` and `IVehicleChargingService`.
*/
object SaicCharging {
private const val NAME = "vehiclecharging"
private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IVehicleChargingService"
private const val TX_GET_SOC = 3
private const val TX_GET_ENDURANCE_KM = 4
private const val TX_GET_CHARGING_STATUS = 9
private const val TX_GET_CLOSE_SOC = 13
private const val TX_SET_CLOSE_SOC = 14
private const val TX_GET_CONTROL_SWITCH = 17
private const val TX_SET_CONTROL_SWITCH = 18
private const val TX_GET_RESERVE_CONTROL = 19
private const val TX_SET_RESERVE_CONTROL = 20
private const val TX_GET_RESERVE_START_HOUR = 21
private const val TX_SET_RESERVE_START_HOUR = 22
private const val TX_GET_RESERVE_START_MINUTE = 23
private const val TX_SET_RESERVE_START_MINUTE = 24
private const val TX_GET_RESERVE_STOP_HOUR = 25
private const val TX_SET_RESERVE_STOP_HOUR = 26
private const val TX_GET_RESERVE_STOP_MINUTE = 27
private const val TX_SET_RESERVE_STOP_MINUTE = 28
private const val TX_GET_BATTERY_HEAT = 37
private const val TX_SET_BATTERY_HEAT = 38
private const val TX_GET_CONSUMPTION_PER_KM = 66
private const val TX_GET_CONSUMPTION_PER_KM_VALID = 67
private const val TX_GET_CONSUMPTION_PER_KM_LIST = 68
private const val TX_GET_ACC_CONSUMPTION_AFTER_CHARGE = 73
private const val TX_GET_ACC_CONSUMPTION_AFTER_START = 74
private const val TX_GET_TOTAL_CONSUMPTION_AFTER_CHARGE = 75
private const val TX_GET_TOTAL_CONSUMPTION_AFTER_START = 76
private const val TX_GET_TOTAL_REGEN_AFTER_CHARGE = 77
private const val TX_GET_TOTAL_REGEN_AFTER_START = 78
/** The service's own `*V` companion signals: 1 is a value it stands behind. */
private const val SIGNAL_VALID = 1
val isAvailable: Boolean get() = binder() != null
private fun binder(): IBinder? = SaicHub.service(NAME)
/** State of charge in percent. */
fun stateOfChargePercent(): Float? =
SaicAidl.callFloat(binder(), DESCRIPTOR, TX_GET_SOC)?.takeIf { it >= 0f }
/**
* Remaining range in kilometres — the number on the car's own cluster, not a calculation.
*
* The service answers -1 for a range it holds no value for, and clamps its own signal
* against a maximum before answering, so an implausible reading never reaches here as a
* number. `read` already drops the negative.
*/
fun rangeKm(): Int? = read(TX_GET_ENDURANCE_KM)
fun chargingStatus(): Int? = read(TX_GET_CHARGING_STATUS)
/** Charge limit in percent — charging stops here. */
fun chargeLimitPercent(): Int? = read(TX_GET_CLOSE_SOC)
fun setChargeLimitPercent(percent: Int): Boolean =
SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CLOSE_SOC, percent.coerceIn(0, 100))
fun chargingEnabled(): Boolean? = read(TX_GET_CONTROL_SWITCH)?.let { it == 1 }
fun setChargingEnabled(on: Boolean): Boolean =
SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CONTROL_SWITCH, if (on) 1 else 0)
fun scheduleEnabled(): Boolean? = read(TX_GET_RESERVE_CONTROL)?.let { it == 1 }
fun setScheduleEnabled(on: Boolean): Boolean =
SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_RESERVE_CONTROL, if (on) 1 else 0)
fun scheduleStartMinutes(): Int? = minutes(TX_GET_RESERVE_START_HOUR, TX_GET_RESERVE_START_MINUTE)
fun scheduleStopMinutes(): Int? = minutes(TX_GET_RESERVE_STOP_HOUR, TX_GET_RESERVE_STOP_MINUTE)
/** Hour and minute are two separate signals; both must land for the window to change. */
fun setScheduleStart(minutesOfDay: Int): Boolean =
setTime(TX_SET_RESERVE_START_HOUR, TX_SET_RESERVE_START_MINUTE, minutesOfDay)
fun setScheduleStop(minutesOfDay: Int): Boolean =
setTime(TX_SET_RESERVE_STOP_HOUR, TX_SET_RESERVE_STOP_MINUTE, minutesOfDay)
fun batteryPreheatOn(): Boolean? = read(TX_GET_BATTERY_HEAT)?.let { it == 1 }
fun setBatteryPreheat(on: Boolean): Boolean =
SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_BATTERY_HEAT, if (on) 1 else 0)
/**
* Energy the car itself counted since the last ignition-on, in kWh.
*
* This is not derived from battery power, and that is the point: SWI68 publishes no
* `EV_BATTERY_INSTANTANEOUS_CHARGE_RATE`, so every kWh figure in EVSuite has had to be
* modelled or refused. The charging service keeps its own counters, fed by the vehicle's
* energy properties, and hands them out over the same binder the charge limit uses. A
* counter read at the start of a trip and again at the end gives measured kilowatt-hours
* with no pack capacity assumed and nothing integrated.
*
* The counter resets at ignition-on, so a trip that spans a stop cannot be measured by
* subtracting the ends: the second reading starts again from zero and the difference goes
* negative. Callers must treat a drop as a reset, not as regeneration.
*/
fun consumedKwhSinceStart(): Float? = energy(TX_GET_TOTAL_CONSUMPTION_AFTER_START)
/** Same counter, reset at the end of the last charge rather than at ignition-on. */
fun consumedKwhSinceCharge(): Float? = energy(TX_GET_TOTAL_CONSUMPTION_AFTER_CHARGE)
fun regeneratedKwhSinceStart(): Float? = energy(TX_GET_TOTAL_REGEN_AFTER_START)
fun regeneratedKwhSinceCharge(): Float? = energy(TX_GET_TOTAL_REGEN_AFTER_CHARGE)
/**
* The `AccConsumption` counter, alongside `TotalConsumption`, over the same period.
*
* **What this counts is not established.** The service validates it against the same
* bounds as the total and names it no further, so `Acc` reads equally as *accessory* —
* the auxiliary share the total also contains, which would be a measured climate figure —
* or as *accumulated*, a second framing of the same energy. The two readings differ by
* everything: one is a breakdown, the other is a duplicate.
*
* Until a drive with the climate off and a drive with it on are compared on the car, this
* is exposed as the raw counter under the vehicle's own name and nothing is attributed to
* it. Naming it `climateKwh` here would be inventing the evidence.
*/
fun accCounterKwhSinceStart(): Float? = energy(TX_GET_ACC_CONSUMPTION_AFTER_START)
fun accCounterKwhSinceCharge(): Float? = energy(TX_GET_ACC_CONSUMPTION_AFTER_CHARGE)
/**
* Consumption per kilometre as the cluster shows it, or null when the car disowns it.
*
* The value carries a companion validity signal, and the service answers the last value
* it held whatever that signal says. Reading one without the other reports a stale number
* as a current one, so both are read and a value the car does not stand behind is dropped.
*/
fun consumptionPerKm(): Float? =
if (read(TX_GET_CONSUMPTION_PER_KM_VALID) != SIGNAL_VALID) null
else SaicAidl.callFloat(binder(), DESCRIPTOR, TX_GET_CONSUMPTION_PER_KM)
?.takeIf { it.isFinite() && it >= 0f }
/**
* The last fifty per-kilometre readings, oldest first — the cluster's own history.
*
* The service persists them as a JSON array of floats and drops the oldest past fifty, so
* this is a window the car maintains, not a series this app has to record.
*/
fun consumptionPerKmHistory(): List<Float> =
parseHistory(SaicAidl.callString(binder(), DESCRIPTOR, TX_GET_CONSUMPTION_PER_KM_LIST))
/** Kept apart from the binder so the car's own wire format can be tested without one. */
internal fun parseHistory(json: String?): List<Float> {
if (json.isNullOrBlank()) return emptyList()
return json.trim().removeSurrounding("[", "]")
.split(',')
.mapNotNull { it.trim().toFloatOrNull()?.takeIf(Float::isFinite) }
}
/** A counter the car never started is 0, and a negative one is not a counter. */
private fun energy(code: Int): Float? =
SaicAidl.callFloat(binder(), DESCRIPTOR, code)?.takeIf { it.isFinite() && it >= 0f }
private fun read(code: Int): Int? =
SaicAidl.callInt(binder(), DESCRIPTOR, code)?.takeIf { it >= 0 }
private fun minutes(hourCode: Int, minuteCode: Int): Int? {
val hour = read(hourCode) ?: return null
val minute = read(minuteCode) ?: return null
return hour * 60 + minute
}
private fun setTime(hourCode: Int, minuteCode: Int, minutesOfDay: Int): Boolean {
val clamped = minutesOfDay.coerceIn(0, 24 * 60 - 1)
val hourOk = SaicAidl.callVoid(binder(), DESCRIPTOR, hourCode, clamped / 60)
val minuteOk = SaicAidl.callVoid(binder(), DESCRIPTOR, minuteCode, clamped % 60)
return hourOk && minuteOk
}
}
/**
* Windows and door locks, through the same hub as climate and charging.
*
* **Reading and writing the glass are two different scales, and this is the whole story of
* the feature.** The service validates every value against a maximum of its own and silently
* drops anything above it, and the two maxima are not the same:
*
* • reading — `max_vehicle_window_get = 100`: a position in percent, 0 closed to 100 open.
* • writing — `max_vehicle_window_set = 7`: a three-bit command, not a position. A write
* of 100 is above the maximum, so `VehicleControlBinder.setDriveWindow` drops it without
* touching the property and without answering anything. The binder call still succeeds:
* the method returns void, so "accepted" and "ignored" look identical from here.
*
* That is why glass writes scaled as a percentage reported success and moved nothing. The
* same shape holds elsewhere in the service — the tailgate is `get = 100`, `set = 1` — so a
* status range and a command range simply are not the same thing on this interface.
*
* **The hub is the fallback, not the path.** No head-unit application writes this property,
* so the firmware offers no example to read the seven values off, and the command is passed
* through as the caller gave it with anything outside 0–7 refused here rather than sent to
* be dropped. The VSM carries the same glass under a setter the car's own window switch
* goes through, where the commands *are* established — [VsmGlass]. Every read and write
* below prefers it and falls back to the hub only on a firmware whose VSM does not have it.
*
* The electric tailgate is deliberately absent: the launcher defines OPEN and CLOSE as the
* same value (1.0f), so it is a pulse whose direction depends on state this cannot read.
* Firing it blind could open a boot at the wrong moment.
*
* Binding contract: `IVehicleControlService` (codes) and
* `VehicleControlBinder` (the CarCabinManager properties behind them);
* `VehicleConstant` (value semantics).
*/
object SaicVehicleControl {
private const val NAME = "vehiclecontrol"
private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IVehicleControlService"
private const val TX_GET_DOOR_LOCK = 3
private const val TX_SET_DOOR_LOCK = 4
private const val TX_GET_DRIVER_WINDOW = 5
private const val TX_SET_DRIVER_WINDOW = 6
private const val TX_GET_PASSENGER_WINDOW = 7
private const val TX_SET_PASSENGER_WINDOW = 8
private const val TX_GET_LEFT_REAR_WINDOW = 9
private const val TX_SET_LEFT_REAR_WINDOW = 10
private const val TX_GET_RIGHT_REAR_WINDOW = 11
private const val TX_SET_RIGHT_REAR_WINDOW = 12
/** `VehicleConstant.DoorLockStateItem` — 1 locked, 2 unlocked. */
private const val DOOR_LOCKED = 1
private const val DOOR_UNLOCKED = 2
/** Position read, in percent — `max_vehicle_window_get` in the service's own resources. */
const val WINDOW_POSITION_MAX = 100
/**
* The largest command the service will pass on — `max_vehicle_window_set`. Anything
* above it is dropped there in silence, which is why it is enforced here instead.
*/
const val WINDOW_COMMAND_MAX = 7
/**
* The four windows, each with the pair of codes that reads and moves it.
*
* `setAllWindows` was here first and stays: closing the glass is one gesture, and asking
* a caller to make four calls for it would let three succeed and one be forgotten. This
* is the other half — a rule that vents the driver's window alone cannot be written with
* an all-or-nothing call.
*/
enum class Window(
internal val readCode: Int,
internal val writeCode: Int,
/** The same window's number on the VSM, which addresses glass by area — [VsmGlass]. */
internal val area: Int,
) {
DRIVER(TX_GET_DRIVER_WINDOW, TX_SET_DRIVER_WINDOW, 0),
PASSENGER(TX_GET_PASSENGER_WINDOW, TX_SET_PASSENGER_WINDOW, 1),
REAR_LEFT(TX_GET_LEFT_REAR_WINDOW, TX_SET_LEFT_REAR_WINDOW, 2),
REAR_RIGHT(TX_GET_RIGHT_REAR_WINDOW, TX_SET_RIGHT_REAR_WINDOW, 3)
}
private val WINDOW_READS = Window.entries.map { it.readCode }
private val WINDOW_WRITES = Window.entries.map { it.writeCode }
val isAvailable: Boolean get() = binder() != null
private fun binder(): IBinder? = SaicHub.service(NAME)
/**
* The widest-open window, in percent — the one number a rule about glass wants. "Any
* window open" is `> 0`, and a rule closing them cares about the worst case.
*/
fun widestWindowPercent(): Int? =
if (VsmGlass.isAvailable) Window.entries.mapNotNull { VsmGlass.percent(it.area) }.maxOrNull()
else WINDOW_READS.mapNotNull { code -> SaicAidl.callFloat(binder(), DESCRIPTOR, code) }
.takeIf { it.isNotEmpty() }
?.max()
?.toInt()
?.takeIf { it >= 0 }
/**
* One window's position in percent, 0 closed to 100 open. Null when it does not answer.
*/
fun windowPercent(window: Window): Int? =
if (VsmGlass.isAvailable) VsmGlass.percent(window.area)
else SaicAidl.callFloat(binder(), DESCRIPTOR, window.readCode)?.toInt()?.takeIf { it >= 0 }
/**
* Sends one glass command to one window.
*
* [command] is a command in `0..`[WINDOW_COMMAND_MAX], **not** a position — see the class
* KDoc. A value outside that range is refused here and returns false: the service would
* accept the call and drop the value, which is indistinguishable from having moved.
*
* [VsmGlass.Command.UP] is held down rather than written once, because that is the only
* way it moves anything — see [holdWindow]. The hub path has no such notion and cannot
* acquire one: it is one write there whatever the value.
*/
fun setWindow(window: Window, command: Int): Boolean {
if (command !in 0..WINDOW_COMMAND_MAX) return false
if (!VsmGlass.isAvailable)
return SaicAidl.callVoid(binder(), DESCRIPTOR, window.writeCode, command.toFloat())
return if (command == VsmGlass.Command.UP) VsmGlass.hold(listOf(window.area), command)
else VsmGlass.send(listOf(window.area), command)
}
/**
* Holds [command] on one window for [durationMs], then releases it.
*
* What [GlassProbe] sweeps with, and the reason a close works on the trims whose motors
* have no position sensor: those obey nothing but a switch held down. Falls back to a
* single write on the hub, which is all that path offers.
*/
fun holdWindow(window: Window, command: Int, durationMs: Long = VsmGlass.HOLD_MS): Boolean {
if (command !in 0..WINDOW_COMMAND_MAX) return false
return if (VsmGlass.isAvailable) VsmGlass.hold(listOf(window.area), command, durationMs)
else SaicAidl.callVoid(binder(), DESCRIPTOR, window.writeCode, command.toFloat())
}
/** Each window individually, for the diagnostic — this is what confirms the scale. */
fun windowPercents(): List<Float?> =
WINDOW_READS.map { code -> SaicAidl.callFloat(binder(), DESCRIPTOR, code) }
/**
* Sends the same glass command to all four windows.
*
* @return true when every one of them was accepted.
*/
fun setAllWindows(command: Int): Boolean {
if (command !in 0..WINDOW_COMMAND_MAX) return false
if (VsmGlass.isAvailable) {
val areas = Window.entries.map { it.area }
return if (command == VsmGlass.Command.UP) VsmGlass.hold(areas, command)
else VsmGlass.send(areas, command)
}
val value = command.toFloat()
// Every window attempted even if one refuses: three closed beats none closed, and
// the caller still learns that it was not complete.
return WINDOW_WRITES.map { code -> SaicAidl.callVoid(binder(), DESCRIPTOR, code, value) }
.all { it }
}
fun doorsLocked(): Boolean? =
doorLockState()
?.takeIf { it == DOOR_LOCKED || it == DOOR_UNLOCKED }
?.let { it == DOOR_LOCKED }
/**
* The raw lock state, for the diagnostic only.
*
* The service answers a status in `0..7` (`max_vehicle_lock_get`) of which only 1 and 2
* are named, so [doorsLocked] discards everything else. Reporting "unreadable" without
* saying what actually came back is what makes that a dead end: the number is what tells
* whether the state is simply unnamed here or the read is not answering at all.
*/
fun doorLockState(): Int? = SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_DOOR_LOCK)
fun setDoorsLocked(locked: Boolean): Boolean =
SaicAidl.callVoid(
binder(), DESCRIPTOR, TX_SET_DOOR_LOCK,
if (locked) DOOR_LOCKED else DOOR_UNLOCKED
)
}
/**
* The gear, read from the same hub as climate, charging and glass.
*
* A second source for something [com.evsuite.hardware.EVHardware.isVehicleInPark] already
* reads. It exists because the primary path on SWI68/165 goes through the vendor
* `VehicleConditionManager` object held in-process, and when that object never arrived the
* gear was simply unknown — while the very same service was sitting on the hub, answering.
*
* Only gear 1 is named, and only because three independent places agree on it: EVHardware's
* own `GEAR_PARK_VALUE`, two head-unit apps using `carGear == 1` as the condition for playing
* video on the centre screen (`UsbVideoView`, `VideoListFragment`), and
* `IBackUpBinder.isSecureForBackUp`, which accepts gears 1 and 3 alongside `speed == 0`.
* Nothing pins 2, 3 and 4, so nothing here names them.
*
* `getCarSpeed` is on this interface too and is deliberately unused: the binder returns a
* float with no unit in any source, and a speed read in the wrong unit would open the write
* gate at 50 km/h believing it was 14. Speed keeps coming from the AOSP property, whose unit
* is specified.
*
* Binding contract: `IVehicleConditionService` (codes) and
* `VehicleConditionBinder` (the CarSensorManager signals behind them).
*/
object SaicVehicleCondition {
private const val NAME = "vehiclecondition"
private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IVehicleConditionService"
private const val TX_GET_CAR_GEAR = 5
val isAvailable: Boolean get() = binder() != null
private fun binder(): IBinder? = SaicHub.service(NAME)
/** Raw gear, or null when unreadable — the binder answers -1 when the signal is absent. */
fun gearOrNull(): Int? = SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_CAR_GEAR)?.takeIf { it > 0 }
}
@@ -0,0 +1,200 @@
package com.evsuite.hardware.saic
import android.content.Context
import android.os.Binder
import android.os.IBinder
import android.os.Parcel
import com.evsuite.hardware.AppLogger
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* The head unit's own weather, from the service its map stack asks.
*
* The launcher shows weather too, but it broadcasts what it has for the city it settled on.
* This is the source underneath: a query for a position, answered for that position. A rule
* about rain is about where the car is, so the position query is the one worth binding.
*
* **Asynchronous, unlike every other service here.** The call takes a callback binder and
* answers on it, so the reading is made synchronous with a bounded wait — a rule cycle has to
* finish, and a network round-trip inside the head unit must not hold it open. A query that
* does not answer in time is a null reading, which the catalogue already treats as
* "unreadable" rather than as "no rain".
*
* **Current conditions only.** The service declares seven transactions and none of them
* answers a forecast: its `WeatherFuture` parcelable is carried by `Result` but nothing on
* this firmware ever fills it. The daily outlook the weather app shows is fetched by that app
* itself, with credentials that are not reachable from here, and broadcast as
* `com.saicmotor.weather` carrying today's temperature and phrase alone. A forecast condition
* would therefore be permanently unreadable, so there is none.
*
* Binding contract: `com.saicmotor.mapservice.SaicService` (bind target),
* `ISaicService` (the queries) and `ICallBack` (the single-method answer).
*/
object SaicWeather {
private const val TAG = "EV_SAIC"
private const val PACKAGE = "com.saicmotor.mapservice"
private const val CLASS = "com.saicmotor.mapservice.SaicService"
private const val DESCRIPTOR = "com.saicmotor.mapservice.ISaicService"
private const val CALLBACK_DESCRIPTOR = "com.saicmotor.mapservice.ICallBack"
private const val TX_QUERY_WEATHER_BY_POSITION = 2
private const val TX_CALLBACK_ON_RESULT = 1
/**
* `Result` carries its payload behind a type tag, and only this one is decoded. Another
* tag is another shape, and answering with a half-read parcel would be worse than saying
* nothing.
*/
private const val DATA_OBJECT_WEATHER = "data_object_weather"
/**
* How long a reading may hold the rule cycle.
*
* The query can reach the network, so it is not instant, and a rule evaluated at ignition
* would otherwise wait on a cold radio. Two seconds is longer than a warm answer and short
* enough that a cold one simply comes back unreadable.
*/
private const val TIMEOUT_MS = 2_000L
private val service = SaicService.byComponent(PACKAGE, CLASS, "weather")
val isAvailable: Boolean get() = binder() != null
fun connect(context: Context) = service.connect(context)
private fun binder(): IBinder? = service.binder()
/**
* Current conditions where the car is.
*
* [languageCode] reaches the provider and decides the language of [Reading.text], which is
* the field a rule compares against — so it is the caller's, not a constant here.
*/
fun currentAt(latitude: Double, longitude: Double, languageCode: String): Reading? =
// Longitude first, then latitude, and both as strings — the query takes them in that
// order, so swapping them would ask about a plausible-looking wrong place rather than
// failing outright.
query(TX_QUERY_WEATHER_BY_POSITION, ::readCurrent) { target, callback ->
SaicAidl.callVoid(
target, DESCRIPTOR, TX_QUERY_WEATHER_BY_POSITION,
longitude.toString(), latitude.toString(), languageCode, callback
)
}
/**
* One query, made synchronous.
*
* The service takes a callback binder and answers on it, so every reading here is a send
* plus a bounded wait. A binder that never calls back leaves the latch untripped and the
* reading null, which the catalogue treats as "cannot tell" — the same as any other
* unreadable signal.
*/
private fun <T> query(
code: Int,
decode: (Parcel) -> T?,
send: (IBinder, Binder) -> Boolean
): T? {
val target = binder() ?: return null
var answer: T? = null
val answered = CountDownLatch(1)
val callback = object : Binder() {
override fun onTransact(tx: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
if (tx != TX_CALLBACK_ON_RESULT) return super.onTransact(tx, data, reply, flags)
data.enforceInterface(CALLBACK_DESCRIPTOR)
answer = runCatching { decodeArgument(data, decode) }
.onFailure { AppLogger.d(TAG, "weather#$code: undecodable answer: ${it.message}") }
.getOrNull()
answered.countDown()
reply?.writeNoException()
return true
}
override fun getInterfaceDescriptor(): String = CALLBACK_DESCRIPTOR
}
if (!send(target, callback)) return null
return if (answered.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) answer else null
}
/**
* The `Result` argument, past the flag that says whether there is one.
*
* A nullable parcelable argument is written as a presence int and then the object, so the
* payload starts one int later than the object's own first field. Reading from the wrong
* offset is not an error the parcel reports — every field simply lands in the next one's
* place, the type tag no longer matches, and the answer is silently discarded. That is
* exactly how a weather reading that arrived was reported as unreadable.
*/
private fun <T> decodeArgument(parcel: Parcel, decode: (Parcel) -> T?): T? =
if (parcel.readInt() == 0) null else decode(parcel)
/**
* `Result`: a code, a message, a tag naming what follows, then the tagged object.
*
* Read by hand because the classes it names live in the head unit's own APKs, not on the
* boot classpath — the same reason every transaction here is written out rather than
* called through an SDK.
*/
private fun readCurrent(parcel: Parcel): Reading? {
if (!openPayload(parcel, DATA_OBJECT_WEATHER)) return null
val text = parcel.readString()
val icon = parcel.readString()
parcel.readByte() // daytime
val temperature = parcel.readDouble()
val unit = parcel.readString()
parcel.readInt() // unit type
val city = parcel.readString()
if (text.isNullOrBlank()) return null
return Reading(
text = text,
icon = icon.orEmpty(),
temperatureCelsius = toCelsius(temperature, unit),
city = city.orEmpty()
)
}
/**
* Reads the `Result` envelope and stops unless it holds the expected payload.
*
* A code, a message, a tag naming what follows, then the tagged object. Another tag is
* another shape, and answering from a half-read parcel would be worse than saying nothing.
*/
private fun openPayload(parcel: Parcel, expectedTag: String): Boolean {
parcel.readInt() // code — the payload's presence is the real answer
parcel.readString() // message
if (parcel.readString() != expectedTag) return false
parcel.readString() // the parcelable's own class name
return true
}
/**
* The service answers in whatever unit it was configured with and names it, so the name is
* what decides. Anything it does not name is left alone rather than assumed Celsius: a
* Fahrenheit value passed through unconverted would read as a mild summer day in winter.
*/
private fun toCelsius(value: Double?, unit: String?): Double? {
if (value == null) return null
return when (unit?.trim()?.uppercase()) {
"C", "°C" -> value
"F", "°F" -> (value - 32.0) * 5.0 / 9.0
else -> null
}
}
/**
* One answer.
*
* [text] is the provider's own description ("Rain", "Partly cloudy"), which is what a rule
* compares against; [temperatureCelsius] is null when the answer named no unit.
*/
data class Reading(
val text: String,
val icon: String,
val temperatureCelsius: Double?,
val city: String
)
}
@@ -0,0 +1,124 @@
package com.evsuite.hardware.telemetry
import kotlin.math.max
import kotlin.math.sqrt
/**
* A deliberately small range model fitted only from the driver's recent completed averages.
*
* Each trip contributes one observation so a long motorway drive does not silently erase every
* other driving context. The newest observations win, the current trip is considered newest,
* and the fixed-size window keeps both memory and model history explicit.
*/
class AdaptiveRangeEstimator(
private val maxConsumptionSamples: Int = MAX_CONSUMPTION_SAMPLES,
private val minimumConsumptionSamples: Int = MINIMUM_CONSUMPTION_SAMPLES,
) {
/**
* Whether this vehicle has ever published battery power to this instance.
*
* The capability question is "can this firmware measure power at all", and the answer does
* not change between two polls. Asking the live reading instead made a single unreadable
* sample blank an estimate computed from stored trips and the pack's charge — neither of
* which had moved — so the figure flickered for a reason that had nothing to do with it.
*/
private var powerEverPublished = false
init {
require(minimumConsumptionSamples >= 2) { "at least two samples are needed for spread" }
require(maxConsumptionSamples >= minimumConsumptionSamples) {
"the range window must hold the minimum sample count"
}
}
/**
* Estimates remaining range without replacing [EnergySnapshot.rangeKm].
*
* Battery power is the capability gate, latched rather than sampled: a firmware that has
* never published it must not reuse another firmware's stored history to claim an adaptive
* range, but one that published it a second ago has not lost the ability since.
*/
fun estimate(
snapshot: EnergySnapshot,
currentTrip: EnergyTripSummary?,
recentTrips: List<EnergyTripSummary>,
missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT,
): Provenanced<Double> {
if (snapshot.batteryPowerKw?.isFinite() == true) powerEverPublished = true
if (!powerEverPublished) return Provenanced.unavailable(missingReason)
val samples = consumptionSamples(currentTrip, recentTrips)
if (samples.size < minimumConsumptionSamples) {
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
val usableEnergyKwh = usableEnergy(snapshot)
?: return Provenanced.unavailable(missingReason)
val meanConsumption = samples.average()
val estimatedKm = usableEnergyKwh * 100.0 / meanConsumption
if (!estimatedKm.isFinite()) return Provenanced.unavailable(missingReason)
// Sample standard deviation describes the consumption observations we actually saw.
// First-order propagation through range = energy * 100 / consumption converts that
// spread to the symmetric kilometre half-band Provenanced exposes.
val variance = samples.sumOf { sample ->
val delta = sample - meanConsumption
delta * delta
} / (samples.size - 1).toDouble()
val consumptionSpread = sqrt(variance)
val observedBandKm = estimatedKm * consumptionSpread / meanConsumption
if (!observedBandKm.isFinite()) return Provenanced.unavailable(missingReason)
// A floor, because the spread of at most eight of one driver's trips is not the model's
// error. Trips that happened to agree produce a band of exactly zero, and "266.7 ± 0.0
// km" reads as a measurement — the one thing Provenance exists to stop an estimate
// doing. The floor is what the model cannot claim to know better than.
val rangeUncertaintyKm = max(observedBandKm, estimatedKm * MIN_RELATIVE_UNCERTAINTY)
return Provenanced.estimated(estimatedKm, rangeUncertaintyKm)
}
private fun consumptionSamples(
currentTrip: EnergyTripSummary?,
recentTrips: List<EnergyTripSummary>,
): List<Double> {
val current = currentTrip?.averageConsumptionKwhPer100Km
?.takeIf(::isUsableConsumption)
val history = recentTrips.asSequence()
.sortedByDescending { it.endedAtMs }
.mapNotNull { it.averageConsumptionKwhPer100Km }
.filter(::isUsableConsumption)
return sequenceOf(current).filterNotNull()
.plus(history)
.take(maxConsumptionSamples)
.toList()
}
private fun usableEnergy(snapshot: EnergySnapshot): Double? {
snapshot.batteryEnergyKwh?.toDouble()
?.takeIf { it.isFinite() && it >= 0.0 }
?.let { return it }
val soc = snapshot.socPercent?.toDouble()
?.takeIf { it.isFinite() && it in 0.0..100.0 } ?: return null
val capacity = snapshot.batteryCapacityKwh?.toDouble()
?.takeIf { it.isFinite() && it > 0.0 } ?: return null
return capacity * soc / 100.0
}
private fun isUsableConsumption(value: Double): Boolean = value.isFinite() && value > 0.0
companion object {
/** Current trip plus the seven most recent valid stored-trip averages. */
const val MAX_CONSUMPTION_SAMPLES = 8
/** Three observations permit a centre and a non-degenerate observed spread. */
const val MINIMUM_CONSUMPTION_SAMPLES = 3
/**
* The narrowest band this model is allowed to claim, as a fraction of the estimate.
*
* Eight trips of one driver do not measure the error of extrapolating to a whole pack:
* terrain, load, weather and speed all sit outside the fit. Ten percent is the width
* below which the figure would invite more trust than the method can carry.
*/
const val MIN_RELATIVE_UNCERTAINTY = 0.10
}
}
@@ -0,0 +1,82 @@
package com.evsuite.hardware.telemetry
/**
* The state of charge on arrival, and how wrong it might be.
*
* An arrival-SOC point estimate is how drivers get stranded, so this never returns one: the
* value always carries a band, and the band always widens with distance because the rate it
* rests on is itself uncertain. Where the band grows wider than [MAX_BAND_PERCENT] the
* forecast is refused outright rather than shown wide — a number spanning half the pack tells
* a driver nothing while looking like it does.
*
* A negative arrival figure is deliberately not clamped. It means the destination is beyond
* what the charge covers, which is the single most useful thing this can say.
*/
object ArrivalSocForecast {
/**
* Widest band worth showing, in points of charge.
*
* Past this the honest answer is "not enough evidence", and saying so is more use than a
* figure the driver has to discount themselves.
*/
const val MAX_BAND_PERCENT = 15.0
/**
* @param socPercent the vehicle's current state of charge, 0..100.
* @param remainingDistanceKm distance still to cover, from the head unit's navigation.
* @param rate what a kilometre costs, from [SocRateEstimator].
*/
fun of(
socPercent: Double?,
remainingDistanceKm: Double?,
rate: SocRate?,
): Provenanced<Double> {
if (socPercent == null || !socPercent.isFinite() || socPercent !in 0.0..100.0) {
return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT)
}
if (remainingDistanceKm == null || !remainingDistanceKm.isFinite() ||
remainingDistanceKm < 0.0
) {
return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT)
}
if (rate == null) {
return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED)
}
val arrival = socPercent - rate.percentPerKm * remainingDistanceKm
// The band comes from the rate alone: the distance is the head unit's own figure and
// the current charge is a vehicle reading, so neither contributes an error bar here.
val band = rate.uncertaintyPercentPerKm * remainingDistanceKm
if (!arrival.isFinite() || !band.isFinite()) {
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
if (band > MAX_BAND_PERCENT) {
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
return Provenanced.estimated(arrival, uncertainty = band)
}
/**
* How far the charge reaches at this rate, for the same inputs.
*
* The complement of [of]: an arrival figure answers "will I get there", this one answers
* "how far does this get me", and a driver deciding whether to stop wants both.
*/
fun rangeAtRateKm(socPercent: Double?, rate: SocRate?): Provenanced<Double> {
if (socPercent == null || !socPercent.isFinite() || socPercent !in 0.0..100.0) {
return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT)
}
if (rate == null || rate.percentPerKm <= 0.0) {
return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED)
}
val km = socPercent / rate.percentPerKm
// A rate known to ±x% puts the distance between soc/(rate+x) and soc/(rate-x); the
// near edge is the half-width worth quoting, because overstating range is the danger.
val worst = socPercent / (rate.percentPerKm + rate.uncertaintyPercentPerKm)
val band = km - worst
if (!km.isFinite() || !band.isFinite() || band < 0.0) {
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
return Provenanced.estimated(km, uncertainty = band)
}
}
@@ -0,0 +1,89 @@
package com.evsuite.hardware.telemetry
/**
* The pack size, as the driver declares it, because the vehicle will not say.
*
* `INFO_EV_BATTERY_CAPACITY` is declared and never published on the MG4, and deriving the
* capacity from an energy total needs battery power, which is itself unvalidated. Without a
* capacity there is no bridge between kWh and state of charge, and every energy model stops
* at kWh/100 km. A declared figure is the only one available, so it is taken — and labelled
* for exactly what it is.
*
* **Usable, not nameplate.** State of charge spans the usable window, not the gross pack: the
* buffers at each end are outside it. The MG4 Long Range is sold as a 64 kWh car and its usable
* capacity is 61,7 kWh — EVKX's specification sheet for that model, reviewed 2026-09-04, and a
* citation rather than a measurement of any particular pack. Entering the nameplate figure
* would overstate every arrival forecast by the size of those buffers, in the optimistic
* direction. The field asks for the usable figure and nothing here converts one into the other:
* the relationship is model-specific and this layer serves more than one model.
*
* See the workspace `AGENTS.md` for how a specification sheet may be used — it corroborates a
* declared figure and never stands in for on-vehicle evidence.
*
* **Everything derived from this is [Provenance.ESTIMATED].** A dealer's state-of-health
* reading is itself an estimate, the usable figure is a specification rather than a
* measurement of this pack, and neither was taken today. [RELATIVE_UNCERTAINTY] carries that,
* so a value built on a declared capacity can never be drawn like a vehicle reading.
*/
data class BatteryCapacityConfig(
/** kWh between 0 % and 100 % state of charge on a healthy pack — the usable figure. */
val usableCapacityKwhWhenNew: Double,
/** State of health in percent, as measured by whoever measured it. */
val stateOfHealthPercent: Double,
) {
/** What 100 % state of charge is worth on this pack today. */
val effectiveUsableKwh: Double
get() = usableCapacityKwhWhenNew * stateOfHealthPercent / 100.0
/**
* The energy behind a state of charge.
*
* @param socPercent the vehicle's own reading, 0..100.
*/
fun energyAtSocKwh(socPercent: Double?): Provenanced<Double> {
if (socPercent == null || !socPercent.isFinite() || socPercent !in 0.0..100.0) {
return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT)
}
val kwh = effectiveUsableKwh * socPercent / 100.0
return Provenanced.estimated(kwh, uncertainty = kwh * RELATIVE_UNCERTAINTY)
}
/** How many state-of-charge points an amount of energy is worth on this pack. */
fun socPercentForEnergy(kwh: Double?): Provenanced<Double> {
if (kwh == null || !kwh.isFinite() || effectiveUsableKwh <= 0.0) {
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
val soc = kwh * 100.0 / effectiveUsableKwh
return Provenanced.estimated(soc, uncertainty = kotlin.math.abs(soc) * RELATIVE_UNCERTAINTY)
}
companion object {
/**
* How wrong a declared capacity may reasonably be.
*
* It covers a dealer's state-of-health figure, a usable capacity taken from a
* specification rather than from this pack, and the drift since either was taken. It
* is deliberately not smaller: a narrow band on a number nobody measured today would
* be a false precision, and arrival forecasts are where that gets someone stranded.
*/
const val RELATIVE_UNCERTAINTY = 0.05
private val CAPACITY_RANGE = 10.0..250.0
private val HEALTH_RANGE = 50.0..100.0
/**
* A configuration, or null when the figures are not usable.
*
* Out-of-range input is refused rather than clamped: a clamped capacity is a number
* the driver did not enter, silently standing in for the one they did.
*/
fun of(usableCapacityKwhWhenNew: Double?, stateOfHealthPercent: Double?):
BatteryCapacityConfig? {
if (usableCapacityKwhWhenNew == null || stateOfHealthPercent == null) return null
if (!usableCapacityKwhWhenNew.isFinite() || !stateOfHealthPercent.isFinite()) return null
if (usableCapacityKwhWhenNew !in CAPACITY_RANGE) return null
if (stateOfHealthPercent !in HEALTH_RANGE) return null
return BatteryCapacityConfig(usableCapacityKwhWhenNew, stateOfHealthPercent)
}
}
}
@@ -0,0 +1,157 @@
package com.evsuite.hardware.telemetry
/** One rise in charge: a charge session, identified the only way this car makes possible. */
data class BatteryChargeSession(
val startedAtMs: Long,
val endedAtMs: Long,
val startSocPercent: Double,
val endSocPercent: Double,
/**
* Mean and peak points of charge per hour. The rate is what separates a wall box from a
* fast charger here: the car publishes no charger type, and a DC session is an order of
* magnitude faster than an AC one on the same pack.
*/
val meanPercentPerHour: Double,
val peakPercentPerHour: Double,
/** Outside temperature, named as such — the pack's own is unpublished on this firmware. */
val meanOutsideTempCelsius: Double?,
) {
val gainedPercent: Double get() = endSocPercent - startSocPercent
val durationHours: Double get() = (endedAtMs - startedAtMs) / 3_600_000.0
}
data class BatteryExposureReport(
val spanDays: Double,
/** Hours the ledger actually covers, which is not the same as the span it stretches over. */
val observedHours: Double,
val hoursAboveHighSoc: Double,
val hoursBelowLowSoc: Double,
val meanSocPercent: Double,
val equivalentFullCycles: Double,
val kmPerEquivalentCycle: Double?,
val sessions: List<BatteryChargeSession>,
)
/**
* What this pack has been subjected to — which is not the same question as how healthy it is.
*
* Calendar ageing is driven by time spent at a high charge and by temperature, cycle ageing by
* throughput and rate. The car reports none of it and keeps no history a driver can read, while
* this app has been recording the raw material since the ledger existed.
*
* Exposure is never combined with [StateOfHealthEstimate] into a score. They are two
* measurements with two provenances, and a single number would hide which one moved.
*/
class BatteryExposure(
private val highSocPercent: Double = HIGH_SOC_PERCENT,
private val lowSocPercent: Double = LOW_SOC_PERCENT,
) {
fun analyse(entries: List<BatteryLedgerEntry>): BatteryExposureReport? {
val ordered = entries.filter { it.socPercent.isFinite() }.sortedBy { it.atMs }
if (ordered.size < 2) return null
var observedHours = 0.0
var aboveHours = 0.0
var belowHours = 0.0
var socHours = 0.0
var spentPercent = 0.0
for (index in 1 until ordered.size) {
val previous = ordered[index - 1]
val entry = ordered[index]
val hours = (entry.atMs - previous.atMs) / MS_PER_HOUR
if (hours <= 0.0) continue
val start = previous.socPercent.toDouble()
val end = entry.socPercent.toDouble()
observedHours += hours
aboveHours += hours * fractionAbove(start, end, highSocPercent)
belowHours += hours * (1.0 - fractionAbove(start, end, lowSocPercent))
socHours += hours * (start + end) / 2.0
if (start > end) spentPercent += start - end
}
if (observedHours <= 0.0) return null
val cycles = spentPercent / 100.0
val odometers = ordered.mapNotNull { it.odometerKm }
val km = if (odometers.size >= 2) (odometers.last() - odometers.first()).toDouble() else null
return BatteryExposureReport(
spanDays = (ordered.last().atMs - ordered.first().atMs) / MS_PER_DAY,
observedHours = observedHours,
hoursAboveHighSoc = aboveHours,
hoursBelowLowSoc = belowHours,
meanSocPercent = socHours / observedHours,
equivalentFullCycles = cycles,
kmPerEquivalentCycle = km?.takeIf { cycles > 0.0 && it >= 0.0 }?.div(cycles),
sessions = sessions(ordered),
)
}
/**
* The share of a segment spent above a threshold, taking the charge as a straight line
* between its two ends.
*
* Attributing the whole segment to whichever side its mean fell on would count a night that
* ended at 85 % as entirely below 80 %, and the overnight hours at a high charge are exactly
* what calendar ageing is about.
*/
private fun fractionAbove(start: Double, end: Double, threshold: Double): Double {
if (start >= threshold && end >= threshold) return 1.0
if (start <= threshold && end <= threshold) return 0.0
return ((if (start > threshold) start else end) - threshold) / kotlin.math.abs(end - start)
}
/** Consecutive rises, merged: one session, however many entries the ledger wrote during it. */
private fun sessions(ordered: List<BatteryLedgerEntry>): List<BatteryChargeSession> {
val result = ArrayList<BatteryChargeSession>()
var index = 1
while (index < ordered.size) {
if (ordered[index].socPercent <= ordered[index - 1].socPercent + RISE_EPSILON) {
index++
continue
}
val start = ordered[index - 1]
var peak = 0.0
var temps = 0.0
var tempCount = 0
var last = start
while (index < ordered.size &&
ordered[index].socPercent > last.socPercent + RISE_EPSILON
) {
val entry = ordered[index]
val hours = (entry.atMs - last.atMs) / MS_PER_HOUR
if (hours > 0.0) {
val rate = (entry.socPercent - last.socPercent) / hours
if (rate > peak) peak = rate
}
entry.outsideTempCelsius?.let { temps += it.toDouble(); tempCount++ }
last = entry
index++
}
val hours = (last.atMs - start.atMs) / MS_PER_HOUR
if (hours > 0.0) {
result.add(
BatteryChargeSession(
startedAtMs = start.atMs,
endedAtMs = last.atMs,
startSocPercent = start.socPercent.toDouble(),
endSocPercent = last.socPercent.toDouble(),
meanPercentPerHour = (last.socPercent - start.socPercent) / hours,
peakPercentPerHour = peak,
meanOutsideTempCelsius = (temps / tempCount).takeIf { tempCount > 0 },
)
)
}
}
return result
}
companion object {
const val HIGH_SOC_PERCENT = 80.0
const val LOW_SOC_PERCENT = 10.0
/** Same step the health estimator calls a charge, so the two agree on what a rise is. */
private const val RISE_EPSILON = 0.6f
private const val MS_PER_HOUR = 3_600_000.0
private const val MS_PER_DAY = 86_400_000.0
}
}
@@ -0,0 +1,235 @@
package com.evsuite.hardware.telemetry
import com.google.gson.Gson
import java.io.File
import java.io.FileOutputStream
/**
* One moment in the pack's life, including the ones a trip never sees.
*
* The trip store answers "what did that drive cost". Battery health asks a different question —
* what did the charge do between two charges — and the answer spans the parking, the overnight
* standing and the drives in between. None of that is in `trips.json`: a trip begins when the
* car moves and a pack loses charge whether or not it does.
*
* Every field except the charge itself is nullable and stays that way. An entry with no charge
* reading says nothing at all and is never written; everything else is recorded when the car
* offers it and left absent when it does not.
*/
data class BatteryLedgerEntry(
val atMs: Long,
val socPercent: Float,
val odometerKm: Float? = null,
val outsideTempCelsius: Float? = null,
/**
* The vehicle's own energy counters, as they read at this moment — `SaicCharging`
* transactions 75 and 77, reset by the car at the end of a charge. Measured, nothing
* integrated here. A drop between two entries is that reset, never regeneration.
*/
val vehicleConsumedKwh: Float? = null,
val vehicleRegeneratedKwh: Float? = null,
/**
* This app's own running integral of pack power, cumulative since the ledger began.
*
* Cumulative rather than per-entry so a window is one subtraction, and so a missed sample
* costs the window that contains it instead of every window after it.
*/
val packEnergyKwh: Double? = null,
/** Distance this app actually integrated, cumulative, beside [odometerKm] which is the car's. */
val integratedDistanceKm: Double? = null,
val chargingStatus: Int? = null,
val chargePortConnected: Boolean? = null,
val parked: Boolean? = null,
)
/** The ledger file's envelope, stating its own version exactly as the trip history does. */
data class BatteryLedgerFile(
val schemaVersion: Int = SCHEMA_VERSION,
val entries: List<BatteryLedgerEntry>,
) {
companion object {
const val SCHEMA_VERSION = 1
}
}
/**
* Bounded app-private ledger, replaced atomically after every successful write.
*
* Same shape as [EnergyTripHistoryStore] and for the same reasons: a file this build cannot
* read is quarantined rather than deleted, and the bound is a count rather than a byte budget
* because an entry here is a fixed handful of numbers.
*/
class BatteryLedgerStore(
private val target: File,
private val maxEntries: Int = MAX_ENTRIES,
private val gson: Gson = Gson(),
) {
/** Oldest first, which is the order every reader of this file walks it in. */
fun read(): List<BatteryLedgerEntry> {
if (!target.exists()) return emptyList()
val text = runCatching { target.readText() }.getOrNull() ?: return emptyList()
return parse(text) ?: run {
quarantine()
emptyList()
}
}
fun append(entry: BatteryLedgerEntry): Boolean = write(bound(read() + entry))
fun clear(): Boolean = write(emptyList())
@Suppress("SENSELESS_COMPARISON")
private fun parse(text: String): List<BatteryLedgerEntry>? {
val envelope = runCatching { gson.fromJson(text, LedgerEnvelope::class.java) }
.getOrNull() ?: return null
if (envelope.schemaVersion != BatteryLedgerFile.SCHEMA_VERSION) return null
// Gson writes straight through Kotlin's nullability: a truncated file can hold an entry
// with no charge reading at all, and the comparison against null is not senseless here.
return envelope.entries.orEmpty()
.filter { it != null && it.socPercent != null && it.socPercent.isFinite() }
.sortedBy { it.atMs }
}
private data class LedgerEnvelope(
val schemaVersion: Int = 0,
val entries: List<BatteryLedgerEntry>? = null,
)
/** Newest entries win: a health estimate is about the pack as it is now. */
private fun bound(entries: List<BatteryLedgerEntry>): List<BatteryLedgerEntry> =
if (entries.size <= maxEntries) entries
else entries.subList(entries.size - maxEntries.coerceAtLeast(1), entries.size)
private fun quarantine() {
val kept = File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}")
if (target.renameTo(kept)) return
if (runCatching { target.copyTo(kept, overwrite = true) }.isSuccess) target.delete()
else kept.delete()
}
private fun write(entries: List<BatteryLedgerEntry>): Boolean {
val bytes = gson.toJson(BatteryLedgerFile(entries = entries)).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
}
}
companion object {
/** About a year of ordinary use at one entry per point of charge moved. */
const val MAX_ENTRIES = 2_000
const val FILE_NAME = "battery-ledger.json"
}
}
/**
* Turns the 1 Hz sample stream into the handful of entries a health estimate needs.
*
* Two jobs, and they are one class because the second depends on the first. It keeps the
* running integrals — pack energy and distance — that no single sample carries, and it decides
* when the ledger has learnt something worth a disk write. Writing every sample would be 3 600
* rewrites an hour of a file that would say the same thing as one.
*
* An entry is earned by a change: the charge moved by a point, the charging state changed, or
* enough quiet time passed that the next reader cannot tell a parked car from a stopped app.
*
* **The integrals are cumulative and survive a restart by being re-seeded from the last entry,
* not by being recomputed.** Whatever happened while this process was not running is lost, and
* that is exactly what [BatteryLedgerEntry.integratedDistanceKm] against the car's own odometer
* lets a reader detect — a window whose integral covers less ground than the odometer moved is
* refused rather than believed.
*/
class BatteryLedgerRecorder(
private val store: BatteryLedgerStore,
private val socStepPercent: Float = SOC_STEP_PERCENT,
private val quietIntervalMs: Long = QUIET_INTERVAL_MS,
private val maxSampleGapMs: Long = MAX_SAMPLE_GAP_MS,
) {
private var last: EnergySnapshot? = null
private var lastEntry: BatteryLedgerEntry? = null
private var packEnergyKwh: Double = 0.0
private var integratedDistanceKm: Double = 0.0
private var hasPowerInterval = false
private var hasSpeedInterval = false
private var seeded = false
/** @return true when this sample earned an entry and it was written. */
fun observe(snapshot: EnergySnapshot): Boolean {
seed()
integrate(snapshot)
val soc = snapshot.socPercent?.takeIf { it.isFinite() } ?: return false
val previous = lastEntry
val earned = previous == null ||
kotlin.math.abs(soc - previous.socPercent) >= socStepPercent ||
snapshot.chargingStatus != previous.chargingStatus ||
snapshot.timestampMs - previous.atMs >= quietIntervalMs
if (!earned) return false
val entry = BatteryLedgerEntry(
atMs = snapshot.timestampMs,
socPercent = soc,
odometerKm = snapshot.odometerKm,
outsideTempCelsius = snapshot.outsideTempCelsius,
vehicleConsumedKwh = snapshot.vehicleConsumedKwh,
vehicleRegeneratedKwh = snapshot.vehicleRegeneratedKwh,
packEnergyKwh = packEnergyKwh.takeIf { hasPowerInterval },
integratedDistanceKm = integratedDistanceKm.takeIf { hasSpeedInterval },
chargingStatus = snapshot.chargingStatus,
chargePortConnected = snapshot.chargePortConnected,
parked = snapshot.parked,
)
if (!store.append(entry)) return false
lastEntry = entry
return true
}
private fun seed() {
if (seeded) return
seeded = true
val previous = store.read().lastOrNull() ?: return
lastEntry = previous
packEnergyKwh = previous.packEnergyKwh ?: 0.0
integratedDistanceKm = previous.integratedDistanceKm ?: 0.0
hasPowerInterval = previous.packEnergyKwh != null
hasSpeedInterval = previous.integratedDistanceKm != null
}
/** Trapezoid over adjacent samples only, with the same five-second bound trips use. */
private fun integrate(snapshot: EnergySnapshot) {
val previous = last
last = snapshot
if (previous == null) return
val gapMs = snapshot.timestampMs - previous.timestampMs
if (gapMs <= 0L || gapMs > maxSampleGapMs) return
val hours = gapMs / 3_600_000.0
val previousPower = previous.batteryPowerKw
val power = snapshot.batteryPowerKw
if (previousPower != null && power != null) {
hasPowerInterval = true
packEnergyKwh += (previousPower + power) / 2.0 * hours
}
val previousSpeed = previous.speedKmh
val speed = snapshot.speedKmh
if (previousSpeed != null && speed != null) {
hasSpeedInterval = true
integratedDistanceKm += (previousSpeed + speed) / 2.0 * hours
}
}
companion object {
/** One point of charge is the smallest step this car's gauge actually takes. */
const val SOC_STEP_PERCENT = 1.0f
const val QUIET_INTERVAL_MS = 15 * 60 * 1000L
const val MAX_SAMPLE_GAP_MS = 5_000L
}
}
@@ -0,0 +1,152 @@
package com.evsuite.hardware.telemetry
/** What the ledger says about the charge gauge's own reference. */
enum class CalibrationVerdict {
/** The gauge has seen an end of the pack recently; nothing to suggest. */
SETTLED,
/** Drifting towards having nothing to anchor on, with no evidence that it has. */
WATCH,
/** Either the anchor is long gone, or a step says the reference already moved. */
RE_ANCHOR_SUGGESTED,
}
/**
* A charge step no energy explains.
*
* The clean case is a parked car: it stood still, drove nowhere, and woke up several points
* lower. Nothing consumed that charge — the BMS re-estimated it, and the size of the step is
* the error that had accumulated.
*/
data class UnexplainedSocStep(
val atMs: Long,
val dropPercent: Double,
val elapsedHours: Double,
val parked: Boolean,
)
data class CalibrationDriftReport(
val verdict: CalibrationVerdict,
val lastFullChargeAtMs: Long?,
val lastLowChargeAtMs: Long?,
val daysSinceFullCharge: Double?,
val equivalentCyclesSinceFullCharge: Double,
val steps: List<UnexplainedSocStep>,
/** Width of the health estimate's band in points, when there is an estimate. */
val estimateSpreadPercentPoints: Double?,
val ledgerSpanDays: Double,
)
/**
* When the charge gauge last had anything to correct itself against.
*
* Every figure this project produces is anchored on state of charge, and that estimate is
* counted between rare anchor points — a full charge, a deep discharge, a long rest. A car
* charged to 80 % and never run below 30 % visits none of them, and the error accumulates
* silently until the gauge jumps.
*
* **This notices; it never acts.** No interface on this head unit recalibrates a BMS, and
* re-anchoring is something the pack does for itself during a full charge. Nor are the app's
* own forecasts corrected against an observed step: a corrected figure would be this app's
* opinion of the charge, drawn beside the car's own gauge saying something else, with no way
* for a driver to tell which is which. The gauge is the car's. This says it moved.
*/
class CalibrationDrift(
private val fullSocPercent: Double = FULL_SOC_PERCENT,
private val lowSocPercent: Double = LOW_SOC_PERCENT,
private val settledDays: Double = SETTLED_DAYS,
private val reAnchorDays: Double = RE_ANCHOR_DAYS,
private val stepPercent: Double = STEP_PERCENT,
private val selfDischargePercentPerDay: Double = SELF_DISCHARGE_PERCENT_PER_DAY,
) {
fun analyse(
entries: List<BatteryLedgerEntry>,
nowMs: Long,
estimate: StateOfHealthEstimate? = null,
): CalibrationDriftReport? {
val ordered = entries.filter { it.socPercent.isFinite() }.sortedBy { it.atMs }
if (ordered.size < 2) return null
val lastFull = ordered.lastOrNull { it.socPercent >= fullSocPercent }
val lastLow = ordered.lastOrNull { it.socPercent <= lowSocPercent }
val daysSinceFull = lastFull?.let { (nowMs - it.atMs) / MS_PER_DAY }
val cyclesSinceFull = cycles(ordered.filter { lastFull == null || it.atMs >= lastFull.atMs })
val steps = steps(ordered)
val spread = estimate?.stateOfHealthPercent?.uncertainty?.times(2.0)
val verdict = when {
steps.isNotEmpty() -> CalibrationVerdict.RE_ANCHOR_SUGGESTED
daysSinceFull == null || daysSinceFull > reAnchorDays ->
CalibrationVerdict.RE_ANCHOR_SUGGESTED
daysSinceFull <= settledDays -> CalibrationVerdict.SETTLED
else -> CalibrationVerdict.WATCH
}
return CalibrationDriftReport(
verdict = verdict,
lastFullChargeAtMs = lastFull?.atMs,
lastLowChargeAtMs = lastLow?.atMs,
daysSinceFullCharge = daysSinceFull,
equivalentCyclesSinceFullCharge = cyclesSinceFull,
steps = steps,
estimateSpreadPercentPoints = spread,
ledgerSpanDays = (ordered.last().atMs - ordered.first().atMs) / MS_PER_DAY,
)
}
/**
* Steps between two entries where the car went nowhere.
*
* A parked pack does lose charge — slowly, and the allowance below says how slowly. What is
* refused is the reading that a car standing still for an hour spent four points on nothing.
* The odometer has to be readable at both ends and identical: without it, a drive the app
* did not sample is indistinguishable from a step, and calling that a calibration problem
* would be an invented fact rather than an observed one.
*/
private fun steps(ordered: List<BatteryLedgerEntry>): List<UnexplainedSocStep> {
val result = ArrayList<UnexplainedSocStep>()
for (index in 1 until ordered.size) {
val previous = ordered[index - 1]
val entry = ordered[index]
val startOdometer = previous.odometerKm ?: continue
val endOdometer = entry.odometerKm ?: continue
if (endOdometer != startOdometer) continue
if (previous.parked == false || entry.parked == false) continue
val drop = (previous.socPercent - entry.socPercent).toDouble()
if (drop < stepPercent) continue
val hours = (entry.atMs - previous.atMs) / MS_PER_HOUR
if (hours <= 0.0) continue
val allowance = selfDischargePercentPerDay * hours / 24.0
if (drop <= allowance) continue
result.add(UnexplainedSocStep(entry.atMs, drop, hours, parked = true))
}
return result
}
/** Equivalent full cycles: every point of charge spent, over a hundred. */
private fun cycles(ordered: List<BatteryLedgerEntry>): Double {
var spent = 0.0
for (index in 1 until ordered.size) {
val drop = ordered[index - 1].socPercent - ordered[index].socPercent
if (drop > 0f) spent += drop.toDouble()
}
return spent / 100.0
}
companion object {
/** What counts as a full charge for anchoring: the gauge rarely reports a clean 100. */
const val FULL_SOC_PERCENT = 97.0
const val LOW_SOC_PERCENT = 15.0
const val SETTLED_DAYS = 30.0
const val RE_ANCHOR_DAYS = 90.0
/** Smaller than this and a step is quantisation plus a cold night, not a re-estimate. */
const val STEP_PERCENT = 3.0
/** A parked MG4 is allowed to lose this much a day before a drop needs explaining. */
const val SELF_DISCHARGE_PERCENT_PER_DAY = 1.0
private const val MS_PER_DAY = 86_400_000.0
private const val MS_PER_HOUR = 3_600_000.0
}
}
@@ -0,0 +1,287 @@
package com.evsuite.hardware.telemetry
import kotlin.math.abs
/**
* Whether a route needs a charging stop, and in how many kilometres.
*
* This is the sentence the whole project was described by — *"il faudra recharger une fois dans
* N km"* — and it is arithmetic, not a planner. [chain] walks a route long enough to need the
* sentence twice, and that is all it does: there is deliberately no optimisation here, no
* charging curve, no cost function and no search over which chargers to use. ABRP does that and
* does it better; what the car cannot tell its driver is the one thing this answers.
*
* It plans to a **reserve**, never to zero. A forecast that says the car arrives at 3 % is not
* saying the trip works, it is saying the trip works if nothing goes wrong, and something goes
* wrong. The reserve is a stated number the driver can change rather than an optimism built into
* the arithmetic where nobody can see it.
*
* Uncertainty is the reason this refuses. [SocRate] carries a band, the band grows with
* distance, and past some distance "you will arrive between 5 % and 45 %" is not a plan. The
* refusal is visible; a wide answer rendered as a plan is not.
*/
object ChargeStopPlan {
/** What a careful driver keeps in the pack. Not a physical limit — a stated choice. */
const val DEFAULT_RESERVE_PERCENT = 10.0
/**
* Wider than this and the plan is refused. Looser than [ArrivalSocForecast.MAX_BAND_PERCENT]
* because a stop decision survives more uncertainty than a printed arrival figure: "you will
* need to charge" holds even when "you will arrive at 12 %" does not.
*/
const val MAX_BAND_PERCENT = 25.0
/**
* What a stop is planned to leave with.
*
* Not a full pack: the last fifth of a rapid charge takes as long as the first three, and a
* plan that assumed it would be waited for is a plan about a driver nobody knows. The driver
* declares it (CP-054) and this is only the figure a plan starts from.
*/
const val DEFAULT_DEPARTURE_PERCENT = 80.0
/**
* How many driving legs a chain may hold.
*
* A trip needing a fourth stop is a day's drive, and this screen is not the place it should
* be planned. The cap is also what guarantees the walk terminates whatever the arithmetic
* does.
*/
const val MAX_LEGS = 4
/** Under this a leg is not a drive, it is the same leg again. */
const val MIN_LEG_KM = 1.0
sealed interface Plan {
/** The route completes with the reserve intact. */
data class NoStop(
val arrivalPercent: Double,
val marginPercent: Double,
val bandPercent: Double,
) : Plan
/** A stop is needed, this far along the route. */
data class Stop(
val afterKm: Double,
val bandKm: Double,
val shortfallKm: Double,
val bandPercent: Double,
) : Plan
/** Nothing worth acting on, and the reason. */
data class Refused(val reason: Reason) : Plan
}
/**
* [NO_PROGRESS] and [TOO_MANY_LEGS] are [chain]'s alone: one leg either goes somewhere or the
* chain is not a plan, and a trip past [MAX_LEGS] is one this screen should not answer.
*/
enum class Reason { NO_CHARGE, NO_ROUTE, NO_RATE, BAND_TOO_WIDE, NO_PROGRESS, TOO_MANY_LEGS }
/**
* One leg of a chained plan: the drive from [startKm] to [endKm] on [startPercent] of charge.
*
* Distances are measured from the origin, not from the start of the leg, because that is
* where everything else on the screen measures from — the charger search, the route geometry
* and the waypoints handed to the car.
*
* A leg whose [plan] is [Plan.Refused] ends where it started: it is the point the trip stops
* being planned, kept in the list so the legs before it still read.
*/
data class Leg(
val startKm: Double,
val endKm: Double,
val startPercent: Double,
val plan: Plan,
) {
/** True when the leg ends at a charging stop rather than at the destination. */
val isStop: Boolean get() = plan is Plan.Stop
}
/** Every leg of one trip, in the order they are driven. */
data class Chain(val legs: List<Leg>) {
/** The legs that end at a charging stop — as many as the trip needs, and never more. */
val stops: List<Leg> get() = legs.filter { it.isStop }
/** The arrival at the destination, or null when the chain never got that far. */
val arrival: Plan.NoStop? get() = legs.lastOrNull()?.plan as? Plan.NoStop
/** Why the chain stopped short, or null when it reaches the destination. */
val refusal: Reason? get() = (legs.lastOrNull()?.plan as? Plan.Refused)?.reason
}
/**
* @param socPercent charge now.
* @param routeKm the whole remaining route.
* @param rate charge spent per kilometre, with its own uncertainty.
* @param reservePercent the charge the driver refuses to go below.
* @param grade what the route's climb and descent cost, from [RouteGrade], or null when no
* elevation profile came back. Null is not zero: a plan with no profile is exactly the
* plan this made before profiles existed.
*/
fun of(
socPercent: Double?,
routeKm: Double?,
rate: SocRate?,
reservePercent: Double = DEFAULT_RESERVE_PERCENT,
grade: RouteGrade.Cost? = null,
): Plan {
if (socPercent == null || !socPercent.isFinite()) return Plan.Refused(Reason.NO_CHARGE)
if (routeKm == null || !routeKm.isFinite() || routeKm <= 0.0) {
return Plan.Refused(Reason.NO_ROUTE)
}
if (rate == null || rate.percentPerKm <= 0.0) return Plan.Refused(Reason.NO_RATE)
return leg(
socPercent,
routeKm,
effectiveRate(rate, routeKm, grade),
reservePercent,
bandOverLeg = false,
)
}
/**
* The same trip planned as far as it goes, charging on the way.
*
* [of] answers one question — does this charge reach the far end — and a route long enough to
* need two stops makes it answer it about a car that never stops. Over 680 km the band on
* that non-existent drive is tens of percent wide and the plan is refused, which is honest
* about the wrong trip: whether a stop is needed near Brive is not uncertain at all.
*
* So the route is walked leg by leg. Each leg is planned on the charge held at its start, and
* **each leg carries the band of its own distance** — that, not a looser cap, is what makes a
* long trip plannable. [MAX_BAND_PERCENT] is unchanged; it now measures the drive it was
* always about.
*
* What this is still not: an optimiser. There is no charging curve, no cost function and no
* search over which chargers to use. The charge a stop is left with is [departurePercent], a
* figure the driver declares, because how long they plug in is theirs and modelling it would
* be inventing a number this app cannot measure.
*
* @param departurePercent the charge each stop is planned to leave with.
* @param maxLegs how many driving legs may be planned before the trip is refused as one this
* screen should not be answering. The refusal itself is appended as a further leg.
*/
fun chain(
socPercent: Double?,
routeKm: Double?,
rate: SocRate?,
reservePercent: Double = DEFAULT_RESERVE_PERCENT,
grade: RouteGrade.Cost? = null,
departurePercent: Double = DEFAULT_DEPARTURE_PERCENT,
maxLegs: Int = MAX_LEGS,
): Chain {
// The refusals that are about the trip rather than about a leg of it. One leg holding
// them keeps the shape of the answer the same whatever went wrong.
if (socPercent == null || !socPercent.isFinite()) return refusal(Reason.NO_CHARGE)
if (routeKm == null || !routeKm.isFinite() || routeKm <= 0.0) {
return refusal(Reason.NO_ROUTE)
}
if (rate == null || rate.percentPerKm <= 0.0) return refusal(Reason.NO_RATE)
// Folded once, over the whole route, exactly as [of] folds it. A leg that spread a
// whole-route total of climb over its own length would charge the col to every leg.
val totalKm: Double = routeKm
val effective = effectiveRate(rate, totalKm, grade)
val leaveWith = departurePercent.coerceIn(0.0, 100.0)
val legs = ArrayList<Leg>()
var startKm = 0.0
var socAtStart: Double = socPercent
while (legs.size < maxLegs) {
val step = leg(socAtStart, totalKm - startKm, effective, reservePercent, true)
if (step !is Plan.Stop) {
// NoStop ends at the destination; a refusal ends where the driver would be
// standing when the plan stopped being one.
val endKm = if (step is Plan.NoStop) totalKm else startKm
legs += Leg(startKm, endKm, socAtStart, step)
return Chain(legs)
}
// A leg that gets nowhere never ends: the charge it leaves with buys no distance, and
// another turn of this loop would plan the same leg again. Said once, as a refusal.
if (step.afterKm < MIN_LEG_KM) {
legs += Leg(startKm, startKm, socAtStart, Plan.Refused(Reason.NO_PROGRESS))
return Chain(legs)
}
legs += Leg(startKm, startKm + step.afterKm, socAtStart, step)
startKm += step.afterKm
socAtStart = leaveWith
}
legs += Leg(startKm, startKm, socAtStart, Plan.Refused(Reason.TOO_MANY_LEGS))
return Chain(legs)
}
/**
* One leg's plan.
*
* @param bandOverLeg where the band is measured. [of] measures it over the whole route,
* because a single-stop plan is a claim about arriving at the far end of it. [chain]
* measures it over the distance this leg actually drives, which is the stop it is about.
*/
private fun leg(
socPercent: Double,
routeKm: Double,
effective: SocRate,
reservePercent: Double,
bandOverLeg: Boolean,
): Plan {
val spendable = socPercent - reservePercent
val needed = effective.percentPerKm * routeKm
if (spendable >= needed) {
val band = 2.0 * effective.uncertaintyPercentPerKm * routeKm
if (band > MAX_BAND_PERCENT) return Plan.Refused(Reason.BAND_TOO_WIDE)
return Plan.NoStop(
arrivalPercent = socPercent - needed,
marginPercent = spendable - needed,
bandPercent = band,
)
}
// Pessimistic on purpose: the distance the car reaches if it spends at the top of the
// band. Being told to charge earlier than strictly necessary is a cost of minutes;
// being told to charge later is a cost of a tow truck.
val worstRate = effective.percentPerKm + effective.uncertaintyPercentPerKm
val reachKm = (spendable / worstRate).coerceAtLeast(0.0)
val band =
2.0 * effective.uncertaintyPercentPerKm * (if (bandOverLeg) reachKm else routeKm)
if (band > MAX_BAND_PERCENT) return Plan.Refused(Reason.BAND_TOO_WIDE)
val bestRate =
(effective.percentPerKm - effective.uncertaintyPercentPerKm).coerceAtLeast(1e-6)
val optimisticKm = spendable / bestRate
return Plan.Stop(
afterKm = reachKm,
bandKm = (optimisticKm - reachKm).coerceAtLeast(0.0),
shortfallKm = routeKm - reachKm,
bandPercent = band,
)
}
private fun refusal(reason: Reason) =
Chain(listOf(Leg(0.0, 0.0, 0.0, Plan.Refused(reason))))
/**
* The rate with the climb folded into it, so the rest of the arithmetic never learns that
* grade exists.
*
* The profile is a total and not a position: the col may be at kilometre 10 or at 400 and
* this spreads it evenly over the route either way, which makes a stop before a late col
* slightly early and one after an early col slightly late. Early is the safe direction and
* the error is small next to the rate's own band; the upgrade, when a route needs it, is a
* per-segment profile rather than two cumulative numbers.
*
* Public because [PlanDrift] has to compare a drive against the rate the plan was actually
* made with, and a second copy of this fold would drift away from the one that planned.
*/
fun effectiveRate(rate: SocRate, routeKm: Double, grade: RouteGrade.Cost?): SocRate {
if (grade == null) return rate
return rate.copy(
// Floored, not clamped away: a descent long enough to make the net rate negative
// would otherwise plan a car that gains charge for ever.
percentPerKm = (rate.percentPerKm + grade.percent / routeKm).coerceAtLeast(1e-6),
uncertaintyPercentPerKm = rate.uncertaintyPercentPerKm +
abs(grade.uncertaintyPercent) / routeKm,
)
}
}
@@ -0,0 +1,62 @@
package com.evsuite.hardware.telemetry
/**
* Integrates charge power into energy received during the current charging session.
*
* The meter is intentionally stateful and not thread-safe: one sampling owner must call
* [sample] in timestamp order. Missing samples keep an open session alive without inventing
* energy, and long gaps are skipped.
*/
class ChargingSessionMeter @JvmOverloads constructor(
private val maxSampleGapMs: Long = MAX_SAMPLE_GAP_MS
) {
private var sessionOpen = false
private var lastSampleMs = 0L
private var lastRateKw: Float? = null
private var chargedKwh = 0.0
/**
* @param charging true while energy is flowing, false when the session ended, null when
* the state is temporarily unreadable
* @param chargeRateKw positive power entering the pack; negative noise is clamped to zero
* @return session energy in kWh, or null when no charging session is open
*/
fun sample(nowMs: Long, charging: Boolean?, chargeRateKw: Float?): Double? {
if (charging == false) {
sessionOpen = false
lastRateKw = null
return null
}
if (charging == null) {
if (!sessionOpen) return null
lastRateKw = null
lastSampleMs = nowMs
return chargedKwh.rounded()
}
val rateKw = chargeRateKw?.coerceAtLeast(0f)
if (!sessionOpen) {
sessionOpen = true
chargedKwh = 0.0
lastSampleMs = nowMs
lastRateKw = rateKw
return 0.0
}
val gapMs = nowMs - lastSampleMs
val previousRate = lastRateKw
if (rateKw != null && previousRate != null && gapMs in 1..maxSampleGapMs) {
chargedKwh += (previousRate + rateKw) / 2.0 * (gapMs / MS_PER_HOUR)
}
lastSampleMs = nowMs
lastRateKw = rateKw
return chargedKwh.rounded()
}
private fun Double.rounded(): Double = kotlin.math.round(this * 100.0) / 100.0
companion object {
const val MAX_SAMPLE_GAP_MS = 1_200_000L
private const val MS_PER_HOUR = 3_600_000.0
}
}
@@ -0,0 +1,182 @@
package com.evsuite.hardware.telemetry
import kotlin.math.exp
/** Instantaneous and short-window consumption derived only from measured power and speed. */
class ConsumptionCalculator(
private val rollingWindowMs: Long = ROLLING_WINDOW_MS,
private val maxWindowSamples: Int = MAX_WINDOW_SAMPLES,
) {
data class Reading(
/** Unfiltered arithmetic, exposed for diagnostics; trip integration never consumes it. */
val rawInstantaneous: Provenanced<Double>,
/** Exponential smoothing used only for the glanceable dashboard number. */
val smoothedInstantaneous: Provenanced<Double>,
/** Consumed energy divided by distance over the bounded recent window. */
val rollingAverage: Provenanced<Double>,
)
private data class Sample(
val atMs: Long,
val speedKmh: Double?,
val batteryPowerKw: Double?,
)
private val window = ArrayDeque<Sample>()
private var smoothedValue: Double? = null
private var smoothedAtMs: Long? = null
init {
require(rollingWindowMs > 0L) { "rolling window must be positive" }
require(maxWindowSamples >= 2) { "rolling window needs at least two samples" }
}
fun add(
snapshot: EnergySnapshot,
missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT,
): Reading = add(
timestampMs = snapshot.timestampMs,
batteryPowerKw = snapshot.batteryPowerKw,
speedKmh = snapshot.speedKmh,
missingReason = missingReason,
)
fun add(
timestampMs: Long,
batteryPowerKw: Float?,
speedKmh: Float?,
missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT,
): Reading {
if (window.lastOrNull()?.atMs?.let { timestampMs <= it } == true) reset()
val raw = instantaneous(batteryPowerKw, speedKmh, missingReason)
val smoothed = smooth(timestampMs, raw)
window.addLast(
Sample(
atMs = timestampMs,
speedKmh = speedKmh?.toDouble()?.takeIf { it.isFinite() && it >= 0.0 },
batteryPowerKw = batteryPowerKw?.toDouble()?.takeIf(Double::isFinite),
)
)
trim(timestampMs)
return Reading(raw, smoothed, rollingAverage(timestampMs))
}
fun reset() {
window.clear()
smoothedValue = null
smoothedAtMs = null
}
private fun smooth(timestampMs: Long, raw: Provenanced<Double>): Provenanced<Double> {
val current = raw.value ?: run {
smoothedValue = null
smoothedAtMs = null
return Provenanced.unavailable(checkNotNull(raw.reason))
}
val previous = smoothedValue
val previousAt = smoothedAtMs
val deltaMs = previousAt?.let { timestampMs - it }
val signChanged = previous != null && previous * current < 0.0
val value = if (
previous == null || deltaMs == null || deltaMs <= 0L ||
deltaMs > MAX_SAMPLE_GAP_MS || signChanged
) {
current
} else {
val alpha = 1.0 - exp(-deltaMs.toDouble() / SMOOTHING_TIME_CONSTANT_MS)
previous + alpha * (current - previous)
}
smoothedValue = value
smoothedAtMs = timestampMs
return Provenanced.derived(value)
}
private fun trim(nowMs: Long) {
val cutoff = nowMs - rollingWindowMs
// Keep one predecessor so an interval crossing the window edge can be clipped.
while (window.size > 1 && window.elementAt(1).atMs <= cutoff) window.removeFirst()
while (window.size > maxWindowSamples) window.removeFirst()
}
private fun rollingAverage(nowMs: Long): Provenanced<Double> {
if (window.size < 2) return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
val cutoff = nowMs - rollingWindowMs
var distanceKm = 0.0
var consumedKwh = 0.0
var hasSpeedInterval = false
var hasPowerInterval = false
val samples = window.toList()
for (index in 1 until samples.size) {
val first = samples[index - 1]
val second = samples[index]
val fullGapMs = second.atMs - first.atMs
if (fullGapMs <= 0L || fullGapMs > MAX_SAMPLE_GAP_MS) continue
val fromMs = maxOf(first.atMs, cutoff)
val gapMs = second.atMs - fromMs
if (gapMs <= 0L) continue
val fraction = (fromMs - first.atMs).toDouble() / fullGapMs.toDouble()
val hours = gapMs / 3_600_000.0
if (first.speedKmh != null && second.speedKmh != null) {
val speedAtEdge = interpolate(first.speedKmh, second.speedKmh, fraction)
distanceKm += ((speedAtEdge + second.speedKmh) / 2.0) * hours
hasSpeedInterval = true
}
if (first.batteryPowerKw != null && second.batteryPowerKw != null) {
val powerAtEdge = interpolate(
first.batteryPowerKw,
second.batteryPowerKw,
fraction,
)
val intervalKwh = ((powerAtEdge + second.batteryPowerKw) / 2.0) * hours
if (intervalKwh >= 0.0) consumedKwh += intervalKwh
hasPowerInterval = true
}
}
val average = if (
hasSpeedInterval && hasPowerInterval && distanceKm >= MIN_AVERAGE_DISTANCE_KM
) {
consumedKwh * 100.0 / distanceKm
} else null
return Provenanced.derived(average)
}
private fun interpolate(first: Double, second: Double, fraction: Double): Double =
first + (second - first) * fraction
companion object {
/** Below 5 km/h, power / speed diverges and stationary accessory use has no distance. */
const val LOW_SPEED_FLOOR_KMH = 5.0
/** Five-second EMA time constant: stable at a glance without hiding sustained changes. */
const val SMOOTHING_TIME_CONSTANT_MS = 5_000.0
/** Two recent minutes describe current driving without growing with trip duration. */
const val ROLLING_WINDOW_MS = 120_000L
/** Extra hard bound if a caller samples faster than the dashboard's expected 1 Hz. */
const val MAX_WINDOW_SAMPLES = 256
/** Matches the trip accumulator: longer gaps represent motion the app did not observe. */
const val MAX_SAMPLE_GAP_MS = 5_000L
/** Avoids presenting a ratio dominated by rounding after only a few metres. */
const val MIN_AVERAGE_DISTANCE_KM = 0.1
fun instantaneous(
batteryPowerKw: Float?,
speedKmh: Float?,
missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT,
): Provenanced<Double> {
val power = batteryPowerKw?.toDouble()?.takeIf(Double::isFinite)
?: return Provenanced.unavailable(missingReason)
val speed = speedKmh?.toDouble()?.takeIf(Double::isFinite)
?: return Provenanced.unavailable(missingReason)
if (speed < LOW_SPEED_FLOOR_KMH) {
return Provenanced.unavailable(UnavailableReason.SPEED_TOO_LOW)
}
return Provenanced.derived(power * 100.0 / speed)
}
}
}
@@ -0,0 +1,70 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
/** One immutable, nullable, read-only view of the vehicle's energy state. */
data class EnergySnapshot(
val timestampMs: Long,
val firmware: FirmwareInfo.Gen,
val socPercent: Float?,
val rangeKm: Float?,
val speedKmh: Float?,
/** Positive means energy leaves the battery; negative means charging or regeneration. */
val batteryPowerKw: Float?,
val outsideTempCelsius: Float?,
val cabinTempCelsius: Float?,
val batteryTempCelsius: Float?,
val batteryEnergyKwh: Float?,
val batteryCapacityKwh: Float?,
val odometerKm: Float?,
val chargePortConnected: Boolean?,
val chargingStatus: Int?,
/**
* The vehicle's own kWh counters since the end of the last charge, when it keeps any.
*
* Defaulted rather than required: they arrived with the battery ledger, every existing
* caller builds this snapshot positionally, and a counter this car may not keep must not
* become a parameter every one of them has to pass as null.
*/
val vehicleConsumedKwh: Float? = null,
val vehicleRegeneratedKwh: Float? = null,
val parked: Boolean?,
val climate: ClimateSnapshot,
val tirePressures: TirePressureSnapshot,
) {
val hasVehicleData: Boolean
get() = socPercent != null || rangeKm != null || speedKmh != null ||
batteryPowerKw != null || outsideTempCelsius != null || cabinTempCelsius != null ||
batteryTempCelsius != null || batteryEnergyKwh != null || batteryCapacityKwh != null ||
odometerKm != null || chargePortConnected != null || chargingStatus != null ||
vehicleConsumedKwh != null || vehicleRegeneratedKwh != null ||
parked != null || climate.hasData || tirePressures.hasData
}
data class TirePressureSnapshot(
val frontLeftKpa: Float?,
val frontRightKpa: Float?,
val rearLeftKpa: Float?,
val rearRightKpa: Float?,
) {
val hasData: Boolean
get() = frontLeftKpa != null || frontRightKpa != null ||
rearLeftKpa != null || rearRightKpa != null
}
data class ClimateSnapshot(
val powerOn: Boolean?,
val acOn: Boolean?,
val autoOn: Boolean?,
val econOn: Boolean?,
val recirculationOn: Boolean?,
val fanLevel: Int?,
val fanLevelMax: Int?,
val driverTargetCelsius: Float?,
val passengerTargetCelsius: Float?,
) {
val hasData: Boolean
get() = powerOn != null || acOn != null || autoOn != null || econOn != null ||
recirculationOn != null || fanLevel != null || fanLevelMax != null ||
driverTargetCelsius != null || passengerTargetCelsius != null
}
@@ -0,0 +1,134 @@
package com.evsuite.hardware.telemetry
import android.content.Context
import com.evsuite.hardware.EVHardware
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.saic.SaicCharging
import com.evsuite.hardware.saic.SaicClimate
import com.evsuite.hardware.saic.SaicHub
/** Injectable signal seam: production uses [EvHardwareEnergySignalSource], tests use a fake. */
interface EnergySignalSource {
fun firmware(): FirmwareInfo.Gen
fun socPercent(): Float?
fun rangeKm(): Float?
fun speedKmh(): Float?
fun batteryPowerKw(): Float?
fun outsideTempCelsius(): Float?
fun cabinTempCelsius(): Float?
fun batteryTempCelsius(): Float?
fun batteryEnergyKwh(): Float?
fun batteryCapacityKwh(): Float?
fun odometerKm(): Float?
fun chargePortConnected(): Boolean?
fun chargingStatus(): Int?
/** The vehicle's own counters since its last charge; null where the car keeps none. */
fun vehicleConsumedKwh(): Float? = null
fun vehicleRegeneratedKwh(): Float? = null
fun parked(): Boolean?
fun climate(): ClimateSnapshot
fun tirePressures(): TirePressureSnapshot
/** Probe-only values excluded from [EnergySnapshot] and normal consumers. */
fun evidenceProbes(): List<TelemetryEvidenceProbe> = emptyList()
}
class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
init {
val appContext = context.applicationContext
EVHardware.init(appContext)
SaicHub.connect(appContext)
}
override fun firmware() = FirmwareInfo.getGeneration()
override fun socPercent() =
SaicCharging.stateOfChargePercent() ?: EVHardware.getVendorBatterySocPercent()
override fun rangeKm() =
SaicCharging.rangeKm()?.toFloat()
?: EVHardware.getStandardRangeKm()
?: EVHardware.getVendorRangeKm()?.toFloat()
override fun speedKmh() = EVHardware.getVehicleSpeedKmh()
// The standard property first, the vendor pack pair second: SWI68 answers no
// EV_INSTANTANEOUS_CHARGE_RATE but does publish the voltage and the current it is made of.
override fun batteryPowerKw() =
EVHardware.getBatteryPowerKw() ?: EVHardware.getVendorBatteryPowerKw()
override fun outsideTempCelsius() =
SaicClimate.outsideTempCelsius() ?: EVHardware.getOutsideTempCelsius()
override fun cabinTempCelsius() = EVHardware.getCabinTemperatureCelsius()
override fun batteryTempCelsius() = EVHardware.getBatteryTemperatureCelsius()
override fun batteryEnergyKwh() = EVHardware.getBatteryEnergyKwh()
override fun batteryCapacityKwh() = EVHardware.getBatteryCapacityKwh()
override fun odometerKm() = EVHardware.getOdometerKm()
override fun chargePortConnected() = EVHardware.isChargePortConnected()
// The charging service first, the vendor property second: it answers on units where
// the service does not bind.
override fun chargingStatus() =
SaicCharging.chargingStatus() ?: EVHardware.getVendorChargeStatus()
override fun vehicleConsumedKwh() = SaicCharging.consumedKwhSinceCharge()
override fun vehicleRegeneratedKwh() = SaicCharging.regeneratedKwhSinceCharge()
override fun parked() = EVHardware.isVehicleInPark()
override fun tirePressures() = TirePressureSnapshot(
frontLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_LEFT),
frontRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_RIGHT),
rearLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.REAR_LEFT),
rearRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.REAR_RIGHT),
)
override fun evidenceProbes() = listOf(
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
EVHardware.probeCurrentBatteryCapacityWh()?.toDouble(),
)
)
override fun climate() = ClimateSnapshot(
powerOn = SaicClimate.powerOn(),
acOn = SaicClimate.acOn(),
autoOn = SaicClimate.autoOn(),
econOn = SaicClimate.econOn(),
recirculationOn = SaicClimate.recirculationOn(),
fanLevel = SaicClimate.fanLevel(),
fanLevelMax = SaicClimate.fanLevelMax(),
driverTargetCelsius = SaicClimate.driverTemp()?.toFloat()
?: 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(),
)
}
/** Builds a coherent snapshot from one shared source; every unavailable signal remains null. */
class EnergyTelemetryReader(private val source: EnergySignalSource) {
constructor(context: Context) : this(EvHardwareEnergySignalSource(context))
fun read(nowMs: Long = System.currentTimeMillis()) = EnergySnapshot(
timestampMs = nowMs,
firmware = source.firmware(),
socPercent = source.socPercent()?.takeIf { it.isFinite() && it in 0f..100f },
rangeKm = source.rangeKm()?.takeIf { it.isFinite() && it >= 0f },
speedKmh = source.speedKmh()?.takeIf { it.isFinite() && it >= 0f },
batteryPowerKw = source.batteryPowerKw()?.takeIf { it.isFinite() },
outsideTempCelsius = source.outsideTempCelsius()?.takeIf { it.isFinite() },
cabinTempCelsius = source.cabinTempCelsius()?.takeIf { it.isFinite() },
batteryTempCelsius = source.batteryTempCelsius()?.takeIf { it.isFinite() },
batteryEnergyKwh = source.batteryEnergyKwh()?.takeIf { it.isFinite() && it >= 0f },
batteryCapacityKwh = source.batteryCapacityKwh()?.takeIf { it.isFinite() && it > 0f },
odometerKm = source.odometerKm()?.takeIf { it.isFinite() && it >= 0f },
chargePortConnected = source.chargePortConnected(),
chargingStatus = source.chargingStatus(),
vehicleConsumedKwh = source.vehicleConsumedKwh()?.takeIf { it.isFinite() && it >= 0f },
vehicleRegeneratedKwh = source.vehicleRegeneratedKwh()?.takeIf { it.isFinite() && it >= 0f },
parked = source.parked(),
climate = source.climate(),
tirePressures = source.tirePressures(),
)
/** Reads CP-004 candidates only when an unstable evidence capture explicitly requests them. */
fun readEvidence(nowMs: Long = System.currentTimeMillis()) = TelemetryEvidenceSample(
snapshot = read(nowMs),
probes = source.evidenceProbes().map { probe ->
probe.copy(value = probe.value?.takeIf { it.isFinite() })
},
)
}
@@ -0,0 +1,142 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.BatteryPowerEvidence
import com.evsuite.hardware.CarPropertyEvidence
import com.evsuite.hardware.VehicleSpeedEvidence
data class EnergyTripSummary(
val startedAtMs: Long,
val endedAtMs: Long,
/**
* Time actually covered by usable samples, not wall clock. A trip whose sampler was
* suspended — the dashboard went to the background, the process was paused — keeps a
* duration consistent with its distance and energy, which is what the averages divide.
*/
val durationMs: Long,
val distanceKm: Double,
val startSocPercent: Float?,
val endSocPercent: Float?,
val consumedKwh: Double?,
val regeneratedKwh: Double?,
/**
* `null` means a pre-field record whose numeric distance remains authoritative for
* compatibility. New accumulators write false until at least one usable speed interval
* exists, so an unreadable signal can no longer masquerade as 0 km.
*/
val distanceAvailable: Boolean? = null,
/** Null for legacy or unvalidated records; models must not reuse their energy totals. */
val batteryPowerEvidence: BatteryPowerEvidence? = null,
/**
* Which speed conversion produced [distanceKm]. Null on records written before the
* conversion was versioned — their distance may carry the 3.6x error and no model may
* train on it. See [VehicleSpeedEvidence].
*/
val speedEvidence: VehicleSpeedEvidence? = null,
) {
/**
* The distance, only when the conversion behind it is the one believed correct today.
*
* `recordedDistanceKm` stays what the trip reported, because history is what it is and a
* driver's own record should not silently change. This is the one a model may use.
*/
fun modellableDistanceKm(
generation: com.evsuite.hardware.FirmwareInfo.Gen =
com.evsuite.hardware.FirmwareInfo.getGeneration(),
): Double? = recordedDistanceKm?.takeIf { speedEvidence?.matchesCurrent(generation) == true }
val recordedDistanceKm: Double?
get() = distanceKm.takeIf { distanceAvailable != false }
val averageConsumptionKwhPer100Km: Double?
get() = if (consumedKwh != null && recordedDistanceKm != null && distanceKm >= 0.1) {
consumedKwh * 100.0 / distanceKm
} else null
}
/** A finished trip: its totals, and the track they were integrated from. */
data class RecordedTrip(
val summary: EnergyTripSummary,
val samples: List<TripSample>,
)
/** Trapezoidal integration with bounded gaps so sleep/resume never invents energy. */
class EnergyTripAccumulator(
private val startedAtMs: Long,
private val startSocPercent: Float?,
private val track: TripSampleTrack = TripSampleTrack(),
private val batteryPowerEvidence: BatteryPowerEvidence? = null,
private val speedEvidence: VehicleSpeedEvidence? = VehicleSpeedEvidence.current(),
) {
private var last: EnergySnapshot? = null
private var distanceKm = 0.0
private var consumedKwh = 0.0
private var regeneratedKwh = 0.0
private var hasSpeedInterval = false
private var hasPowerInterval = false
private var recordedMs = 0L
private var latestSocPercent = startSocPercent
fun add(sample: EnergySnapshot) {
track.add(sample)
latestSocPercent = sample.socPercent ?: latestSocPercent
val previous = last
last = sample
if (previous == null) return
val gapMs = sample.timestampMs - previous.timestampMs
if (gapMs <= 0L || gapMs > MAX_SAMPLE_GAP_MS) return
recordedMs += gapMs
val hours = gapMs / 3_600_000.0
if (previous.speedKmh != null && sample.speedKmh != null) {
hasSpeedInterval = true
distanceKm += ((previous.speedKmh + sample.speedKmh) / 2.0) * hours
}
if (previous.batteryPowerKw != null && sample.batteryPowerKw != null) {
hasPowerInterval = true
val intervalKwh =
((previous.batteryPowerKw + sample.batteryPowerKw) / 2.0) * hours
if (intervalKwh >= 0.0) consumedKwh += intervalKwh
else regeneratedKwh += -intervalKwh
}
}
fun samples(): List<TripSample> = track.samples()
fun snapshot(nowMs: Long) = EnergyTripSummary(
startedAtMs = startedAtMs,
endedAtMs = nowMs,
durationMs = recordedMs,
distanceKm = distanceKm,
startSocPercent = startSocPercent,
endSocPercent = latestSocPercent,
consumedKwh = consumedKwh.takeIf { hasPowerInterval },
regeneratedKwh = regeneratedKwh.takeIf { hasPowerInterval },
distanceAvailable = hasSpeedInterval,
speedEvidence = speedEvidence.takeIf { hasSpeedInterval },
batteryPowerEvidence = batteryPowerEvidence.takeIf { hasPowerInterval },
)
private companion object { const val MAX_SAMPLE_GAP_MS = 5_000L }
}
/** Process-local recording state shared by any energy dashboard in the same app process. */
object EnergyTripSession {
private var accumulator: EnergyTripAccumulator? = null
val isRecording: Boolean get() = accumulator != null
@Synchronized fun start(sample: EnergySnapshot) {
if (accumulator != null) return
accumulator = EnergyTripAccumulator(
sample.timestampMs,
sample.socPercent,
batteryPowerEvidence = CarPropertyEvidence.powerModelEvidence(sample.firmware),
)
.also { it.add(sample) }
}
@Synchronized fun add(sample: EnergySnapshot) = accumulator?.add(sample)
@Synchronized fun current(nowMs: Long) = accumulator?.snapshot(nowMs)
@Synchronized fun stop(nowMs: Long): RecordedTrip? {
val finished = accumulator ?: return null
accumulator = null
return RecordedTrip(finished.snapshot(nowMs), finished.samples())
}
}
@@ -0,0 +1,183 @@
package com.evsuite.hardware.telemetry
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.File
import java.io.FileOutputStream
/** A stored trip: always its totals, and its track for as long as there is room for it. */
data class StoredTrip(
val summary: EnergyTripSummary,
val samples: List<TripSample>? = null,
)
/**
* The history file's envelope.
*
* The first version of this file was a bare JSON array of summaries, with no room to say what
* it was. Trips now carry a sample track, and the track will change again as models arrive, so
* the file states its own version and a reader can decide whether it understands it.
*/
data class TripHistoryFile(
val schemaVersion: Int = SCHEMA_VERSION,
val trips: List<StoredTrip>,
) {
companion object {
const val SCHEMA_VERSION = 2
}
}
/**
* Bounded app-private history, replaced atomically after every successful write.
*
* Three bounds apply, in the order that keeps the most meaning per byte:
* • at most [maxTrips] trips, newest first;
* • at most [maxBytes] on disk, reached by dropping sample tracks oldest-first — a trip
* keeps its totals even when its track is evicted, because the totals are what the driver
* reads and the track is what a model would like;
* • only then, if a single trip still does not fit, by dropping the oldest trips.
*
* A file this build cannot read is quarantined rather than deleted. It is the only copy of
* whatever it holds, and a future build — or a person — may be able to read it.
*/
class EnergyTripHistoryStore(
private val target: File,
private val maxTrips: Int = 200,
private val maxBytes: Int = 512 * 1024,
private val gson: Gson = Gson(),
) {
/** Trips with whatever tracks survived eviction, newest first. */
fun read(): List<StoredTrip> {
if (!target.exists()) return emptyList()
val text = runCatching { target.readText() }.getOrNull() ?: return emptyList()
return parse(text) ?: run {
quarantine()
emptyList()
}
}
fun readSummaries(): List<EnergyTripSummary> = read().map { it.summary }
fun append(summary: EnergyTripSummary, samples: List<TripSample> = emptyList()): Boolean {
val entry = StoredTrip(summary, samples.takeIf { it.isNotEmpty() })
val updated = bound(listOf(entry) + read())
return write(TripHistoryFile(trips = updated))
}
/** Removes exactly one trip identified by its recording start, preserving every other entry. */
fun deleteTrip(startedAtMs: Long): Boolean {
val trips = read()
val index = trips.indexOfFirst { it.summary.startedAtMs == startedAtMs }
if (index < 0) return false
val updated = trips.toMutableList().also { it.removeAt(index) }
return write(TripHistoryFile(trips = updated))
}
/** Atomically replaces the history with a valid empty v2 envelope. */
fun clear(): Boolean = write(TripHistoryFile(trips = emptyList()))
/**
* Reads both shapes: the v2 envelope, and the v1 bare array that shipped before it.
*
* Returns null when the text is neither — a truncated write, or a file from a build that
* knows something this one does not. The caller quarantines it; nothing is deleted, and
* nothing is guessed at.
*/
@Suppress("SENSELESS_COMPARISON")
private fun parse(text: String): List<StoredTrip>? {
if (text.trimStart().startsWith("[")) return parseLegacy(text)
val envelope = runCatching { gson.fromJson(text, HistoryEnvelope::class.java) }
.getOrNull() ?: return null
if (envelope.schemaVersion != TripHistoryFile.SCHEMA_VERSION) return null
// Gson writes straight through Kotlin's nullability, so a truncated or hand-edited file
// can hold a trip with no summary at all. The comparison is not senseless here.
return envelope.trips.orEmpty().filter { it != null && it.summary != null }
}
/** The shape that shipped first: a bare array of summaries, with no envelope and no track. */
private fun parseLegacy(text: String): List<StoredTrip>? = runCatching {
val type = object : TypeToken<List<EnergyTripSummary>>() {}.type
gson.fromJson<List<EnergyTripSummary>>(text, type)?.map { StoredTrip(it) }
}.getOrNull()
private data class HistoryEnvelope(
val schemaVersion: Int = 0,
val trips: List<StoredTrip>? = null,
)
/**
* The unreadable file keeps its contents under a new name; the history starts again.
*
* The original has to go, by rename or by copy-then-delete. Leaving it in place made every
* later read fail to parse it again and write another quarantine copy — one file per read,
* without a bound, in storage the driver cannot see. When even the copy fails the partial
* is removed rather than left behind: nothing was preserved, so nothing should accumulate.
*/
private fun quarantine() {
val kept = File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}")
if (target.renameTo(kept)) return
if (runCatching { target.copyTo(kept, overwrite = true) }.isSuccess) target.delete()
else kept.delete()
}
/**
* Applies the three bounds, costing one serialisation per trip rather than one per step.
*
* The envelope is `{...,"trips":[t1,t2,...]}`, so its size is the empty envelope plus every
* trip's own JSON plus one comma between each pair. Keeping that arithmetic lets eviction
* subtract as it goes. Measuring the whole file again after every dropped track re-encoded
* a graph of up to two hundred trips and four thousand samples each, once per step, on the
* path that ends a drive.
*/
private fun bound(trips: List<StoredTrip>): List<StoredTrip> {
val candidate = trips.take(maxTrips.coerceAtLeast(1)).toMutableList()
val sizes = candidate.mapTo(ArrayList()) { sizeOf(it) }
var total = totalOf(sizes)
// Tracks first: a trip without its track is still a trip, a missing trip is not.
while (total > maxBytes) {
val oldest = candidate.indexOfLast { it.samples != null }
if (oldest < 0) break
val stripped = candidate[oldest].copy(samples = null)
candidate[oldest] = stripped
sizes[oldest] = sizeOf(stripped)
total = totalOf(sizes)
}
while (total > maxBytes && candidate.size > 1) {
candidate.removeAt(candidate.lastIndex)
sizes.removeAt(sizes.lastIndex)
total = totalOf(sizes)
}
return candidate
}
/** Empty envelope, plus every trip, plus the comma between each pair. */
private fun totalOf(sizes: List<Int>): Int =
emptyEnvelopeBytes + sizes.sum() + (sizes.size - 1).coerceAtLeast(0)
private val emptyEnvelopeBytes: Int by lazy {
gson.toJson(TripHistoryFile(trips = emptyList())).toByteArray(Charsets.UTF_8).size
}
private fun sizeOf(trip: StoredTrip): Int =
gson.toJson(trip).toByteArray(Charsets.UTF_8).size
private fun write(file: TripHistoryFile): Boolean {
val bytes = gson.toJson(file).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
}
}
}
@@ -0,0 +1,186 @@
package com.evsuite.hardware.telemetry
/**
* Whether the plan the driver chose at the kerb is still the plan they are driving.
*
* A plan is a prediction, and predictions go wrong on the road: a headwind, a cold pack, traffic,
* a driver going faster than the row they picked. The moment worth a line is the one where the
* charge left on arrival has fallen under the reserve while there is still a charger behind the
* car rather than ahead of it, and nobody notices that by watching a percentage move.
*
* **This costs nothing.** Charge and the odometer are already being read once a second, and the
* plan was written down when it was chosen. There is no request here, no quota and no network —
* the whole of it is a division. Only a *re-proposal* costs a request, and that happens on a tap
* somewhere else.
*
* **The alarm sits outside the model's own band, never on a point.** The rate a drive is actually
* spending is measured through two quantised instruments — a gauge that steps a tenth of a
* percent and an odometer that answers whole kilometres — so it arrives with a band of its own, and the plan
* arrived with one too. Drift is the two bands failing to overlap. Anything narrower would fire on
* a hill.
*
* **A drift that costs nothing is not reported.** Driving faster than the row that was chosen is
* the driver's business, and a companion that says so while still arriving at 30 % is a companion
* that gets ignored by the time it matters. So [Verdict.Short] needs both halves: the spending
* left the plan's band *and* the arrival no longer clears the reserve.
*/
object PlanDrift {
/**
* Below this the instruments' own steps dominate whatever they measured.
*
* The odometer sets this, not the gauge: at 15 km its whole kilometre is already 0,015 %/km
* of band at this car's rate, and under that the measurement says less than the plan it would
* be contradicting.
*/
const val MIN_DISTANCE_KM = 15.0
/**
* The finest step the charge gauge publishes. Two readings differ by at most this.
*
* Measured, not assumed: the 2026-09-05 drive on SWI68 recorded 457 samples stepping between
* 53,9 % and 52,9 % in tenths, refreshed every 25 s or so. A whole percent — the figure this
* started with — would have widened every band tenfold and made the companion deaf.
*/
const val SOC_RESOLUTION_PERCENT = 0.1
/**
* A rise past this is a charging session rather than a long descent.
*
* Deliberately not [SOC_RESOLUTION_PERCENT]: quantisation and regeneration are different
* quantities, and sharing one constant would make every descent look like a charge. Regen down
* a col gives back single-figure percent on this pack; the shortest plausible plug-in gives
* back far more, so anything between the two separates them.
*
* Wrong low, a long descent silences the line until the gauge falls back under the departure
* figure. Wrong high, a real mid-drive charge leaves the departure figure anchoring nothing.
* The first failure is quiet and the second is not, which is why this sits nearer the descent.
*/
const val MAX_REGEN_RECOVERY_PERCENT = 5.0
/** The adapter's odometer answers whole kilometres, so a difference of them carries one. */
const val ODOMETER_RESOLUTION_KM = 1.0
/**
* The plan as it was handed to the car, frozen at the moment the driver chose it.
*
* @param legKm the leg being driven — to the charging stop where the plan has one, and to the
* destination where it does not. Never the whole route past a stop: arriving under the
* reserve at a destination the plan says to charge before is not drift, it is the plan.
* @param plannedRatePercentPerKm the rate the plan was made with, climb already folded in by
* [ChargeStopPlan.effectiveRate].
* @param reservePercent the charge the driver refuses to go below.
*/
data class Followed(
val legKm: Double,
val socAtDeparturePercent: Double,
val plannedRatePercentPerKm: Double,
val plannedUncertaintyPercentPerKm: Double,
val reservePercent: Double,
)
/** Why there is nothing to say, kept apart because the reasons read differently. */
enum class Reason {
/** Nothing was chosen, or it was forgotten. */
NO_PLAN,
/** The gauge is unreadable. */
NO_CHARGE,
/** Nothing can say how far this car has come since the plan. */
NO_DISTANCE,
/** Too little road behind the car for the measurement to mean anything. */
TOO_SOON,
/** The car has more charge than it left with; the departure figure no longer anchors. */
CHARGED_EN_ROUTE,
/** The leg is behind the car. */
ARRIVED,
}
/** What the drive is actually doing, whatever the verdict on it. */
data class Reading(
val drivenKm: Double,
val remainingKm: Double,
/** Charge per kilometre this drive has actually spent. */
val observedPercentPerKm: Double,
/** Charge left at the end of the leg if the rest is spent like the part behind. */
val arrivalPercent: Double,
/** Half-width on [arrivalPercent]. */
val bandPercent: Double,
)
sealed interface Verdict {
/** The plan is still true: the spending is inside its band, or it costs the arrival nothing. */
data class Holding(val reading: Reading) : Verdict
/**
* The plan stopped being true.
*
* @param shortfallPercent how far under the reserve the pessimistic edge of the arrival
* falls. Always positive, and it is what a slower speed has to give back.
*/
data class Short(val reading: Reading, val shortfallPercent: Double) : Verdict
data class Unavailable(val reason: Reason) : Verdict
}
/**
* @param drivenKm road covered since [plan] was chosen — an odometer difference, not a trip.
* @param remainingKm the car's own remaining distance where the head unit is guiding on this
* leg, which is the better number because it knows about the road actually taken. Null
* falls back to the leg's own arithmetic.
*/
fun check(
plan: Followed?,
drivenKm: Double?,
socNowPercent: Double?,
remainingKm: Double? = null,
): Verdict {
if (plan == null || !plan.legKm.isFinite() || plan.legKm <= 0.0) {
return Verdict.Unavailable(Reason.NO_PLAN)
}
if (plan.plannedRatePercentPerKm <= 0.0) return Verdict.Unavailable(Reason.NO_PLAN)
if (socNowPercent == null || !socNowPercent.isFinite() || socNowPercent !in 0.0..100.0) {
return Verdict.Unavailable(Reason.NO_CHARGE)
}
if (drivenKm == null || !drivenKm.isFinite() || drivenKm < 0.0) {
return Verdict.Unavailable(Reason.NO_DISTANCE)
}
if (drivenKm < MIN_DISTANCE_KM) return Verdict.Unavailable(Reason.TOO_SOON)
val spent = plan.socAtDeparturePercent - socNowPercent
// A gauge that rose down a long descent is regeneration, not a charging session. Only a
// rise bigger than a descent can give back means the car was plugged in and the departure
// figure stopped anchoring anything.
if (spent < -MAX_REGEN_RECOVERY_PERCENT) {
return Verdict.Unavailable(Reason.CHARGED_EN_ROUTE)
}
val remaining = remainingKm?.takeIf { it.isFinite() } ?: (plan.legKm - drivenKm)
if (remaining <= 0.0) return Verdict.Unavailable(Reason.ARRIVED)
val observed = (spent / drivenKm).coerceAtLeast(0.0)
// Both instruments quantise, and both errors land on the same division. Added rather
// than combined in quadrature: they are two systematic steps, not two noise sources,
// and the wide reading is the one that refuses to raise an alarm it cannot support.
val observedBand =
(SOC_RESOLUTION_PERCENT + observed * ODOMETER_RESOLUTION_KM) / drivenKm
val arrival = socNowPercent - observed * remaining
val band = observedBand * remaining
val reading = Reading(drivenKm, remaining, observed, arrival, band)
val outsideBand =
observed - plan.plannedRatePercentPerKm >
plan.plannedUncertaintyPercentPerKm + observedBand
val shortfall = plan.reservePercent - (arrival - band)
return if (outsideBand && shortfall > 0.0) {
Verdict.Short(reading, shortfall)
} else {
Verdict.Holding(reading)
}
}
}
@@ -0,0 +1,177 @@
package com.evsuite.hardware.telemetry
/**
* What a number on an energy screen actually is.
*
* A dashboard that shows a vehicle's state of charge next to a model's opinion of the
* remaining range is showing two different kinds of claim in the same typeface. The MG4
* publishes no per-consumer energy counters, so anything past traction totals — how much
* the climate system took, what a slower motorway speed would have saved, the state of
* charge on arrival — can only ever be inferred. Those figures are worth showing, and they
* are not measurements.
*
* The distinction is a type rather than a convention because conventions are kept by
* whoever wrote the line last. A screen renders a [Provenanced] value, and the class it
* carries decides how it is drawn.
*/
enum class Provenance {
/** Read from the vehicle, with the VHAL standing behind it (see `CarPropertyEvidence`). */
MEASURED,
/** Arithmetic on measured values only — kWh/100 km from power and speed, and nothing else. */
DERIVED,
/** A model's output. Always carries an uncertainty band; never rendered like a measurement. */
ESTIMATED,
/** No value, and a reason why. Rendered as an em dash, never as zero. */
UNAVAILABLE,
}
/** Why a value is missing. The screen says which of these it is instead of only "—". */
enum class UnavailableReason {
/** The firmware generation does not offer this signal at all. */
UNSUPPORTED_FIRMWARE,
/** A read path exists, but no on-vehicle evidence confirms its unit, sign or cadence yet. */
UNVALIDATED_FIRMWARE,
/** Declared by the VHAL and not published right now: an unavailable status, or a null read. */
SIGNAL_ABSENT,
/** A derived or estimated value whose inputs have not accumulated enough samples. */
INSUFFICIENT_SAMPLES,
/** A speed-normalised value is undefined or unstable below its documented speed floor. */
SPEED_TOO_LOW,
/** An estimator with no fit to predict from. */
MODEL_NOT_TRAINED,
}
/**
* A value, what kind of claim it is, and — for an estimate — how wrong it might be.
*
* Construction is validated rather than trusted: an [Provenance.ESTIMATED] value cannot be
* built without a band, and an [Provenance.UNAVAILABLE] one cannot carry a number. That is
* the whole point of the class. The constructor enforces those rules, so it is safe to call
* directly, but the factories below read better at the call site and pick the right reason
* when a signal turns out to be missing.
*
* @param uncertainty half-width of the band, in the value's own unit: 14.2 ± 1.6 kWh/100 km.
*/
data class Provenanced<T : Any>(
val value: T?,
val provenance: Provenance,
val uncertainty: Double? = null,
val reason: UnavailableReason? = null,
) {
init {
when (provenance) {
Provenance.UNAVAILABLE -> {
require(value == null) { "an unavailable value carries no number" }
require(reason != null) { "an unavailable value must say why" }
require(uncertainty == null) { "an unavailable value has no band" }
}
Provenance.ESTIMATED -> {
require(value != null) { "an estimate with no value is unavailable instead" }
require(uncertainty != null) { "an estimate must carry its uncertainty band" }
require(uncertainty.isFinite() && uncertainty >= 0.0) {
"an uncertainty band is finite and non-negative, was $uncertainty"
}
require(reason == null) { "an available value has no unavailability reason" }
}
Provenance.MEASURED, Provenance.DERIVED -> {
require(value != null) { "a missing reading is unavailable instead" }
require(uncertainty == null) {
"only an estimate carries a band; the vehicle publishes no error bars"
}
require(reason == null) { "an available value has no unavailability reason" }
}
}
}
val isAvailable: Boolean get() = provenance != Provenance.UNAVAILABLE
/** The band's lower and upper edge, or null when there is no band. */
val bandLow: Double? get() = boundsOrNull()?.first
val bandHigh: Double? get() = boundsOrNull()?.second
private fun boundsOrNull(): Pair<Double, Double>? {
val centre = (value as? Number)?.toDouble() ?: return null
val half = uncertainty ?: return null
return centre - half to centre + half
}
companion object {
/** A vehicle reading. A null becomes unavailable for [absent], never a zero. */
fun <T : Any> measured(
value: T?,
absent: UnavailableReason = UnavailableReason.SIGNAL_ABSENT,
): Provenanced<T> =
if (value == null) unavailable(absent)
else Provenanced(value, Provenance.MEASURED)
/** Arithmetic on measured inputs. Prefer the [derive] overloads, which propagate gaps. */
fun <T : Any> derived(
value: T?,
absent: UnavailableReason = UnavailableReason.INSUFFICIENT_SAMPLES,
): Provenanced<T> =
if (value == null) unavailable(absent)
else Provenanced(value, Provenance.DERIVED)
/** A model output. The band is not optional. */
fun <T : Any> estimated(
value: T?,
uncertainty: Double,
absent: UnavailableReason = UnavailableReason.MODEL_NOT_TRAINED,
): Provenanced<T> =
if (value == null) unavailable(absent)
else Provenanced(value, Provenance.ESTIMATED, uncertainty = uncertainty)
fun <T : Any> unavailable(reason: UnavailableReason): Provenanced<T> =
Provenanced(null, Provenance.UNAVAILABLE, reason = reason)
/**
* Derives from one measured input, carrying its gap forward.
*
* A derivation over a missing input is not a smaller number, it is no number: the
* consumption of a car whose speed is unreadable is unknown, not zero. The reason
* travels with the gap so the screen can still explain itself.
*/
fun <A : Any, R : Any> derive(
a: Provenanced<A>,
transform: (A) -> R?,
): Provenanced<R> {
requireNotEstimated(a)
val value = a.value ?: return unavailable(a.reasonOrAbsent())
return derived(transform(value))
}
/** Derives from two inputs; the first unavailable one decides the reason. */
fun <A : Any, B : Any, R : Any> derive(
a: Provenanced<A>,
b: Provenanced<B>,
transform: (A, B) -> R?,
): Provenanced<R> {
requireNotEstimated(a)
requireNotEstimated(b)
val first = a.value ?: return unavailable(a.reasonOrAbsent())
val second = b.value ?: return unavailable(b.reasonOrAbsent())
return derived(transform(first, second))
}
/**
* A derivation whose input is itself an estimate is an estimate, and its band has to
* be computed by whoever knows the model — not silently dropped here.
*/
private fun requireNotEstimated(input: Provenanced<*>) {
require(input.provenance != Provenance.ESTIMATED) {
"deriving from an estimate loses its band; propagate the band explicitly"
}
}
private fun Provenanced<*>.reasonOrAbsent(): UnavailableReason =
reason ?: UnavailableReason.SIGNAL_ABSENT
}
}
@@ -0,0 +1,105 @@
package com.evsuite.hardware.telemetry
import kotlin.math.abs
/**
* What the climbing costs, and how little of it comes back.
*
* CP-031 refused a grade source on 2026-09-01 and was right to: the vehicle publishes none, a
* barometric reference is unproven, power-derived grade is circular, and sampled GPS altitude
* describes the road *behind* the car. CP-047 changed the input rather than the argument — a
* route carries an elevation profile of the road **ahead**, which is the only kind a forecast
* can use. Toulouse to Alès crosses the Cévennes, and a forecast that does not know that is
* wrong in the direction that strands people.
*
* This is physics, not a fit. Potential energy for a stated mass, a stated drivetrain
* efficiency on the way up and a stated regeneration efficiency on the way down — every one of
* them a declared constant with a band, none of them a parameter tuned against data this
* project does not have. When there are trips over known profiles a fitted coefficient can
* replace it; until then a fitted number would only be a guess wearing a measurement's clothes.
*
* **Regeneration is not free.** A descent returns charge, but less than the same climb spent,
* because the round trip pays the drivetrain twice and pays the brakes wherever the descent is
* steeper than the car's drag. That asymmetry is the whole point: a route over a col comes back
* costing more than the flat one, never the same and never less.
*/
object RouteGrade {
/**
* The car and what is in it, in kilograms.
*
* MG4 Long Range kerb mass is about 1685 kg on the manufacturer's specification; a driver
* and their luggage is the rest. A specification describes the model and not this car, so
* this is a stated assumption carried in [RELATIVE_UNCERTAINTY] rather than a measurement —
* see `AGENTS.md` on specification references.
*/
const val MASS_KG = 1750.0
const val GRAVITY_M_PER_S2 = 9.80665
/** Battery to wheels going up: motor, inverter and reduction gear, all of them lossy. */
const val DRIVETRAIN_EFFICIENCY = 0.85
/**
* Wheels back to battery going down, and deliberately pessimistic.
*
* The chain loses as much as the way up, and on top of that some of the descent is spent on
* drag rather than recovered, and a steep one reaches the regeneration limit and goes to
* the friction brakes. Overstating this is how a forecast promises charge that never
* arrives, so it is understated instead.
*/
const val REGENERATION_EFFICIENCY = 0.60
/**
* How wrong the three constants above may be, together.
*
* Mass is a specification plus a guess at the load, and neither efficiency was measured on
* this car. Wide on purpose: a narrow band on numbers nobody measured is a false precision,
* and this one feeds a stop decision.
*/
const val RELATIVE_UNCERTAINTY = 0.40
private const val JOULES_PER_KWH = 3_600_000.0
/**
* What the profile costs in points of charge, with its band.
*
* Negative on a route that descends more than it climbs, which is a real effect and is not
* clamped away — a car crossing the Alps towards the sea does arrive with more than the flat
* arithmetic says.
*/
data class Cost(val percent: Double, val uncertaintyPercent: Double)
/**
* The grade term, or null when there is nothing to say.
*
* Null rather than zero, always: a silent zero grade term is a claim that the road is flat,
* and this cannot tell a flat road from a missing profile.
*
* @param ascentMetres cumulative climb over the route, metres.
* @param descentMetres cumulative descent over the route, metres.
* @param capacity the pack the charge is a percentage of; without it there is no bridge
* between joules and state of charge.
*/
fun of(
ascentMetres: Double?,
descentMetres: Double?,
capacity: BatteryCapacityConfig?,
): Cost? {
if (ascentMetres == null || descentMetres == null || capacity == null) return null
if (!ascentMetres.isFinite() || !descentMetres.isFinite()) return null
if (ascentMetres < 0.0 || descentMetres < 0.0) return null
val potentialPerMetreKwh = MASS_KG * GRAVITY_M_PER_S2 / JOULES_PER_KWH
val spentKwh = potentialPerMetreKwh * ascentMetres / DRIVETRAIN_EFFICIENCY
val recoveredKwh = potentialPerMetreKwh * descentMetres * REGENERATION_EFFICIENCY
val soc = capacity.socPercentForEnergy(spentKwh - recoveredKwh)
val percent = soc.value ?: return null
if (!percent.isFinite()) return null
// The capacity's own band on top of the constants', added rather than combined in
// quadrature: they are not independent errors and the wider answer is the safe one.
val band = abs(percent) * RELATIVE_UNCERTAINTY + (soc.uncertainty ?: 0.0)
return Cost(percent, band)
}
}
@@ -0,0 +1,123 @@
package com.evsuite.hardware.telemetry
import kotlin.math.abs
import kotlin.math.sqrt
/**
* How much state of charge a kilometre costs.
*
* The obvious route to an arrival forecast runs through the energy model: kWh/100 km, times a
* distance, divided by a pack capacity. On this vehicle it is a dead end — the model trains on
* energy integrated from battery power, and `EV_INSTANTANEOUS_CHARGE_RATE` publishes nothing.
*
* State of charge per kilometre needs no energy unit at all. Both of its inputs read on a car
* where battery power does not, which is why the forecast is built on it instead.
*/
data class SocRate(
/** Percentage points of charge per kilometre. Always positive for a car that is driving. */
val percentPerKm: Double,
/** Half-width of the band on [percentPerKm], in the same unit. */
val uncertaintyPercentPerKm: Double,
val source: Source,
/** Trips behind the figure, or 0 when it came from the vehicle's own range. */
val sampleCount: Int = 0,
) {
enum class Source {
/**
* State of charge divided by the vehicle's own remaining range. Available from the
* first reading, and only as good as the car's own estimate.
*/
VEHICLE_RANGE,
/** Measured across recorded trips: what this driver actually spends per kilometre. */
TRIP_HISTORY,
}
}
/** Builds a [SocRate] from whichever evidence exists, preferring what the driver has driven. */
object SocRateEstimator {
/**
* How wrong the vehicle's own range estimate may be.
*
* The car publishes a range and no error bar. It is a model like any other — it reacts to
* recent driving, temperature and its own assumptions — so a fifth is a stated assumption
* rather than a measurement, and it is deliberately wide. Trip history replaces it with a
* band that was actually observed.
*/
const val VEHICLE_RANGE_RELATIVE_UNCERTAINTY = 0.20
/** Below this a trip's own state-of-charge resolution dominates whatever it measured. */
const val MIN_TRIP_DISTANCE_KM = 1.0
/** Fewer than this and the spread across trips is not a band, it is noise. */
const val MIN_TRIPS = 3
/** A floor on the observed spread, so a run of similar trips cannot imply false precision. */
const val MIN_TRIP_RELATIVE_UNCERTAINTY = 0.10
/**
* The rate implied by the vehicle's own range reading.
*
* @param socPercent the vehicle's state of charge, 0..100.
* @param rangeKm the vehicle's own remaining range.
*/
fun fromVehicleRange(socPercent: Double?, rangeKm: Double?): SocRate? {
if (socPercent == null || rangeKm == null) return null
if (!socPercent.isFinite() || !rangeKm.isFinite()) return null
if (socPercent !in 0.0..100.0 || rangeKm <= 0.0) return null
val rate = socPercent / rangeKm
if (!rate.isFinite() || rate <= 0.0) return null
return SocRate(
percentPerKm = rate,
uncertaintyPercentPerKm = rate * VEHICLE_RANGE_RELATIVE_UNCERTAINTY,
source = SocRate.Source.VEHICLE_RANGE,
)
}
/**
* The rate this driver has actually spent, across trips whose distance can be vouched for.
*
* Only trips carrying [EnergyTripSummary.modellableDistanceKm] count: a distance recorded
* under the old speed conversion is 3.6 times too long, and averaging it in would make the
* car look three and a half times more efficient than it is.
*
* Charging stops are excluded by construction — a trip whose charge went up is not a trip
* whose consumption means anything.
*/
fun fromTrips(
trips: List<EnergyTripSummary>,
generation: com.evsuite.hardware.FirmwareInfo.Gen =
com.evsuite.hardware.FirmwareInfo.getGeneration(),
): SocRate? {
val rates = trips.mapNotNull { trip ->
val km = trip.modellableDistanceKm(generation)?.takeIf { it >= MIN_TRIP_DISTANCE_KM }
?: return@mapNotNull null
val start = trip.startSocPercent ?: return@mapNotNull null
val end = trip.endSocPercent ?: return@mapNotNull null
val spent = (start - end).toDouble()
if (spent <= 0.0) return@mapNotNull null
(spent / km).takeIf { it.isFinite() && it > 0.0 }
}
if (rates.size < MIN_TRIPS) return null
val mean = rates.average()
if (!mean.isFinite() || mean <= 0.0) return null
val variance = rates.sumOf { (it - mean) * (it - mean) } / (rates.size - 1)
val observed = sqrt(variance)
return SocRate(
percentPerKm = mean,
uncertaintyPercentPerKm = maxOf(observed, abs(mean) * MIN_TRIP_RELATIVE_UNCERTAINTY),
source = SocRate.Source.TRIP_HISTORY,
sampleCount = rates.size,
)
}
/** Trip history when there is enough of it, the vehicle's own range otherwise. */
fun best(
socPercent: Double?,
rangeKm: Double?,
trips: List<EnergyTripSummary>,
generation: com.evsuite.hardware.FirmwareInfo.Gen =
com.evsuite.hardware.FirmwareInfo.getGeneration(),
): SocRate? = fromTrips(trips, generation) ?: fromVehicleRange(socPercent, rangeKm)
}

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