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
1200 lines
79 KiB
Markdown
1200 lines
79 KiB
Markdown
# Changelog
|
||
|
||
All notable changes to this project are documented here. Format follows
|
||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||
|
||
## [Unreleased]
|
||
|
||
### Added
|
||
|
||
- `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,
|
||
and RI-002 was nearly closed on them. `EVHardware.hasCarAdapterClient()` is checked once before
|
||
the sweep and every swept entry now carries the caveat on itself rather than in the bundle
|
||
preamble, because a later session reads one entry and not the header.
|
||
|
||
- **`SocConsumptionFitter.describe()` tells the two refusals apart.** Too few segments and a speed
|
||
span too narrow to separate rolling from aero both answer `INSUFFICIENT_SAMPLES`, and the two
|
||
ask the driver for opposite things — more kilometres, or one motorway leg. A third cause was
|
||
silent altogether: a trip skipped for a speed conversion no longer believed leaves no trace, and
|
||
a history of them looks exactly like a history of short drives. One diagnostic line now states
|
||
used-trips, segments and speed span against their thresholds. Diagnostic only — it runs the
|
||
collection pass a second time, so it belongs in a probe and not on a screen.
|
||
|
||
- **The pack's health, as a ratio rather than a reading.** `INFO_EV_BATTERY_CAPACITY`,
|
||
`EV_BATTERY_LEVEL` and the `EV_CURRENT_BATTERY_CAPACITY` candidate are declared and never
|
||
published on SWI68 — null in all 612 snapshots of the 2026-09-13 drive — so the capacity behind
|
||
every kWh figure in EVSuite has been a specification sheet the driver retypes. It does not have
|
||
to be: the energy that left the pack, over the charge it cost, *is* a capacity, and both halves
|
||
are now recorded. `BatteryLedgerStore` keeps the entries a trip cannot — the parking, the
|
||
standing, the charge between two drives — and `BatteryLedgerRecorder` turns the 1 Hz sample
|
||
stream into one entry per point of charge, change of charging state, or quarter of an hour,
|
||
carrying the car's own kWh counters (`SaicCharging` transactions 75 and 77), this app's running
|
||
integral of `BMS_PACK_VOL × BMS_PACK_CRNT`, the odometer and the outside temperature.
|
||
`StateOfHealthEstimator` assembles the discharges between two charges and reports the median of
|
||
at least six of them, with the source named on the estimate and the two sources never averaged
|
||
together. A charge step on this car is about one point, so a 25-point window carries ±4 % before
|
||
anything else goes wrong and a year of real degradation is two or three points: one window is
|
||
never a health figure, the band never claims to be tighter than the quantisation allows, and the
|
||
figure worth reading is the trend, which cancels whatever node the energy was counted at. Cold
|
||
windows are excluded and counted rather than believed. Every output is `ESTIMATED` with its band.
|
||
|
||
- **When the charge gauge last had anything to anchor itself on.** `CalibrationDrift` reads the
|
||
same ledger for a different question: how long since the pack saw a full charge, how many
|
||
equivalent cycles since, and whether any charge step went unexplained — a car that stood still,
|
||
drove nowhere and woke up several points lower did not consume that charge, the BMS re-estimated
|
||
it. The odometer has to read identical at both ends for a step to count at all, and a
|
||
self-discharge allowance keeps a fortnight of standing still from being reported as a fault.
|
||
It notices; it changes nothing, and no figure is corrected against an observed step.
|
||
|
||
- **What a pack has been subjected to.** `BatteryExposure` reports hours above 80 % and below
|
||
10 % of charge (interpolated across each segment, so a night that ended at 85 % counts its hours
|
||
where they were spent), the mean charge, equivalent full cycles, kilometres per cycle, and one
|
||
row per charge session with its mean and peak points per hour — the rate being what separates a
|
||
wall box from a fast charger on a car that publishes no charger type.
|
||
|
||
- **The head unit can now be asked what it publishes.** `RuntimeInterfaceProbe` walks the
|
||
adapter's service codes and the vehicle hub's service names, reads back the interface
|
||
descriptor of every binder that answers, and records one of three verdicts per interface:
|
||
absent, reached-and-refused, or answered. The distinction is the whole point. Half the `RI-`
|
||
questions are currently open because nobody knows whether an interface is missing from this
|
||
head unit or merely closed to an unprivileged caller, and those two answers lead to opposite
|
||
decisions. Reading a binder's own descriptor is not a call into it, so nothing of unknown
|
||
meaning is invoked, and no setter is referenced anywhere in the file — several of these
|
||
binders would move a car. Typed reads follow only for the two bind points already known: the
|
||
vehicle option getters and the head unit's power state. The screen sub-service is bound but
|
||
never read: it publishes no getter at all, and guessing a code to find one is exactly what
|
||
this file exists not to do. There is a second hub behind engineering mode, mapped the same
|
||
way, and the survey asks it for the four part numbers the head unit states about itself —
|
||
identity the vehicle asserts, where the generation label is something the project inferred.
|
||
It also reads the second speed source, which reports a float where the source the write gate
|
||
trusts is integral. Nothing in production calls any of it; it exists to be run once from an
|
||
unstable capture and turned into answers.
|
||
|
||
- **A leg is not over on one reading.** `NavLegChain` needs three consecutive answers of "not
|
||
guiding" before it treats a leg as finished. The drive of 2026-09-09 16:34 is why: a route
|
||
handed over through `startNavFromEVRout` guided for twenty-five minutes while the adapter's
|
||
remaining distance went to nothing seven minutes in and its notification listener heard nothing
|
||
at all. Whether the guiding flag is as shaky as its siblings is not yet known, and the cost of
|
||
being wrong is a car sent onward from a charger it is still driving to. Three readings cost
|
||
seconds; a real arrival stays false for the rest of the day.
|
||
|
||
- **A trip can now be driven one leg at a time.** `NavLegChain` holds the points of a plan and
|
||
hands the next one over when the current one is done — done being the navigation app's own
|
||
`isMapNavigating` flag going false after having been true. It exists because the only call ever
|
||
seen to *start* guidance on this head unit, `goTo`, carries a single point: a plan with a
|
||
charging stop on the way has to be sent as the stop, then the destination. Pure and
|
||
Android-free, so the whole of its behaviour is proven on the JVM rather than on a road. Its
|
||
ceiling is written down in its own documentation: a driver who cancels guidance mid-leg looks
|
||
the same as one who arrived, so the next leg starts under them — and cancelling that one too
|
||
ends the chain.
|
||
|
||
- **The car can now say whether guidance actually started.** `SaicNavGuidance.isMapNavigating` is
|
||
`IGeneralService` transaction 18, and what it returns is not an inference: `MapService` holds
|
||
the flag and the navigation app sets it itself through `IMapService.isMapNavigating(boolean)`.
|
||
A command accepted by the adapter only ever meant the adapter took it — the fan-out swallows a
|
||
refusal — so this is the difference between "sent" and "started", and it lets a caller try one
|
||
channel, ask the car, and move to the next.
|
||
|
||
- **Guidance can be started, not only drawn.** The route handoff put the destination on the map
|
||
and stopped there: the driver saw the place and no guidance ever ran. `SaicNavGuidance.goTo` is
|
||
the command that runs it — `IGeneralService` transaction 23, which `GeneralService` turns into
|
||
`MapService.goToPoi(null, address, null, latitude, longitude)`, the same call the head unit's
|
||
voice assistant makes when someone says "take me there". One point and no pathway, because the
|
||
interface has room for nothing else: a plan with a charging stop guides to the stop, which is
|
||
the leg being driven. Same boundary as `startNavFromEvRoute` — a navigation write, not a
|
||
vehicle write — and the same two cautions: the adapter logs the point, and the fan-out is
|
||
synchronous, so it must not run on the main thread.
|
||
|
||
- **A planned route can be handed to the car's own navigation, waypoints included.** Two drives
|
||
proved that nothing on SWI68 declares a filter for `geo:` or `google.navigation:`: the map
|
||
opened at its default view and the driver typed the address in again. The head unit has its own
|
||
channel and it was hiding in plain sight — `IGeneralService` transaction 48,
|
||
`startNavFromEVRout(pathway, destination)`, on the same `GeneralService` this module already
|
||
binds for guidance. It forwards to `MapService`, which fans the points out over its
|
||
`RemoteCallbackList` to whichever navigation app registered. `SaicNavGuidance.startNavFromEvRoute`
|
||
is that call.
|
||
|
||
**It is not an impersonation and not a vehicle write.** `IMapNotificationListener` is the
|
||
channel the head unit uses to *command* the navigation app; registering on it to send one
|
||
destination would mean claiming to be a navigation provider, and this is the other end of that
|
||
wire. `VehicleWriteGate` is untouched and stays untouched: it exists for the settings that
|
||
change how the car behaves under the driver — AEB, ELK, ACC/TJA, the drive mode — and a
|
||
destination is no more one of those than the comfort writes it already exempts.
|
||
|
||
**What travels is three fields.** `NavigationHandoff.poi` validates a point the same way the
|
||
`geo:` form does — a swap of latitude and longitude routes to another continent while looking
|
||
like an ordinary number — and keeps the name as a parcel field rather than a URI label, because
|
||
a parenthesis cannot end a parcel field early and stripping it would only mangle what the driver
|
||
reads on the map. `NavigationHandoff.pathway` caps the waypoints. The list is written by hand,
|
||
since this module does not carry the vendor's `EVRoutPoiInfo`, and `EvRoutPoiParcelTest` proves
|
||
the framing byte-for-byte against what Android's own `writeTypedList` produces for a bean with
|
||
the same three fields.
|
||
|
||
**Two things a caller has to know.** The transaction is not `oneway` and the service fans out
|
||
synchronously while holding a lock, so it must not be called on the main thread; and
|
||
`MapService` writes both point lists to the head unit's own logcat before fanning out, which is
|
||
the vendor's log rather than this app's and the one place a destination appears in text.
|
||
|
||
- **The guidance census records the size of the last parcel on each transaction code.** The
|
||
2026-09-07 capture is why: with guidance running on the car's own navigation, code 13 decoded
|
||
as a remaining distance of zero and code 12 as a remaining time of zero, while an undecoded
|
||
code 11 carried 98 callbacks in the same window. Nothing in the artifact could tell a genuine
|
||
zero from a payload of a shape this build reads as an int. A parcel of one int is the
|
||
descriptor plus four bytes; a larger one settles it without decoding a field, and a byte count
|
||
is not a place, which is why it can travel on the stick.
|
||
|
||
### Changed
|
||
|
||
- `EnergySnapshot` carries `vehicleConsumedKwh` and `vehicleRegeneratedKwh`, read through
|
||
`EnergyTelemetryReader` from the charging service's own counters. Both are defaulted: every
|
||
existing caller builds that snapshot by name, and a counter this car may not keep should not
|
||
become a parameter each of them has to pass as null.
|
||
|
||
- **`PlanDrift` now measures the charge gauge at a tenth of a percent, not a whole one.** The
|
||
2026-09-05 drive on SWI68 recorded 457 samples stepping between 53,9 % and 52,9 % in tenths and
|
||
refreshing every 25 s or so, which is ten times finer than the figure the arithmetic started
|
||
with. Every band it derives narrows by the same ten, and the odometer's whole kilometre — not
|
||
the gauge — is now what sets `MIN_DISTANCE_KM`. An arrival two points above the reserve no
|
||
longer reads as short on quantisation alone.
|
||
- **A charge en route is separated from regeneration.** The guard used to reuse the gauge's own
|
||
step, so any rise above the departure figure looked like a plug-in. It now has its own
|
||
`MAX_REGEN_RECOVERY_PERCENT`: a col's worth of regen is not a charging session, and the
|
||
shortest plausible plug-in gives back far more than a descent can.
|
||
|
||
### Added
|
||
|
||
- **`PlanDrift` — whether the plan the driver chose is still the plan they are driving.** Charge
|
||
and the odometer are already read once a second and the plan was written down when it was
|
||
picked, so noticing that a trip has stopped matching it is a division: no request, no quota, no
|
||
network. The alarm is deliberately hard to trip. It fires only when the rate actually being
|
||
spent has left the band the plan admitted to *and* the arrival no longer clears the reserve,
|
||
because a driver going faster than the row they chose and still arriving at 30 % does not need
|
||
telling, and a companion that speaks then is one nobody reads when it matters. Both instruments
|
||
quantise — a gauge that steps a whole percent, an odometer that answers whole kilometres — so
|
||
the measurement carries its own band, which is also why it refuses under 15 km of road.
|
||
- `ChargeStopPlan.effectiveRate` is public. It was the private fold of the climb into the rate;
|
||
`PlanDrift` has to compare a drive against the rate the plan was *actually* made with, and a
|
||
second copy of that arithmetic would have drifted away from the one that planned.
|
||
|
||
- **`NavigationHandoff` — the first write this library builds towards the car.** Everything else
|
||
here reads. This composes a `geo:` URI so a route the driver has chosen can be handed to the
|
||
car's own navigation instead of retyped, and it is separated from the sending because the
|
||
format is the part that can be wrong and the part a JVM test can settle without a head unit.
|
||
It refuses coordinates that are not a place on Earth — latitude and longitude are two doubles
|
||
of the same type and a swap routes to the wrong continent while looking like an ordinary
|
||
number — and it formats in `Locale.ROOT`, because a head unit set to French writes 43.5 as
|
||
`43,5` and a comma is a field separator in this URI. A label from a geocoder or a charger
|
||
dataset is reduced to letters, digits, spaces and hyphens: it is text this project did not
|
||
write, and a parenthesis in it would end the label early. `geo:` carries one point and no
|
||
waypoints, which is a limit the caller has to show the driver rather than paper over.
|
||
|
||
### Fixed
|
||
|
||
- **A momentarily unreadable battery power no longer blanks a settled range estimate.**
|
||
`AdaptiveRangeEstimator` gated on the live power reading, so one sample the firmware failed to
|
||
answer erased a figure computed from stored trips and the pack's charge — neither of which had
|
||
moved. The question the gate asks is "can this firmware measure power at all", and that answer
|
||
does not change between two polls, so it is latched: a firmware that has never published power
|
||
still refuses to borrow another firmware's history, and one that published it a second ago is
|
||
not treated as having lost the ability since.
|
||
|
||
- **An estimate no longer claims a band of exactly zero.** Eight of one driver's trips that
|
||
happened to agree produced "266,7 ± 0,0 km", which reads as a measurement — the one thing
|
||
provenance exists to stop an estimate doing. The spread of a handful of trips is not the error
|
||
of extrapolating to a whole pack: terrain, load, weather and speed all sit outside the fit. The
|
||
band now has a floor of ten percent of the estimate.
|
||
|
||
- **A corrupt trip history no longer multiplies itself.** `EnergyTripHistoryStore.quarantine`
|
||
copied the unreadable file aside and left the original in place, so the next read failed to
|
||
parse it again and wrote another copy — one file per read, unbounded, in storage the driver
|
||
cannot see. The original now goes, by rename or by copy-then-delete, and a copy that itself
|
||
fails is removed rather than left behind.
|
||
|
||
- **Ending a drive no longer re-encodes the whole history once per evicted track.** Bounding the
|
||
file measured every remaining trip again after each step, over as many as two hundred trips of
|
||
four thousand samples, on the path that closes a trip. The envelope's size is arithmetic —
|
||
empty envelope, plus each trip, plus one comma between each pair — so eviction now subtracts as
|
||
it goes and each trip is serialised once.
|
||
|
||
- **Decimating a track no longer throws away the newest sample.** `TripSampleTrack` halves itself
|
||
when the sample just added overflows it, and on an even-sized track that sample sits at an odd
|
||
index: taking every second one from the start discarded the most recent moment of the trip at
|
||
every doubling. It is now kept explicitly.
|
||
|
||
### Added
|
||
|
||
- **`SaicNav.probeTransaction` — what a navigation transaction replies, recorded rather than
|
||
interpreted.** CP-040 saw transactions 38 and 39 declared on `IMapService` and never called
|
||
them; CP-051 wants to know whether they carry the destination the driver has already entered
|
||
in the car's own navigation, because that destination costs no keyboard and no geocoding
|
||
request. The probe returns the reply's bytes and its length, and refuses to read them as a
|
||
typed value: a latitude read as a longitude routes to the wrong continent while looking like
|
||
an ordinary number. Read-only, capped at 128 bytes, and it answers "unbound" or "no answer"
|
||
as plainly as it answers with a payload. Called only by the unstable channel's validation
|
||
probe (CP-055).
|
||
|
||
- **A consumption model for a car that publishes no battery power — which is this car.**
|
||
`CarPropertyEvidence` has recorded since 2026-09-04 that SWI68 never publishes battery power:
|
||
three diagnostic bundles, 51 samples, zero readings. The consequence was that the kWh model
|
||
could never train on it, and three finished screens stayed blank. `SocConsumptionModel` fits
|
||
the same shape — rolling plus aero times speed squared plus a thermal term — in **percent of
|
||
charge per 100 km**, from the state of charge the car does publish and the distance the speed
|
||
integral already gives. No pack capacity enters it, which matters because the capacity here is
|
||
a specification sheet rather than a measurement: fitting in percent removes an unmeasured
|
||
multiplier from every number instead of carrying it. Training points are whole segments of
|
||
road, accumulated until the charge has moved by two points, because a state of charge read
|
||
sample by sample is quantisation and a fit built on it would be fitting the quantiser. A hole
|
||
in the track ends a segment rather than being driven through, a distance recorded under a
|
||
conversion no longer believed contributes nothing, and a history that has only seen one
|
||
temperature fits two coefficients instead of inventing a third.
|
||
|
||
- **The col between here and there costs charge, and the forecast knows it.** CP-031 refused a
|
||
grade source on good grounds — no vehicle signal, no proven barometric reference, and
|
||
power-derived grade is circular — but every candidate it weighed described the road *behind*
|
||
the car. A route's elevation profile describes the road ahead, which is the only kind a
|
||
forecast can use. `RouteGrade` turns cumulative climb and descent into points of charge by
|
||
physics rather than by a fit: potential energy for a stated mass, a stated drivetrain
|
||
efficiency going up, a deliberately pessimistic regeneration efficiency coming down, each one
|
||
a declared constant inside a 40 % band and none of them tuned against data that does not
|
||
exist yet. A descent gives charge back and always less than the same climb spent, so a round
|
||
trip over a col is never a free lunch. `ChargeStopPlan` takes the term as an optional
|
||
argument and folds it into the rate, which means the band widens with the profile and a
|
||
missing profile is `null` rather than a zero quietly claiming the road is flat — a plan
|
||
without one is byte-for-byte the plan it was before.
|
||
|
||
- **An arrival state of charge, built on what this car actually publishes.** The obvious route
|
||
runs through the energy model — kWh/100 km, a distance, a pack capacity — and it is a dead end
|
||
here, because the model trains on energy integrated from a battery power that never publishes.
|
||
State of charge per kilometre needs no energy unit at all, and both its inputs read.
|
||
`SocRateEstimator` takes the rate from the vehicle's own range on the first reading and from
|
||
recorded trips once there are enough, preferring what the driver has actually driven; trips
|
||
whose distance the current speed conversion did not produce are never averaged in.
|
||
`ArrivalSocForecast` never returns a bare number: the band widens with distance, a band wider
|
||
than 15 points is refused rather than shown, and a negative arrival is left negative because
|
||
"the destination is beyond this charge" is the most useful thing it can say.
|
||
|
||
- **A declared battery capacity bridges kWh and state of charge.** `INFO_EV_BATTERY_CAPACITY` is
|
||
declared and never published on the MG4, and deriving the pack size from an energy total needs
|
||
battery power, which is unvalidated — so nothing could turn a model's kWh/100 km into a state
|
||
of charge. `BatteryCapacityConfig` takes the figure from whoever knows it. It asks for the
|
||
**usable** capacity, not the nameplate one, because state of charge spans the usable window
|
||
and the buffers at each end sit outside it: entering 64 kWh where 61.7 is usable overstates a
|
||
full pack by 2.2 kWh, in the optimistic direction. Everything derived from it is `ESTIMATED`
|
||
with a 5 % band covering a dealer's state-of-health reading and a specification figure that
|
||
was never a measurement of this pack. Out-of-range input is refused, never clamped.
|
||
|
||
- **A recorded distance says which speed conversion produced it.** `VehicleSpeedEvidence` is the
|
||
counterpart of `BatteryPowerEvidence` on the distance side, and it exists because the
|
||
conversion has already been wrong once: every trip stored before the km/h fix carries a
|
||
distance 3.6 times too long, and nothing in the number said so — 8.46 km for a 2.4 km route
|
||
reads exactly like 8.46 km for an 8.46 km route. `EnergyTripSummary.modellableDistanceKm`
|
||
answers only for a distance produced by the conversion believed correct today, so a model
|
||
refuses history it cannot vouch for instead of quietly training on it. The reported distance
|
||
is unchanged: a driver's own record does not silently rewrite itself.
|
||
|
||
- **The odometer reads again.** `PERF_ODOMETER` is declared and never published on the MG4, so
|
||
total mileage showed as unavailable while the head unit knew it. The navigation adapter
|
||
answers it, and `getOdometerKm` now falls back to it — the same reading that gave the 3.6x
|
||
speed error its independent reference.
|
||
|
||
- **Guidance can be read, not only waited for.** The five listener callbacks are change
|
||
notifications, so a car standing still with a guidance already running publishes nothing —
|
||
which made a parked capture unable to tell "the adapter says nothing" from "nothing happened
|
||
to say". `IGeneralService` answers `getGuideStatus`, `getRemainingTimes`, `getRemainingDistance`
|
||
and `getRoadName` synchronously on the binder already bound, so `SaicNavGuidance.readNow`
|
||
reports the current trip on demand. `SaicNav`'s note that the head unit answers no synchronous
|
||
question about a trip was read off `IMapService`; the general service does answer. A distance
|
||
or time of zero is treated as unreadable, as the service returns zero before any guidance has
|
||
run.
|
||
|
||
### Fixed
|
||
|
||
- **`PERF_VEHICLE_SPEED` already reports km/h on SWI68, and was being converted a second time.**
|
||
AAOS specifies the property in metres per second, so every read was multiplied by 3.6 — and
|
||
every distance integrated from it was scaled by the same factor. A 2.4 km drive was recorded
|
||
as 8.46 km and, on a second run, 7.69 km: 124 and 129 km/h average over four minutes of town
|
||
driving. Divided by 3.6 the same trips read 2.35 km at 34 km/h, which is the drive that
|
||
happened. `VehicleSpeedScale` makes the conversion evidence-gated per generation like every
|
||
other firmware-dependent read here. SWI68 is listed on the 2026-09-04 bundle from
|
||
SWI68-29958-1300R67; every other generation keeps the specified conversion, unproven but wrong
|
||
in the conservative direction, since a speed read too high makes `VehicleWriteGate` refuse
|
||
more often rather than less.
|
||
|
||
### Fixed
|
||
|
||
- **Repeated vehicle states no longer flood the diagnostic log.** The ignition condition
|
||
arrives about ten times a second and the gear is read once a second; both were logged every
|
||
time, so a 400-line retained log held under a minute of history and anything older had already
|
||
been evicted from the bundle a driver sends back. `StateChangeLog` lets a state through only
|
||
when it changed, keeping the transitions a diagnostic actually reads and dropping the repeats
|
||
between them. Only logging is gated — every callback is still dispatched.
|
||
|
||
## [1.14.0] - 2026-09-04
|
||
|
||
### Changed
|
||
|
||
- **`WEATHER_NOW` is an `ENUM`, not free text.** The condition compared the weather service's
|
||
own phrase, in the head unit's language, against a string the driver had to type — a value
|
||
nobody can guess and that changes with the display language. It now carries one of the seven
|
||
states in the new `WeatherConditions`, which classifies the phrase by keyword fragments in
|
||
the six languages the apps ship. `SnapshotKeys.KEY_WEATHER_TEXT` became
|
||
`KEY_WEATHER_CONDITION` and carries that state; a phrase nothing recognises writes no key at
|
||
all, so the condition is unavailable rather than reported as fine weather.
|
||
- **`ConditionGroup.AUDIO`**, the counterpart of `ActionGroup.AUDIO`. `MEDIA_PLAYING`,
|
||
`RADIO_PLAYING` and `MEDIA_VOLUME` moved into it from `CONTEXT` and `COMFORT`.
|
||
|
||
## [1.13.0] - 2026-09-03
|
||
|
||
### Changed
|
||
|
||
- **Radio band, frequency and playback became one catalogue entry.** `SELECT_RADIO_BAND` is
|
||
gone; `TUNE_RADIO` now carries the band in `number`, the frequency in `text` and playback in
|
||
`flag`, behind a new `ValueKind.RADIO`. The split was the wrong seam: a band could not carry a
|
||
station, a station could not reach DAB, and "put the radio on FM 103.5 and play it" — one
|
||
instruction — took two entries that each had half of it. `number = 0` is what every rule saved
|
||
before the merge carries and still means "read the band off the frequency", so nothing that
|
||
worked stopped working. `RadioFrequency.parse` takes the picked band and refuses text that
|
||
disagrees with it rather than resolving the disagreement, for the same reason it already
|
||
refused `AM 103.5`. `SaicRadio.selectBand` gained `andPlay`, so a band switch can leave the
|
||
car silent the way a tune already could.
|
||
|
||
### Added
|
||
|
||
- **A generated catalogue document.** `docs/catalogue.md` lists every `ConditionType` and every
|
||
`ActionType` with its English label, what it asks for, whether it is refused while moving and
|
||
the generations it is declared on. Rendered from the enums by `CatalogueDocTest`, which
|
||
rewrites it on every test run exactly as `FirmwareSupportTest` does for the matrix — the
|
||
hand-written list it replaces had gone a year without mentioning twenty entries and still
|
||
promised an "AM or FM only" tune action.
|
||
|
||
- **Read-only route guidance listener for the head unit's own navigation.** The map adapter
|
||
answers no synchronous question about a trip, but its general service fans remaining distance,
|
||
remaining time, guidance status and road name out to registered listeners. `SaicNavGuidance`
|
||
registers for that fan-out and folds it into a nullable `NavGuidance`. The callbacks are not
|
||
`oneway` and the adapter holds its callback-list lock while broadcasting, so the listener does
|
||
one parcel read and one volatile write and nothing else — a slow listener would stall the
|
||
instrument cluster. Registration is additive and displaces no other consumer. Distances carry
|
||
no proven unit and status codes no proven meaning, so nothing is converted and nothing is
|
||
displayable: this is CP-040 evidence, not a value. Costs no Android permission and no network.
|
||
|
||
- **Post-trip energy attribution preserves uncertainty and gaps.** Measured trip consumption is
|
||
reconciled against modelled traction, regeneration, climate-conditioned residual groups and an
|
||
explicit unmodelled discrepancy. Every attributed value requires a band; values inside model
|
||
noise are marked indistinguishable from zero, and negative residuals remain visible as model
|
||
error. No per-consumer vehicle counter is claimed.
|
||
|
||
- **Driver-specific energy modelling is bounded and evidence-gated.** A compact least-squares
|
||
fit learns rolling, aerodynamic speed-squared and outside-temperature terms from retained trip
|
||
samples only when their exact firmware/conversion evidence matches. It uses constant working
|
||
memory, caps training at 50,000 samples, rejects sparse/noisy fits and all extrapolation, and
|
||
returns only provenance-labelled estimates with residual-derived uncertainty. One atomic
|
||
16 KiB model file stores coefficients and envelope; raw tracks are never duplicated.
|
||
|
||
## [1.12.0] - 2026-08-31
|
||
|
||
### Added
|
||
|
||
- **Vehicle-validated telemetry is catalogued per signal and firmware generation.** Battery
|
||
power starts unvalidated everywhere; CP-003 must add each generation only after its unit,
|
||
scale, sign and cadence have on-vehicle evidence. Consumers can therefore distinguish an
|
||
unproven signal from a property that is genuinely absent. Trip energy integrated from a
|
||
validated signal carries the exact firmware and conversion version; legacy or mismatched
|
||
totals can therefore be excluded from later models without growing the stored sample track.
|
||
|
||
## [1.11.0] - 2026-08-29
|
||
|
||
### Added
|
||
|
||
- **A rule can put the tuner on a band, DAB included.** `SELECT_RADIO_BAND` is the answer to
|
||
the one gap the radio work left: "tune the radio" takes a frequency a driver typed, and a DAB
|
||
service is addressed by an ensemble and service id, so there was nothing to type and no way
|
||
to ask for DAB. The action guesses no transaction code — the vendor service exposes no
|
||
band-only call that anyone here has observed, and a wrong code on a vehicle binder does
|
||
something unknown to a car. It composes calls that are already proven instead: read the
|
||
current band from `RadioBean`, then `tune` into the requested one, since `tune` carries the
|
||
band as its first argument. It lands on the station this car was last heard on for that band,
|
||
and when it has never been there it steps to the first station the tuner lists rather than
|
||
leaving the driver on an empty frequency. An unreadable current band sends nothing at all.
|
||
|
||
### Changed
|
||
|
||
- **The window actions ask to open or close, instead of carrying a raw command.** They used to
|
||
take a number in 0..7 — the vendor service's own command range, which nothing in the app or
|
||
the car explains to the person writing the rule. An on-vehicle capture showed what that
|
||
costs: a rule asking for `7` ran five times, was reported as applied five times, and left the
|
||
driver's window fully open throughout, because the service accepts a command it does not
|
||
implement and drops it. Closed and open are the only two states the glass can be asked for,
|
||
so those are the two the editor now offers.
|
||
Which raw command reaches each is a property of the *car*, established by the window probe and
|
||
stored per car, so it stays out of the catalogue and out of saved rules — a rule exported from
|
||
one car must not carry that car's command numbers.
|
||
|
||
- **"Windows" is now "All windows".** It commands four windows at once, and the bare plural read
|
||
as the family of window actions rather than the one that moves them together.
|
||
|
||
## [1.10.0] - 2026-08-29
|
||
|
||
### Fixed
|
||
|
||
- **A property the firmware declares and never publishes no longer reads as a measurement.**
|
||
`CarPropertyManager.getFloatProperty` answers such a property with a plain zero, and nothing
|
||
in the number says the vehicle never put a value behind it — an MG4 on SWI68 reporting 31 °C
|
||
outside showed a 0 °C traction battery and a 0 kW pack current on the dashboard next to it.
|
||
Reads now go through `getProperty`, whose `CarPropertyValue` carries the VHAL's status for
|
||
the property, and only `STATUS_AVAILABLE` is treated as a reading; anything else stays null
|
||
and reaches the screen as "unknown", which is what it is. A measured zero is still a zero.
|
||
Runtimes that do not expose `getProperty` fall back to the typed getters unchanged, so no
|
||
generation loses a property it was already answering.
|
||
|
||
### Added
|
||
|
||
- **Automatic trip boundaries are now one fail-closed telemetry state machine.**
|
||
`TripDetector` requires five seconds above 5 km/h to start and two minutes below 1 km/h
|
||
to end, using park or charge-port confirmation when those signals exist. Missing speed
|
||
cancels partial evidence and can never start or stop a recording; all timers and threshold
|
||
bounce behaviour are covered without Android or a vehicle.
|
||
|
||
- **Adaptive range is now an explicit estimate with evidence-sized uncertainty.**
|
||
`AdaptiveRangeEstimator` fits up to eight recent trip-consumption averages, requires three
|
||
usable observations, and converts their sample spread into a kilometre uncertainty band.
|
||
It prefers measured usable energy, falls back to SOC times capacity, and refuses to estimate
|
||
when the current firmware does not publish battery power; the vehicle's own range is untouched.
|
||
|
||
- **Consumption is now one shared, nullable derivation instead of UI arithmetic.**
|
||
`ConsumptionCalculator` exposes raw and five-second exponentially smoothed instantaneous
|
||
kWh/100 km plus a two-minute energy/distance average. It masks missing inputs and speeds
|
||
below 5 km/h, preserves negative regeneration, rejects unobserved gaps over five seconds,
|
||
and bounds its working set independently of trip length. Every output is provenance-labelled;
|
||
the existing trip accumulator continues to integrate raw power and speed unchanged.
|
||
|
||
- **Trip history can be pruned without bypassing its atomic persistence boundary.**
|
||
`EnergyTripHistoryStore` can remove one start-time-identified trip or replace the history with
|
||
a valid empty v2 envelope; both operations preserve the same unique-temp-file rewrite used by
|
||
appends, so a UI never deletes the only valid file before its replacement exists. The default
|
||
summary bound is 200 trips; the existing byte ceiling still drops old sample tracks first.
|
||
|
||
- **A public post-AAOS 9 capacity signal can be tested without becoming product data.**
|
||
`EV_CURRENT_BATTERY_CAPACITY` describes real-time usable capacity rather than the pack's nominal
|
||
new capacity, but a public id does not prove that an MG4 firmware publishes it. The new
|
||
status-aware, firmware-branched reader is therefore internal to explicit evidence capture,
|
||
stays null when absent or invalid, and is carried beside — never inside — `EnergySnapshot`.
|
||
[The candidate review](docs/energy-signal-candidates.md) records exact AOSP provenance and the
|
||
negative search results for HVAC, battery-heater, accessory and cumulative energy counters.
|
||
|
||
- **Three radio behaviours the library could already perform are now things a rule can ask for.**
|
||
`srcPauseRadio`, the tuner's own play state and `startRadioActivity` were implemented and
|
||
unreachable: the catalogue offered play, tune and station stepping, so "silence the radio"
|
||
had to be approximated with `MEDIA_CONTROL`, which addresses whichever source owns the audio
|
||
and therefore stops Bluetooth when Bluetooth is the one playing. `PAUSE_RADIO`,
|
||
`RADIO_PLAY_PAUSE` and `OPEN_RADIO_SCREEN` name the tuner instead. The toggle reads
|
||
`RadioBean.state` rather than `AudioManager.isMusicActive`, which is **false while the radio
|
||
plays** — a toggle driven by it would answer a playing radio with another "play", forever —
|
||
and when even that cannot be read it sends nothing and says so: a wheel button can be pressed
|
||
twice, an unattended rule cannot, and a guessed direction leaves the car silent or playing
|
||
with nothing in the history admitting the direction was invented. Opening the radio screen is
|
||
the one of the three that is not audio-only, so it takes the standstill gate, unreadable
|
||
speed included; skipping a station does not, and did not gain it. Band selection and direct
|
||
DAB service selection were considered and rejected — see
|
||
[docs/radio-action-gap.md](docs/radio-action-gap.md), which records every candidate including
|
||
the refused ones. DAB remains reachable through station stepping, which is what the head
|
||
unit's own launcher calls.
|
||
|
||
- **A trip now keeps its shape, not only its totals.** A summary cannot be un-summarised, and
|
||
every model this project will grow — what a slower motorway speed would have saved, what the
|
||
climate system took, the state of charge on arrival — is fitted from the shape of past drives.
|
||
`TripSampleTrack` records one sample per five seconds of speed, power, state of charge,
|
||
temperatures and climate state, all nullable, so a car that publishes no pack temperature
|
||
stores no pack temperature rather than a column of zeros a fit would believe. A drive that
|
||
outlasts the track's length is decimated rather than truncated: every second sample goes and
|
||
the interval doubles, so the motorway stretch at the end survives at a coarser resolution
|
||
instead of being cut off.
|
||
|
||
### Changed
|
||
|
||
- **The trip history file states its own version.** The first shape was a bare JSON array with
|
||
no room to say what it was; it is read as before and rewritten into the v2 envelope on the
|
||
next append, losing nothing. A file this build cannot read — truncated, or written by a
|
||
build that knows something this one does not — is quarantined under a new name rather than
|
||
deleted, because it is the only copy of whatever it holds. Three bounds now apply in the
|
||
order that keeps the most meaning per byte: at most fifty trips, then sample tracks dropped
|
||
oldest-first to stay under the size ceiling, and only then whole trips — a trip without its
|
||
track is still a trip, a missing trip is not.
|
||
- `EnergyTripSession.stop` returns a `RecordedTrip` (summary plus track) rather than a bare
|
||
summary, and `EnergyTripHistoryStore.read()` returns stored trips; `readSummaries()` is the
|
||
old view.
|
||
|
||
- **`TelemetryEvidenceRecorder` — what a signal actually did on the car, instead of what the
|
||
code assumes it does.** Every energy figure past a state of charge divides by a number whose
|
||
unit, sign and update rate are currently a hypothesis: the battery power reader divides the raw
|
||
property by -1,000,000 because that is what the value looked like, not because a vehicle
|
||
confirmed it. The recorder consumes the same snapshots the dashboard renders and keeps, per
|
||
signal, how often it answered and how often it did not, its range and mean, its positive and
|
||
negative split, and the interval between the values it actually published — measured from value
|
||
*changes*, so a 1 Hz sampler over a signal republished every five seconds reports five seconds
|
||
rather than one. A signal the car never answered keeps no statistics at all: no minimum of
|
||
zero, no mean of zero, nothing. Captures serialise to a versioned schema for the next build and
|
||
render as a Markdown table for the person writing the conclusion.
|
||
|
||
- **`Provenanced` — a value that carries what kind of claim it is.** The MG4 publishes no
|
||
per-consumer energy counters, so anything past traction totals — the climate system's share,
|
||
what a slower motorway speed would have saved, the state of charge on arrival — can only be
|
||
inferred, and an inference rendered in the same typeface as a vehicle reading is a
|
||
measurement as far as the driver is concerned. `Provenance` names the four kinds a figure can
|
||
be (measured, derived, estimated, unavailable) and `Provenanced<T>` enforces them at
|
||
construction: an estimate cannot exist without its uncertainty band, an unavailable value
|
||
cannot carry a number, and a measurement cannot carry a band the vehicle never published.
|
||
`Provenanced.derive` propagates gaps rather than filling them, so consumption over an
|
||
unreadable speed is unknown — with `UnavailableReason` saying whether the firmware lacks the
|
||
signal, has not been validated for it, or simply is not publishing it right now.
|
||
|
||
- **`EVHardware.probeTelemetryProperties()` — what each energy property actually answers on the
|
||
car in front of you.** An unsupported property, one declared and never published, and one
|
||
this runtime cannot reach all look identical from the driver's seat, where every unusable
|
||
signal is one em dash. The probe reports status, raw value and publication timestamp per
|
||
property, unfiltered and outside the supported-generation gate, so a generation's telemetry
|
||
surface can be recorded from the vehicle instead of inferred from a blank field.
|
||
|
||
### Changed
|
||
|
||
- **A read that fails the same way every second is worth one log line, not one per sample.**
|
||
One unreadable speed property wrote two identical lines a second, filled the 400-entry buffer
|
||
and pushed everything else out of it, so the diagnostics screen was least useful exactly when
|
||
something was wrong. A repeated failure is logged again only when its reason changes.
|
||
Reflected failures also name their cause: `InvocationTargetException` carries no message of
|
||
its own, which is why these lines used to read `exc: null`.
|
||
|
||
## [1.9.0] - 2026-08-28
|
||
|
||
### Fixed
|
||
|
||
- **The glass probe refused to run on a car whose ignition was on.** It read
|
||
`getCurrentIgnitionState()`, which answers on the AAOS `IgnitionState` scale, against
|
||
`CarIgnitionItem.RUN` — and the two scales do not line up: RUN is 2 for Katman5, where 2 is
|
||
OFF. Contact mis read as 4 and was refused; contact coupé read as 2 and was let through.
|
||
`setEsc` compared the same two scales and now refuses only a *known* non-RUN ignition, as its
|
||
own documentation always said it did.
|
||
- **Window actions moved nothing.** They were written through the vendor hub's
|
||
`setDriveWindow`, a setter no application on the head unit calls, whose accepted values
|
||
nothing explains and whose dropped writes are indistinguishable from obeyed ones.
|
||
|
||
### Added
|
||
|
||
- **`VsmGlass` — the windows through the interface the car's own window switch uses.**
|
||
`setVehicleWindowStatus(area, command)` and `getVehicleWindowValue(area)`, on the VSM already
|
||
bound for ADAS. The commands are established rather than guessed: `STOP`, `UP` and `AUTO_UP`.
|
||
`UP` is a switch held down and not an order — the glass travels only while the command keeps
|
||
arriving, which is why a single write looked exactly like a command that does nothing, and
|
||
why `hold()` re-sends it and then releases it. Reads answer 0–255, not the hub's 0–100, and a
|
||
motor without a position sensor answers off that scale and is reported as no reading rather
|
||
than as a closed window. VSM-based firmwares only; the hub stays the fallback.
|
||
|
||
### Changed
|
||
|
||
- **`SaicVehicleControl` prefers the VSM for every glass read and write**, falling back to the
|
||
hub only where the VSM does not carry the methods. `GlassProbe` holds each command down for
|
||
`HOLD_MS` instead of writing it once, which is the only way a momentary command shows up in a
|
||
position read at all.
|
||
|
||
## [1.8.0] - 2026-08-27
|
||
|
||
### Added
|
||
|
||
- **`SaicMediaPlayer` — next track, previous track and play/pause sent to the source that is
|
||
actually playing.** The car publishes a single Android media session
|
||
(`com.android.bluetooth`), so a media key either reached nothing or reached Bluetooth, woke a
|
||
phone that was not playing and changed the audio source while the radio was on. The new
|
||
object asks the vendor service which source owns the audio and commands that source's own
|
||
player — radio, Bluetooth, USB, online, CarPlay/Android Auto — with **no cascade between
|
||
sources**: a player that is not playing is never addressed, because it would answer "yes" and
|
||
start. The A9 generations have no such service and drive the framework's media sessions
|
||
instead, then allgo's `IRemoteUIService` for projection, then a media key only while
|
||
something is audible.
|
||
- **`SaicRadio.nextStation()` / `previousStation()` / `isPlaying()`.** Stepping through the
|
||
tuner's stations is the one radio call that reaches **DAB**: a DAB service is addressed by
|
||
ensemble and service id, so `tune` has no frequency to take. `isPlaying` unwinds the vendor
|
||
`RadioBean` because `AudioManager.isMusicActive` is false while the radio plays — its stream
|
||
is not the music one — which made play/pause answer a playing radio with another "play".
|
||
- **`ActionType.RADIO_NEXT_STATION` / `RADIO_PREV_STATION`** (AUDIO, SWI68/SWI165).
|
||
- **`GlassProbe` — settles on a car what the catalogue could not settle in the project.**
|
||
Which of the eight window commands opens a window is written down nowhere and the vendor
|
||
service reports success whatever it is given, so the glass actions ship refused
|
||
(`writeProven = false`). The probe sends each command with the position read either side of
|
||
it, in **two passes** — from a closed window a closing command is indistinguishable from an
|
||
inert one, so the second pass retries exactly those once the glass is open — and stops as
|
||
soon as both directions are known. Standstill is re-checked before every command, not once
|
||
at the start, and the glass is put back where it was found or the caller is told it was not.
|
||
- **`GlassEvidence`** records what the probe watched happen and unlocks the five window
|
||
actions **on that car only**. Evidence is scoped to the firmware generation it was observed
|
||
on: a command proven on one generation proves nothing about another.
|
||
- **ESC and the drowsiness warning (UDW), on every generation.** Three different routes —
|
||
VPM properties on SWI133, named methods on SWI68/SWI165, and the carapi client on A9, where
|
||
ESC is spelled "Eps". ESC on SWI68/SWI165 needed a manager the library did not bind at all:
|
||
`get/setEspSwitch` live on **VehicleControlManager**, not the settings one, so looking for
|
||
them on `sVsm` failed without a sound and left ESC inert.
|
||
Two guards come with the write, both earned on a car: it is a **toggle** driven by a read, so
|
||
it runs only on an ignition known to be in RUN — getting in while the cluster is dark, the
|
||
property lies, and aiming at ON from a false OFF disables an ESC that was on, in silence —
|
||
and only on three agreeing readings, with the result checked afterwards rather than assumed.
|
||
- **`ActionType.SET_ESC`, `SET_DROWSINESS`, `SET_DROWSINESS_SENSITIVITY`** (ADAS, gated).
|
||
The drowsiness switch is UDW and not the camera-based DMS one: both exist, their labels read
|
||
alike, and writing the camera one changed nothing visible.
|
||
- **`A9Climate` — climate on SWI69, SWI131 and SWI132.** The vendor hub `SaicClimate` talks
|
||
to does not exist on those generations, so eight climate actions were marked SWI68/SWI165
|
||
only on cars whose own HVAC screen works fine. They now go through `carapi`'s
|
||
`CarHvacClient` (`queryClient(0x7)`), the same door `queryClient(0x8)` already opens for the
|
||
vehicle-settings client. Several A9 settings are argument-less `switch…()` calls that step to
|
||
the next state, so aiming at one is read-step-read, bounded — which makes those writes
|
||
blocking and unfit for the main thread. ECON and the passenger's own target stay off A9: the
|
||
client exposes neither, and refusing is more honest than writing to the driver's side and
|
||
calling it the passenger's.
|
||
- **`ActionType.ADJUST_MEDIA_VOLUME` and `EVHardware.adjustMediaVolume(delta)`** — a step
|
||
rather than a target, for a wheel button or a rule that must not throw away the level the
|
||
driver chose. Falls back to `AudioManager` when the vendor level is unreadable, and reports
|
||
either end of the range as done rather than refused.
|
||
- **`PhysicalButtonEventDecoder` reads a double press.** Two releases of the same button
|
||
within `DOUBLE_TAP_MS` make one `Press.DOUBLE`; a long press breaks the pairing so the
|
||
release that follows it cannot become half of a double nobody made, and a third press starts
|
||
over rather than producing a second overlapping double.
|
||
- **`DataUsage`** — what the head unit's connection has carried, today and this month, from
|
||
Android's own counters. The modem presents as **Ethernet**, and the public
|
||
`querySummaryForDevice(int, …)` only builds MOBILE and WIFI templates: it answers zero here,
|
||
which reads exactly like a car that used no data. The hidden `NetworkTemplate` overload
|
||
answers, with `TrafficStats` since boot as the fallback, and an unreadable counter is null
|
||
rather than zero.
|
||
- **`ConditionType.DATA_USED_TODAY` / `DATA_USED_MONTH`** (context, in MB).
|
||
- **`ActionType.effectProven`** — `writeProven` answers "has anyone ever seen this work",
|
||
`effectProven` answers "does it work here". Everything user-facing asks the second.
|
||
|
||
### Changed
|
||
|
||
- **An action whose write was never shown to do anything is no longer offered anywhere.**
|
||
`ActionType.writeProven` separates two questions a single ✅ used to answer at once: whether
|
||
the firmware carries the property (`@SupportedOn`) and whether writing it changes anything.
|
||
The five glass actions are the case that forced the distinction — the service accepts a
|
||
command in `0..7`, no head-unit application sends one, and which command raises a window is
|
||
written down nowhere, so the write is accepted, dropped, and reported as applied. They ship
|
||
`writeProven = false` until one command is observed to move a window; the generated firmware
|
||
matrix marks them ⚠️ and says what the mark means.
|
||
|
||
## [1.7.0] - 2026-08-27
|
||
|
||
### Added
|
||
|
||
- A shared, read-only energy telemetry API: one nullable `EnergySnapshot` combines SOC,
|
||
range, speed, battery power and temperatures, pack energy/capacity, odometer, charge state
|
||
tyre pressures and climate state with explicit firmware dispatch and unit conversion.
|
||
- Reusable trip and charging-session integration plus bounded atomic trip-history storage.
|
||
Both skip unreadable samples and long gaps rather than inventing energy.
|
||
|
||
### Changed
|
||
|
||
- Reflected `CarPropertyManager` getters are cached when the service connects instead of
|
||
resolving a `Method` for every sample. The cache is keyed on "resolved", not on "found",
|
||
so a getter this `CarPropertyManager` does not expose is no longer looked up again on
|
||
every sample.
|
||
- A trip's reported duration is the time actually covered by usable samples, not wall clock.
|
||
A sampler suspended for ten minutes no longer adds ten minutes to a trip whose distance
|
||
and energy did not move — the consumption average now divides comparable numbers.
|
||
|
||
### Fixed
|
||
|
||
- The Car-service binding now retries without a bound. The previous fixed retry ladder gave
|
||
up after one minute, so a process started before the SAIC Car service was ready never saw
|
||
a vehicle again for its whole lifetime, and a service that died and came back was only
|
||
recovered if the `ServiceConnection` callback fired. Consumers carried their own reconnect
|
||
loops to work around this; the watchdog now lives next to the binding it repairs.
|
||
|
||
## [1.6.0] - 2026-08-26
|
||
|
||
### Fixed
|
||
|
||
- **Glass writes never moved a window.** `IVehicleControlService` validates a window write
|
||
against `max_vehicle_window_set = 7` and drops anything above it without an error — the
|
||
setter returns void, so a dropped write and an applied one are indistinguishable from the
|
||
caller. The library was sending a position in percent, so every write above 7 was
|
||
discarded while the history recorded it as applied. Reading is unchanged and stays a
|
||
percentage (`max_vehicle_window_get = 100`): the two directions are not the same scale.
|
||
`setWindow` / `setAllWindows` now take a **command in 0..7** and refuse anything else
|
||
rather than hand it over to be dropped. Which command opens and which closes is not
|
||
written down on the firmware and no head-unit application sends one, so the mapping is
|
||
still open — a command sent and a position read back is what identifies it.
|
||
- **Current weather always came back unreadable.** The `ICallBack.onResult` parcel carries
|
||
the nullable `Result` argument behind a presence flag, and the decoder started at the
|
||
object's first field instead. Every field then landed in the previous one's place, the
|
||
payload tag no longer matched, and an answer that had arrived was reported as "cannot
|
||
tell". The flag is read now, and a null `Result` is a null reading.
|
||
|
||
### Changed
|
||
|
||
- Glass actions are standstill-gated outright, and `ActionType.gatedWhenOpening` is gone
|
||
with the directional gate it fed. That gate compared the target against the position, and
|
||
the target is no longer a position: with the direction of a command unknown, the safe
|
||
reading of an unknown direction is that it is not one to allow at speed.
|
||
- `SaicVehicleControl.doorLockState()` exposes the raw lock status for the diagnostic. The
|
||
service answers `0..7` and only 1 and 2 are named, so `doorsLocked()` still discards the
|
||
rest — but "unreadable" without the number left no way to tell an unnamed state from a
|
||
read that never answered.
|
||
|
||
### Removed
|
||
|
||
- The daily forecast — `WEATHER_TOMORROW`, `TEMP_MAX_TODAY`, `TEMP_MIN_TOMORROW` and
|
||
`SaicWeather.forecastAt`. `ISaicService` declares seven transactions on this firmware and
|
||
none of them answers a forecast; the `WeatherFuture` parcelable its `Result` can carry is
|
||
never filled by anything in the map service. The transaction 1.5.0 called does not exist,
|
||
which is what the `transact returned false` in the log was. The head unit's own weather
|
||
application fetches the outlook itself with credentials that are not reachable from here
|
||
and broadcasts only current conditions, so there is no second source either. Three
|
||
conditions that could never be readable are worse than none.
|
||
|
||
## [1.5.0] - 2026-08-25
|
||
|
||
### Added
|
||
|
||
- Per-window control. `SaicVehicleControl.Window` names the four windows and
|
||
`windowPercent` / `setWindow` address one at a time, alongside the group read and
|
||
`setAllWindows` — which stay, because closing the glass is one gesture that must not be
|
||
able to leave three shut and one open. Catalogue: `ConditionType.WINDOW_DRIVER`,
|
||
`WINDOW_PASSENGER`, `WINDOW_REAR_LEFT`, `WINDOW_REAR_RIGHT` and the four matching actions.
|
||
- `SaicCharging.rangeKm` and `ConditionType.RANGE_ESTIMATE` — the cluster's own remaining
|
||
range. A rule that diverts to a charger is about distance, which a battery percentage does
|
||
not answer on a cold motorway.
|
||
- `SaicNav`, the head unit's navigation adapter, and `ConditionType.ODOMETER`. Trip distance
|
||
is deliberately absent: remaining distance and time only arrive as callbacks into that
|
||
service from the running navigation app, so reading them is a subscription, not a getter.
|
||
- `SaicWeather` with `ConditionType.WEATHER_NOW`, `WEATHER_TOMORROW`, `TEMP_MAX_TODAY` and
|
||
`TEMP_MIN_TOMORROW` — the weather service the head unit's own map stack queries, asked for
|
||
the car's position. The outlook is **daily**: the service answers one entry per day, so
|
||
"rain tomorrow" is a question it takes and "rain in three hours" is not, and the catalogue
|
||
offers only what the data supports. The only asynchronous service here: the answer
|
||
arrives on a callback binder and is awaited with a bounded wait, so a slow query comes back
|
||
unreadable instead of holding a rule cycle open.
|
||
- `SaicAidl` now marshals `Double` and `IBinder` arguments, for that callback.
|
||
|
||
Neither `ODOMETER` nor `WEATHER_NOW` carries `@SupportedOn`: they come from the head unit's
|
||
own applications rather than from the vehicle SDK, so nothing routes them per generation —
|
||
they answer or they do not, the same as the message action.
|
||
|
||
## [1.4.0] - 2026-08-25
|
||
|
||
### Added
|
||
|
||
- Context entries that read Android rather than the car, so they carry no firmware
|
||
annotation: `ConditionType.MEDIA_PLAYING`, `WIFI_SSID`, `IN_CALL`, `DRIVE_DURATION`,
|
||
`RANDOM_CHANCE`. `IN_CALL` is "a call is routed through the car", not "the phone is
|
||
ringing" — the difference is stated rather than papered over.
|
||
- `ActionType.ENABLE_RULE` / `DISABLE_RULE` with `ValueKind.RULE`: a rule can now switch
|
||
another on or off, which is what turns a catalogue of one-shot rules into chains.
|
||
- `ActionType.MEDIA_CONTROL` (`MediaCommand`, the platform's own media key codes) and
|
||
`SET_BLUETOOTH` / `SET_WIFI`.
|
||
- Climate readings that had a vendor getter and no catalogue entry: `ConditionType.ECON_MODE`,
|
||
`PASSENGER_TEMP`, `FRONT_DEFROST`, `REAR_DEFROST`. The last two also become the `currentKey`
|
||
of the defroster actions, which until now opened on a state nothing could read back.
|
||
- `ActionType.SET_ECON` and `ActionType.SET_PASSENGER_TEMP`. The passenger target is a second
|
||
action rather than a zone argument on `SET_CABIN_TEMP`: a zone would have made every rule
|
||
saved before it carry a value it never chose.
|
||
- Charging schedule readings: `ConditionType.CHARGE_SCHEDULE_ENABLED`, `CHARGE_WINDOW_START`,
|
||
`CHARGE_WINDOW_STOP`, `BATTERY_PREHEAT` — the read side of actions that could already write
|
||
them.
|
||
- `ConditionType.CHARGING_STATUS`, the charging state itself. `CHARGING` stays a boolean and
|
||
keeps its meaning; the state is what can say "plugged in but not charging", which a boolean
|
||
cannot.
|
||
- `ConditionType.FRONT_DOOR_OPEN` (SWI133), reading the door status the volume-drop watcher
|
||
already polls, through the new `EVHardware.frontDoorOpenOrNull()`. Front doors only — the
|
||
rear ones are not claimed rather than guessed at.
|
||
- `ValueKind.TIME`: one time of day, minutes since midnight. The charging window's ends are
|
||
clock times, and a 0–1439 slider is unanswerable at the wheel.
|
||
|
||
### Fixed
|
||
|
||
- `VehicleEnums.CHARGING_ACTIVE_STATES` names the states in which current actually flows into
|
||
the battery. Consumers filling `KEY_CHARGING` derived it from `chargingStatus != 0`, which
|
||
made a finished charge, a stopped one and a charging fault all report as charging.
|
||
|
||
## [1.3.0] - 2026-08-23
|
||
|
||
### Added
|
||
|
||
- `ActionType.SEND_SMS`: send a text message through the paired phone, with `ValueKind.SMS`
|
||
(recipient in `text`, contact label in `displayName`, message in `payload`). No
|
||
`@SupportedOn`: the message leaves over the Bluetooth Message Access Profile, not over a
|
||
SAIC service, so nothing is routed per firmware generation — availability is a bind, read at
|
||
runtime. The vendor Bluetooth call interface exposes no message transaction, so the
|
||
message cannot travel that way; the head unit's Bluetooth stack does expose a MAP client
|
||
profile at runtime, and that is the route taken.
|
||
- `ValueKind.CONFIRM`: a yes/no question plus the seconds it waits for an answer. The editor
|
||
draws one control per kind, so the wait had to become part of the kind rather than a
|
||
special case for one action.
|
||
|
||
### Changed
|
||
|
||
- `ActionType.ASK_CONFIRM` now carries `ValueKind.CONFIRM` (5–60 s) instead of plain text.
|
||
`Action.number` is the wait; `0` — every rule saved before the field existed — means
|
||
`ActionType.ASK_CONFIRM_DEFAULT_SECONDS` (10), not the bottom of the range.
|
||
|
||
## [1.2.0] - 2026-08-22
|
||
|
||
### Added
|
||
|
||
- `SaicRadio.pause`, the vendor service's `srcPauseRadio` — the counterpart of `play`. It
|
||
mutes the tuner and marks the radio stopped; it does not hand the audio focus back, which
|
||
the vendor service only does when it loses it.
|
||
|
||
### Changed
|
||
|
||
- `SaicRadio.tune` takes an `andPlay` flag and is responsible for the outcome. 1.1.0 removed
|
||
the `srcPlayRadio` call and assumed that was enough to tune in silence; it is not. The
|
||
vendor's `tune` calls `AudioController.requestMuted(false)` itself, which requests the
|
||
audio focus and unmutes the tuner, so changing station always starts the radio. Not
|
||
playing is therefore something that has to be undone after the fact: `andPlay = false`
|
||
pauses the radio as soon as it is tuned. Callers must pass the flag — there is no default.
|
||
|
||
### Known limitation
|
||
|
||
- Tuning with `andPlay = false` leaves the radio holding the audio focus, silent. Whatever
|
||
was playing before does not resume: the vendor service exposes no way to give the focus
|
||
back for AM/FM, only for DAB scanning.
|
||
|
||
## [1.1.0] - 2026-08-22
|
||
|
||
### Changed
|
||
|
||
- `SaicRadio.tune` no longer starts playback. It used to call `srcPlayRadio` after setting
|
||
the frequency, which made the radio the current audio source every time a station was
|
||
tuned, so a caller that wanted the station changed silently had no way to ask for it.
|
||
Tuning and playing are now two calls: `tune` sets the frequency, `play` switches the
|
||
source. Callers that relied on the combined behaviour must call `play` themselves.
|
||
|
||
## [1.0.1] - 2026-08-21
|
||
|
||
### Fixed
|
||
|
||
- `SaicClimate.outsideTempCelsius` no longer returns the vendor's error value as a
|
||
temperature. `AirConditionBinder.getOutCarTemp` answers `TEMPERATURE_ERROR_CONST`
|
||
(-10000f) for a signal it holds no value for, which is not a reading; it now maps to
|
||
`null`, same rule as every other getter here. A caller that stored it would have had
|
||
every "below N degrees" comparison come out true.
|
||
|
||
## [1.0.0] - 2026-08-15
|
||
|
||
### ⚠️ Breaking
|
||
|
||
- Renamed from MG4Hardware to **EVHardware**, and the package from `com.mg4.hardware` to
|
||
**`com.evsuite.hardware`**. Every import has to be updated; there is no compatibility
|
||
alias.
|
||
- `VehicleWriteGate.decide` no longer takes `allowUpToKmh` or `parked`. The gate is fixed at
|
||
a readable 0 km/h, with no caller threshold and no park rescue, so callers that passed
|
||
either argument must drop it. Consumers using `decideNow()` or `decide(speed)` are
|
||
unaffected.
|
||
|
||
### Added
|
||
|
||
- `FirmwareInfo.generationOf` as a pure parser, and `isDetectedGenerationSupported`, so an
|
||
unforced unknown firmware fails closed.
|
||
- Catalogue entry `ASK_CONFIRM`, with its localised strings and firmware-matrix row.
|
||
|
||
### Changed
|
||
|
||
- Consolidated the four copies of this library that had drifted apart while it was vendored
|
||
into EVProfile, EVTasker and EVABRPUploader.
|
||
|
||
## [0.3.0] - 2026-08-10
|
||
|
||
### Added
|
||
|
||
- **Shared diagnostic engine** in `com.evsuite.hardware.diag`, so the apps stop each carrying
|
||
their own copy:
|
||
- `CrashLogger` writes an uncaught exception, its cause chain and the `AppLogger` buffer
|
||
to `filesDir/last_crash.txt`, chaining the previous handler and truncating from the tail
|
||
so the exception survives. It replaces the two divergent copies in EVProfile and
|
||
EVTasker, each of which carried a fix the other was missing.
|
||
- `PrivateBin` uploads a report to a PrivateBin instance, zero-knowledge — the key never
|
||
leaves the URL fragment. Moved verbatim from EVTasker.
|
||
No UI: rendering a report and deciding when to paste it stay in the apps.
|
||
|
||
### Changed
|
||
|
||
- **One "Call" action.** `CALL_NUMBER` now carries a `CONTACT` value, so the same entry takes
|
||
either a typed number or a name from the phone book. `CALL_CONTACT` is deprecated and no
|
||
longer offered by `ActionType.byGroup()`: it stays in the enum only so rules saved by a
|
||
release that exposed it still load and still call, since enum entries are persisted by name
|
||
and a removed one deserialises to null.
|
||
|
||
## [0.2.0] - 2026-08-02
|
||
|
||
### Added
|
||
|
||
- Vehicle access to climate, charging, radio and calls through the vendor's own
|
||
service, alongside the existing reflection layer.
|
||
- `SPEAK_TEXT` local action: text-to-speech through the vehicle's own voice service.
|
||
- Window and door-lock control through the vendor control service.
|
||
- Park-based fallback for the write gate, and radio tuning.
|
||
|
||
### Fixed
|
||
|
||
- Unreadable vehicle signals were being reported as "off"; they're now reported
|
||
as unknown.
|
||
|
||
### Changed
|
||
|
||
- Adopted the EVSuite documentation structure: shared README skeleton, generated table
|
||
of contents, and [DESIGN.md](DESIGN.md) carried in every suite repository.
|
||
|
||
## [0.1.0] - 2026-07-23
|
||
|
||
Initial tagged release.
|