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
This commit is contained in:
2026-10-19 09:44:00 +02:00
co-authored by Claude Opus 5.5
parent c6e6b3e88e
commit f0f4f57fee
71 changed files with 4646 additions and 191 deletions
+252
View File
@@ -8,6 +8,258 @@ All notable changes to this project are documented here. Format follows
### Added
- `diag.DiagnosticInbox`: posts one diagnostic artifact (a bundle zip or a text report) to an
HTTPS inbox the driver runs at home, as one JSON object shared by every app. No redirect
followed; an address with credentials or a query is refused; 1 MiB cap.
### Changed
- Hard acceleration and hard braking start at 2.5 m/s², up from 1.5. On the car, ordinary town
drives counted 7 to 16 hard stops every 10 km, and regeneration on High alone comes close to
the old line. Trips recorded before keep the counts they were given.
- `EnergySnapshot.climate.passengerTargetCelsius` is null when the car publishes no passenger
setpoint, instead of a copy of the driver's. A screen that shows one zone for both seats says so.
### Fixed
- Screen brightness rounds both ways: 10 % was written as native 25 and read back as 9 %.
- A trip that started on the head unit's factory clock is dated from the corrected one.
- `RuntimeInterfaceProbe` records ABSENT, not DENIED, when the hub hands over another interface:
ICarPowerService and IVehiclePropertyService answer as IVehicleSettingService on SWI68.
- The charge diagnostic line shows `pack_delta_kwh=unavailable` for an unwatched charge.
- The ACC/TJA and speed-limiter getters log the value they read.
## [1.18.0] - 2026-10-16
### Added
- `EnergySnapshot`, `TripSample` and `EnergyTripSummary` carry the raw drive mode and regen
codes (`getDriveModeRaw`, `getRegenLevelRaw`; 6 is one-pedal on SWI68). Raw, so an unknown code
stays unknown instead of reading NORMAL or MEDIUM; `DriveMode.ofRaw` and `RegenLevel.ofRaw`
decode exactly.
- `EnergyTripSummary` counts driving style at the live 1 Hz rate: moving and hard time, hard
accelerations and hard brakings as episodes. The 5 s track had smoothed short hard stops away.
- `EcoScore`: a trip out of 100 from anticipation (hard brakings per 10 km), smoothness (hard
share) and efficiency (charge against the driver's own expectation). Unmeasured parts drop out.
### Changed
- The eco verdict's TYPICAL zone is one residual either side of the expectation, half the
prediction band. At the full 1.96 band almost every drive read TYPICAL.
### Fixed
- `TripDetector` ends an automatic trip two seconds after the car reports P or a plugged cable,
instead of after the two-minute standstill. The SWI68 head unit powers off about twelve
seconds after P, so an automatic trip died with the process before the timer ran out.
- `SocConsumptionFitter` falls back to two coefficients when the thermal term comes out
negative, instead of refusing the fit. The 2026-10-16 bundle refused 87 segments for this
reason alone.
- `CarPropertyEvidence` no longer lists SWI68 battery power as never published: the vendor pack
fallback reads −32,9 to +32,4 kW on a town drive. Screens now say "not validated yet" instead
of "this vehicle never publishes battery power".
## [1.17.0] - 2026-10-14
### Changed
- `SET_CHARGE_LIMIT` is `writeProven = false`: SWI68 reads the limit back as `5`, a code, while
the write sends a percent. Hidden from editors and refused by executors until one write is
seen to set the limit on the car's screen.
### Added
- Probe-only vendor charge readings for the telemetry evidence: `BMS_CHRG_STS`, the AC and DC
plug states and the kWh-since-plug-in counter, ids from MG4Control's SWI133 readings and
unproven on SWI68. Recorded as `candidate.vendor*`; nothing reads them as values yet.
- `RuntimeInterfaceProbe.componentMap()`, part of `survey()`: every service the
`com.saicmotor.*` packages declare, with its exported flag and permission, from
PackageManager metadata only. Nothing is bound or called; capped at 64 packages and
40 services per package, with the cut recorded.
- `windows.widestPercent` in the telemetry evidence, from `EnergySnapshot.widestWindowPercent`.
- `SocConsumptionFitter.describe()` ends with the solve's outcome once both gates pass:
`fit=ready rmse=…` or `fit=refused(<guard>)`. The existing prefix is unchanged.
### Fixed
- **`SaicCharging.chargeLimitPercent()` answers null outside 40–100 %.** SWI68 answered `5`,
a code rather than a percent; callers now take their "limit unreadable" path. The setter
is unchanged.
- **The battery ledger ignores readings taken on the factory clock.** A head unit woken by a
night charge runs on its late-2018 clock until it syncs; one such entry sorted first in the
ledger and split the charge. `BatteryLedgerRecorder` now drops samples before 2025-01-01.
## [1.16.0] - 2026-10-11
### Added
- `EVHardware.probeFrontDoors()`: front-door evidence on firmwares the door feature does not
claim. Connects and subscribes to DLOCK_DOOR_OPEN_STS whatever `hasDoorVolumeFeature()` says
(MG4Control reads it through the change subscription on SWI68/69/131/165) and reports the
managers, the subscriptions, the on-demand read and the last event per front door.
`frontDoorOpenOrNull()` stays gated until a car confirms.
- `SaicClimate.passengerTempState()`: the passenger target as the service answers it, -1
included, for the diagnostic.
### Fixed
- `SET_PASSENGER_TEMP` no longer claims SWI68: the passenger target reads -1 there, so nothing
shows the write lands.
## [1.15.1] - 2026-10-11
### Fixed
- **`PASSENGER_TEMP` and `DOORS_LOCKED` no longer claim SWI68.** Both were declared from the
getters the SAIC services expose, never from a read on the car, and a SWI68 MG4 reported
both `NOT_READABLE` (2026-10-11). The passenger target reads a zone a single-zone car does not
have; the service answers -1. `getDoorLock` reads back the lock *request* property, and the
car answered 0, which is neither of the two named values. The editor now hides both there.
SWI165 keeps them until a car says otherwise.
## [1.15.0] - 2026-10-08
### Added
- **Exterior-light conditions and standstill actions (CR-046).** Conditions `HEADLIGHTS`
(off / on / daytime running), `HIGH_BEAM_ON` and `FOG_LIGHTS_ON`, plus gated actions
`SET_HEADLIGHTS` (ON / AUTO), `SET_HIGH_BEAM` (OFF / AUTO) and `SET_FOG_LIGHTS`. They use the
standard AAOS `HEADLIGHTS` / `HIGH_BEAM_LIGHTS` / `FOG_LIGHTS` `_STATE` and `_SWITCH` ids.
Writes happen only at 0 km/h. A rule can never switch the low beams OFF, and can never force
the high beam ON (`ExteriorLights`). **Proven on no generation yet**: `ExteriorLights.PROVEN`
and `@SupportedOn` are empty and `writeProven` is false, so the entries stay hidden and inert
until a capture proves a generation.
- **Read-only vendor-surface tools (CR-044, CR-045)** for an app's unstable capture.
`VendorSurfaceProbe.socSourcesReport()` reads every candidate source of state of charge side by
side. `VendorSurfaceProbe.capture()` together with `VendorSurfaceRules.diff()` reads every
`CarPropertyManager` property and every value getter of the vendor setting objects, before and
after a setting is changed on the car's own screen, and lists what changed, naming the light
properties. No setter is called: getter selection is pinned by `VendorSurfaceRulesTest`.
### Changed
- **`EcoDrivingMonitor.harshSharePercent` counts hard braking as well as hard acceleration.**
The live steadiness share read only positive steps past `HARSH_ACCELERATION_MS2`, while
EVChargePilot's trip review (`DrivingStyle`) has always counted both signs at the same threshold,
so the live gauge read lower than the summary of the same drive — roughly half in stop-and-go.
Thresholds are unchanged.
- **The trip history keeps four times as many tracks.** `EnergyTripHistoryStore`'s default
`maxBytes` goes from 512 KiB to 2 MiB. At about 145 KB per hour of driving, 512 KiB held roughly
fourteen short trips' tracks, so each new drive evicted older tracks and the consumption model's
segment count fell as trips were added (51 to 47 on SWI68, 2026-09-30). One long trip's
4 096-sample track alone did not fit. Every reader parses the file off the main thread and the
write stays atomic, so the cost is parse time and memory per read, not a new failure mode.
### Added
- **CUSTOM drive mode settings (CR-040).** `CustomDrive` reads and writes powertrain response,
steering weight and brake-pedal feel by index (0 Eco/Comfort, 1 Normal, 2 Sport) on the VSM
generations: `…Level` methods on SWI68/SWI165, `…Mode` methods on SWI69/SWI131/SWI132. The
index↔code tables live here only — the pedal's Normal is `0`, the A9 powertrain uses the
drive-mode scale. Writes are standstill-gated. SWI133 answers unavailable: its route is a raw
property id this project has not verified. Codes beyond RI-006 are leads until a car confirms.
- **`DrivingProfile` carries the CUSTOM settings and an optional `ProfileClimate` (CR-041).**
All new fields are nullable and default to null, so every saved profile and backup reads as
"not configured" and writes nothing new.
### Fixed
- **An overnight charge the head unit slept through is a charge.** `ChargeEnergy.plugged` required
the odometer to stand still between the two ledger entries around a rise; when the drive home
was never recorded either, the evening entry predates it, the odometer "moved", and a night's
fifty points were filed as regeneration and hidden from the battery page. A rise is now a charge
when the odometer stood still, **or** the car reported a non-zero charging status, **or** it is
at least 10 points and nobody watched the car move during it.
- **Each charge carries its curve.** `ChargeEnergy.steps` is pack power per watched ledger step
(one step per point of charge), with `peakPowerKw` and the outside temperature range;
`ChargeEnergyReport.powerBySocBand()` aggregates the steps of every charge into 10-point bands,
which is where the taper shows. Measured only; an unwatched charge has no steps. Both reach
`describe()`.
- **A calm drive is measured, not waiting.** `EcoDrivingMonitor.harshSharePercent()` returns the
share of hard acceleration once the window holds a minute of movement, below the advice
threshold included. `steadiness()` still answers only above it; callers can now tell "not
measured yet" from "measured, and calm".
- **The power model trains on town driving.** `EnergyModelTrainer` fitted every 5 s sample,
so an acceleration costing several times a cruise counted as model error and stop-and-go
history never passed the residual gate. It now fits one point per kilometre of driving, a
point per charge made rather than per instant, and `sampleCount` counts those kilometres
(twelve at least). A history without 5 °C of temperature spread fits speed alone, with no
weather term, instead of refusing.
- **The charge model trains on short drives.** `SocConsumptionFitter` waited for 2 % of charge
before cutting a segment; SWI68 publishes tenths of a percent, so every drive under about
ten kilometres gave nothing. The drop needed is now two steps of the track's own gauge,
capped at 2 %, and a stretch shorter than 2 km keeps accumulating instead of being dropped.
- **Hard keys are decoded per broadcast.** `com.saic.keyevent.hardkey.report` carries Android
key codes and `com.android.systemui.ACTION_HARD_KEY_EVENT` carries SAIC's own numbering, and
the same press arrives on both. Read as one space, SystemUI's next-track (5) decoded as the
report's phone (5), and a phone press (5 and 16) read as a double. `accept()` now takes a
`Source`; SystemUI contributes only the cluster pad (6–10), which the report does not carry.
A key neither table names is reported with an id of its own (`unknownKeyId`), so it can still
be bound. `Event` carries `keyId`; `Button.id` keeps the numbers rules already store.
- **Eco-driving trip replay now accepts persisted sample cadence.** Live monitoring still rejects
gaps over five seconds, while replay allows the expected delay between five-second track samples
and the next telemetry frame. Stored trips can therefore report measured steadiness again.
### Added
- **What a drive asked of the pack at the moments it minds most (CP-090).** `DrivingStress.of`
measures, per trip, the minutes of hard discharge (above 60 % of the trip's own peak) below
20 % charge or below 0 °C outside, and the energy regenerated below 0 °C. The pack power sign
is unvalidated (RI-002), so each trip orients itself from its falling charge; a trip that
cannot, or with more than a tenth of its time undecidable, gives null rather than zero.
`DrivingStress.monthly` sums the trips that could say, per local month. Measured quantities,
never a wear figure.
- **How long the rest of a charge takes (CP-089).** `ChargeDuration.estimate` sums, band by
10-point band, the minutes per point the driver's own watched charges took: the median, and the
fastest and slowest charge as the range. Charges at a similar outside temperature (±8 °C) are
tried first, all charges after. A band on the path that no watched charge crossed is refused
(`MISSING_BAND`), never extrapolated. `lastStretchMinutes` is the part above 80 %.
`ChargeDuration.curveCheck` compares one finished band with the same band in at least three
past charges; `slower` only when it took longer than the slowest of them.
- **A daily battery digest (CP-087).** `BatteryDigest` copies one day of the health, calibration,
exposure and charge analyses; `BatteryDigestStore` keeps a year of them, one per local day, with
the same atomic write and quarantine as the ledger. `BatteryDigest.changes` reports only what
moved past its own band from one day to the next — health beyond its uncertainty, a band that
narrowed by at least a point (or a first estimate), a calibration verdict that changed — so a
health figure wandering inside its band is never news.
- **Open windows are a consumption factor, like the climate (CP-086).** `EnergySnapshot` and
`TripSample` carry `widestWindowPercent`, read from `SaicVehicleControl.widestWindowPercent()`;
null where unread, never 0, and absent from older stored trips. The attribution gains
`ResidualContext.WINDOWS_OPEN`: an interval with a window open at either end gets its own
residual instead of joining a climate group, so the cabin share no longer absorbs the drag.
- **`EcoDriving`: the band, the levers and the refusals behind the eco coach.** `EcoDrivingMonitor`
keeps a bounded window of the speed channel and answers one question — is this drive cheaper or
dearer than what this driver's own fit expects at this mean speed and outside temperature —
against the fit's own residual rather than a threshold invented for the purpose. It offers at
most one lever: a cruising speed quoted from `SocConsumptionModel.predict`, or the share of
moving time spent accelerating hard, chosen by precedence because the two have no common unit.
Every silence carries its `UnavailableReason`: no fit, a speed outside the fitted envelope, a
distance recorded under the superseded speed conversion, too little distance, too little charge
drop, no outside temperature. `forReplay()` gives the same class an unbounded window, so a
finished track is judged whole rather than on its last three minutes.
### Added
- **The charge side of the battery ledger, which nothing read.** Phase 5 measures the pack from
its discharges alone: a rise in charge is only a boundary marker, so the energy going back in
was being recorded and never looked at — and the two facts RI-002 has been blocked on since
2026-09-13 live in exactly that gap. `ChargeEnergyAnalyzer` reads it, taking `BatteryExposure`'s
sessions rather than cutting its own so the two can never disagree about what a charge is. Per
charge: energy and mean power from the pack-pair integral, which way that integral moved
(`ENERGY_FELL` is `EnergySnapshot.batteryPowerKw`'s declared convention holding, `ENERGY_ROSE`
is it inverted, `CONTRADICTORY` when two charges disagree — never averaged), what the car's own
counters did (`RESET` is what "since the last charge" predicts, and one observed reset outranks
a charge that ended before the car clears them), and the odometer, because a long descent raises
the charge too and only a stationary session is a plug. A charge the app slept through keeps its
points and loses its power: the recorder earns an entry every quarter of an hour, so a longer
silence is this app having been down, and the integral does not move while nobody is
integrating. `describe()` puts the verdicts in a diagnostic bundle. Nothing is corrected and
nothing new is sampled — no new property, no new transaction, no new permission (CP-073).
- **A probe that says when it was never able to ask.** `RuntimeInterfaceProbe.adapterClientMap()`
swept nineteen service codes and recorded ABSENT for every one of them on a firmware where
`sCarAdapter` is never assigned — nineteen answers to a question that never reached a binder,