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