# Changelog All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added - **A probe that says when it was never able to ask.** `RuntimeInterfaceProbe.adapterClientMap()` swept nineteen service codes and recorded ABSENT for every one of them on a firmware where `sCarAdapter` is never assigned — nineteen answers to a question that never reached a binder, and RI-002 was nearly closed on them. `EVHardware.hasCarAdapterClient()` is checked once before the sweep and every swept entry now carries the caveat on itself rather than in the bundle preamble, because a later session reads one entry and not the header. - **`SocConsumptionFitter.describe()` tells the two refusals apart.** Too few segments and a speed span too narrow to separate rolling from aero both answer `INSUFFICIENT_SAMPLES`, and the two ask the driver for opposite things — more kilometres, or one motorway leg. A third cause was silent altogether: a trip skipped for a speed conversion no longer believed leaves no trace, and a history of them looks exactly like a history of short drives. One diagnostic line now states used-trips, segments and speed span against their thresholds. Diagnostic only — it runs the collection pass a second time, so it belongs in a probe and not on a screen. - **The pack's health, as a ratio rather than a reading.** `INFO_EV_BATTERY_CAPACITY`, `EV_BATTERY_LEVEL` and the `EV_CURRENT_BATTERY_CAPACITY` candidate are declared and never published on SWI68 — null in all 612 snapshots of the 2026-09-13 drive — so the capacity behind every kWh figure in EVSuite has been a specification sheet the driver retypes. It does not have to be: the energy that left the pack, over the charge it cost, *is* a capacity, and both halves are now recorded. `BatteryLedgerStore` keeps the entries a trip cannot — the parking, the standing, the charge between two drives — and `BatteryLedgerRecorder` turns the 1 Hz sample stream into one entry per point of charge, change of charging state, or quarter of an hour, carrying the car's own kWh counters (`SaicCharging` transactions 75 and 77), this app's running integral of `BMS_PACK_VOL × BMS_PACK_CRNT`, the odometer and the outside temperature. `StateOfHealthEstimator` assembles the discharges between two charges and reports the median of at least six of them, with the source named on the estimate and the two sources never averaged together. A charge step on this car is about one point, so a 25-point window carries ±4 % before anything else goes wrong and a year of real degradation is two or three points: one window is never a health figure, the band never claims to be tighter than the quantisation allows, and the figure worth reading is the trend, which cancels whatever node the energy was counted at. Cold windows are excluded and counted rather than believed. Every output is `ESTIMATED` with its band. - **When the charge gauge last had anything to anchor itself on.** `CalibrationDrift` reads the same ledger for a different question: how long since the pack saw a full charge, how many equivalent cycles since, and whether any charge step went unexplained — a car that stood still, drove nowhere and woke up several points lower did not consume that charge, the BMS re-estimated it. The odometer has to read identical at both ends for a step to count at all, and a self-discharge allowance keeps a fortnight of standing still from being reported as a fault. It notices; it changes nothing, and no figure is corrected against an observed step. - **What a pack has been subjected to.** `BatteryExposure` reports hours above 80 % and below 10 % of charge (interpolated across each segment, so a night that ended at 85 % counts its hours where they were spent), the mean charge, equivalent full cycles, kilometres per cycle, and one row per charge session with its mean and peak points per hour — the rate being what separates a wall box from a fast charger on a car that publishes no charger type. - **The head unit can now be asked what it publishes.** `RuntimeInterfaceProbe` walks the adapter's service codes and the vehicle hub's service names, reads back the interface descriptor of every binder that answers, and records one of three verdicts per interface: absent, reached-and-refused, or answered. The distinction is the whole point. Half the `RI-` questions are currently open because nobody knows whether an interface is missing from this head unit or merely closed to an unprivileged caller, and those two answers lead to opposite decisions. Reading a binder's own descriptor is not a call into it, so nothing of unknown meaning is invoked, and no setter is referenced anywhere in the file — several of these binders would move a car. Typed reads follow only for the two bind points already known: the vehicle option getters and the head unit's power state. The screen sub-service is bound but never read: it publishes no getter at all, and guessing a code to find one is exactly what this file exists not to do. There is a second hub behind engineering mode, mapped the same way, and the survey asks it for the four part numbers the head unit states about itself — identity the vehicle asserts, where the generation label is something the project inferred. It also reads the second speed source, which reports a float where the source the write gate trusts is integral. Nothing in production calls any of it; it exists to be run once from an unstable capture and turned into answers. - **A leg is not over on one reading.** `NavLegChain` needs three consecutive answers of "not guiding" before it treats a leg as finished. The drive of 2026-09-09 16:34 is why: a route handed over through `startNavFromEVRout` guided for twenty-five minutes while the adapter's remaining distance went to nothing seven minutes in and its notification listener heard nothing at all. Whether the guiding flag is as shaky as its siblings is not yet known, and the cost of being wrong is a car sent onward from a charger it is still driving to. Three readings cost seconds; a real arrival stays false for the rest of the day. - **A trip can now be driven one leg at a time.** `NavLegChain` holds the points of a plan and hands the next one over when the current one is done — done being the navigation app's own `isMapNavigating` flag going false after having been true. It exists because the only call ever seen to *start* guidance on this head unit, `goTo`, carries a single point: a plan with a charging stop on the way has to be sent as the stop, then the destination. Pure and Android-free, so the whole of its behaviour is proven on the JVM rather than on a road. Its ceiling is written down in its own documentation: a driver who cancels guidance mid-leg looks the same as one who arrived, so the next leg starts under them — and cancelling that one too ends the chain. - **The car can now say whether guidance actually started.** `SaicNavGuidance.isMapNavigating` is `IGeneralService` transaction 18, and what it returns is not an inference: `MapService` holds the flag and the navigation app sets it itself through `IMapService.isMapNavigating(boolean)`. A command accepted by the adapter only ever meant the adapter took it — the fan-out swallows a refusal — so this is the difference between "sent" and "started", and it lets a caller try one channel, ask the car, and move to the next. - **Guidance can be started, not only drawn.** The route handoff put the destination on the map and stopped there: the driver saw the place and no guidance ever ran. `SaicNavGuidance.goTo` is the command that runs it — `IGeneralService` transaction 23, which `GeneralService` turns into `MapService.goToPoi(null, address, null, latitude, longitude)`, the same call the head unit's voice assistant makes when someone says "take me there". One point and no pathway, because the interface has room for nothing else: a plan with a charging stop guides to the stop, which is the leg being driven. Same boundary as `startNavFromEvRoute` — a navigation write, not a vehicle write — and the same two cautions: the adapter logs the point, and the fan-out is synchronous, so it must not run on the main thread. - **A planned route can be handed to the car's own navigation, waypoints included.** Two drives proved that nothing on SWI68 declares a filter for `geo:` or `google.navigation:`: the map opened at its default view and the driver typed the address in again. The head unit has its own channel and it was hiding in plain sight — `IGeneralService` transaction 48, `startNavFromEVRout(pathway, destination)`, on the same `GeneralService` this module already binds for guidance. It forwards to `MapService`, which fans the points out over its `RemoteCallbackList` to whichever navigation app registered. `SaicNavGuidance.startNavFromEvRoute` is that call. **It is not an impersonation and not a vehicle write.** `IMapNotificationListener` is the channel the head unit uses to *command* the navigation app; registering on it to send one destination would mean claiming to be a navigation provider, and this is the other end of that wire. `VehicleWriteGate` is untouched and stays untouched: it exists for the settings that change how the car behaves under the driver — AEB, ELK, ACC/TJA, the drive mode — and a destination is no more one of those than the comfort writes it already exempts. **What travels is three fields.** `NavigationHandoff.poi` validates a point the same way the `geo:` form does — a swap of latitude and longitude routes to another continent while looking like an ordinary number — and keeps the name as a parcel field rather than a URI label, because a parenthesis cannot end a parcel field early and stripping it would only mangle what the driver reads on the map. `NavigationHandoff.pathway` caps the waypoints. The list is written by hand, since this module does not carry the vendor's `EVRoutPoiInfo`, and `EvRoutPoiParcelTest` proves the framing byte-for-byte against what Android's own `writeTypedList` produces for a bean with the same three fields. **Two things a caller has to know.** The transaction is not `oneway` and the service fans out synchronously while holding a lock, so it must not be called on the main thread; and `MapService` writes both point lists to the head unit's own logcat before fanning out, which is the vendor's log rather than this app's and the one place a destination appears in text. - **The guidance census records the size of the last parcel on each transaction code.** The 2026-09-07 capture is why: with guidance running on the car's own navigation, code 13 decoded as a remaining distance of zero and code 12 as a remaining time of zero, while an undecoded code 11 carried 98 callbacks in the same window. Nothing in the artifact could tell a genuine zero from a payload of a shape this build reads as an int. A parcel of one int is the descriptor plus four bytes; a larger one settles it without decoding a field, and a byte count is not a place, which is why it can travel on the stick. ### Changed - `EnergySnapshot` carries `vehicleConsumedKwh` and `vehicleRegeneratedKwh`, read through `EnergyTelemetryReader` from the charging service's own counters. Both are defaulted: every existing caller builds that snapshot by name, and a counter this car may not keep should not become a parameter each of them has to pass as null. - **`PlanDrift` now measures the charge gauge at a tenth of a percent, not a whole one.** The 2026-09-05 drive on SWI68 recorded 457 samples stepping between 53,9 % and 52,9 % in tenths and refreshing every 25 s or so, which is ten times finer than the figure the arithmetic started with. Every band it derives narrows by the same ten, and the odometer's whole kilometre — not the gauge — is now what sets `MIN_DISTANCE_KM`. An arrival two points above the reserve no longer reads as short on quantisation alone. - **A charge en route is separated from regeneration.** The guard used to reuse the gauge's own step, so any rise above the departure figure looked like a plug-in. It now has its own `MAX_REGEN_RECOVERY_PERCENT`: a col's worth of regen is not a charging session, and the shortest plausible plug-in gives back far more than a descent can. ### Added - **`PlanDrift` — whether the plan the driver chose is still the plan they are driving.** Charge and the odometer are already read once a second and the plan was written down when it was picked, so noticing that a trip has stopped matching it is a division: no request, no quota, no network. The alarm is deliberately hard to trip. It fires only when the rate actually being spent has left the band the plan admitted to *and* the arrival no longer clears the reserve, because a driver going faster than the row they chose and still arriving at 30 % does not need telling, and a companion that speaks then is one nobody reads when it matters. Both instruments quantise — a gauge that steps a whole percent, an odometer that answers whole kilometres — so the measurement carries its own band, which is also why it refuses under 15 km of road. - `ChargeStopPlan.effectiveRate` is public. It was the private fold of the climb into the rate; `PlanDrift` has to compare a drive against the rate the plan was *actually* made with, and a second copy of that arithmetic would have drifted away from the one that planned. - **`NavigationHandoff` — the first write this library builds towards the car.** Everything else here reads. This composes a `geo:` URI so a route the driver has chosen can be handed to the car's own navigation instead of retyped, and it is separated from the sending because the format is the part that can be wrong and the part a JVM test can settle without a head unit. It refuses coordinates that are not a place on Earth — latitude and longitude are two doubles of the same type and a swap routes to the wrong continent while looking like an ordinary number — and it formats in `Locale.ROOT`, because a head unit set to French writes 43.5 as `43,5` and a comma is a field separator in this URI. A label from a geocoder or a charger dataset is reduced to letters, digits, spaces and hyphens: it is text this project did not write, and a parenthesis in it would end the label early. `geo:` carries one point and no waypoints, which is a limit the caller has to show the driver rather than paper over. ### Fixed - **A momentarily unreadable battery power no longer blanks a settled range estimate.** `AdaptiveRangeEstimator` gated on the live power reading, so one sample the firmware failed to answer erased a figure computed from stored trips and the pack's charge — neither of which had moved. The question the gate asks is "can this firmware measure power at all", and that answer does not change between two polls, so it is latched: a firmware that has never published power still refuses to borrow another firmware's history, and one that published it a second ago is not treated as having lost the ability since. - **An estimate no longer claims a band of exactly zero.** Eight of one driver's trips that happened to agree produced "266,7 ± 0,0 km", which reads as a measurement — the one thing provenance exists to stop an estimate doing. The spread of a handful of trips is not the error of extrapolating to a whole pack: terrain, load, weather and speed all sit outside the fit. The band now has a floor of ten percent of the estimate. - **A corrupt trip history no longer multiplies itself.** `EnergyTripHistoryStore.quarantine` copied the unreadable file aside and left the original in place, so the next read failed to parse it again and wrote another copy — one file per read, unbounded, in storage the driver cannot see. The original now goes, by rename or by copy-then-delete, and a copy that itself fails is removed rather than left behind. - **Ending a drive no longer re-encodes the whole history once per evicted track.** Bounding the file measured every remaining trip again after each step, over as many as two hundred trips of four thousand samples, on the path that closes a trip. The envelope's size is arithmetic — empty envelope, plus each trip, plus one comma between each pair — so eviction now subtracts as it goes and each trip is serialised once. - **Decimating a track no longer throws away the newest sample.** `TripSampleTrack` halves itself when the sample just added overflows it, and on an even-sized track that sample sits at an odd index: taking every second one from the start discarded the most recent moment of the trip at every doubling. It is now kept explicitly. ### Added - **`SaicNav.probeTransaction` — what a navigation transaction replies, recorded rather than interpreted.** CP-040 saw transactions 38 and 39 declared on `IMapService` and never called them; CP-051 wants to know whether they carry the destination the driver has already entered in the car's own navigation, because that destination costs no keyboard and no geocoding request. The probe returns the reply's bytes and its length, and refuses to read them as a typed value: a latitude read as a longitude routes to the wrong continent while looking like an ordinary number. Read-only, capped at 128 bytes, and it answers "unbound" or "no answer" as plainly as it answers with a payload. Called only by the unstable channel's validation probe (CP-055). - **A consumption model for a car that publishes no battery power — which is this car.** `CarPropertyEvidence` has recorded since 2026-09-04 that SWI68 never publishes battery power: three diagnostic bundles, 51 samples, zero readings. The consequence was that the kWh model could never train on it, and three finished screens stayed blank. `SocConsumptionModel` fits the same shape — rolling plus aero times speed squared plus a thermal term — in **percent of charge per 100 km**, from the state of charge the car does publish and the distance the speed integral already gives. No pack capacity enters it, which matters because the capacity here is a specification sheet rather than a measurement: fitting in percent removes an unmeasured multiplier from every number instead of carrying it. Training points are whole segments of road, accumulated until the charge has moved by two points, because a state of charge read sample by sample is quantisation and a fit built on it would be fitting the quantiser. A hole in the track ends a segment rather than being driven through, a distance recorded under a conversion no longer believed contributes nothing, and a history that has only seen one temperature fits two coefficients instead of inventing a third. - **The col between here and there costs charge, and the forecast knows it.** CP-031 refused a grade source on good grounds — no vehicle signal, no proven barometric reference, and power-derived grade is circular — but every candidate it weighed described the road *behind* the car. A route's elevation profile describes the road ahead, which is the only kind a forecast can use. `RouteGrade` turns cumulative climb and descent into points of charge by physics rather than by a fit: potential energy for a stated mass, a stated drivetrain efficiency going up, a deliberately pessimistic regeneration efficiency coming down, each one a declared constant inside a 40 % band and none of them tuned against data that does not exist yet. A descent gives charge back and always less than the same climb spent, so a round trip over a col is never a free lunch. `ChargeStopPlan` takes the term as an optional argument and folds it into the rate, which means the band widens with the profile and a missing profile is `null` rather than a zero quietly claiming the road is flat — a plan without one is byte-for-byte the plan it was before. - **An arrival state of charge, built on what this car actually publishes.** The obvious route runs through the energy model — kWh/100 km, a distance, a pack capacity — and it is a dead end here, because the model trains on energy integrated from a battery power that never publishes. State of charge per kilometre needs no energy unit at all, and both its inputs read. `SocRateEstimator` takes the rate from the vehicle's own range on the first reading and from recorded trips once there are enough, preferring what the driver has actually driven; trips whose distance the current speed conversion did not produce are never averaged in. `ArrivalSocForecast` never returns a bare number: the band widens with distance, a band wider than 15 points is refused rather than shown, and a negative arrival is left negative because "the destination is beyond this charge" is the most useful thing it can say. - **A declared battery capacity bridges kWh and state of charge.** `INFO_EV_BATTERY_CAPACITY` is declared and never published on the MG4, and deriving the pack size from an energy total needs battery power, which is unvalidated — so nothing could turn a model's kWh/100 km into a state of charge. `BatteryCapacityConfig` takes the figure from whoever knows it. It asks for the **usable** capacity, not the nameplate one, because state of charge spans the usable window and the buffers at each end sit outside it: entering 64 kWh where 61.7 is usable overstates a full pack by 2.2 kWh, in the optimistic direction. Everything derived from it is `ESTIMATED` with a 5 % band covering a dealer's state-of-health reading and a specification figure that was never a measurement of this pack. Out-of-range input is refused, never clamped. - **A recorded distance says which speed conversion produced it.** `VehicleSpeedEvidence` is the counterpart of `BatteryPowerEvidence` on the distance side, and it exists because the conversion has already been wrong once: every trip stored before the km/h fix carries a distance 3.6 times too long, and nothing in the number said so — 8.46 km for a 2.4 km route reads exactly like 8.46 km for an 8.46 km route. `EnergyTripSummary.modellableDistanceKm` answers only for a distance produced by the conversion believed correct today, so a model refuses history it cannot vouch for instead of quietly training on it. The reported distance is unchanged: a driver's own record does not silently rewrite itself. - **The odometer reads again.** `PERF_ODOMETER` is declared and never published on the MG4, so total mileage showed as unavailable while the head unit knew it. The navigation adapter answers it, and `getOdometerKm` now falls back to it — the same reading that gave the 3.6x speed error its independent reference. - **Guidance can be read, not only waited for.** The five listener callbacks are change notifications, so a car standing still with a guidance already running publishes nothing — which made a parked capture unable to tell "the adapter says nothing" from "nothing happened to say". `IGeneralService` answers `getGuideStatus`, `getRemainingTimes`, `getRemainingDistance` and `getRoadName` synchronously on the binder already bound, so `SaicNavGuidance.readNow` reports the current trip on demand. `SaicNav`'s note that the head unit answers no synchronous question about a trip was read off `IMapService`; the general service does answer. A distance or time of zero is treated as unreadable, as the service returns zero before any guidance has run. ### Fixed - **`PERF_VEHICLE_SPEED` already reports km/h on SWI68, and was being converted a second time.** AAOS specifies the property in metres per second, so every read was multiplied by 3.6 — and every distance integrated from it was scaled by the same factor. A 2.4 km drive was recorded as 8.46 km and, on a second run, 7.69 km: 124 and 129 km/h average over four minutes of town driving. Divided by 3.6 the same trips read 2.35 km at 34 km/h, which is the drive that happened. `VehicleSpeedScale` makes the conversion evidence-gated per generation like every other firmware-dependent read here. SWI68 is listed on the 2026-09-04 bundle from SWI68-29958-1300R67; every other generation keeps the specified conversion, unproven but wrong in the conservative direction, since a speed read too high makes `VehicleWriteGate` refuse more often rather than less. ### Fixed - **Repeated vehicle states no longer flood the diagnostic log.** The ignition condition arrives about ten times a second and the gear is read once a second; both were logged every time, so a 400-line retained log held under a minute of history and anything older had already been evicted from the bundle a driver sends back. `StateChangeLog` lets a state through only when it changed, keeping the transitions a diagnostic actually reads and dropping the repeats between them. Only logging is gated — every callback is still dispatched. ## [1.14.0] - 2026-09-04 ### Changed - **`WEATHER_NOW` is an `ENUM`, not free text.** The condition compared the weather service's own phrase, in the head unit's language, against a string the driver had to type — a value nobody can guess and that changes with the display language. It now carries one of the seven states in the new `WeatherConditions`, which classifies the phrase by keyword fragments in the six languages the apps ship. `SnapshotKeys.KEY_WEATHER_TEXT` became `KEY_WEATHER_CONDITION` and carries that state; a phrase nothing recognises writes no key at all, so the condition is unavailable rather than reported as fine weather. - **`ConditionGroup.AUDIO`**, the counterpart of `ActionGroup.AUDIO`. `MEDIA_PLAYING`, `RADIO_PLAYING` and `MEDIA_VOLUME` moved into it from `CONTEXT` and `COMFORT`. ## [1.13.0] - 2026-09-03 ### Changed - **Radio band, frequency and playback became one catalogue entry.** `SELECT_RADIO_BAND` is gone; `TUNE_RADIO` now carries the band in `number`, the frequency in `text` and playback in `flag`, behind a new `ValueKind.RADIO`. The split was the wrong seam: a band could not carry a station, a station could not reach DAB, and "put the radio on FM 103.5 and play it" — one instruction — took two entries that each had half of it. `number = 0` is what every rule saved before the merge carries and still means "read the band off the frequency", so nothing that worked stopped working. `RadioFrequency.parse` takes the picked band and refuses text that disagrees with it rather than resolving the disagreement, for the same reason it already refused `AM 103.5`. `SaicRadio.selectBand` gained `andPlay`, so a band switch can leave the car silent the way a tune already could. ### Added - **A generated catalogue document.** `docs/catalogue.md` lists every `ConditionType` and every `ActionType` with its English label, what it asks for, whether it is refused while moving and the generations it is declared on. Rendered from the enums by `CatalogueDocTest`, which rewrites it on every test run exactly as `FirmwareSupportTest` does for the matrix — the hand-written list it replaces had gone a year without mentioning twenty entries and still promised an "AM or FM only" tune action. - **Read-only route guidance listener for the head unit's own navigation.** The map adapter answers no synchronous question about a trip, but its general service fans remaining distance, remaining time, guidance status and road name out to registered listeners. `SaicNavGuidance` registers for that fan-out and folds it into a nullable `NavGuidance`. The callbacks are not `oneway` and the adapter holds its callback-list lock while broadcasting, so the listener does one parcel read and one volatile write and nothing else — a slow listener would stall the instrument cluster. Registration is additive and displaces no other consumer. Distances carry no proven unit and status codes no proven meaning, so nothing is converted and nothing is displayable: this is CP-040 evidence, not a value. Costs no Android permission and no network. - **Post-trip energy attribution preserves uncertainty and gaps.** Measured trip consumption is reconciled against modelled traction, regeneration, climate-conditioned residual groups and an explicit unmodelled discrepancy. Every attributed value requires a band; values inside model noise are marked indistinguishable from zero, and negative residuals remain visible as model error. No per-consumer vehicle counter is claimed. - **Driver-specific energy modelling is bounded and evidence-gated.** A compact least-squares fit learns rolling, aerodynamic speed-squared and outside-temperature terms from retained trip samples only when their exact firmware/conversion evidence matches. It uses constant working memory, caps training at 50,000 samples, rejects sparse/noisy fits and all extrapolation, and returns only provenance-labelled estimates with residual-derived uncertainty. One atomic 16 KiB model file stores coefficients and envelope; raw tracks are never duplicated. ## [1.12.0] - 2026-08-31 ### Added - **Vehicle-validated telemetry is catalogued per signal and firmware generation.** Battery power starts unvalidated everywhere; CP-003 must add each generation only after its unit, scale, sign and cadence have on-vehicle evidence. Consumers can therefore distinguish an unproven signal from a property that is genuinely absent. Trip energy integrated from a validated signal carries the exact firmware and conversion version; legacy or mismatched totals can therefore be excluded from later models without growing the stored sample track. ## [1.11.0] - 2026-08-29 ### Added - **A rule can put the tuner on a band, DAB included.** `SELECT_RADIO_BAND` is the answer to the one gap the radio work left: "tune the radio" takes a frequency a driver typed, and a DAB service is addressed by an ensemble and service id, so there was nothing to type and no way to ask for DAB. The action guesses no transaction code — the vendor service exposes no band-only call that anyone here has observed, and a wrong code on a vehicle binder does something unknown to a car. It composes calls that are already proven instead: read the current band from `RadioBean`, then `tune` into the requested one, since `tune` carries the band as its first argument. It lands on the station this car was last heard on for that band, and when it has never been there it steps to the first station the tuner lists rather than leaving the driver on an empty frequency. An unreadable current band sends nothing at all. ### Changed - **The window actions ask to open or close, instead of carrying a raw command.** They used to take a number in 0..7 — the vendor service's own command range, which nothing in the app or the car explains to the person writing the rule. An on-vehicle capture showed what that costs: a rule asking for `7` ran five times, was reported as applied five times, and left the driver's window fully open throughout, because the service accepts a command it does not implement and drops it. Closed and open are the only two states the glass can be asked for, so those are the two the editor now offers. Which raw command reaches each is a property of the *car*, established by the window probe and stored per car, so it stays out of the catalogue and out of saved rules — a rule exported from one car must not carry that car's command numbers. - **"Windows" is now "All windows".** It commands four windows at once, and the bare plural read as the family of window actions rather than the one that moves them together. ## [1.10.0] - 2026-08-29 ### Fixed - **A property the firmware declares and never publishes no longer reads as a measurement.** `CarPropertyManager.getFloatProperty` answers such a property with a plain zero, and nothing in the number says the vehicle never put a value behind it — an MG4 on SWI68 reporting 31 °C outside showed a 0 °C traction battery and a 0 kW pack current on the dashboard next to it. Reads now go through `getProperty`, whose `CarPropertyValue` carries the VHAL's status for the property, and only `STATUS_AVAILABLE` is treated as a reading; anything else stays null and reaches the screen as "unknown", which is what it is. A measured zero is still a zero. Runtimes that do not expose `getProperty` fall back to the typed getters unchanged, so no generation loses a property it was already answering. ### Added - **Automatic trip boundaries are now one fail-closed telemetry state machine.** `TripDetector` requires five seconds above 5 km/h to start and two minutes below 1 km/h to end, using park or charge-port confirmation when those signals exist. Missing speed cancels partial evidence and can never start or stop a recording; all timers and threshold bounce behaviour are covered without Android or a vehicle. - **Adaptive range is now an explicit estimate with evidence-sized uncertainty.** `AdaptiveRangeEstimator` fits up to eight recent trip-consumption averages, requires three usable observations, and converts their sample spread into a kilometre uncertainty band. It prefers measured usable energy, falls back to SOC times capacity, and refuses to estimate when the current firmware does not publish battery power; the vehicle's own range is untouched. - **Consumption is now one shared, nullable derivation instead of UI arithmetic.** `ConsumptionCalculator` exposes raw and five-second exponentially smoothed instantaneous kWh/100 km plus a two-minute energy/distance average. It masks missing inputs and speeds below 5 km/h, preserves negative regeneration, rejects unobserved gaps over five seconds, and bounds its working set independently of trip length. Every output is provenance-labelled; the existing trip accumulator continues to integrate raw power and speed unchanged. - **Trip history can be pruned without bypassing its atomic persistence boundary.** `EnergyTripHistoryStore` can remove one start-time-identified trip or replace the history with a valid empty v2 envelope; both operations preserve the same unique-temp-file rewrite used by appends, so a UI never deletes the only valid file before its replacement exists. The default summary bound is 200 trips; the existing byte ceiling still drops old sample tracks first. - **A public post-AAOS 9 capacity signal can be tested without becoming product data.** `EV_CURRENT_BATTERY_CAPACITY` describes real-time usable capacity rather than the pack's nominal new capacity, but a public id does not prove that an MG4 firmware publishes it. The new status-aware, firmware-branched reader is therefore internal to explicit evidence capture, stays null when absent or invalid, and is carried beside — never inside — `EnergySnapshot`. [The candidate review](docs/energy-signal-candidates.md) records exact AOSP provenance and the negative search results for HVAC, battery-heater, accessory and cumulative energy counters. - **Three radio behaviours the library could already perform are now things a rule can ask for.** `srcPauseRadio`, the tuner's own play state and `startRadioActivity` were implemented and unreachable: the catalogue offered play, tune and station stepping, so "silence the radio" had to be approximated with `MEDIA_CONTROL`, which addresses whichever source owns the audio and therefore stops Bluetooth when Bluetooth is the one playing. `PAUSE_RADIO`, `RADIO_PLAY_PAUSE` and `OPEN_RADIO_SCREEN` name the tuner instead. The toggle reads `RadioBean.state` rather than `AudioManager.isMusicActive`, which is **false while the radio plays** — a toggle driven by it would answer a playing radio with another "play", forever — and when even that cannot be read it sends nothing and says so: a wheel button can be pressed twice, an unattended rule cannot, and a guessed direction leaves the car silent or playing with nothing in the history admitting the direction was invented. Opening the radio screen is the one of the three that is not audio-only, so it takes the standstill gate, unreadable speed included; skipping a station does not, and did not gain it. Band selection and direct DAB service selection were considered and rejected — see [docs/radio-action-gap.md](docs/radio-action-gap.md), which records every candidate including the refused ones. DAB remains reachable through station stepping, which is what the head unit's own launcher calls. - **A trip now keeps its shape, not only its totals.** A summary cannot be un-summarised, and every model this project will grow — what a slower motorway speed would have saved, what the climate system took, the state of charge on arrival — is fitted from the shape of past drives. `TripSampleTrack` records one sample per five seconds of speed, power, state of charge, temperatures and climate state, all nullable, so a car that publishes no pack temperature stores no pack temperature rather than a column of zeros a fit would believe. A drive that outlasts the track's length is decimated rather than truncated: every second sample goes and the interval doubles, so the motorway stretch at the end survives at a coarser resolution instead of being cut off. ### Changed - **The trip history file states its own version.** The first shape was a bare JSON array with no room to say what it was; it is read as before and rewritten into the v2 envelope on the next append, losing nothing. A file this build cannot read — truncated, or written by a build that knows something this one does not — is quarantined under a new name rather than deleted, because it is the only copy of whatever it holds. Three bounds now apply in the order that keeps the most meaning per byte: at most fifty trips, then sample tracks dropped oldest-first to stay under the size ceiling, and only then whole trips — a trip without its track is still a trip, a missing trip is not. - `EnergyTripSession.stop` returns a `RecordedTrip` (summary plus track) rather than a bare summary, and `EnergyTripHistoryStore.read()` returns stored trips; `readSummaries()` is the old view. - **`TelemetryEvidenceRecorder` — what a signal actually did on the car, instead of what the code assumes it does.** Every energy figure past a state of charge divides by a number whose unit, sign and update rate are currently a hypothesis: the battery power reader divides the raw property by -1,000,000 because that is what the value looked like, not because a vehicle confirmed it. The recorder consumes the same snapshots the dashboard renders and keeps, per signal, how often it answered and how often it did not, its range and mean, its positive and negative split, and the interval between the values it actually published — measured from value *changes*, so a 1 Hz sampler over a signal republished every five seconds reports five seconds rather than one. A signal the car never answered keeps no statistics at all: no minimum of zero, no mean of zero, nothing. Captures serialise to a versioned schema for the next build and render as a Markdown table for the person writing the conclusion. - **`Provenanced` — a value that carries what kind of claim it is.** The MG4 publishes no per-consumer energy counters, so anything past traction totals — the climate system's share, what a slower motorway speed would have saved, the state of charge on arrival — can only be inferred, and an inference rendered in the same typeface as a vehicle reading is a measurement as far as the driver is concerned. `Provenance` names the four kinds a figure can be (measured, derived, estimated, unavailable) and `Provenanced` 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.