Files
mg4/EVHardware/CHANGELOG.md
T
nilsandClaude Opus 5.5 f0f4f57fee App 2.19.0: EVHardware updated from upstream (malys/EVHardware e4e1158)
Three-way merge from b595ccb, the commit Fatih copied, to upstream's
current state; Fatih's vendor reads (tyres, mileage, charge status,
SWI69) stay. LICENSE.md records the base for the next update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-19 09:44:00 +02:00

79 KiB
Raw Blame History

Changelog

All notable changes to this project are documented here. Format follows Keep a Changelog; versions follow Semantic Versioning.

[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 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, 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 carried in every suite repository.

[0.1.0] - 2026-07-23

Initial tagged release.