Compare commits

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 21:25:27 +02:00
113 changed files with 5161 additions and 356 deletions
+1 -6
View File
@@ -1,12 +1,7 @@
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
/.idea/caches /.idea/
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store .DS_Store
/build /build
/captures /captures
-3
View File
@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
-9
View File
@@ -1,9 +0,0 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+1 -2
View File
@@ -25,8 +25,7 @@ MG4 head unit (Android 9, AAOS, MT2712)
dashboards, Traefik routes). See `k8s/README.md`. dashboards, Traefik routes). See `k8s/README.md`.
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`). - **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), work branch Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), branch `main`. Upstream fork: `fatih` → github.com/fatihdonmezdev/FatihsMG4.
`english-translation`. Upstream fork: `fatih` → github.com/fatihdonmezdev/FatihsMG4.
## Rules ## Rules
+252
View File
@@ -8,6 +8,258 @@ All notable changes to this project are documented here. Format follows
### Added ### Added
- `diag.DiagnosticInbox`: posts one diagnostic artifact (a bundle zip or a text report) to an
HTTPS inbox the driver runs at home, as one JSON object shared by every app. No redirect
followed; an address with credentials or a query is refused; 1 MiB cap.
### Changed
- Hard acceleration and hard braking start at 2.5 m/s², up from 1.5. On the car, ordinary town
drives counted 7 to 16 hard stops every 10 km, and regeneration on High alone comes close to
the old line. Trips recorded before keep the counts they were given.
- `EnergySnapshot.climate.passengerTargetCelsius` is null when the car publishes no passenger
setpoint, instead of a copy of the driver's. A screen that shows one zone for both seats says so.
### Fixed
- Screen brightness rounds both ways: 10 % was written as native 25 and read back as 9 %.
- A trip that started on the head unit's factory clock is dated from the corrected one.
- `RuntimeInterfaceProbe` records ABSENT, not DENIED, when the hub hands over another interface:
ICarPowerService and IVehiclePropertyService answer as IVehicleSettingService on SWI68.
- The charge diagnostic line shows `pack_delta_kwh=unavailable` for an unwatched charge.
- The ACC/TJA and speed-limiter getters log the value they read.
## [1.18.0] - 2026-10-16
### Added
- `EnergySnapshot`, `TripSample` and `EnergyTripSummary` carry the raw drive mode and regen
codes (`getDriveModeRaw`, `getRegenLevelRaw`; 6 is one-pedal on SWI68). Raw, so an unknown code
stays unknown instead of reading NORMAL or MEDIUM; `DriveMode.ofRaw` and `RegenLevel.ofRaw`
decode exactly.
- `EnergyTripSummary` counts driving style at the live 1 Hz rate: moving and hard time, hard
accelerations and hard brakings as episodes. The 5 s track had smoothed short hard stops away.
- `EcoScore`: a trip out of 100 from anticipation (hard brakings per 10 km), smoothness (hard
share) and efficiency (charge against the driver's own expectation). Unmeasured parts drop out.
### Changed
- The eco verdict's TYPICAL zone is one residual either side of the expectation, half the
prediction band. At the full 1.96 band almost every drive read TYPICAL.
### Fixed
- `TripDetector` ends an automatic trip two seconds after the car reports P or a plugged cable,
instead of after the two-minute standstill. The SWI68 head unit powers off about twelve
seconds after P, so an automatic trip died with the process before the timer ran out.
- `SocConsumptionFitter` falls back to two coefficients when the thermal term comes out
negative, instead of refusing the fit. The 2026-10-16 bundle refused 87 segments for this
reason alone.
- `CarPropertyEvidence` no longer lists SWI68 battery power as never published: the vendor pack
fallback reads −32,9 to +32,4 kW on a town drive. Screens now say "not validated yet" instead
of "this vehicle never publishes battery power".
## [1.17.0] - 2026-10-14
### Changed
- `SET_CHARGE_LIMIT` is `writeProven = false`: SWI68 reads the limit back as `5`, a code, while
the write sends a percent. Hidden from editors and refused by executors until one write is
seen to set the limit on the car's screen.
### Added
- Probe-only vendor charge readings for the telemetry evidence: `BMS_CHRG_STS`, the AC and DC
plug states and the kWh-since-plug-in counter, ids from MG4Control's SWI133 readings and
unproven on SWI68. Recorded as `candidate.vendor*`; nothing reads them as values yet.
- `RuntimeInterfaceProbe.componentMap()`, part of `survey()`: every service the
`com.saicmotor.*` packages declare, with its exported flag and permission, from
PackageManager metadata only. Nothing is bound or called; capped at 64 packages and
40 services per package, with the cut recorded.
- `windows.widestPercent` in the telemetry evidence, from `EnergySnapshot.widestWindowPercent`.
- `SocConsumptionFitter.describe()` ends with the solve's outcome once both gates pass:
`fit=ready rmse=…` or `fit=refused(<guard>)`. The existing prefix is unchanged.
### Fixed
- **`SaicCharging.chargeLimitPercent()` answers null outside 40–100 %.** SWI68 answered `5`,
a code rather than a percent; callers now take their "limit unreadable" path. The setter
is unchanged.
- **The battery ledger ignores readings taken on the factory clock.** A head unit woken by a
night charge runs on its late-2018 clock until it syncs; one such entry sorted first in the
ledger and split the charge. `BatteryLedgerRecorder` now drops samples before 2025-01-01.
## [1.16.0] - 2026-10-11
### Added
- `EVHardware.probeFrontDoors()`: front-door evidence on firmwares the door feature does not
claim. Connects and subscribes to DLOCK_DOOR_OPEN_STS whatever `hasDoorVolumeFeature()` says
(MG4Control reads it through the change subscription on SWI68/69/131/165) and reports the
managers, the subscriptions, the on-demand read and the last event per front door.
`frontDoorOpenOrNull()` stays gated until a car confirms.
- `SaicClimate.passengerTempState()`: the passenger target as the service answers it, -1
included, for the diagnostic.
### Fixed
- `SET_PASSENGER_TEMP` no longer claims SWI68: the passenger target reads -1 there, so nothing
shows the write lands.
## [1.15.1] - 2026-10-11
### Fixed
- **`PASSENGER_TEMP` and `DOORS_LOCKED` no longer claim SWI68.** Both were declared from the
getters the SAIC services expose, never from a read on the car, and a SWI68 MG4 reported
both `NOT_READABLE` (2026-10-11). The passenger target reads a zone a single-zone car does not
have; the service answers -1. `getDoorLock` reads back the lock *request* property, and the
car answered 0, which is neither of the two named values. The editor now hides both there.
SWI165 keeps them until a car says otherwise.
## [1.15.0] - 2026-10-08
### Added
- **Exterior-light conditions and standstill actions (CR-046).** Conditions `HEADLIGHTS`
(off / on / daytime running), `HIGH_BEAM_ON` and `FOG_LIGHTS_ON`, plus gated actions
`SET_HEADLIGHTS` (ON / AUTO), `SET_HIGH_BEAM` (OFF / AUTO) and `SET_FOG_LIGHTS`. They use the
standard AAOS `HEADLIGHTS` / `HIGH_BEAM_LIGHTS` / `FOG_LIGHTS` `_STATE` and `_SWITCH` ids.
Writes happen only at 0 km/h. A rule can never switch the low beams OFF, and can never force
the high beam ON (`ExteriorLights`). **Proven on no generation yet**: `ExteriorLights.PROVEN`
and `@SupportedOn` are empty and `writeProven` is false, so the entries stay hidden and inert
until a capture proves a generation.
- **Read-only vendor-surface tools (CR-044, CR-045)** for an app's unstable capture.
`VendorSurfaceProbe.socSourcesReport()` reads every candidate source of state of charge side by
side. `VendorSurfaceProbe.capture()` together with `VendorSurfaceRules.diff()` reads every
`CarPropertyManager` property and every value getter of the vendor setting objects, before and
after a setting is changed on the car's own screen, and lists what changed, naming the light
properties. No setter is called: getter selection is pinned by `VendorSurfaceRulesTest`.
### Changed
- **`EcoDrivingMonitor.harshSharePercent` counts hard braking as well as hard acceleration.**
The live steadiness share read only positive steps past `HARSH_ACCELERATION_MS2`, while
EVChargePilot's trip review (`DrivingStyle`) has always counted both signs at the same threshold,
so the live gauge read lower than the summary of the same drive — roughly half in stop-and-go.
Thresholds are unchanged.
- **The trip history keeps four times as many tracks.** `EnergyTripHistoryStore`'s default
`maxBytes` goes from 512 KiB to 2 MiB. At about 145 KB per hour of driving, 512 KiB held roughly
fourteen short trips' tracks, so each new drive evicted older tracks and the consumption model's
segment count fell as trips were added (51 to 47 on SWI68, 2026-09-30). One long trip's
4 096-sample track alone did not fit. Every reader parses the file off the main thread and the
write stays atomic, so the cost is parse time and memory per read, not a new failure mode.
### Added
- **CUSTOM drive mode settings (CR-040).** `CustomDrive` reads and writes powertrain response,
steering weight and brake-pedal feel by index (0 Eco/Comfort, 1 Normal, 2 Sport) on the VSM
generations: `…Level` methods on SWI68/SWI165, `…Mode` methods on SWI69/SWI131/SWI132. The
index↔code tables live here only — the pedal's Normal is `0`, the A9 powertrain uses the
drive-mode scale. Writes are standstill-gated. SWI133 answers unavailable: its route is a raw
property id this project has not verified. Codes beyond RI-006 are leads until a car confirms.
- **`DrivingProfile` carries the CUSTOM settings and an optional `ProfileClimate` (CR-041).**
All new fields are nullable and default to null, so every saved profile and backup reads as
"not configured" and writes nothing new.
### Fixed
- **An overnight charge the head unit slept through is a charge.** `ChargeEnergy.plugged` required
the odometer to stand still between the two ledger entries around a rise; when the drive home
was never recorded either, the evening entry predates it, the odometer "moved", and a night's
fifty points were filed as regeneration and hidden from the battery page. A rise is now a charge
when the odometer stood still, **or** the car reported a non-zero charging status, **or** it is
at least 10 points and nobody watched the car move during it.
- **Each charge carries its curve.** `ChargeEnergy.steps` is pack power per watched ledger step
(one step per point of charge), with `peakPowerKw` and the outside temperature range;
`ChargeEnergyReport.powerBySocBand()` aggregates the steps of every charge into 10-point bands,
which is where the taper shows. Measured only; an unwatched charge has no steps. Both reach
`describe()`.
- **A calm drive is measured, not waiting.** `EcoDrivingMonitor.harshSharePercent()` returns the
share of hard acceleration once the window holds a minute of movement, below the advice
threshold included. `steadiness()` still answers only above it; callers can now tell "not
measured yet" from "measured, and calm".
- **The power model trains on town driving.** `EnergyModelTrainer` fitted every 5 s sample,
so an acceleration costing several times a cruise counted as model error and stop-and-go
history never passed the residual gate. It now fits one point per kilometre of driving, a
point per charge made rather than per instant, and `sampleCount` counts those kilometres
(twelve at least). A history without 5 °C of temperature spread fits speed alone, with no
weather term, instead of refusing.
- **The charge model trains on short drives.** `SocConsumptionFitter` waited for 2 % of charge
before cutting a segment; SWI68 publishes tenths of a percent, so every drive under about
ten kilometres gave nothing. The drop needed is now two steps of the track's own gauge,
capped at 2 %, and a stretch shorter than 2 km keeps accumulating instead of being dropped.
- **Hard keys are decoded per broadcast.** `com.saic.keyevent.hardkey.report` carries Android
key codes and `com.android.systemui.ACTION_HARD_KEY_EVENT` carries SAIC's own numbering, and
the same press arrives on both. Read as one space, SystemUI's next-track (5) decoded as the
report's phone (5), and a phone press (5 and 16) read as a double. `accept()` now takes a
`Source`; SystemUI contributes only the cluster pad (6–10), which the report does not carry.
A key neither table names is reported with an id of its own (`unknownKeyId`), so it can still
be bound. `Event` carries `keyId`; `Button.id` keeps the numbers rules already store.
- **Eco-driving trip replay now accepts persisted sample cadence.** Live monitoring still rejects
gaps over five seconds, while replay allows the expected delay between five-second track samples
and the next telemetry frame. Stored trips can therefore report measured steadiness again.
### Added
- **What a drive asked of the pack at the moments it minds most (CP-090).** `DrivingStress.of`
measures, per trip, the minutes of hard discharge (above 60 % of the trip's own peak) below
20 % charge or below 0 °C outside, and the energy regenerated below 0 °C. The pack power sign
is unvalidated (RI-002), so each trip orients itself from its falling charge; a trip that
cannot, or with more than a tenth of its time undecidable, gives null rather than zero.
`DrivingStress.monthly` sums the trips that could say, per local month. Measured quantities,
never a wear figure.
- **How long the rest of a charge takes (CP-089).** `ChargeDuration.estimate` sums, band by
10-point band, the minutes per point the driver's own watched charges took: the median, and the
fastest and slowest charge as the range. Charges at a similar outside temperature (±8 °C) are
tried first, all charges after. A band on the path that no watched charge crossed is refused
(`MISSING_BAND`), never extrapolated. `lastStretchMinutes` is the part above 80 %.
`ChargeDuration.curveCheck` compares one finished band with the same band in at least three
past charges; `slower` only when it took longer than the slowest of them.
- **A daily battery digest (CP-087).** `BatteryDigest` copies one day of the health, calibration,
exposure and charge analyses; `BatteryDigestStore` keeps a year of them, one per local day, with
the same atomic write and quarantine as the ledger. `BatteryDigest.changes` reports only what
moved past its own band from one day to the next — health beyond its uncertainty, a band that
narrowed by at least a point (or a first estimate), a calibration verdict that changed — so a
health figure wandering inside its band is never news.
- **Open windows are a consumption factor, like the climate (CP-086).** `EnergySnapshot` and
`TripSample` carry `widestWindowPercent`, read from `SaicVehicleControl.widestWindowPercent()`;
null where unread, never 0, and absent from older stored trips. The attribution gains
`ResidualContext.WINDOWS_OPEN`: an interval with a window open at either end gets its own
residual instead of joining a climate group, so the cabin share no longer absorbs the drag.
- **`EcoDriving`: the band, the levers and the refusals behind the eco coach.** `EcoDrivingMonitor`
keeps a bounded window of the speed channel and answers one question — is this drive cheaper or
dearer than what this driver's own fit expects at this mean speed and outside temperature —
against the fit's own residual rather than a threshold invented for the purpose. It offers at
most one lever: a cruising speed quoted from `SocConsumptionModel.predict`, or the share of
moving time spent accelerating hard, chosen by precedence because the two have no common unit.
Every silence carries its `UnavailableReason`: no fit, a speed outside the fitted envelope, a
distance recorded under the superseded speed conversion, too little distance, too little charge
drop, no outside temperature. `forReplay()` gives the same class an unbounded window, so a
finished track is judged whole rather than on its last three minutes.
### Added
- **The charge side of the battery ledger, which nothing read.** Phase 5 measures the pack from
its discharges alone: a rise in charge is only a boundary marker, so the energy going back in
was being recorded and never looked at — and the two facts RI-002 has been blocked on since
2026-09-13 live in exactly that gap. `ChargeEnergyAnalyzer` reads it, taking `BatteryExposure`'s
sessions rather than cutting its own so the two can never disagree about what a charge is. Per
charge: energy and mean power from the pack-pair integral, which way that integral moved
(`ENERGY_FELL` is `EnergySnapshot.batteryPowerKw`'s declared convention holding, `ENERGY_ROSE`
is it inverted, `CONTRADICTORY` when two charges disagree — never averaged), what the car's own
counters did (`RESET` is what "since the last charge" predicts, and one observed reset outranks
a charge that ended before the car clears them), and the odometer, because a long descent raises
the charge too and only a stationary session is a plug. A charge the app slept through keeps its
points and loses its power: the recorder earns an entry every quarter of an hour, so a longer
silence is this app having been down, and the integral does not move while nobody is
integrating. `describe()` puts the verdicts in a diagnostic bundle. Nothing is corrected and
nothing new is sampled — no new property, no new transaction, no new permission (CP-073).
- **A probe that says when it was never able to ask.** `RuntimeInterfaceProbe.adapterClientMap()` - **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 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, `sCarAdapter` is never assigned — nineteen answers to a question that never reached a binder,
+34 -29
View File
@@ -5,13 +5,14 @@
[![Tests](https://github.com/malys/EVHardware/actions/workflows/tests.yml/badge.svg)](https://github.com/malys/EVHardware/actions/workflows/tests.yml) [![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) [![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) [![Publish](https://github.com/malys/EVHardware/actions/workflows/publish.yml/badge.svg)](https://github.com/malys/EVHardware/actions/workflows/publish.yml)
[![Release](https://img.shields.io/github/v/release/malys/EVHardware?include_prereleases&amp;sort=semver)](https://github.com/malys/EVHardware/releases) [![Release](https://img.shields.io/github/v/release/malys/EVHardware?include_prereleases&sort=semver)](https://github.com/malys/EVHardware/releases)
[![License: PolyForm Noncommercial](https://img.shields.io/badge/license-PolyForm%20Noncommercial-blue.svg)](LICENSE) [![License: PolyForm Noncommercial](https://img.shields.io/badge/license-PolyForm%20Noncommercial-blue.svg)](LICENSE)
[![Part of EVSuite](https://img.shields.io/badge/part%20of-EVSuite-2f81f7)](https://malys.github.io/EVSuite/)
> ⚠️ **This library reads and writes a car's settings.** It runs inside apps installed on > ⚠️ **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. > 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 > MG and MG4 are third-party marks used only to identify compatibility; this independent
> independent and is not approved by SAIC Motor or MG Motor. > project is not affiliated with or approved by SAIC Motor or MG Motor.
Shared vehicle-access layer for the SAIC MG4 (Android Automotive OS 9, MT2712). One 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 implementation of the reflection-based hardware layer, the 0 km/h safety gate, the driving
@@ -22,14 +23,34 @@ The design line: **the apps are thin. EVHardware provides the vehicle.** EVTaske
engine between shared conditions and actions; EVChargePilot and EVABRPUploader consume the 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. same energy snapshot instead of carrying their own property ids, units and fallback order.
Consumers: EVProfile (drive-profile manager; owns the signature-protected TaskerBridge), EVTasker, EVABRPUploader
and EVChargePilot. Each app consumes EVHardware as a git submodule (`EVHardware/lib` as the
`:evhardware` subproject). Common toolchain: **AGP 9.1.1 / Gradle 9.3.1 / compileSdk 36 / JDK 17**.
## Part of EVSuite
EVHardware is the shared vehicle library of [**EVSuite**](https://malys.github.io/EVSuite/), a family of independent,
offline-first apps for the MG4 head unit (Android Automotive OS 9). Each app installs on its
own — pick only what you need. User guides and install instructions:
<https://malys.github.io/EVSuite/>.
Discover the rest of the suite:
[![EVProfile](https://img.shields.io/badge/EVProfile-settings%20%26%20drive%20profiles-2f81f7?logo=github)](https://github.com/malys/EVProfile)
[![EVTasker](https://img.shields.io/badge/EVTasker-rule%20automation-2f81f7?logo=github)](https://github.com/malys/EVTasker)
[![EVABRPUploader](https://img.shields.io/badge/EVABRPUploader-ABRP%20telemetry-2f81f7?logo=github)](https://github.com/malys/EVABRPUploader)
[![EVChargePilot](https://img.shields.io/badge/EVChargePilot-energy%20%26%20trips-2f81f7?logo=github)](https://github.com/malys/EVChargePilot)
[![EVLauncher](https://img.shields.io/badge/EVLauncher-home%20launcher-2f81f7?logo=github)](https://github.com/malys/EVLauncher)
[![EVSwipe](https://img.shields.io/badge/EVSwipe-swipe%20shortcuts-2f81f7?logo=github)](https://github.com/malys/EVSwipe)
--- ---
## Contents ## Contents
- [Part of EVSuite](#part-of-evsuite)
- [Overview](#overview) - [Overview](#overview)
- [How it works](#how-it-works) - [How it works](#how-it-works)
- [Install](#install) - [Install](#install)
- [The EVSuite](#the-evsuite)
- [Building](#building) - [Building](#building)
- [Project documents](#project-documents) - [Project documents](#project-documents)
- [Security](#security) - [Security](#security)
@@ -114,9 +135,16 @@ permissions are `signature|privileged`); reads of standard AAOS properties work
The library itself declares no permissions and no `sharedUserId` — that is the consuming The library itself declares no permissions and no `sharedUserId` — that is the consuming
app's decision. app's decision.
--- ## Install
### External projects: consume the AAR EVHardware is a library, not an APK: it ships inside the consumer apps (EVProfile, EVTasker, EVABRPUploader,
EVChargePilot). Those apps are sideloaded on the head unit via the keyboard route: open a
text field, long-press `,` on the on-screen keyboard → **Language settings** → search
`backup` then press back to reach Android Settings → enable **Developer options** +
**Install unknown apps** → search `storage` and open the APK. See each app's README for
the full steps.
### Consume the AAR
Projects outside this org can depend on EVHardware as a binary instead of a submodule. A 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 version tag publishes the AAR to GitHub Packages (`com.evsuite:evhardware:<version>`), and
@@ -133,29 +161,6 @@ dependencies { implementation("com.evsuite:evhardware:0.1.0-SNAPSHOT") }
The AAR carries `consumer-rules.pro`, so a consumer's R8 keeps the reflected names. Vehicle 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. 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 ## Building
```bash ```bash
./gradlew :lib:testDebugUnitTest # unit tests + regenerates docs/firmware-matrix.md ./gradlew :lib:testDebugUnitTest # unit tests + regenerates docs/firmware-matrix.md
+20 -4
View File
@@ -24,7 +24,7 @@ Every condition a rule can test and every action it can run, straight out of `Co
| Call in progress | `IN_CALL` | on / off | any | | Call in progress | `IN_CALL` | on / off | any |
| Drive duration | `DRIVE_DURATION` | 0…600 min (=, <, >) | any | | Drive duration | `DRIVE_DURATION` | 0…600 min (=, <, >) | any |
| Random chance | `RANDOM_CHANCE` | 1…100 % | any | | Random chance | `RANDOM_CHANCE` | 1…100 % | any |
| Doors locked | `DOORS_LOCKED` | on / off | SWI68, SWI165 | | Doors locked | `DOORS_LOCKED` | on / off | SWI165 |
| Front door open | `FRONT_DOOR_OPEN` | on / off | SWI133 | | Front door open | `FRONT_DOOR_OPEN` | on / off | SWI133 |
| Data used today | `DATA_USED_TODAY` | 0…100000 MB (=, <, >) | any | | Data used today | `DATA_USED_TODAY` | 0…100000 MB (=, <, >) | any |
| Data used this month | `DATA_USED_MONTH` | 0…100000 MB (=, <, >) | any | | Data used this month | `DATA_USED_MONTH` | 0…100000 MB (=, <, >) | any |
@@ -72,7 +72,7 @@ Every condition a rule can test and every action it can run, straight out of `Co
| Air recirculation on | `RECIRC` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | | Air recirculation on | `RECIRC` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Fan speed | `FAN_SPEED` | 0…11 (=, <, >) | 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 | | Set temperature | `TEMPERATURE_SET` | 17…33 °C (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
| Passenger temperature | `PASSENGER_TEMP` | 17…33 °C (=, <, >) | SWI68, SWI165 | | Passenger temperature | `PASSENGER_TEMP` | 17…33 °C (=, <, >) | SWI165 |
| ECON mode on | `ECON_MODE` | on / off | SWI68, SWI165 | | ECON mode on | `ECON_MODE` | on / off | SWI68, SWI165 |
| Windscreen defroster on | `FRONT_DEFROST` | on / off | SWI68, SWI165 | | Windscreen defroster on | `FRONT_DEFROST` | on / off | SWI68, SWI165 |
| Rear window defroster on | `REAR_DEFROST` | on / off | SWI68, SWI165 | | Rear window defroster on | `REAR_DEFROST` | on / off | SWI68, SWI165 |
@@ -116,6 +116,14 @@ Every condition a rule can test and every action it can run, straight out of `Co
| Speed limit tone | `SPEED_LIMIT_TONE` | on / off | SWI133, SWI132 | | Speed limit tone | `SPEED_LIMIT_TONE` | on / off | SWI133, SWI132 |
| ADAS sound warning | `SOUND_WARNING` | on / off | SWI68, SWI165, SWI69, SWI131, SWI132 | | ADAS sound warning | `SOUND_WARNING` | on / off | SWI68, SWI165, SWI69, SWI131, SWI132 |
### Lights
| Condition | Entry | Tested against | Firmware |
|---|---|---|---|
| Low beams | `HEADLIGHTS` | Off, On, Daytime running | none |
| High beam on | `HIGH_BEAM_ON` | on / off | none |
| Fog lights on | `FOG_LIGHTS_ON` | on / off | none |
A reading the car does not answer makes the rule **not evaluable**: it does not fire, and it does not count as false. A reading the car does not answer makes the rule **not evaluable**: it does not fire, and it does not count as false.
## Actions ## Actions
@@ -151,7 +159,7 @@ A reading the car does not answer makes the rule **not evaluable**: it does not
|---|---|---|:--:|---| |---|---|---|:--:|---|
| Climate on/off | `SET_CLIMATE_POWER` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 | | 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 | | Cabin temperature | `SET_CABIN_TEMP` | 17…33 °C | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
| Passenger temperature | `SET_PASSENGER_TEMP` | 17…33 °C | — | SWI68, SWI165 | | Passenger temperature | `SET_PASSENGER_TEMP` | 17…33 °C | — | SWI165 |
| Air conditioning | `SET_AC` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 | | Air conditioning | `SET_AC` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
| ECON mode | `SET_ECON` | on / off | — | SWI68, SWI165 | | ECON mode | `SET_ECON` | on / off | — | SWI68, SWI165 |
| Climate AUTO | `SET_CLIMATE_AUTO` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 | | Climate AUTO | `SET_CLIMATE_AUTO` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
@@ -170,7 +178,7 @@ A reading the car does not answer makes the rule **not evaluable**: it does not
| Action | Entry | Asks for | When stopped only | Firmware | | Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---| |---|---|---|:--:|---|
| Charge limit | `SET_CHARGE_LIMIT` | 40…100 % | — | SWI68, SWI165 | | Charge limit ⚠️ | `SET_CHARGE_LIMIT` | 40…100 % | — | SWI68, SWI165 |
| Allow charging | `SET_CHARGING_ENABLED` | on / off | — | SWI68, SWI165 | | Allow charging | `SET_CHARGING_ENABLED` | on / off | — | SWI68, SWI165 |
| Scheduled charging | `SET_CHARGE_SCHEDULE` | 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 | | Charging window | `SET_CHARGE_WINDOW` | a start and an end time | — | SWI68, SWI165 |
@@ -217,6 +225,14 @@ A reading the car does not answer makes the rule **not evaluable**: it does not
| Lane departure sound warning | `SET_LAS_WARNING_SOUND` | on / off | — | SWI132 | | Lane departure sound warning | `SET_LAS_WARNING_SOUND` | on / off | — | SWI132 |
| Lane departure steering vibration | `SET_LAS_WARNING_VIBRATION` | on / off | — | SWI132 | | Lane departure steering vibration | `SET_LAS_WARNING_VIBRATION` | on / off | — | SWI132 |
### Lights
| Action | Entry | Asks for | When stopped only | Firmware |
|---|---|---|:--:|---|
| Low beams ⚠️ | `SET_HEADLIGHTS` | On, Automatic | yes | none |
| High beam ⚠️ | `SET_HIGH_BEAM` | Off, Automatic | yes | none |
| Fog lights ⚠️ | `SET_FOG_LIGHTS` | on / off | yes | none |
### System ### System
| Action | Entry | Asks for | When stopped only | Firmware | | Action | Entry | Asks for | When stopped only | Firmware |
+10 -4
View File
@@ -45,7 +45,7 @@
| RECIRC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | RECIRC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| FAN_SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | FAN_SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| TEMPERATURE_SET | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | TEMPERATURE_SET | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | PASSENGER_TEMP | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| ECON_MODE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | ECON_MODE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| FRONT_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | FRONT_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| REAR_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | REAR_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
@@ -55,12 +55,15 @@
| WINDOW_PASSENGER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | WINDOW_PASSENGER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| WINDOW_REAR_LEFT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | WINDOW_REAR_LEFT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| WINDOW_REAR_RIGHT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | WINDOW_REAR_RIGHT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| DOORS_LOCKED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | DOORS_LOCKED | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| FRONT_DOOR_OPEN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | FRONT_DOOR_OPEN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| HEADLIGHTS | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| HIGH_BEAM_ON | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| FOG_LIGHTS_ON | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| RADIO_PLAYING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | RADIO_PLAYING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -103,9 +106,12 @@
| SET_SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | SET_SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_HEADLIGHTS ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| SET_HIGH_BEAM ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| SET_FOG_LIGHTS ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| SET_CLIMATE_POWER | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | | SET_CLIMATE_POWER | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_CABIN_TEMP | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | | SET_CABIN_TEMP | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_PASSENGER_TEMP | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| SET_AC | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | | SET_AC | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
| SET_ECON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_ECON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CLIMATE_AUTO | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | | SET_CLIMATE_AUTO | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -119,7 +125,7 @@
| SET_WINDOW_REAR_LEFT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_WINDOW_REAR_LEFT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_WINDOW_REAR_RIGHT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_WINDOW_REAR_RIGHT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_DOOR_LOCK | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_DOOR_LOCK | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_LIMIT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_CHARGE_LIMIT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGING_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_CHARGING_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_SCHEDULE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_CHARGE_SCHEDULE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| SET_CHARGE_WINDOW | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | SET_CHARGE_WINDOW | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
@@ -143,12 +143,12 @@ object CarPropertyEvidence {
* between them, zero readings for either signal, and `EV_INSTANTANEOUS_CHARGE_RATE` and * between them, zero readings for either signal, and `EV_INSTANTANEOUS_CHARGE_RATE` and
* `EV_BATTERY_AVG_TEMP` answering `no CarPropertyValue` on every property probe. * `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 * Battery power left this list once the vendor pack voltage × current fallback published:
* power on this generation, because there is no battery power to integrate. An arrival * −32,9 to +32,4 kW on the urban drive of 2026-10-16, net agreeing with the charge drop.
* forecast on this car has to come from state of charge per kilometre instead. * The standard property is still silent, but a screen asking "is there battery power"
* now gets "not validated yet", which is the true answer, instead of "never".
*/ */
private val neverPublishedByFirmware: Map<Signal, Set<FirmwareInfo.Gen>> = mapOf( 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), Signal.BATTERY_TEMPERATURE_CELSIUS to setOf(FirmwareInfo.Gen.SWI68),
) )
} }
@@ -0,0 +1,153 @@
package com.evsuite.hardware
import java.lang.reflect.Method
/**
* The three settings the car exposes only in [com.evsuite.hardware.model.DriveMode.CUSTOM]:
* powertrain response, steering weight and brake-pedal feel (CR-040).
*
* Callers speak in an **index** — 0 Eco/Comfort, 1 Normal, 2 Sport — and never in vehicle
* codes. The codes differ per setting and per platform, and one of them is a trap: the pedal's
* Normal is `0`, not the middle value. The conversion lives in [code] / [index] and nowhere else.
*
* **Per generation.** VSM-based firmwares only, through the same VSM instance [VsmGlass] uses:
*
* • [Family.VSM] — SWI68 / SWI165, `VehicleSettingManager` `…Level` methods
* • [Family.A9] — SWI69 / SWI131 / SWI132, `CarVehicleSettingClient` `…Mode` methods
*
* SWI133 and unknown firmware answer [isAvailable] false. Their route is a raw property id, and
* no id for these settings is verified on this project — writing an unverified id is exactly
* what the library refuses to do. A method name is different in kind: if it is wrong it does
* not resolve, and the setting reports unavailable instead of writing somewhere else.
*
* **Evidence.** The VSM read/write codes (powertrain and steering 1/2/3, pedal 0/1/2) agree
* with RI-006. Which pedal code is Comfort and Sport, and the A9 powertrain offset (2/3/4,
* the drive-mode scale), are community leads (MG4Control 2.6.7, behaviour reference only) and
* stay unproven until an on-vehicle read-after-write confirms them per generation.
*/
object CustomDrive {
enum class Setting { POWER, STEERING, PEDAL }
enum class Family { VSM, A9 }
/** Positions a caller may ask for. */
val INDICES = 0..2
/** Pedal codes by index: Comfort 1, Normal **0**, Sport 2. */
private val PEDAL_CODES = intArrayOf(1, 0, 2)
fun familyOf(gen: FirmwareInfo.Gen): Family? = when (gen) {
FirmwareInfo.Gen.SWI68, FirmwareInfo.Gen.SWI165 -> Family.VSM
FirmwareInfo.Gen.SWI69, FirmwareInfo.Gen.SWI131, FirmwareInfo.Gen.SWI132 -> Family.A9
else -> null
}
/** Vehicle code for [index], or null when [index] is not a position. */
fun code(setting: Setting, index: Int, family: Family): Int? {
if (index !in INDICES) return null
return when (setting) {
Setting.POWER -> index + if (family == Family.A9) 2 else 1
Setting.STEERING -> index + 1
Setting.PEDAL -> PEDAL_CODES[index]
}
}
/** Index for a code read from the car, or null when the code is no known position. */
fun index(setting: Setting, code: Int, family: Family): Int? =
INDICES.firstOrNull { code(setting, it, family) == code }
fun methodName(setting: Setting, family: Family, write: Boolean): String {
val stem = when (family) {
Family.VSM -> when (setting) {
Setting.POWER -> "ElectricPowertrainLevel"
Setting.STEERING -> "SteeringLevel"
Setting.PEDAL -> "BrakePedalLevel"
}
Family.A9 -> when (setting) {
Setting.POWER -> "DrivingPowerTrainMode"
Setting.STEERING -> "SteeringMode"
Setting.PEDAL -> "BrakePedalMode"
}
}
return (if (write) "set" else "get") + stem
}
private val INT = Int::class.javaPrimitiveType!!
// Resolved once per VSM class, like VsmGlass: a profile apply reads and writes all three.
private var owner: Class<*>? = null
private val methods = HashMap<String, Method?>()
@Synchronized
private fun method(vsm: Any, name: String, vararg types: Class<*>): Method? {
if (owner !== vsm.javaClass) {
owner = vsm.javaClass
methods.clear()
}
return methods.getOrPut(name) {
try {
vsm.javaClass.getMethod(name, *types)
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name absent — ${e.javaClass.simpleName}")
null
}
}
}
private fun family(): Family? = familyOf(FirmwareInfo.getGeneration())
/** True when this firmware's VSM carries the setter for [setting]. */
fun isAvailable(setting: Setting): Boolean {
val family = family() ?: return false
val vsm = EVHardware.vsmInstance() ?: return false
return method(vsm, methodName(setting, family, write = true), INT) != null
}
/**
* The car's current position for [setting], or null when it cannot tell.
*
* Null covers an absent method, a failed call, and a code outside the scale — the last one
* is logged raw, because it is the evidence a wrong scale would leave behind.
*/
fun read(setting: Setting): Int? {
val family = family() ?: return null
val vsm = EVHardware.vsmInstance() ?: return null
val name = methodName(setting, family, write = false)
val get = method(vsm, name) ?: return null
val raw = try {
(get.invoke(vsm) as? Number)?.toInt()
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name() exc: ${e.message}")
null
} ?: return null
return index(setting, raw, family).also {
if (it == null && raw >= 0) AppLogger.w(TAG, " $name() = $raw — outside the $family scale")
}
}
/**
* Sets [setting] to position [index]. The car obeys only while it is in CUSTOM; the caller
* decides when that is (EVProfile shows the controls only then).
*/
@RequiresStandstill
fun write(setting: Setting, index: Int): Boolean {
val family = family() ?: return false
val value = code(setting, index, family) ?: return false
val vsm = EVHardware.vsmInstance() ?: return false
val name = methodName(setting, family, write = true)
val set = method(vsm, name, INT) ?: return false
// [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable.
if (!VehicleWriteGate.allow("VSM $name")) return false
return try {
set.invoke(vsm, value)
AppLogger.i(TAG, " $name($value) ← index $index ($family)")
true
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name($value) exc: ${e.message}")
false
}
}
private const val TAG = "EV_CUSTOM"
}
@@ -418,12 +418,15 @@ object EVHardware {
/** Total mileage, the vendor's own integer — `PERF_ODOMETER` never publishes here. */ /** Total mileage, the vendor's own integer — `PERF_ODOMETER` never publishes here. */
private const val PROP_VENDOR_TOTAL_MILEAGE = 0x21401566 private const val PROP_VENDOR_TOTAL_MILEAGE = 0x21401566
/** // Ids from MG4Control's SWI133 readings; unproven on SWI68. The plug and energy ids are
* Charging session state. The vehicle's own values, from the stock charging screen: // probe-only. PROP_BMS_CHRG_STS is also read by getVendorChargeStatus() — the charging session
* 1 = AC charging, 10 = DC charging, 5 = connecting, 7 = connected but not charging, // state, the vehicle's own values from the stock charging screen: 1 = AC charging,
* 8 = stopped. Anything else is not a session. // 10 = DC charging, 5 = connecting, 7 = connected but not charging, 8 = stopped. Anything
*/ // else is not a session.
private const val PROP_VENDOR_CHARGE_STATUS = 0x2140F409 private const val PROP_BMS_CHRG_STS = 0x2140F409 // vendor int
private const val PROP_CCU_ONBD_PLUG = 0x2140F43E // vendor int, AC socket
private const val PROP_CCU_OFFBD_PLUG = 0x2140F43F // vendor int, DC socket
private const val PROP_ENERGY_SINCE_CHARGE = 0x2160A1BB // vendor float kWh
/** /**
* `HVAC_AMBIENT_TEMPERATURE`, the one in-car candidate left on this firmware. * `HVAC_AMBIENT_TEMPERATURE`, the one in-car candidate left on this firmware.
@@ -479,6 +482,24 @@ object EVHardware {
?.takeIf { it.isFinite() && it >= 0f } ?.takeIf { it.isFinite() && it >= 0f }
} }
/**
* Probe-only vendor charge properties, read raw. MG4Control (a system-signed app) reads them
* on SWI133: `BMS_CHRG_STS` 1 while charging, the AC and DC plug states 0 off a charge and
* non-zero on the active socket, and a kWh counter that resets at plug-in. Whether this app
* may read them on SWI68, and what they say there, is what the capture is for.
*/
internal fun probeVendorChargeStatus(): Int? = probeVendorInt(PROP_BMS_CHRG_STS)
internal fun probeVendorPlugAc(): Int? = probeVendorInt(PROP_CCU_ONBD_PLUG)
internal fun probeVendorPlugDc(): Int? = probeVendorInt(PROP_CCU_OFFBD_PLUG)
internal fun probeVendorEnergySinceChargeKwh(): Float? = supportedTelemetryRead {
getFloatPropertyCPM(PROP_ENERGY_SINCE_CHARGE, AREA_GLOBAL)?.takeIf { it.isFinite() }
}
/** Null when unreadable, never the -1 [getIntPropertyCPM] stands in for a failure with. */
private fun probeVendorInt(propId: Int): Int? = supportedTelemetryRead {
readPropertyCPM(propId, AREA_GLOBAL, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int
}
fun getStandardRangeKm(): Float? = supportedTelemetryRead { fun getStandardRangeKm(): Float? = supportedTelemetryRead {
getFloatPropertyCPM(PROP_RANGE_REMAINING, AREA_GLOBAL) getFloatPropertyCPM(PROP_RANGE_REMAINING, AREA_GLOBAL)
?.takeIf { it.isFinite() && it >= 0f } ?.takeIf { it.isFinite() && it >= 0f }
@@ -509,10 +530,10 @@ object EVHardware {
* *
* The vendor property, read second: the charging service is the better source when it * The vendor property, read second: the charging service is the better source when it
* answers, and this is what is left on a unit where it does not. * answers, and this is what is left on a unit where it does not.
* See [PROP_VENDOR_CHARGE_STATUS] for the values. * See [PROP_BMS_CHRG_STS] for the values.
*/ */
fun getVendorChargeStatus(): Int? = fun getVendorChargeStatus(): Int? =
getIntPropertyCPM(PROP_VENDOR_CHARGE_STATUS, AREA_GLOBAL).takeIf { it >= 0 } getIntPropertyCPM(PROP_BMS_CHRG_STS, AREA_GLOBAL).takeIf { it >= 0 }
fun getCabinTemperatureCelsius(): Float? = supportedTelemetryRead { fun getCabinTemperatureCelsius(): Float? = supportedTelemetryRead {
getFloatPropertyCPM(PROP_CABIN_TEMP, AREA_HVAC) getFloatPropertyCPM(PROP_CABIN_TEMP, AREA_HVAC)
@@ -642,6 +663,15 @@ object EVHardware {
probeProperty("HVAC_AMBIENT_TEMPERATURE", PROP_HVAC_AMBIENT_TEMP_SWI68, AREA_GLOBAL, Float::class.javaObjectType), probeProperty("HVAC_AMBIENT_TEMPERATURE", PROP_HVAC_AMBIENT_TEMP_SWI68, AREA_GLOBAL, Float::class.javaObjectType),
) )
/**
* The six exterior-light properties (CR-046), read raw whatever [ExteriorLights.PROVEN] says:
* this is the evidence that fills it. EVTasker's Diagnostic shows and exports it — read once
* with a light off and once with it on; a generation is proven when the state follows.
*/
fun probeExteriorLights(): List<PropertyReport> = ExteriorLights.NAMES.map { (id, name) ->
probeProperty(name, id, AREA_GLOBAL, Int::class.javaObjectType)
}
private fun probeProperty(name: String, propId: Int, areaId: Int, boxed: Class<*>): PropertyReport { private fun probeProperty(name: String, propId: Int, areaId: Int, boxed: Class<*>): PropertyReport {
val cpm = sCarPropertyManager val cpm = sCarPropertyManager
?: return PropertyReport(name, propId, areaId, "CarPropertyManager not connected") ?: return PropertyReport(name, propId, areaId, "CarPropertyManager not connected")
@@ -661,6 +691,118 @@ object EVHardware {
} }
} }
/**
* The vendor setting objects this library already holds, by the name a report prints.
* Probe use only ([com.evsuite.hardware.probe.VendorSurfaceProbe]); no production path
* reads this, and the probe calls nothing on them but value getters.
*/
internal fun vendorSettingHandles(): Map<String, Any> = listOfNotNull(
sVsm?.let { "VehicleSetting" to it }, // SWI68/165 manager, A9 CarVehicleSettingClient
sVsm133?.let { "VehicleSetting133" to it },
sVcontrol?.let { "VehicleControl" to it },
sGeneral?.let { "General" to it },
sCarGeneral?.let { "CarGeneral" to it },
sCarState?.let { "CarState" to it },
).toMap()
/**
* Every property the CarPropertyManager lists, read once per area, keyed `CPM 0x…/area`.
* Probe use only. Stops at [deadlineMs] and says so rather than blocking a diagnostic on a
* VHAL that answers slowly.
*/
internal fun readAllCarProperties(deadlineMs: Long): Map<String, String> {
val cpm = sCarPropertyManager ?: return mapOf("CPM" to "not connected")
cacheCarPropertyMethods(cpm)
val getProperty = sCpmGetPropertyMethod ?: return mapOf("CPM" to "no getProperty")
val configs = try {
cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*>
} catch (e: Exception) {
return mapOf("CPM" to "getPropertyList: ${e.readFailureReason()}")
} ?: return mapOf("CPM" to "getPropertyList: null")
val out = sortedMapOf<String, String>()
for (config in configs) {
if (config == null) continue
if (System.currentTimeMillis() > deadlineMs) {
out["CPM ~"] = "time budget spent after ${out.size} values"
break
}
val type = config.javaClass
val id = runCatching { type.getMethod("getPropertyId").invoke(config) as Int }.getOrNull() ?: continue
val valueType = runCatching { type.getMethod("getPropertyType").invoke(config) as Class<*> }
.getOrNull() ?: Any::class.java
val areas = runCatching { type.getMethod("getAreaIds").invoke(config) as IntArray }
.getOrNull()?.takeIf { it.isNotEmpty() } ?: intArrayOf(AREA_GLOBAL)
for (area in areas) {
val key = "CPM 0x%08X/%d".format(id, area)
out[key] = try {
val holder = getProperty.invoke(cpm, valueType, id, area)
if (holder == null) "no value" else {
val status = (holder.javaClass.getMethod("getStatus").invoke(holder) as? Int)
?.let(CarPropertyEvidence::describe) ?: "no status"
"$status ${com.evsuite.hardware.probe.VendorSurfaceRules.render(
holder.javaClass.getMethod("getValue").invoke(holder))}"
}
} catch (e: Exception) {
e.readFailureReason()
}
}
}
return out
}
// -------------------------------------------------------------------------
// Exterior lights (CR-046) — standard AAOS ids and policy in [ExteriorLights]
// -------------------------------------------------------------------------
/** Status-checked read, only on a proven generation: an unpublished 0 would read as OFF. */
private fun lightRead(propId: Int): Int? =
if (ExteriorLights.isProven(FirmwareInfo.getGeneration()))
readPropertyCPM(propId, AREA_GLOBAL, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int
else null
/** Low beams as VehicleLightState (0 off, 1 on, 2 daytime running); null when unreadable. */
fun headlightsStateOrNull(): Int? = lightRead(ExteriorLights.PROP_HEADLIGHTS_STATE)
fun highBeamOnOrNull(): Boolean? =
ExteriorLights.isOn(lightRead(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_STATE))
fun fogLightsOnOrNull(): Boolean? =
ExteriorLights.isOn(lightRead(ExteriorLights.PROP_FOG_LIGHTS_STATE))
/** Switch positions, for the action editor's current value. */
fun headlightsSwitchOrNull(): Int? = lightRead(ExteriorLights.PROP_HEADLIGHTS_SWITCH)
fun highBeamSwitchOrNull(): Int? = lightRead(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_SWITCH)
fun fogLightsSwitchOnOrNull(): Boolean? =
lightRead(ExteriorLights.PROP_FOG_LIGHTS_SWITCH)?.let { it == ExteriorLights.SWITCH_ON }
/** Low beams ON or AUTOMATIC — never OFF ([ExteriorLights.HEADLIGHT_SWITCH_ALLOWED]). */
@RequiresStandstill
fun setHeadlightsSwitch(mode: Int): Boolean =
lightWrite(ExteriorLights.PROP_HEADLIGHTS_SWITCH, mode, ExteriorLights.HEADLIGHT_SWITCH_ALLOWED)
/** High beam OFF or AUTOMATIC — never forced ON. */
@RequiresStandstill
fun setHighBeamSwitch(mode: Int): Boolean =
lightWrite(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_SWITCH, mode, ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED)
@RequiresStandstill
fun setFogLights(on: Boolean): Boolean = lightWrite(
ExteriorLights.PROP_FOG_LIGHTS_SWITCH,
if (on) ExteriorLights.SWITCH_ON else ExteriorLights.SWITCH_OFF,
ExteriorLights.FOG_SWITCH_ALLOWED,
)
/** Policy, then proven generation, then the 0 km/h gate inside [setIntPropertyCPM]. */
private fun lightWrite(propId: Int, value: Int, allowed: Set<Int>): Boolean {
ExteriorLights.writeRefusal(FirmwareInfo.getGeneration(), allowed, value)?.let {
AppLogger.w(TAG, " light 0x${Integer.toHexString(propId)} refused: $it")
return false
}
return setIntPropertyCPM(propId, AREA_GLOBAL, value)
}
private inline fun <T> supportedTelemetryRead(read: () -> T?): T? = private inline fun <T> supportedTelemetryRead(read: () -> T?): T? =
when (FirmwareInfo.getGeneration()) { when (FirmwareInfo.getGeneration()) {
FirmwareInfo.Gen.SWI68, FirmwareInfo.Gen.SWI68,
@@ -1439,7 +1581,7 @@ object EVHardware {
return try { return try {
val native = (g.javaClass.getMethod("getBrightness").invoke(g) as? Int) ?: return -1 val native = (g.javaClass.getMethod("getBrightness").invoke(g) as? Int) ?: return -1
if (native < 0) return -1 if (native < 0) return -1
val pct = (native * 100 / BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100) val pct = ((native * 100 + BRIGHTNESS_NATIVE_MAX / 2) / BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
AppLogger.d(TAG, " getBrightness native=$native → $pct%") AppLogger.d(TAG, " getBrightness native=$native → $pct%")
pct pct
} catch (e: Exception) { } catch (e: Exception) {
@@ -1450,7 +1592,7 @@ object EVHardware {
private fun setBrightnessOldSdk(clampedPct: Int): Boolean { private fun setBrightnessOldSdk(clampedPct: Int): Boolean {
val g = sGeneral ?: return false val g = sGeneral ?: return false
val native = (clampedPct * BRIGHTNESS_NATIVE_MAX / 100).coerceIn(0, BRIGHTNESS_NATIVE_MAX) val native = ((clampedPct * BRIGHTNESS_NATIVE_MAX + 50) / 100).coerceIn(0, BRIGHTNESS_NATIVE_MAX)
if (logEnabled) AppLogger.i(TAG, "setBrightness → $clampedPct% (native=$native/255)") if (logEnabled) AppLogger.i(TAG, "setBrightness → $clampedPct% (native=$native/255)")
return try { return try {
g.javaClass.getMethod("setBrightness", Int::class.javaPrimitiveType).invoke(g, native) g.javaClass.getMethod("setBrightness", Int::class.javaPrimitiveType).invoke(g, native)
@@ -1477,12 +1619,12 @@ object EVHardware {
AppLogger.w(TAG, " A9 getBrightness Settings.System exc: ${e.message}"); return -1 AppLogger.w(TAG, " A9 getBrightness Settings.System exc: ${e.message}"); return -1
} }
if (native < 0) return -1 if (native < 0) return -1
return (native * 100 / A9_BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100) return ((native * 100 + A9_BRIGHTNESS_NATIVE_MAX / 2) / A9_BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
} }
private fun setBrightnessA9(clampedPct: Int): Boolean { private fun setBrightnessA9(clampedPct: Int): Boolean {
val resolver = sAppContext?.contentResolver ?: return false val resolver = sAppContext?.contentResolver ?: return false
val native = (clampedPct.coerceIn(0, 100) * A9_BRIGHTNESS_NATIVE_MAX / 100).coerceIn(1, A9_BRIGHTNESS_NATIVE_MAX) val native = ((clampedPct.coerceIn(0, 100) * A9_BRIGHTNESS_NATIVE_MAX + 50) / 100).coerceIn(1, A9_BRIGHTNESS_NATIVE_MAX)
return try { return try {
// Manual mode, otherwise auto-brightness overwrites the value at once // Manual mode, otherwise auto-brightness overwrites the value at once
android.provider.Settings.System.putInt(resolver, android.provider.Settings.System.putInt(resolver,
@@ -2329,23 +2471,22 @@ object EVHardware {
fun getSeatHeatRight(): Int = seatHeatRightOrNull() ?: 0 fun getSeatHeatRight(): Int = seatHeatRightOrNull() ?: 0
fun isSteeringHeatOn(): Boolean = steeringHeatOnOrNull() ?: false fun isSteeringHeatOn(): Boolean = steeringHeatOnOrNull() ?: false
fun getDriveMode(): DriveMode? { fun getDriveMode(): DriveMode? = getDriveModeRaw()?.let(DriveMode::fromValue)
val cpm = sCarPropertyManager ?: return null
return try {
val raw = (cpm.javaClass
.getMethod("getIntProperty", Int::class.java, Int::class.java)
.invoke(cpm, PROP_DRIVE_MODE, AREA_GLOBAL) as? Int) ?: return null
DriveMode.fromValue(raw)
} catch (_: Exception) { null }
}
fun getRegenLevel(): RegenLevel? { /** The drive mode code as the car sends it; null when it could not be read. */
fun getDriveModeRaw(): Int? = readIntGlobal(PROP_DRIVE_MODE)
fun getRegenLevel(): RegenLevel? = getRegenLevelRaw()?.let(RegenLevel::fromValue)
/** The regen code as the car sends it: 6 is one-pedal on SWI68 (EVTasker CR-045 diff). */
fun getRegenLevelRaw(): Int? = readIntGlobal(PROP_REGEN_LEVEL)
private fun readIntGlobal(prop: Int): Int? {
val cpm = sCarPropertyManager ?: return null val cpm = sCarPropertyManager ?: return null
return try { return try {
val raw = (cpm.javaClass cpm.javaClass
.getMethod("getIntProperty", Int::class.java, Int::class.java) .getMethod("getIntProperty", Int::class.java, Int::class.java)
.invoke(cpm, PROP_REGEN_LEVEL, AREA_GLOBAL) as? Int) ?: return null .invoke(cpm, prop, AREA_GLOBAL) as? Int
RegenLevel.fromValue(raw)
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
@@ -2464,8 +2605,9 @@ object EVHardware {
fun getAccTjaMode(): Int { fun getAccTjaMode(): Int {
val useNewApi = FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 val useNewApi = FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132
val method = if (useNewApi) "getAccTjaState" else "getAccTjaMode" val method = if (useNewApi) "getAccTjaState" else "getAccTjaMode"
if (logEnabled) AppLogger.i(TAG, "$method →") val value = (callVsm(method) as? Int) ?: -1
return (callVsm(method) as? Int) ?: -1 if (logEnabled) AppLogger.i(TAG, "$method → $value")
return value
} }
/** SWI68/SWI165 : setAccTjaMode(I)V SWI69/SWI131/SWI132 : setAccTjaState(I)V — void */ /** SWI68/SWI165 : setAccTjaMode(I)V SWI69/SWI131/SWI132 : setAccTjaState(I)V — void */
@@ -2491,8 +2633,9 @@ object EVHardware {
/** Lit le mode du limiteur de speed. Returns 0/2/3, ou -1 si unavailable. */ /** Lit le mode du limiteur de speed. Returns 0/2/3, ou -1 si unavailable. */
fun getSpeedLimiterMode(): Int { fun getSpeedLimiterMode(): Int {
val method = if (useSasApi()) "getSasMode" else "getSpeedAsstMode" val method = if (useSasApi()) "getSasMode" else "getSpeedAsstMode"
if (logEnabled) AppLogger.i(TAG, "$method →") val value = (callVsm(method) as? Int) ?: -1
return (callVsm(method) as? Int) ?: -1 if (logEnabled) AppLogger.i(TAG, "$method → $value")
return value
} }
/** Configure le mode du limiteur de speed. 0=Disabled, 2=Manual, 3=Intelligent. */ /** Configure le mode du limiteur de speed. 0=Disabled, 2=Manual, 3=Intelligent. */
@@ -4316,6 +4459,30 @@ object EVHardware {
return if (answered) open else null return if (answered) open else null
} }
/**
* Front-door evidence for a firmware the door feature does not claim yet, for EVTasker's
* Diagnostic.
*
* MG4Control reads DLOCK_DOOR_OPEN_STS on SWI68/69/131/165 through the change
* subscription, because the on-demand read throws there. Nothing here has seen a car
* confirm that, so [frontDoorOpenOrNull] stays gated. This connects and subscribes
* regardless of [hasDoorVolumeFeature] — the first call starts the connection, which is
* asynchronous — and reports what came back: export with the front doors shut, open
* one, export again. Proven when the event value follows the door.
*/
fun probeFrontDoors(): String {
connectCarProperty()
registerDoorCallback()
val reads = DOOR_FRONT_AREAS.joinToString(" ") { area ->
"read[0x${area.toString(16)}]=${readDoorOpen(area) ?: "?"}"
}
val events = synchronized(this) {
DOOR_FRONT_AREAS.joinToString(" ") { area -> "event[0x${area.toString(16)}]=${sDoorReadLast[area] ?: "?"}" }
}
return "managers property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null} " +
"subscribed property=$sDoorSubProperty doorlock=$sDoorSubDoorlock $reads $events"
}
/** Diagnostic button probe: logs the volume + door states at click time. */ /** Diagnostic button probe: logs the volume + door states at click time. */
fun runDoorVolumeDiag() { fun runDoorVolumeDiag() {
AppLogger.i(VOL_TAG, "── DIAG (bouton Diagnostic) ──") AppLogger.i(VOL_TAG, "── DIAG (bouton Diagnostic) ──")
@@ -0,0 +1,79 @@
package com.evsuite.hardware
/**
* Exterior lights (CR-046): the standard AAOS ids, their values, and what a rule may do with them.
*
* Kept free of Android so the policy is pinned by JVM tests. The ids are the public
* `VehicleProperty` ones (types.hal 2.0, available on AAOS 9); nothing here comes from a vendor
* catalogue. Whether the MG4 VHAL publishes them is a per-generation question: a generation joins
* [PROVEN] only with a capture showing the state follow the stalk — EVTasker's Diagnostic
* `EXTERIOR_LIGHTS` row ([EVHardware.probeExteriorLights]), exported off and on — in the same commit as the
* `@SupportedOn` of the light entries and their `writeProven`.
*
* Owner decisions (2026-10-08): writes only at 0 km/h like every vehicle write, and a rule may
* never switch the low beams OFF. Same reasoning for the high beam: a rule may set it OFF or
* AUTOMATIC, never force it ON — that dazzles, and the stalk stays the driver's.
*/
object ExteriorLights {
// VehicleProperty — SYSTEM | GLOBAL | INT32 | 0x0E0x.
const val PROP_HEADLIGHTS_STATE = 0x11400E00
const val PROP_HIGH_BEAM_LIGHTS_STATE = 0x11400E01
const val PROP_FOG_LIGHTS_STATE = 0x11400E02
const val PROP_HEADLIGHTS_SWITCH = 0x11400E10
const val PROP_HIGH_BEAM_LIGHTS_SWITCH = 0x11400E11
const val PROP_FOG_LIGHTS_SWITCH = 0x11400E12
// VehicleLightState
const val STATE_OFF = 0
const val STATE_ON = 1
const val STATE_DAYTIME_RUNNING = 2
// VehicleLightSwitch
const val SWITCH_OFF = 0
const val SWITCH_ON = 1
const val SWITCH_DAYTIME_RUNNING = 2
const val SWITCH_AUTOMATIC = 0x100
/** Generations where the ids above are proven on the car. Empty until a capture says so. */
val PROVEN: Set<FirmwareInfo.Gen> = emptySet()
/** Names for the capture diff, so a changed light property reads as itself. */
val NAMES: Map<Int, String> = mapOf(
PROP_HEADLIGHTS_STATE to "HEADLIGHTS_STATE",
PROP_HIGH_BEAM_LIGHTS_STATE to "HIGH_BEAM_LIGHTS_STATE",
PROP_FOG_LIGHTS_STATE to "FOG_LIGHTS_STATE",
PROP_HEADLIGHTS_SWITCH to "HEADLIGHTS_SWITCH",
PROP_HIGH_BEAM_LIGHTS_SWITCH to "HIGH_BEAM_LIGHTS_SWITCH",
PROP_FOG_LIGHTS_SWITCH to "FOG_LIGHTS_SWITCH",
)
/** Low beams: ON or AUTOMATIC. Never OFF. */
val HEADLIGHT_SWITCH_ALLOWED = setOf(SWITCH_ON, SWITCH_AUTOMATIC)
/** High beam: OFF or AUTOMATIC. Never forced ON. */
val HIGH_BEAM_SWITCH_ALLOWED = setOf(SWITCH_OFF, SWITCH_AUTOMATIC)
/** Fog lights: on or off. */
val FOG_SWITCH_ALLOWED = setOf(SWITCH_OFF, SWITCH_ON)
fun isProven(gen: FirmwareInfo.Gen): Boolean = gen in PROVEN
/**
* Why a light write must not be sent, or null when it may go on to the speed gate.
* Policy first, so a forbidden value is refused on every car, proven or not.
*/
fun writeRefusal(
gen: FirmwareInfo.Gen,
allowed: Set<Int>,
value: Int,
proven: Set<FirmwareInfo.Gen> = PROVEN,
): String? = when {
value !in allowed -> "value 0x${Integer.toHexString(value)} not allowed for a rule"
gen !in proven -> "exterior lights not proven on $gen"
else -> null
}
/** A light state read as "on"; daytime running lights are not headlights on. */
fun isOn(state: Int?): Boolean? = state?.let { it == STATE_ON }
}
@@ -3,20 +3,44 @@ package com.evsuite.hardware
import com.evsuite.hardware.catalog.SnapshotKeys import com.evsuite.hardware.catalog.SnapshotKeys
/** /**
* Decodes `com.saic.keyevent.hardkey.report` payloads. * Decodes the head unit's two hard-key broadcasts.
* *
* Codes 5/23/24/25/87/88/164/286/287 come from R69 EOL's * They number their keys in two different spaces, so a code only means something together
* PhysicalKeysBroadcastReceiver; 287 is also named VOICE_KEY_CODE by VrSpeechService. * with the broadcast it came on ([Source]):
* Codes 17 and 18 are firmware aliases already observed by EVProfile. *
* - `com.saic.keyevent.hardkey.report` carries Android key codes. 5/23/24/25/87/88/164/286/287
* come from R69 EOL's PhysicalKeysBroadcastReceiver; 287 is also VrSpeechService's
* VOICE_KEY_CODE. 17 and 18 are firmware aliases already observed by EVProfile.
* - `com.android.systemui.ACTION_HARD_KEY_EVENT` carries SAIC's own numbering: 1/2 volume,
* 3 mute, 4/5 previous/next (mediaservice HardKeyMonitor, radioservice ReceiveUtils),
* 6–10 the instrument-cluster pad (engineermodeservice KeyEventReceiver, `_IPK`), 16 phone
* (btcallservice).
*
* The same physical press arrives on both. Read as one space, SystemUI's 5 (next track) was
* decoded as the report's 5 (phone), and a phone press arriving twice read as a double.
* So SystemUI keys the report already carries are dropped, and only the cluster pad — which
* the report does not carry — is read from SystemUI.
*
* A key neither list names is still reported, as an unknown key with its own [Event.keyId],
* so a driver can bind a button this table has not met yet.
*/ */
class PhysicalButtonEventDecoder { class PhysicalButtonEventDecoder {
enum class Button(val codes: Set<Int>, val shortKey: String, val longKey: String) { enum class Source { HARDKEY_REPORT, SYSTEM_UI }
PHONE(setOf(5, 16), SnapshotKeys.KEY_PHONE_SHORT, SnapshotKeys.KEY_PHONE_LONG),
UP(setOf(6), SnapshotKeys.KEY_UP_SHORT, SnapshotKeys.KEY_UP_LONG), enum class Button(
DOWN(setOf(7), SnapshotKeys.KEY_DOWN_SHORT, SnapshotKeys.KEY_DOWN_LONG), /** On the report broadcast. */
OK(setOf(8), SnapshotKeys.KEY_OK_SHORT, SnapshotKeys.KEY_OK_LONG), val codes: Set<Int>,
LEFT(setOf(9), SnapshotKeys.KEY_LEFT_SHORT, SnapshotKeys.KEY_LEFT_LONG), val shortKey: String,
RIGHT(setOf(10), SnapshotKeys.KEY_RIGHT_SHORT, SnapshotKeys.KEY_RIGHT_LONG), val longKey: String,
/** On the SystemUI broadcast, for keys the report does not carry. */
val systemUiCodes: Set<Int> = emptySet(),
) {
PHONE(setOf(5), SnapshotKeys.KEY_PHONE_SHORT, SnapshotKeys.KEY_PHONE_LONG),
UP(emptySet(), SnapshotKeys.KEY_UP_SHORT, SnapshotKeys.KEY_UP_LONG, setOf(6)),
DOWN(emptySet(), SnapshotKeys.KEY_DOWN_SHORT, SnapshotKeys.KEY_DOWN_LONG, setOf(7)),
OK(emptySet(), SnapshotKeys.KEY_OK_SHORT, SnapshotKeys.KEY_OK_LONG, setOf(8)),
LEFT(emptySet(), SnapshotKeys.KEY_LEFT_SHORT, SnapshotKeys.KEY_LEFT_LONG, setOf(9)),
RIGHT(emptySet(), SnapshotKeys.KEY_RIGHT_SHORT, SnapshotKeys.KEY_RIGHT_LONG, setOf(10)),
SOURCE(setOf(110), SnapshotKeys.KEY_SOURCE_SHORT, SnapshotKeys.KEY_SOURCE_LONG), SOURCE(setOf(110), SnapshotKeys.KEY_SOURCE_SHORT, SnapshotKeys.KEY_SOURCE_LONG),
CENTER(setOf(23), SnapshotKeys.KEY_CENTER_SHORT, SnapshotKeys.KEY_CENTER_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_UP(setOf(24), SnapshotKeys.KEY_VOLUME_UP_SHORT, SnapshotKeys.KEY_VOLUME_UP_LONG),
@@ -26,15 +50,28 @@ class PhysicalButtonEventDecoder {
MUTE(setOf(164), SnapshotKeys.KEY_MUTE_SHORT, SnapshotKeys.KEY_MUTE_LONG), 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_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), STAR_RIGHT(setOf(286, 18), SnapshotKeys.KEY_STAR_RIGHT_SHORT, SnapshotKeys.KEY_STAR_RIGHT_LONG),
ASSISTANT(setOf(287), SnapshotKeys.KEY_ASSISTANT_SHORT, SnapshotKeys.KEY_ASSISTANT_LONG) ASSISTANT(setOf(287), SnapshotKeys.KEY_ASSISTANT_SHORT, SnapshotKeys.KEY_ASSISTANT_LONG);
/** The number a rule stores for this button — the first code, as rules always stored. */
val id: Int get() = (codes + systemUiCodes).first()
companion object {
fun byId(id: Int): Button? = entries.firstOrNull { it.id == id }
}
} }
enum class Press { SHORT, LONG, DOUBLE } enum class Press { SHORT, LONG, DOUBLE }
data class Event(val button: Button, val press: Press) {
val value: String get() = "${button.name}:${press.name}" /**
* One decoded press. [keyId] is [Button.id] for a known button, and an [unknownKeyId]
* otherwise.
*/
data class Event(val keyId: Int, val press: Press) {
val button: Button? get() = Button.byId(keyId)
val value: String get() = "${keyName(keyId)}:${press.name}"
fun readings(): Map<String, Any> = mapOf(SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT to value) fun readings(): Map<String, Any> = mapOf(SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT to value)
} }
private enum class State { DOWN, LONG_REPORTED } private enum class State { DOWN, LONG_REPORTED }
private val states = mutableMapOf<Button, State>() private val states = mutableMapOf<Int, State>()
/** /**
* How long a second press has to arrive within to count as a double. * How long a second press has to arrive within to count as a double.
@@ -42,7 +79,7 @@ class PhysicalButtonEventDecoder {
* The stock steering controls use the same window, and it is short on purpose: longer * 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. * would make two deliberate single presses merge into one double.
*/ */
private val lastShortAt = mutableMapOf<Button, Long>() private val lastShortAt = mutableMapOf<Int, Long>()
/** /**
* @param atMillis when the event happened. A monotonic default, and JVM-only so the * @param atMillis when the event happened. A monotonic default, and JVM-only so the
@@ -53,27 +90,28 @@ class PhysicalButtonEventDecoder {
down: Boolean, down: Boolean,
longPress: Boolean, longPress: Boolean,
atMillis: Long = System.nanoTime() / 1_000_000, atMillis: Long = System.nanoTime() / 1_000_000,
source: Source = Source.HARDKEY_REPORT,
): Event? { ): Event? {
val button = Button.entries.firstOrNull { keyCode in it.codes } ?: return null val key = keyId(source, keyCode) ?: return null
return when { return when {
down && longPress && states[button] != State.LONG_REPORTED -> { down && longPress && states[key] != State.LONG_REPORTED -> {
states[button] = State.LONG_REPORTED states[key] = State.LONG_REPORTED
// A long press ends the pairing: the release that follows must not become the // A long press ends the pairing: the release that follows must not become the
// second half of a double the driver never made. // second half of a double the driver never made.
lastShortAt.remove(button) lastShortAt.remove(key)
Event(button, Press.LONG) Event(key, Press.LONG)
} }
down -> { states.putIfAbsent(button, State.DOWN); null } down -> { states.putIfAbsent(key, State.DOWN); null }
states.remove(button) == State.DOWN -> { states.remove(key) == State.DOWN -> {
val previous = lastShortAt[button] val previous = lastShortAt[key]
if (previous != null && atMillis - previous <= DOUBLE_TAP_MS) { if (previous != null && atMillis - previous <= DOUBLE_TAP_MS) {
// Consumed, so three presses read as one double and one single rather than // Consumed, so three presses read as one double and one single rather than
// as two overlapping doubles. // as two overlapping doubles.
lastShortAt.remove(button) lastShortAt.remove(key)
Event(button, Press.DOUBLE) Event(key, Press.DOUBLE)
} else { } else {
lastShortAt[button] = atMillis lastShortAt[key] = atMillis
Event(button, Press.SHORT) Event(key, Press.SHORT)
} }
} }
else -> null else -> null
@@ -91,5 +129,37 @@ class PhysicalButtonEventDecoder {
* double — see EVTasker's vehicle service. * double — see EVTasker's vehicle service.
*/ */
const val DOUBLE_TAP_MS = 300L const val DOUBLE_TAP_MS = 300L
/** SystemUI codes for keys the report broadcast already carries — see the class doc. */
private val SYSTEM_UI_DUPLICATES = setOf(1, 2, 3, 4, 5, 16)
private const val UNKNOWN_REPORT_BASE = 10_000
private const val UNKNOWN_SYSTEM_UI_BASE = 20_000
/** Kept apart from every [Button.id] and from the other broadcast's codes. */
fun unknownKeyId(source: Source, keyCode: Int): Int = keyCode + when (source) {
Source.HARDKEY_REPORT -> UNKNOWN_REPORT_BASE
Source.SYSTEM_UI -> UNKNOWN_SYSTEM_UI_BASE
}
/** Where an unknown key id came from, as `(source, code)` — null for a known button. */
fun unknownKey(keyId: Int): Pair<Source, Int>? = when {
Button.byId(keyId) != null -> null
keyId >= UNKNOWN_SYSTEM_UI_BASE -> Source.SYSTEM_UI to keyId - UNKNOWN_SYSTEM_UI_BASE
keyId >= UNKNOWN_REPORT_BASE -> Source.HARDKEY_REPORT to keyId - UNKNOWN_REPORT_BASE
else -> null
}
fun keyName(keyId: Int): String = Button.byId(keyId)?.name ?: "KEY_$keyId"
private fun keyId(source: Source, keyCode: Int): Int? {
if (keyCode < 0) return null
return when (source) {
Source.HARDKEY_REPORT -> Button.entries.firstOrNull { keyCode in it.codes }?.id
Source.SYSTEM_UI -> {
if (keyCode in SYSTEM_UI_DUPLICATES) return null
Button.entries.firstOrNull { keyCode in it.systemUiCodes }?.id
}
} ?: unknownKeyId(source, keyCode)
}
} }
} }
@@ -22,6 +22,7 @@ enum class ActionGroup(@StringRes val labelRes: Int) {
ENERGY(R.string.group_energy), ENERGY(R.string.group_energy),
AUDIO(R.string.group_audio), AUDIO(R.string.group_audio),
ADAS(R.string.group_adas), ADAS(R.string.group_adas),
LIGHTS(R.string.group_lights),
SYSTEM(R.string.group_system) SYSTEM(R.string.group_system)
} }
@@ -165,6 +166,32 @@ enum class ActionType(
"SET_SCREEN_BRIGHTNESS", currentKey = SnapshotKeys.KEY_BRIGHTNESS "SET_SCREEN_BRIGHTNESS", currentKey = SnapshotKeys.KEY_BRIGHTNESS
), ),
// ── Exterior lights (CR-046, gated) ──────────────────────────────────────
// Owner decisions: 0 km/h only, never low beams OFF, never high beam forced ON — the
// options below cannot express either, and ExteriorLights refuses them again at the write.
// Unproven everywhere: empty @SupportedOn and writeProven = false until a capture and an
// observed effect prove a generation.
@SupportedOn
SET_HEADLIGHTS(
R.string.act_headlights, ActionGroup.LIGHTS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HEADLIGHT_SWITCH_CHOICES),
"SET_HEADLIGHTS", gated = true, currentKey = SnapshotKeys.KEY_HEADLIGHTS_SWITCH,
writeProven = false
),
@SupportedOn
SET_HIGH_BEAM(
R.string.act_high_beam, ActionGroup.LIGHTS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HIGH_BEAM_SWITCH_CHOICES),
"SET_HIGH_BEAM", gated = true, currentKey = SnapshotKeys.KEY_HIGH_BEAM_SWITCH,
writeProven = false
),
@SupportedOn
SET_FOG_LIGHTS(
R.string.act_fog_lights, ActionGroup.LIGHTS,
ValueSpec.BOOL, "SET_FOG_LIGHTS", gated = true,
currentKey = SnapshotKeys.KEY_FOG_LIGHTS_SWITCH, writeProven = false
),
// ── Climate (vendor service — com.evsuite.hardware.saic.SaicClimate) ───────── // ── 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. // 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 // Not gated: changing the cabin temperature does not alter road behaviour, and a rule
@@ -191,8 +218,13 @@ enum class ActionType(
* A second action rather than a zone argument on the first: the driver's target is the * 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 * one every existing rule sets, and giving it a zone would have made every saved rule
* carry a value it never chose. * carry a value it never chose.
*
* Not SWI68: the passenger target reads -1 there, the converter's answer for a zone
* the car does not have, so nothing shows the write lands anywhere. EVTasker's
* Diagnostic prints that raw read (`CLIMATE_ZONES`); a SWI68 that answers a real
* target there is what would bring the action back.
*/ */
@SupportedOn(SWI68, SWI165) @SupportedOn(SWI165)
SET_PASSENGER_TEMP( SET_PASSENGER_TEMP(
R.string.act_passenger_temp, ActionGroup.CLIMATE, R.string.act_passenger_temp, ActionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius), number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
@@ -296,11 +328,15 @@ enum class ActionType(
), ),
// ── Energy (vendor service — com.evsuite.hardware.saic.SaicCharging) ───────── // ── Energy (vendor service — com.evsuite.hardware.saic.SaicCharging) ─────────
// Unproven: the write sends a percent, but SWI68 reads the limit back as `5` (2026-10-13),
// a code from a table nobody has read. Writing 80 into a coded field is a guess, so the
// action is held back until one write is seen to set the limit shown on the car's screen.
@SupportedOn(SWI68, SWI165) @SupportedOn(SWI68, SWI165)
SET_CHARGE_LIMIT( SET_CHARGE_LIMIT(
R.string.act_charge_limit, ActionGroup.ENERGY, R.string.act_charge_limit, ActionGroup.ENERGY,
number(VehicleEnums.CHARGE_LIMIT_MIN, VehicleEnums.CHARGE_LIMIT_MAX, R.string.unit_percent), number(VehicleEnums.CHARGE_LIMIT_MIN, VehicleEnums.CHARGE_LIMIT_MAX, R.string.unit_percent),
"SET_CHARGE_LIMIT", currentKey = SnapshotKeys.KEY_CHARGE_LIMIT "SET_CHARGE_LIMIT", currentKey = SnapshotKeys.KEY_CHARGE_LIMIT,
writeProven = false
), ),
@SupportedOn(SWI68, SWI165) @SupportedOn(SWI68, SWI165)
SET_CHARGING_ENABLED( SET_CHARGING_ENABLED(
@@ -23,7 +23,8 @@ enum class ConditionGroup(@StringRes val labelRes: Int) {
CLIMATE(R.string.group_climate), CLIMATE(R.string.group_climate),
COMFORT(R.string.group_comfort), COMFORT(R.string.group_comfort),
AUDIO(R.string.group_audio), AUDIO(R.string.group_audio),
ADAS(R.string.group_adas) ADAS(R.string.group_adas),
LIGHTS(R.string.group_lights)
} }
/** /**
@@ -362,8 +363,12 @@ enum class ConditionType(
* The two are the same number until dual zone is on, which is exactly when a rule needs * 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 * to tell them apart — and there is no readable "dual zone" flag, so the honest way to
* ask is to compare the two targets. * ask is to compare the two targets.
*
* Not SWI68: the service reads the passenger zone (area 68) and answers -1, which is what
* its converter returns for a value out of range — a single-zone car has no such zone.
* Seen on a SWI68 MG4 that read the driver's target fine in the same pass.
*/ */
@SupportedOn(SWI68, SWI165) @SupportedOn(SWI165)
PASSENGER_TEMP( PASSENGER_TEMP(
R.string.cond_passenger_temp, ConditionGroup.CLIMATE, R.string.cond_passenger_temp, ConditionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius), number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
@@ -431,7 +436,12 @@ enum class ConditionType(
number(0, 100, R.string.unit_percent), number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_WINDOW_REAR_RIGHT, comparable = true SnapshotKeys.KEY_WINDOW_REAR_RIGHT, comparable = true
), ),
@SupportedOn(SWI68, SWI165) /**
* Not SWI68: `getDoorLock` reads back the property `setDoorLock` writes, a lock request
* rather than a lock state. A SWI68 MG4 in use answered 0, which is neither of the two
* named values, so the condition never had anything to say there.
*/
@SupportedOn(SWI165)
DOORS_LOCKED( DOORS_LOCKED(
R.string.cond_doors_locked, ConditionGroup.CONTEXT, R.string.cond_doors_locked, ConditionGroup.CONTEXT,
ValueSpec.BOOL, SnapshotKeys.KEY_DOORS_LOCKED ValueSpec.BOOL, SnapshotKeys.KEY_DOORS_LOCKED
@@ -473,6 +483,26 @@ enum class ConditionType(
SnapshotKeys.KEY_BRIGHTNESS, comparable = true SnapshotKeys.KEY_BRIGHTNESS, comparable = true
), ),
// ── Exterior lights (CR-046) ─────────────────────────────────────────────
// Standard AAOS ids (ExteriorLights). Proven on no generation yet: @SupportedOn stays empty
// until a capture shows the state follow the stalk, in the commit that fills
// ExteriorLights.PROVEN.
@SupportedOn
HEADLIGHTS(
R.string.cond_headlights, ConditionGroup.LIGHTS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HEADLIGHT_STATES), SnapshotKeys.KEY_HEADLIGHTS
),
@SupportedOn
HIGH_BEAM_ON(
R.string.cond_high_beam, ConditionGroup.LIGHTS,
ValueSpec.BOOL, SnapshotKeys.KEY_HIGH_BEAM
),
@SupportedOn
FOG_LIGHTS_ON(
R.string.cond_fog_lights, ConditionGroup.LIGHTS,
ValueSpec.BOOL, SnapshotKeys.KEY_FOG_LIGHTS
),
// ── Audio ──────────────────────────────────────────────────────────────── // ── Audio ────────────────────────────────────────────────────────────────
// What is coming out of the speakers, and how loud. The picker groups them together for // 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 same reason the action catalogue does: a rule about the radio is written next to
@@ -60,6 +60,13 @@ object SnapshotKeys {
const val KEY_MEDIA_VOLUME = "mediaVolume" const val KEY_MEDIA_VOLUME = "mediaVolume"
const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax" const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax"
const val KEY_BRIGHTNESS = "brightnessPct" const val KEY_BRIGHTNESS = "brightnessPct"
// Exterior lights (CR-046): states for conditions, switch positions for the action editor.
const val KEY_HEADLIGHTS = "headlightsState"
const val KEY_HIGH_BEAM = "highBeamOn"
const val KEY_FOG_LIGHTS = "fogLightsOn"
const val KEY_HEADLIGHTS_SWITCH = "headlightsSwitch"
const val KEY_HIGH_BEAM_SWITCH = "highBeamSwitch"
const val KEY_FOG_LIGHTS_SWITCH = "fogLightsSwitchOn"
const val KEY_AEB_ENABLED = "aebEnabled" const val KEY_AEB_ENABLED = "aebEnabled"
const val KEY_AEB_MODE = "aebMode" const val KEY_AEB_MODE = "aebMode"
const val KEY_AEB_SENSITIVITY = "aebSensitivity" const val KEY_AEB_SENSITIVITY = "aebSensitivity"
@@ -176,4 +176,23 @@ object VehicleEnums {
EnumOption(2, R.string.radio_band_fm), EnumOption(2, R.string.radio_band_fm),
EnumOption(4, R.string.radio_band_dab) EnumOption(4, R.string.radio_band_dab)
) )
/** VehicleLightState of the low beams — see ExteriorLights. */
val HEADLIGHT_STATES = listOf(
EnumOption(0, R.string.light_off),
EnumOption(1, R.string.light_on),
EnumOption(2, R.string.light_daytime_running)
)
/** What a rule may ask of the low beams: ON or AUTOMATIC, never OFF. */
val HEADLIGHT_SWITCH_CHOICES = listOf(
EnumOption(1, R.string.light_on),
EnumOption(0x100, R.string.light_auto)
)
/** What a rule may ask of the high beam: OFF or AUTOMATIC, never forced ON. */
val HIGH_BEAM_SWITCH_CHOICES = listOf(
EnumOption(0, R.string.light_off),
EnumOption(0x100, R.string.light_auto)
)
} }
@@ -0,0 +1,101 @@
package com.evsuite.hardware.diag
import com.google.gson.JsonObject
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL
import java.util.Base64
/**
* Posts one diagnostic artifact to an inbox the driver runs at home, instead of a USB stick.
*
* The inbox is the driver's own server (a Node-RED flow on the house network, for one): the
* app holds no account and no key, and nothing is sent unless the driver taps Send and
* confirms the address. HTTPS only, no redirect followed, no credentials or query in the URL,
* so a typo cannot send a car's report somewhere else in clear.
*
* The body is one JSON object, the same for every app, so one script on the desk unpacks them
* all: `{"evsuite":1,"app","version","name","createdAtMs","contentBase64"}`. The content is the
* artifact exactly as it would land on the stick — a zip, or a text report — base64-encoded.
*
* No Android dependency: [poster] is injectable, so a test checks the wire without a network.
*/
object DiagnosticInbox {
const val FORMAT_VERSION = 1
/** Above what a bundle is allowed to be on the stick, with base64's third on top. */
const val MAX_CONTENT_BYTES = 1024 * 1024
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 20_000
data class Artifact(
val app: String,
val version: String,
val name: String,
val createdAtMs: Long,
val content: ByteArray,
)
sealed interface Outcome {
data class Sent(val status: Int) : Outcome
data class Failed(val reason: String) : Outcome
}
/** Why [url] is not an inbox address, or null when it is one. */
fun refuse(url: String): String? {
val uri = runCatching { URI(url.trim()) }.getOrNull() ?: return "not a URL"
if (!uri.isAbsolute) return "not an absolute URL"
if (!uri.scheme.equals("https", ignoreCase = true)) return "must be https"
if (uri.host.isNullOrBlank()) return "no host"
if (uri.userInfo != null) return "must not carry credentials"
if (uri.query != null || uri.fragment != null) return "must not carry a query"
return null
}
internal fun body(artifact: Artifact): String = JsonObject().apply {
addProperty("evsuite", FORMAT_VERSION)
addProperty("app", artifact.app)
addProperty("version", artifact.version)
addProperty("name", artifact.name)
addProperty("createdAtMs", artifact.createdAtMs)
addProperty("contentBase64", Base64.getEncoder().encodeToString(artifact.content))
}.toString()
fun send(
url: String,
artifact: Artifact,
poster: (String, String) -> Int = ::post,
): Outcome {
refuse(url)?.let { return Outcome.Failed(it) }
if (artifact.content.size > MAX_CONTENT_BYTES) {
return Outcome.Failed("artifact is ${artifact.content.size} bytes, over $MAX_CONTENT_BYTES")
}
return try {
val status = poster(url.trim(), body(artifact))
if (status in 200..299) Outcome.Sent(status) else Outcome.Failed("HTTP $status")
} catch (e: Exception) {
Outcome.Failed(e.javaClass.simpleName + (e.message?.let { ": $it" } ?: ""))
}
}
private fun post(url: String, body: String): Int {
val bytes = body.toByteArray(Charsets.UTF_8)
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
instanceFollowRedirects = false
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty("Content-Type", "application/json; charset=utf-8")
setFixedLengthStreamingMode(bytes.size)
}
return try {
connection.outputStream.use { it.write(bytes) }
connection.responseCode
} finally {
connection.disconnect()
}
}
}
@@ -12,5 +12,8 @@ enum class DriveMode(val value: Int, val label: String, @StringRes val labelRes:
companion object { companion object {
fun fromValue(v: Int): DriveMode = values().firstOrNull { it.value == v } ?: NORMAL fun fromValue(v: Int): DriveMode = values().firstOrNull { it.value == v } ?: NORMAL
/** Exact decode: a code this enum does not know is null, not a default. */
fun ofRaw(v: Int?): DriveMode? = values().firstOrNull { it.value == v }
} }
} }
@@ -37,5 +37,29 @@ data class DrivingProfile(
val tsrEnabled: Boolean = false, val tsrEnabled: Boolean = false,
val isDefault: Boolean = false, val isDefault: Boolean = false,
// [BT-PROFILES] MAC of the Bluetooth device tied to this profile (null = none) // [BT-PROFILES] MAC of the Bluetooth device tied to this profile (null = none)
val btDeviceMac: String? = null val btDeviceMac: String? = null,
// CUSTOM drive mode (CR-040) — CustomDrive index: 0=Eco/Comfort, 1=Normal, 2=Sport.
// null = not configured, never written; profiles saved before this feature read as null.
// Written only when driveMode is CUSTOM — the car ignores them in any other mode.
val customPower: Int? = null,
val customSteering: Int? = null,
val customPedal: Int? = null,
// Climate (CR-041) — null = the profile does not touch climate (every older profile).
val climate: ProfileClimate? = null
)
/**
* The climate a profile applies (CR-041). A null switch means "unchanged": the profile leaves
* that control where the car has it, which is what lets a profile care about the setpoint
* without turning the defrost off.
*/
data class ProfileClimate(
val powerOn: Boolean = true,
val autoOn: Boolean? = null,
val acOn: Boolean? = null,
val tempCelsius: Int? = null,
val fanLevel: Int? = null,
val recirculation: Boolean? = null,
val frontDefrost: Boolean? = null,
val rearDefrost: Boolean? = null
) )
@@ -13,5 +13,8 @@ enum class RegenLevel(val value: Int, val label: String, @StringRes val labelRes
companion object { companion object {
fun fromValue(v: Int): RegenLevel = values().firstOrNull { it.value == v } ?: MEDIUM fun fromValue(v: Int): RegenLevel = values().firstOrNull { it.value == v } ?: MEDIUM
/** Exact decode: a code this enum does not know is null, not a default. */
fun ofRaw(v: Int?): RegenLevel? = values().firstOrNull { it.value == v }
} }
} }
@@ -1,6 +1,7 @@
package com.evsuite.hardware.probe package com.evsuite.hardware.probe
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.IBinder import android.os.IBinder
import com.evsuite.hardware.EVHardware import com.evsuite.hardware.EVHardware
import com.evsuite.hardware.FirmwareInfo import com.evsuite.hardware.FirmwareInfo
@@ -61,6 +62,7 @@ object RuntimeInterfaceProbe {
* it from a capture that has been up for a while. * it from a capture that has been up for a while.
*/ */
fun connect(context: Context) { fun connect(context: Context) {
packages = context.applicationContext.packageManager
SaicHub.connect(context) SaicHub.connect(context)
engMode.connect(context) engMode.connect(context)
} }
@@ -276,6 +278,80 @@ object RuntimeInterfaceProbe {
add(vehicleProperty()) add(vehicleProperty())
add(vehicleCharging()) add(vehicleCharging())
add(carDiagnostics()) add(carDiagnostics())
addAll(componentMap())
}
/** Kept from [connect], so [componentMap] needs no extra argument from the callers. */
@Volatile private var packages: PackageManager? = null
/**
* Every service the vendor packages declare in their manifests, as PackageManager lists it.
*
* The hub maps above can only name what someone already guessed to ask for. This is the
* other direction: what is installed, which services each package declares, and whether an
* unprivileged caller may reach them — RI-001, RI-003, RI-004, RI-007 and RI-008 each start
* by not knowing which component publishes their signal.
*
* Manifest metadata only. Nothing is bound, no transaction is sent and no vendor class is
* loaded, so this cannot reach a write on any interface. Visibility of other packages holds
* because the target is API 28; a later target needs `QUERY_ALL_PACKAGES`, and without it
* this would read as a head unit with no vendor packages at all.
*/
fun componentMap(): List<InterfaceProbe> {
val pm = packages ?: return listOf(
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.ABSENT, note = NOT_CONNECTED),
)
val names = try {
pm.getInstalledPackages(0).map { it.packageName }
.filter { name -> COMPONENT_PREFIXES.any { name.startsWith(it) } }.sorted()
} catch (e: RuntimeException) {
return listOf(
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.DENIED, note = e.javaClass.name),
)
}
return listOf(componentSummary(names)) + names.take(MAX_COMPONENT_PACKAGES).map { name ->
val services = try {
pm.getPackageInfo(name, PackageManager.GET_SERVICES).services.orEmpty()
.map { ServiceRow(it.name, it.exported, it.permission) }
} catch (e: PackageManager.NameNotFoundException) {
return@map InterfaceProbe("$COMPONENT_MAP:$name", null, BindVerdict.ABSENT)
} catch (e: RuntimeException) {
return@map InterfaceProbe(
"$COMPONENT_MAP:$name", null, BindVerdict.DENIED, note = e.javaClass.name,
)
}
componentEntry(name, services)
}
}
/** One declared service, as the manifest states it. */
internal data class ServiceRow(val name: String, val exported: Boolean, val permission: String?)
/** How many vendor packages there are, and how many of them the entries below list. */
internal fun componentSummary(names: List<String>): InterfaceProbe = InterfaceProbe(
COMPONENT_MAP, null, BindVerdict.ANSWERED,
mapOf(
"packages" to names.size.toString(),
"listed" to minOf(names.size, MAX_COMPONENT_PACKAGES).toString(),
),
COMPONENT_NOTE,
)
/**
* One package's services, keyed by class name, the value kept as the manifest's own words.
*
* A package that declares none is still ANSWERED: the manifest was read and said so.
*/
internal fun componentEntry(pkg: String, services: List<ServiceRow>): InterfaceProbe {
val values = LinkedHashMap<String, String?>()
values["services"] = services.size.toString()
services.take(MAX_COMPONENT_SERVICES).forEach {
values[it.name] = "exported=${it.exported} permission=${it.permission}"
}
if (services.size > MAX_COMPONENT_SERVICES) {
values["truncated"] = (services.size - MAX_COMPONENT_SERVICES).toString()
}
return InterfaceProbe("$COMPONENT_MAP:$pkg", null, BindVerdict.ANSWERED, values)
} }
/** /**
@@ -348,6 +424,16 @@ object RuntimeInterfaceProbe {
): InterfaceProbe { ): InterfaceProbe {
val target = binder val target = binder
?: return InterfaceProbe(label, descriptor, BindVerdict.ABSENT, note = note) ?: return InterfaceProbe(label, descriptor, BindVerdict.ABSENT, note = note)
// The hub may hand over a different interface at this bind point. Every call would then
// fail the remote descriptor check, which reads as a refusal and is not one: the
// interface is not here, so the entry is ABSENT and nothing is called.
val answersAs = runCatching { target.interfaceDescriptor }.getOrNull()
if (answersAs != null && answersAs != descriptor) {
return InterfaceProbe(
label, descriptor, BindVerdict.ABSENT,
note = "$note; the handle answers as $answersAs, so nothing was called",
)
}
val values = calls.mapValues { (_, call) -> val values = calls.mapValues { (_, call) ->
val args = call.args.toTypedArray() val args = call.args.toTypedArray()
when (call.readAs) { when (call.readAs) {
@@ -365,6 +451,18 @@ object RuntimeInterfaceProbe {
*/ */
private val CODE_SWEEP = (0x0..0x12).toList() private val CODE_SWEEP = (0x0..0x12).toList()
private const val COMPONENT_MAP = "componentMap"
private val COMPONENT_PREFIXES = listOf("com.saicmotor.", "com.saicvehicleservice.")
/** A bundle is pasted and read; a few hundred lines of manifest is the useful end of it. */
internal const val MAX_COMPONENT_PACKAGES = 64
internal const val MAX_COMPONENT_SERVICES = 40
private const val NOT_CONNECTED = "connect() was not called, so there is no PackageManager"
private const val COMPONENT_NOTE =
"PackageManager GET_SERVICES only: nothing bound, called or loaded. exported=false or " +
"a permission we do not hold means unreachable from EVSuite, not absent."
/** /**
* What ABSENT means when there is no adapter client to ask. Carried on every swept entry * 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 * rather than stated once at the top of the bundle: a later session reads one entry, not the
@@ -0,0 +1,81 @@
package com.evsuite.hardware.probe
import com.evsuite.hardware.AppLogger
import com.evsuite.hardware.EVHardware
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.saic.SaicCharging
import java.lang.reflect.InvocationTargetException
/**
* Two read-only diagnostic tools. EVTasker's stable diagnostic screen calls [capture] from two
* buttons (CR-045/CR-046, 2026-10-13), because the owner only installs stable builds; that is
* safe only because **no setter is called from this file** — only the getters
* [VendorSurfaceRules.readableGetters] admits.
*
* 1. [socSourcesReport] (CR-044): every place state of charge might come from, side by side,
* for the generations where the uploader reads none.
* 2. [capture] + [VendorSurfaceRules.diff] (CR-045): read everything, let the driver flip a
* setting on the car's own screen at standstill, read again, list what changed. That is how
* an unknown setting (Automatic High Beam) is found on a given firmware without taking an
* identifier from anybody else's code. It works for any setting, and across a SOC change
* too: capture, drive a few kilometres, capture again.
*
* Every value printed is a candidate. Only a lead proven per generation becomes a read path.
*/
object VendorSurfaceProbe {
private const val TAG = "EV_SURVEY"
/** Longest a capture may take before it stops and says so. */
const val BUDGET_MS = 20_000L
/** Getter-name words that may carry battery state, for the SOC survey. */
val SOC_WORDS = listOf(
"soc", "battery", "batt", "bms", "energy", "endurance", "range", "electric",
"charge", "mileage",
)
/**
* Everything readable, keyed by source: every CarPropertyManager property (when
* [words] is empty) and every value getter of the vendor setting objects EVHardware holds.
* Call off the main thread: these are binder calls.
*/
fun capture(words: List<String> = emptyList()): Map<String, String> {
val deadline = System.currentTimeMillis() + BUDGET_MS
val out = sortedMapOf<String, String>()
if (words.isEmpty()) out += EVHardware.readAllCarProperties(deadline)
for ((name, handle) in EVHardware.vendorSettingHandles()) {
for (getter in VendorSurfaceRules.readableGetters(handle.javaClass, words)) {
if (System.currentTimeMillis() > deadline) {
out["$name ~"] = "time budget spent"
break
}
out["$name.${getter.name}"] = try {
VendorSurfaceRules.render(getter.invoke(handle))
} catch (e: InvocationTargetException) {
"ERR ${e.targetException?.javaClass?.simpleName}"
} catch (e: Exception) {
"ERR ${e.javaClass.simpleName}"
}
}
}
AppLogger.i(TAG, "capture(${words.joinToString()}) → ${out.size} values")
return out
}
/** CR-044: where state of charge can come from on this car, and what each source says. */
fun socSourcesReport(): String = buildString {
appendLine("─── SOC sources (CR-044) — ${FirmwareInfo.getGeneration()} ───")
appendLine("SaicCharging hub: ${if (SaicCharging.isAvailable) "bound" else "absent or not bound"}" +
" → SOC ${SaicCharging.stateOfChargePercent() ?: "null"}")
appendLine("Vendor SOC property (SWI68 path) → ${EVHardware.getVendorBatterySocPercent() ?: "null"}")
EVHardware.probeTelemetryProperties()
.filter { it.name in setOf("EV_BATTERY_LEVEL", "INFO_EV_BATTERY_CAPACITY", "RANGE_REMAINING") }
.forEach { appendLine(it.toString()) }
val getters = capture(SOC_WORDS)
appendLine("Vendor getters with battery words: ${getters.size}")
getters.forEach { (key, value) -> appendLine(" $key = $value") }
appendLine("Candidates only. To find SOC among unnamed properties, use the before/after")
appendLine("capture across a SOC change (a few km, or a charge).")
}
}
@@ -0,0 +1,112 @@
package com.evsuite.hardware.probe
import java.lang.reflect.Method
import java.lang.reflect.Modifier
/**
* The decisions behind [VendorSurfaceProbe], kept free of Android so the JVM tests reach them.
*
* Two diagnostic tools stand on these rules (CR-044, CR-045). Both answer "where does this car
* publish X?" by READING what the runtime already exposes. Neither tool writes anything:
* - the SOC survey reads every candidate source of state of charge side by side;
* - the before/after capture reads everything twice — once before the driver flips a setting
* on the car's own screen, once after — and lists what changed.
*
* Getters are named by the runtime class itself, not by any external catalogue. What changed is
* a lead to prove per generation before it becomes a supported read or write.
*/
object VendorSurfaceRules {
/** Read-only name shapes: a getter answers, it does not act. */
private val GETTER_PREFIXES = listOf("get", "is", "has")
/**
* Words that make a "getter" something else: a call that starts, registers, resets or
* connects can move state even when its name begins with `get`, and one that hands back a
* service or a listener is plumbing, not a reading. Matched as whole camel-case words, so
* `getHighBeamSetting` and `isAutoEnabled` stay readings while `getAndReset` does not.
*/
private val ACTION_WORDS = setOf(
"set", "reset", "clear", "request", "start", "stop", "connect", "disconnect",
"register", "unregister", "bind", "unbind", "toggle", "send", "write", "apply", "click",
"and", "instance", "service", "binder", "context", "listener", "callback", "handler",
"looper",
)
/** `getAndResetCount` → [and, reset, count]. */
fun words(name: String): List<String> =
name.split(Regex("(?<=[a-z0-9])(?=[A-Z])|_")).filter { it.isNotEmpty() }
.map { it.lowercase() }
/** Only values: anything returning an object could hand back a binder or a context. */
private val VALUE_TYPES: Set<Class<*>> = setOf(
Int::class.javaPrimitiveType!!, Long::class.javaPrimitiveType!!,
Float::class.javaPrimitiveType!!, Double::class.javaPrimitiveType!!,
Boolean::class.javaPrimitiveType!!, Short::class.javaPrimitiveType!!,
Byte::class.javaPrimitiveType!!,
Int::class.javaObjectType, Long::class.javaObjectType, Float::class.javaObjectType,
Double::class.javaObjectType, Boolean::class.javaObjectType, Short::class.javaObjectType,
Byte::class.javaObjectType, String::class.java,
)
/** The getters of [cls] safe to call for a reading, sorted by name. */
fun readableGetters(cls: Class<*>, keywords: List<String> = emptyList()): List<Method> =
cls.methods
.filter { isReadableGetter(it) }
.filter { m -> keywords.isEmpty() || keywords.any { m.name.contains(it, ignoreCase = true) } }
.distinctBy { it.name }
.sortedBy { it.name }
fun isReadableGetter(m: Method): Boolean {
if (m.parameterCount != 0) return false
if (!Modifier.isPublic(m.modifiers) || Modifier.isStatic(m.modifiers)) return false
if (m.declaringClass == Any::class.java) return false
if (m.returnType !in VALUE_TYPES) return false
val name = m.name
val prefix = GETTER_PREFIXES.firstOrNull { name.startsWith(it) && name.length > it.length }
?: return false
if (!name[prefix.length].isUpperCase()) return false
return words(name.substring(prefix.length)).none { it in ACTION_WORDS }
}
data class Change(val key: String, val before: String?, val after: String?)
/**
* What differs between two readings. A key present on one side only is a change too: a
* property that starts answering when a feature is switched on is exactly the lead sought.
*/
fun diff(before: Map<String, String>, after: Map<String, String>): List<Change> =
(before.keys + after.keys).sorted()
.filter { before[it] != after[it] }
.map { Change(it, before[it], after[it]) }
/** One line per value, shaped like the rest of the diagnostic report. */
fun formatDiff(changes: List<Change>, beforeCount: Int, afterCount: Int): String = buildString {
appendLine("Values read: $beforeCount before, $afterCount after — ${changes.size} changed")
if (changes.isEmpty()) {
appendLine(" nothing changed: the setting is not in what this app can read, or the")
appendLine(" car had not applied it yet when the second capture was taken")
}
changes.forEach { appendLine(" ${label(it.key)}: ${it.before ?: "—"} → ${it.after ?: "—"}") }
}
private val CPM_KEY = Regex("""^CPM 0x([0-9A-Fa-f]{8})/""")
/** `CPM 0x11400E00/0` → `CPM 0x11400E00/0 (HEADLIGHTS_STATE)` for a standard id we name. */
fun label(key: String, names: Map<Int, String> = com.evsuite.hardware.ExteriorLights.NAMES): String {
val id = CPM_KEY.find(key)?.groupValues?.get(1)?.toLongOrNull(16)?.toInt() ?: return key
return names[id]?.let { "$key ($it)" } ?: key
}
/** Arrays print their content; everything else its own text. */
fun render(value: Any?): String = when (value) {
null -> "null"
is IntArray -> value.contentToString()
is LongArray -> value.contentToString()
is FloatArray -> value.contentToString()
is BooleanArray -> value.contentToString()
is ByteArray -> value.contentToString()
is Array<*> -> value.contentDeepToString()
else -> value.toString()
}
}
@@ -222,7 +222,7 @@ object SaicNavGuidance {
* A `List<EVRoutPoiInfo>` as `Parcel.writeTypedList` lays it out: the count, then per item a * 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 * 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 * them. Written by hand because the bean is the vendor's class and this module does not
* carry it — the layout is three lines and depending on a decompiled class would be worse. * carry it — the layout is three lines and depending on an OEM class would be worse.
*/ */
internal fun Parcel.writeEvRoutPoiList(pois: List<NavigationHandoff.Poi>) { internal fun Parcel.writeEvRoutPoiList(pois: List<NavigationHandoff.Poi>) {
writeInt(pois.size) writeInt(pois.size)
@@ -5,6 +5,7 @@ import android.os.IBinder
import com.evsuite.hardware.A9Climate import com.evsuite.hardware.A9Climate
import com.evsuite.hardware.GlassProbe import com.evsuite.hardware.GlassProbe
import com.evsuite.hardware.VsmGlass import com.evsuite.hardware.VsmGlass
import com.evsuite.hardware.catalog.VehicleEnums
/** /**
* The vendor vehicle service the head unit's HVAC and charging screens talk to. * The vendor vehicle service the head unit's HVAC and charging screens talk to.
@@ -173,6 +174,15 @@ object SaicClimate {
fun fanLevelMax(): Int? = if (a9) null else level(TX_GET_MAX_AIR_VOLUME) fun 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 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 passengerTemp(): Int? = if (a9) null else level(TX_GET_PSG_TEMP)
/**
* The passenger target exactly as the service answers it, for the diagnostic only.
*
* [passengerTemp] drops -1, which is also what the service's converter returns for a
* zone the car does not have: the raw number is what tells "single zone" from "the
* read did not answer" (null).
*/
fun passengerTempState(): Int? = if (a9) null else SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_PSG_TEMP)
fun frontDefrostOn(): Boolean? = if (a9) A9Climate.frontDefrostOn() else flag(TX_GET_FRONT_DEFROST) fun 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) 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. */ /** The service answers [TEMP_ERROR] for a reading it holds no value for, not an error. */
@@ -254,8 +264,18 @@ object SaicCharging {
fun chargingStatus(): Int? = read(TX_GET_CHARGING_STATUS) fun chargingStatus(): Int? = read(TX_GET_CHARGING_STATUS)
/** Charge limit in percent — charging stops here. */ /**
fun chargeLimitPercent(): Int? = read(TX_GET_CLOSE_SOC) * Charge limit in percent — charging stops here.
*
* Null outside the range the car accepts: SWI68 answered `5` on 2026-10-13, a code from a
* table nobody has read, not a percent. Null sends every caller down its "limit
* unreadable" path instead of planning a charge to 5 %.
*/
fun chargeLimitPercent(): Int? = chargeLimitOrNull(read(TX_GET_CLOSE_SOC))
internal fun chargeLimitOrNull(raw: Int?): Int? =
raw?.takeIf { it in VehicleEnums.CHARGE_LIMIT_MIN..VehicleEnums.CHARGE_LIMIT_MAX }
fun setChargeLimitPercent(percent: Int): Boolean = fun setChargeLimitPercent(percent: Int): Boolean =
SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CLOSE_SOC, percent.coerceIn(0, 100)) SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CLOSE_SOC, percent.coerceIn(0, 100))
@@ -0,0 +1,163 @@
package com.evsuite.hardware.telemetry
import com.google.gson.Gson
import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs
import kotlin.math.max
/**
* One day of the battery analyses, kept so that tomorrow can be compared with today (CP-087).
*
* Nothing here is measured: every field is copied from [StateOfHealthEstimator],
* [CalibrationDrift] and [BatteryExposure] as they stood that day. A null is an analysis that
* refused, never a zero.
*/
data class BatteryDigest(
/** The local calendar day, `yyyy-MM-dd`. One record per day; the last write of a day wins. */
val day: String,
val healthPercent: Double?,
/** Half-width of the health band, in points. Null where the estimate refused. */
val healthBandPercent: Double?,
val healthWindows: Int,
val calibration: CalibrationVerdict?,
val daysSinceFullCharge: Double?,
val hoursAboveHighSoc: Double?,
val equivalentFullCycles: Double?,
val chargeCount: Int,
) {
companion object {
fun of(
day: String,
health: StateOfHealthResult,
drift: CalibrationDriftReport?,
exposure: BatteryExposureReport?,
charge: ChargeEnergyReport,
): BatteryDigest {
val estimate = (health as? StateOfHealthResult.Ready)?.estimate
return BatteryDigest(
day = day,
healthPercent = estimate?.stateOfHealthPercent?.value,
healthBandPercent = estimate?.stateOfHealthPercent?.uncertainty,
healthWindows = estimate?.windowCount ?: 0,
calibration = drift?.verdict,
daysSinceFullCharge = drift?.daysSinceFullCharge,
hoursAboveHighSoc = exposure?.hoursAboveHighSoc,
equivalentFullCycles = exposure?.equivalentFullCycles,
chargeCount = charge.charges.size,
)
}
/**
* What moved between two days by more than its own uncertainty, and only that.
*
* A health figure that wanders inside its band has not changed: saying so every morning
* is the notification a driver turns off in a week.
*/
fun changes(previous: BatteryDigest?, current: BatteryDigest): List<DigestChange> {
if (previous == null) return emptyList()
return listOfNotNull(
healthMoved(previous, current),
bandNarrowed(previous, current),
calibrationChanged(previous, current),
)
}
private fun healthMoved(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
val from = previous.healthPercent ?: return null
val to = current.healthPercent ?: return null
val band = max(previous.healthBandPercent ?: 0.0, current.healthBandPercent ?: 0.0)
if (abs(to - from) <= band) return null
return DigestChange.HealthMoved(from, to, current.healthBandPercent ?: band)
}
private fun bandNarrowed(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
val from = previous.healthBandPercent
val to = current.healthBandPercent ?: return null
// A first estimate is a narrowing from nothing, and the driver has never seen it.
if (from == null) {
val health = current.healthPercent ?: return null
return DigestChange.HealthBandNarrowed(null, to, health)
}
if (from - to < MIN_BAND_NARROWING_PERCENT) return null
return DigestChange.HealthBandNarrowed(from, to, current.healthPercent ?: return null)
}
private fun calibrationChanged(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
val to = current.calibration ?: return null
if (previous.calibration == to) return null
return DigestChange.CalibrationChanged(previous.calibration, to, current.daysSinceFullCharge)
}
/** A point of band is what one more watched window buys; less is rounding. */
const val MIN_BAND_NARROWING_PERCENT = 1.0
}
}
sealed interface DigestChange {
data class HealthMoved(val fromPercent: Double, val toPercent: Double, val bandPercent: Double) : DigestChange
data class HealthBandNarrowed(
val fromBandPercent: Double?,
val toBandPercent: Double,
val healthPercent: Double,
) : DigestChange
data class CalibrationChanged(
val from: CalibrationVerdict?,
val to: CalibrationVerdict,
val daysSinceFullCharge: Double?,
) : DigestChange
}
/** A year of days, newest last. Same atomic write and quarantine as [BatteryLedgerStore]. */
class BatteryDigestStore(
private val target: File,
private val maxDays: Int = MAX_DAYS,
private val gson: Gson = Gson(),
) {
fun read(): List<BatteryDigest> {
if (!target.exists()) return emptyList()
val text = runCatching { target.readText() }.getOrNull() ?: return emptyList()
val envelope = runCatching { gson.fromJson(text, Envelope::class.java) }.getOrNull()
if (envelope == null || envelope.schemaVersion != SCHEMA_VERSION) {
target.renameTo(File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}"))
return emptyList()
}
@Suppress("SENSELESS_COMPARISON")
return envelope.days.orEmpty().filter { it != null && it.day != null }.sortedBy { it.day }
}
/** Replaces the record of the same day, or adds it; returns the day before it, if any. */
fun upsert(digest: BatteryDigest): BatteryDigest? {
val others = read().filter { it.day != digest.day }
val previous = others.lastOrNull { it.day < digest.day }
write((others + digest).sortedBy { it.day }.takeLast(maxDays.coerceAtLeast(1)))
return previous
}
private fun write(days: List<BatteryDigest>): Boolean {
val bytes = gson.toJson(Envelope(SCHEMA_VERSION, days)).toByteArray(Charsets.UTF_8)
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "${target.name}.${System.nanoTime()}.tmp")
return try {
FileOutputStream(temp).use { output ->
output.write(bytes)
output.fd.sync()
}
if (temp.renameTo(target)) true else {
temp.delete()
false
}
} catch (_: Exception) {
temp.delete()
false
}
}
private data class Envelope(val schemaVersion: Int = 0, val days: List<BatteryDigest>? = null)
companion object {
const val SCHEMA_VERSION = 1
const val MAX_DAYS = 365
const val FILE_NAME = "battery-digest.json"
}
}
@@ -166,6 +166,9 @@ class BatteryLedgerRecorder(
/** @return true when this sample earned an entry and it was written. */ /** @return true when this sample earned an entry and it was written. */
fun observe(snapshot: EnergySnapshot): Boolean { fun observe(snapshot: EnergySnapshot): Boolean {
// A head unit woken by a night charge runs on its factory clock (late 2018) until it syncs.
// The ledger is read in time order, so one such entry would sort first and split the charge.
if (snapshot.timestampMs < MIN_PLAUSIBLE_CLOCK_MS) return false
seed() seed()
integrate(snapshot) integrate(snapshot)
val soc = snapshot.socPercent?.takeIf { it.isFinite() } ?: return false val soc = snapshot.socPercent?.takeIf { it.isFinite() } ?: return false
@@ -231,5 +234,8 @@ class BatteryLedgerRecorder(
const val SOC_STEP_PERCENT = 1.0f const val SOC_STEP_PERCENT = 1.0f
const val QUIET_INTERVAL_MS = 15 * 60 * 1000L const val QUIET_INTERVAL_MS = 15 * 60 * 1000L
const val MAX_SAMPLE_GAP_MS = 5_000L const val MAX_SAMPLE_GAP_MS = 5_000L
/** 2025-01-01T00:00Z: no reading of this app can be older. Seen by MG4Control on the car. */
const val MIN_PLAUSIBLE_CLOCK_MS = 1_735_689_600_000L
} }
} }
@@ -0,0 +1,137 @@
package com.evsuite.hardware.telemetry
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
/**
* How long the rest of a charge takes, from the driver's own watched charges (CP-089).
*
* The unit is minutes per point of charge, measured on [ChargeEnergy.steps]: the wall clock
* between two ledger entries over the points between them. No kWh per point and no power sign
* stand between the history and the answer. A band of [BAND_PERCENT] points that no watched
* charge crossed is a refusal, never an extrapolation.
*/
data class ChargeDurationEstimate(
val fromPercent: Double,
val targetPercent: Int,
/** Median charge per band, summed. */
val minutes: Double,
/** Fastest and slowest past charge per band, summed: the range the history allows. */
val lowMinutes: Double,
val highMinutes: Double,
/** From [LAST_STRETCH_PERCENT] to the target, median; null when the path does not cross it. */
val lastStretchMinutes: Double?,
/** True when only charges at a similar outside temperature were used. */
val similarTemperature: Boolean,
)
sealed interface ChargeDurationResult {
data class Ready(val estimate: ChargeDurationEstimate) : ChargeDurationResult
data class Refused(val reason: ChargeDurationRefusal, val bandFromPercent: Int? = null) :
ChargeDurationResult
}
enum class ChargeDurationRefusal { AT_TARGET, NO_WATCHED_CHARGE, MISSING_BAND }
/** One band of the charge in progress against the same band in past charges. */
data class ChargeCurveCheck(
val bandFromPercent: Int,
val minutesPerPoint: Double,
val usualLowMinutesPerPoint: Double,
val usualHighMinutesPerPoint: Double,
val pastCharges: Int,
) {
/** Slower than the slowest past charge in that band: less power than the history's low edge. */
val slower: Boolean get() = minutesPerPoint > usualHighMinutesPerPoint
}
object ChargeDuration {
fun estimate(
charges: List<ChargeEnergy>,
socPercent: Double,
targetPercent: Int,
outsideCelsius: Double?,
): ChargeDurationResult {
if (socPercent >= targetPercent) return ChargeDurationResult.Refused(ChargeDurationRefusal.AT_TARGET)
if (outsideCelsius != null) {
val similar = charges.filter { charge ->
val low = charge.minOutsideTempCelsius
val high = charge.maxOutsideTempCelsius
low != null && high != null && abs((low + high) / 2.0 - outsideCelsius) <= SIMILAR_TEMPERATURE_CELSIUS
}
val ready = along(history(similar), socPercent, targetPercent, similar = true)
if (ready is ChargeDurationResult.Ready) return ready
}
return along(history(charges), socPercent, targetPercent, similar = false)
}
fun curveCheck(charges: List<ChargeEnergy>, bandFromPercent: Int, minutesPerPoint: Double): ChargeCurveCheck? {
val past = history(charges)[bandFromPercent / BAND_PERCENT].orEmpty()
if (past.size < MIN_PAST_CHARGES) return null
return ChargeCurveCheck(bandFromPercent, minutesPerPoint, past.min(), past.max(), past.size)
}
/** Band index to one minutes-per-point figure per past charge that crossed it. */
fun history(charges: List<ChargeEnergy>): Map<Int, List<Double>> {
val byBand = HashMap<Int, MutableList<Double>>()
charges.forEach { charge ->
charge.steps.filter { it.toSocPercent > it.fromSocPercent && it.durationMs > 0 }
.groupBy { band(it.midSocPercent) }
.forEach { (band, steps) ->
val points = steps.sumOf { it.toSocPercent - it.fromSocPercent }
byBand.getOrPut(band) { ArrayList() } += steps.sumOf { it.durationMs } / MINUTE_MS / points
}
}
return byBand
}
private fun along(
history: Map<Int, List<Double>>,
socPercent: Double,
targetPercent: Int,
similar: Boolean,
): ChargeDurationResult {
if (history.isEmpty()) return ChargeDurationResult.Refused(ChargeDurationRefusal.NO_WATCHED_CHARGE)
var minutes = 0.0
var low = 0.0
var high = 0.0
var lastStretch = 0.0
val first = band(socPercent)
val last = ceil(targetPercent / BAND_PERCENT.toDouble()).toInt() - 1
for (band in first..last) {
val past = history[band]?.sorted()
?: return ChargeDurationResult.Refused(ChargeDurationRefusal.MISSING_BAND, band * BAND_PERCENT)
val from = maxOf(socPercent, band * BAND_PERCENT.toDouble())
val to = minOf(targetPercent.toDouble(), (band + 1) * BAND_PERCENT.toDouble())
val median = past[past.size / 2]
minutes += (to - from) * median
low += (to - from) * past.first()
high += (to - from) * past.last()
val stretchFrom = maxOf(from, LAST_STRETCH_PERCENT.toDouble())
if (to > stretchFrom) lastStretch += (to - stretchFrom) * median
}
val crosses = socPercent < LAST_STRETCH_PERCENT && targetPercent > LAST_STRETCH_PERCENT
return ChargeDurationResult.Ready(
ChargeDurationEstimate(
fromPercent = socPercent,
targetPercent = targetPercent,
minutes = minutes,
lowMinutes = low,
highMinutes = high,
lastStretchMinutes = if (crosses) lastStretch else null,
similarTemperature = similar,
),
)
}
private fun band(socPercent: Double): Int =
floor(socPercent / BAND_PERCENT).toInt().coerceIn(0, 100 / BAND_PERCENT - 1)
const val BAND_PERCENT = 10
const val LAST_STRETCH_PERCENT = 80
const val SIMILAR_TEMPERATURE_CELSIUS = 8.0
const val MIN_PAST_CHARGES = 3
private const val MINUTE_MS = 60_000.0
}
@@ -0,0 +1,405 @@
package com.evsuite.hardware.telemetry
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
/**
* Which way this app's own integral moved while charge was entering the pack.
*
* [EnergySnapshot.batteryPowerKw] declares positive as energy leaving the battery, so an
* integral that **falls** during a charge is the declared convention holding. One that rises is
* the same convention inverted, and it matters well beyond a sign on a dashboard: the state of
* health estimator reads `PACK_PAIR_INTEGRAL` windows as energy that *left* the pack, so an
* inverted source would hand it discharge windows with the sign of a charge and the estimator
* would refuse every one of them.
*
* Nothing here corrects anything. RI-002 asks what the car actually does and this reports it.
*/
enum class ChargePackSign {
/** The integral fell, which is the convention [EnergySnapshot.batteryPowerKw] declares. */
ENERGY_FELL,
/** The integral rose, so the property signs a charge the other way round. */
ENERGY_ROSE,
/** Readable and level: the pack pair said nothing moved while the gauge said it did. */
FLAT,
/** No charge was watched closely enough, or no pack reading was integrated at all. */
UNREADABLE,
/** Two charges answered differently, which settles nothing and must not be averaged. */
CONTRADICTORY,
}
/**
* What the car's own kWh counters did across a charge.
*
* `SaicCharging` transactions 75 and 77 are documented as *since the last charge*, so the
* expected behaviour is [RESET] — and "expected" is the whole reason to measure it. A counter
* that resets is what lets [StateOfHealthEstimator] tell one discharge window from the next; a
* counter that never moves is a source that will never produce a window at all.
*/
enum class ChargeCounterBehaviour {
/** A counter went backwards: the car cleared it, which is what a charge is said to do. */
RESET,
/**
* A counter rose during the charge, which "since the last charge" does not predict.
*/
ROSE,
/** Read at both ends and unchanged. */
STATIC,
/** Never published on either end of any charge. */
ABSENT,
}
/**
* One charge session measured in energy rather than in points of charge.
*
* [BatteryExposure] already segments the ledger into sessions and says how fast each one moved
* the gauge. This adds the half nothing reads today: how much energy went in, which way the
* pack pair signed it, and what the car's own counters did while it happened.
*/
data class ChargeEnergy(
val session: BatteryChargeSession,
/**
* Whether this app was sampling throughout.
*
* The ledger earns an entry every quarter of an hour whatever else happens, so a longer
* silence than that is the app having been down — a head unit asleep on a charger overnight
* is the ordinary case, not an edge one. An unwatched charge still gained the charge it
* gained; what it cannot carry is a power, because the integral stopped moving while nobody
* was integrating.
*/
val watched: Boolean,
val maxEntryGapMs: Long,
/** Signed, as the integral moved. Negative is the declared convention; see [ChargePackSign]. */
val packDeltaKwh: Double?,
/** Magnitude over the wall clock, and only where the whole session was watched. */
val meanPowerKw: Double?,
val counterBehaviour: ChargeCounterBehaviour,
val consumedDeltaKwh: Double?,
val regeneratedDeltaKwh: Double?,
/**
* Odometer moved, when the car published one at both ends.
*
* A rise in charge is how a charge is detected here, and a long descent raises the charge
* too. Distance is what tells the two apart, so a moving session is regeneration and is
* never offered to the driver as "your last charge".
*/
val movedKm: Double?,
/** Every distinct value the charging status held, in the order the ledger saw them. */
val chargingStatuses: List<Int>,
/**
* Pack power between each pair of watched ledger entries, which the recorder writes once
* per point of charge: this is the charge curve, one step per point. Empty when unwatched.
*/
val steps: List<ChargePowerStep> = emptyList(),
/** Outside temperature over the session, named as such: the pack's own is unpublished. */
val minOutsideTempCelsius: Double? = null,
val maxOutsideTempCelsius: Double? = null,
) {
/** The fastest watched step, measured; null when no step was watched. */
val peakPowerKw: Double? get() = steps.maxOfOrNull { it.powerKw }
/**
* Whether this rise was a charge rather than regeneration.
*
* Three pieces of evidence, any one of them enough:
* - the odometer stood still;
* - the car reported a non-zero charging status during the rise (every drive recorded so far
* read zero throughout, 1 473 samples on SWI68);
* - the rise is too large to be regeneration **and** nobody watched the car move during it.
* A head unit asleep on the charger is the ordinary overnight case: the ledger then holds
* the evening entry and the morning one, the odometer may have moved between them because
* the drive home was never recorded either, and requiring it to stand still is what made
* a whole night's charge read as a descent and vanish from the battery page.
*/
val plugged: Boolean
get() {
val moved = movedKm
if (moved != null && moved <= STATIONARY_TOLERANCE_KM) return true
if (chargingStatuses.any { it != 0 }) return true
return session.gainedPercent >= UNAMBIGUOUS_CHARGE_PERCENT && (moved == null || !watched)
}
val sign: ChargePackSign
get() {
val delta = packDeltaKwh
if (!watched || delta == null) return ChargePackSign.UNREADABLE
return when {
delta <= -ChargeEnergyAnalyzer.MIN_ENERGY_KWH -> ChargePackSign.ENERGY_FELL
delta >= ChargeEnergyAnalyzer.MIN_ENERGY_KWH -> ChargePackSign.ENERGY_ROSE
else -> ChargePackSign.FLAT
}
}
companion object {
/** The odometer reads in whole kilometres, so anything under one is standing still. */
const val STATIONARY_TOLERANCE_KM = 0.5
/**
* A rise no descent produces. Regeneration on the steepest ordinary pass returns a few
* points; ten points of a 61,7 kWh pack is over 6 kWh of potential energy recovered.
*/
const val UNAMBIGUOUS_CHARGE_PERCENT = 10.0
}
}
/** One watched step of a charge: from one ledger entry to the next, usually one point. */
data class ChargePowerStep(
val fromSocPercent: Double,
val toSocPercent: Double,
val durationMs: Long,
/** Magnitude of the pack-pair integral's move over the step's wall clock. */
val powerKw: Double,
) {
val midSocPercent: Double get() = (fromSocPercent + toSocPercent) / 2.0
}
/** Time-weighted mean power over every watched step whose midpoint fell in one charge band. */
data class SocBandPower(
val fromPercent: Int,
val toPercent: Int,
val meanPowerKw: Double,
val hours: Double,
)
/** Every charge the ledger holds, and the two questions the set of them settles. */
data class ChargeEnergyReport(
val charges: List<ChargeEnergy>,
val packSign: ChargePackSign,
val counterBehaviour: ChargeCounterBehaviour,
val chargingStatuses: List<Int>,
) {
/** The most recent charge that stood still, which is the one a driver means by "my charge". */
val lastPluggedCharge: ChargeEnergy? get() = charges.lastOrNull { it.plugged }
/** Every charge that was a charge, oldest first. */
val pluggedCharges: List<ChargeEnergy> get() = charges.filter { it.plugged }
/**
* The charge curve across every watched charge: mean power per band of [bandPercent]
* points of charge. This is where a taper shows — the pack accepting less as it fills —
* and it is measured, one step per point, never modelled.
*/
fun powerBySocBand(bandPercent: Int = SOC_BAND_PERCENT): List<SocBandPower> =
pluggedCharges.flatMap { it.steps }
.groupBy { (it.midSocPercent / bandPercent).toInt().coerceIn(0, 100 / bandPercent - 1) }
.toSortedMap()
.map { (band, steps) ->
val ms = steps.sumOf { it.durationMs }.toDouble()
SocBandPower(
fromPercent = band * bandPercent,
toPercent = (band + 1) * bandPercent,
meanPowerKw = steps.sumOf { it.powerKw * it.durationMs } / ms,
hours = ms / 3_600_000.0,
)
}
val watchedCount: Int get() = charges.count { it.watched }
/**
* The report as lines, because the answer belongs in the diagnostic bundle rather than
* only on a screen. Same reason [com.evsuite.hardware.telemetry.model.SocConsumptionFitter]
* describes itself: a ticket is settled by a bundle a driver brought back, not by a UI.
*/
fun describe(): List<String> {
if (charges.isEmpty()) return listOf("charges=0")
val lines = ArrayList<String>()
lines += "charges=${charges.size} watched=$watchedCount"
lines += "pack_pair_sign=${packSign.name}"
lines += "vehicle_counters=${counterBehaviour.name}"
lines += "charging_status_values=" + chargingStatuses.joinToString(",").ifEmpty { "none" }
charges.takeLast(MAX_DESCRIBED_CHARGES).forEach { charge ->
lines += "charge started_epoch_ms=${charge.session.startedAtMs}" +
" gained_percent=${format(charge.session.gainedPercent)}" +
" hours=${format(charge.session.durationHours)}" +
" watched=${charge.watched}" +
" max_entry_gap_ms=${charge.maxEntryGapMs}" +
// Unwatched, the integral only moved during the few seconds the app was up: a
// number there reads as a measured charge of nearly nothing.
" pack_delta_kwh=${format(charge.packDeltaKwh?.takeIf { charge.watched })}" +
" mean_power_kw=${format(charge.meanPowerKw)}" +
" consumed_delta_kwh=${format(charge.consumedDeltaKwh)}" +
" regenerated_delta_kwh=${format(charge.regeneratedDeltaKwh)}" +
" moved_km=${format(charge.movedKm)}" +
" counters=${charge.counterBehaviour.name}" +
" sign=${charge.sign.name}" +
" plugged=${charge.plugged}" +
" peak_power_kw=${format(charge.peakPowerKw)}" +
" steps=${charge.steps.size}"
}
powerBySocBand().forEach {
lines += "soc_band ${it.fromPercent}-${it.toPercent} mean_power_kw=${format(it.meanPowerKw)}" +
" hours=${format(it.hours)}"
}
return lines
}
private fun format(value: Double?): String =
if (value == null) "unavailable" else String.format(Locale.ROOT, "%.2f", value)
companion object {
/** A bundle is bounded; the newest charges are the ones a question is asked about. */
const val MAX_DESCRIBED_CHARGES = 8
const val SOC_BAND_PERCENT = 10
}
}
/**
* The energy side of a charge, read from the ledger that is already being written.
*
* Nothing new is sampled and no new vehicle call is made. [BatteryLedgerRecorder] has been
* recording the charge gauge, the car's counters, the integrated pack energy and the odometer
* since CP-070; [BatteryExposure] already cuts that stream into charge sessions. What was
* missing is a reader: the pack's health is estimated from discharges alone, so the energy
* *entering* the pack — and with it the two facts RI-002 has been blocked on — was recorded and
* never looked at.
*
* **Sessions are not re-segmented here.** They arrive from [BatteryExposure], so the two
* screens can never disagree about what counts as a charge.
*/
class ChargeEnergyAnalyzer(
private val maxWatchedGapMs: Long = MAX_WATCHED_GAP_MS,
) {
fun analyse(
entries: List<BatteryLedgerEntry>,
sessions: List<BatteryChargeSession>,
): ChargeEnergyReport {
val ordered = entries.sortedBy { it.atMs }
val charges = sessions.map { measure(ordered, it) }
return ChargeEnergyReport(
charges = charges,
packSign = consensusSign(charges),
counterBehaviour = mostInformative(charges.map { it.counterBehaviour }),
chargingStatuses = charges.flatMap { it.chargingStatuses }.distinct().sorted(),
)
}
private fun measure(
ordered: List<BatteryLedgerEntry>,
session: BatteryChargeSession,
): ChargeEnergy {
val slice = ordered.filter { it.atMs in session.startedAtMs..session.endedAtMs }
var gap = 0L
for (index in 1 until slice.size) gap = max(gap, slice[index].atMs - slice[index - 1].atMs)
val watched = slice.size >= 2 && gap <= maxWatchedGapMs
val packDelta = span(slice.mapNotNull { it.packEnergyKwh })
val consumed = span(slice.mapNotNull { it.vehicleConsumedKwh?.toDouble() })
val regenerated = span(slice.mapNotNull { it.vehicleRegeneratedKwh?.toDouble() })
val hours = session.durationHours
val temps = slice.mapNotNull { it.outsideTempCelsius?.toDouble() }
return ChargeEnergy(
session = session,
watched = watched,
maxEntryGapMs = gap,
packDeltaKwh = packDelta,
meanPowerKw = if (watched && packDelta != null && hours > 0.0) {
abs(packDelta) / hours
} else {
null
},
counterBehaviour = behaviourOf(consumed, regenerated),
consumedDeltaKwh = consumed,
regeneratedDeltaKwh = regenerated,
movedKm = span(slice.mapNotNull { it.odometerKm?.toDouble() }),
chargingStatuses = slice.mapNotNull { it.chargingStatus }.distinct(),
steps = if (watched) steps(slice) else emptyList(),
minOutsideTempCelsius = temps.minOrNull(),
maxOutsideTempCelsius = temps.maxOrNull(),
)
}
/** Adjacent pairs that both carry the integral; a pair with none says nothing and is dropped. */
private fun steps(slice: List<BatteryLedgerEntry>): List<ChargePowerStep> =
slice.zipWithNext().mapNotNull { (a, b) ->
val from = a.packEnergyKwh ?: return@mapNotNull null
val to = b.packEnergyKwh ?: return@mapNotNull null
val ms = b.atMs - a.atMs
if (ms <= 0L || !from.isFinite() || !to.isFinite()) return@mapNotNull null
ChargePowerStep(
fromSocPercent = a.socPercent.toDouble(),
toSocPercent = b.socPercent.toDouble(),
durationMs = ms,
powerKw = abs(to - from) / (ms / 3_600_000.0),
)
}
/** Last reading minus first, or null when fewer than two of them were published. */
private fun span(values: List<Double>): Double? {
val finite = values.filter { it.isFinite() }
return if (finite.size < 2) null else finite.last() - finite.first()
}
private fun behaviourOf(consumed: Double?, regenerated: Double?): ChargeCounterBehaviour {
val deltas = listOfNotNull(consumed, regenerated)
if (deltas.isEmpty()) return ChargeCounterBehaviour.ABSENT
if (deltas.any { it <= -COUNTER_EPSILON_KWH }) return ChargeCounterBehaviour.RESET
if (deltas.any { it >= COUNTER_EPSILON_KWH }) return ChargeCounterBehaviour.ROSE
return ChargeCounterBehaviour.STATIC
}
/**
* One answer from several charges, or an admission that there is not one.
*
* Two charges that signed the pack pair differently is not a figure to average: it says the
* property means something this app has not understood, and reporting either half of it as
* the answer would settle RI-002 wrongly.
*/
private fun consensusSign(charges: List<ChargeEnergy>): ChargePackSign {
val signs = charges.map { it.sign }
val decided = signs.filter {
it == ChargePackSign.ENERGY_FELL || it == ChargePackSign.ENERGY_ROSE
}.distinct()
return when {
decided.size > 1 -> ChargePackSign.CONTRADICTORY
decided.size == 1 -> decided.first()
signs.contains(ChargePackSign.FLAT) -> ChargePackSign.FLAT
else -> ChargePackSign.UNREADABLE
}
}
/**
* A reset seen once is a fact about the car; a static reading elsewhere only says that
* charge did not end where the car clears its counters. So the set is reported by its most
* informative member rather than refused as a contradiction.
*/
private fun mostInformative(behaviours: List<ChargeCounterBehaviour>): ChargeCounterBehaviour =
INFORMATIVENESS.firstOrNull { behaviours.contains(it) } ?: ChargeCounterBehaviour.ABSENT
companion object {
/**
* How long the ledger may go quiet inside a charge before it stops being watched.
*
* Twice [BatteryLedgerRecorder.QUIET_INTERVAL_MS]: the recorder earns an entry every
* quarter of an hour on its own, so a longer silence is this app having been down rather
* than a slow charge. The margin covers a sampler that stopped for one interval.
*/
const val MAX_WATCHED_GAP_MS = 2 * BatteryLedgerRecorder.QUIET_INTERVAL_MS
/**
* Below this a charge says nothing about the sign.
*
* A watched charge of any length moves the integral by kilowatt-hours; half of one is
* comfortably below anything real and comfortably above the drift of a pack pair read
* at rest.
*/
const val MIN_ENERGY_KWH = 0.5
/** The car's counters step by a whole kilowatt-hour, so anything above noise is a move. */
const val COUNTER_EPSILON_KWH = 0.05
private val INFORMATIVENESS = listOf(
ChargeCounterBehaviour.RESET,
ChargeCounterBehaviour.ROSE,
ChargeCounterBehaviour.STATIC,
ChargeCounterBehaviour.ABSENT,
)
}
}
@@ -0,0 +1,108 @@
package com.evsuite.hardware.telemetry
import java.time.Instant
import java.time.YearMonth
import java.time.ZoneId
/**
* What a drive asked of the pack at the moments a pack minds most (CP-090): hard discharge at a
* low charge or in the cold, and regeneration pushed into a cold pack.
*
* Measured quantities only, never a wear figure. "Hard" is the trip's own: discharge above
* [HARD_SHARE] of that trip's peak, so a gentle driver's hardest moments count as well as a
* heavy one's.
*
* [TripSample.batteryPowerKw]'s sign is unvalidated on this car (RI-002), so each trip orients
* itself: the charge fell, so the way the power integral moved over the trip is discharge. A trip
* whose charge did not fall, or whose integral did not move, cannot say which way is which and
* gives no figure.
*/
data class DrivingStress(
/** Minutes of hard discharge below [LOW_SOC_PERCENT] charge or below [COLD_CELSIUS] outside. */
val stressedMinutes: Double,
/** Energy regenerated below [COLD_CELSIUS] outside; null when the temperature was never read. */
val coldRegeneratedKwh: Double?,
) {
companion object {
/**
* Null when the trip cannot say: no orientation, or more than [MAX_UNDECIDED_SHARE] of
* its time with an unread power, or a hard moment with an unread charge or temperature.
*/
fun of(samples: List<TripSample>): DrivingStress? {
val socs = samples.mapNotNull { it.socPercent }
if (socs.size < 2 || socs.first() - socs.last() < MIN_SOC_DROP) return null
val segments = samples.zipWithNext().filter { (a, b) -> b.atMs - a.atMs in 1..MAX_GAP_MS }
val net = segments.sumOf { (a, b) -> (a.batteryPowerKw ?: 0f) * hours(a, b) }
val orientation = when {
net >= MIN_NET_KWH -> 1.0
net <= -MIN_NET_KWH -> -1.0
else -> return null
}
val peak = samples.mapNotNull { it.batteryPowerKw }.maxOfOrNull { it * orientation } ?: return null
if (peak <= 0.0) return null
var totalMs = 0L
var undecidedMs = 0L
var stressedMs = 0L
var coldKnown = false
var coldRegen = 0.0
segments.forEach { (a, b) ->
val ms = b.atMs - a.atMs
totalMs += ms
// An unread power could have been a hard one.
val power = a.batteryPowerKw ?: run { undecidedMs += ms; return@forEach }
val discharge = power * orientation
val cold = a.outsideTempCelsius?.let { it < COLD_CELSIUS }
val low = a.socPercent?.let { it < LOW_SOC_PERCENT }
if (cold != null) coldKnown = true
if (cold == true && discharge < 0.0) coldRegen += -discharge * hours(a, b)
if (discharge <= HARD_SHARE * peak) return@forEach
when {
cold == true || low == true -> stressedMs += ms
cold == null || low == null -> undecidedMs += ms
}
}
if (totalMs == 0L || undecidedMs > MAX_UNDECIDED_SHARE * totalMs) return null
return DrivingStress(stressedMs / MINUTE_MS, coldRegen.takeIf { coldKnown })
}
/** Per local month, over the trips that could say; a month none could say is absent. */
fun monthly(trips: List<StoredTrip>, zone: ZoneId): List<MonthlyDrivingStress> = trips
.mapNotNull { trip -> of(trip.samples.orEmpty())?.let { trip.summary.startedAtMs to it } }
.groupBy { (atMs, _) -> YearMonth.from(Instant.ofEpochMilli(atMs).atZone(zone)) }
.map { (month, list) ->
val regen = list.mapNotNull { it.second.coldRegeneratedKwh }
MonthlyDrivingStress(
month = month,
stressedMinutes = list.sumOf { it.second.stressedMinutes },
coldRegeneratedKwh = regen.takeIf { it.isNotEmpty() }?.sum(),
trips = list.size,
)
}
.sortedBy { it.month }
private fun hours(a: TripSample, b: TripSample) = (b.atMs - a.atMs) / HOUR_MS
const val HARD_SHARE = 0.6
const val LOW_SOC_PERCENT = 20f
const val COLD_CELSIUS = 0f
/** A trip that spent less than a point cannot orient its power. */
const val MIN_SOC_DROP = 1f
const val MIN_NET_KWH = 0.1
/** A longer hole in the track is a pause, not a stretch of known power. */
const val MAX_GAP_MS = 5 * 60_000L
/** A dropped reading here and there is not a blind trip; more than this is. */
const val MAX_UNDECIDED_SHARE = 0.1
private const val MINUTE_MS = 60_000.0
private const val HOUR_MS = 3_600_000.0
}
}
data class MonthlyDrivingStress(
val month: YearMonth,
val stressedMinutes: Double,
val coldRegeneratedKwh: Double?,
/** Trips that could say; the others are not counted as zero. */
val trips: Int,
)
@@ -0,0 +1,384 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
import java.util.Locale
import kotlin.math.abs
/** How this drive compares with what the driver's own fitted history expected of it. */
enum class EcoBand {
/** Below the expectation by more than the fit's own band. */
BETTER,
/** Inside the band. A fit that does not know much says this often, and should. */
TYPICAL,
/** Above the expectation by more than the band. */
WORSE,
}
/**
* The one thing worth saying, chosen from the two the recorded signals can measure.
*
* The climate system is deliberately not here. Cabin state is passive state — an on/off flag and
* a fan level — and turning either into kilowatts is exactly what `AGENTS.md` forbids. The cabin's
* share is a fitted number that only exists after the trip, where `EnergyAttribution` produces it.
*/
enum class EcoLever {
/**
* Drag rises with the square of speed, and the fitted model carries that term, so the saving
* is arithmetic on a fit this car trained rather than a rule of thumb.
*/
CRUISE_SPEED,
/**
* Time spent accelerating or braking hard. Measured from the speed channel alone, which
* matters: on a firmware where battery power is derived rather than measured this lever is
* still a measurement.
*/
STEADINESS,
}
/**
* One piece of advice, with the number that earned it.
*
* Exactly one of the two number pairs is populated, decided by [lever]. There is no common
* currency between "14 % less at 110" and "a fifth of this drive was hard acceleration", and
* inventing one to rank them would be inventing the more important of the two figures.
*/
data class EcoAdvice(
val lever: EcoLever,
/** [EcoLever.CRUISE_SPEED]: what the fit says the slower speed costs, as a percentage saved. */
val savingPercent: Double? = null,
val fromSpeedKmh: Double? = null,
val toSpeedKmh: Double? = null,
/** [EcoLever.STEADINESS]: the share of moving time spent beyond the threshold, either sign. */
val harshSharePercent: Double? = null,
)
/**
* What this drive is costing, against what this driver's own history expected it to cost.
*
* Everything here is in **percent of charge per 100 km**, never kilowatt-hours, and that is a
* decision rather than a convenience: `EnergySnapshot.batteryPowerKw`'s sign convention is still
* unvalidated (RI-002, CP-073), so every kWh figure in this app inherits an open question. Charge
* and speed are the two signals CP-003 actually proved on this car, and a verdict the driver is
* asked to act on is built on those.
*/
data class EcoVerdict(
/**
* The verdict, and — as its band — the width of the model's own `TYPICAL` zone in percent
* per 100 km: how far [observedPercentPer100Km] would have had to move to say something
* else. A fit that knows little carries a wide one and says `TYPICAL` for almost any drive,
* which is the honest answer from a fit that knows little.
*/
val band: Provenanced<EcoBand>,
val observedPercentPer100Km: Double? = null,
val expectedPercentPer100Km: Double? = null,
/** Signed: negative is below the expectation, which is the good direction. */
val deltaPercent: Double? = null,
val meanSpeedKmh: Double? = null,
val advice: EcoAdvice? = null,
) {
/** For the diagnostic bundle: the verdict, its inputs, and any refusal, on one line each. */
fun describe(): List<String> {
val lines = ArrayList<String>()
lines += "band=" + (band.value?.name ?: "unavailable(${band.reason?.name})")
lines += "observed_percent_per_100km=${format(observedPercentPer100Km)}"
lines += "expected_percent_per_100km=${format(expectedPercentPer100Km)}"
lines += "delta_percent=${format(deltaPercent)}"
lines += "mean_speed_kmh=${format(meanSpeedKmh)}"
val advice = advice
lines += if (advice == null) {
"advice=none"
} else {
"advice=${advice.lever.name}" +
" saving_percent=${format(advice.savingPercent)}" +
" from_speed_kmh=${format(advice.fromSpeedKmh)}" +
" to_speed_kmh=${format(advice.toSpeedKmh)}" +
" harsh_share_percent=${format(advice.harshSharePercent)}"
}
return lines
}
private fun format(value: Double?): String =
if (value == null) "unavailable" else String.format(Locale.ROOT, "%.2f", value)
}
/**
* The eco verdict and its one piece of advice, live on the dashboard and replayed after the trip.
*
* **One analyzer, two callers.** The dashboard feeds it the 1 Hz speed channel as it arrives; the
* trip review replays a stored [TripSample] track through the same instance. That is the whole
* reason it lives here rather than in the app: the number the driver saw at 90 km/h and the number
* they read afterwards come out of the same code, and cannot disagree.
*
* **It costs nothing to run.** No new sampling, no vehicle call, no allocation per frame beyond one
* bounded deque of speeds. The verdict is arithmetic over a trip summary the recorder already keeps
* and a model CP-052 already trained.
*
* **It refuses rather than guesses.** No fit, a speed outside the envelope the fit was trained in,
* too little distance to divide by, too small a charge drop to read against a gauge that moves in
* whole percent — each of those is an [UnavailableReason] the rest of the app already renders, not
* a number with a shrug attached.
*/
class EcoDrivingMonitor(
private val windowMs: Long = WINDOW_MS,
private val harshAccelerationMs2: Double = HARSH_ACCELERATION_MS2,
private val maxSamples: Int = MAX_WINDOW_SAMPLES,
private val maxGapMs: Long = MAX_GAP_MS,
) {
private data class Speed(val atMs: Long, val kmh: Double)
private val window = ArrayDeque<Speed>()
init {
require(windowMs > 0L) { "the lever window must be positive" }
require(harshAccelerationMs2 > 0.0) { "the acceleration threshold must be positive" }
require(maxSamples > 1) { "a window of one sample measures nothing" }
require(maxGapMs > 0L) { "the sampling gap must be positive" }
}
/** Feed one frame. A null or negative speed is a gap in the window, never a zero. */
fun add(atMs: Long, speedKmh: Float?) {
if (window.lastOrNull()?.let { atMs <= it.atMs } == true) reset()
val speed = speedKmh?.toDouble()?.takeIf { it.isFinite() && it >= 0.0 }
if (speed != null) window.addLast(Speed(atMs, speed))
while (window.size > maxSamples) window.removeFirst()
while (window.size > 1 && atMs - window.first().atMs > windowMs) window.removeFirst()
}
fun reset() = window.clear()
/** Replays a stored track, so a finished trip is judged by the same code that judged it live. */
fun replay(samples: List<TripSample>): EcoDrivingMonitor {
reset()
samples.forEach { add(it.atMs, it.speedKmh) }
return this
}
/**
* The share of moving time spent accelerating or braking hard, whatever the cruise lever said.
*
* Live, the two levers compete for one line and the cruise lever wins when it has a measured
* saving. A trip review has no such contest: the motorway question is answered there by
* `SpeedWhatIfCalculator` from the trip's own intervals, and this is the other half.
*/
fun steadiness(): EcoAdvice? = steadinessAdvice()
/**
* @param trip the trip so far, live, or the finished one. Its charge drop and its distance are
* what the verdict is measured from.
* @param outsideTempCelsius the fit's second input; without it the model cannot be asked.
*/
fun verdict(
model: SocConsumptionModel?,
trip: EnergyTripSummary?,
outsideTempCelsius: Float?,
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
): EcoVerdict {
val advice = advice(model, outsideTempCelsius)
if (model == null) return refuse(UnavailableReason.MODEL_NOT_TRAINED, advice)
// A distance the current speed conversion did not produce may carry a 3.6x error, and a
// verdict built on it would be wrong by the same factor without saying so.
val distanceKm = trip?.modellableDistanceKm(generation)
val start = trip?.startSocPercent?.toDouble()
val end = trip?.endSocPercent?.toDouble()
val hours = trip?.durationMs?.let { it / MILLIS_PER_HOUR }
if (distanceKm == null || distanceKm < SocConsumptionFitter.MIN_SEGMENT_KM ||
start == null || end == null || hours == null || hours <= 0.0
) {
return refuse(UnavailableReason.INSUFFICIENT_SAMPLES, advice)
}
// The gauge moves in whole percent. Below the fitter's own floor the quantisation is
// larger than the thing being measured.
val drop = start - end
if (drop < SocConsumptionFitter.MIN_SOC_DROP_PERCENT) {
return refuse(UnavailableReason.INSUFFICIENT_SAMPLES, advice)
}
val meanSpeedKmh = distanceKm / hours
val temp = outsideTempCelsius?.toDouble()
?: return refuse(UnavailableReason.SIGNAL_ABSENT, advice, meanSpeedKmh)
val expected = model.predict(meanSpeedKmh, temp)
val expectedValue = expected.value
?: return refuse(
expected.reason ?: UnavailableReason.MODEL_NOT_TRAINED, advice, meanSpeedKmh
)
val observed = drop / distanceKm * PER_100_KM
// Compared against the fit's *own* band rather than a threshold invented here: a fit with
// a wide residual says TYPICAL more often, which is the correct behaviour for a fit that
// does not know much yet. Half of it, though, not all: the prediction band is 1.96
// residuals wide, so TYPICAL covered 95 % of drives and the verdict almost never said
// anything else. One residual either side leaves about a third of drives outside it.
val band = (expected.uncertainty ?: 0.0) * VERDICT_BAND_SHARE
val verdict = when {
observed < expectedValue - band -> EcoBand.BETTER
observed > expectedValue + band -> EcoBand.WORSE
else -> EcoBand.TYPICAL
}
return EcoVerdict(
band = Provenanced.estimated(verdict, band),
observedPercentPer100Km = observed,
expectedPercentPer100Km = expectedValue,
deltaPercent = (observed - expectedValue) / expectedValue * PERCENT,
meanSpeedKmh = meanSpeedKmh,
advice = advice,
)
}
private fun refuse(
reason: UnavailableReason,
advice: EcoAdvice?,
meanSpeedKmh: Double? = null,
) = EcoVerdict(
band = Provenanced.unavailable(reason),
meanSpeedKmh = meanSpeedKmh,
advice = advice,
)
/**
* At most one lever, and by precedence rather than by a ranking that would have to invent a
* comparison. The two are near-disjoint in practice anyway: cruise speed cannot fire below
* [CRUISE_MIN_KMH], and a drive spent above it is not a drive spent accelerating.
*/
private fun advice(model: SocConsumptionModel?, outsideTempCelsius: Float?): EcoAdvice? =
cruiseAdvice(model, outsideTempCelsius) ?: steadinessAdvice()
private fun cruiseAdvice(
model: SocConsumptionModel?,
outsideTempCelsius: Float?,
): EcoAdvice? {
if (model == null) return null
val temp = outsideTempCelsius?.toDouble() ?: return null
val from = windowMeanSpeedKmh() ?: return null
if (from < CRUISE_MIN_KMH) return null
val to = from - CRUISE_STEP_KMH
val at = model.predict(from, temp).value ?: return null
val slower = model.predict(to, temp).value ?: return null
val saving = (at - slower) / at * PERCENT
if (!saving.isFinite() || saving < MIN_ADVICE_SAVING_PERCENT) return null
return EcoAdvice(
lever = EcoLever.CRUISE_SPEED,
savingPercent = saving,
fromSpeedKmh = from,
toSpeedKmh = to,
)
}
/**
* The share of moving time spent accelerating or braking hard, below the advice threshold
* included.
*
* Braking counts because every hard stop throws away what regeneration could not take back,
* and because the trip review (`DrivingStyle` in EVChargePilot) has always counted it: a live
* gauge that saw only half the habit read lower than the summary of the same drive.
*
* Null only while the window holds less than [MIN_LEVER_WINDOW_MS] of movement, so a caller
* can tell "not measured yet" from "measured, and calm" — the two read the same through
* [steadiness], which answers only when there is something to say.
*/
fun harshSharePercent(): Double? {
var movingMs = 0L
var harshMs = 0L
window.zipWithNext { previous, current ->
val deltaMs = current.atMs - previous.atMs
// Beyond the gap this app allows itself to integrate over, the missing minutes are
// unknown motion. Reading them as one enormous step would invent an acceleration
// out of the app having been asleep.
if (deltaMs !in 1..maxGapMs) return@zipWithNext
if (current.kmh <= 0.0 && previous.kmh <= 0.0) return@zipWithNext
movingMs += deltaMs
val acceleration = (current.kmh - previous.kmh) / KMH_PER_MS2 / (deltaMs / 1000.0)
if (abs(acceleration) >= harshAccelerationMs2) harshMs += deltaMs
}
if (movingMs < MIN_LEVER_WINDOW_MS) return null
return harshMs.toDouble() / movingMs * PERCENT
}
private fun steadinessAdvice(): EcoAdvice? {
val share = harshSharePercent() ?: return null
if (share < MIN_HARSH_SHARE_PERCENT) return null
return EcoAdvice(lever = EcoLever.STEADINESS, harshSharePercent = share)
}
private fun windowMeanSpeedKmh(): Double? {
if (window.size < 2) return null
var weightedMs = 0L
var distanceKmh = 0.0
window.zipWithNext { previous, current ->
val deltaMs = current.atMs - previous.atMs
if (deltaMs !in 1..maxGapMs) return@zipWithNext
weightedMs += deltaMs
distanceKmh += (previous.kmh + current.kmh) / 2.0 * deltaMs
}
if (weightedMs < MIN_LEVER_WINDOW_MS) return null
return distanceKmh / weightedMs
}
companion object {
/**
* The whole of a finished drive, rather than the last three minutes of a live one.
*
* A review that judged steadiness on the final window would grade the driver on their
* arrival, and the bound that keeps the live window cheap has no purpose over a track
* that is already on the disk.
*/
fun forReplay() = EcoDrivingMonitor(
windowMs = Long.MAX_VALUE,
maxSamples = Int.MAX_VALUE,
maxGapMs = REPLAY_MAX_GAP_MS,
)
/** The share of the prediction band that counts as TYPICAL: about one residual. */
const val VERDICT_BAND_SHARE = 0.5
/** Long enough that a single roundabout is not a driving style. */
const val WINDOW_MS = 180_000L
/**
* Brisk rather than emergency. Eco-driving practice puts the useful line around here, and
* the number is a calibration knob rather than physics: a heavier car, a different tyre or
* a driver who simply disagrees moves it, and nothing else in this class assumes its value.
*
* 1.5 m/s² was too low on the car: ordinary town drives counted 7 to 16 hard stops every
* 10 km (bundle of 2026-10-18), and regeneration on High alone comes close to that line
* with no brake pedal at all. 2.5 sits above lift-off, where the brakes do the work.
*/
const val HARSH_ACCELERATION_MS2 = 2.5
/** Below this the levers are measuring a traffic light, not a drive. */
const val MIN_LEVER_WINDOW_MS = 60_000L
/** Under this share, saying anything would be nagging about noise. */
const val MIN_HARSH_SHARE_PERCENT = 12.0
/** Motorway, where the square term is worth more than everything else on this list. */
const val CRUISE_MIN_KMH = 95.0
/** The step the advice proposes: large enough to matter, small enough to be plausible. */
const val CRUISE_STEP_KMH = 10.0
/** A saving smaller than this is not worth a line the driver has to read. */
const val MIN_ADVICE_SAVING_PERCENT = 3.0
/** The app's own integration rule: past five seconds, the motion in between is unknown. */
const val MAX_GAP_MS = 5_000L
/** Stored samples cross the five-second boundary on the next live telemetry frame. */
private const val REPLAY_MAX_GAP_MS = TripSampleTrack.DEFAULT_INTERVAL_MS * 2
/** Three minutes at 1 Hz, with room for a faster caller. */
const val MAX_WINDOW_SAMPLES = 1_024
private const val MILLIS_PER_HOUR = 3_600_000.0
private const val PER_100_KM = 100.0
private const val PERCENT = 100.0
/** 1 m/s² is 3.6 km/h per second. */
private const val KMH_PER_MS2 = 3.6
}
}
@@ -0,0 +1,81 @@
package com.evsuite.hardware.telemetry
import kotlin.math.roundToInt
/**
* A trip out of 100, and the three measured parts it is made of.
*
* Every part is a measurement this car is known to give: hard braking and hard acceleration
* from the speed channel, counted at 1 Hz by [EnergyTripAccumulator], and the charge against
* the driver's own fitted expectation from [EcoVerdict]. A part that could not be measured is
* null and the total is taken over the others; a trip where none could be measured has no score
* at all, never a default one.
*
* The scales are calibration knobs rather than physics. They are set so a calm town drive at
* the driver's usual consumption lands in the sixties and the eighties need effort: a score that
* starts near 100 has nothing left to reward.
*/
data class EcoScore(
val total: Int,
/** Hard braking per 10 km: braking hard throws away what regeneration could not take back. */
val anticipation: Int?,
/** Share of moving time beyond the hard-acceleration threshold, either sign. */
val smoothness: Int?,
/** Charge against the driver's own expectation at this speed and temperature. */
val efficiency: Int?,
) {
companion object {
fun of(summary: EnergyTripSummary, verdict: EcoVerdict?): EcoScore? {
val distanceKm = summary.recordedDistanceKm
val anticipation = summary.harshBrakings?.let { brakings ->
distanceKm?.takeIf { it >= MIN_DISTANCE_KM }?.let { km ->
scale(brakings / km * PER_10_KM, best = 0.0, worst = BRAKINGS_PER_10_KM_AT_ZERO)
}
}
val smoothness = summary.harshMs?.let { harsh ->
summary.movingMs?.takeIf { it >= MIN_MOVING_MS }?.let { moving ->
scale(harsh.toDouble() / moving * PERCENT, best = 0.0, worst = HARSH_SHARE_AT_ZERO)
}
}
val efficiency = verdict?.takeIf { it.band.value != null }?.deltaPercent?.let {
scale(it, best = DELTA_AT_FULL, worst = DELTA_AT_ZERO)
}
val parts = listOf(
anticipation to ANTICIPATION_WEIGHT,
smoothness to SMOOTHNESS_WEIGHT,
efficiency to EFFICIENCY_WEIGHT,
).filter { it.first != null }
if (parts.isEmpty()) return null
val total = parts.sumOf { it.first!! * it.second } / parts.sumOf { it.second }
return EcoScore(total.roundToInt(), anticipation, smoothness, efficiency)
}
/** 100 at [best], 0 at [worst], straight in between and clamped outside. */
private fun scale(value: Double, best: Double, worst: Double): Int? {
if (!value.isFinite()) return null
val share = ((worst - value) / (worst - best)).coerceIn(0.0, 1.0)
return (share * PERCENT).roundToInt()
}
/** Shorter than the fitter's own segment, a single junction would be a habit. */
const val MIN_DISTANCE_KM = 2.0
const val MIN_MOVING_MS = 60_000L
/** Six hard stops every 10 km is a drive with no anticipation left to measure. */
const val BRAKINGS_PER_10_KM_AT_ZERO = 6.0
/** A quarter of moving time spent hard is the bottom of the scale. */
const val HARSH_SHARE_AT_ZERO = 25.0
/** 15 % under the driver's own expectation is full marks; 25 % over is none. */
const val DELTA_AT_FULL = -15.0
const val DELTA_AT_ZERO = 25.0
const val ANTICIPATION_WEIGHT = 0.35
const val SMOOTHNESS_WEIGHT = 0.25
const val EFFICIENCY_WEIGHT = 0.40
private const val PER_10_KM = 10.0
private const val PERCENT = 100.0
}
}
@@ -1,5 +1,8 @@
package com.evsuite.hardware.telemetry package com.evsuite.hardware.telemetry
import com.evsuite.hardware.model.DriveMode
import com.evsuite.hardware.model.RegenLevel
import com.evsuite.hardware.FirmwareInfo import com.evsuite.hardware.FirmwareInfo
/** One immutable, nullable, read-only view of the vehicle's energy state. */ /** One immutable, nullable, read-only view of the vehicle's energy state. */
@@ -33,6 +36,18 @@ data class EnergySnapshot(
val tirePressures: TirePressureSnapshot, val tirePressures: TirePressureSnapshot,
val batteryVoltageV: Float? = null, val batteryVoltageV: Float? = null,
val batteryCurrentA: Float? = null, val batteryCurrentA: Float? = null,
/**
* CP-086. The widest-open window, 0 closed to 100 open; null where the car does not answer,
* never 0 — a closed window and an unread one are different facts to the attribution.
*/
val widestWindowPercent: Int? = null,
/**
* The vendor's own drive mode and regen codes, unmapped: [DriveMode.ofRaw] and
* [RegenLevel.ofRaw] decode them, and a code neither knows stays a code rather than
* becoming NORMAL or MEDIUM. Null where the car did not answer.
*/
val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null,
) { ) {
val hasVehicleData: Boolean val hasVehicleData: Boolean
get() = socPercent != null || rangeKm != null || speedKmh != null || get() = socPercent != null || rangeKm != null || speedKmh != null ||
@@ -40,7 +55,8 @@ data class EnergySnapshot(
batteryTempCelsius != null || batteryEnergyKwh != null || batteryCapacityKwh != null || batteryTempCelsius != null || batteryEnergyKwh != null || batteryCapacityKwh != null ||
odometerKm != null || chargePortConnected != null || chargingStatus != null || odometerKm != null || chargePortConnected != null || chargingStatus != null ||
vehicleConsumedKwh != null || vehicleRegeneratedKwh != null || vehicleConsumedKwh != null || vehicleRegeneratedKwh != null ||
parked != null || climate.hasData || tirePressures.hasData parked != null || climate.hasData || tirePressures.hasData ||
widestWindowPercent != null || driveModeRaw != null || regenLevelRaw != null
} }
data class TirePressureSnapshot( data class TirePressureSnapshot(
@@ -6,6 +6,7 @@ import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.saic.SaicCharging import com.evsuite.hardware.saic.SaicCharging
import com.evsuite.hardware.saic.SaicClimate import com.evsuite.hardware.saic.SaicClimate
import com.evsuite.hardware.saic.SaicHub import com.evsuite.hardware.saic.SaicHub
import com.evsuite.hardware.saic.SaicVehicleControl
/** Injectable signal seam: production uses [EvHardwareEnergySignalSource], tests use a fake. */ /** Injectable signal seam: production uses [EvHardwareEnergySignalSource], tests use a fake. */
interface EnergySignalSource { interface EnergySignalSource {
@@ -28,6 +29,11 @@ interface EnergySignalSource {
fun parked(): Boolean? fun parked(): Boolean?
fun batteryVoltageV(): Float? = null fun batteryVoltageV(): Float? = null
fun batteryCurrentA(): Float? = null fun batteryCurrentA(): Float? = null
/** Widest window opening in percent; null where the firmware has no read. */
fun widestWindowPercent(): Int? = null
/** Raw vendor codes; null where the firmware has no read. */
fun driveModeRaw(): Int? = null
fun regenLevelRaw(): Int? = null
fun climate(): ClimateSnapshot fun climate(): ClimateSnapshot
fun tirePressures(): TirePressureSnapshot fun tirePressures(): TirePressureSnapshot
/** Probe-only values excluded from [EnergySnapshot] and normal consumers. */ /** Probe-only values excluded from [EnergySnapshot] and normal consumers. */
@@ -70,6 +76,9 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
override fun parked() = EVHardware.isVehicleInPark() override fun parked() = EVHardware.isVehicleInPark()
override fun batteryVoltageV() = EVHardware.getVendorBatteryVoltageV() override fun batteryVoltageV() = EVHardware.getVendorBatteryVoltageV()
override fun batteryCurrentA() = EVHardware.getVendorBatteryCurrentA() override fun batteryCurrentA() = EVHardware.getVendorBatteryCurrentA()
override fun widestWindowPercent() = SaicVehicleControl.widestWindowPercent()
override fun driveModeRaw() = EVHardware.getDriveModeRaw()
override fun regenLevelRaw() = EVHardware.getRegenLevelRaw()
override fun tirePressures() = TirePressureSnapshot( override fun tirePressures() = TirePressureSnapshot(
frontLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_LEFT), frontLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_LEFT),
frontRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_RIGHT), frontRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_RIGHT),
@@ -80,7 +89,23 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
TelemetryEvidenceProbe( TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH, TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
EVHardware.probeCurrentBatteryCapacityWh()?.toDouble(), EVHardware.probeCurrentBatteryCapacityWh()?.toDouble(),
) ),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_CHARGE_STATUS,
EVHardware.probeVendorChargeStatus()?.toDouble(),
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_PLUG_AC,
EVHardware.probeVendorPlugAc()?.toDouble(),
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_PLUG_DC,
EVHardware.probeVendorPlugDc()?.toDouble(),
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_ENERGY_SINCE_CHARGE_KWH,
EVHardware.probeVendorEnergySinceChargeKwh()?.toDouble(),
),
) )
override fun climate() = ClimateSnapshot( override fun climate() = ClimateSnapshot(
powerOn = SaicClimate.powerOn(), powerOn = SaicClimate.powerOn(),
@@ -94,11 +119,9 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
?: EVHardware.getTemperatureSetCelsius(), ?: EVHardware.getTemperatureSetCelsius(),
// The passenger setpoint is its own signal only when the car publishes one: the vendor // 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 // 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, // dropped upstream. Null then, not the driver's number: a screen that shows both seats
// so that is what the passenger reads — the same number, because it is the same air. // the same air says so itself, and evidence captured here must not call a copy a reading.
passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat() passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat(),
?: SaicClimate.driverTemp()?.toFloat()
?: EVHardware.getTemperatureSetCelsius(),
) )
} }
@@ -128,6 +151,9 @@ class EnergyTelemetryReader(private val source: EnergySignalSource) {
tirePressures = source.tirePressures(), tirePressures = source.tirePressures(),
batteryVoltageV = source.batteryVoltageV()?.takeIf { it.isFinite() }, batteryVoltageV = source.batteryVoltageV()?.takeIf { it.isFinite() },
batteryCurrentA = source.batteryCurrentA()?.takeIf { it.isFinite() }, batteryCurrentA = source.batteryCurrentA()?.takeIf { it.isFinite() },
widestWindowPercent = source.widestWindowPercent()?.takeIf { it in 0..100 },
driveModeRaw = source.driveModeRaw(),
regenLevelRaw = source.regenLevelRaw(),
) )
/** Reads CP-004 candidates only when an unstable evidence capture explicitly requests them. */ /** Reads CP-004 candidates only when an unstable evidence capture explicitly requests them. */
@@ -32,6 +32,20 @@ data class EnergyTripSummary(
* train on it. See [VehicleSpeedEvidence]. * train on it. See [VehicleSpeedEvidence].
*/ */
val speedEvidence: VehicleSpeedEvidence? = null, val speedEvidence: VehicleSpeedEvidence? = null,
/**
* How the trip was driven, counted at the live sampling rate over the whole trip.
*
* The stored track is one sample per five seconds, and a two-second hard stop averaged over
* five reads as a gentle one: replayed, the same drive came out calmer than it was. Null on
* trips recorded before these were counted, which a reader treats as "not counted", never 0.
*/
val movingMs: Long? = null,
val harshMs: Long? = null,
val harshAccelerations: Int? = null,
val harshBrakings: Int? = null,
/** The vendor drive mode and regen codes held for the longest share of the trip. */
val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null,
) { ) {
/** /**
* The distance, only when the conversion behind it is the one believed correct today. * The distance, only when the conversion behind it is the one believed correct today.
@@ -75,6 +89,13 @@ class EnergyTripAccumulator(
private var hasPowerInterval = false private var hasPowerInterval = false
private var recordedMs = 0L private var recordedMs = 0L
private var latestSocPercent = startSocPercent private var latestSocPercent = startSocPercent
private var movingMs = 0L
private var harshMs = 0L
private var harshAccelerations = 0
private var harshBrakings = 0
private var harshSign = 0
private val modeMs = HashMap<Int, Long>()
private val regenMs = HashMap<Int, Long>()
fun add(sample: EnergySnapshot) { fun add(sample: EnergySnapshot) {
track.add(sample) track.add(sample)
@@ -90,6 +111,7 @@ class EnergyTripAccumulator(
hasSpeedInterval = true hasSpeedInterval = true
distanceKm += ((previous.speedKmh + sample.speedKmh) / 2.0) * hours distanceKm += ((previous.speedKmh + sample.speedKmh) / 2.0) * hours
} }
countStyle(previous, sample, gapMs)
if (previous.batteryPowerKw != null && sample.batteryPowerKw != null) { if (previous.batteryPowerKw != null && sample.batteryPowerKw != null) {
hasPowerInterval = true hasPowerInterval = true
val intervalKwh = val intervalKwh =
@@ -99,10 +121,36 @@ class EnergyTripAccumulator(
} }
} }
/**
* One interval of driving style. A hard stretch counts once however many seconds it lasts:
* a driver asked to brake less hard three times wants three to be the number, not eleven.
*/
private fun countStyle(previous: EnergySnapshot, sample: EnergySnapshot, gapMs: Long) {
sample.driveModeRaw?.let { modeMs.merge(it, gapMs, Long::plus) }
sample.regenLevelRaw?.let { regenMs.merge(it, gapMs, Long::plus) }
val from = previous.speedKmh ?: return
val to = sample.speedKmh ?: return
if (from <= 0f && to <= 0f) {
harshSign = 0
return
}
movingMs += gapMs
val acceleration = (to - from) / KMH_PER_MS2 / (gapMs / 1000.0)
val sign = when {
acceleration >= EcoDrivingMonitor.HARSH_ACCELERATION_MS2 -> 1
acceleration <= -EcoDrivingMonitor.HARSH_ACCELERATION_MS2 -> -1
else -> 0
}
if (sign != 0) harshMs += gapMs
if (sign == 1 && harshSign != 1) harshAccelerations++
if (sign == -1 && harshSign != -1) harshBrakings++
harshSign = sign
}
fun samples(): List<TripSample> = track.samples() fun samples(): List<TripSample> = track.samples()
fun snapshot(nowMs: Long) = EnergyTripSummary( fun snapshot(nowMs: Long) = EnergyTripSummary(
startedAtMs = startedAtMs, startedAtMs = startedAtMs(nowMs),
endedAtMs = nowMs, endedAtMs = nowMs,
durationMs = recordedMs, durationMs = recordedMs,
distanceKm = distanceKm, distanceKm = distanceKm,
@@ -113,9 +161,30 @@ class EnergyTripAccumulator(
distanceAvailable = hasSpeedInterval, distanceAvailable = hasSpeedInterval,
speedEvidence = speedEvidence.takeIf { hasSpeedInterval }, speedEvidence = speedEvidence.takeIf { hasSpeedInterval },
batteryPowerEvidence = batteryPowerEvidence.takeIf { hasPowerInterval }, batteryPowerEvidence = batteryPowerEvidence.takeIf { hasPowerInterval },
movingMs = movingMs.takeIf { hasSpeedInterval },
harshMs = harshMs.takeIf { hasSpeedInterval },
harshAccelerations = harshAccelerations.takeIf { hasSpeedInterval },
harshBrakings = harshBrakings.takeIf { hasSpeedInterval },
driveModeRaw = modeMs.maxByOrNull { it.value }?.key,
regenLevelRaw = regenMs.maxByOrNull { it.value }?.key,
) )
private companion object { const val MAX_SAMPLE_GAP_MS = 5_000L } /**
* A trip that started before the head unit's clock was set (it boots on its factory date and
* syncs later) would sort years back. Once the clock is right, its start is the end minus the
* time driven; the jump itself is a gap [add] already refuses to count.
*/
private fun startedAtMs(nowMs: Long): Long =
if (startedAtMs < BatteryLedgerRecorder.MIN_PLAUSIBLE_CLOCK_MS &&
nowMs >= BatteryLedgerRecorder.MIN_PLAUSIBLE_CLOCK_MS
) nowMs - recordedMs else startedAtMs
private companion object {
const val MAX_SAMPLE_GAP_MS = 5_000L
/** 1 m/s² is 3.6 km/h per second. */
const val KMH_PER_MS2 = 3.6
}
} }
/** Process-local recording state shared by any energy dashboard in the same app process. */ /** Process-local recording state shared by any energy dashboard in the same app process. */
@@ -43,7 +43,7 @@ data class TripHistoryFile(
class EnergyTripHistoryStore( class EnergyTripHistoryStore(
private val target: File, private val target: File,
private val maxTrips: Int = 200, private val maxTrips: Int = 200,
private val maxBytes: Int = 512 * 1024, private val maxBytes: Int = 2 * 1024 * 1024,
private val gson: Gson = Gson(), private val gson: Gson = Gson(),
) { ) {
/** Trips with whatever tracks survived eviction, newest first. */ /** Trips with whatever tracks survived eviction, newest first. */
@@ -104,6 +104,7 @@ class TelemetryEvidenceRecorder {
record(CHARGING_STATUS, snapshot.chargingStatus?.toDouble(), at) record(CHARGING_STATUS, snapshot.chargingStatus?.toDouble(), at)
record(CHARGE_PORT_CONNECTED, snapshot.chargePortConnected, at) record(CHARGE_PORT_CONNECTED, snapshot.chargePortConnected, at)
record(PARKED, snapshot.parked, at) record(PARKED, snapshot.parked, at)
record(WINDOWS_WIDEST_PERCENT, snapshot.widestWindowPercent?.toDouble(), at)
val climate = snapshot.climate val climate = snapshot.climate
record(CLIMATE_POWER_ON, climate.powerOn, at) record(CLIMATE_POWER_ON, climate.powerOn, at)
@@ -250,6 +251,7 @@ class TelemetryEvidenceRecorder {
const val CHARGING_STATUS = "chargingStatus" const val CHARGING_STATUS = "chargingStatus"
const val CHARGE_PORT_CONNECTED = "chargePortConnected" const val CHARGE_PORT_CONNECTED = "chargePortConnected"
const val PARKED = "parked" const val PARKED = "parked"
const val WINDOWS_WIDEST_PERCENT = "windows.widestPercent"
const val CLIMATE_POWER_ON = "climate.powerOn" const val CLIMATE_POWER_ON = "climate.powerOn"
const val CLIMATE_AC_ON = "climate.acOn" const val CLIMATE_AC_ON = "climate.acOn"
const val CLIMATE_AUTO_ON = "climate.autoOn" const val CLIMATE_AUTO_ON = "climate.autoOn"
@@ -261,6 +263,10 @@ class TelemetryEvidenceRecorder {
const val CLIMATE_PASSENGER_TARGET_C = "climate.passengerTargetCelsius" const val CLIMATE_PASSENGER_TARGET_C = "climate.passengerTargetCelsius"
const val CANDIDATE_CURRENT_BATTERY_CAPACITY_WH = const val CANDIDATE_CURRENT_BATTERY_CAPACITY_WH =
"candidate.evCurrentBatteryCapacityWh" "candidate.evCurrentBatteryCapacityWh"
const val CANDIDATE_VENDOR_CHARGE_STATUS = "candidate.vendorChargeStatus"
const val CANDIDATE_VENDOR_PLUG_AC = "candidate.vendorPlugAc"
const val CANDIDATE_VENDOR_PLUG_DC = "candidate.vendorPlugDc"
const val CANDIDATE_VENDOR_ENERGY_SINCE_CHARGE_KWH = "candidate.vendorEnergySinceChargeKwh"
} }
} }
@@ -73,7 +73,8 @@ class TripDetector {
transition(State.RECORDING, timestampMs) transition(State.RECORDING, timestampMs)
Result(state) Result(state)
} }
elapsed(timestampMs) >= STOP_DEBOUNCE_MS && endConfirmed(snapshot) -> { elapsed(timestampMs) >= STOP_DEBOUNCE_MS && endConfirmed(snapshot) ||
elapsed(timestampMs) >= PARKED_STOP_DEBOUNCE_MS && endObserved(snapshot) -> {
transition(State.IDLE, timestampMs) transition(State.IDLE, timestampMs)
Result(state, Event.STOP) Result(state, Event.STOP)
} }
@@ -117,9 +118,13 @@ class TripDetector {
private fun endConfirmed(snapshot: EnergySnapshot): Boolean { private fun endConfirmed(snapshot: EnergySnapshot): Boolean {
val confirmationAvailable = snapshot.parked != null || snapshot.chargePortConnected != null val confirmationAvailable = snapshot.parked != null || snapshot.chargePortConnected != null
return !confirmationAvailable || snapshot.parked == true || snapshot.chargePortConnected == true return !confirmationAvailable || endObserved(snapshot)
} }
/** The car said so itself: a gear in P or a cable in the port is not a traffic stop. */
private fun endObserved(snapshot: EnergySnapshot): Boolean =
snapshot.parked == true || snapshot.chargePortConnected == true
companion object { companion object {
/** Ignores parking creep and noisy zero readings; 5 km/h is unambiguous road motion. */ /** Ignores parking creep and noisy zero readings; 5 km/h is unambiguous road motion. */
const val START_SPEED_KMH = 5.0 const val START_SPEED_KMH = 5.0
@@ -133,6 +138,13 @@ class TripDetector {
/** Longer than the ticket's 90-second traffic stop while still practical at arrival. */ /** Longer than the ticket's 90-second traffic stop while still practical at arrival. */
const val STOP_DEBOUNCE_MS = 120_000L const val STOP_DEBOUNCE_MS = 120_000L
/**
* The SWI68 head unit powers off about twelve seconds after P (2026-10-16: P at
* 12:13:54, off at 12:14:06), so the two-minute timer never ran out and every
* automatic trip died with the process. A confirmed P needs no traffic allowance.
*/
const val PARKED_STOP_DEBOUNCE_MS = 2_000L
/** Five seconds tolerates scheduler jitter but cannot bridge a meaningful telemetry hole. */ /** Five seconds tolerates scheduler jitter but cannot bridge a meaningful telemetry hole. */
const val MAX_OBSERVATION_GAP_MS = 5_000L const val MAX_OBSERVATION_GAP_MS = 5_000L
} }
@@ -24,6 +24,14 @@ data class TripSample(
val climatePowerOn: Boolean?, val climatePowerOn: Boolean?,
val climateAcOn: Boolean?, val climateAcOn: Boolean?,
val climateFanLevel: Int?, val climateFanLevel: Int?,
/** CP-086. Absent from trips stored before it, which Gson reads back as null: unknown. */
val widestWindowPercent: Int? = null,
/**
* Raw vendor drive mode and regen codes, as [EnergySnapshot] carries them. Ints rather than
* the enums: Gson writes an enum by its constant name, which a release build may rename.
*/
val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null,
) { ) {
companion object { companion object {
fun of(snapshot: EnergySnapshot) = TripSample( fun of(snapshot: EnergySnapshot) = TripSample(
@@ -37,6 +45,9 @@ data class TripSample(
climatePowerOn = snapshot.climate.powerOn, climatePowerOn = snapshot.climate.powerOn,
climateAcOn = snapshot.climate.acOn, climateAcOn = snapshot.climate.acOn,
climateFanLevel = snapshot.climate.fanLevel, climateFanLevel = snapshot.climate.fanLevel,
widestWindowPercent = snapshot.widestWindowPercent,
driveModeRaw = snapshot.driveModeRaw,
regenLevelRaw = snapshot.regenLevelRaw,
) )
} }
} }
@@ -7,7 +7,14 @@ import com.evsuite.hardware.telemetry.UnavailableReason
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
enum class ResidualContext { CLIMATE_ACTIVE, CLIMATE_INACTIVE, CLIMATE_UNKNOWN } /**
* Which auxiliary load an interval's residual may belong to.
*
* CP-086: [WINDOWS_OPEN] takes an interval with a window open at either end out of the climate
* groups, so the drag of open glass never reads as cabin energy. Its residual may still carry
* the climate system if that ran at the same time; its average fan level says whether it did.
*/
enum class ResidualContext { CLIMATE_ACTIVE, CLIMATE_INACTIVE, CLIMATE_UNKNOWN, WINDOWS_OPEN }
enum class ResidualFinding { enum class ResidualFinding {
DISTINGUISHABLE, DISTINGUISHABLE,
@@ -115,7 +122,11 @@ object EnergyAttributionCalculator {
val modelled = predictedConsumption * distanceKm / 100.0 val modelled = predictedConsumption * distanceKm / 100.0
val uncertainty = predictedUncertainty * distanceKm / 100.0 val uncertainty = predictedUncertainty * distanceKm / 100.0
val measured = (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * hours val measured = (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * hours
val context = climateContext(previous, current) val context = if (windowOpen(previous) || windowOpen(current)) {
ResidualContext.WINDOWS_OPEN
} else {
climateContext(previous, current)
}
tractionKwh += modelled tractionKwh += modelled
tractionUncertaintyKwh += uncertainty tractionUncertaintyKwh += uncertainty
@@ -159,6 +170,9 @@ object EnergyAttributionCalculator {
} }
} }
private fun windowOpen(sample: TripSample): Boolean =
sample.widestWindowPercent?.let { it > 0 } == true
private fun climateActive(sample: TripSample): Boolean? = when { private fun climateActive(sample: TripSample): Boolean? = when {
sample.climatePowerOn == true || sample.climateAcOn == true || sample.climatePowerOn == true || sample.climateAcOn == true ||
sample.climateFanLevel?.let { it > 0 } == true -> true sample.climateFanLevel?.let { it > 0 } == true -> true
@@ -6,6 +6,7 @@ import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.telemetry.StoredTrip import com.evsuite.hardware.telemetry.StoredTrip
import com.evsuite.hardware.telemetry.UnavailableReason import com.evsuite.hardware.telemetry.UnavailableReason
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt import kotlin.math.sqrt
sealed interface EnergyModelTrainingResult { sealed interface EnergyModelTrainingResult {
@@ -40,29 +41,33 @@ class EnergyModelTrainer(
trips: List<StoredTrip>, trips: List<StoredTrip>,
evidence: BatteryPowerEvidence, evidence: BatteryPowerEvidence,
): EnergyModelTrainingResult { ): EnergyModelTrainingResult {
// Both fits are accumulated in the one pass; the temperature span picks which to solve.
val equations = LeastSquares3() val equations = LeastSquares3()
val narrowed = LeastSquares3(2)
var minSpeed = Double.POSITIVE_INFINITY var minSpeed = Double.POSITIVE_INFINITY
var maxSpeed = Double.NEGATIVE_INFINITY var maxSpeed = Double.NEGATIVE_INFINITY
var minTemp = Double.POSITIVE_INFINITY var minTemp = Double.POSITIVE_INFINITY
var maxTemp = Double.NEGATIVE_INFINITY var maxTemp = Double.NEGATIVE_INFINITY
val count = forEachUsable(trips, evidence) { speed, temp, consumption -> val count = forEachUsable(trips, evidence) { speed, temp, consumption ->
equations.add(speed, temp, consumption) equations.add(speed, temp, consumption)
narrowed.add(speed, temp, consumption)
minSpeed = minOf(minSpeed, speed) minSpeed = minOf(minSpeed, speed)
maxSpeed = maxOf(maxSpeed, speed) maxSpeed = maxOf(maxSpeed, speed)
minTemp = minOf(minTemp, temp) minTemp = minOf(minTemp, temp)
maxTemp = maxOf(maxTemp, temp) maxTemp = maxOf(maxTemp, temp)
} }
if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH || if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH) {
maxTemp - minTemp < MIN_TEMPERATURE_SPAN_CELSIUS
) {
return EnergyModelTrainingResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) return EnergyModelTrainingResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
} }
val coefficients = equations.solve() // As in SocConsumptionFitter: one kind of weather is no evidence about weather, so two
// coefficients are fitted and the narrow envelope refuses the cold instead of guessing.
val features = if (maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
val coefficients = (if (features == 3) equations else narrowed).solve()
?: return EnergyModelTrainingResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) ?: return EnergyModelTrainingResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
val rolling = coefficients[0] val rolling = coefficients[0]
val aero = coefficients[1] val aero = coefficients[1]
val thermal = coefficients[2] val thermal = if (features == 3) coefficients[2] else 0.0
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 || if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 ||
coefficients.any { !it.isFinite() } coefficients.any { !it.isFinite() }
) { ) {
@@ -93,7 +98,19 @@ class EnergyModelTrainer(
return EnergyModelTrainingResult.Ready(model) return EnergyModelTrainingResult.Ready(model)
} }
private inline fun forEachUsable( /**
* Calls [block] once per kilometre of driving, with its mean speed, mean temperature and
* gross consumption, and returns how many kilometres it reported.
*
* A 5-second sample is not a consumption. The one taken while pulling away from a light
* costs several times the one taken cruising, and a fit made per sample read that spread
* as model error and refused town driving for good. Over a kilometre the accelerations
* average into what the road actually cost, which is also what the attribution predicts.
* Standing still is skipped without ending the window: it has no speed to be modelled at.
* A hole in the track does end it, as in SocConsumptionFitter, so no window spans
* kilometres nobody recorded.
*/
private fun forEachUsable(
trips: List<StoredTrip>, trips: List<StoredTrip>,
evidence: BatteryPowerEvidence, evidence: BatteryPowerEvidence,
block: (speedKmh: Double, outsideTempCelsius: Double, consumption: Double) -> Unit, block: (speedKmh: Double, outsideTempCelsius: Double, consumption: Double) -> Unit,
@@ -101,22 +118,55 @@ class EnergyModelTrainer(
var accepted = 0 var accepted = 0
for (trip in trips) { for (trip in trips) {
if (trip.summary.batteryPowerEvidence != evidence) continue if (trip.summary.batteryPowerEvidence != evidence) continue
for (sample in trip.samples.orEmpty()) { val samples = trip.samples.orEmpty()
val speed = sample.speedKmh?.toDouble() var km = 0.0
val power = sample.batteryPowerKw?.toDouble() var hours = 0.0
val temp = sample.outsideTempCelsius?.toDouble() var kwh = 0.0
if (speed == null || power == null || temp == null || var tempSum = 0.0
!speed.isFinite() || speed !in MIN_SPEED_KMH..MAX_SPEED_KMH || var tempCount = 0
!power.isFinite() || power <= 0.0 || for (index in 1 until samples.size) {
!temp.isFinite() || temp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS val previous = samples[index - 1]
val current = samples[index]
val durationMs = current.atMs - previous.atMs
val speed = mean(previous.speedKmh, current.speedKmh)
val previousPower = previous.batteryPowerKw?.toDouble()
val currentPower = current.batteryPowerKw?.toDouble()
val temp = mean(previous.outsideTempCelsius, current.outsideTempCelsius)
if (speed == null || temp == null || previousPower == null ||
currentPower == null || !previousPower.isFinite() ||
!currentPower.isFinite() || durationMs !in 1..MAX_SAMPLE_GAP_MS
) {
km = 0.0
hours = 0.0
kwh = 0.0
tempSum = 0.0
tempCount = 0
continue
}
if (speed < MIN_SPEED_KMH) continue
val intervalHours = durationMs / MILLIS_PER_HOUR
km += speed * intervalHours
hours += intervalHours
// Gross, as the attribution measures it: regeneration is a separate ledger line.
kwh += (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * intervalHours
tempSum += temp
tempCount++
if (km < WINDOW_KM) continue
val meanSpeed = km / hours
val meanTemp = tempSum / tempCount
val consumption = kwh * 100.0 / km
km = 0.0
hours = 0.0
kwh = 0.0
tempSum = 0.0
tempCount = 0
if (meanSpeed !in MIN_SPEED_KMH..MAX_SPEED_KMH ||
meanTemp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS ||
consumption !in 0.0..MAX_CONSUMPTION_KWH_PER_100_KM
) { ) {
continue continue
} }
val consumption = power * 100.0 / speed block(meanSpeed, meanTemp, consumption)
if (!consumption.isFinite() || consumption !in 0.0..MAX_CONSUMPTION_KWH_PER_100_KM) {
continue
}
block(speed, temp, consumption)
accepted++ accepted++
if (accepted == maxSamples) return accepted if (accepted == maxSamples) return accepted
} }
@@ -124,9 +174,19 @@ class EnergyModelTrainer(
return accepted return accepted
} }
private fun mean(a: Float?, b: Float?): Double? {
val first = a?.toDouble()?.takeIf { it.isFinite() } ?: return null
val second = b?.toDouble()?.takeIf { it.isFinite() } ?: return null
return (first + second) / 2.0
}
companion object { companion object {
const val MIN_TRAINING_SAMPLES = 60 /** Kilometre windows, not samples: a dozen, as SocConsumptionFitter asks segments. */
const val MAX_TRAINING_SAMPLES = 50_000 const val MIN_TRAINING_SAMPLES = 12
const val MAX_TRAINING_SAMPLES = 20_000
const val WINDOW_KM = 1.0
private const val MAX_SAMPLE_GAP_MS = 120_000L
private const val MILLIS_PER_HOUR = 3_600_000.0
const val MAX_RESIDUAL_RMSE_KWH_PER_100_KM = 12.0 const val MAX_RESIDUAL_RMSE_KWH_PER_100_KM = 12.0
private const val MIN_SPEED_KMH = 10.0 private const val MIN_SPEED_KMH = 10.0
private const val MAX_SPEED_KMH = 180.0 private const val MAX_SPEED_KMH = 180.0
@@ -6,6 +6,7 @@ import com.evsuite.hardware.telemetry.Provenanced
import com.evsuite.hardware.telemetry.StoredTrip import com.evsuite.hardware.telemetry.StoredTrip
import com.evsuite.hardware.telemetry.TripSample import com.evsuite.hardware.telemetry.TripSample
import com.evsuite.hardware.telemetry.UnavailableReason import com.evsuite.hardware.telemetry.UnavailableReason
import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -148,8 +149,8 @@ class SocConsumptionFitter(
* altogether — a trip skipped for a speed conversion that is no longer believed leaves no * altogether — a trip skipped for a speed conversion that is no longer believed leaves no
* trace at all, and a history of them looks exactly like a history of short drives. * trace at all, and a history of them looks exactly like a history of short drives.
* *
* Diagnostic only: it runs the same collection pass as [fit] a second time, so call it * Diagnostic only: it runs the same collection pass and solve as [fit] a second time, so
* from a probe and not from a screen. * call it from a probe and not from a screen.
*/ */
fun describe( fun describe(
trips: List<StoredTrip>, trips: List<StoredTrip>,
@@ -161,9 +162,20 @@ class SocConsumptionFitter(
"segments=${segments.size}/$MIN_SEGMENTS" "segments=${segments.size}/$MIN_SEGMENTS"
if (segments.isEmpty()) return head if (segments.isEmpty()) return head
val span = segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh } val span = segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh }
return "$head span=${span.roundToInt()}/${MIN_SPEED_SPAN_KMH.roundToInt()} km/h" val line = "$head span=${span.roundToInt()}/${MIN_SPEED_SPAN_KMH.roundToInt()} km/h"
if (segments.size < MIN_SEGMENTS || span < MIN_SPEED_SPAN_KMH) return line
// Past both gates the line used to stop, and a bundle could not say whether a model
// came out of the solve. Appended, so a parser of the prefix above still matches.
val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
val solved = solve(segments, evidence)
val model = solved.model ?: return "$line fit=refused(${solved.refusal})"
val rmse = model.residualRmsePercentPer100Km
return line + String.format(Locale.ROOT, " fit=ready rmse=%.2f", rmse)
} }
/** What [solve] produced: a model, or the name of the guard that refused one. */
private class Solved(val model: SocConsumptionModel?, val refusal: String? = null)
fun fit( fun fit(
trips: List<StoredTrip>, trips: List<StoredTrip>,
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(), generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
@@ -180,18 +192,30 @@ class SocConsumptionFitter(
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
} }
if (segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh } <
MIN_SPEED_SPAN_KMH
) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
return solve(segments, evidence).model?.let { SocConsumptionFitResult.Ready(it) }
?: SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
}
/** The least-squares solve behind [fit], shared with [describe] so both report one answer. */
private fun solve(
segments: List<Segment>,
evidence: VehicleSpeedEvidence,
withWeather: Boolean = true,
): Solved {
val minSpeed = segments.minOf { it.meanSpeedKmh } val minSpeed = segments.minOf { it.meanSpeedKmh }
val maxSpeed = segments.maxOf { it.meanSpeedKmh } val maxSpeed = segments.maxOf { it.meanSpeedKmh }
val minTemp = segments.minOf { it.meanOutsideTempCelsius } val minTemp = segments.minOf { it.meanOutsideTempCelsius }
val maxTemp = segments.maxOf { it.meanOutsideTempCelsius } val maxTemp = segments.maxOf { it.meanOutsideTempCelsius }
if (maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
// A driver who has only recorded one kind of weather has no evidence about weather. // A driver who has only recorded one kind of weather has no evidence about weather.
// Fitting two coefficients is the honest answer there; the envelope keeps the narrow // Fitting two coefficients is the honest answer there; the envelope keeps the narrow
// temperature range, so this model refuses the cold instead of guessing at it. // temperature range, so this model refuses the cold instead of guessing at it.
val features = if (maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2 val features = if (withWeather && maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
val equations = LeastSquares3(features) val equations = LeastSquares3(features)
for (segment in segments) { for (segment in segments) {
equations.add( equations.add(
@@ -200,13 +224,19 @@ class SocConsumptionFitter(
segment.percentPer100Km, segment.percentPer100Km,
) )
} }
val coefficients = equations.solve() val coefficients = equations.solve() ?: return Solved(null, "singular")
?: return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
val rolling = coefficients[0] val rolling = coefficients[0]
val aero = coefficients[1] val aero = coefficients[1]
val thermal = if (features == 3) coefficients[2] else 0.0 val thermal = if (features == 3) coefficients[2] else 0.0
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 || coefficients.any { !it.isFinite() }) { when {
return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) coefficients.any { !it.isFinite() } -> return Solved(null, "nonfinite")
rolling <= 0.0 -> return Solved(null, "rolling")
aero < 0.0 -> return Solved(null, "aero")
// The weather recorded so far costs nothing measurable: the bundle of 2026-10-16
// refused 87 segments over 77 km/h of span on this alone, and the model never
// came out on the car. That is the single-weather case again — two coefficients,
// and the envelope still refuses a temperature the history never saw.
thermal < 0.0 -> return solve(segments, evidence, withWeather = false)
} }
var squaredError = 0.0 var squaredError = 0.0
@@ -221,7 +251,7 @@ class SocConsumptionFitter(
} }
val rmse = sqrt(squaredError / segments.size) val rmse = sqrt(squaredError / segments.size)
if (!rmse.isFinite() || rmse > maxResidualRmse) { if (!rmse.isFinite() || rmse > maxResidualRmse) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) return Solved(null, String.format(Locale.ROOT, "rmse=%.2f", rmse))
} }
val model = SocConsumptionModel( val model = SocConsumptionModel(
@@ -233,11 +263,7 @@ class SocConsumptionFitter(
segmentCount = segments.size, segmentCount = segments.size,
envelope = EnergyModelEnvelope(minSpeed, maxSpeed, minTemp, maxTemp), envelope = EnergyModelEnvelope(minSpeed, maxSpeed, minTemp, maxTemp),
) )
return if (model.isValid()) { return if (model.isValid()) Solved(model) else Solved(null, "invalid")
SocConsumptionFitResult.Ready(model)
} else {
SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
}
} }
/** /**
@@ -247,6 +273,11 @@ class SocConsumptionFitter(
* refuses to build a segment from is most of what makes the fit trustworthy. * refuses to build a segment from is most of what makes the fit trustworthy.
*/ */
internal fun collect(samples: List<TripSample>, out: MutableList<Segment>) { internal fun collect(samples: List<TripSample>, out: MutableList<Segment>) {
// Two steps of this track's own gauge, never more than [minSocDropPercent]. SWI68
// publishes tenths of a percent, and waiting for two whole points there threw away
// every town drive shorter than ten kilometres — most of what the car is used for.
val minDrop = gaugeStep(samples)?.let { minOf(minSocDropPercent, 2.0 * it) }
?: minSocDropPercent
var startSoc: Double? = null var startSoc: Double? = null
var distanceKm = 0.0 var distanceKm = 0.0
var hours = 0.0 var hours = 0.0
@@ -302,13 +333,29 @@ class SocConsumptionFitter(
continue continue
} }
val drop = start - soc val drop = start - soc
if (drop < minSocDropPercent) continue // A short stretch keeps accumulating rather than being thrown away: on a fine gauge
// the drop arrives before the distance does.
if (drop < minDrop || distanceKm < MIN_SEGMENT_KM) continue
emit(distanceKm, hours, tempSum, tempCount, drop)?.let(out::add) emit(distanceKm, hours, tempSum, tempCount, drop)?.let(out::add)
reset(soc) reset(soc)
if (out.size >= maxSegments) return if (out.size >= maxSegments) return
} }
} }
/** The smallest move this track's gauge made, or null when it never moved. */
private fun gaugeStep(samples: List<TripSample>): Double? {
var step: Double? = null
var previous: Double? = null
for (sample in samples) {
val soc = sample.socPercent?.toDouble()?.takeIf { it.isFinite() } ?: continue
val move = previous?.let { abs(soc - it) }
// Float storage turns 0.1 into 0.0999…; anything below a hundredth is that noise.
if (move != null && move >= 0.01) step = minOf(step ?: move, move)
previous = soc
}
return step
}
private fun emit( private fun emit(
distanceKm: Double, distanceKm: Double,
hours: Double, hours: Double,
@@ -7,6 +7,17 @@
<string name="group_comfort">Komfort</string> <string name="group_comfort">Komfort</string>
<string name="group_audio">Audio</string> <string name="group_audio">Audio</string>
<string name="group_adas">Fahrerassistenz</string> <string name="group_adas">Fahrerassistenz</string>
<string name="group_lights">Beleuchtung</string>
<string name="cond_headlights">Abblendlicht</string>
<string name="cond_high_beam">Fernlicht an</string>
<string name="cond_fog_lights">Nebelscheinwerfer an</string>
<string name="act_headlights">Abblendlicht</string>
<string name="act_high_beam">Fernlicht</string>
<string name="act_fog_lights">Nebelscheinwerfer</string>
<string name="light_off">Aus</string>
<string name="light_on">An</string>
<string name="light_daytime_running">Tagfahrlicht</string>
<string name="light_auto">Automatisch</string>
<string name="group_climate">Klima</string> <string name="group_climate">Klima</string>
<string name="group_profile">Profil</string> <string name="group_profile">Profil</string>
<string name="group_system">System</string> <string name="group_system">System</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Confort</string> <string name="group_comfort">Confort</string>
<string name="group_audio">Audio</string> <string name="group_audio">Audio</string>
<string name="group_adas">Asistencia a la conducción</string> <string name="group_adas">Asistencia a la conducción</string>
<string name="group_lights">Iluminación</string>
<string name="cond_headlights">Luces de cruce</string>
<string name="cond_high_beam">Luces largas encendidas</string>
<string name="cond_fog_lights">Antiniebla encendidas</string>
<string name="act_headlights">Luces de cruce</string>
<string name="act_high_beam">Luces largas</string>
<string name="act_fog_lights">Antiniebla</string>
<string name="light_off">Apagadas</string>
<string name="light_on">Encendidas</string>
<string name="light_daytime_running">Luces diurnas</string>
<string name="light_auto">Automático</string>
<string name="group_climate">Climatización</string> <string name="group_climate">Climatización</string>
<string name="group_profile">Perfil</string> <string name="group_profile">Perfil</string>
<string name="group_system">Sistema</string> <string name="group_system">Sistema</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Confort</string> <string name="group_comfort">Confort</string>
<string name="group_audio">Audio</string> <string name="group_audio">Audio</string>
<string name="group_adas">Aides à la conduite</string> <string name="group_adas">Aides à la conduite</string>
<string name="group_lights">Éclairage</string>
<string name="cond_headlights">Feux de croisement</string>
<string name="cond_high_beam">Feux de route allumés</string>
<string name="cond_fog_lights">Antibrouillards allumés</string>
<string name="act_headlights">Feux de croisement</string>
<string name="act_high_beam">Feux de route</string>
<string name="act_fog_lights">Antibrouillards</string>
<string name="light_off">Éteints</string>
<string name="light_on">Allumés</string>
<string name="light_daytime_running">Feux de jour</string>
<string name="light_auto">Automatique</string>
<string name="group_climate">Climatisation</string> <string name="group_climate">Climatisation</string>
<string name="group_profile">Profil</string> <string name="group_profile">Profil</string>
<string name="group_system">Système</string> <string name="group_system">Système</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Comfort</string> <string name="group_comfort">Comfort</string>
<string name="group_audio">Audio</string> <string name="group_audio">Audio</string>
<string name="group_adas">Assistenza alla guida</string> <string name="group_adas">Assistenza alla guida</string>
<string name="group_lights">Luci</string>
<string name="cond_headlights">Anabbaglianti</string>
<string name="cond_high_beam">Abbaglianti accesi</string>
<string name="cond_fog_lights">Fendinebbia accesi</string>
<string name="act_headlights">Anabbaglianti</string>
<string name="act_high_beam">Abbaglianti</string>
<string name="act_fog_lights">Fendinebbia</string>
<string name="light_off">Spente</string>
<string name="light_on">Accese</string>
<string name="light_daytime_running">Luci diurne</string>
<string name="light_auto">Automatico</string>
<string name="group_climate">Clima</string> <string name="group_climate">Clima</string>
<string name="group_profile">Profilo</string> <string name="group_profile">Profilo</string>
<string name="group_system">Sistema</string> <string name="group_system">Sistema</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Conforto</string> <string name="group_comfort">Conforto</string>
<string name="group_audio">Áudio</string> <string name="group_audio">Áudio</string>
<string name="group_adas">Assistência à condução</string> <string name="group_adas">Assistência à condução</string>
<string name="group_lights">Iluminação</string>
<string name="cond_headlights">Médios</string>
<string name="cond_high_beam">Máximos ligados</string>
<string name="cond_fog_lights">Faróis de nevoeiro ligados</string>
<string name="act_headlights">Médios</string>
<string name="act_high_beam">Máximos</string>
<string name="act_fog_lights">Faróis de nevoeiro</string>
<string name="light_off">Desligados</string>
<string name="light_on">Ligados</string>
<string name="light_daytime_running">Luzes diurnas</string>
<string name="light_auto">Automático</string>
<string name="group_climate">Climatização</string> <string name="group_climate">Climatização</string>
<string name="group_profile">Perfil</string> <string name="group_profile">Perfil</string>
<string name="group_system">Sistema</string> <string name="group_system">Sistema</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Comfort</string> <string name="group_comfort">Comfort</string>
<string name="group_audio">Audio</string> <string name="group_audio">Audio</string>
<string name="group_adas">Driver assistance</string> <string name="group_adas">Driver assistance</string>
<string name="group_lights">Lights</string>
<string name="cond_headlights">Low beams</string>
<string name="cond_high_beam">High beam on</string>
<string name="cond_fog_lights">Fog lights on</string>
<string name="act_headlights">Low beams</string>
<string name="act_high_beam">High beam</string>
<string name="act_fog_lights">Fog lights</string>
<string name="light_off">Off</string>
<string name="light_on">On</string>
<string name="light_daytime_running">Daytime running</string>
<string name="light_auto">Automatic</string>
<string name="group_climate">Climate</string> <string name="group_climate">Climate</string>
<string name="group_profile">Profile</string> <string name="group_profile">Profile</string>
<string name="group_system">System</string> <string name="group_system">System</string>
@@ -127,13 +127,28 @@ class ReadFailureLogTest {
class NeverPublishedCatalogueTest { class NeverPublishedCatalogueTest {
@Test fun `SWI68 never publishes battery power or battery temperature`() { @Test fun `SWI68 never publishes battery temperature`() {
CarPropertyEvidence.Signal.entries.forEach { signal ->
assertTrue( assertTrue(
"$signal is proven absent on SWI68", CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.isNeverPublished(signal, FirmwareInfo.Gen.SWI68), CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
) )
} }
@Test fun `SWI68 battery power publishes through the vendor pack and is not validated yet`() {
assertFalse(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
assertFalse(
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
} }
@Test fun `absent is not the same claim as validated`() { @Test fun `absent is not the same claim as validated`() {
@@ -141,13 +156,13 @@ class NeverPublishedCatalogueTest {
// never be validated. Both must be false-for-validated and true-for-absent at once. // never be validated. Both must be false-for-validated and true-for-absent at once.
assertFalse( assertFalse(
CarPropertyEvidence.isValidated( CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW, CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68, FirmwareInfo.Gen.SWI68,
) )
) )
assertTrue( assertTrue(
CarPropertyEvidence.isNeverPublished( CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_POWER_KW, CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68, FirmwareInfo.Gen.SWI68,
) )
) )
@@ -0,0 +1,61 @@
package com.evsuite.hardware
import com.evsuite.hardware.CustomDrive.Family
import com.evsuite.hardware.CustomDrive.Setting
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CustomDriveTest {
private fun codes(setting: Setting, family: Family) =
CustomDrive.INDICES.map { CustomDrive.code(setting, it, family) }
@Test fun `VSM scale matches the RI-006 read and write codes`() {
assertEquals(listOf(1, 2, 3), codes(Setting.POWER, Family.VSM))
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.VSM))
assertEquals(listOf(1, 0, 2), codes(Setting.PEDAL, Family.VSM))
}
@Test fun `pedal Normal is zero on every family`() {
Family.values().forEach { assertEquals(0, CustomDrive.code(Setting.PEDAL, 1, it)) }
}
@Test fun `A9 powertrain reuses the drive-mode scale`() {
assertEquals(listOf(2, 3, 4), codes(Setting.POWER, Family.A9))
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.A9))
}
@Test fun `every code reads back to its index`() {
Family.values().forEach { family ->
Setting.values().forEach { setting ->
CustomDrive.INDICES.forEach { i ->
assertEquals(i, CustomDrive.index(setting, CustomDrive.code(setting, i, family)!!, family))
}
}
}
}
@Test fun `out of range is refused both ways`() {
assertNull(CustomDrive.code(Setting.POWER, 3, Family.VSM))
assertNull(CustomDrive.code(Setting.PEDAL, -1, Family.A9))
assertNull(CustomDrive.index(Setting.POWER, 0, Family.VSM))
assertNull(CustomDrive.index(Setting.POWER, 1, Family.A9))
assertNull(CustomDrive.index(Setting.STEERING, -1, Family.VSM))
}
@Test fun `only VSM generations have a route`() {
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI68))
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI165))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI69))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI131))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI132))
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.SWI133))
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.UNKNOWN))
}
@Test fun `method names follow the family`() {
assertEquals("setBrakePedalLevel", CustomDrive.methodName(Setting.PEDAL, Family.VSM, write = true))
assertEquals("getDrivingPowerTrainMode", CustomDrive.methodName(Setting.POWER, Family.A9, write = false))
}
}
@@ -0,0 +1,97 @@
package com.evsuite.hardware
import com.evsuite.hardware.catalog.ActionType
import com.evsuite.hardware.catalog.ConditionType
import com.evsuite.hardware.probe.VendorSurfaceRules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* CR-046. Owner decisions pinned here, off the car: a rule never switches the low beams OFF,
* never forces the high beam ON, and touches no light on a generation nobody has proven.
*/
class ExteriorLightsTest {
private val gen = FirmwareInfo.Gen.SWI68
private val proven = setOf(gen)
@Test
fun `low beams can be set ON or AUTOMATIC, never OFF`() {
val allowed = ExteriorLights.HEADLIGHT_SWITCH_ALLOWED
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
}
@Test
fun `high beam can be set OFF or AUTOMATIC, never forced ON`() {
val allowed = ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
}
@Test
fun `daytime running is never a value a rule can write`() {
listOf(
ExteriorLights.HEADLIGHT_SWITCH_ALLOWED,
ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED,
ExteriorLights.FOG_SWITCH_ALLOWED,
).forEach { assertFalse(ExteriorLights.SWITCH_DAYTIME_RUNNING in it) }
}
@Test
fun `an unproven generation refuses every light write`() {
assertNotNull(
ExteriorLights.writeRefusal(
FirmwareInfo.Gen.SWI69, ExteriorLights.FOG_SWITCH_ALLOWED, ExteriorLights.SWITCH_ON, proven
)
)
// Until a capture proves one, no generation is proven at all.
assertTrue(ExteriorLights.PROVEN.isEmpty())
FirmwareInfo.Gen.entries.forEach { assertFalse(ExteriorLights.isProven(it)) }
}
@Test
fun `daytime running lights are not headlights on`() {
assertEquals(true, ExteriorLights.isOn(ExteriorLights.STATE_ON))
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_DAYTIME_RUNNING))
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_OFF))
assertNull(ExteriorLights.isOn(null))
}
@Test
fun `the editor cannot even express a forbidden light value`() {
val lowBeam = ActionType.SET_HEADLIGHTS.spec.options.map { it.value }.toSet()
val highBeam = ActionType.SET_HIGH_BEAM.spec.options.map { it.value }.toSet()
assertEquals(ExteriorLights.HEADLIGHT_SWITCH_ALLOWED, lowBeam)
assertEquals(ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED, highBeam)
}
@Test
fun `light actions are gated and unproven, light entries supported nowhere yet`() {
val actions = listOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
actions.forEach {
assertTrue("${it.name} must be gated", it.gated)
assertFalse("${it.name} must stay unproven until a capture", it.writeProven)
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
}
listOf(ConditionType.HEADLIGHTS, ConditionType.HIGH_BEAM_ON, ConditionType.FOG_LIGHTS_ON).forEach {
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
}
}
@Test
fun `a light property is named in the capture diff`() {
assertEquals(
"CPM 0x11400E00/0 (HEADLIGHTS_STATE)",
VendorSurfaceRules.label("CPM 0x11400E00/0"),
)
assertEquals("CPM 0x2160F404/0", VendorSurfaceRules.label("CPM 0x2160F404/0"))
assertEquals("VehicleSetting.getX", VendorSurfaceRules.label("VehicleSetting.getX"))
}
}
@@ -8,12 +8,6 @@ class PhysicalButtonEventDecoderTest {
@Test fun `every R69 hardkey code decodes`() { @Test fun `every R69 hardkey code decodes`() {
val expected = mapOf( val expected = mapOf(
5 to PhysicalButtonEventDecoder.Button.PHONE, 5 to PhysicalButtonEventDecoder.Button.PHONE,
6 to PhysicalButtonEventDecoder.Button.UP,
7 to PhysicalButtonEventDecoder.Button.DOWN,
8 to PhysicalButtonEventDecoder.Button.OK,
9 to PhysicalButtonEventDecoder.Button.LEFT,
10 to PhysicalButtonEventDecoder.Button.RIGHT,
16 to PhysicalButtonEventDecoder.Button.PHONE,
23 to PhysicalButtonEventDecoder.Button.CENTER, 23 to PhysicalButtonEventDecoder.Button.CENTER,
24 to PhysicalButtonEventDecoder.Button.VOLUME_UP, 24 to PhysicalButtonEventDecoder.Button.VOLUME_UP,
25 to PhysicalButtonEventDecoder.Button.VOLUME_DOWN, 25 to PhysicalButtonEventDecoder.Button.VOLUME_DOWN,
@@ -32,6 +26,72 @@ class PhysicalButtonEventDecoderTest {
assertEquals(button, decoder.accept(code, false, false)?.button) assertEquals(button, decoder.accept(code, false, false)?.button)
} }
} }
@Test fun `the SystemUI broadcast adds only the cluster pad`() {
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
val expected = mapOf(
6 to PhysicalButtonEventDecoder.Button.UP,
7 to PhysicalButtonEventDecoder.Button.DOWN,
8 to PhysicalButtonEventDecoder.Button.OK,
9 to PhysicalButtonEventDecoder.Button.LEFT,
10 to PhysicalButtonEventDecoder.Button.RIGHT
)
expected.forEach { (code, button) ->
val decoder = PhysicalButtonEventDecoder()
decoder.accept(code, true, false, source = systemUi)
assertEquals(button, decoder.accept(code, false, false, source = systemUi)?.button)
}
}
@Test fun `a press on both broadcasts is one press`() {
// Next track: 87 on the report, 5 on SystemUI. Read as one space, SystemUI's 5 was a
// phone press; and the phone itself (5 and 16) arrived twice, i.e. as a double.
val decoder = PhysicalButtonEventDecoder()
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
val events = listOfNotNull(
decoder.accept(87, true, false, atMillis = 0),
decoder.accept(5, true, false, atMillis = 0, source = systemUi),
decoder.accept(87, false, false, atMillis = 50),
decoder.accept(5, false, false, atMillis = 50, source = systemUi),
decoder.accept(5, true, false, atMillis = 1_000),
decoder.accept(16, true, false, atMillis = 1_000, source = systemUi),
decoder.accept(5, false, false, atMillis = 1_050),
decoder.accept(16, false, false, atMillis = 1_050, source = systemUi)
)
assertEquals(listOf("MEDIA_NEXT:SHORT", "PHONE:SHORT"), events.map { it.value })
}
@Test fun `an unlisted key is reported with an id of its own`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(3, true, false)
val event = decoder.accept(3, false, false)!!
assertNull(event.button)
assertEquals(
PhysicalButtonEventDecoder.Source.HARDKEY_REPORT to 3,
PhysicalButtonEventDecoder.unknownKey(event.keyId)
)
// Same code, other broadcast, other key.
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
decoder.accept(30, true, false, source = systemUi)
val other = decoder.accept(30, false, false, source = systemUi)!!
assertEquals(PhysicalButtonEventDecoder.Source.SYSTEM_UI to 30, PhysicalButtonEventDecoder.unknownKey(other.keyId))
assertEquals("KEY_${event.keyId}:SHORT", event.value)
}
@Test fun `stored button ids are unchanged`() {
// Rules on disk hold these numbers; changing one silently rebinds a rule.
val ids = PhysicalButtonEventDecoder.Button.entries.associate { it.name to it.id }
assertEquals(
mapOf(
"PHONE" to 5, "UP" to 6, "DOWN" to 7, "OK" to 8, "LEFT" to 9, "RIGHT" to 10,
"SOURCE" to 110, "CENTER" to 23, "VOLUME_UP" to 24, "VOLUME_DOWN" to 25,
"MEDIA_NEXT" to 87, "MEDIA_PREVIOUS" to 88, "MUTE" to 164, "STAR_LEFT" to 17,
"STAR_RIGHT" to 286, "ASSISTANT" to 287
),
ids
)
}
@Test fun `two releases inside the window make one double`() { @Test fun `two releases inside the window make one double`() {
val decoder = PhysicalButtonEventDecoder() val decoder = PhysicalButtonEventDecoder()
decoder.accept(17, true, false, atMillis = 0) decoder.accept(17, true, false, atMillis = 0)
@@ -108,7 +108,8 @@ class CatalogConsistencyTest {
ActionType.SET_ELK_MODE, ActionType.SET_ELK_SENSITIVITY, ActionType.SET_ELK_MODE, ActionType.SET_ELK_SENSITIVITY,
ActionType.SET_ACC_TJA_MODE, ActionType.SET_LIMITER_MODE, ActionType.SET_ACC_TJA_MODE, ActionType.SET_LIMITER_MODE,
ActionType.SET_TSR, ActionType.SET_OVERSPEED_ALARM, ActionType.SET_TSR, ActionType.SET_OVERSPEED_ALARM,
ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING,
ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS
) )
mustBeGated.forEach { type -> mustBeGated.forEach { type ->
@@ -325,7 +326,15 @@ class CatalogConsistencyTest {
} }
// Exactly these: an unproven write is invisible to the user, so adding one elsewhere // Exactly these: an unproven write is invisible to the user, so adding one elsewhere
// silently removes an action from every app that carries this catalogue. // silently removes an action from every app that carries this catalogue.
assertEquals(glass.toSet(), ActionType.entries.filter { !it.writeProven }.toSet()) // CR-046: the exterior-light actions are unproven too, on purpose, until a capture
// proves a generation (see ExteriorLightsTest).
val lights = setOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
// CP-088: the charge limit reads back as a code on SWI68 while the write sends a percent.
val chargeLimit = setOf(ActionType.SET_CHARGE_LIMIT)
assertEquals(
glass.toSet() + lights + chargeLimit,
ActionType.entries.filter { !it.writeProven }.toSet()
)
} }
@Test @Test
@@ -0,0 +1,46 @@
package com.evsuite.hardware.diag
import com.google.gson.JsonParser
import java.util.Base64
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DiagnosticInboxTest {
private val artifact = DiagnosticInbox.Artifact(
app = "EVTasker", version = "2.17.0", name = "evtasker-diagnostic.txt",
createdAtMs = 1_792_323_000_000L, content = "report ✓".toByteArray(),
)
@Test fun `only a plain https address is an inbox`() {
assertNull(DiagnosticInbox.refuse("https://nodered.example/evsuite"))
assertNotNull(DiagnosticInbox.refuse("http://192.0.2.10:1880/evsuite"))
assertNotNull(DiagnosticInbox.refuse("https://user:pw@host/evsuite"))
assertNotNull(DiagnosticInbox.refuse("https://host/evsuite?token=1"))
assertNotNull(DiagnosticInbox.refuse("evsuite"))
}
@Test fun `the body carries the artifact byte for byte`() {
var sent: String? = null
val outcome = DiagnosticInbox.send("https://host/evsuite", artifact) { _, body -> sent = body; 200 }
assertEquals(DiagnosticInbox.Outcome.Sent(200), outcome)
val json = JsonParser.parseString(sent).asJsonObject
assertEquals(1, json["evsuite"].asInt)
assertEquals("EVTasker", json["app"].asString)
assertEquals("report ✓", String(Base64.getDecoder().decode(json["contentBase64"].asString)))
}
@Test fun `a refused address never reaches the network, a server error is a failure`() {
var called = false
val refused = DiagnosticInbox.send("http://host/evsuite", artifact) { _, _ -> called = true; 200 }
assertTrue(refused is DiagnosticInbox.Outcome.Failed && !called)
assertEquals(
DiagnosticInbox.Outcome.Failed("HTTP 500"),
DiagnosticInbox.send("https://host/evsuite", artifact) { _, _ -> 500 },
)
}
}
@@ -91,4 +91,25 @@ class RuntimeInterfaceProbeTest {
val calls = RuntimeInterfaceProbe.CONFIG_CALLS val calls = RuntimeInterfaceProbe.CONFIG_CALLS
assertEquals(calls.size, calls.values.map { it.code }.toSet().size) assertEquals(calls.size, calls.values.map { it.code }.toSet().size)
} }
@Test
fun `the component map keeps the manifest's words and says when it stopped listing`() {
val rows = (0 until RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 3).map {
RuntimeInterfaceProbe.ServiceRow("com.saicmotor.x.Service$it", it == 0, null)
}
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.x", rows)
assertEquals("componentMap:com.saicmotor.x", entry.name)
assertEquals(BindVerdict.ANSWERED, entry.verdict)
assertEquals((rows.size).toString(), entry.values["services"])
assertEquals("exported=true permission=null", entry.values["com.saicmotor.x.Service0"])
assertEquals("3", entry.values["truncated"])
assertEquals(RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 2, entry.values.size)
}
@Test
fun `a package that declares no service was still read`() {
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.y", emptyList())
assertEquals(BindVerdict.ANSWERED, entry.verdict)
assertEquals(mapOf("services" to "0"), entry.values)
}
} }
@@ -0,0 +1,106 @@
package com.evsuite.hardware.probe
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The before/after capture and the SOC survey call getters by reflection on vendor objects, in a
* car with a driver in it. The one thing that must never happen is a call that changes something,
* so the selection is pinned here, off the car, where a wrong answer is a failing test rather
* than a setting that moved.
*/
class VendorSurfaceRulesTest {
@Suppress("unused")
class FakeVendorClient {
fun getAutoHighBeamSetting(): Int = 1
fun isAutoHighBeamEnabled(): Boolean = true
fun getBatterySoc(): Float = 63f
fun hasRainSensor(): Boolean = false
fun getVersionName(): String = "x"
fun getStatusBoxed(): Int? = 2
fun setAutoHighBeamSetting(value: Int) = Unit
fun getAndResetCounter(): Int = 0
fun getInstance(): FakeVendorClient = this
fun getService(): Any = Any()
fun getListenerCount(): Int = 0
fun getLevel(area: Int): Int = area
fun requestState(): Int = 0
fun getter(): Int = 0
fun gettable(): Int = 0
fun getValues(): IntArray = intArrayOf()
fun startCharging(): Boolean = true
companion object {
@JvmStatic fun getStaticValue(): Int = 0
}
}
private val names = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java).map { it.name }
@Test
fun `value getters are read, including Setting and Enabled names`() {
assertEquals(
listOf(
"getAutoHighBeamSetting", "getBatterySoc", "getStatusBoxed", "getVersionName",
"hasRainSensor", "isAutoHighBeamEnabled",
),
names,
)
}
@Test
fun `nothing that acts, takes an argument or returns an object is called`() {
listOf(
"setAutoHighBeamSetting", "getAndResetCounter", "getInstance", "getService",
"getListenerCount", "getLevel", "requestState", "getter", "gettable", "getValues",
"startCharging", "getStaticValue", "getClass", "hashCode", "toString",
).forEach { assertFalse(it, it in names) }
}
@Test
fun `keywords narrow the survey, case-insensitively`() {
val soc = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java, listOf("SOC", "battery"))
assertEquals(listOf("getBatterySoc"), soc.map { it.name })
}
@Test
fun `camel-case words split on case and underscore`() {
assertEquals(listOf("and", "reset", "counter"), VendorSurfaceRules.words("AndResetCounter"))
assertEquals(listOf("high", "beam", "setting"), VendorSurfaceRules.words("High_BeamSetting"))
}
@Test
fun `the diff lists changed, appeared and vanished values, and nothing else`() {
val before = mapOf("a" to "1", "b" to "2", "gone" to "x")
val after = mapOf("a" to "1", "b" to "3", "new" to "y")
assertEquals(
listOf(
VendorSurfaceRules.Change("b", "2", "3"),
VendorSurfaceRules.Change("gone", "x", null),
VendorSurfaceRules.Change("new", null, "y"),
),
VendorSurfaceRules.diff(before, after),
)
assertTrue(VendorSurfaceRules.diff(before, before).isEmpty())
}
@Test
fun `an empty diff says why it may be empty`() {
val text = VendorSurfaceRules.formatDiff(emptyList(), 10, 10)
assertTrue(text.contains("0 changed"))
assertTrue(text.contains("nothing changed"))
val one = VendorSurfaceRules.formatDiff(listOf(VendorSurfaceRules.Change("k", null, "1")), 1, 2)
assertTrue(one.contains("k: — → 1"))
}
@Test
fun `arrays print their content`() {
assertEquals("[1, 2]", VendorSurfaceRules.render(intArrayOf(1, 2)))
assertEquals("[0.5]", VendorSurfaceRules.render(floatArrayOf(0.5f)))
assertEquals("null", VendorSurfaceRules.render(null))
}
}
@@ -0,0 +1,25 @@
package com.evsuite.hardware.saic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** The charge limit read refuses what the car could never have been set to. */
class SaicChargeLimitTest {
@Test fun `the code SWI68 answers is not a percent`() {
assertNull(SaicCharging.chargeLimitOrNull(5))
}
@Test fun `the range the car accepts reads through, ends included`() {
assertEquals(40, SaicCharging.chargeLimitOrNull(40))
assertEquals(80, SaicCharging.chargeLimitOrNull(80))
assertEquals(100, SaicCharging.chargeLimitOrNull(100))
}
@Test fun `outside it, and unread, is unknown`() {
assertNull(SaicCharging.chargeLimitOrNull(39))
assertNull(SaicCharging.chargeLimitOrNull(101))
assertNull(SaicCharging.chargeLimitOrNull(null))
}
}
@@ -0,0 +1,84 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
class BatteryDigestTest {
private fun day(
day: String,
health: Double? = 94.0,
band: Double? = 3.0,
calibration: CalibrationVerdict? = CalibrationVerdict.SETTLED,
) = BatteryDigest(
day = day,
healthPercent = health,
healthBandPercent = band,
healthWindows = if (health == null) 0 else 5,
calibration = calibration,
daysSinceFullCharge = 4.0,
hoursAboveHighSoc = 10.0,
equivalentFullCycles = 12.0,
chargeCount = 7,
)
@Test
fun `movement inside the band is not a change`() {
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 92.0)).isEmpty())
}
@Test
fun `movement past the band is one change`() {
val change = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 90.0)).single()
assertEquals(DigestChange.HealthMoved(94.0, 90.0, 3.0), change)
}
@Test
fun `a band that narrows by a point is said, less is rounding`() {
val narrowed = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.0))
assertEquals(listOf(DigestChange.HealthBandNarrowed(3.0, 2.0, 94.0)), narrowed)
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.5)).isEmpty())
}
@Test
fun `the first estimate is news, and the first day has nothing to compare`() {
val first = BatteryDigest.changes(day("2026-09-28", health = null, band = null), day("2026-09-29"))
assertEquals(listOf(DigestChange.HealthBandNarrowed(null, 3.0, 94.0)), first)
assertTrue(BatteryDigest.changes(null, day("2026-09-29")).isEmpty())
}
@Test
fun `a calibration verdict that changes is a change`() {
val change = BatteryDigest.changes(
day("2026-09-28"),
day("2026-09-29", calibration = CalibrationVerdict.WATCH),
).single() as DigestChange.CalibrationChanged
assertEquals(CalibrationVerdict.WATCH, change.to)
}
@Test
fun `the store keeps one record a day, a bounded year, and returns the day before`() {
val dir = Files.createTempDirectory("digest").toFile()
val store = BatteryDigestStore(File(dir, BatteryDigestStore.FILE_NAME), maxDays = 2)
assertNull(store.upsert(day("2026-09-27")))
assertEquals("2026-09-27", store.upsert(day("2026-09-28"))?.day)
assertEquals("2026-09-27", store.upsert(day("2026-09-28", health = 80.0))?.day)
store.upsert(day("2026-09-29"))
assertEquals(listOf("2026-09-28", "2026-09-29"), store.read().map { it.day })
assertEquals(80.0, store.read().first().healthPercent!!, 1e-9)
dir.deleteRecursively()
}
@Test
fun `an unreadable file is set aside, not trusted`() {
val dir = Files.createTempDirectory("digest").toFile()
val file = File(dir, BatteryDigestStore.FILE_NAME).apply { writeText("{not json") }
assertTrue(BatteryDigestStore(file).read().isEmpty())
assertTrue(dir.listFiles()!!.any { it.name.contains("quarantine") })
dir.deleteRecursively()
}
}
@@ -72,6 +72,16 @@ class BatteryLedgerTest {
assertTrue(recorder.observe(snapshot(atMs = 1_000L, soc = 50f, chargingStatus = 1))) assertTrue(recorder.observe(snapshot(atMs = 1_000L, soc = 50f, chargingStatus = 1)))
} }
@Test fun `a reading taken on the factory clock earns nothing`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
val factoryClock = 1_546_214_400_000L - NOW_MS // 2018-12-31, as the woken head unit reads
assertFalse(recorder.observe(snapshot(atMs = factoryClock, soc = 80f)))
assertTrue(recorder.observe(snapshot(atMs = 0L, soc = 80f)))
assertEquals(listOf(NOW_MS), store.read().map { it.atMs })
}
@Test fun `the integrals are cumulative and bounded by the same five seconds a trip uses`() { @Test fun `the integrals are cumulative and bounded by the same five seconds a trip uses`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME)) val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store) val recorder = BatteryLedgerRecorder(store)
@@ -134,7 +144,7 @@ class BatteryLedgerTest {
consumedKwh: Float? = null, consumedKwh: Float? = null,
regeneratedKwh: Float? = null, regeneratedKwh: Float? = null,
) = EnergySnapshot( ) = EnergySnapshot(
timestampMs = atMs, timestampMs = NOW_MS + atMs,
firmware = FirmwareInfo.Gen.SWI68, firmware = FirmwareInfo.Gen.SWI68,
socPercent = soc, socPercent = soc,
rangeKm = null, rangeKm = null,
@@ -156,4 +166,9 @@ class BatteryLedgerTest {
) )
private fun tempDirectory(): File = Files.createTempDirectory("ledger").toFile() private fun tempDirectory(): File = Files.createTempDirectory("ledger").toFile()
private companion object {
/** 2026-10-11, past the recorder's clock floor. */
const val NOW_MS = 1_791_728_806_645L
}
} }
@@ -0,0 +1,95 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChargeDurationTest {
/** A watched charge from [from] to [to] %, one step per point, [minutesOf] minutes for each. */
private fun charge(from: Int, to: Int, outside: Double? = 15.0, minutesOf: (Int) -> Double) = ChargeEnergy(
session = BatteryChargeSession(0L, 1L, from.toDouble(), to.toDouble(), 0.0, 0.0, outside),
watched = true,
maxEntryGapMs = 0L,
packDeltaKwh = null,
meanPowerKw = null,
counterBehaviour = ChargeCounterBehaviour.RESET,
consumedDeltaKwh = null,
regeneratedDeltaKwh = null,
movedKm = 0.0,
chargingStatuses = emptyList(),
steps = (from until to).map {
ChargePowerStep(it.toDouble(), it + 1.0, (minutesOf(it) * 60_000).toLong(), 7.0)
},
minOutsideTempCelsius = outside,
maxOutsideTempCelsius = outside,
)
/** Three minutes a point below 80 %, six above: the taper. */
private val tapered = { soc: Int -> if (soc < 80) 3.0 else 6.0 }
@Test
fun `duration sums the bands on the path, and the last stretch is its own figure`() {
val history = listOf(charge(20, 100, minutesOf = tapered))
val estimate = (ChargeDuration.estimate(history, 50.0, 100, null) as ChargeDurationResult.Ready).estimate
assertEquals(30 * 3.0 + 20 * 6.0, estimate.minutes, 1e-6)
assertEquals(120.0, estimate.lastStretchMinutes!!, 1e-6)
assertFalse(estimate.similarTemperature)
}
@Test
fun `the range is the fastest and the slowest past charge`() {
val history = listOf(
charge(40, 80) { 2.0 },
charge(40, 80) { 3.0 },
charge(40, 80) { 5.0 },
)
val estimate = (ChargeDuration.estimate(history, 60.0, 80, null) as ChargeDurationResult.Ready).estimate
assertEquals(60.0, estimate.minutes, 1e-6)
assertEquals(40.0, estimate.lowMinutes, 1e-6)
assertEquals(100.0, estimate.highMinutes, 1e-6)
assertNull(estimate.lastStretchMinutes)
}
@Test
fun `a band nobody watched is refused, never extrapolated`() {
val refused = ChargeDuration.estimate(listOf(charge(20, 80, minutesOf = tapered)), 50.0, 100, null)
assertEquals(ChargeDurationResult.Refused(ChargeDurationRefusal.MISSING_BAND, 80), refused)
assertEquals(
ChargeDurationResult.Refused(ChargeDurationRefusal.NO_WATCHED_CHARGE),
ChargeDuration.estimate(emptyList(), 50.0, 100, null),
)
assertEquals(
ChargeDurationResult.Refused(ChargeDurationRefusal.AT_TARGET),
ChargeDuration.estimate(emptyList(), 80.0, 80, null),
)
}
@Test
fun `a similar outside temperature is preferred, and all charges are the fallback`() {
val history = listOf(
charge(40, 80, outside = 0.0) { 5.0 },
charge(40, 80, outside = 20.0) { 3.0 },
)
val cold = (ChargeDuration.estimate(history, 60.0, 80, 2.0) as ChargeDurationResult.Ready).estimate
assertTrue(cold.similarTemperature)
assertEquals(100.0, cold.minutes, 1e-6)
val hot = (ChargeDuration.estimate(history, 60.0, 80, 35.0) as ChargeDurationResult.Ready).estimate
assertFalse(hot.similarTemperature)
assertEquals(20 * 5.0, hot.minutes, 1e-6) // the median of two is the upper one
}
@Test
fun `the curve check needs three past charges and flags only slower than all of them`() {
val two = listOf(charge(80, 90) { 6.0 }, charge(80, 90) { 7.0 })
assertNull(ChargeDuration.curveCheck(two, 80, 20.0))
val three = two + charge(80, 90) { 8.0 }
assertTrue(ChargeDuration.curveCheck(three, 80, 9.0)!!.slower)
val usual = ChargeDuration.curveCheck(three, 80, 7.5)!!
assertFalse(usual.slower)
assertEquals(6.0, usual.usualLowMinutesPerPoint, 1e-9)
assertEquals(3, usual.pastCharges)
}
}
@@ -0,0 +1,237 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChargeEnergyTest {
@Test fun `a watched charge carries its power and the sign the integral moved`() {
// Three quarters of an hour at sixteen points an hour, with the pack pair integral
// falling by 8,25 kWh: the declared convention — positive leaves the battery — holding
// across a charge, and 11 kW of it.
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
entry(15 * MINUTE, 44f, pack = 97.25, odometer = 10_000f),
entry(30 * MINUTE, 48f, pack = 94.5, odometer = 10_000f),
entry(45 * MINUTE, 52f, pack = 91.75, odometer = 10_000f),
)
val report = analyse(entries)
assertEquals(1, report.charges.size)
val charge = report.charges.single()
assertTrue(charge.watched)
assertTrue(charge.plugged)
assertEquals(-8.25, charge.packDeltaKwh!!, 1e-6)
assertEquals(11.0, charge.meanPowerKw!!, 1e-6)
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
assertEquals(charge, report.lastPluggedCharge)
}
@Test fun `an integral that rises during a charge is reported as the other convention`() {
val entries = listOf(
entry(0L, 40f, pack = 10.0),
entry(15 * MINUTE, 45f, pack = 17.0),
entry(30 * MINUTE, 50f, pack = 24.0),
)
assertEquals(ChargePackSign.ENERGY_ROSE, analyse(entries).packSign)
}
@Test fun `two charges that disagree settle nothing`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0),
entry(15 * MINUTE, 45f, pack = 96.5),
entry(30 * MINUTE, 50f, pack = 93.0),
// A drive in between, so what follows is a second session rather than the same one.
entry(45 * MINUTE, 30f, pack = 99.0),
entry(60 * MINUTE, 35f, pack = 102.5),
entry(75 * MINUTE, 40f, pack = 106.0),
)
assertEquals(ChargePackSign.CONTRADICTORY, analyse(entries).packSign)
}
@Test fun `a charge the app slept through carries no power and no sign`() {
// The recorder earns an entry every quarter of an hour while it is running, so a
// six-hour silence is the head unit having been off — and the integral did not move
// during it, which would otherwise read as a charge that took no energy at all.
val entries = listOf(
entry(0L, 30f, pack = 100.0),
entry(6 * HOUR, 90f, pack = 100.0),
)
val report = analyse(entries)
val charge = report.charges.single()
assertFalse(charge.watched)
assertNull(charge.meanPowerKw)
assertEquals(ChargePackSign.UNREADABLE, charge.sign)
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
}
@Test fun `a counter that goes backwards across a charge is the car resetting it`() {
val entries = listOf(
entry(0L, 40f, consumed = 12f, regenerated = 3f),
entry(15 * MINUTE, 55f, consumed = 12f, regenerated = 3f),
entry(30 * MINUTE, 70f, consumed = 0f, regenerated = 0f),
)
val report = analyse(entries)
assertEquals(ChargeCounterBehaviour.RESET, report.counterBehaviour)
assertEquals(-12.0, report.charges.single().consumedDeltaKwh!!, 1e-6)
}
@Test fun `counters read at both ends and unchanged are static, never absent`() {
val entries = listOf(
entry(0L, 40f, consumed = 12f, regenerated = 3f),
entry(20 * MINUTE, 70f, consumed = 12f, regenerated = 3f),
)
assertEquals(ChargeCounterBehaviour.STATIC, analyse(entries).counterBehaviour)
}
@Test fun `a car with no counters at all says absent rather than static`() {
val entries = listOf(entry(0L, 40f), entry(20 * MINUTE, 70f))
assertEquals(ChargeCounterBehaviour.ABSENT, analyse(entries).counterBehaviour)
}
@Test fun `a reset seen once outranks a charge that ended before the reset`() {
val entries = listOf(
entry(0L, 40f, consumed = 9f),
entry(15 * MINUTE, 60f, consumed = 9f),
entry(30 * MINUTE, 30f, consumed = 14f),
entry(45 * MINUTE, 70f, consumed = 0f),
)
assertEquals(ChargeCounterBehaviour.RESET, analyse(entries).counterBehaviour)
}
@Test fun `a rise while the odometer moves is regeneration, never the last charge`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
entry(20 * MINUTE, 48f, pack = 96.0, odometer = 10_020f),
)
val report = analyse(entries)
val charge = report.charges.single()
assertEquals(20.0, charge.movedKm!!, 1e-6)
assertFalse(charge.plugged)
assertNull(report.lastPluggedCharge)
// The sign is still evidence: energy entering the pack is energy entering the pack.
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
}
@Test fun `a night the head unit slept through is a charge even when the drive home was not recorded`() {
// The reported case: plugged in overnight, battery page empty the next morning. The last
// entry before sleep was written before the unrecorded drive home, so the odometer moved
// between the two ends and the old rule read fifty points as a descent.
val entries = listOf(
entry(0L, 38f, pack = 100.0, odometer = 10_000f, temp = 12f),
entry(11 * HOUR, 88f, pack = 100.0, odometer = 10_014f, temp = 7f),
)
val charge = analyse(entries).lastPluggedCharge!!
assertFalse(charge.watched)
assertTrue(charge.plugged)
assertNull(charge.meanPowerKw)
assertTrue(charge.steps.isEmpty())
assertEquals(7.0, charge.minOutsideTempCelsius!!, 1e-6)
assertEquals(12.0, charge.maxOutsideTempCelsius!!, 1e-6)
}
@Test fun `the car saying it charges is enough without an odometer`() {
val entries = listOf(
entry(0L, 40f, status = 1),
entry(10 * MINUTE, 43f, status = 1),
)
assertTrue(analyse(entries).charges.single().plugged)
}
@Test fun `a small rise across a gap while the car moved stays unclaimed`() {
val entries = listOf(
entry(0L, 40f, odometer = 10_000f),
entry(3 * HOUR, 46f, odometer = 10_030f),
)
assertNull(analyse(entries).lastPluggedCharge)
}
@Test fun `a watched charge carries its curve, its peak and its taper by band`() {
// 7 kW up to 80 %, then the pack takes 3,5 kW above it.
val entries = listOf(
entry(0L, 77f, pack = 100.0, odometer = 10_000f),
entry(6 * MINUTE, 78f, pack = 99.3, odometer = 10_000f),
entry(12 * MINUTE, 79f, pack = 98.6, odometer = 10_000f),
entry(18 * MINUTE, 80f, pack = 97.9, odometer = 10_000f),
entry(30 * MINUTE, 81f, pack = 97.2, odometer = 10_000f),
)
val report = analyse(entries)
val charge = report.charges.single()
assertEquals(4, charge.steps.size)
assertEquals(7.0, charge.peakPowerKw!!, 1e-6)
val bands = report.powerBySocBand()
assertEquals(listOf(70, 80), bands.map { it.fromPercent })
assertEquals(7.0, bands[0].meanPowerKw, 1e-6)
assertEquals(3.5, bands[1].meanPowerKw, 1e-6)
assertTrue(report.describe().any { it.startsWith("soc_band 80-90 mean_power_kw=3.50") })
}
@Test fun `the description names every question and stays one line per charge`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f, status = 2),
entry(20 * MINUTE, 55f, pack = 86.0, odometer = 10_000f, status = 2),
)
val lines = analyse(entries).describe()
assertTrue(lines.any { it == "pack_pair_sign=ENERGY_FELL" })
assertTrue(lines.any { it == "vehicle_counters=ABSENT" })
assertTrue(lines.any { it == "charging_status_values=2" })
assertEquals(1, lines.count { it.startsWith("charge ") })
}
@Test fun `an empty ledger describes itself without inventing a verdict`() {
val report = ChargeEnergyAnalyzer().analyse(emptyList(), emptyList())
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
assertEquals(ChargeCounterBehaviour.ABSENT, report.counterBehaviour)
assertEquals(listOf("charges=0"), report.describe())
}
/** The sessions are the exposure report's, never re-segmented — the analyzer's own rule. */
private fun analyse(entries: List<BatteryLedgerEntry>): ChargeEnergyReport {
val sessions = BatteryExposure().analyse(entries)?.sessions.orEmpty()
return ChargeEnergyAnalyzer().analyse(entries, sessions)
}
private fun entry(
atMs: Long,
soc: Float,
pack: Double? = null,
consumed: Float? = null,
regenerated: Float? = null,
odometer: Float? = null,
status: Int? = null,
temp: Float? = null,
) = BatteryLedgerEntry(
atMs = atMs,
socPercent = soc,
odometerKm = odometer,
vehicleConsumedKwh = consumed,
vehicleRegeneratedKwh = regenerated,
packEnergyKwh = pack,
chargingStatus = status,
outsideTempCelsius = temp,
)
private companion object {
const val MINUTE = 60_000L
const val HOUR = 3_600_000L
}
}
@@ -0,0 +1,72 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DrivingStressTest {
/**
* Ten minutes at one sample every ten seconds, the charge falling two points. Minutes 0, 3
* and 6 pull 60 kW, minute 9 regenerates 10 kW, the rest cruise at 20 kW.
*/
private fun track(
soc: Float?,
outside: Float?,
sign: Float = 1f,
power: (Int) -> Float? = { i ->
when (i / 6) {
0, 3, 6 -> 60f
9 -> -10f
else -> 20f
}
},
) = (0 until 60).map { i ->
TripSample(
atMs = i * 10_000L,
speedKmh = 50f,
batteryPowerKw = power(i)?.times(sign),
socPercent = soc?.minus(i / 30f),
outsideTempCelsius = outside,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
@Test
fun `hard power in the cold is counted, and cold regeneration summed`() {
val stress = DrivingStress.of(track(soc = 60f, outside = -5f))!!
assertEquals(3.0, stress.stressedMinutes, 1e-9)
assertEquals(50 * 10.0 / 3600.0, stress.coldRegeneratedKwh!!, 1e-9)
}
@Test
fun `the trip orients its own power sign`() {
assertEquals(DrivingStress.of(track(soc = 60f, outside = -5f)), DrivingStress.of(track(60f, -5f, sign = -1f)))
}
@Test
fun `a warm drive at a good charge is a measured zero`() {
assertEquals(DrivingStress(0.0, 0.0), DrivingStress.of(track(soc = 60f, outside = 15f)))
}
@Test
fun `low charge alone is enough, and an unread temperature leaves the regeneration unknown`() {
val stress = DrivingStress.of(track(soc = 19f, outside = null))!!
assertEquals(3.0, stress.stressedMinutes, 1e-9)
assertNull(stress.coldRegeneratedKwh)
}
@Test
fun `unknown is never zero`() {
// Hard moments at a good charge with no temperature: they cannot be decided.
assertNull(DrivingStress.of(track(soc = 60f, outside = null)))
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { null }))
assertNull(DrivingStress.of(track(soc = null, outside = -5f)))
// A third of the power unread.
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { i -> if (i % 3 == 0) null else 20f }))
}
}
@@ -0,0 +1,289 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.VehicleSpeedEvidence
import com.evsuite.hardware.telemetry.model.EnergyModelEnvelope
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class EcoDrivingTest {
private val generation = FirmwareInfo.Gen.SWI68
private val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
/** Predicts 18.0 % per 100 km at 100 km/h and 20 °C, with a band of 0.98. */
private val model = SocConsumptionModel(
speedEvidence = evidence,
rollingPercentPer100Km = 10.0,
aeroPercentPer100KmPerSpeedSquared = 0.0008,
thermalPercentPer100KmPerDegree = 0.2,
residualRmsePercentPer100Km = 0.5,
segmentCount = SocConsumptionFitter.MIN_SEGMENTS,
envelope = EnergyModelEnvelope(20.0, 160.0, -10.0, 35.0),
)
/** 50 km in half an hour, so the mean speed the model is asked about is 100 km/h. */
private fun trip(
startSoc: Float? = 90f,
endSoc: Float? = 81f,
distanceKm: Double = 50.0,
durationMs: Long = 1_800_000L,
speedEvidence: VehicleSpeedEvidence? = evidence,
) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = durationMs,
durationMs = durationMs,
distanceKm = distanceKm,
startSocPercent = startSoc,
endSocPercent = endSoc,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
speedEvidence = speedEvidence,
)
private fun verdict(
monitor: EcoDrivingMonitor = EcoDrivingMonitor(),
trip: EnergyTripSummary? = trip(),
model: SocConsumptionModel? = this.model,
tempCelsius: Float? = 20f,
) = monitor.verdict(model, trip, tempCelsius, generation)
/** A steady cruise, one sample a second, from which no lever can fire. */
private fun cruise(speedKmh: Float, seconds: Int, fromMs: Long = 0L): EcoDrivingMonitor {
val monitor = EcoDrivingMonitor()
(0..seconds).forEach { monitor.add(fromMs + it * 1_000L, speedKmh) }
return monitor
}
@Test
fun `a drive below the expectation is better than it`() {
// 7 % over 50 km is 14 per 100 km, below the 18.0 the fit expects by more than its band.
val result = verdict(trip = trip(endSoc = 83f))
assertEquals(EcoBand.BETTER, result.band.value)
assertEquals(14.0, result.observedPercentPer100Km!!, 1e-9)
assertEquals(18.0, result.expectedPercentPer100Km!!, 1e-9)
assertTrue(result.deltaPercent!! < 0.0)
assertEquals(100.0, result.meanSpeedKmh!!, 1e-9)
}
@Test
fun `a drive inside the fit's own band is typical`() {
assertEquals(EcoBand.TYPICAL, verdict().band.value)
}
@Test
fun `a drive above the expectation is worse than it`() {
val result = verdict(trip = trip(endSoc = 78f))
assertEquals(EcoBand.WORSE, result.band.value)
assertTrue(result.deltaPercent!! > 0.0)
}
@Test
fun `the band widens with the fit's residual rather than a threshold of our own`() {
// The same 24 per 100 km that is WORSE against a tight fit is TYPICAL against a loose
// one: a model that knows little must not be allowed to accuse the driver.
val loose = model.copy(residualRmsePercentPer100Km = 7.0)
assertEquals(EcoBand.TYPICAL, verdict(trip = trip(endSoc = 78f), model = loose).band.value)
}
@Test
fun `a sixth over the expectation is worse, not typical, against a two-point residual`() {
// 21 per 100 km against 18: the 95 % band (±3.9) called this TYPICAL, one residual does not.
val fit = model.copy(residualRmsePercentPer100Km = 2.0)
assertEquals(EcoBand.WORSE, verdict(trip = trip(endSoc = 79.5f), model = fit).band.value)
}
@Test
fun `no model is a refusal, not a verdict`() {
val result = verdict(model = null)
assertNull(result.band.value)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
}
@Test
fun `a speed outside the fitted envelope is the model's refusal, carried through`() {
// 200 km/h over the 160 the envelope saw.
val result = verdict(trip = trip(distanceKm = 100.0))
assertNull(result.band.value)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
assertEquals(200.0, result.meanSpeedKmh!!, 1e-9)
}
@Test
fun `a distance too short to divide by is refused`() {
val result = verdict(trip = trip(distanceKm = 1.0, durationMs = 36_000L))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `a charge drop below the gauge's own step is refused`() {
val result = verdict(trip = trip(endSoc = 89f))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `a distance from the superseded speed conversion is refused`() {
val stale = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
val result = verdict(trip = trip(speedEvidence = stale))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `no outside temperature is a missing signal, not a comfortable assumption`() {
val result = verdict(tempCelsius = null)
assertEquals(UnavailableReason.SIGNAL_ABSENT, result.band.reason)
}
@Test
fun `the cruise lever quotes the fit rather than a rule of thumb`() {
val advice = verdict(monitor = cruise(110f, 120)).advice
assertEquals(EcoLever.CRUISE_SPEED, advice?.lever)
assertEquals(110.0, advice!!.fromSpeedKmh!!, 1e-9)
assertEquals(100.0, advice.toSpeedKmh!!, 1e-9)
// predict(110) is 19.68 and predict(100) is 18.0: 8.5 % less.
assertEquals(8.54, advice.savingPercent!!, 0.01)
assertNull(advice.harshSharePercent)
}
@Test
fun `a town speed leaves the cruise lever alone`() {
assertNull(verdict(monitor = cruise(50f, 120)).advice)
}
@Test
fun `the steadiness lever fires from the speed channel alone`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 0f
// Five stop-and-go cycles: 6 s of hard acceleration, then 18 s of cruising.
repeat(5) {
repeat(6) { speed += 10f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(18) { monitor.add(atMs, speed); atMs += 1_000L }
speed = 0f
}
val advice = verdict(monitor = monitor, model = null).advice
assertEquals(EcoLever.STEADINESS, advice?.lever)
assertTrue(advice!!.harshSharePercent!! >= EcoDrivingMonitor.MIN_HARSH_SHARE_PERCENT)
assertNull(advice.savingPercent)
}
@Test
fun `a calm drive is measured, not waiting`() {
val monitor = EcoDrivingMonitor()
(0..30).forEach { monitor.add(it * 1_000L, 50f) }
assertNull(monitor.harshSharePercent())
(31..90).forEach { monitor.add(it * 1_000L, 50f) }
assertEquals(0.0, monitor.harshSharePercent()!!, 1e-9)
assertNull(monitor.steadiness())
}
@Test
fun `hard braking counts like hard acceleration`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 0f
// Gentle launches (0.69 m/s²) and abrupt stops (2.8 m/s²): only the braking is harsh.
repeat(5) {
repeat(24) { speed += 2.5f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(6) { speed -= 10f; monitor.add(atMs, speed); atMs += 1_000L }
}
// 6 hard seconds in every 30: the trip review's `DrivingStyle` reads the same 20 %.
assertEquals(20.0, monitor.harshSharePercent()!!, 0.5)
}
@Test
fun `a sampling gap is a gap, not an acceleration`() {
val monitor = EcoDrivingMonitor()
(0..70).forEach { monitor.add(it * 1_000L, 50f) }
// The app was asleep for half a minute and the car is now at 120: unknown motion, and
// reading it as one step would invent 3.9 m/s² out of the process having been paused.
monitor.add(100_000L, 120f)
assertNull(verdict(monitor = monitor, model = null).advice)
}
@Test
fun `replay accepts the stored track cadence without relaxing live gaps`() {
val samples = (0..24).map { index ->
TripSample(
atMs = index * 5_500L,
speedKmh = if (index % 2 == 0) 0f else 60f,
batteryPowerKw = null,
socPercent = null,
outsideTempCelsius = null,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
assertEquals(EcoLever.STEADINESS, EcoDrivingMonitor.forReplay().replay(samples).steadiness()?.lever)
assertNull(EcoDrivingMonitor().replay(samples).steadiness())
}
@Test
fun `a window too short to judge says nothing`() {
assertNull(verdict(monitor = cruise(110f, 30)).advice)
}
@Test
fun `the cruise lever wins when both are measurable`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 100f
// Motorway, and driven unevenly: both levers have something to say and only one is said.
repeat(5) {
repeat(4) { speed += 8f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(20) { monitor.add(atMs, speed); atMs += 1_000L }
speed = 100f
}
assertEquals(EcoLever.CRUISE_SPEED, verdict(monitor = monitor).advice?.lever)
}
@Test
fun `replaying a stored track is the same monitor the dashboard fed`() {
val samples = (0..120).map { index ->
TripSample(
atMs = index * 1_000L,
speedKmh = 110f,
batteryPowerKw = null,
socPercent = null,
outsideTempCelsius = 20f,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
val replayed = EcoDrivingMonitor().replay(samples)
assertEquals(
verdict(monitor = cruise(110f, 120)).advice,
verdict(monitor = replayed).advice,
)
}
@Test
fun `describe names the refusal it made`() {
val lines = verdict(model = null).describe()
assertTrue(lines.any { it == "band=unavailable(MODEL_NOT_TRAINED)" })
assertTrue(lines.any { it == "observed_percent_per_100km=unavailable" })
assertTrue(lines.any { it == "advice=none" })
}
@Test
fun `describe states the verdict and its inputs`() {
val lines = verdict(monitor = cruise(110f, 120)).describe()
assertTrue(lines.any { it == "band=TYPICAL" })
assertTrue(lines.any { it == "observed_percent_per_100km=18.00" })
assertTrue(lines.any { it == "expected_percent_per_100km=18.00" })
assertNotNull(lines.firstOrNull { it.startsWith("advice=CRUISE_SPEED") })
}
}
@@ -0,0 +1,61 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class EcoScoreTest {
private fun trip(
distanceKm: Double = 10.0,
brakings: Int? = 3,
harshMs: Long? = 60_000L,
movingMs: Long? = 1_200_000L,
) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = 1_200_000L,
durationMs = 1_200_000L,
distanceKm = distanceKm,
startSocPercent = 80f,
endSocPercent = 78f,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
movingMs = movingMs,
harshMs = harshMs,
harshAccelerations = 2,
harshBrakings = brakings,
)
private fun verdict(delta: Double) = EcoVerdict(
band = com.evsuite.hardware.telemetry.Provenanced.estimated(EcoBand.TYPICAL, 1.0),
deltaPercent = delta,
)
@Test fun `a calm town drive at the usual consumption sits in the sixties, not near 100`() {
// 3 hard stops per 10 km, 5 % of moving time hard, consumption exactly as expected.
val score = EcoScore.of(trip(), verdict(0.0))!!
assertEquals(50, score.anticipation)
assertEquals(80, score.smoothness)
assertEquals(63, score.efficiency)
assertEquals(63, score.total)
}
@Test fun `a part that cannot be measured drops out instead of scoring zero or full`() {
val score = EcoScore.of(trip(), verdict = null)!!
assertNull(score.efficiency)
// (50 × 0.35 + 80 × 0.25) / 0.60
assertEquals(63, score.total)
}
@Test fun `a trip recorded before the counters existed has no score at all`() {
assertNull(EcoScore.of(trip(brakings = null, harshMs = null, movingMs = null), null))
}
@Test fun `scales clamp at both ends`() {
val best = EcoScore.of(trip(brakings = 0, harshMs = 0L), verdict(-40.0))!!
assertEquals(100, best.total)
val worst = EcoScore.of(trip(brakings = 20, harshMs = 600_000L), verdict(60.0))!!
assertEquals(0, worst.total)
}
}
@@ -31,6 +31,17 @@ class EnergyTelemetryTest {
assertEquals(18f, snapshot.batteryPowerKw) assertEquals(18f, snapshot.batteryPowerKw)
} }
@Test fun `one pedal reaches the track as the car's own code, and an unknown code stays unknown`() {
val signals = FakeSignals(80f, null, null).apply { mode = 9; regen = 6 }
val sample = TripSample.of(EnergyTelemetryReader(signals).read(1_000L))
assertEquals(6, sample.regenLevelRaw)
assertEquals(com.evsuite.hardware.model.RegenLevel.ONE_PEDAL,
com.evsuite.hardware.model.RegenLevel.ofRaw(sample.regenLevelRaw))
assertEquals(9, sample.driveModeRaw)
assertNull(com.evsuite.hardware.model.DriveMode.ofRaw(sample.driveModeRaw))
}
@Test fun `candidate probes exist only in the explicit evidence read`() { @Test fun `candidate probes exist only in the explicit evidence read`() {
val source = FakeSignals(soc = 80f, range = 200f, power = 18f) val source = FakeSignals(soc = 80f, range = 200f, power = 18f)
source.probes = listOf( source.probes = listOf(
@@ -70,6 +81,50 @@ class EnergyTelemetryTest {
assertEquals(0.001666, result.regeneratedKwh!!, 0.000001) assertEquals(0.001666, result.regeneratedKwh!!, 0.000001)
} }
@Test fun `a trip started on the factory clock is dated from the corrected one`() {
val factory = 1_546_300_800_000L
val synced = 1_792_323_000_000L
val trip = EnergyTripAccumulator(factory, 80f)
trip.add(snapshot(factory, 30f, 5f))
trip.add(snapshot(factory + 1_000L, 30f, 5f))
trip.add(snapshot(synced, 30f, 5f))
trip.add(snapshot(synced + 1_000L, 30f, 5f))
val result = trip.snapshot(synced + 1_000L)
assertEquals(2_000L, result.durationMs)
assertEquals(synced - 1_000L, result.startedAtMs)
}
@Test fun `lifting off into regeneration is not a hard stop`() {
val trip = EnergyTripAccumulator(0L, 80f)
// 7 km/h a second is about 1.9 m/s², what regeneration on High does with no brake pedal.
listOf(50f, 43f, 36f, 29f, 22f, 15f).forEachIndexed { second, speed ->
trip.add(snapshot(second * 1_000L, speed, -10f))
}
val result = trip.snapshot(5_000L)
assertEquals(0, result.harshBrakings)
assertEquals(0L, result.harshMs)
}
@Test fun `a hard stretch counts once, braking and accelerating apart, at the live rate`() {
val trip = EnergyTripAccumulator(0L, 80f)
listOf(0f, 30f, 60f, 60f, 40f, 20f, 20f, 20f, 0f).forEachIndexed { second, speed ->
trip.add(
snapshot(second * 1_000L, speed, 5f)
.copy(driveModeRaw = if (second < 6) 4 else 2, regenLevelRaw = 6)
)
}
val result = trip.snapshot(8_000L)
assertEquals(1, result.harshAccelerations)
assertEquals(2, result.harshBrakings)
assertEquals(8_000L, result.movingMs)
assertEquals(5_000L, result.harshMs)
assertEquals(4, result.driveModeRaw)
assertEquals(6, result.regenLevelRaw)
}
@Test fun `trip keeps distance unknown without a usable speed interval`() { @Test fun `trip keeps distance unknown without a usable speed interval`() {
val trip = EnergyTripAccumulator(0L, 80f) val trip = EnergyTripAccumulator(0L, 80f)
trip.add(snapshot(0L, null, 18f)) trip.add(snapshot(0L, null, 18f))
@@ -124,6 +179,10 @@ class EnergyTelemetryTest {
private val power: Float?, private val power: Float?,
) : EnergySignalSource { ) : EnergySignalSource {
var probes: List<TelemetryEvidenceProbe> = emptyList() var probes: List<TelemetryEvidenceProbe> = emptyList()
var mode: Int? = null
var regen: Int? = null
override fun driveModeRaw() = mode
override fun regenLevelRaw() = regen
override fun firmware() = FirmwareInfo.Gen.SWI68 override fun firmware() = FirmwareInfo.Gen.SWI68
override fun socPercent() = soc override fun socPercent() = soc
override fun rangeKm() = range override fun rangeKm() = range
@@ -65,6 +65,20 @@ class TelemetryEvidenceTest {
assertEquals(1, parked.samples) assertEquals(1, parked.samples)
} }
@Test fun `the widest window is a quantity, recorded as the snapshot carries it`() {
val recorder = TelemetryEvidenceRecorder()
recorder.record(snapshot(at = 0L, power = null).copy(widestWindowPercent = 40))
recorder.record(snapshot(at = 1_000L, power = null))
val windows = recorder.evidence()
.single { it.signal == TelemetryEvidenceRecorder.WINDOWS_WIDEST_PERCENT }
assertEquals("windows.widestPercent", windows.signal)
assertEquals(SignalKind.NUMERIC, windows.kind)
assertEquals(1, windows.samples)
assertEquals(1, windows.nulls)
assertEquals(40.0, windows.max!!, 0.0)
}
@Test fun `the update period follows value changes, not sampling ticks`() { @Test fun `the update period follows value changes, not sampling ticks`() {
val recorder = TelemetryEvidenceRecorder() val recorder = TelemetryEvidenceRecorder()
// One second between reads, five seconds between the values the vehicle publishes. // One second between reads, five seconds between the values the vehicle publishes.
@@ -30,11 +30,12 @@ class TripDetectorTest {
assertNull(movingAgain.event) assertNull(movingAgain.event)
} }
@Test fun `a genuine confirmed end closes the trip`() { @Test fun `a genuine confirmed end closes the trip before the head unit powers off`() {
val detector = recordingDetector() val detector = recordingDetector()
detector.add(snapshot(6_000L, 0f, parked = false)) detector.add(snapshot(6_000L, 0f, parked = false))
val stopped = sustainStop(detector, 7_000L, 126_000L, parked = true) assertNull(detector.add(snapshot(7_000L, 0f, parked = true)).event)
val stopped = detector.add(snapshot(8_000L, 0f, parked = true))
assertEquals(TripDetector.State.IDLE, stopped.state) assertEquals(TripDetector.State.IDLE, stopped.state)
assertEquals(TripDetector.Event.STOP, stopped.event) assertEquals(TripDetector.Event.STOP, stopped.event)
@@ -88,6 +88,25 @@ class TripHistoryStoreTest {
assertNull(restored.batteryTempCelsius) assertNull(restored.batteryTempCelsius)
assertNull(restored.climateAcOn) assertNull(restored.climateAcOn)
assertNull(restored.climateFanLevel) assertNull(restored.climateFanLevel)
assertNull(restored.widestWindowPercent)
}
@Test fun `a sample stored before the window field reads it as unknown`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
val store = EnergyTripHistoryStore(target)
val sample = TripSample(
atMs = 10L, speedKmh = 42f, batteryPowerKw = null, socPercent = null,
outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null,
climatePowerOn = null, climateAcOn = null, climateFanLevel = null,
widestWindowPercent = 30,
)
assertTrue(store.append(summary(1L), listOf(sample)))
target.writeText(target.readText().replace(",\"widestWindowPercent\":30", ""))
val restored = EnergyTripHistoryStore(target).read().single().samples!!.single()
assertEquals(42f, restored.speedKmh)
assertNull(restored.widestWindowPercent)
} }
@Test fun `battery power evidence survives a history round trip`() { @Test fun `battery power evidence survives a history round trip`() {
@@ -57,6 +57,26 @@ class EnergyAttributionTest {
assertEquals(0.0, result.reconciliationErrorKwh, 1e-9) assertEquals(0.0, result.reconciliationErrorKwh, 1e-9)
} }
@Test fun `window-open intervals keep their own residual, out of the climate one`() {
val result = ready(
calculate(powerKw = 12f, climateOn = false, consumedKwh = 12.0, windowPercent = 40),
)
val windows = result.residuals.single()
assertEquals(ResidualContext.WINDOWS_OPEN, windows.context)
assertEquals(ResidualFinding.DISTINGUISHABLE, windows.finding)
assertEquals(2.0, windows.estimate.valueKwh, 1e-6)
assertEquals(0.0, result.reconciliationErrorKwh, 1e-9)
}
@Test fun `a closed window leaves the climate grouping as it was`() {
val result = ready(
calculate(powerKw = 12f, climateOn = true, consumedKwh = 12.0, windowPercent = 0),
)
assertEquals(ResidualContext.CLIMATE_ACTIVE, result.residuals.single().context)
}
@Test fun `missing model evidence stays unavailable`() { @Test fun `missing model evidence stays unavailable`() {
val result = EnergyAttributionCalculator.calculate(trip(12f, true, 12.0), null) val result = EnergyAttributionCalculator.calculate(trip(12f, true, 12.0), null)
@@ -66,10 +86,22 @@ class EnergyAttributionTest {
) )
} }
private fun calculate(powerKw: Float, climateOn: Boolean, consumedKwh: Double) = private fun calculate(
EnergyAttributionCalculator.calculate(trip(powerKw, climateOn, consumedKwh), model) powerKw: Float,
climateOn: Boolean,
consumedKwh: Double,
windowPercent: Int? = null,
) = EnergyAttributionCalculator.calculate(
trip(powerKw, climateOn, consumedKwh, windowPercent),
model,
)
private fun trip(powerKw: Float, climateOn: Boolean, consumedKwh: Double): StoredTrip { private fun trip(
powerKw: Float,
climateOn: Boolean,
consumedKwh: Double,
windowPercent: Int? = null,
): StoredTrip {
val samples = (0..60).map { minute -> val samples = (0..60).map { minute ->
TripSample( TripSample(
atMs = minute * 60_000L, atMs = minute * 60_000L,
@@ -82,6 +114,7 @@ class EnergyAttributionTest {
climatePowerOn = climateOn, climatePowerOn = climateOn,
climateAcOn = climateOn, climateAcOn = climateOn,
climateFanLevel = if (climateOn) 4 else 0, climateFanLevel = if (climateOn) 4 else 0,
widestWindowPercent = windowPercent,
) )
} }
return StoredTrip( return StoredTrip(
@@ -91,12 +91,11 @@ class EnergyModelTest {
} }
@Test fun `bounded month-sized fit completes inside host budget`() { @Test fun `bounded month-sized fit completes inside host budget`() {
val base = syntheticTrip().samples.orEmpty() val trip = syntheticTrip()
val samples = List(EnergyModelTrainer.MAX_TRAINING_SAMPLES) { base[it % base.size] } val trips = List(EnergyModelTrainer.MAX_TRAINING_SAMPLES / SYNTHETIC_WINDOWS + 1) { trip }
val trip = storedTrip(samples)
lateinit var result: EnergyModelTrainingResult lateinit var result: EnergyModelTrainingResult
val elapsedMs = measureTimeMillis { result = trainer.fit(listOf(trip), evidence) } val elapsedMs = measureTimeMillis { result = trainer.fit(trips, evidence) }
assertNotNull((result as EnergyModelTrainingResult.Ready).model) assertNotNull((result as EnergyModelTrainingResult.Ready).model)
assertEquals( assertEquals(
@@ -106,13 +105,74 @@ class EnergyModelTest {
assertTrue("fit took ${elapsedMs}ms", elapsedMs < HOST_TRAINING_BUDGET_MS) assertTrue("fit took ${elapsedMs}ms", elapsedMs < HOST_TRAINING_BUDGET_MS)
} }
@Test fun `stop-and-go town driving trains once a dozen kilometres are recorded`() {
// Every sample of an acceleration costs several times what a cruise does, and a fit
// made per sample judged that spread as model error and refused the town for good.
// Two afternoons two degrees apart: no evidence about weather, which must not stop it.
val trips = listOf(24.0, 26.0).map { temp ->
storedTrip(urbanTrack(listOf(25.0, 35.0, 45.0, 55.0), temp))
}
val model = ready(trainer.fit(trips, evidence))
assertEquals(0.0, model.thermalKwhPer100KmPerDegree, 0.0)
assertTrue(model.envelope.minSpeedKmh < 30.0 && model.envelope.maxSpeedKmh > 50.0)
// Town consumption carries the accelerations: above the cruise-only truth, still sane.
val town = model.predict(40.0, 25.0).value!!
assertTrue("predicted $town", town in consumption(40.0, 25.0)..40.0)
}
/**
* Stop, accelerate for 15 s, cruise for a minute, brake for 15 s, repeated at each speed,
* sampled every 5 s as the recorder does. Acceleration pays the kinetic energy of a
* 1700 kg car at 90 % efficiency; braking returns 60 % of it.
*/
private fun urbanTrack(speeds: List<Double>, tempCelsius: Double): List<TripSample> {
val out = ArrayList<TripSample>()
var atMs = 0L
fun add(speedKmh: Double, powerKw: Double) {
out += TripSample(
atMs, speedKmh.toFloat(), powerKw.toFloat(), 80f, tempCelsius.toFloat(),
null, null, null, null, null,
)
atMs += 5_000L
}
for (cruise in speeds) {
val cruiseMs = cruise / 3.6
val accel = cruiseMs / 15.0
val cruisePower = consumption(cruise, tempCelsius) * cruise / 100.0
// Enough cycles for five kilometres at this speed.
val cycleKm = cruise * (60.0 + 15.0) / 3600.0
repeat(kotlin.math.ceil(5.0 / cycleKm).toInt()) {
repeat(4) { add(0.0, 1.0) }
for (step in 1..3) {
val v = cruiseMs * step / 3.0
add(v * 3.6, cruisePower * step / 3.0 + MASS_KG * accel * v / 900.0)
}
repeat(12) { add(cruise, cruisePower) }
for (step in 2 downTo 0) {
val v = cruiseMs * step / 3.0
add(v * 3.6, -0.6 * MASS_KG * accel * v / 1000.0)
}
}
}
return out
}
/** One steady kilometre per speed and temperature, each after a hole that ends a window. */
private fun syntheticTrip(): StoredTrip { private fun syntheticTrip(): StoredTrip {
var atMs = 0L
val samples = buildList { val samples = buildList {
repeat(2) { repetition -> repeat(2) { repetition ->
for (temp in listOf(-5.0, 5.0, 15.0, 25.0, 35.0)) { for (temp in listOf(-5.0, 5.0, 15.0, 25.0, 35.0)) {
for (speed in 30..130 step 10) { for (speed in 30..130 step 10) {
val shiftedSpeed = speed + repetition * 0.1 val shiftedSpeed = speed + repetition * 0.1
add(sample(shiftedSpeed, temp)) val intervals = kotlin.math.ceil(3_600.0 / (5.0 * shiftedSpeed)).toInt()
// One interval spare, so float rounding cannot leave the window short.
repeat(intervals + 2) {
add(sample(atMs, shiftedSpeed, temp))
atMs += 5_000L
}
atMs += 600_000L
} }
} }
} }
@@ -139,10 +199,10 @@ class EnergyModelTest {
samples = samples, samples = samples,
) )
private fun sample(speedKmh: Double, tempCelsius: Double): TripSample { private fun sample(atMs: Long, speedKmh: Double, tempCelsius: Double): TripSample {
val powerKw = consumption(speedKmh, tempCelsius) * speedKmh / 100.0 val powerKw = consumption(speedKmh, tempCelsius) * speedKmh / 100.0
return TripSample( return TripSample(
atMs = speedKmh.toLong(), atMs = atMs,
speedKmh = speedKmh.toFloat(), speedKmh = speedKmh.toFloat(),
batteryPowerKw = powerKw.toFloat(), batteryPowerKw = powerKw.toFloat(),
socPercent = 80f, socPercent = 80f,
@@ -164,5 +224,9 @@ class EnergyModelTest {
companion object { companion object {
private const val HOST_TRAINING_BUDGET_MS = 2_000L private const val HOST_TRAINING_BUDGET_MS = 2_000L
private const val MASS_KG = 1_700.0
/** Two passes over five temperatures and eleven speeds. */
private const val SYNTHETIC_WINDOWS = 110
} }
} }
@@ -150,6 +150,24 @@ class SocConsumptionModelTest {
assertNull(model.predict(110.0, -5.0).value) assertNull(model.predict(110.0, -5.0).value)
} }
@Test
fun `weather that costs nothing falls back to two coefficients instead of refusing`() {
// The 10 °C drives cost less than the 20 °C ones, so a thermal term comes out negative.
val result = SocConsumptionFitter().fit(
listOf(
trip(drive(90.0, 20.0, 21.0, startAtMs = 0L)),
trip(drive(130.0, 20.0, 31.0, startAtMs = 100_000_000L)),
trip(drive(90.0, 10.0, 16.0, startAtMs = 200_000_000L)),
trip(drive(130.0, 10.0, 26.0, startAtMs = 300_000_000L)),
),
generation,
)
val model = (result as SocConsumptionFitResult.Ready).model
assertEquals(0.0, model.thermalPercentPer100KmPerDegree, 1e-12)
assertTrue(model.predict(130.0, 15.0).value!! > model.predict(90.0, 15.0).value!!)
assertNull(model.predict(110.0, -5.0).value)
}
@Test @Test
fun `a hole in the track is not driven through`() { fun `a hole in the track is not driven through`() {
// Two minutes of missing samples across which the car covered kilometres nobody // Two minutes of missing samples across which the car covered kilometres nobody
@@ -190,6 +208,24 @@ class SocConsumptionModelTest {
assertTrue(segments.isEmpty()) assertTrue(segments.isEmpty())
} }
@Test
fun `a gauge in tenths trains on a town drive that a whole-percent gauge cannot`() {
// Nine kilometres at 40 km/h for 15 %/100 km: 1.35 % of charge, under two whole points.
val town = drive(40.0, 20.0, 15.0, distanceKm = 9.0)
fun gauge(step: Float) = town.map {
it.copy(socPercent = kotlin.math.round(it.socPercent!! / step) * step)
}
val tenths = ArrayList<SocConsumptionFitter.Segment>()
SocConsumptionFitter().collect(gauge(0.1f), tenths)
val whole = ArrayList<SocConsumptionFitter.Segment>()
SocConsumptionFitter().collect(gauge(1f), whole)
// Cut at two kilometres, the shortest segment a stretch may be, and still near 15.
assertEquals(4, tenths.size)
assertTrue("$tenths", tenths.all { it.distanceKm >= 2.0 && it.percentPer100Km in 10.0..20.0 })
assertTrue(whole.isEmpty())
}
@Test @Test
fun `describe tells the two INSUFFICIENT_SAMPLES gates apart`() { fun `describe tells the two INSUFFICIENT_SAMPLES gates apart`() {
// The reason code is the same word for both, and the two ask the driver for opposite // The reason code is the same word for both, and the two ask the driver for opposite
@@ -221,4 +257,28 @@ class SocConsumptionModelTest {
assertTrue(skipped, skipped.contains("segments=0/")) assertTrue(skipped, skipped.contains("segments=0/"))
assertTrue(skipped, !skipped.contains("span=")) assertTrue(skipped, !skipped.contains("span="))
} }
@Test
fun `describe says whether the solve past the gates produced a model`() {
// Past both gates the line used to stop, so a bundle could not tell a trained model
// from a solve that refused one.
val ready = SocConsumptionFitter().describe(history(), generation)
assertTrue(ready, ready.startsWith("used=4/4 segments="))
assertTrue(ready, Regex(""" fit=ready rmse=\d+\.\d{2}$""").containsMatchIn(ready))
// Faster costs less: a negative aero term, which the fit refuses by name.
val backwards = listOf(
trip(drive(90.0, 20.0, 26.0, distanceKm = 120.0, startAtMs = 0L)),
trip(drive(130.0, 20.0, 16.0, distanceKm = 120.0, startAtMs = 100_000_000L)),
)
assertEquals(
SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED),
SocConsumptionFitter().fit(backwards, generation),
)
val refused = SocConsumptionFitter().describe(backwards, generation)
assertTrue(refused, refused.endsWith("span=40/20 km/h fit=refused(aero)"))
val strict = SocConsumptionFitter(maxResidualRmse = -1.0).describe(history(), generation)
assertTrue(strict, Regex(""" fit=refused\(rmse=\d+\.\d{2}\)$""").containsMatchIn(strict))
}
} }
+8
View File
@@ -25,6 +25,14 @@ software. `LICENSE` names all of them; keep every line.
[`EVHardware/LICENSE.md`](EVHardware/LICENSE.md). Commercial use is not permitted, and its [`EVHardware/LICENSE.md`](EVHardware/LICENSE.md). Commercial use is not permitted, and its
`Required Notice: Copyright (c) 2026 EVHardware contributors` must go with any copy. `Required Notice: Copyright (c) 2026 EVHardware contributors` must go with any copy.
It is a copy, not a submodule: [malys/EVHardware](https://github.com/malys/EVHardware) at
`e4e1158` (2026-10-18, after 1.18.0), merged on 2026-10-19 with the changes this fork carries on
top. Those are Fatih's reads the car needs and upstream does not have — the vendor tyre-pressure
ids `0x21401553`–`56`, the vendor mileage `0x21401566` for the odometer, the charging status
from `0x2140F409`, SoC, range, power, pack voltage and current on SWI69 as on SWI68, and SWI69
speed already in km/h — plus translated comments. To take upstream again, merge three ways with
`e4e1158` as the base.
The car app links EVHardware into its APK, so the **built app** may only be used and passed on The car app links EVHardware into its APK, so the **built app** may only be used and passed on
non-commercially, whatever the MIT parts would allow on their own. The server, the Kubernetes non-commercially, whatever the MIT parts would allow on their own. The server, the Kubernetes
manifests and the docs do not contain EVHardware and are plain MIT. manifests and the docs do not contain EVHardware and are plain MIT.
+2 -2
View File
@@ -10,7 +10,7 @@ build-car ─▶ publish-car ─▶ forge pre-release (private repo, git.stinnes
car server /v1/app/releases, /v1/app/assets/… car server /v1/app/releases, /v1/app/assets/…
│ the car's own paired token │ the car's own paired token
▼ ▼
app: refresh button → check → SHA-256 → install app: Settings → Check for updates → SHA-256 → install
``` ```
The repository stays private and the forge token stays on the server: the car asks its own The repository stays private and the forge token stays on the server: the car asks its own
@@ -51,7 +51,7 @@ The version comes from the **asset file name**, not the tag.
## Installing ## Installing
Press the refresh button in the app's top bar. The app checks, downloads the APK into Press **Check for updates** on the Settings tab. The app checks, downloads the APK into
**Downloads** (with progress), verifies its SHA-256, and hands it to a `PackageInstaller` **Downloads** (with progress), verifies its SHA-256, and hands it to a `PackageInstaller`
session. As the system user it may install without asking; if the platform wants a session. As the system user it may install without asking; if the platform wants a
confirmation, the confirmation screen opens. When a session cannot start at all, the file confirmation, the confirmation screen opens. When a session cannot start at all, the file
+46 -17
View File
@@ -25,7 +25,7 @@ APIs.
## Contents ## Contents
- [Screenshot](#screenshot) - [Screenshots](#screenshots)
- [How it fits together](#how-it-fits-together) - [How it fits together](#how-it-fits-together)
- [The app](#the-app) - [The app](#the-app)
- [Install on the car](#install-on-the-car) - [Install on the car](#install-on-the-car)
@@ -36,15 +36,31 @@ APIs.
- [Project documents](#project-documents) - [Project documents](#project-documents)
- [Credits](#credits) - [Credits](#credits)
## Screenshot ## Screenshots
Version 2.8.2 at the head unit's 1920x720 pixels and 160 dpi, in full screen (Android 9 emulator; no Version 2.18.1 in the car's app window (1778×640 dp next to the MG4's own sidebar), full screen,
vehicle there, so every value shows as a dash). on the Android 9 emulator. There is no vehicle there, so every value shows as a dash. Every tab
fits the window without scrolling.
<p align="center"> <p align="center">
<img src="screenshots/nilsmg4-2.8.2-overview.png" width="900" alt="Overview tab: battery, range, range estimates, power and speed tiles above the energy and tyre-pressure cards"> <img src="screenshots/nilsmg4-2.18.1-overview.png" width="900" alt="Overview tab: battery, range, power and speed tiles with since-start and 5-minute averages, vehicle info and tyre pressures">
</p> </p>
<table>
<tr>
<td><img src="screenshots/nilsmg4-2.18.1-consumption.png" width="440" alt="Consumption tab: period totals beside the daily log"></td>
<td><img src="screenshots/nilsmg4-2.18.1-charging.png" width="440" alt="Charging tab: live charging values beside the SOC/power curve"></td>
</tr>
<tr>
<td><img src="screenshots/nilsmg4-2.18.1-live-data.png" width="440" alt="Live data tab: on/off, ABRP credentials, upload frequency"></td>
<td><img src="screenshots/nilsmg4-2.18.1-server.png" width="440" alt="Server tab: URL, pairing, ingest token, installation ID"></td>
</tr>
<tr>
<td><img src="screenshots/nilsmg4-2.18.1-settings.png" width="440" alt="Settings tab: display, battery, charging price, phone entry, app updates"></td>
<td><img src="screenshots/nilsmg4-2.18.1-logs.png" width="440" alt="Logs tab: recent uploads"></td>
</tr>
</table>
## How it fits together ## How it fits together
``` ```
@@ -63,12 +79,12 @@ with the server using a token of its own, obtained by pairing with a phone (belo
| Tab | What it shows | | Tab | What it shows |
|---|---| |---|---|
| **Overview** | Battery, range, range since start and at the 5-minute average with their usage, energy since start, power and speed with their 5-minute averages; odometer, climate setpoint and tyre pressures | | **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read every half second |
| **Consumption** | Daily and period consumption, efficiency | | **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
| **Charging** | Charging sessions and curves, cost in euro | | **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency | | **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import | | **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
| **Settings** | Full screen, voice key, tyre pressure unit, accent colour, battery (usable capacity, manual SoH), charging price, version, update check, About | | **Settings** | Full screen, voice key, tyre pressure unit, accent colour, battery (manual SoH, usable capacity), charging price, entry on the phone, version, update check, install from USB stick, About |
| **Logs** | Upload log | | **Logs** | Upload log |
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the - **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
@@ -107,9 +123,15 @@ adb shell "pm install -r -f -d -t /data/local/tmp/update.apk"
> ⚠️ Do this **parked**, and only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md). > ⚠️ Do this **parked**, and only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md).
After that first install, the app updates itself: publish a build with `publish-car`, then After that first install, the app updates itself, two ways (details in [`OTA.md`](OTA.md)):
press the refresh button in the app's top bar (`OTA.md`). The car fetches it through your car
server, so the repository stays private. - **Over the air:** publish a build with `publish-car`, then press **Check for updates** on the
Settings tab. The car fetches it through your car server, which relays the forge's release, so
the car never holds a forge credential.
- **From a USB stick:** copy `NilsMG4-unstable-<version>.apk` and its `.sha256` from `build/car/`
to the stick's top folder and press **Install from USB stick** on the Settings tab.
Either way the app checks the SHA-256, the package and the signature before installing.
## Setup ## Setup
@@ -122,7 +144,7 @@ server, so the repository stays private.
### On your phone ### On your phone
Once the car is paired, **Enter on phone** (Live data tab, and Settings → Charging price) shows a Once the car is paired, **Enter on phone** (Live data tab, and Settings → On your phone) shows a
QR code. It opens a form on your server where you type the ABRP API key and user token, the QR code. It opens a form on your server where you type the ABRP API key and user token, the
charging price and the usable capacity, and send them with the approval password. The car charging price and the usable capacity, and send them with the approval password. The car
collects them within a few seconds and saves them through the same checks as the fields on collects them within a few seconds and saves them through the same checks as the fields on
@@ -186,13 +208,19 @@ Tests and lint:
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug ./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
``` ```
A head-unit emulator for checking layouts and installs: `nix run .#car-emulator` (add
`-no-window` for headless) starts Android 9 at 1920×720 and 160 dpi. It is signed with the same
platform key as the car, so the car build installs there as the system user;
`adb shell wm size 1778x640` gives the car's app window. It has no vehicle, so values stay "—".
[`mise`](https://mise.jdx.dev) tasks from upstream (`mise run check`, `mise run build`) still [`mise`](https://mise.jdx.dev) tasks from upstream (`mise run check`, `mise run build`) still
work for the plain Gradle builds. work for the plain Gradle builds.
## Server and dashboards ## Server and dashboards
- **Server** ([`server/`](server/README.md)): Go, API only. Live telemetry, daily consumption - **Server** ([`server/`](server/README.md)): Go, API only. Live telemetry, daily consumption
chunks, charging sessions, pairing. Image at chunks, charging sessions, pairing, settings entered on a phone, and the release relay for the
in-app updater. Image at
`git.stinnesbeck.com/nils/mg4-server`. `git.stinnesbeck.com/nils/mg4-server`.
- **Kubernetes** ([`k8s/`](k8s/README.md)): plain manifests for the server, InfluxDB 2 and - **Kubernetes** ([`k8s/`](k8s/README.md)): plain manifests for the server, InfluxDB 2 and
Grafana, applied by hand with `kubectl apply -k k8s/`. Secrets go in the gitignored Grafana, applied by hand with `kubectl apply -k k8s/`. Secrets go in the gitignored
@@ -205,13 +233,13 @@ work for the plain Gradle builds.
``` ```
app/src/main app: upload service, server client, pairing, UI app/src/main app: upload service, server client, pairing, UI
app/src/stable no-op update hook (no updater in the APK) app/src/stable no-op update hook (no updater in the APK)
app/src/unstable car build: system UID, updater (currently pointed nowhere) app/src/unstable car build: system UID, updater (via the car server) and USB install
app/src/debug VHAL probe tools, absent from release builds app/src/debug VHAL probe tools, absent from release builds
app/src/test JVM unit tests app/src/test JVM unit tests
EVHardware shared vehicle reads per firmware generation EVHardware shared vehicle reads per firmware generation
server/ Go server server/ Go server
k8s/ Kubernetes manifests and Grafana provisioning k8s/ Kubernetes manifests and Grafana provisioning
flake.nix dev shell and build-car flake.nix dev shell, build-car, publish-car, car-emulator
``` ```
## Project documents ## Project documents
@@ -219,8 +247,9 @@ flake.nix dev shell and build-car
| Document | What it covers | | Document | What it covers |
|---|---| |---|---|
| [SPECS.md](SPECS.md) | Journal of this fork: architecture, status, to-dos, done log | | [SPECS.md](SPECS.md) | Journal of this fork: architecture, status, to-dos, done log |
| [OTA.md](OTA.md) | Publishing releases, the updater's checks, USB install |
| [FIRMWARE.md](FIRMWARE.md) | MG4 firmware generations and how the app reads each | | [FIRMWARE.md](FIRMWARE.md) | MG4 firmware generations and how the app reads each |
| [AGENTS.md](AGENTS.md) | Context for AI agents working in this repository | | [AGENTS.md](AGENTS.md) | Overview of the repository for AI agents and new contributors |
| [DESIGN.md](DESIGN.md) | The EVSuite design system (the app overrides its palette) | | [DESIGN.md](DESIGN.md) | The EVSuite design system (the app overrides its palette) |
| [SECURITY.md](SECURITY.md) | Threat model | | [SECURITY.md](SECURITY.md) | Threat model |
| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer; read before installing | | [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer; read before installing |
+42
View File
@@ -112,10 +112,52 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
`kubectl apply -f k8s/`). Filled-in secrets live only there from now on. `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
- 2026-10-18 — `english-translation` merged into the default branch (merge commit `f54e684`, the
summary as its message), `master` renamed to `main`, the work branch deleted. Work goes on `main`.
- 2026-10-18 — AGENTS.md rewritten as the entry point for agents: what the project is, rules, - 2026-10-18 — AGENTS.md rewritten as the entry point for agents: what the project is, rules,
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-19 — `k8s/agent-access/`: a service account `mg4-agent` for a coding agent, limited to
the `mg4` namespace. Read-only role by default; an optional deploy role. Neither may touch
Secrets, exec, port-forward or RBAC; the deploy role cannot delete PVCs. `make-kubeconfig.sh`
writes a kubeconfig with a `kubectl create token` token that expires (default 24 h). Validated
with kubeconform and shellcheck, the script against a stub kubectl; not applied.
- 2026-10-19 — App 2.19.0: EVHardware updated from malys/EVHardware `b595ccb` (2026-09-17, the
base Fatih copied) to `e4e1158` (2026-10-18, after 1.18.0), 42 commits, merged three ways so Fatih's
changes stay (listed in `LICENSE.md`). Upstream brings among others: trip, charge and eco
telemetry fixes, a daily battery digest, the snapshot's widest window and raw drive mode and
regen, probes for the vendor charge status, plug and energy ids, read-only vendor-surface probes,
exterior-light conditions and actions, and a diagnostic inbox. The app uses only
`EnergyTelemetryReader` and its snapshot, so the new reads (windows, drive mode, regen) are the
only new calls it makes; they are reads. The new actions and the inbox are not called. Conflicts
were only where both sides added fields; the charge-status id is now upstream's
`PROP_BMS_CHRG_STS`. EVHardware's 491 tests and the app's pass; the app ran on the emulator
without errors. Not tried on the car.
- 2026-10-19 — App 2.18.3: the Battery tile's value is 40 sp too. The first row of tiles now
shares one height, since the Battery tile, with its charging line, grew taller than the rest.
Checked on the emulator with sample values: the Overview still fits 1778×640. Not tried on the car.
- 2026-10-19 — App 2.18.2: Overview's Range, Power and Speed tiles (the ones without a line under
the value) show it at 40 sp instead of 28; auto-size still shrinks a long value. Checked on the
emulator with sample values ("-142.5 kW", "160 km/h") in a throwaway build: they fit at full
size and the row keeps its height. Not tried on the car.
- 2026-10-19 — Server 2.6.1: the code field on the phone pages (`/pair`, `/settings`, when opened
without the code in the link) upper-cases what is typed and puts the dash in after the third
character. That takes the pages' first script: one inline script, allowed by its SHA-256 hash in
the CSP (`script-src 'sha256-…'`, still `default-src 'none'`); a test checks the served script
against the header. Without it the server still accepts the code in any case, with or without
the dash. Image pinned by digest in `k8s/server.yaml` (not applied).
- 2026-10-18 — README brought up to date: screenshots of all seven tabs from 2.18.1 at the car's
1778×640 window (the upstream and 2.8.2 screenshots are gone), tab descriptions, updating over
the air and from a USB stick, the emulator, the release relay. OTA.md: updates are checked from
Settings, no longer from a top-bar button.
- 2026-10-18 — App 2.18.1: every tab fits the car's window (1778×640 dp) without scrolling — checked
on the emulator by swiping each page and comparing screenshots. Charging and Consumption get wide
layouts with two columns (values | curve; period totals | daily log and storage note); the curve
table scrolls inside its card, so a long session no longer stretches the page. Live data: ABRP key
and token side by side, Save / Test / Enter on phone in one row. Settings: save buttons beside
their fields, charging price under Battery, "Enter on phone" in a card of its own above App. Wide
pages use 12 dp top padding. With real data the value texts are longer; not tried on the car.
- 2026-10-18 — App 2.18.0: Settings → App → "Install from USB stick" (car build only): the newest - 2026-10-18 — App 2.18.0: Settings → App → "Install from USB stick" (car build only): the newest
`NilsMG4-unstable-<version>.apk` newer than the installed one in a stick's top folder, checked `NilsMG4-unstable-<version>.apk` newer than the installed one in a stick's top folder, checked
against its `.sha256` and the package, installed with the OTA installer session. Tested on the against its `.sha256` and the package, installed with the OTA installer session. Tested on the
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 95 versionCode 99
versionName "2.18.0" versionName "2.19.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true">
<!-- Two columns: live values | curve. The table scrolls inside its card so a long session does not push the page. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="16dp" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:id="@+id/charge_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/charging_not_charging" android:textAppearance="@style/Text.EV.Title" android:textColor="?attr/evAccentLight"/>
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="2" android:layout_marginTop="12dp">
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_soc" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_start_soc" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_start_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_battery_voltage" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_voltage" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_battery_current" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_current" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_power_label" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_power" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_net_energy_into_battery" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_drawn_from_grid" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_grid_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_time_label" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_duration" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/charging_total_cost" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/charge_total_cost" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="10dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
</GridLayout>
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/charging_curve_title" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.evsuite.abrp.ChargingGraphView android:id="@+id/charge_graph" android:layout_width="match_parent" android:layout_height="250dp" android:layout_marginTop="12dp"/>
<androidx.core.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="170dp" android:layout_marginTop="16dp" android:scrollbars="none">
<TextView android:id="@+id/charge_curve_table" android:layout_width="match_parent" android:layout_height="wrap_content" android:fontFamily="monospace" android:text="@string/charging_no_charging_measurements_yet" android:textAppearance="@style/Text.EV.Body" android:textColor="?attr/evAccentLight"/>
</androidx.core.widget.NestedScrollView>
</LinearLayout>
</LinearLayout>
</com.evsuite.abrp.StableScrollView>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true">
<!-- Two columns: period totals | daily log and the storage note. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="16dp" android:orientation="vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/consumption_period_title" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_refresh" style="@style/Widget.EV.Button.Outlined" android:layout_width="48dp" android:layout_height="48dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="@string/btn_refresh" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" app:iconPadding="0dp" app:iconTint="?attr/evAccent"/>
</LinearLayout>
<HorizontalScrollView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:scrollbars="none">
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal">
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_lifetime" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:text="@string/consumption_lifetime"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_week" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="@string/consumption_last_7_days"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_month" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="@string/consumption_last_30_days"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_trip_a" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="@string/consumption_trip_a"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_trip_b" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:layout_marginStart="8dp" android:text="@string/consumption_trip_b"/>
</LinearLayout>
</HorizontalScrollView>
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:columnCount="2">
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_distance" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_distance" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_net_energy" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_energy" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_average_consumption" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_average" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_average_speed" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_speed" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_duration" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_time" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
<TextView android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/consumption_soc_change" android:textAppearance="@style/DataLabel"/><TextView android:id="@+id/consumption_soc" android:layout_width="0dp" android:layout_columnWeight="1" android:padding="12dp" android:text="@string/no_value" android:textAppearance="@style/DataValue"/>
</GridLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_reset" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginTop="12dp" android:text="@string/consumption_reset_selected_trip" android:visibility="gone"/>
</LinearLayout>
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/consumption_daily_log_title" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_date_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="56dp" android:layout_marginTop="12dp" android:text="@string/consumption_pick_a_date"/>
<TextView android:id="@+id/consumption_daily_history" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:fontFamily="monospace" android:text="@string/consumption_no_daily_data_yet" android:textAppearance="@style/Text.EV.Body" android:textColor="?attr/evAccentLight"/>
</LinearLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/consumption_stored_note" android:textAppearance="@style/Text.EV.Secondary"/>
</LinearLayout>
</LinearLayout>
</com.evsuite.abrp.StableScrollView>
@@ -1,30 +1,34 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false"> <com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" /> <TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:paddingStart="16dp" android:paddingEnd="16dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:layout_marginTop="12dp" android:layout_marginBottom="4dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" /> <com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass" android:layout_marginTop="12dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_abrp_title" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_abrp_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/hint_abrp_direct" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/hint_abrp_direct" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_api_key_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_abrp_api_key" app:endIconMode="password_toggle" app:endIconMinSize="72dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:baselineAligned="false">
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_api_key_layout" style="@style/Widget.EV.Input" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:hint="@string/hint_abrp_api_key" app:endIconMode="password_toggle" app:endIconMinSize="72dp">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_api_key_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" /> <com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_api_key_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_abrp_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp"> <com.google.android.material.textfield.TextInputLayout android:id="@+id/abrp_token_layout" style="@style/Widget.EV.Input" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:hint="@string/hint_abrp_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" /> <com.google.android.material.textfield.TextInputEditText android:id="@+id/abrp_token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" /> </LinearLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal">
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" /> <com.google.android.material.button.MaterialButton android:id="@+id/abrp_save_button" style="@style/Widget.EV.Button" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/btn_save" />
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" /> <com.google.android.material.button.MaterialButton android:id="@+id/abrp_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginStart="8dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginStart="8dp" />
</LinearLayout>
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:textAppearance="@style/Text.EV.Body" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_frequency" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_frequency" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp"> <com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
@@ -1,50 +1,60 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false"> <com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<!-- Three columns: display and steering wheel | battery | charging price and the app. --> <!-- Three columns: display and steering wheel | battery and charging price | phone and the app.
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false"> Sized to fit the car's 1778x640 dp window without scrolling. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_display_title" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_display_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/full_screen_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_full_screen" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" /> <com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/full_screen_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_full_screen" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_full_screen" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_full_screen" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" /> <com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp"> <com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" /> <com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp"> <com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" /> <com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_settings_title" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_settings_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/settings_manual_soh" app:suffixText="@string/unit_percent"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:hint="@string/settings_manual_soh" app:suffixText="@string/unit_percent">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/> <com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_soh_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/settings_save_soh_value"/> <com.google.android.material.button.MaterialButton android:id="@+id/consumption_soh_save" style="@style/Widget.EV.Button" android:layout_width="wrap_content" android:layout_height="72dp" android:layout_marginStart="12dp" android:text="@string/settings_save_soh_value"/>
</LinearLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/settings_soh_note" android:textAppearance="@style/Text.EV.Secondary"/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/settings_soh_note" android:textAppearance="@style/Text.EV.Secondary"/>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/usable_capacity_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:hint="@string/hint_usable_capacity" app:suffixText="@string/unit_kwh"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
<com.google.android.material.textfield.TextInputLayout android:id="@+id/usable_capacity_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:hint="@string/hint_usable_capacity" app:suffixText="@string/unit_kwh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/usable_capacity_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/> <com.google.android.material.textfield.TextInputEditText android:id="@+id/usable_capacity_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/usable_capacity_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/btn_save_capacity"/> <com.google.android.material.button.MaterialButton android:id="@+id/usable_capacity_save" style="@style/Widget.EV.Button" android:layout_width="wrap_content" android:layout_height="72dp" android:layout_marginStart="12dp" android:text="@string/btn_save_capacity"/>
</LinearLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/helper_usable_capacity" android:textAppearance="@style/Text.EV.Secondary"/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/helper_usable_capacity" android:textAppearance="@style/Text.EV.Secondary"/>
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass" android:layout_marginTop="12dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:hint="@string/settings_unit_price" app:suffixText="@string/unit_eur_per_kwh">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="wrap_content" android:layout_height="72dp" android:layout_marginStart="12dp" android:text="@string/settings_save_price"/>
</LinearLayout>
</LinearLayout>
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_price_title" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_phone_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/settings_unit_price" app:suffixText="@string/unit_eur_per_kwh"> <com.google.android.material.button.MaterialButton android:id="@+id/settings_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="12dp" />
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="@string/settings_save_price"/>
<com.google.android.material.button.MaterialButton android:id="@+id/settings_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/hint_phone_settings" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/hint_phone_settings" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass" android:layout_marginTop="12dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_app_title" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_app_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/settings_version" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" /> <TextView android:id="@+id/settings_version" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.button.MaterialButton android:id="@+id/settings_update_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_check_update" app:icon="@drawable/ic_refresh" /> <com.google.android.material.button.MaterialButton android:id="@+id/settings_update_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_check_update" app:icon="@drawable/ic_refresh" />
@@ -2,32 +2,32 @@
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false"> <com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_weight="1" android:layout_marginEnd="10dp"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:textSize="40sp" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="40sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_charging" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" /> <TextView android:id="@+id/vehicle_charging" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:textSize="40sp" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="40sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_trip" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_range_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" /> <TextView android:id="@+id/vehicle_range_trip_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_average" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_average" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_range_average" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_range_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" /> <TextView android:id="@+id/vehicle_range_average_note" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:textSize="40sp" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="40sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_weight="1" android:layout_marginEnd="10dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:textSize="40sp" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="40sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:baselineAligned="false"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:baselineAligned="false">
+4 -4
View File
@@ -4,12 +4,12 @@
<GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:columnCount="2"> <GridLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:columnCount="2">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_columnWeight="1" android:layout_margin="4dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_battery_card" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_short" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_soc" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:textSize="40sp" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="40sp" android:autoSizeStepGranularity="1sp" />
<TextView android:id="@+id/vehicle_charging" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" /> <TextView android:id="@+id/vehicle_charging" android:gravity="end" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_range" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:textSize="40sp" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="40sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_trip" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_range_trip" android:textAppearance="@style/Text.EV.Secondary" />
@@ -38,7 +38,7 @@
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_power" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:textSize="40sp" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="40sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power_average" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_power_average" android:textAppearance="@style/Text.EV.Secondary" />
@@ -47,7 +47,7 @@
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:layout_columnWeight="1" android:layout_margin="4dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" /> <TextView android:id="@+id/vehicle_speed" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:textSize="40sp" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="40sp" android:autoSizeStepGranularity="1sp" />
</LinearLayout> </LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_columnWeight="1" android:layout_margin="4dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed_average" android:textAppearance="@style/Text.EV.Secondary" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_speed_average" android:textAppearance="@style/Text.EV.Secondary" />
@@ -26,6 +26,7 @@
<string name="design_battery_settings_title">BATTERY</string> <string name="design_battery_settings_title">BATTERY</string>
<string name="design_app_title">APP</string> <string name="design_app_title">APP</string>
<string name="design_price_title">CHARGING PRICE</string> <string name="design_price_title">CHARGING PRICE</string>
<string name="design_phone_title">ON YOUR PHONE</string>
<string name="design_service_timing">UPDATE FREQUENCY</string> <string name="design_service_timing">UPDATE FREQUENCY</string>
<string name="design_car_desc">Tire pressure positions on the vehicle</string> <string name="design_car_desc">Tire pressure positions on the vehicle</string>
</resources> </resources>
+6
View File
@@ -54,6 +54,12 @@ the forge that can only **read** the `nils/mg4` repository, put it there, apply
restart the server. Without it, updates are off (the car's updater gets a 404). `FORGE_URL` restart the server. Without it, updates are off (the car's updater gets a 404). `FORGE_URL`
and `FORGE_REPO` are set in `server.yaml`. and `FORGE_REPO` are set in `server.yaml`.
## Agent access
[`agent-access/`](agent-access/README.md) creates a service account limited to the `mg4`
namespace (read-only by default, no Secrets, no exec) and a script that writes a kubeconfig with a
time-limited token for it. Applied separately, with `kubectl apply -k k8s/agent-access/`.
## Notes ## Notes
- InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret later - InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret later
+59
View File
@@ -0,0 +1,59 @@
# Agent access to the mg4 namespace
A kubeconfig for a coding agent that reaches **only the `mg4` namespace**, with a token that
expires on its own. You create and apply everything here yourself; the agent never does.
| File | What |
|---|---|
| `serviceaccount.yaml` | the `mg4-agent` service account in `mg4`, never mounted into a pod |
| `role-read.yaml` | read-only: workloads, pods and their logs, services, ConfigMaps, PVCs, events, network policies, IngressRoutes |
| `role-deploy.yaml` | **optional**: apply this repository's manifests and restart workloads |
| `make-kubeconfig.sh` | writes the kubeconfig with a time-limited token |
Neither role allows **Secrets** (any verb), `pods/exec`, `pods/attach`, `pods/portforward`, RBAC
objects, the Namespace itself, or anything in another namespace or at cluster scope. The deploy
role also cannot delete PersistentVolumeClaims, so InfluxDB's data volume stays safe.
## 1. Create the service account and its role
With your own kubeconfig, after `k8s/` itself is applied (the namespace must exist):
```sh
kubectl apply -k k8s/agent-access/
```
That is read-only. To let the agent also apply changes, uncomment `role-deploy.yaml` in
`kustomization.yaml` first. Be aware of what that means: whoever may change a Deployment may run
any image in the namespace, and such a pod could mount mg4's Secrets and print them. Read-only is
the limit that holds even against a misbehaving agent; the deploy role is a convenience.
## 2. Check what it may do
```sh
kubectl auth can-i --list -n mg4 --as=system:serviceaccount:mg4:mg4-agent
kubectl auth can-i get secrets -n mg4 --as=system:serviceaccount:mg4:mg4-agent # no
kubectl auth can-i create pods/exec -n mg4 --as=system:serviceaccount:mg4:mg4-agent # no
kubectl auth can-i list pods -n kube-system --as=system:serviceaccount:mg4:mg4-agent # no
```
## 3. Make the kubeconfig
```sh
k8s/agent-access/make-kubeconfig.sh 24h
```
It takes the API server address and CA from your current context, asks the cluster for a token
for `mg4-agent` (`kubectl create token`, valid for the duration you give; the API server may cap
it), and writes `~/.config/nilsmg4/kubeconfig-mg4-agent` with mode 600. The token is never
printed. The file is outside the repository, so it cannot be committed.
Then tell the agent it may use it, e.g. *"you may use ~/.config/nilsmg4/kubeconfig-mg4-agent"*.
It runs `kubectl` with `KUBECONFIG` pointing at that file, so your own kubeconfig is never used.
## Ending access
- **Wait:** the token expires after its duration. Run the script again for a new one.
- **Now:** `kubectl delete serviceaccount mg4-agent -n mg4` invalidates every token made for
it at once. Remove the rest with `kubectl delete -k k8s/agent-access/`.
- **Less:** take `role-deploy.yaml` out of the kustomization and
`kubectl delete rolebinding,role mg4-agent-deploy -n mg4`.
+8
View File
@@ -0,0 +1,8 @@
# Agent access to the mg4 namespace, applied separately from k8s/: `kubectl apply -k k8s/agent-access/`.
# Read-only by default; add role-deploy.yaml below to let the agent apply changes too.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- serviceaccount.yaml
- role-read.yaml
# - role-deploy.yaml
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Writes a kubeconfig for the mg4-agent service account, valid for a limited time.
#
# k8s/agent-access/make-kubeconfig.sh [duration] [output]
#
# Run it yourself, with your own (admin) kubeconfig active. duration defaults to 24h; the API
# server may cap it (--service-account-max-token-expiration). The token is written to the file
# only, never printed.
set -euo pipefail
duration="${1:-24h}"
out="${2:-$HOME/.config/nilsmg4/kubeconfig-mg4-agent}"
cluster="$(kubectl config view --minify -o jsonpath='{.clusters[0].name}')"
server="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')"
ca="$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')"
if [ -z "$ca" ]; then
ca_file="$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority}')"
[ -n "$ca_file" ] || { echo "no cluster CA in the current kubeconfig" >&2; exit 1; }
ca="$(base64 -w0 < "$ca_file")"
fi
token="$(kubectl create token mg4-agent --namespace mg4 --duration "$duration")"
mkdir -p "$(dirname "$out")"
umask 077
cat > "$out" <<KUBECONFIG
apiVersion: v1
kind: Config
clusters:
- name: ${cluster}
cluster:
server: ${server}
certificate-authority-data: ${ca}
users:
- name: mg4-agent
user:
token: ${token}
contexts:
- name: mg4-agent
context:
cluster: ${cluster}
namespace: mg4
user: mg4-agent
current-context: mg4-agent
KUBECONFIG
echo "Wrote $out (mode 600), token valid for $duration."
echo "Check: KUBECONFIG=$out kubectl auth can-i --list"
+59
View File
@@ -0,0 +1,59 @@
# OPTIONAL, on top of role-read.yaml: lets the agent apply this repository's manifests and
# restart workloads in mg4. Not in kustomization.yaml; add it there only if you want that.
#
# Still absent: Secrets, pods/exec, pods/attach, pods/portforward, RBAC, the Namespace itself,
# and deleting PersistentVolumeClaims (InfluxDB's data lives on one).
#
# Note what this does allow: whoever may change a Deployment may run any image in the namespace,
# and such a pod could mount mg4's Secrets and print them to its log. Read-only is the boundary
# that holds against a misbehaving agent; this role is for convenience, given trust.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mg4-agent-deploy
namespace: mg4
labels:
app.kubernetes.io/part-of: mg4
app.kubernetes.io/component: agent-access
rules:
- apiGroups: [apps]
resources: [deployments, statefulsets]
verbs: [create, update, patch, delete]
- apiGroups: [apps]
resources: [deployments/scale, statefulsets/scale]
verbs: [get, update, patch]
- apiGroups: [""]
resources: [services, configmaps]
verbs: [create, update, patch, delete]
- apiGroups: [""]
resources: [persistentvolumeclaims]
verbs: [create, update, patch]
- apiGroups: [""]
resources: [pods]
verbs: [delete]
- apiGroups: [batch]
resources: [jobs]
verbs: [create, delete]
- apiGroups: [networking.k8s.io]
resources: [networkpolicies]
verbs: [create, update, patch, delete]
- apiGroups: [traefik.io]
resources: [ingressroutes]
verbs: [create, update, patch, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mg4-agent-deploy
namespace: mg4
labels:
app.kubernetes.io/part-of: mg4
app.kubernetes.io/component: agent-access
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mg4-agent-deploy
subjects:
- kind: ServiceAccount
name: mg4-agent
namespace: mg4
+49
View File
@@ -0,0 +1,49 @@
# Read-only view of the mg4 namespace: what runs, its logs and events, its configuration.
# Deliberately absent: Secrets (any verb), pods/exec, pods/attach, pods/portforward, RBAC.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mg4-agent-read
namespace: mg4
labels:
app.kubernetes.io/part-of: mg4
app.kubernetes.io/component: agent-access
rules:
- apiGroups: [""]
resources: [pods, pods/log, services, endpoints, configmaps, persistentvolumeclaims, events, serviceaccounts]
verbs: [get, list, watch]
- apiGroups: [apps]
resources: [deployments, statefulsets, replicasets]
verbs: [get, list, watch]
- apiGroups: [batch]
resources: [jobs, cronjobs]
verbs: [get, list, watch]
- apiGroups: [networking.k8s.io]
resources: [networkpolicies]
verbs: [get, list, watch]
- apiGroups: [events.k8s.io]
resources: [events]
verbs: [get, list, watch]
- apiGroups: [traefik.io]
resources: [ingressroutes, middlewares]
verbs: [get, list, watch]
- apiGroups: [metrics.k8s.io]
resources: [pods]
verbs: [get, list]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mg4-agent-read
namespace: mg4
labels:
app.kubernetes.io/part-of: mg4
app.kubernetes.io/component: agent-access
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mg4-agent-read
subjects:
- kind: ServiceAccount
name: mg4-agent
namespace: mg4
+11
View File
@@ -0,0 +1,11 @@
# The identity a coding agent uses on the cluster. It exists only in the mg4 namespace and is
# never mounted into a pod; its tokens are made by hand with `kubectl create token` (README.md).
apiVersion: v1
kind: ServiceAccount
metadata:
name: mg4-agent
namespace: mg4
labels:
app.kubernetes.io/part-of: mg4
app.kubernetes.io/component: agent-access
automountServiceAccountToken: false
+2 -2
View File
@@ -29,8 +29,8 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: server - name: server
# 2.6.0, pinned by digest so a re-pushed tag cannot change what runs. # 2.6.1, pinned by digest so a re-pushed tag cannot change what runs.
image: git.stinnesbeck.com/nils/mg4-server@sha256:35ff5973836620c1e69f06b00163cf95183f938cb9830355452d4161c6c46e33 image: git.stinnesbeck.com/nils/mg4-server@sha256:df1d40e423b7ee365e8379e2eaefb4c0db3a599e6429728a37d46827f83d82cd
ports: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080
Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

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