From 2fd7b2dde177abee49218c806366bb5add04aaf5 Mon Sep 17 00:00:00 2001 From: TCFDONMEZ Date: Tue, 22 Sep 2026 10:45:00 +0300 Subject: [PATCH] =?UTF-8?q?Fatih'sMG4=20=E2=80=94=20ABRP=20telemetry=20for?= =?UTF-8?q?=20an=20MG4=20EH32=20on=20SWI69?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fork of malys/EVABRPUploader (itself a fork of Leon Kernan's ABRP_Uploader) carrying the changes that make it work on this car. History starts here: the tree is what matters, and the upstream projects are where the earlier work belongs. EVHardware is vendored rather than tracked as a submodule, so one clone builds. Telemetry. SOC, battery power and range read as unavailable on SWI69 because the vendor fallbacks were gated to SWI68 alone, while the property ids they use are the ones this car publishes — the gates now admit SWI69. Tyre pressure, the odometer and charge status come from the vendor block too, since the generic properties for all three are declared here and never publish. Tyre pressure is confirmed on the vehicle; the other two are not yet. Vehicle page. Every signal the car answers, in a table. One that it does not publish shows a dash: an unimplemented VHAL property returns zero, and "0 °C" would read as a measurement. Wi-Fi page. Joins a network the unit already has saved, so a phone hotspot does not need a trip into Settings on every drive. Tried on the upload tick and from a button, skipped while already associated. This leans on the unit being API 28 and the build being platform-signed — getConfiguredNetworks and enableNetwork are closed to ordinary apps from API 29 — and fails quietly where that does not hold. The OTA updater points at this repository. An APK from upstream is signed with a different key, which Android will not install over a platform-signed build. Co-Authored-By: Claude --- .claudeignore | 28 + .gitignore | 30 + .idea/.gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 11 + .idea/gradle.xml | 18 + .idea/misc.xml | 9 + .idea/runConfigurations.xml | 17 + .idea/vcs.xml | 6 + AGENTS.md | 114 + CHANGELOG.md | 290 ++ CONTRIBUTING.md | 95 + DESIGN.md | 463 ++ DISCLAIMER.md | 38 + EVHardware/.gitignore | 24 + EVHardware/AGENTS.md | 65 + EVHardware/CHANGELOG.md | 947 ++++ EVHardware/CONTRIBUTING.md | 75 + EVHardware/DESIGN.md | 463 ++ EVHardware/DISCLAIMER.md | 32 + EVHardware/LICENSE | 74 + EVHardware/LICENSE.md | 15 + EVHardware/README.md | 205 + EVHardware/SECURITY.md | 40 + EVHardware/build.gradle.kts | 4 + EVHardware/docs/catalogue.md | 242 + EVHardware/docs/energy-signal-candidates.md | 48 + EVHardware/docs/firmware-matrix.md | 161 + EVHardware/docs/logo.svg | 12 + EVHardware/docs/radio-action-gap.md | 102 + EVHardware/gradle.properties | 2 + EVHardware/gradle/libs.versions.toml | 25 + EVHardware/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + EVHardware/gradlew | 251 + EVHardware/gradlew.bat | 94 + EVHardware/lib/build.gradle.kts | 92 + EVHardware/lib/consumer-rules.pro | 24 + EVHardware/lib/src/main/AndroidManifest.xml | 2 + .../java/com/evsuite/hardware/A9Climate.kt | 191 + .../java/com/evsuite/hardware/AppLogger.kt | 118 + .../evsuite/hardware/CarPropertyEvidence.kt | 183 + .../java/com/evsuite/hardware/DataUsage.kt | 98 + .../java/com/evsuite/hardware/EVHardware.kt | 4520 +++++++++++++++++ .../java/com/evsuite/hardware/FirmwareGen.kt | 10 + .../java/com/evsuite/hardware/FirmwareInfo.kt | 148 + .../com/evsuite/hardware/FirmwareMatrix.kt | 61 + .../com/evsuite/hardware/FirmwareSupport.kt | 43 + .../com/evsuite/hardware/GlassEvidence.kt | 111 + .../java/com/evsuite/hardware/GlassProbe.kt | 198 + .../hardware/PhysicalButtonEventDecoder.kt | 95 + .../evsuite/hardware/RequiresStandstill.kt | 22 + .../com/evsuite/hardware/StateChangeLog.kt | 38 + .../java/com/evsuite/hardware/SupportedOn.kt | 22 + .../evsuite/hardware/VehicleSpeedEvidence.kt | 54 + .../com/evsuite/hardware/VehicleSpeedScale.kt | 44 + .../com/evsuite/hardware/VehicleWriteGate.kt | 112 + .../java/com/evsuite/hardware/VsmGlass.kt | 208 + .../evsuite/hardware/catalog/ActionType.kt | 852 ++++ .../evsuite/hardware/catalog/ConditionType.kt | 596 +++ .../evsuite/hardware/catalog/SnapshotKeys.kt | 169 + .../com/evsuite/hardware/catalog/ValueSpec.kt | 140 + .../evsuite/hardware/catalog/VehicleEnums.kt | 179 + .../hardware/catalog/WeatherConditions.kt | 110 + .../com/evsuite/hardware/diag/CrashLogger.kt | 163 + .../com/evsuite/hardware/diag/PrivateBin.kt | 206 + .../com/evsuite/hardware/model/DriveMode.kt | 16 + .../evsuite/hardware/model/DrivingProfile.kt | 41 + .../evsuite/hardware/model/ProfileBackup.kt | 17 + .../com/evsuite/hardware/model/RegenLevel.kt | 17 + .../hardware/probe/RuntimeInterfaceProbe.kt | 535 ++ .../com/evsuite/hardware/saic/NavGuidance.kt | 183 + .../com/evsuite/hardware/saic/NavLegChain.kt | 117 + .../hardware/saic/NavigationHandoff.kt | 115 + .../evsuite/hardware/saic/RadioFrequency.kt | 70 + .../java/com/evsuite/hardware/saic/SaicIpc.kt | 219 + .../com/evsuite/hardware/saic/SaicMedia.kt | 355 ++ .../evsuite/hardware/saic/SaicMediaPlayer.kt | 475 ++ .../java/com/evsuite/hardware/saic/SaicNav.kt | 89 + .../evsuite/hardware/saic/SaicNavGuidance.kt | 325 ++ .../com/evsuite/hardware/saic/SaicVehicle.kt | 591 +++ .../com/evsuite/hardware/saic/SaicWeather.kt | 200 + .../telemetry/AdaptiveRangeEstimator.kt | 124 + .../hardware/telemetry/ArrivalSocForecast.kt | 82 + .../telemetry/BatteryCapacityConfig.kt | 89 + .../hardware/telemetry/BatteryExposure.kt | 157 + .../hardware/telemetry/BatteryLedger.kt | 235 + .../hardware/telemetry/CalibrationDrift.kt | 152 + .../hardware/telemetry/ChargeStopPlan.kt | 287 ++ .../telemetry/ChargingSessionMeter.kt | 62 + .../telemetry/ConsumptionCalculator.kt | 182 + .../hardware/telemetry/EnergySnapshot.kt | 70 + .../telemetry/EnergyTelemetryReader.kt | 134 + .../evsuite/hardware/telemetry/EnergyTrip.kt | 142 + .../telemetry/EnergyTripHistoryStore.kt | 183 + .../evsuite/hardware/telemetry/PlanDrift.kt | 186 + .../evsuite/hardware/telemetry/Provenance.kt | 177 + .../evsuite/hardware/telemetry/RouteGrade.kt | 105 + .../com/evsuite/hardware/telemetry/SocRate.kt | 123 + .../hardware/telemetry/StateOfHealth.kt | 319 ++ .../hardware/telemetry/TelemetryEvidence.kt | 309 ++ .../hardware/telemetry/TripDetector.kt | 139 + .../evsuite/hardware/telemetry/TripSample.kt | 99 + .../telemetry/model/EnergyAttribution.kt | 226 + .../hardware/telemetry/model/EnergyModel.kt | 91 + .../telemetry/model/EnergyModelStore.kt | 44 + .../telemetry/model/EnergyModelTrainer.kt | 139 + .../hardware/telemetry/model/LeastSquares3.kt | 80 + .../telemetry/model/SocConsumptionModel.kt | 351 ++ .../lib/src/main/res/values-de/strings.xml | 223 + .../lib/src/main/res/values-es/strings.xml | 223 + .../lib/src/main/res/values-fr/strings.xml | 245 + .../lib/src/main/res/values-it/strings.xml | 223 + .../lib/src/main/res/values-pt/strings.xml | 223 + .../lib/src/main/res/values/strings.xml | 245 + .../hardware/CarPropertyEvidenceTest.kt | 168 + .../evsuite/hardware/FirmwareSupportTest.kt | 148 + .../hardware/FirmwareWritePolicyTest.kt | 23 + .../com/evsuite/hardware/GlassEvidenceTest.kt | 57 + .../com/evsuite/hardware/GlassProbeTest.kt | 134 + .../PhysicalButtonEventDecoderTest.kt | 89 + .../evsuite/hardware/StateChangeLogTest.kt | 52 + .../hardware/VehicleSpeedEvidenceTest.kt | 45 + .../evsuite/hardware/VehicleSpeedScaleTest.kt | 46 + .../evsuite/hardware/VehicleWriteGateTest.kt | 34 + .../java/com/evsuite/hardware/VsmGlassTest.kt | 79 + .../catalog/CatalogConsistencyTest.kt | 386 ++ .../hardware/catalog/CatalogueDocTest.kt | 184 + .../hardware/catalog/WeatherConditionsTest.kt | 62 + .../evsuite/hardware/diag/CrashLoggerTest.kt | 106 + .../evsuite/hardware/diag/PrivateBinTest.kt | 180 + .../probe/RuntimeInterfaceProbeTest.kt | 94 + .../hardware/saic/EvRoutPoiParcelTest.kt | 99 + .../hardware/saic/NavGuidanceReducerTest.kt | 154 + .../evsuite/hardware/saic/NavLegChainTest.kt | 131 + .../hardware/saic/NavigationHandoffTest.kt | 109 + .../hardware/saic/RadioFrequencyTest.kt | 58 + .../evsuite/hardware/saic/RadioToggleTest.kt | 224 + .../hardware/saic/SaicChargingHistoryTest.kt | 28 + .../telemetry/AdaptiveRangeEstimatorTest.kt | 148 + .../telemetry/ArrivalSocForecastTest.kt | 144 + .../telemetry/BatteryCapacityConfigTest.kt | 64 + .../hardware/telemetry/BatteryExposureTest.kt | 109 + .../hardware/telemetry/BatteryLedgerTest.kt | 159 + .../telemetry/CalibrationDriftTest.kt | 123 + .../hardware/telemetry/ChargeStopPlanTest.kt | 217 + .../telemetry/ChargingSessionMeterTest.kt | 34 + .../telemetry/ConsumptionCalculatorTest.kt | 111 + .../hardware/telemetry/EnergyTelemetryTest.kt | 182 + .../hardware/telemetry/PlanDriftTest.kt | 156 + .../hardware/telemetry/ProvenanceTest.kt | 89 + .../hardware/telemetry/RouteGradeTest.kt | 64 + .../hardware/telemetry/StateOfHealthTest.kt | 226 + .../telemetry/TelemetryEvidenceTest.kt | 187 + .../hardware/telemetry/TripDetectorTest.kt | 183 + .../telemetry/TripHistoryStoreTest.kt | 240 + .../telemetry/model/EnergyAttributionTest.kt | 106 + .../telemetry/model/EnergyModelTest.kt | 168 + .../model/SocConsumptionModelTest.kt | 224 + EVHardware/mise.toml | 24 + EVHardware/settings.gradle.kts | 20 + FIRMWARE.md | 46 + LICENSE | 22 + LICENSE.md | 19 + README.md | 257 + SECURITY.md | 37 + app/.gitignore | 1 + app/build.gradle | 145 + app/lint.xml | 37 + app/proguard-rules.pro | 25 + .../java/com/evsuite/abrp/VhalProbe.java | 118 + app/src/main/AndroidManifest.xml | 88 + .../main/java/com/evsuite/abrp/AbrpApi.java | 123 + .../com/evsuite/abrp/AbrpUploadService.java | 762 +++ .../java/com/evsuite/abrp/BootReceiver.java | 69 + .../com/evsuite/abrp/CarPropertyAdapter.java | 289 ++ .../java/com/evsuite/abrp/ChargingSignal.java | 77 + .../java/com/evsuite/abrp/ConfigImport.java | 119 + .../com/evsuite/abrp/HeadUnitWeather.java | 239 + .../java/com/evsuite/abrp/MainActivity.java | 965 ++++ .../main/java/com/evsuite/abrp/QrCode.java | 36 + .../java/com/evsuite/abrp/SecurePrefs.java | 70 + .../com/evsuite/abrp/TelemetryPayload.java | 205 + .../java/com/evsuite/abrp/UploadCadence.java | 66 + .../main/java/com/evsuite/abrp/UploadLog.java | 122 + .../java/com/evsuite/abrp/UploadSettings.java | 69 + .../com/evsuite/abrp/WifiAutoConnect.java | 158 + app/src/main/res/color/nav_tab_background.xml | 8 + app/src/main/res/color/nav_tab_foreground.xml | 7 + app/src/main/res/color/nav_tab_stroke.xml | 7 + app/src/main/res/drawable/about_logo_tile.xml | 6 + app/src/main/res/drawable/bg_topbar.xml | 15 + .../main/res/drawable/circle_indicator.xml | 5 + .../res/drawable/ic_launcher_foreground.xml | 33 + .../res/drawable/ic_launcher_monochrome.xml | 33 + app/src/main/res/drawable/ic_tab_abrp.xml | 23 + app/src/main/res/drawable/ic_tab_log.xml | 26 + app/src/main/res/drawable/ic_tab_service.xml | 11 + app/src/main/res/drawable/ic_tab_vehicle.xml | 12 + app/src/main/res/drawable/ic_tab_wifi.xml | 12 + app/src/main/res/layout/activity_main.xml | 112 + app/src/main/res/layout/dialog_about.xml | 142 + app/src/main/res/layout/pane_abrp.xml | 153 + app/src/main/res/layout/pane_log.xml | 51 + app/src/main/res/layout/pane_service.xml | 161 + app/src/main/res/layout/pane_vehicle.xml | 173 + app/src/main/res/layout/pane_wifi.xml | 122 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + app/src/main/res/values-night/colors.xml | 35 + app/src/main/res/values/colors.xml | 41 + app/src/main/res/values/dimens.xml | 27 + app/src/main/res/values/strings.xml | 122 + app/src/main/res/values/styles_app.xml | 69 + app/src/main/res/values/styles_ev.xml | 96 + app/src/main/res/values/themes.xml | 28 + app/src/main/res/xml/backup_rules.xml | 10 + .../main/res/xml/data_extraction_rules.xml | 15 + .../java/com/evsuite/abrp/UpdateHook.java | 20 + .../java/com/evsuite/abrp/AbrpApiTest.java | 50 + .../com/evsuite/abrp/ChargingSignalTest.java | 122 + .../com/evsuite/abrp/ConfigImportTest.java | 87 + .../com/evsuite/abrp/HeadUnitWeatherTest.java | 109 + .../evsuite/abrp/TelemetryPayloadTest.java | 333 ++ .../com/evsuite/abrp/UploadCadenceTest.java | 158 + .../java/com/evsuite/abrp/UploadLogTest.java | 164 + .../java/com/evsuite/abrp/OtaUpdaterTest.java | 120 + app/src/unstable/AndroidManifest.xml | 3 + .../java/com/evsuite/abrp/ApkSignature.java | 87 + .../java/com/evsuite/abrp/OtaUpdater.java | 337 ++ .../java/com/evsuite/abrp/UpdateHook.java | 13 + build.gradle | 4 + docs/logo.svg | 12 + gradle.properties | 13 + gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 28 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 + gradlew.bat | 94 + mise.toml | 221 + release-notes.sh | 61 + screenshots/evabrp1.png | Bin 0 -> 44026 bytes screenshots/evabrp2.png | Bin 0 -> 46249 bytes settings.gradle | 28 + 245 files changed, 35594 insertions(+) create mode 100644 .claudeignore create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/vcs.xml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 DESIGN.md create mode 100644 DISCLAIMER.md create mode 100644 EVHardware/.gitignore create mode 100644 EVHardware/AGENTS.md create mode 100644 EVHardware/CHANGELOG.md create mode 100644 EVHardware/CONTRIBUTING.md create mode 100644 EVHardware/DESIGN.md create mode 100644 EVHardware/DISCLAIMER.md create mode 100644 EVHardware/LICENSE create mode 100644 EVHardware/LICENSE.md create mode 100644 EVHardware/README.md create mode 100644 EVHardware/SECURITY.md create mode 100644 EVHardware/build.gradle.kts create mode 100644 EVHardware/docs/catalogue.md create mode 100644 EVHardware/docs/energy-signal-candidates.md create mode 100644 EVHardware/docs/firmware-matrix.md create mode 100644 EVHardware/docs/logo.svg create mode 100644 EVHardware/docs/radio-action-gap.md create mode 100644 EVHardware/gradle.properties create mode 100644 EVHardware/gradle/libs.versions.toml create mode 100644 EVHardware/gradle/wrapper/gradle-wrapper.jar create mode 100644 EVHardware/gradle/wrapper/gradle-wrapper.properties create mode 100755 EVHardware/gradlew create mode 100644 EVHardware/gradlew.bat create mode 100644 EVHardware/lib/build.gradle.kts create mode 100644 EVHardware/lib/consumer-rules.pro create mode 100644 EVHardware/lib/src/main/AndroidManifest.xml create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/A9Climate.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/AppLogger.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/CarPropertyEvidence.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/DataUsage.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareGen.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareInfo.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareMatrix.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareSupport.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/GlassEvidence.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/GlassProbe.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/PhysicalButtonEventDecoder.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/RequiresStandstill.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/StateChangeLog.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/SupportedOn.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleSpeedEvidence.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleSpeedScale.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleWriteGate.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/VsmGlass.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/ActionType.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/ConditionType.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/SnapshotKeys.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/ValueSpec.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/VehicleEnums.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/WeatherConditions.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/diag/CrashLogger.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/diag/PrivateBin.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/model/DriveMode.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/model/DrivingProfile.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/model/ProfileBackup.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/model/RegenLevel.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/probe/RuntimeInterfaceProbe.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavGuidance.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavLegChain.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavigationHandoff.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/RadioFrequency.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicIpc.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicMedia.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicMediaPlayer.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicNav.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicNavGuidance.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicVehicle.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicWeather.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimator.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ArrivalSocForecast.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryCapacityConfig.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryExposure.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryLedger.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/CalibrationDrift.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ChargeStopPlan.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ChargingSessionMeter.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ConsumptionCalculator.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergySnapshot.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTelemetryReader.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTrip.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTripHistoryStore.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/PlanDrift.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/Provenance.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/RouteGrade.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/SocRate.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/StateOfHealth.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/TelemetryEvidence.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/TripDetector.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/TripSample.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyAttribution.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModel.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModelStore.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModelTrainer.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/LeastSquares3.kt create mode 100644 EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/SocConsumptionModel.kt create mode 100644 EVHardware/lib/src/main/res/values-de/strings.xml create mode 100644 EVHardware/lib/src/main/res/values-es/strings.xml create mode 100644 EVHardware/lib/src/main/res/values-fr/strings.xml create mode 100644 EVHardware/lib/src/main/res/values-it/strings.xml create mode 100644 EVHardware/lib/src/main/res/values-pt/strings.xml create mode 100644 EVHardware/lib/src/main/res/values/strings.xml create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/CarPropertyEvidenceTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/FirmwareSupportTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/FirmwareWritePolicyTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/GlassEvidenceTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/GlassProbeTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/PhysicalButtonEventDecoderTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/StateChangeLogTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleSpeedEvidenceTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleSpeedScaleTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleWriteGateTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/VsmGlassTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/CatalogConsistencyTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/CatalogueDocTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/WeatherConditionsTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/diag/CrashLoggerTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/diag/PrivateBinTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/probe/RuntimeInterfaceProbeTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/saic/EvRoutPoiParcelTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavGuidanceReducerTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavLegChainTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavigationHandoffTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/saic/RadioFrequencyTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/saic/RadioToggleTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/saic/SaicChargingHistoryTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimatorTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ArrivalSocForecastTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryCapacityConfigTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryExposureTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryLedgerTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/CalibrationDriftTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ChargeStopPlanTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ChargingSessionMeterTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ConsumptionCalculatorTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/EnergyTelemetryTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/PlanDriftTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ProvenanceTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/RouteGradeTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/StateOfHealthTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/TelemetryEvidenceTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/TripDetectorTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/TripHistoryStoreTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/model/EnergyAttributionTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/model/EnergyModelTest.kt create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/model/SocConsumptionModelTest.kt create mode 100644 EVHardware/mise.toml create mode 100644 EVHardware/settings.gradle.kts create mode 100644 FIRMWARE.md create mode 100644 LICENSE create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/lint.xml create mode 100644 app/proguard-rules.pro create mode 100644 app/src/debug/java/com/evsuite/abrp/VhalProbe.java create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/evsuite/abrp/AbrpApi.java create mode 100644 app/src/main/java/com/evsuite/abrp/AbrpUploadService.java create mode 100644 app/src/main/java/com/evsuite/abrp/BootReceiver.java create mode 100644 app/src/main/java/com/evsuite/abrp/CarPropertyAdapter.java create mode 100644 app/src/main/java/com/evsuite/abrp/ChargingSignal.java create mode 100644 app/src/main/java/com/evsuite/abrp/ConfigImport.java create mode 100644 app/src/main/java/com/evsuite/abrp/HeadUnitWeather.java create mode 100644 app/src/main/java/com/evsuite/abrp/MainActivity.java create mode 100644 app/src/main/java/com/evsuite/abrp/QrCode.java create mode 100644 app/src/main/java/com/evsuite/abrp/SecurePrefs.java create mode 100644 app/src/main/java/com/evsuite/abrp/TelemetryPayload.java create mode 100644 app/src/main/java/com/evsuite/abrp/UploadCadence.java create mode 100644 app/src/main/java/com/evsuite/abrp/UploadLog.java create mode 100644 app/src/main/java/com/evsuite/abrp/UploadSettings.java create mode 100644 app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java create mode 100644 app/src/main/res/color/nav_tab_background.xml create mode 100644 app/src/main/res/color/nav_tab_foreground.xml create mode 100644 app/src/main/res/color/nav_tab_stroke.xml create mode 100644 app/src/main/res/drawable/about_logo_tile.xml create mode 100644 app/src/main/res/drawable/bg_topbar.xml create mode 100644 app/src/main/res/drawable/circle_indicator.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 app/src/main/res/drawable/ic_tab_abrp.xml create mode 100644 app/src/main/res/drawable/ic_tab_log.xml create mode 100644 app/src/main/res/drawable/ic_tab_service.xml create mode 100644 app/src/main/res/drawable/ic_tab_vehicle.xml create mode 100644 app/src/main/res/drawable/ic_tab_wifi.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/dialog_about.xml create mode 100644 app/src/main/res/layout/pane_abrp.xml create mode 100644 app/src/main/res/layout/pane_log.xml create mode 100644 app/src/main/res/layout/pane_service.xml create mode 100644 app/src/main/res/layout/pane_vehicle.xml create mode 100644 app/src/main/res/layout/pane_wifi.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles_app.xml create mode 100644 app/src/main/res/values/styles_ev.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/stable/java/com/evsuite/abrp/UpdateHook.java create mode 100644 app/src/test/java/com/evsuite/abrp/AbrpApiTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/ChargingSignalTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/ConfigImportTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/TelemetryPayloadTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/UploadCadenceTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/UploadLogTest.java create mode 100644 app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java create mode 100644 app/src/unstable/AndroidManifest.xml create mode 100644 app/src/unstable/java/com/evsuite/abrp/ApkSignature.java create mode 100644 app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java create mode 100644 app/src/unstable/java/com/evsuite/abrp/UpdateHook.java create mode 100644 build.gradle create mode 100644 docs/logo.svg create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 mise.toml create mode 100755 release-notes.sh create mode 100644 screenshots/evabrp1.png create mode 100644 screenshots/evabrp2.png create mode 100644 settings.gradle diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..226d77e --- /dev/null +++ b/.claudeignore @@ -0,0 +1,28 @@ +# Build output — regenerated, enormous, never worth reading. +build/ +app/build/ +.gradle/ +.idea/ +*.iml + +# Local environment, secrets and signing material. +local.properties +mise.toml +mise.local.toml +*.keystore +*.jks +*.p12 + +# Binary artefacts. +*.apk +*.aab +*.dex +*.so +*.png +*.jpg +*.webp +*.ttf + +# Generated reports. +app/build/reports/ +*.sarif diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..934595e --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties + +# mise : mise.toml est COMMITÉ (il décrit le build). Seules les surcharges +# locales, machine-spécifiques, sont ignorées. +mise.local.toml +.mise.local.toml + +# Signed build output. Rebuilt from source by ./gradlew assembleUnstableDebug and +# signed locally — a checked-in APK is stale the moment anything above it changes. +*.apk +*.idsig + +# Build dirs of the vendored EVHardware library. +/EVHardware/build +/EVHardware/.gradle +/EVHardware/**/build diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..02c4aa5 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9d26677 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ +# AGENTS.md — EVABRPUploader + +Context for AI agents and new contributors working in this repository. + +EVABRPUploader is part of **EVSuite**. The workspace `AGENTS.md` and normative +workspace `DESIGN.md` apply; this file contains only telemetry-specific additions. + +## What this is + +An Android Automotive app for the **MG4 (SAIC eh32)** that reads vehicle telemetry and +pushes it to A Better Route Planner. It runs on the car's head unit (AAOS 9, MT2712). + +Fork of Leon Kernan's `ABRP_Uploader`. See `LICENSE.md` — the licence status is +**unresolved** and that is a real constraint, not paperwork. + +## Non-negotiables + +1. **Read-only towards the car.** This app must never write a vehicle property. The + sibling EVProfile project writes settings and gates every write on 0 km/h; this one has + no business writing at all. A write path is a bug. +2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null` + means "could not read". Null fields are omitted from the payload. Sending 0 for SOC + tells ABRP the battery is empty and wrecks a live route plan. +3. **Fail closed.** Update origin, APK signature, unknown vehicle state — refuse rather + than assume. +4. **Never let a credential reach a URL, a log line, or a crash report.** +5. **AAOS 9 must stay stable.** A third-party app crashing the head unit is unacceptable. + Every car call goes through reflection with try/catch; keep it that way. +6. **Power matters.** The head unit is awake whenever the car is on. No wake locks, no + alarms. The GPS subscription and the upload tick both cost power — keep them in step. + +## Architecture + +``` +AbrpUploadService foreground service; scheduler thread does all IPC and HTTP +CarPropertyAdapter reflection bridge to android.car (not on the compile classpath) +TelemetryPayload pure JSON building — no Android types, unit-tested +UploadCadence when a tick actually uploads — pure, unit-tested +UploadSettings user cadence configuration +UploadLog last 20 attempts + derived service state — pure, unit-tested +SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext +AbrpApi HTTP; credentials in the POST body, never the query string +MainActivity configuration, service state, upload log +``` + +**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM; +anything inside the service effectively cannot be tested without a car. + +## Source sets + +| Set | Contents | Ships in | +|---|---|---| +| `main` | everything shared | all builds | +| `stable` | `UpdateHook` no-op | stable channel | +| `unstable` | updater policy code; trigger suspended during the suite audit | unstable channel | +| `debug` | `VhalProbe` — enumerates the whole VHAL | debug builds only | +| `testUnstable` | OTA policy tests | test only | + +The stable channel does not merely disable self-update — the updater is **not in the +APK**. + +## Firmware generations + +MG4 infotainment ships as SWI68 / SWI69 / SWI131 / SWI132 / SWI133 / SWI165, and they +differ. Property IDs confirmed on one generation are not universal. Anything vehicle-facing +must tolerate a property simply not existing. + +**Two read paths, split by whether the signal is firmware-agnostic:** + +| Signal | Read path | Firmwares | +|---|---|---| +| Speed, outside temp, park | `EVHardware` getters (`getVehicleSpeedKmh`, `getOutsideTempCelsius`, `isVehicleInPark`) | all six — EVHardware branches per generation internally | +| SOC, range | `CarPropertyAdapter` vendor IDs (`0x2160F404`, `0x2140F41C`) | **SWI68 only** | +| Charge rate/port, cabin temp | `CarPropertyAdapter` standard-AAOS IDs | wherever the VHAL implements them | + +`EVHardware.init()` is called in `AbrpUploadService.onCreate()` (idempotent); it detects +the generation and picks the right underlying API. Fields it covers therefore work on every +supported firmware. + +**Reference firmware: `SWI68-29958-1300R69`.** The vendor EV IDs still in +`CarPropertyAdapter` are supported only on SWI68 firmware. EVHardware +has **no EV-battery abstraction for any generation**, so SOC and range cannot go through it; +until those vendor IDs are confirmed on the other generations they stay SWI68-only, and on +another firmware they may silently return nothing (handled safely — the field is omitted, but +SOC/range would be missing). Re-confirm with `VhalProbe` before trusting the vendor IDs on any +other generation. + +## Working here + +```bash +mise run check # what CI runs: permission gate + lint + unit tests +mise run build +mise run logs # adb logcat, filtered to this app +``` + +Gradle needs JDK 17. If a build fails in `JdkImageTransform` complaining about `jlink`, +a stale Gradle daemon started by VS Code's Java extension is being reused — +`pkill -f GradleDaemon` and retry. + +## Things that will bite you + +- **`android.car` is not on the compile classpath.** Everything car-related is reflection. + R8 cannot see those uses, so `proguard-rules.pro` keeps them by name. +- **Unit tests get a stub `org.json`** from `android.jar` that returns null. The real + implementation is added as a test dependency; do not remove it. +- **`service_running` in preferences goes stale-true** after a force-kill. Use + `AbrpUploadService.isRunning()`. +- **Permission changes fail CI** until allowlisted with a justification. + +## Honesty requirements + +Most of this cannot be verified off a vehicle. When reporting work, state plainly what was +tested and what was not. "Unit tests pass, not tried on the car" is the expected and +acceptable answer. Do not imply road-testing that did not happen. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6ff0d94 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,290 @@ +# Changelog + +All notable changes to this project are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.2.2] - 2026-09-17 + +### Changed + +- Updated EVHardware to the shared `master` catalogue and telemetry implementation. + +## [2.2.1] - 2026-09-04 + +### Fixed + +- **Speed was uploaded 3.6× too high.** `PERF_VEHICLE_SPEED` reports km/h on SWI68, not the + metres per second AAOS specifies, and EVHardware converted it a second time. Every telemetry + upload from this generation therefore carried a speed 3.6 times the real one, and ABRP's + consumption and arrival models were fed it. Proven on SWI68-29958-1300R67 by two recordings of + a 2.4 km route that came out at 7.69 km and 8.46 km — 129 and 124 km/h average over a few + minutes of town driving. The conversion is now evidence-gated per firmware generation in + EVHardware. Telemetry already sent to ABRP is not corrected by this. + +## [2.2.0] - 2026-08-27 + +### Changed + +- Energy acquisition now consumes EVHardware's coherent `EnergySnapshot`; duplicate SOC, + range, power, temperature, odometer and charge property ids were removed from the app. +- Charging-session integration and tyre-pressure reads moved to EVHardware. The local + car-property adapter is now unused by production and supports debug diagnostics only. + +## [2.1.8] - 2026-08-26 + +### Fixed + +- **The weather answers arrived all along; they were being thrown away.** EVHardware read the + reply parcel one int too early — a nullable parcelable argument is written as a presence flag + and then the object, so every field landed in the next one's place, the payload's type tag + never matched, and a reading that had arrived was reported as unreadable. That is precisely + the `map=bound` with nothing behind it that 2.1.7 timed the query to explain. Tracking + EVHardware 1.6.0 fixes it, and `ext_temp` has a working source that does not depend on + catching a broadcast at the right moment. + +## [2.1.7] - 2026-08-26 + +### Fixed + +- **The weather broadcast is real but rare, and the app forgot it between restarts.** The action + is confirmed: the head unit's weather app sends `com.saicmotor.weather` when *it* refreshes, + not on any cadence this app can ask for. 2.1.6 listened and gave up between refreshes, and the + app is restarted every time the driver opens it to look at the log — which is the worst moment + to start waiting. The last reading is now kept across restarts for two hours, and the platform + is asked at start-up and on every temperature-less upload for a held broadcast, which costs + nothing and answers immediately if the weather app sends its update sticky. + +### Changed + +- **`map=bound` in 2.1.6 corrected the release note that shipped with it.** That release said the + map service never bound; the log showed the binder alive and the *query* returning nothing, + which is a different failure with a different fix. The map query is now timed, and the elapsed + time appears in the log — the query is bounded by a two-second wait inside EVHardware, so a + null returning at once was refused or answered undecodably while a null returning at the bound + is a service that did not answer in time. Nothing outside the library distinguishes them, and + guessing which one it was is what produced the wrong note. +- The broadcast reading now carries its age in the log (`bcast=Toulouse/12min`): where a value + came from does not say whether it still describes the day. + +## [2.1.6] - 2026-08-26 + +### Fixed + +- **`ext_temp` was still absent: the weather service 2.1.5 added never bound.** The log added in + that release is what showed it — every upload listed `ext_temp` among the omitted fields, with + no place name beside the fix. Meanwhile the head unit's own status bar shows a temperature + continuously, and it does not get it from that service: it gets it from a broadcast. The + uploader now listens for the same one (`com.saicmotor.weather`) and prefers it, falling back + to the map service query and, before either, to the vehicle sensor on a car that has one. + + The payload's shape is unverified, so it is read by searching for any key that names a + temperature at any depth rather than by a fixed layout, in either unit, whether the value + arrives as a number or as a string. A parser written against a guessed layout fails silently + and looks exactly like the bug it was meant to fix. + +### Added + +- **The temperature's source, on every log entry** — `[temp car]`, `[temp bcast:Toulouse]`, + `[temp map:...]`, or when nothing answered, why each one did not: `[temp none bcast=no + broadcast yet map=unbound]`. A payload that arrives but carries no readable temperature is + reported with a sample of what it did carry, which is what a fixed parser would have hidden. + +### Notes + +- Position is not the problem, and the log settled it: fixes arrive from the `fused` provider, + accurate to about a metre and seconds old, with the coordinates the car is actually at. An + address that looks wrong on ABRP is not coming from what this app sends. + +## [2.1.5] - 2026-08-26 + +### Fixed + +- **`ext_temp` reached ABRP as 0 degC, and 2.1.4 did not change that.** Guarding the value only + stopped the app sending a zero; ABRP shows 0 for an absent `ext_temp` too, so the symptom was + identical. The cause is the car: this VHAL does not implement `ENV_OUTSIDE_TEMPERATURE`, so + there was never a vehicle reading to correct. The outside temperature now comes from the head + unit's own weather service (`com.saicmotor.mapservice`, already bound by EVTasker through + `SaicWeather`), queried for the car's position and cached for 10 minutes — the query can reach + the network from inside the head unit and it blocks the upload thread while it does. The + vehicle sensor is still preferred whenever a car does implement it. +- **A GPS fix could be dropped for being stale when its age was never knowable.** 2.1.4 aged + fixes on the monotonic clock, but a provider that does not stamp `getElapsedRealtimeNanos()` + leaves it at zero, which dated every fix to the last boot and would have discarded all of + them — a staleness guard turning into a position blackout. An unstamped fix is now kept, and + a fix from a clock that jumped forward is no longer aged negatively. + +### Added + +- **The payload summary is in the app's Log tab, not only in logcat.** 2.1.4 put it where a car + with no adb attached cannot show it. Each attempt now carries the fields it sent and the ones + it left out, which is the only thing that separates "ABRP shows 0" from "the app sent 0". +- **Position diagnostics on each entry:** the fix's age, its accuracy, the provider that + supplied it, and the place name the weather service answered for. ABRP reverse-geocodes the + address it displays from the same coordinates, so a place name that disagrees with it means + the coordinates are wrong, and one that agrees means ABRP is showing something older. + +## [2.1.4] - 2026-08-25 + +### Fixed + +- **The car was reported where it used to be, not where it is.** A GPS fix had no expiry, so + `lastLocation` kept whatever it last held. Two paths turned that into a wrong position: + start-up seeded it from `getLastKnownLocation()` unconditionally — with a cold receiver that + is the *previous* drive's fix, uploaded stamped with the current time for the first minutes + of every trip — and once the receiver stopped delivering (garage, tunnel) the same fix was + resent forever. A fix is now dropped past `max(5 min, 3 x upload interval)`, measured on the + monotonic clock rather than the wall clock, which the head unit adjusts from GPS itself. + Position is then omitted and ABRP keeps its own last point instead of being moved backwards. +- **`ext_temp` could be sent as a freezing day the car never measured.** It was the only + temperature without a plausibility guard, while `cabin_temp`, `batt_temp` and `hvac_setpoint` + all had one. A VHAL that does not implement `ENV_OUTSIDE_TEMPERATURE` returns `0.0` rather + than throwing, so the read looked successful and `ext_temp: 0` went to ABRP's consumption + model. Now guarded like the others (-50..80 degC, exactly 0.0 excluded). A genuine 0 degC is + lost with it — the cheaper of the two errors, and the same trade-off already made three times. + +### Added + +- **A one-line summary of every upload in the log.** `TelemetryPayload.summarize()` renders + what actually went on the wire, derived from the JSON itself so it cannot drift from the + payload it describes, and names the fields that were left out. That is what separates a read + that failed from a read that returned something wrong — the question this release started from. + Dropped stale fixes are logged with their age. + +## [2.1.3] - 2026-08-25 + +### Removed + +- `android.car.permission.CAR_POWERTRAIN` — declared since the first release and never used. + The app reads no powertrain property: park state comes from the vendor gear services through + EVHardware, and nothing here touches the standard AAOS gear or ignition properties the + permission guards. Dropped from the manifest and from the security allowlist. + +## [2.1.2] - 2026-08-25 + +### Fixed + +- **The uploader starts on a car that has not granted position yet.** The service declares the + `location` foreground type, and since Android 14 every declared type is checked against the + permissions held at the moment it enters the foreground — so a fresh install, or a boot + before the app had ever been opened, killed the service in its own `onCreate` before the + first tick. The type is now claimed only while the permission behind it is held, and the + activity asks for the permission *before* starting the service rather than after. +- **Outside and cabin temperature reach ABRP again.** Both were declared and read since 2.1.0 + but never requested at runtime, so both properties answered as unreadable and were dropped + from every payload. +- **The service notification is visible.** `POST_NOTIFICATIONS` was declared and never asked + for; the only status the service has was being dropped silently on Android 13 and later. +- A permission granted while the service is already running now takes effect at once instead + of at the next boot. + +## [2.1.1] - 2026-08-23 + +### Fixed + +- Restored stable release builds by approving the read-only odometer, tyre-pressure and + battery-capacity permissions in the security allowlist. + +## [2.1.0] - 2026-08-23 + +### Added + +- The telemetry payload now carries every ABRP field this vehicle can actually answer: + pack temperature, usable capacity, state of energy, odometer, climate setpoint, the four + tyre pressures, and `kwh_charged` for the running charge session. `kwh_charged` has no + counter in the VHAL, so it is integrated from the instantaneous charge rate on every + sample tick — including the ticks the cadence policy throttles away — and resets when a + new session starts. +- Range falls back to the standard AAOS `RANGE_REMAINING` property when the SWI68 vendor + cluster answers nothing, so range now reaches ABRP on other firmware generations too. + +### Fixed + +- Three vehicle property IDs were off by one and had silently disabled their fields: the + charge-port read was the flap (`EV_CHARGE_PORT_OPEN`) rather than the cable + (`EV_CHARGE_PORT_CONNECTED`), and the cabin-temperature ID was not a VehiclePropertyIds + value at all, so `cabin_temp` had never been sent. + +## [2.0.1] - 2026-08-22 + +### Fixed + +- Telemetry no longer stops while the car is charging. Charge detection relied solely on + the standard AAOS charge-port property, which this VHAL need not implement: a plugged-in + car reading "unplugged" was treated as parked and idle and throttled to one sample every + 15 minutes, so ABRP never saw the SOC curve it needs to size a charging stop. The charge + rate and, failing that, a rising SOC on a parked car are now used as fallbacks. +- `power` is now signed ABRP's way — positive leaving the battery, negative charging. The + vehicle's charge rate uses the opposite convention and was being forwarded unchanged, so + ABRP saw a fast-charging car as discharging. + +## [2.0.0] - 2026-08-15 + +### ⚠️ Breaking — existing users must install once more + +- Renamed from MG4ABRPUploader to **EVABRPUploader**, and the application id changed from + `com.mg4.abrptelemetry` to **`com.evsuite.abrp`**. Android treats this as a different app, + so it does not update an existing install: it is added next to it and starts with no ABRP + credentials and no permissions. Re-enter your ABRP token here, confirm telemetry reaches + your account, and only then uninstall the old app. + +## [1.1.0] - 2026-08-10 + +### Changed + +- Reworked the main screen as three swipeable ABRP, Service and Log pages with a fixed, + high-contrast top navigation bar and consistent selected-state accessibility. +- Updated the shared EVHardware dependency to the current catalogue and diagnostics layer. + +## [1.0.3] - 2026-08-09 + +### Fixed + +- Corrected stable release packaging and metadata. + +## [1.0.2] - 2026-08-04 + +### Added + +- Add an in-app About surface with project information and a repository QR code. + +### Security + +- Keep unstable OTA downloads in private cache, validate every redirect, verify the APK + signing certificate, and require an explicit successful `pm install` result. +- Remove the public-download update path and its storage permission exposure. + +## [1.0.1] - 2026-08-03 + +### Changed + +- Prefill the ABRP API-key input with the public open-source telemetry key while + preserving any custom key already saved by the user. + +## [1.0.0] - 2026-08-02 + +### Added + +- API key and token inputs are masked by default, with a password visibility + toggle sized for in-car touch targets. +- CONTRIBUTING.md, DESIGN.md and this CHANGELOG. +- Security issue template. + +### Changed + +- Adopted the EVSuite design system. Colour, spacing, type and component styles now + come from shared tokens (`values/colors.xml`, `values/dimens.xml`, + `values/styles_ev.xml`), generated by `tools/sync-tokens.mjs` and specified in + [DESIGN.md](DESIGN.md). +- Theme is now `Theme.Material3.DayNight.NoActionBar`: the app follows the vehicle's + day/night setting, and the light palette is held to the same 7:1 contrast floor as + the dark one. +- `ev_outline` raised from `#4A525B` to `#7A8492`. The old value was 2.25:1 against + `ev_surface`, below the 3:1 floor for non-text UI, so the card border it was meant + to provide effectively was not there. +- Launcher icon replaced with the EVSuite adaptive icon: charcoal tile, white glyph + with a single red accent, product caption. Vector only — the five legacy density + bitmap buckets are gone. +- README restructured to the shared EVSuite skeleton; table of contents is now + generated by `tools/sync-docs.mjs`. +- Repaired bug/feature issue templates (duplicate YAML key, field ordering). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..05709da --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# Contributing + +This app runs on a car. That single fact drives everything below. + +## Ground rules + +1. **The app never writes to the vehicle.** It is read-only towards the car. A patch that + introduces a write will be rejected regardless of how useful the feature is. +2. **A property that cannot be read is omitted, never defaulted.** Sending 0 for an + unreadable battery level tells ABRP the car is empty and corrupts the user's route + plan. Nullable in, absent from the payload. +3. **Fail closed on anything security-shaped.** Unknown update origin, mismatched + signature, unreadable state: refuse, do not guess. +4. **Say what you did not verify.** Most of this code can only be fully tested on a + vehicle. "Builds and unit tests pass, not tried on the car" is a perfectly good PR + note. Silence implying it was road-tested is not. + +## Before opening a PR + +```bash +mise run check # permission gate + lint + unit tests +``` + +or, without mise: + +```bash +bash .github/security/check-permissions.sh +./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug +``` + +New behaviour needs a unit test. The pure-logic classes — `TelemetryPayload`, +`UploadCadence`, `UploadLog`, `OtaUpdater` — are deliberately free of Android types so +they can be tested on the JVM; put testable logic there rather than in the service. + +## Permissions + +Any new `uses-permission` fails CI until it is added to +`.github/security/permission-allowlist.txt` **with a justification comment**. This is +deliberate: an app on someone's car should not gain capabilities quietly. + +## Channels + +- `stable` — tagged releases, no self-update, no updater code in the APK. +- `unstable` — automated pre-releases that update themselves. + +If you touch the updater, remember it installs code on a vehicle: the origin allowlist and +the signature check are the whole defence. + +## Ways to help + +Not every useful contribution is code: + +- **Bug reports** — firmware generation, app version, what you did, what happened. +- **Feature requests** — describe the problem before the solution. +- **Documentation** — README, this guide, translations. +- **Pull requests** — see below. +- **Testing pre-releases** — install an `unstable` build and report what broke. +- **Sponsorship** — see below. + +## Contributing efficiently + +Maintainer time and AI quota are the scarce resources here, ideas are not. If you have +access to Claude Opus or another capable coding model, a finished pull request is worth +much more than a feature request: someone still has to design, write, test and verify the +request, and that someone has a limited quota too. + +A pull request that lands quickly usually carries: + +- The problem, in one or two sentences. +- The proposed solution, and what you rejected. +- The implementation, scoped to one concern. +- Tests — `mise run check` passes. +- Documentation updated: README, CHANGELOG, this guide where relevant. + +Generate the change locally with whatever model you have, then read every line yourself +before opening the PR. You are the author, not the model. Unreviewed generated code on a +path that reaches the vehicle will be sent back. + +## Sponsorship + +Maintaining EVSuite costs development time, test hardware and AI usage. If it is useful in +your daily driving, consider sponsoring through +[GitHub Sponsors](https://github.com/sponsors/malys). Sponsorship covers those costs and +gets fixes and features out faster. + +## Commit messages + +Explain **why**, not what — the diff already says what. If you fixed something subtle, +say what the failure looked like, so the next person recognises it. + +## Licensing + +Read [`LICENSE.md`](LICENSE.md) first. The project's licence status is unresolved because +the upstream fork source has none. Contributions are accepted on the understanding that +they are offered under MIT once that is settled. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..63ddce6 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,463 @@ +--- +name: EVSuite +description: One design system for the EVSuite — a driver-facing instrument surface, not a phone UI parked in a car. +colors: + background: "#F2F4F7" + background-dark: "#0B0D0F" + surface: "#FFFFFF" + surface-dark: "#15181C" + surface-raised: "#E4E8ED" + surface-raised-dark: "#272C33" + outline: "#737F8F" + outline-dark: "#7A8492" + text-primary: "#101418" + text-primary-dark: "#F4F7FA" + text-secondary: "#414B55" + text-secondary-dark: "#C6CFD8" + accent: "#044C63" + accent-dark: "#7FD4FA" + on-accent: "#FFFFFF" + on-accent-dark: "#04121A" + ok: "#09501F" + ok-dark: "#8FE6A6" + warn: "#6B3A00" + warn-dark: "#FFC978" + error: "#8F0F18" + error-dark: "#FFACAC" + icon-tile: "#2A2A2A" + icon-glyph: "#F4F7FA" + icon-mark: "#E0424C" +typography: + display: + fontFamily: "Roboto, sans-serif" + fontSize: "32sp" + fontWeight: 500 + title: + fontFamily: "Roboto, sans-serif" + fontSize: "26sp" + fontWeight: 500 + body: + fontFamily: "Roboto, sans-serif" + fontSize: "20sp" + fontWeight: 400 + secondary: + fontFamily: "Roboto, sans-serif" + fontSize: "17sp" + fontWeight: 400 + label: + fontFamily: "Roboto, sans-serif" + fontSize: "16sp" + fontWeight: 700 + letterSpacing: "0.12" +rounded: + md: "12dp" +spacing: + xs: "4dp" + sm: "8dp" + md: "16dp" + lg: "24dp" + touch: "72dp" +components: + button-primary: + backgroundColor: "{colors.accent}" + textColor: "{colors.on-accent}" + typography: "{typography.body}" + rounded: "{rounded.md}" + height: "{spacing.touch}" + padding: "0 24dp" + button-outlined: + backgroundColor: "transparent" + textColor: "{colors.accent}" + typography: "{typography.body}" + rounded: "{rounded.md}" + height: "{spacing.touch}" + padding: "0 24dp" + card: + backgroundColor: "{colors.surface}" + textColor: "{colors.text-primary}" + rounded: "{rounded.md}" + padding: "16dp" + section-header: + textColor: "{colors.accent}" + typography: "{typography.label}" +--- + +# Design System: EVSuite + +Applies to **EVProfile**, **EVTasker**, **EVABRPUploader**, **EVChargePilot**, +**EVLauncher**, **EVSwipe** and the shared **EVHardware** library. This file is the normative +source; a copy lives at the root of every suite repository and they are kept identical. + +## Overview + +**Creative North Star: "The Instrument Binnacle"** + +These are not phone apps that happen to run in a car. They are extensions of the +instrument panel, and they are judged the way a gauge is judged: can the driver read it +in one glance, at 70 cm, through a windscreen reflection, without thinking about it. Every +decision below follows from that. The suite is quiet, dense with information but never +busy, and it never asks for attention it hasn't earned — an MG4 app that animates, +celebrates, or decorates is competing with the road. + +The visual language is flat, high-contrast and typographic. Depth comes from tonal +layering and a hairline outline, never from shadow. Colour carries meaning — accent, +ok, warn, error — and is never decorative. The one place personality is allowed is the +launcher icon, which is why the icon system gets its own section. + +The suite is deliberately unlike consumer Android: bigger type, bigger targets, fewer +elements per screen, no gesture-only affordances, no ambient motion. Where Material 3 +defaults conflict with legibility in daylight, legibility wins and the override is +commented in the theme. + +**Key characteristics** + +- Every text colour clears **7:1 (WCAG AAA)** on every surface it can land on. 4.5:1 is + not enough once sunlight hits the glass. +- Minimum touch target **72dp**, everywhere, including dialog buttons and switches. +- Minimum type size **16sp**. There is no "small" tier. +- Flat surfaces, tonal layering, one hairline outline. No shadows, no gradients. +- Colour is never the only carrier of meaning; a text label always accompanies it. +- Landscape-first. Portrait is not a supported orientation on the head unit. + +## Colors + +A neutral blue-grey ladder carrying one cyan accent and three status hues. The suite is +**DayNight**: `values/` holds the light palette, `values-night/` the dark one, and both +are held to the same 7:1 floor. Token names are identical in both files — a layout never +references a light or dark colour, only a role. + +Every ratio below is measured, not estimated. Re-run the check after any change: +`node tools/contrast.mjs` (see *Do's and Don'ts*). + +### Neutral + +| Token | Light | Dark | Role | +|---|---|---|---| +| `ev_background` | `#F2F4F7` | `#0B0D0F` | The window. Never holds content directly. | +| `ev_surface` | `#FFFFFF` | `#15181C` | Cards, panes, list rows. | +| `ev_surface_raised` | `#E4E8ED` | `#272C33` | Buttons, chips, selected rows, dialogs. | +| `ev_outline` | `#737F8F` | `#7A8492` | Hairline on every card. 4.07:1 / 4.70:1. | +| `ev_text_primary` | `#101418` | `#F4F7FA` | 18.5:1 / 16.6:1 on surface. | +| `ev_text_secondary` | `#414B55` | `#C6CFD8` | 7.2:1 worst case (on `surface_raised`). | + +### Primary + +- **Instrument Cyan** (`ev_accent`, `#044C63` light / `#7FD4FA` dark): section headers, + selected state, links, the active value in a control. 7.7:1 worst case in light, 10.8:1 + in dark. Light mode uses a deep cyan rather than tinting the dark one — a pale cyan on + white is 1.5:1 and disappears entirely in daylight. +- **On-accent** (`ev_on_accent`, `#FFFFFF` / `#04121A`): the only colour permitted on a + filled accent surface. + +### Status + +Never used alone. Each is paired with a text label, because a driver glancing sideways +reads shape and text faster than hue, and because ~8% of male drivers cannot separate +these three by colour at all. + +- **OK** (`ev_ok`, `#09501F` / `#8FE6A6`) — the rule ran, the write landed, the upload succeeded. +- **Warn / refused** (`ev_warn`, `#6B3A00` / `#FFC978`) — the speed gate refused a write, + a deferred action is queued, firmware support is unknown. +- **Error** (`ev_error`, `#8F0F18` / `#FFACAC`) — the operation failed. + +### Pressed state + +- `ev_pressed` (`#14000000` / `#1FFFFFFF`) and `ev_pressed_strong` (`#29000000` / + `#33FFFFFF`). Alpha overlays on the surface beneath, not a separate hue, so a pressed + card still reads as the same card. + +### Named rules + +**The Daylight Rule.** 7:1 or it does not ship. AA (4.5:1) is a phone-in-a-hand +standard; the MG4 screen is read through a reflection with the sun behind the driver, and +4.5:1 collapses to unreadable there. This is the single constraint that has forced the +most rewrites in this codebase — `#9EA6AE` secondary text (6:1) and the original +`#4A525B` outline (2.25:1) both shipped and both had to be replaced. + +**The Meaning Rule.** A colour outside the neutral ladder must encode state. If it is +there to look nice, delete it. This is why the suite has one accent and not a palette. + +**The Role Rule.** Layouts reference roles (`ev_surface`), never values and never +theme-specific names. A layout that needs to know whether it is day or night is a bug. + +## Typography + +**Family:** Roboto — the AAOS 9 system face. No bundled fonts: an APK on a vehicle earns +every kilobyte, and a missing glyph in a six-locale app is a worse failure than a generic face. + +**Character:** Neutral to the point of invisibility. The type is a readout, not a voice. +Weight and size carry hierarchy; italics and letterspacing are used once each, on purpose. + +### Hierarchy + +| Role | Size | Weight | Purpose | +|---|---|---|---| +| **Display** (`text_display`) | 32sp | Medium | A single number or word that is the whole point of the screen — a battery percentage, a drawer title. At most one per screen. | +| **Title** (`text_title`) | 26sp | Medium | Screen and card titles. | +| **Body** (`text_body`) | 20sp | Regular | Everything the driver actually reads. Also the size of every button label. | +| **Secondary** (`text_secondary`) | 17sp | Regular | Supporting detail, timestamps, units. | +| **Label** (`text_label`) | 16sp | Bold, `0.12` tracking | Section headers, in accent, uppercase-adjacent. The floor. | + +### Named rules + +**The 16sp Floor.** Nothing renders below 16sp. Not captions, not units, not footnotes. +Below that, contrast stops compensating for reflection and viewing distance, and the +information may as well not be on screen. If content does not fit at 16sp, there is too +much content. + +**The One Display Rule.** One `text_display` element per screen. Two competing 32sp +numbers is two glances, and a glance is the budget. + +## Layout + +**Target.** MG4 Electric head unit: 12.8" HD centre display, AAOS 9 on MT2712, landscape, +viewed at ~70 cm. DPI is user-adjustable per app on this platform, so everything is +expressed in `dp`/`sp` and nothing assumes a pixel size. Portrait is not supported and is +not laid out for. + +**Spacing scale.** `4 / 8 / 16 / 24dp` (`spacing_xs` … `spacing_lg`). Screen edges get +`spacing_lg`. Nothing between two rows is smaller than `spacing_sm` — tighter than that +and two targets read as one. + +**Touch.** `touch_target` = **72dp** minimum for anything tappable, including dialog +buttons, switches and list rows. Android's 48dp guidance assumes a phone held still in a +hand; this screen is at arm's length in a moving vehicle. Adjacent targets are separated +by at least `spacing_sm`, so a missed tap is a miss, not a wrong action. + +**Two-pane landscape.** Wide screens use a list/detail split rather than a stack — the +horizontal space exists, and a driver who has to navigate back to see context has lost +the context. `pane_list_width` = 380dp (wide enough for a full rule name), `pane_rail_width` += 300dp for label-only rails. The detail pane takes the remainder and is the primary area. + +**Grids.** Tile grids compute their column count from `catalog_tile_min_width` (240dp) +rather than hardcoding a number, so a DPI change re-flows instead of clipping. Tiles are +`catalog_tile_height` = 128dp — twice the touch minimum, so a three-line label plus a +qualifier fits without pushing the next row. + +**Density.** Fewer, larger elements. If a screen needs a scroll before the fifth item, the +screen is wrong, not the scroll. + +**App-local dimensions are allowed** where a surface genuinely differs (launcher tiles, +settings margins) — but they are declared in the app's `dimens.xml` with a comment saying +why, and they never redefine a suite token. + +## Elevation & Depth + +**There are no shadows.** Not one, in any app. A shadow on this display is a grey smear +that costs a composite pass and reads as dirt under sunlight. Depth is tonal: +`background` → `surface` → `surface_raised`, three steps, plus a 1dp `ev_outline` +hairline on every card. + +The hairline is not decoration. `surface` and `surface_raised` differ by roughly one +tonal step; in direct sun that difference vanishes and the card boundary goes with it. +The outline is what survives. + +State is expressed by the pressed overlay, never by lift. + +## Shapes + +One radius: **12dp** (`ev_corner`), on cards, buttons, chips, tiles and dialogs. Not two, +not a scale. A single radius is what makes five apps by different hands read as one suite, +and it is the cheapest consistency there is. + +Borders are 1dp `ev_outline`. No dividers where a gap will do — `spacing_md` between two +cards separates them better than a line, and costs no ink. + +The launcher icon is the one place a different silhouette appears: a squircle tile, which +is the platform's shape, not ours. + +## Components + +Component styles are named identically in every app so a screen can be moved between +repositories without a rename: `Theme.EV`, `Widget.EV.*`, `Text.EV.*`, +`ThemeOverlay.EV.*`. + +### Buttons +- **Shape:** 12dp radius. +- **Primary** (`Widget.EV.Button`): filled accent, `on_accent` label, `text_body` (20sp), + min height 72dp, `spacing_lg` horizontal padding. +- **Outlined** (`Widget.EV.Button.Outlined`): 1dp accent border, accent label, same metrics. +- **Dialog** (`Widget.EV.DialogButton`): text button, but pulled up to the same 72dp + target and 140dp min width. Dialog buttons are the smallest thing in any app and the + most likely to be mis-tapped. +- **Pressed:** `ev_pressed_strong` overlay. No ripple travel, no scale, no lift. + +### Cards / containers +- 12dp radius, `ev_surface`, 1dp `ev_outline`, `spacing_md` internal padding. +- Selected state: background steps to `ev_surface_raised` **and** the leading label + turns accent. Two signals, because one is a colour. + +### Switches +- `Widget.EV.Switch`, 72dp min height, `text_body` label. A switch that needs a precise + tap is a switch that gets missed at 90 km/h. + +### Section headers +- `Text.EV.SectionHeader`: 16sp, bold, `0.12` tracking, accent. The only tracked type in + the suite; the tracking is what lets it work at the size floor without shouting. + +### Dialogs +- `ThemeOverlay.EV.Dialog` on `ev_surface_raised`. Material's default dialog palette is + a grey-mauve tuned for indoors and it is the first thing to wash out; every app overrides it. +- A picker that fills the screen (`Theme.EV.Picker`) is not floating and does not dim: + `windowIsFloating=false`, `backgroundDimEnabled=false`. In landscape, a bottom sheet + leaves a third of the height, which means one column and a scrollbar by the fifth entry. + There is nothing behind a picker worth seeing. +- A confirmation or warning with explanatory content is a **dedicated full-screen + window, never a popup**. Put its explicit confirm and cancel actions together at the + upper-right, consistently with the suite's editor windows, keep both at the 72dp touch + minimum, and let the content use all remaining space. Reserve a compact dialog for a + short, single-decision interruption whose complete message remains immediately readable + at 70 cm. + +### Lists +- Row height ≥ 72dp, `ev_surface`, `spacing_sm` gaps. No swipe-to-delete: a gesture with + no visible affordance and no undo does not belong in a vehicle. Destructive actions are + a button plus a confirmation. + +## Iconography + +The launcher icon is the suite's signature and the one place it is allowed to have fun. + +**The system.** Adaptive icon, 108dp, safe zone 66dp centred. Three fixed elements: + +1. **Tile** — flat `#2A2A2A` charcoal. No gradient. Identical in every app, and identical + in light and dark: this is the tile EVProfile has always had, and it is what makes a + row of suite apps read as a row. +2. **Glyph** — `#F4F7FA`, geometric, 5dp stroke, round caps and joins, with exactly one + `#E0424C` red element as the accent (3.45:1 on the tile — it is a mark, not text). + The glyph says what the app does at 48px. EVProfile's glyph is the `MG⁴` lockup: it + is the flagship, so it wears the suite mark. +3. **Caption** — the product word beneath the glyph, `#C6CFD8`, small caps, `0.18` tracking. + +**The glyphs** + +| App | Glyph | +|---|---| +| EVProfile | `MG⁴` lockup — white letters, red superscript 4 | +| EVTasker | A branch: one input, a condition, two outcomes | +| EVABRPUploader | An upward arrow leaving two broadcast arcs | +| EVChargePilot | A battery crossed by a measured energy trace | +| EVLauncher | A four-tile grid, one tile red | +| EVSwipe | Two chevrons and a swipe trail | + +**Rules.** Vector (`VectorDrawable`) only — the same file serves every density and costs +one file instead of five PNGs. Every icon ships a `` layer for themed icons. +No photographic content, no bevel, no drop shadow, no stock clipart. If a glyph is not +legible at 48px in greyscale, it is the wrong glyph. + +## Nomenclature + +**Product names** are `EV` — space, title case: *EVProfile*, *EVTasker*, +*EVABRPUploader*, *EVLauncher*, *EVSwipe*. This is what +`app_name` holds and what the driver sees under the icon. + +**Repositories, modules and directories** are `EV` — no space: `EVProfile`, +`EVTasker`, `EVABRPUploader`, `EVChargePilot`, `EVLauncher`, `EVSwipe`, `EVHardware`. + +**Package ids** are `com.evsuite.`; a family shares a segment +(`com.evsuite.launcher`, `com.evsuite.swipe`). + +**Resources** are `snake_case` with a suite prefix when the token is shared +(`ev_surface`, `ev_corner`) and unprefixed when app-local (`catalog_tile_height`). +Styles are `Theme.EV`, `Widget.EV.`, `Text.EV.`. + +**The suite** is *EVSuite* — one word, when referring to all of it. + +## Documentation + +Every repository carries the same files, in the same order, with the same headings. + +| File | Contents | +|---|---| +| `README.md` | The one below. English. | +| `DESIGN.md` | This file, verbatim. | +| `AGENTS.md` | Repo-specific agent context. | +| `CONTRIBUTING.md` | How to build, test, submit. | +| `SECURITY.md` | Threat model, disclosure. | +| `DISCLAIMER.md` | Vehicle-safety disclaimer. | +| `CHANGELOG.md` | Keep a Changelog format. | +| `LICENSE.md` | Licence text. | + +**README skeleton** — sections in this order, omitting any that do not apply: + +``` +# EV + + +## Contents +## Screenshots +## Overview +## Features +## Requirements +## How it works + … app-specific detail sections belong here, and only here +## Install +## Configuration +## Building +## Project layout +## Project documents +## Security +## Contributing +## Legal +``` + +The `## Contents` table of contents is generated from the headings, not hand-written: +`node tools/sync-docs.mjs`. A hand-written TOC goes stale the first time a heading +is renamed, and a dead anchor in a README is a small lie about the project. + +Translations live in `README..md` and are linked from the top of the English +README — never interleaved into it. A bilingual README doubles the size of every future edit. + +## Constraint history + +Why the rules above are the shape they are. Each of these was paid for. + +- **7:1, not 4.5:1.** Secondary text shipped at `#9EA6AE` (6:1) and was unreadable in + daylight. The floor moved to AAA and the whole ladder was re-derived. +- **The outline exists because tonal layering alone failed.** `surface` and + `surface_raised` are one step apart and merge under sun. The border was added — then had + to be raised from `#4A525B` (2.25:1) to `#7A8492` (4.70:1), because a border below the + 3:1 non-text floor is a border that isn't there. +- **72dp, not 48dp.** Google's target assumes a hand-held phone. Missed taps on a moving + vehicle drove it to 72dp, and dialog buttons and switches were the last holdouts. +- **16sp floor.** Anything smaller stopped being legible at 70 cm through a reflection. +- **Full-screen pickers.** A bottom sheet in landscape left a third of the height: one + column, scrolling by the fifth entry. The picker became a full-screen non-floating window. +- **No shadows.** Cost a composite pass on MT2712 and read as smudge in sun. +- **Dark-first, DayNight second.** The suite ran dark-only for a long time because a light + background facing the driver at night is glare. It is now DayNight across all five apps + by explicit decision — which means the light palette carries the same 7:1 obligation + the dark one always had, and the pale dark-mode accent could not simply be reused. +- **Colour never carries meaning alone.** Verdict rows show a word as well as a hue. +- **Vector icons, no PNG sets.** Five density buckets per app, for an icon, on a vehicle + where APK size is a stability concern. +- **AAOS 9, MT2712, must not destabilise the vehicle.** Everything above is downstream of + this. When a design choice and stability disagree, stability wins and the design choice + gets a comment explaining what it gave up. + +## Do's and Don'ts + +### Do +- **Do** verify contrast numerically before committing a colour. Every ratio in this file + is measured; new ones must be too. +- **Do** put the reason in the resource file. Every override of a Material default in this + suite carries a comment saying what would break without it. That comment is why the rule + survives the next refactor. +- **Do** reference roles (`@color/ev_surface`, `@dimen/touch_target`), never literals. + A hex value in a layout is a bug. +- **Do** give every state two signals — colour plus text, or colour plus position. +- **Do** design landscape first, and check that nothing needs a scroll before the fifth item. +- **Do** name new resources with the suite prefix if another app could want them. + +### Don't +- **Don't** add a shadow, gradient, or elevation. Depth is tonal. +- **Don't** introduce a second accent hue. One accent, three status colours, that is the + whole palette. +- **Don't** ship type below 16sp or a target below 72dp, including in dialogs. +- **Don't** use `Theme.Material3.Dark` or `.Light` directly — always `.DayNight`, so both + palettes stay wired. +- **Don't** rely on a gesture with no button fallback. No swipe-to-delete, no long-press-only. +- **Don't** animate anything ambient. Motion is confirmation of a touch, or it is absent. +- **Don't** put a light background behind a full-screen surface at night without checking + it against the dark palette — the driver's night vision is a safety property. +- **Don't** hardcode a pixel size or assume a DPI. This platform lets the user change it. diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 0000000..e7b5a21 --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,38 @@ +# Disclaimer — no warranty, no liability + +**Use this software entirely at your own risk.** + +This project is provided **"as is"**, without warranty of any kind, express or implied, +including but not limited to the warranties of merchantability, fitness for a particular +purpose and non-infringement. In no event shall the authors or contributors be liable for +any claim, damages or other liability, whether in an action of contract, tort or +otherwise, arising from, out of or in connection with the software or its use. + +## What that means concretely + +- The app runs on a **vehicle**. Installing third-party software on a car is your + decision and your responsibility, including any effect on the head unit's stability, + your warranty, your insurance, or your vehicle's roadworthiness. +- **Do not operate the app while driving.** Configure it parked. Nothing in it should + require attention on the move, and looking at a screen instead of the road is on you. +- Telemetry may be **wrong, delayed or missing**. Never rely on it for range or charging + decisions where being wrong matters — check the car's own instruments. +- The **unstable channel is untested on vehicles** by definition. It builds automatically + from `main`; its OTA trigger is suspended during the suite safety and legal audit. +- Vehicle data is uploaded to **A Better Route Planner**, a third-party service governed + by its own terms and privacy policy. This project has no control over what happens to + data once sent. +- Reading vehicle properties uses **undocumented runtime interfaces**. They can change or + disappear with any firmware update. + +## Not affiliated + +This project is **not affiliated with, endorsed by, or supported by** SAIC Motor, MG +Motor, Iternio (A Better Route Planner), or Google. MG, MG4 and related names and logos are +trademarks of their respective owners. They are used solely to identify compatibility with +certain vehicles; no official origin, certification or approval is claimed. + +## Contributors + +Contributors provide their work on the same terms: no warranty, and no liability for how +anyone uses it. diff --git a/EVHardware/.gitignore b/EVHardware/.gitignore new file mode 100644 index 0000000..ce47397 --- /dev/null +++ b/EVHardware/.gitignore @@ -0,0 +1,24 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build +/app/build +/captures +.externalNativeBuild +.cxx +local.properties + +# Surcharge locale de mise (machine-spécifique) — mise.toml, lui, est commité. +mise.local.toml +.mise.local.toml + +# Secrets de signature : jamais dans le dépôt. +keystore.properties +*.keystore +*.jks + +# Android library module build output +lib/build/ +build/ diff --git a/EVHardware/AGENTS.md b/EVHardware/AGENTS.md new file mode 100644 index 0000000..778f087 --- /dev/null +++ b/EVHardware/AGENTS.md @@ -0,0 +1,65 @@ +# AGENTS.md — EVHardware + +Shared vehicle-access library for the SAIC MG4 (AAOS 9, MT2712), consumed by EVProfile, +EVTasker, EVABRPUploader and EVChargePilot via git submodule + Gradle subproject. + +EVHardware is the shared vehicle layer for **EVSuite**. The workspace `AGENTS.md` and +normative workspace `DESIGN.md` apply; this file defines library-specific invariants. + +Commit author: malys.training@gmail.com + +License exception: EVHardware's own sources use PolyForm Noncommercial 1.0.0, not the +workspace MIT default. + +## Why this exists + +One implementation of the reflection hardware layer, the 0 km/h gate, the models, and the +condition/action catalogue — so the apps do not each re-derive vehicle access and drift. +The apps are thin; the vehicle lives here. + +## Non-negotiables + +- **The 0 km/h gate is here.** `VehicleWriteGate` decides every vehicle-setting write inside + the low-level write primitives. Every setter carries `@RequiresStandstill`; coverage keeps + the set honest. Unknown or negative speed fails closed, and a moving reading is never + rescued by anything. + The threshold is fixed at a *readable* 0 km/h, with no caller override and no park rescue. +- **Per-firmware routing, never universal.** SWI68/69/131/132/133/165 differ; dispatch + through `FirmwareInfo`. Any new vehicle call must branch per generation. +- **Unreadable ≠ a value.** Getters return null / -1 when the layer is not ready or the + property is absent; callers must treat that as "unknown", never as 0/false. +- **Energy semantics are shared.** Property ids, units, sign convention, physical validation, + fallback order, trip integration and charging-session integration belong in `telemetry/`. + Consumers own their UI, sampling cadence and network payload only. +- **`@SupportedOn` is the source of truth** for `docs/firmware-matrix.md` (generated, never + hand-edited) and for consumers' runtime filters. A vehicle catalogue entry without it + fails the tests. +- **Never write an unverified AOSP id.** The climate and window ids currently known are + used for reads only. Writes go through `saic.*`, with every constant documented and + validated per supported firmware generation. +- **English only** — code, comments, commits, docs. User strings in `values/` (English) + + `values-fr/`. + +## Consuming it + +Submodule at `./EVHardware`; `settings.gradle.kts` includes `EVHardware/lib` as +`:evhardware`; app depends on `project(":evhardware")`. The library declares no +permissions and no `sharedUserId` — vehicle-write capability is the app's platform-signing +decision, not the library's. + +**Every consuming app tracks the HEAD of `master`.** Never pin an older commit, and never +commit on a detached submodule HEAD: the catalogue is shared, so an entry removed here must +disappear from every app at once, and two apps on two lines of this library means the same +change written twice and reconciled by hand afterwards. Land library work in the standalone +checkout, push it, then move each app's pointer forward and run that app's tests. + +## Reflection + R8 + +All vehicle access reflects `android.car` (absent from the compile SDK). `consumer-rules.pro` +keeps the reflected names (android.car / SAIC / models / catalogue enums / `@SupportedOn`) +so a consumer's R8 does not strip them. Verify a minified consumer on a vehicle. + +## Build + +`mise run test | build | check`. JDK 17. Tests are JVM-only (no emulator): gate decision, +firmware annotations, catalogue consistency, matrix generation. diff --git a/EVHardware/CHANGELOG.md b/EVHardware/CHANGELOG.md new file mode 100644 index 0000000..61e37c2 --- /dev/null +++ b/EVHardware/CHANGELOG.md @@ -0,0 +1,947 @@ +# Changelog + +All notable changes to this project are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **A probe that says when it was never able to ask.** `RuntimeInterfaceProbe.adapterClientMap()` + swept nineteen service codes and recorded ABSENT for every one of them on a firmware where + `sCarAdapter` is never assigned — nineteen answers to a question that never reached a binder, + and RI-002 was nearly closed on them. `EVHardware.hasCarAdapterClient()` is checked once before + the sweep and every swept entry now carries the caveat on itself rather than in the bundle + preamble, because a later session reads one entry and not the header. + +- **`SocConsumptionFitter.describe()` tells the two refusals apart.** Too few segments and a speed + span too narrow to separate rolling from aero both answer `INSUFFICIENT_SAMPLES`, and the two + ask the driver for opposite things — more kilometres, or one motorway leg. A third cause was + silent altogether: a trip skipped for a speed conversion no longer believed leaves no trace, and + a history of them looks exactly like a history of short drives. One diagnostic line now states + used-trips, segments and speed span against their thresholds. Diagnostic only — it runs the + collection pass a second time, so it belongs in a probe and not on a screen. + +- **The pack's health, as a ratio rather than a reading.** `INFO_EV_BATTERY_CAPACITY`, + `EV_BATTERY_LEVEL` and the `EV_CURRENT_BATTERY_CAPACITY` candidate are declared and never + published on SWI68 — null in all 612 snapshots of the 2026-09-13 drive — so the capacity behind + every kWh figure in EVSuite has been a specification sheet the driver retypes. It does not have + to be: the energy that left the pack, over the charge it cost, *is* a capacity, and both halves + are now recorded. `BatteryLedgerStore` keeps the entries a trip cannot — the parking, the + standing, the charge between two drives — and `BatteryLedgerRecorder` turns the 1 Hz sample + stream into one entry per point of charge, change of charging state, or quarter of an hour, + carrying the car's own kWh counters (`SaicCharging` transactions 75 and 77), this app's running + integral of `BMS_PACK_VOL × BMS_PACK_CRNT`, the odometer and the outside temperature. + `StateOfHealthEstimator` assembles the discharges between two charges and reports the median of + at least six of them, with the source named on the estimate and the two sources never averaged + together. A charge step on this car is about one point, so a 25-point window carries ±4 % before + anything else goes wrong and a year of real degradation is two or three points: one window is + never a health figure, the band never claims to be tighter than the quantisation allows, and the + figure worth reading is the trend, which cancels whatever node the energy was counted at. Cold + windows are excluded and counted rather than believed. Every output is `ESTIMATED` with its band. + +- **When the charge gauge last had anything to anchor itself on.** `CalibrationDrift` reads the + same ledger for a different question: how long since the pack saw a full charge, how many + equivalent cycles since, and whether any charge step went unexplained — a car that stood still, + drove nowhere and woke up several points lower did not consume that charge, the BMS re-estimated + it. The odometer has to read identical at both ends for a step to count at all, and a + self-discharge allowance keeps a fortnight of standing still from being reported as a fault. + It notices; it changes nothing, and no figure is corrected against an observed step. + +- **What a pack has been subjected to.** `BatteryExposure` reports hours above 80 % and below + 10 % of charge (interpolated across each segment, so a night that ended at 85 % counts its hours + where they were spent), the mean charge, equivalent full cycles, kilometres per cycle, and one + row per charge session with its mean and peak points per hour — the rate being what separates a + wall box from a fast charger on a car that publishes no charger type. + +- **The head unit can now be asked what it publishes.** `RuntimeInterfaceProbe` walks the + adapter's service codes and the vehicle hub's service names, reads back the interface + descriptor of every binder that answers, and records one of three verdicts per interface: + absent, reached-and-refused, or answered. The distinction is the whole point. Half the `RI-` + questions are currently open because nobody knows whether an interface is missing from this + head unit or merely closed to an unprivileged caller, and those two answers lead to opposite + decisions. Reading a binder's own descriptor is not a call into it, so nothing of unknown + meaning is invoked, and no setter is referenced anywhere in the file — several of these + binders would move a car. Typed reads follow only for the two bind points already known: the + vehicle option getters and the head unit's power state. The screen sub-service is bound but + never read: it publishes no getter at all, and guessing a code to find one is exactly what + this file exists not to do. There is a second hub behind engineering mode, mapped the same + way, and the survey asks it for the four part numbers the head unit states about itself — + identity the vehicle asserts, where the generation label is something the project inferred. + It also reads the second speed source, which reports a float where the source the write gate + trusts is integral. Nothing in production calls any of it; it exists to be run once from an + unstable capture and turned into answers. + +- **A leg is not over on one reading.** `NavLegChain` needs three consecutive answers of "not + guiding" before it treats a leg as finished. The drive of 2026-09-09 16:34 is why: a route + handed over through `startNavFromEVRout` guided for twenty-five minutes while the adapter's + remaining distance went to nothing seven minutes in and its notification listener heard nothing + at all. Whether the guiding flag is as shaky as its siblings is not yet known, and the cost of + being wrong is a car sent onward from a charger it is still driving to. Three readings cost + seconds; a real arrival stays false for the rest of the day. + +- **A trip can now be driven one leg at a time.** `NavLegChain` holds the points of a plan and + hands the next one over when the current one is done — done being the navigation app's own + `isMapNavigating` flag going false after having been true. It exists because the only call ever + seen to *start* guidance on this head unit, `goTo`, carries a single point: a plan with a + charging stop on the way has to be sent as the stop, then the destination. Pure and + Android-free, so the whole of its behaviour is proven on the JVM rather than on a road. Its + ceiling is written down in its own documentation: a driver who cancels guidance mid-leg looks + the same as one who arrived, so the next leg starts under them — and cancelling that one too + ends the chain. + +- **The car can now say whether guidance actually started.** `SaicNavGuidance.isMapNavigating` is + `IGeneralService` transaction 18, and what it returns is not an inference: `MapService` holds + the flag and the navigation app sets it itself through `IMapService.isMapNavigating(boolean)`. + A command accepted by the adapter only ever meant the adapter took it — the fan-out swallows a + refusal — so this is the difference between "sent" and "started", and it lets a caller try one + channel, ask the car, and move to the next. + +- **Guidance can be started, not only drawn.** The route handoff put the destination on the map + and stopped there: the driver saw the place and no guidance ever ran. `SaicNavGuidance.goTo` is + the command that runs it — `IGeneralService` transaction 23, which `GeneralService` turns into + `MapService.goToPoi(null, address, null, latitude, longitude)`, the same call the head unit's + voice assistant makes when someone says "take me there". One point and no pathway, because the + interface has room for nothing else: a plan with a charging stop guides to the stop, which is + the leg being driven. Same boundary as `startNavFromEvRoute` — a navigation write, not a + vehicle write — and the same two cautions: the adapter logs the point, and the fan-out is + synchronous, so it must not run on the main thread. + +- **A planned route can be handed to the car's own navigation, waypoints included.** Two drives + proved that nothing on SWI68 declares a filter for `geo:` or `google.navigation:`: the map + opened at its default view and the driver typed the address in again. The head unit has its own + channel and it was hiding in plain sight — `IGeneralService` transaction 48, + `startNavFromEVRout(pathway, destination)`, on the same `GeneralService` this module already + binds for guidance. It forwards to `MapService`, which fans the points out over its + `RemoteCallbackList` to whichever navigation app registered. `SaicNavGuidance.startNavFromEvRoute` + is that call. + + **It is not an impersonation and not a vehicle write.** `IMapNotificationListener` is the + channel the head unit uses to *command* the navigation app; registering on it to send one + destination would mean claiming to be a navigation provider, and this is the other end of that + wire. `VehicleWriteGate` is untouched and stays untouched: it exists for the settings that + change how the car behaves under the driver — AEB, ELK, ACC/TJA, the drive mode — and a + destination is no more one of those than the comfort writes it already exempts. + + **What travels is three fields.** `NavigationHandoff.poi` validates a point the same way the + `geo:` form does — a swap of latitude and longitude routes to another continent while looking + like an ordinary number — and keeps the name as a parcel field rather than a URI label, because + a parenthesis cannot end a parcel field early and stripping it would only mangle what the driver + reads on the map. `NavigationHandoff.pathway` caps the waypoints. The list is written by hand, + since this module does not carry the vendor's `EVRoutPoiInfo`, and `EvRoutPoiParcelTest` proves + the framing byte-for-byte against what Android's own `writeTypedList` produces for a bean with + the same three fields. + + **Two things a caller has to know.** The transaction is not `oneway` and the service fans out + synchronously while holding a lock, so it must not be called on the main thread; and + `MapService` writes both point lists to the head unit's own logcat before fanning out, which is + the vendor's log rather than this app's and the one place a destination appears in text. + +- **The guidance census records the size of the last parcel on each transaction code.** The + 2026-09-07 capture is why: with guidance running on the car's own navigation, code 13 decoded + as a remaining distance of zero and code 12 as a remaining time of zero, while an undecoded + code 11 carried 98 callbacks in the same window. Nothing in the artifact could tell a genuine + zero from a payload of a shape this build reads as an int. A parcel of one int is the + descriptor plus four bytes; a larger one settles it without decoding a field, and a byte count + is not a place, which is why it can travel on the stick. + +### Changed + +- `EnergySnapshot` carries `vehicleConsumedKwh` and `vehicleRegeneratedKwh`, read through + `EnergyTelemetryReader` from the charging service's own counters. Both are defaulted: every + existing caller builds that snapshot by name, and a counter this car may not keep should not + become a parameter each of them has to pass as null. + +- **`PlanDrift` now measures the charge gauge at a tenth of a percent, not a whole one.** The + 2026-09-05 drive on SWI68 recorded 457 samples stepping between 53,9 % and 52,9 % in tenths and + refreshing every 25 s or so, which is ten times finer than the figure the arithmetic started + with. Every band it derives narrows by the same ten, and the odometer's whole kilometre — not + the gauge — is now what sets `MIN_DISTANCE_KM`. An arrival two points above the reserve no + longer reads as short on quantisation alone. +- **A charge en route is separated from regeneration.** The guard used to reuse the gauge's own + step, so any rise above the departure figure looked like a plug-in. It now has its own + `MAX_REGEN_RECOVERY_PERCENT`: a col's worth of regen is not a charging session, and the + shortest plausible plug-in gives back far more than a descent can. + +### Added + +- **`PlanDrift` — whether the plan the driver chose is still the plan they are driving.** Charge + and the odometer are already read once a second and the plan was written down when it was + picked, so noticing that a trip has stopped matching it is a division: no request, no quota, no + network. The alarm is deliberately hard to trip. It fires only when the rate actually being + spent has left the band the plan admitted to *and* the arrival no longer clears the reserve, + because a driver going faster than the row they chose and still arriving at 30 % does not need + telling, and a companion that speaks then is one nobody reads when it matters. Both instruments + quantise — a gauge that steps a whole percent, an odometer that answers whole kilometres — so + the measurement carries its own band, which is also why it refuses under 15 km of road. +- `ChargeStopPlan.effectiveRate` is public. It was the private fold of the climb into the rate; + `PlanDrift` has to compare a drive against the rate the plan was *actually* made with, and a + second copy of that arithmetic would have drifted away from the one that planned. + +- **`NavigationHandoff` — the first write this library builds towards the car.** Everything else + here reads. This composes a `geo:` URI so a route the driver has chosen can be handed to the + car's own navigation instead of retyped, and it is separated from the sending because the + format is the part that can be wrong and the part a JVM test can settle without a head unit. + It refuses coordinates that are not a place on Earth — latitude and longitude are two doubles + of the same type and a swap routes to the wrong continent while looking like an ordinary + number — and it formats in `Locale.ROOT`, because a head unit set to French writes 43.5 as + `43,5` and a comma is a field separator in this URI. A label from a geocoder or a charger + dataset is reduced to letters, digits, spaces and hyphens: it is text this project did not + write, and a parenthesis in it would end the label early. `geo:` carries one point and no + waypoints, which is a limit the caller has to show the driver rather than paper over. + +### Fixed + +- **A momentarily unreadable battery power no longer blanks a settled range estimate.** + `AdaptiveRangeEstimator` gated on the live power reading, so one sample the firmware failed to + answer erased a figure computed from stored trips and the pack's charge — neither of which had + moved. The question the gate asks is "can this firmware measure power at all", and that answer + does not change between two polls, so it is latched: a firmware that has never published power + still refuses to borrow another firmware's history, and one that published it a second ago is + not treated as having lost the ability since. + +- **An estimate no longer claims a band of exactly zero.** Eight of one driver's trips that + happened to agree produced "266,7 ± 0,0 km", which reads as a measurement — the one thing + provenance exists to stop an estimate doing. The spread of a handful of trips is not the error + of extrapolating to a whole pack: terrain, load, weather and speed all sit outside the fit. The + band now has a floor of ten percent of the estimate. + +- **A corrupt trip history no longer multiplies itself.** `EnergyTripHistoryStore.quarantine` + copied the unreadable file aside and left the original in place, so the next read failed to + parse it again and wrote another copy — one file per read, unbounded, in storage the driver + cannot see. The original now goes, by rename or by copy-then-delete, and a copy that itself + fails is removed rather than left behind. + +- **Ending a drive no longer re-encodes the whole history once per evicted track.** Bounding the + file measured every remaining trip again after each step, over as many as two hundred trips of + four thousand samples, on the path that closes a trip. The envelope's size is arithmetic — + empty envelope, plus each trip, plus one comma between each pair — so eviction now subtracts as + it goes and each trip is serialised once. + +- **Decimating a track no longer throws away the newest sample.** `TripSampleTrack` halves itself + when the sample just added overflows it, and on an even-sized track that sample sits at an odd + index: taking every second one from the start discarded the most recent moment of the trip at + every doubling. It is now kept explicitly. + +### Added + +- **`SaicNav.probeTransaction` — what a navigation transaction replies, recorded rather than + interpreted.** CP-040 saw transactions 38 and 39 declared on `IMapService` and never called + them; CP-051 wants to know whether they carry the destination the driver has already entered + in the car's own navigation, because that destination costs no keyboard and no geocoding + request. The probe returns the reply's bytes and its length, and refuses to read them as a + typed value: a latitude read as a longitude routes to the wrong continent while looking like + an ordinary number. Read-only, capped at 128 bytes, and it answers "unbound" or "no answer" + as plainly as it answers with a payload. Called only by the unstable channel's validation + probe (CP-055). + +- **A consumption model for a car that publishes no battery power — which is this car.** + `CarPropertyEvidence` has recorded since 2026-09-04 that SWI68 never publishes battery power: + three diagnostic bundles, 51 samples, zero readings. The consequence was that the kWh model + could never train on it, and three finished screens stayed blank. `SocConsumptionModel` fits + the same shape — rolling plus aero times speed squared plus a thermal term — in **percent of + charge per 100 km**, from the state of charge the car does publish and the distance the speed + integral already gives. No pack capacity enters it, which matters because the capacity here is + a specification sheet rather than a measurement: fitting in percent removes an unmeasured + multiplier from every number instead of carrying it. Training points are whole segments of + road, accumulated until the charge has moved by two points, because a state of charge read + sample by sample is quantisation and a fit built on it would be fitting the quantiser. A hole + in the track ends a segment rather than being driven through, a distance recorded under a + conversion no longer believed contributes nothing, and a history that has only seen one + temperature fits two coefficients instead of inventing a third. + +- **The col between here and there costs charge, and the forecast knows it.** CP-031 refused a + grade source on good grounds — no vehicle signal, no proven barometric reference, and + power-derived grade is circular — but every candidate it weighed described the road *behind* + the car. A route's elevation profile describes the road ahead, which is the only kind a + forecast can use. `RouteGrade` turns cumulative climb and descent into points of charge by + physics rather than by a fit: potential energy for a stated mass, a stated drivetrain + efficiency going up, a deliberately pessimistic regeneration efficiency coming down, each one + a declared constant inside a 40 % band and none of them tuned against data that does not + exist yet. A descent gives charge back and always less than the same climb spent, so a round + trip over a col is never a free lunch. `ChargeStopPlan` takes the term as an optional + argument and folds it into the rate, which means the band widens with the profile and a + missing profile is `null` rather than a zero quietly claiming the road is flat — a plan + without one is byte-for-byte the plan it was before. + +- **An arrival state of charge, built on what this car actually publishes.** The obvious route + runs through the energy model — kWh/100 km, a distance, a pack capacity — and it is a dead end + here, because the model trains on energy integrated from a battery power that never publishes. + State of charge per kilometre needs no energy unit at all, and both its inputs read. + `SocRateEstimator` takes the rate from the vehicle's own range on the first reading and from + recorded trips once there are enough, preferring what the driver has actually driven; trips + whose distance the current speed conversion did not produce are never averaged in. + `ArrivalSocForecast` never returns a bare number: the band widens with distance, a band wider + than 15 points is refused rather than shown, and a negative arrival is left negative because + "the destination is beyond this charge" is the most useful thing it can say. + +- **A declared battery capacity bridges kWh and state of charge.** `INFO_EV_BATTERY_CAPACITY` is + declared and never published on the MG4, and deriving the pack size from an energy total needs + battery power, which is unvalidated — so nothing could turn a model's kWh/100 km into a state + of charge. `BatteryCapacityConfig` takes the figure from whoever knows it. It asks for the + **usable** capacity, not the nameplate one, because state of charge spans the usable window + and the buffers at each end sit outside it: entering 64 kWh where 61.7 is usable overstates a + full pack by 2.2 kWh, in the optimistic direction. Everything derived from it is `ESTIMATED` + with a 5 % band covering a dealer's state-of-health reading and a specification figure that + was never a measurement of this pack. Out-of-range input is refused, never clamped. + +- **A recorded distance says which speed conversion produced it.** `VehicleSpeedEvidence` is the + counterpart of `BatteryPowerEvidence` on the distance side, and it exists because the + conversion has already been wrong once: every trip stored before the km/h fix carries a + distance 3.6 times too long, and nothing in the number said so — 8.46 km for a 2.4 km route + reads exactly like 8.46 km for an 8.46 km route. `EnergyTripSummary.modellableDistanceKm` + answers only for a distance produced by the conversion believed correct today, so a model + refuses history it cannot vouch for instead of quietly training on it. The reported distance + is unchanged: a driver's own record does not silently rewrite itself. + +- **The odometer reads again.** `PERF_ODOMETER` is declared and never published on the MG4, so + total mileage showed as unavailable while the head unit knew it. The navigation adapter + answers it, and `getOdometerKm` now falls back to it — the same reading that gave the 3.6x + speed error its independent reference. + +- **Guidance can be read, not only waited for.** The five listener callbacks are change + notifications, so a car standing still with a guidance already running publishes nothing — + which made a parked capture unable to tell "the adapter says nothing" from "nothing happened + to say". `IGeneralService` answers `getGuideStatus`, `getRemainingTimes`, `getRemainingDistance` + and `getRoadName` synchronously on the binder already bound, so `SaicNavGuidance.readNow` + reports the current trip on demand. `SaicNav`'s note that the head unit answers no synchronous + question about a trip was read off `IMapService`; the general service does answer. A distance + or time of zero is treated as unreadable, as the service returns zero before any guidance has + run. + +### Fixed + +- **`PERF_VEHICLE_SPEED` already reports km/h on SWI68, and was being converted a second time.** + AAOS specifies the property in metres per second, so every read was multiplied by 3.6 — and + every distance integrated from it was scaled by the same factor. A 2.4 km drive was recorded + as 8.46 km and, on a second run, 7.69 km: 124 and 129 km/h average over four minutes of town + driving. Divided by 3.6 the same trips read 2.35 km at 34 km/h, which is the drive that + happened. `VehicleSpeedScale` makes the conversion evidence-gated per generation like every + other firmware-dependent read here. SWI68 is listed on the 2026-09-04 bundle from + SWI68-29958-1300R67; every other generation keeps the specified conversion, unproven but wrong + in the conservative direction, since a speed read too high makes `VehicleWriteGate` refuse + more often rather than less. + +### Fixed + +- **Repeated vehicle states no longer flood the diagnostic log.** The ignition condition + arrives about ten times a second and the gear is read once a second; both were logged every + time, so a 400-line retained log held under a minute of history and anything older had already + been evicted from the bundle a driver sends back. `StateChangeLog` lets a state through only + when it changed, keeping the transitions a diagnostic actually reads and dropping the repeats + between them. Only logging is gated — every callback is still dispatched. + +## [1.14.0] - 2026-09-04 + +### Changed + +- **`WEATHER_NOW` is an `ENUM`, not free text.** The condition compared the weather service's + own phrase, in the head unit's language, against a string the driver had to type — a value + nobody can guess and that changes with the display language. It now carries one of the seven + states in the new `WeatherConditions`, which classifies the phrase by keyword fragments in + the six languages the apps ship. `SnapshotKeys.KEY_WEATHER_TEXT` became + `KEY_WEATHER_CONDITION` and carries that state; a phrase nothing recognises writes no key at + all, so the condition is unavailable rather than reported as fine weather. +- **`ConditionGroup.AUDIO`**, the counterpart of `ActionGroup.AUDIO`. `MEDIA_PLAYING`, + `RADIO_PLAYING` and `MEDIA_VOLUME` moved into it from `CONTEXT` and `COMFORT`. + +## [1.13.0] - 2026-09-03 + +### Changed + +- **Radio band, frequency and playback became one catalogue entry.** `SELECT_RADIO_BAND` is + gone; `TUNE_RADIO` now carries the band in `number`, the frequency in `text` and playback in + `flag`, behind a new `ValueKind.RADIO`. The split was the wrong seam: a band could not carry a + station, a station could not reach DAB, and "put the radio on FM 103.5 and play it" — one + instruction — took two entries that each had half of it. `number = 0` is what every rule saved + before the merge carries and still means "read the band off the frequency", so nothing that + worked stopped working. `RadioFrequency.parse` takes the picked band and refuses text that + disagrees with it rather than resolving the disagreement, for the same reason it already + refused `AM 103.5`. `SaicRadio.selectBand` gained `andPlay`, so a band switch can leave the + car silent the way a tune already could. + +### Added + +- **A generated catalogue document.** `docs/catalogue.md` lists every `ConditionType` and every + `ActionType` with its English label, what it asks for, whether it is refused while moving and + the generations it is declared on. Rendered from the enums by `CatalogueDocTest`, which + rewrites it on every test run exactly as `FirmwareSupportTest` does for the matrix — the + hand-written list it replaces had gone a year without mentioning twenty entries and still + promised an "AM or FM only" tune action. + +- **Read-only route guidance listener for the head unit's own navigation.** The map adapter + answers no synchronous question about a trip, but its general service fans remaining distance, + remaining time, guidance status and road name out to registered listeners. `SaicNavGuidance` + registers for that fan-out and folds it into a nullable `NavGuidance`. The callbacks are not + `oneway` and the adapter holds its callback-list lock while broadcasting, so the listener does + one parcel read and one volatile write and nothing else — a slow listener would stall the + instrument cluster. Registration is additive and displaces no other consumer. Distances carry + no proven unit and status codes no proven meaning, so nothing is converted and nothing is + displayable: this is CP-040 evidence, not a value. Costs no Android permission and no network. + +- **Post-trip energy attribution preserves uncertainty and gaps.** Measured trip consumption is + reconciled against modelled traction, regeneration, climate-conditioned residual groups and an + explicit unmodelled discrepancy. Every attributed value requires a band; values inside model + noise are marked indistinguishable from zero, and negative residuals remain visible as model + error. No per-consumer vehicle counter is claimed. + +- **Driver-specific energy modelling is bounded and evidence-gated.** A compact least-squares + fit learns rolling, aerodynamic speed-squared and outside-temperature terms from retained trip + samples only when their exact firmware/conversion evidence matches. It uses constant working + memory, caps training at 50,000 samples, rejects sparse/noisy fits and all extrapolation, and + returns only provenance-labelled estimates with residual-derived uncertainty. One atomic + 16 KiB model file stores coefficients and envelope; raw tracks are never duplicated. + +## [1.12.0] - 2026-08-31 + +### Added + +- **Vehicle-validated telemetry is catalogued per signal and firmware generation.** Battery + power starts unvalidated everywhere; CP-003 must add each generation only after its unit, + scale, sign and cadence have on-vehicle evidence. Consumers can therefore distinguish an + unproven signal from a property that is genuinely absent. Trip energy integrated from a + validated signal carries the exact firmware and conversion version; legacy or mismatched + totals can therefore be excluded from later models without growing the stored sample track. + +## [1.11.0] - 2026-08-29 + +### Added + +- **A rule can put the tuner on a band, DAB included.** `SELECT_RADIO_BAND` is the answer to + the one gap the radio work left: "tune the radio" takes a frequency a driver typed, and a DAB + service is addressed by an ensemble and service id, so there was nothing to type and no way + to ask for DAB. The action guesses no transaction code — the vendor service exposes no + band-only call that anyone here has observed, and a wrong code on a vehicle binder does + something unknown to a car. It composes calls that are already proven instead: read the + current band from `RadioBean`, then `tune` into the requested one, since `tune` carries the + band as its first argument. It lands on the station this car was last heard on for that band, + and when it has never been there it steps to the first station the tuner lists rather than + leaving the driver on an empty frequency. An unreadable current band sends nothing at all. + +### Changed + +- **The window actions ask to open or close, instead of carrying a raw command.** They used to + take a number in 0..7 — the vendor service's own command range, which nothing in the app or + the car explains to the person writing the rule. An on-vehicle capture showed what that + costs: a rule asking for `7` ran five times, was reported as applied five times, and left the + driver's window fully open throughout, because the service accepts a command it does not + implement and drops it. Closed and open are the only two states the glass can be asked for, + so those are the two the editor now offers. + Which raw command reaches each is a property of the *car*, established by the window probe and + stored per car, so it stays out of the catalogue and out of saved rules — a rule exported from + one car must not carry that car's command numbers. + +- **"Windows" is now "All windows".** It commands four windows at once, and the bare plural read + as the family of window actions rather than the one that moves them together. + +## [1.10.0] - 2026-08-29 + +### Fixed + +- **A property the firmware declares and never publishes no longer reads as a measurement.** + `CarPropertyManager.getFloatProperty` answers such a property with a plain zero, and nothing + in the number says the vehicle never put a value behind it — an MG4 on SWI68 reporting 31 °C + outside showed a 0 °C traction battery and a 0 kW pack current on the dashboard next to it. + Reads now go through `getProperty`, whose `CarPropertyValue` carries the VHAL's status for + the property, and only `STATUS_AVAILABLE` is treated as a reading; anything else stays null + and reaches the screen as "unknown", which is what it is. A measured zero is still a zero. + Runtimes that do not expose `getProperty` fall back to the typed getters unchanged, so no + generation loses a property it was already answering. + +### Added + +- **Automatic trip boundaries are now one fail-closed telemetry state machine.** + `TripDetector` requires five seconds above 5 km/h to start and two minutes below 1 km/h + to end, using park or charge-port confirmation when those signals exist. Missing speed + cancels partial evidence and can never start or stop a recording; all timers and threshold + bounce behaviour are covered without Android or a vehicle. + +- **Adaptive range is now an explicit estimate with evidence-sized uncertainty.** + `AdaptiveRangeEstimator` fits up to eight recent trip-consumption averages, requires three + usable observations, and converts their sample spread into a kilometre uncertainty band. + It prefers measured usable energy, falls back to SOC times capacity, and refuses to estimate + when the current firmware does not publish battery power; the vehicle's own range is untouched. + +- **Consumption is now one shared, nullable derivation instead of UI arithmetic.** + `ConsumptionCalculator` exposes raw and five-second exponentially smoothed instantaneous + kWh/100 km plus a two-minute energy/distance average. It masks missing inputs and speeds + below 5 km/h, preserves negative regeneration, rejects unobserved gaps over five seconds, + and bounds its working set independently of trip length. Every output is provenance-labelled; + the existing trip accumulator continues to integrate raw power and speed unchanged. + +- **Trip history can be pruned without bypassing its atomic persistence boundary.** + `EnergyTripHistoryStore` can remove one start-time-identified trip or replace the history with + a valid empty v2 envelope; both operations preserve the same unique-temp-file rewrite used by + appends, so a UI never deletes the only valid file before its replacement exists. The default + summary bound is 200 trips; the existing byte ceiling still drops old sample tracks first. + +- **A public post-AAOS 9 capacity signal can be tested without becoming product data.** + `EV_CURRENT_BATTERY_CAPACITY` describes real-time usable capacity rather than the pack's nominal + new capacity, but a public id does not prove that an MG4 firmware publishes it. The new + status-aware, firmware-branched reader is therefore internal to explicit evidence capture, + stays null when absent or invalid, and is carried beside — never inside — `EnergySnapshot`. + [The candidate review](docs/energy-signal-candidates.md) records exact AOSP provenance and the + negative search results for HVAC, battery-heater, accessory and cumulative energy counters. + +- **Three radio behaviours the library could already perform are now things a rule can ask for.** + `srcPauseRadio`, the tuner's own play state and `startRadioActivity` were implemented and + unreachable: the catalogue offered play, tune and station stepping, so "silence the radio" + had to be approximated with `MEDIA_CONTROL`, which addresses whichever source owns the audio + and therefore stops Bluetooth when Bluetooth is the one playing. `PAUSE_RADIO`, + `RADIO_PLAY_PAUSE` and `OPEN_RADIO_SCREEN` name the tuner instead. The toggle reads + `RadioBean.state` rather than `AudioManager.isMusicActive`, which is **false while the radio + plays** — a toggle driven by it would answer a playing radio with another "play", forever — + and when even that cannot be read it sends nothing and says so: a wheel button can be pressed + twice, an unattended rule cannot, and a guessed direction leaves the car silent or playing + with nothing in the history admitting the direction was invented. Opening the radio screen is + the one of the three that is not audio-only, so it takes the standstill gate, unreadable + speed included; skipping a station does not, and did not gain it. Band selection and direct + DAB service selection were considered and rejected — see + [docs/radio-action-gap.md](docs/radio-action-gap.md), which records every candidate including + the refused ones. DAB remains reachable through station stepping, which is what the head + unit's own launcher calls. + +- **A trip now keeps its shape, not only its totals.** A summary cannot be un-summarised, and + every model this project will grow — what a slower motorway speed would have saved, what the + climate system took, the state of charge on arrival — is fitted from the shape of past drives. + `TripSampleTrack` records one sample per five seconds of speed, power, state of charge, + temperatures and climate state, all nullable, so a car that publishes no pack temperature + stores no pack temperature rather than a column of zeros a fit would believe. A drive that + outlasts the track's length is decimated rather than truncated: every second sample goes and + the interval doubles, so the motorway stretch at the end survives at a coarser resolution + instead of being cut off. + +### Changed + +- **The trip history file states its own version.** The first shape was a bare JSON array with + no room to say what it was; it is read as before and rewritten into the v2 envelope on the + next append, losing nothing. A file this build cannot read — truncated, or written by a + build that knows something this one does not — is quarantined under a new name rather than + deleted, because it is the only copy of whatever it holds. Three bounds now apply in the + order that keeps the most meaning per byte: at most fifty trips, then sample tracks dropped + oldest-first to stay under the size ceiling, and only then whole trips — a trip without its + track is still a trip, a missing trip is not. +- `EnergyTripSession.stop` returns a `RecordedTrip` (summary plus track) rather than a bare + summary, and `EnergyTripHistoryStore.read()` returns stored trips; `readSummaries()` is the + old view. + +- **`TelemetryEvidenceRecorder` — what a signal actually did on the car, instead of what the + code assumes it does.** Every energy figure past a state of charge divides by a number whose + unit, sign and update rate are currently a hypothesis: the battery power reader divides the raw + property by -1,000,000 because that is what the value looked like, not because a vehicle + confirmed it. The recorder consumes the same snapshots the dashboard renders and keeps, per + signal, how often it answered and how often it did not, its range and mean, its positive and + negative split, and the interval between the values it actually published — measured from value + *changes*, so a 1 Hz sampler over a signal republished every five seconds reports five seconds + rather than one. A signal the car never answered keeps no statistics at all: no minimum of + zero, no mean of zero, nothing. Captures serialise to a versioned schema for the next build and + render as a Markdown table for the person writing the conclusion. + +- **`Provenanced` — a value that carries what kind of claim it is.** The MG4 publishes no + per-consumer energy counters, so anything past traction totals — the climate system's share, + what a slower motorway speed would have saved, the state of charge on arrival — can only be + inferred, and an inference rendered in the same typeface as a vehicle reading is a + measurement as far as the driver is concerned. `Provenance` names the four kinds a figure can + be (measured, derived, estimated, unavailable) and `Provenanced` enforces them at + construction: an estimate cannot exist without its uncertainty band, an unavailable value + cannot carry a number, and a measurement cannot carry a band the vehicle never published. + `Provenanced.derive` propagates gaps rather than filling them, so consumption over an + unreadable speed is unknown — with `UnavailableReason` saying whether the firmware lacks the + signal, has not been validated for it, or simply is not publishing it right now. + +- **`EVHardware.probeTelemetryProperties()` — what each energy property actually answers on the + car in front of you.** An unsupported property, one declared and never published, and one + this runtime cannot reach all look identical from the driver's seat, where every unusable + signal is one em dash. The probe reports status, raw value and publication timestamp per + property, unfiltered and outside the supported-generation gate, so a generation's telemetry + surface can be recorded from the vehicle instead of inferred from a blank field. + +### Changed + +- **A read that fails the same way every second is worth one log line, not one per sample.** + One unreadable speed property wrote two identical lines a second, filled the 400-entry buffer + and pushed everything else out of it, so the diagnostics screen was least useful exactly when + something was wrong. A repeated failure is logged again only when its reason changes. + Reflected failures also name their cause: `InvocationTargetException` carries no message of + its own, which is why these lines used to read `exc: null`. + +## [1.9.0] - 2026-08-28 + +### Fixed + +- **The glass probe refused to run on a car whose ignition was on.** It read + `getCurrentIgnitionState()`, which answers on the AAOS `IgnitionState` scale, against + `CarIgnitionItem.RUN` — and the two scales do not line up: RUN is 2 for Katman5, where 2 is + OFF. Contact mis read as 4 and was refused; contact coupé read as 2 and was let through. + `setEsc` compared the same two scales and now refuses only a *known* non-RUN ignition, as its + own documentation always said it did. +- **Window actions moved nothing.** They were written through the vendor hub's + `setDriveWindow`, a setter no application on the head unit calls, whose accepted values + nothing explains and whose dropped writes are indistinguishable from obeyed ones. + +### Added + +- **`VsmGlass` — the windows through the interface the car's own window switch uses.** + `setVehicleWindowStatus(area, command)` and `getVehicleWindowValue(area)`, on the VSM already + bound for ADAS. The commands are established rather than guessed: `STOP`, `UP` and `AUTO_UP`. + `UP` is a switch held down and not an order — the glass travels only while the command keeps + arriving, which is why a single write looked exactly like a command that does nothing, and + why `hold()` re-sends it and then releases it. Reads answer 0–255, not the hub's 0–100, and a + motor without a position sensor answers off that scale and is reported as no reading rather + than as a closed window. VSM-based firmwares only; the hub stays the fallback. + +### Changed + +- **`SaicVehicleControl` prefers the VSM for every glass read and write**, falling back to the + hub only where the VSM does not carry the methods. `GlassProbe` holds each command down for + `HOLD_MS` instead of writing it once, which is the only way a momentary command shows up in a + position read at all. + +## [1.8.0] - 2026-08-27 + +### Added + +- **`SaicMediaPlayer` — next track, previous track and play/pause sent to the source that is + actually playing.** The car publishes a single Android media session + (`com.android.bluetooth`), so a media key either reached nothing or reached Bluetooth, woke a + phone that was not playing and changed the audio source while the radio was on. The new + object asks the vendor service which source owns the audio and commands that source's own + player — radio, Bluetooth, USB, online, CarPlay/Android Auto — with **no cascade between + sources**: a player that is not playing is never addressed, because it would answer "yes" and + start. The A9 generations have no such service and drive the framework's media sessions + instead, then allgo's `IRemoteUIService` for projection, then a media key only while + something is audible. +- **`SaicRadio.nextStation()` / `previousStation()` / `isPlaying()`.** Stepping through the + tuner's stations is the one radio call that reaches **DAB**: a DAB service is addressed by + ensemble and service id, so `tune` has no frequency to take. `isPlaying` unwinds the vendor + `RadioBean` because `AudioManager.isMusicActive` is false while the radio plays — its stream + is not the music one — which made play/pause answer a playing radio with another "play". +- **`ActionType.RADIO_NEXT_STATION` / `RADIO_PREV_STATION`** (AUDIO, SWI68/SWI165). +- **`GlassProbe` — settles on a car what the catalogue could not settle in the project.** + Which of the eight window commands opens a window is written down nowhere and the vendor + service reports success whatever it is given, so the glass actions ship refused + (`writeProven = false`). The probe sends each command with the position read either side of + it, in **two passes** — from a closed window a closing command is indistinguishable from an + inert one, so the second pass retries exactly those once the glass is open — and stops as + soon as both directions are known. Standstill is re-checked before every command, not once + at the start, and the glass is put back where it was found or the caller is told it was not. +- **`GlassEvidence`** records what the probe watched happen and unlocks the five window + actions **on that car only**. Evidence is scoped to the firmware generation it was observed + on: a command proven on one generation proves nothing about another. +- **ESC and the drowsiness warning (UDW), on every generation.** Three different routes — + VPM properties on SWI133, named methods on SWI68/SWI165, and the carapi client on A9, where + ESC is spelled "Eps". ESC on SWI68/SWI165 needed a manager the library did not bind at all: + `get/setEspSwitch` live on **VehicleControlManager**, not the settings one, so looking for + them on `sVsm` failed without a sound and left ESC inert. + Two guards come with the write, both earned on a car: it is a **toggle** driven by a read, so + it runs only on an ignition known to be in RUN — getting in while the cluster is dark, the + property lies, and aiming at ON from a false OFF disables an ESC that was on, in silence — + and only on three agreeing readings, with the result checked afterwards rather than assumed. +- **`ActionType.SET_ESC`, `SET_DROWSINESS`, `SET_DROWSINESS_SENSITIVITY`** (ADAS, gated). + The drowsiness switch is UDW and not the camera-based DMS one: both exist, their labels read + alike, and writing the camera one changed nothing visible. +- **`A9Climate` — climate on SWI69, SWI131 and SWI132.** The vendor hub `SaicClimate` talks + to does not exist on those generations, so eight climate actions were marked SWI68/SWI165 + only on cars whose own HVAC screen works fine. They now go through `carapi`'s + `CarHvacClient` (`queryClient(0x7)`), the same door `queryClient(0x8)` already opens for the + vehicle-settings client. Several A9 settings are argument-less `switch…()` calls that step to + the next state, so aiming at one is read-step-read, bounded — which makes those writes + blocking and unfit for the main thread. ECON and the passenger's own target stay off A9: the + client exposes neither, and refusing is more honest than writing to the driver's side and + calling it the passenger's. +- **`ActionType.ADJUST_MEDIA_VOLUME` and `EVHardware.adjustMediaVolume(delta)`** — a step + rather than a target, for a wheel button or a rule that must not throw away the level the + driver chose. Falls back to `AudioManager` when the vendor level is unreadable, and reports + either end of the range as done rather than refused. +- **`PhysicalButtonEventDecoder` reads a double press.** Two releases of the same button + within `DOUBLE_TAP_MS` make one `Press.DOUBLE`; a long press breaks the pairing so the + release that follows it cannot become half of a double nobody made, and a third press starts + over rather than producing a second overlapping double. +- **`DataUsage`** — what the head unit's connection has carried, today and this month, from + Android's own counters. The modem presents as **Ethernet**, and the public + `querySummaryForDevice(int, …)` only builds MOBILE and WIFI templates: it answers zero here, + which reads exactly like a car that used no data. The hidden `NetworkTemplate` overload + answers, with `TrafficStats` since boot as the fallback, and an unreadable counter is null + rather than zero. +- **`ConditionType.DATA_USED_TODAY` / `DATA_USED_MONTH`** (context, in MB). +- **`ActionType.effectProven`** — `writeProven` answers "has anyone ever seen this work", + `effectProven` answers "does it work here". Everything user-facing asks the second. + +### Changed + +- **An action whose write was never shown to do anything is no longer offered anywhere.** + `ActionType.writeProven` separates two questions a single ✅ used to answer at once: whether + the firmware carries the property (`@SupportedOn`) and whether writing it changes anything. + The five glass actions are the case that forced the distinction — the service accepts a + command in `0..7`, no head-unit application sends one, and which command raises a window is + written down nowhere, so the write is accepted, dropped, and reported as applied. They ship + `writeProven = false` until one command is observed to move a window; the generated firmware + matrix marks them ⚠️ and says what the mark means. + +## [1.7.0] - 2026-08-27 + +### Added + +- A shared, read-only energy telemetry API: one nullable `EnergySnapshot` combines SOC, + range, speed, battery power and temperatures, pack energy/capacity, odometer, charge state + tyre pressures and climate state with explicit firmware dispatch and unit conversion. +- Reusable trip and charging-session integration plus bounded atomic trip-history storage. + Both skip unreadable samples and long gaps rather than inventing energy. + +### Changed + +- Reflected `CarPropertyManager` getters are cached when the service connects instead of + resolving a `Method` for every sample. The cache is keyed on "resolved", not on "found", + so a getter this `CarPropertyManager` does not expose is no longer looked up again on + every sample. +- A trip's reported duration is the time actually covered by usable samples, not wall clock. + A sampler suspended for ten minutes no longer adds ten minutes to a trip whose distance + and energy did not move — the consumption average now divides comparable numbers. + +### Fixed + +- The Car-service binding now retries without a bound. The previous fixed retry ladder gave + up after one minute, so a process started before the SAIC Car service was ready never saw + a vehicle again for its whole lifetime, and a service that died and came back was only + recovered if the `ServiceConnection` callback fired. Consumers carried their own reconnect + loops to work around this; the watchdog now lives next to the binding it repairs. + +## [1.6.0] - 2026-08-26 + +### Fixed + +- **Glass writes never moved a window.** `IVehicleControlService` validates a window write + against `max_vehicle_window_set = 7` and drops anything above it without an error — the + setter returns void, so a dropped write and an applied one are indistinguishable from the + caller. The library was sending a position in percent, so every write above 7 was + discarded while the history recorded it as applied. Reading is unchanged and stays a + percentage (`max_vehicle_window_get = 100`): the two directions are not the same scale. + `setWindow` / `setAllWindows` now take a **command in 0..7** and refuse anything else + rather than hand it over to be dropped. Which command opens and which closes is not + written down on the firmware and no head-unit application sends one, so the mapping is + still open — a command sent and a position read back is what identifies it. +- **Current weather always came back unreadable.** The `ICallBack.onResult` parcel carries + the nullable `Result` argument behind a presence flag, and the decoder started at the + object's first field instead. Every field then landed in the previous one's place, the + payload tag no longer matched, and an answer that had arrived was reported as "cannot + tell". The flag is read now, and a null `Result` is a null reading. + +### Changed + +- Glass actions are standstill-gated outright, and `ActionType.gatedWhenOpening` is gone + with the directional gate it fed. That gate compared the target against the position, and + the target is no longer a position: with the direction of a command unknown, the safe + reading of an unknown direction is that it is not one to allow at speed. +- `SaicVehicleControl.doorLockState()` exposes the raw lock status for the diagnostic. The + service answers `0..7` and only 1 and 2 are named, so `doorsLocked()` still discards the + rest — but "unreadable" without the number left no way to tell an unnamed state from a + read that never answered. + +### Removed + +- The daily forecast — `WEATHER_TOMORROW`, `TEMP_MAX_TODAY`, `TEMP_MIN_TOMORROW` and + `SaicWeather.forecastAt`. `ISaicService` declares seven transactions on this firmware and + none of them answers a forecast; the `WeatherFuture` parcelable its `Result` can carry is + never filled by anything in the map service. The transaction 1.5.0 called does not exist, + which is what the `transact returned false` in the log was. The head unit's own weather + application fetches the outlook itself with credentials that are not reachable from here + and broadcasts only current conditions, so there is no second source either. Three + conditions that could never be readable are worse than none. + +## [1.5.0] - 2026-08-25 + +### Added + +- Per-window control. `SaicVehicleControl.Window` names the four windows and + `windowPercent` / `setWindow` address one at a time, alongside the group read and + `setAllWindows` — which stay, because closing the glass is one gesture that must not be + able to leave three shut and one open. Catalogue: `ConditionType.WINDOW_DRIVER`, + `WINDOW_PASSENGER`, `WINDOW_REAR_LEFT`, `WINDOW_REAR_RIGHT` and the four matching actions. +- `SaicCharging.rangeKm` and `ConditionType.RANGE_ESTIMATE` — the cluster's own remaining + range. A rule that diverts to a charger is about distance, which a battery percentage does + not answer on a cold motorway. +- `SaicNav`, the head unit's navigation adapter, and `ConditionType.ODOMETER`. Trip distance + is deliberately absent: remaining distance and time only arrive as callbacks into that + service from the running navigation app, so reading them is a subscription, not a getter. +- `SaicWeather` with `ConditionType.WEATHER_NOW`, `WEATHER_TOMORROW`, `TEMP_MAX_TODAY` and + `TEMP_MIN_TOMORROW` — the weather service the head unit's own map stack queries, asked for + the car's position. The outlook is **daily**: the service answers one entry per day, so + "rain tomorrow" is a question it takes and "rain in three hours" is not, and the catalogue + offers only what the data supports. The only asynchronous service here: the answer + arrives on a callback binder and is awaited with a bounded wait, so a slow query comes back + unreadable instead of holding a rule cycle open. +- `SaicAidl` now marshals `Double` and `IBinder` arguments, for that callback. + +Neither `ODOMETER` nor `WEATHER_NOW` carries `@SupportedOn`: they come from the head unit's +own applications rather than from the vehicle SDK, so nothing routes them per generation — +they answer or they do not, the same as the message action. + +## [1.4.0] - 2026-08-25 + +### Added + +- Context entries that read Android rather than the car, so they carry no firmware + annotation: `ConditionType.MEDIA_PLAYING`, `WIFI_SSID`, `IN_CALL`, `DRIVE_DURATION`, + `RANDOM_CHANCE`. `IN_CALL` is "a call is routed through the car", not "the phone is + ringing" — the difference is stated rather than papered over. +- `ActionType.ENABLE_RULE` / `DISABLE_RULE` with `ValueKind.RULE`: a rule can now switch + another on or off, which is what turns a catalogue of one-shot rules into chains. +- `ActionType.MEDIA_CONTROL` (`MediaCommand`, the platform's own media key codes) and + `SET_BLUETOOTH` / `SET_WIFI`. +- Climate readings that had a vendor getter and no catalogue entry: `ConditionType.ECON_MODE`, + `PASSENGER_TEMP`, `FRONT_DEFROST`, `REAR_DEFROST`. The last two also become the `currentKey` + of the defroster actions, which until now opened on a state nothing could read back. +- `ActionType.SET_ECON` and `ActionType.SET_PASSENGER_TEMP`. The passenger target is a second + action rather than a zone argument on `SET_CABIN_TEMP`: a zone would have made every rule + saved before it carry a value it never chose. +- Charging schedule readings: `ConditionType.CHARGE_SCHEDULE_ENABLED`, `CHARGE_WINDOW_START`, + `CHARGE_WINDOW_STOP`, `BATTERY_PREHEAT` — the read side of actions that could already write + them. +- `ConditionType.CHARGING_STATUS`, the charging state itself. `CHARGING` stays a boolean and + keeps its meaning; the state is what can say "plugged in but not charging", which a boolean + cannot. +- `ConditionType.FRONT_DOOR_OPEN` (SWI133), reading the door status the volume-drop watcher + already polls, through the new `EVHardware.frontDoorOpenOrNull()`. Front doors only — the + rear ones are not claimed rather than guessed at. +- `ValueKind.TIME`: one time of day, minutes since midnight. The charging window's ends are + clock times, and a 0–1439 slider is unanswerable at the wheel. + +### Fixed + +- `VehicleEnums.CHARGING_ACTIVE_STATES` names the states in which current actually flows into + the battery. Consumers filling `KEY_CHARGING` derived it from `chargingStatus != 0`, which + made a finished charge, a stopped one and a charging fault all report as charging. + +## [1.3.0] - 2026-08-23 + +### Added + +- `ActionType.SEND_SMS`: send a text message through the paired phone, with `ValueKind.SMS` + (recipient in `text`, contact label in `displayName`, message in `payload`). No + `@SupportedOn`: the message leaves over the Bluetooth Message Access Profile, not over a + SAIC service, so nothing is routed per firmware generation — availability is a bind, read at + runtime. The vendor Bluetooth call interface exposes no message transaction, so the + message cannot travel that way; the head unit's Bluetooth stack does expose a MAP client + profile at runtime, and that is the route taken. +- `ValueKind.CONFIRM`: a yes/no question plus the seconds it waits for an answer. The editor + draws one control per kind, so the wait had to become part of the kind rather than a + special case for one action. + +### Changed + +- `ActionType.ASK_CONFIRM` now carries `ValueKind.CONFIRM` (5–60 s) instead of plain text. + `Action.number` is the wait; `0` — every rule saved before the field existed — means + `ActionType.ASK_CONFIRM_DEFAULT_SECONDS` (10), not the bottom of the range. + +## [1.2.0] - 2026-08-22 + +### Added + +- `SaicRadio.pause`, the vendor service's `srcPauseRadio` — the counterpart of `play`. It + mutes the tuner and marks the radio stopped; it does not hand the audio focus back, which + the vendor service only does when it loses it. + +### Changed + +- `SaicRadio.tune` takes an `andPlay` flag and is responsible for the outcome. 1.1.0 removed + the `srcPlayRadio` call and assumed that was enough to tune in silence; it is not. The + vendor's `tune` calls `AudioController.requestMuted(false)` itself, which requests the + audio focus and unmutes the tuner, so changing station always starts the radio. Not + playing is therefore something that has to be undone after the fact: `andPlay = false` + pauses the radio as soon as it is tuned. Callers must pass the flag — there is no default. + +### Known limitation + +- Tuning with `andPlay = false` leaves the radio holding the audio focus, silent. Whatever + was playing before does not resume: the vendor service exposes no way to give the focus + back for AM/FM, only for DAB scanning. + +## [1.1.0] - 2026-08-22 + +### Changed + +- `SaicRadio.tune` no longer starts playback. It used to call `srcPlayRadio` after setting + the frequency, which made the radio the current audio source every time a station was + tuned, so a caller that wanted the station changed silently had no way to ask for it. + Tuning and playing are now two calls: `tune` sets the frequency, `play` switches the + source. Callers that relied on the combined behaviour must call `play` themselves. + +## [1.0.1] - 2026-08-21 + +### Fixed + +- `SaicClimate.outsideTempCelsius` no longer returns the vendor's error value as a + temperature. `AirConditionBinder.getOutCarTemp` answers `TEMPERATURE_ERROR_CONST` + (-10000f) for a signal it holds no value for, which is not a reading; it now maps to + `null`, same rule as every other getter here. A caller that stored it would have had + every "below N degrees" comparison come out true. + +## [1.0.0] - 2026-08-15 + +### ⚠️ Breaking + +- Renamed from MG4Hardware to **EVHardware**, and the package from `com.mg4.hardware` to + **`com.evsuite.hardware`**. Every import has to be updated; there is no compatibility + alias. +- `VehicleWriteGate.decide` no longer takes `allowUpToKmh` or `parked`. The gate is fixed at + a readable 0 km/h, with no caller threshold and no park rescue, so callers that passed + either argument must drop it. Consumers using `decideNow()` or `decide(speed)` are + unaffected. + +### Added + +- `FirmwareInfo.generationOf` as a pure parser, and `isDetectedGenerationSupported`, so an + unforced unknown firmware fails closed. +- Catalogue entry `ASK_CONFIRM`, with its localised strings and firmware-matrix row. + +### Changed + +- Consolidated the four copies of this library that had drifted apart while it was vendored + into EVProfile, EVTasker and EVABRPUploader. + +## [0.3.0] - 2026-08-10 + +### Added + +- **Shared diagnostic engine** in `com.evsuite.hardware.diag`, so the apps stop each carrying + their own copy: + - `CrashLogger` writes an uncaught exception, its cause chain and the `AppLogger` buffer + to `filesDir/last_crash.txt`, chaining the previous handler and truncating from the tail + so the exception survives. It replaces the two divergent copies in EVProfile and + EVTasker, each of which carried a fix the other was missing. + - `PrivateBin` uploads a report to a PrivateBin instance, zero-knowledge — the key never + leaves the URL fragment. Moved verbatim from EVTasker. + No UI: rendering a report and deciding when to paste it stay in the apps. + +### Changed + +- **One "Call" action.** `CALL_NUMBER` now carries a `CONTACT` value, so the same entry takes + either a typed number or a name from the phone book. `CALL_CONTACT` is deprecated and no + longer offered by `ActionType.byGroup()`: it stays in the enum only so rules saved by a + release that exposed it still load and still call, since enum entries are persisted by name + and a removed one deserialises to null. + +## [0.2.0] - 2026-08-02 + +### Added + +- Vehicle access to climate, charging, radio and calls through the vendor's own + service, alongside the existing reflection layer. +- `SPEAK_TEXT` local action: text-to-speech through the vehicle's own voice service. +- Window and door-lock control through the vendor control service. +- Park-based fallback for the write gate, and radio tuning. + +### Fixed + +- Unreadable vehicle signals were being reported as "off"; they're now reported + as unknown. + +### Changed + +- Adopted the EVSuite documentation structure: shared README skeleton, generated table + of contents, and [DESIGN.md](DESIGN.md) carried in every suite repository. + +## [0.1.0] - 2026-07-23 + +Initial tagged release. diff --git a/EVHardware/CONTRIBUTING.md b/EVHardware/CONTRIBUTING.md new file mode 100644 index 0000000..04064e7 --- /dev/null +++ b/EVHardware/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing + +This library reads and writes a car. That single fact drives everything below. + +## Ground rules + +1. **The 0 km/h gate is load-bearing.** Any road-behaviour write must pass through + `VehicleWriteGate` and carry `@RequiresStandstill`. It fails closed on unreadable speed — + keep it that way. +2. **Branch every vehicle call per firmware generation** via `FirmwareInfo`. Never assume a + method or property id is universal across SWI68/69/131/132/133/165. +3. **Unreadable is not a value.** Return null / -1 when the layer is not ready or the + property is absent; never fabricate 0/false. +4. **Property ids come from evidence.** Confirm ids/transaction codes against the R69 OEM + sources (or on-vehicle logs) and document the source inline. An unverified id is + read-only until confirmed — no write path. + +## Before a PR + +```bash +mise run check # JVM unit tests + firmware-matrix freshness +``` + +New vehicle catalogue entries need a `@SupportedOn(...)`; a test fails without it, and +`docs/firmware-matrix.md` regenerates on the test run — commit it, do not hand-edit it. + +## Language + +English only — code, comments, commit messages, docs. User strings in `values/` (English) +with a `values-fr/` translation. + +## Consumers + +Changing a public signature ripples into EVProfile, EVTasker and EVABRPUploader (submodule +consumers). Note the affected apps in the PR; a minified consumer must be verified on a +vehicle because vehicle access is reflection-based. + +## Ways to help + +Not every useful contribution is code: + +- **Bug reports** — firmware generation, app version, what you did, what happened. +- **Feature requests** — describe the problem before the solution. +- **Documentation** — README, this guide, the firmware matrix, translations. +- **Pull requests** — see below. +- **Testing pre-releases** — install an `unstable` build and report what broke. +- **Sponsorship** — see below. + +## Contributing efficiently + +Maintainer time and AI quota are the scarce resources here, ideas are not. If you have +access to Claude Opus or another capable coding model, a finished pull request is worth +much more than a feature request: someone still has to design, write, test and verify the +request, and that someone has a limited quota too. + +A pull request that lands quickly usually carries: + +- The problem, in one or two sentences. +- The proposed solution, and what you rejected. +- The implementation, scoped to one concern. +- Tests — `mise run check` passes. +- Documentation updated: README, CHANGELOG, this guide where relevant. + +Generate the change locally with whatever model you have, then read every line yourself +before opening the PR. You are the author, not the model. Unreviewed generated code on a +path that reaches the vehicle will be sent back. + +## Sponsorship + +Maintaining EVSuite costs development time, test hardware and AI usage. If it is useful in +your daily driving, consider sponsoring through +[GitHub Sponsors](https://github.com/sponsors/malys). Sponsorship covers those costs and +gets fixes and features out faster. + +Contributions are accepted under MIT (see [`LICENSE.md`](LICENSE.md)). diff --git a/EVHardware/DESIGN.md b/EVHardware/DESIGN.md new file mode 100644 index 0000000..63ddce6 --- /dev/null +++ b/EVHardware/DESIGN.md @@ -0,0 +1,463 @@ +--- +name: EVSuite +description: One design system for the EVSuite — a driver-facing instrument surface, not a phone UI parked in a car. +colors: + background: "#F2F4F7" + background-dark: "#0B0D0F" + surface: "#FFFFFF" + surface-dark: "#15181C" + surface-raised: "#E4E8ED" + surface-raised-dark: "#272C33" + outline: "#737F8F" + outline-dark: "#7A8492" + text-primary: "#101418" + text-primary-dark: "#F4F7FA" + text-secondary: "#414B55" + text-secondary-dark: "#C6CFD8" + accent: "#044C63" + accent-dark: "#7FD4FA" + on-accent: "#FFFFFF" + on-accent-dark: "#04121A" + ok: "#09501F" + ok-dark: "#8FE6A6" + warn: "#6B3A00" + warn-dark: "#FFC978" + error: "#8F0F18" + error-dark: "#FFACAC" + icon-tile: "#2A2A2A" + icon-glyph: "#F4F7FA" + icon-mark: "#E0424C" +typography: + display: + fontFamily: "Roboto, sans-serif" + fontSize: "32sp" + fontWeight: 500 + title: + fontFamily: "Roboto, sans-serif" + fontSize: "26sp" + fontWeight: 500 + body: + fontFamily: "Roboto, sans-serif" + fontSize: "20sp" + fontWeight: 400 + secondary: + fontFamily: "Roboto, sans-serif" + fontSize: "17sp" + fontWeight: 400 + label: + fontFamily: "Roboto, sans-serif" + fontSize: "16sp" + fontWeight: 700 + letterSpacing: "0.12" +rounded: + md: "12dp" +spacing: + xs: "4dp" + sm: "8dp" + md: "16dp" + lg: "24dp" + touch: "72dp" +components: + button-primary: + backgroundColor: "{colors.accent}" + textColor: "{colors.on-accent}" + typography: "{typography.body}" + rounded: "{rounded.md}" + height: "{spacing.touch}" + padding: "0 24dp" + button-outlined: + backgroundColor: "transparent" + textColor: "{colors.accent}" + typography: "{typography.body}" + rounded: "{rounded.md}" + height: "{spacing.touch}" + padding: "0 24dp" + card: + backgroundColor: "{colors.surface}" + textColor: "{colors.text-primary}" + rounded: "{rounded.md}" + padding: "16dp" + section-header: + textColor: "{colors.accent}" + typography: "{typography.label}" +--- + +# Design System: EVSuite + +Applies to **EVProfile**, **EVTasker**, **EVABRPUploader**, **EVChargePilot**, +**EVLauncher**, **EVSwipe** and the shared **EVHardware** library. This file is the normative +source; a copy lives at the root of every suite repository and they are kept identical. + +## Overview + +**Creative North Star: "The Instrument Binnacle"** + +These are not phone apps that happen to run in a car. They are extensions of the +instrument panel, and they are judged the way a gauge is judged: can the driver read it +in one glance, at 70 cm, through a windscreen reflection, without thinking about it. Every +decision below follows from that. The suite is quiet, dense with information but never +busy, and it never asks for attention it hasn't earned — an MG4 app that animates, +celebrates, or decorates is competing with the road. + +The visual language is flat, high-contrast and typographic. Depth comes from tonal +layering and a hairline outline, never from shadow. Colour carries meaning — accent, +ok, warn, error — and is never decorative. The one place personality is allowed is the +launcher icon, which is why the icon system gets its own section. + +The suite is deliberately unlike consumer Android: bigger type, bigger targets, fewer +elements per screen, no gesture-only affordances, no ambient motion. Where Material 3 +defaults conflict with legibility in daylight, legibility wins and the override is +commented in the theme. + +**Key characteristics** + +- Every text colour clears **7:1 (WCAG AAA)** on every surface it can land on. 4.5:1 is + not enough once sunlight hits the glass. +- Minimum touch target **72dp**, everywhere, including dialog buttons and switches. +- Minimum type size **16sp**. There is no "small" tier. +- Flat surfaces, tonal layering, one hairline outline. No shadows, no gradients. +- Colour is never the only carrier of meaning; a text label always accompanies it. +- Landscape-first. Portrait is not a supported orientation on the head unit. + +## Colors + +A neutral blue-grey ladder carrying one cyan accent and three status hues. The suite is +**DayNight**: `values/` holds the light palette, `values-night/` the dark one, and both +are held to the same 7:1 floor. Token names are identical in both files — a layout never +references a light or dark colour, only a role. + +Every ratio below is measured, not estimated. Re-run the check after any change: +`node tools/contrast.mjs` (see *Do's and Don'ts*). + +### Neutral + +| Token | Light | Dark | Role | +|---|---|---|---| +| `ev_background` | `#F2F4F7` | `#0B0D0F` | The window. Never holds content directly. | +| `ev_surface` | `#FFFFFF` | `#15181C` | Cards, panes, list rows. | +| `ev_surface_raised` | `#E4E8ED` | `#272C33` | Buttons, chips, selected rows, dialogs. | +| `ev_outline` | `#737F8F` | `#7A8492` | Hairline on every card. 4.07:1 / 4.70:1. | +| `ev_text_primary` | `#101418` | `#F4F7FA` | 18.5:1 / 16.6:1 on surface. | +| `ev_text_secondary` | `#414B55` | `#C6CFD8` | 7.2:1 worst case (on `surface_raised`). | + +### Primary + +- **Instrument Cyan** (`ev_accent`, `#044C63` light / `#7FD4FA` dark): section headers, + selected state, links, the active value in a control. 7.7:1 worst case in light, 10.8:1 + in dark. Light mode uses a deep cyan rather than tinting the dark one — a pale cyan on + white is 1.5:1 and disappears entirely in daylight. +- **On-accent** (`ev_on_accent`, `#FFFFFF` / `#04121A`): the only colour permitted on a + filled accent surface. + +### Status + +Never used alone. Each is paired with a text label, because a driver glancing sideways +reads shape and text faster than hue, and because ~8% of male drivers cannot separate +these three by colour at all. + +- **OK** (`ev_ok`, `#09501F` / `#8FE6A6`) — the rule ran, the write landed, the upload succeeded. +- **Warn / refused** (`ev_warn`, `#6B3A00` / `#FFC978`) — the speed gate refused a write, + a deferred action is queued, firmware support is unknown. +- **Error** (`ev_error`, `#8F0F18` / `#FFACAC`) — the operation failed. + +### Pressed state + +- `ev_pressed` (`#14000000` / `#1FFFFFFF`) and `ev_pressed_strong` (`#29000000` / + `#33FFFFFF`). Alpha overlays on the surface beneath, not a separate hue, so a pressed + card still reads as the same card. + +### Named rules + +**The Daylight Rule.** 7:1 or it does not ship. AA (4.5:1) is a phone-in-a-hand +standard; the MG4 screen is read through a reflection with the sun behind the driver, and +4.5:1 collapses to unreadable there. This is the single constraint that has forced the +most rewrites in this codebase — `#9EA6AE` secondary text (6:1) and the original +`#4A525B` outline (2.25:1) both shipped and both had to be replaced. + +**The Meaning Rule.** A colour outside the neutral ladder must encode state. If it is +there to look nice, delete it. This is why the suite has one accent and not a palette. + +**The Role Rule.** Layouts reference roles (`ev_surface`), never values and never +theme-specific names. A layout that needs to know whether it is day or night is a bug. + +## Typography + +**Family:** Roboto — the AAOS 9 system face. No bundled fonts: an APK on a vehicle earns +every kilobyte, and a missing glyph in a six-locale app is a worse failure than a generic face. + +**Character:** Neutral to the point of invisibility. The type is a readout, not a voice. +Weight and size carry hierarchy; italics and letterspacing are used once each, on purpose. + +### Hierarchy + +| Role | Size | Weight | Purpose | +|---|---|---|---| +| **Display** (`text_display`) | 32sp | Medium | A single number or word that is the whole point of the screen — a battery percentage, a drawer title. At most one per screen. | +| **Title** (`text_title`) | 26sp | Medium | Screen and card titles. | +| **Body** (`text_body`) | 20sp | Regular | Everything the driver actually reads. Also the size of every button label. | +| **Secondary** (`text_secondary`) | 17sp | Regular | Supporting detail, timestamps, units. | +| **Label** (`text_label`) | 16sp | Bold, `0.12` tracking | Section headers, in accent, uppercase-adjacent. The floor. | + +### Named rules + +**The 16sp Floor.** Nothing renders below 16sp. Not captions, not units, not footnotes. +Below that, contrast stops compensating for reflection and viewing distance, and the +information may as well not be on screen. If content does not fit at 16sp, there is too +much content. + +**The One Display Rule.** One `text_display` element per screen. Two competing 32sp +numbers is two glances, and a glance is the budget. + +## Layout + +**Target.** MG4 Electric head unit: 12.8" HD centre display, AAOS 9 on MT2712, landscape, +viewed at ~70 cm. DPI is user-adjustable per app on this platform, so everything is +expressed in `dp`/`sp` and nothing assumes a pixel size. Portrait is not supported and is +not laid out for. + +**Spacing scale.** `4 / 8 / 16 / 24dp` (`spacing_xs` … `spacing_lg`). Screen edges get +`spacing_lg`. Nothing between two rows is smaller than `spacing_sm` — tighter than that +and two targets read as one. + +**Touch.** `touch_target` = **72dp** minimum for anything tappable, including dialog +buttons, switches and list rows. Android's 48dp guidance assumes a phone held still in a +hand; this screen is at arm's length in a moving vehicle. Adjacent targets are separated +by at least `spacing_sm`, so a missed tap is a miss, not a wrong action. + +**Two-pane landscape.** Wide screens use a list/detail split rather than a stack — the +horizontal space exists, and a driver who has to navigate back to see context has lost +the context. `pane_list_width` = 380dp (wide enough for a full rule name), `pane_rail_width` += 300dp for label-only rails. The detail pane takes the remainder and is the primary area. + +**Grids.** Tile grids compute their column count from `catalog_tile_min_width` (240dp) +rather than hardcoding a number, so a DPI change re-flows instead of clipping. Tiles are +`catalog_tile_height` = 128dp — twice the touch minimum, so a three-line label plus a +qualifier fits without pushing the next row. + +**Density.** Fewer, larger elements. If a screen needs a scroll before the fifth item, the +screen is wrong, not the scroll. + +**App-local dimensions are allowed** where a surface genuinely differs (launcher tiles, +settings margins) — but they are declared in the app's `dimens.xml` with a comment saying +why, and they never redefine a suite token. + +## Elevation & Depth + +**There are no shadows.** Not one, in any app. A shadow on this display is a grey smear +that costs a composite pass and reads as dirt under sunlight. Depth is tonal: +`background` → `surface` → `surface_raised`, three steps, plus a 1dp `ev_outline` +hairline on every card. + +The hairline is not decoration. `surface` and `surface_raised` differ by roughly one +tonal step; in direct sun that difference vanishes and the card boundary goes with it. +The outline is what survives. + +State is expressed by the pressed overlay, never by lift. + +## Shapes + +One radius: **12dp** (`ev_corner`), on cards, buttons, chips, tiles and dialogs. Not two, +not a scale. A single radius is what makes five apps by different hands read as one suite, +and it is the cheapest consistency there is. + +Borders are 1dp `ev_outline`. No dividers where a gap will do — `spacing_md` between two +cards separates them better than a line, and costs no ink. + +The launcher icon is the one place a different silhouette appears: a squircle tile, which +is the platform's shape, not ours. + +## Components + +Component styles are named identically in every app so a screen can be moved between +repositories without a rename: `Theme.EV`, `Widget.EV.*`, `Text.EV.*`, +`ThemeOverlay.EV.*`. + +### Buttons +- **Shape:** 12dp radius. +- **Primary** (`Widget.EV.Button`): filled accent, `on_accent` label, `text_body` (20sp), + min height 72dp, `spacing_lg` horizontal padding. +- **Outlined** (`Widget.EV.Button.Outlined`): 1dp accent border, accent label, same metrics. +- **Dialog** (`Widget.EV.DialogButton`): text button, but pulled up to the same 72dp + target and 140dp min width. Dialog buttons are the smallest thing in any app and the + most likely to be mis-tapped. +- **Pressed:** `ev_pressed_strong` overlay. No ripple travel, no scale, no lift. + +### Cards / containers +- 12dp radius, `ev_surface`, 1dp `ev_outline`, `spacing_md` internal padding. +- Selected state: background steps to `ev_surface_raised` **and** the leading label + turns accent. Two signals, because one is a colour. + +### Switches +- `Widget.EV.Switch`, 72dp min height, `text_body` label. A switch that needs a precise + tap is a switch that gets missed at 90 km/h. + +### Section headers +- `Text.EV.SectionHeader`: 16sp, bold, `0.12` tracking, accent. The only tracked type in + the suite; the tracking is what lets it work at the size floor without shouting. + +### Dialogs +- `ThemeOverlay.EV.Dialog` on `ev_surface_raised`. Material's default dialog palette is + a grey-mauve tuned for indoors and it is the first thing to wash out; every app overrides it. +- A picker that fills the screen (`Theme.EV.Picker`) is not floating and does not dim: + `windowIsFloating=false`, `backgroundDimEnabled=false`. In landscape, a bottom sheet + leaves a third of the height, which means one column and a scrollbar by the fifth entry. + There is nothing behind a picker worth seeing. +- A confirmation or warning with explanatory content is a **dedicated full-screen + window, never a popup**. Put its explicit confirm and cancel actions together at the + upper-right, consistently with the suite's editor windows, keep both at the 72dp touch + minimum, and let the content use all remaining space. Reserve a compact dialog for a + short, single-decision interruption whose complete message remains immediately readable + at 70 cm. + +### Lists +- Row height ≥ 72dp, `ev_surface`, `spacing_sm` gaps. No swipe-to-delete: a gesture with + no visible affordance and no undo does not belong in a vehicle. Destructive actions are + a button plus a confirmation. + +## Iconography + +The launcher icon is the suite's signature and the one place it is allowed to have fun. + +**The system.** Adaptive icon, 108dp, safe zone 66dp centred. Three fixed elements: + +1. **Tile** — flat `#2A2A2A` charcoal. No gradient. Identical in every app, and identical + in light and dark: this is the tile EVProfile has always had, and it is what makes a + row of suite apps read as a row. +2. **Glyph** — `#F4F7FA`, geometric, 5dp stroke, round caps and joins, with exactly one + `#E0424C` red element as the accent (3.45:1 on the tile — it is a mark, not text). + The glyph says what the app does at 48px. EVProfile's glyph is the `MG⁴` lockup: it + is the flagship, so it wears the suite mark. +3. **Caption** — the product word beneath the glyph, `#C6CFD8`, small caps, `0.18` tracking. + +**The glyphs** + +| App | Glyph | +|---|---| +| EVProfile | `MG⁴` lockup — white letters, red superscript 4 | +| EVTasker | A branch: one input, a condition, two outcomes | +| EVABRPUploader | An upward arrow leaving two broadcast arcs | +| EVChargePilot | A battery crossed by a measured energy trace | +| EVLauncher | A four-tile grid, one tile red | +| EVSwipe | Two chevrons and a swipe trail | + +**Rules.** Vector (`VectorDrawable`) only — the same file serves every density and costs +one file instead of five PNGs. Every icon ships a `` layer for themed icons. +No photographic content, no bevel, no drop shadow, no stock clipart. If a glyph is not +legible at 48px in greyscale, it is the wrong glyph. + +## Nomenclature + +**Product names** are `EV` — space, title case: *EVProfile*, *EVTasker*, +*EVABRPUploader*, *EVLauncher*, *EVSwipe*. This is what +`app_name` holds and what the driver sees under the icon. + +**Repositories, modules and directories** are `EV` — no space: `EVProfile`, +`EVTasker`, `EVABRPUploader`, `EVChargePilot`, `EVLauncher`, `EVSwipe`, `EVHardware`. + +**Package ids** are `com.evsuite.`; a family shares a segment +(`com.evsuite.launcher`, `com.evsuite.swipe`). + +**Resources** are `snake_case` with a suite prefix when the token is shared +(`ev_surface`, `ev_corner`) and unprefixed when app-local (`catalog_tile_height`). +Styles are `Theme.EV`, `Widget.EV.`, `Text.EV.`. + +**The suite** is *EVSuite* — one word, when referring to all of it. + +## Documentation + +Every repository carries the same files, in the same order, with the same headings. + +| File | Contents | +|---|---| +| `README.md` | The one below. English. | +| `DESIGN.md` | This file, verbatim. | +| `AGENTS.md` | Repo-specific agent context. | +| `CONTRIBUTING.md` | How to build, test, submit. | +| `SECURITY.md` | Threat model, disclosure. | +| `DISCLAIMER.md` | Vehicle-safety disclaimer. | +| `CHANGELOG.md` | Keep a Changelog format. | +| `LICENSE.md` | Licence text. | + +**README skeleton** — sections in this order, omitting any that do not apply: + +``` +# EV + + +## Contents +## Screenshots +## Overview +## Features +## Requirements +## How it works + … app-specific detail sections belong here, and only here +## Install +## Configuration +## Building +## Project layout +## Project documents +## Security +## Contributing +## Legal +``` + +The `## Contents` table of contents is generated from the headings, not hand-written: +`node tools/sync-docs.mjs`. A hand-written TOC goes stale the first time a heading +is renamed, and a dead anchor in a README is a small lie about the project. + +Translations live in `README..md` and are linked from the top of the English +README — never interleaved into it. A bilingual README doubles the size of every future edit. + +## Constraint history + +Why the rules above are the shape they are. Each of these was paid for. + +- **7:1, not 4.5:1.** Secondary text shipped at `#9EA6AE` (6:1) and was unreadable in + daylight. The floor moved to AAA and the whole ladder was re-derived. +- **The outline exists because tonal layering alone failed.** `surface` and + `surface_raised` are one step apart and merge under sun. The border was added — then had + to be raised from `#4A525B` (2.25:1) to `#7A8492` (4.70:1), because a border below the + 3:1 non-text floor is a border that isn't there. +- **72dp, not 48dp.** Google's target assumes a hand-held phone. Missed taps on a moving + vehicle drove it to 72dp, and dialog buttons and switches were the last holdouts. +- **16sp floor.** Anything smaller stopped being legible at 70 cm through a reflection. +- **Full-screen pickers.** A bottom sheet in landscape left a third of the height: one + column, scrolling by the fifth entry. The picker became a full-screen non-floating window. +- **No shadows.** Cost a composite pass on MT2712 and read as smudge in sun. +- **Dark-first, DayNight second.** The suite ran dark-only for a long time because a light + background facing the driver at night is glare. It is now DayNight across all five apps + by explicit decision — which means the light palette carries the same 7:1 obligation + the dark one always had, and the pale dark-mode accent could not simply be reused. +- **Colour never carries meaning alone.** Verdict rows show a word as well as a hue. +- **Vector icons, no PNG sets.** Five density buckets per app, for an icon, on a vehicle + where APK size is a stability concern. +- **AAOS 9, MT2712, must not destabilise the vehicle.** Everything above is downstream of + this. When a design choice and stability disagree, stability wins and the design choice + gets a comment explaining what it gave up. + +## Do's and Don'ts + +### Do +- **Do** verify contrast numerically before committing a colour. Every ratio in this file + is measured; new ones must be too. +- **Do** put the reason in the resource file. Every override of a Material default in this + suite carries a comment saying what would break without it. That comment is why the rule + survives the next refactor. +- **Do** reference roles (`@color/ev_surface`, `@dimen/touch_target`), never literals. + A hex value in a layout is a bug. +- **Do** give every state two signals — colour plus text, or colour plus position. +- **Do** design landscape first, and check that nothing needs a scroll before the fifth item. +- **Do** name new resources with the suite prefix if another app could want them. + +### Don't +- **Don't** add a shadow, gradient, or elevation. Depth is tonal. +- **Don't** introduce a second accent hue. One accent, three status colours, that is the + whole palette. +- **Don't** ship type below 16sp or a target below 72dp, including in dialogs. +- **Don't** use `Theme.Material3.Dark` or `.Light` directly — always `.DayNight`, so both + palettes stay wired. +- **Don't** rely on a gesture with no button fallback. No swipe-to-delete, no long-press-only. +- **Don't** animate anything ambient. Motion is confirmation of a touch, or it is absent. +- **Don't** put a light background behind a full-screen surface at night without checking + it against the dark palette — the driver's night vision is a safety property. +- **Don't** hardcode a pixel size or assume a DPI. This platform lets the user change it. diff --git a/EVHardware/DISCLAIMER.md b/EVHardware/DISCLAIMER.md new file mode 100644 index 0000000..0fbd9d5 --- /dev/null +++ b/EVHardware/DISCLAIMER.md @@ -0,0 +1,32 @@ +# Disclaimer — no warranty, no liability + +**Use this software entirely at your own risk.** + +This project is provided **"as is"**, without warranty of any kind, express or implied, +including but not limited to the warranties of merchantability, fitness for a particular +purpose and non-infringement. In no event shall the authors or contributors be liable for +any claim, damages or other liability, whether in an action of contract, tort or otherwise, +arising from, out of or in connection with the software or its use. + +## What that means concretely + +- This library **reads and writes settings on a vehicle**. Any app that embeds it can + change how a car behaves. Installing such an app is your decision and your + responsibility, including any effect on the head unit's stability, your warranty, your + insurance, or your vehicle's roadworthiness. +- The safety gate refuses road-behaviour writes above 0 km/h and fails closed on an + unreadable speed. It is a guard, **not** a guarantee — it does not make an ill-considered + write safe, it only prevents it landing while moving. +- **Compatibility is inferred, not tested.** `docs/firmware-matrix.md` is derived from this + code, not from running on cars. Consumers must check what a given vehicle actually + exposes at runtime. +- Vehicle access uses **undocumented runtime interfaces**. They can change or disappear + with any firmware update. +- Climate and window signals are **read-only and unverified** on MG4 hardware. + +## Not affiliated + +This project is **not affiliated with, endorsed by, or supported by** SAIC Motor, MG Motor, +or Google. MG, MG4 and related names and logos are trademarks of their respective owners. +They are used solely to identify compatibility with certain vehicles; no official origin, +certification or approval is claimed. diff --git a/EVHardware/LICENSE b/EVHardware/LICENSE new file mode 100644 index 0000000..393855a --- /dev/null +++ b/EVHardware/LICENSE @@ -0,0 +1,74 @@ +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. + +**You** refers to the individual or entity agreeing to these terms. + +**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the software under these terms. + +**Use** means anything you do with the software requiring one of your licenses. +Required Notice: Copyright (c) 2026 EVHardware contributors diff --git a/EVHardware/LICENSE.md b/EVHardware/LICENSE.md new file mode 100644 index 0000000..568337e --- /dev/null +++ b/EVHardware/LICENSE.md @@ -0,0 +1,15 @@ +# Licence + +Source-available under the **PolyForm Noncommercial License 1.0.0** — see +[`LICENSE`](LICENSE). Commercial use is not permitted. + +EVHardware is the shared vehicle layer for the EVSuite apps (EVProfile, EVTasker, +EVABRPUploader). Parts of it originate in EVProfile, itself a fork whose upstream licence +situation is unresolved. PolyForm Noncommercial applies only to rights held by EVHardware +contributors. If an upstream author ever objects to a specific piece, the right response is +to honour that rather than argue. + +## No warranty + +The licence disclaims warranty and liability, and this software reads and writes a **vehicle** — +see [`DISCLAIMER.md`](DISCLAIMER.md) for what that means concretely. diff --git a/EVHardware/README.md b/EVHardware/README.md new file mode 100644 index 0000000..f0b10f3 --- /dev/null +++ b/EVHardware/README.md @@ -0,0 +1,205 @@ +# EVHardware + +

EVHardware

+ +[![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) +[![License: PolyForm Noncommercial](https://img.shields.io/badge/license-PolyForm%20Noncommercial-blue.svg)](LICENSE) + +> ⚠️ **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. + +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 +models, and the **condition/action catalogue** — consumed by the EVSuite apps so they do not +each re-derive it and drift apart. + +The design line: **the apps are thin. EVHardware provides the vehicle.** EVTasker is a rule +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. + +--- + +## Contents + +- [Overview](#overview) +- [How it works](#how-it-works) +- [Install](#install) +- [The EVSuite](#the-evsuite) +- [Building](#building) +- [Project documents](#project-documents) +- [Security](#security) +- [Contributing](#contributing) +- [Legal](#legal) + +## Overview +| Area | Types | +|---|---| +| Vehicle access | `EVHardware` — Katman1/4/5 reflection over `android.car` + SAIC SDK, per-firmware routing | +| Safety | `VehicleWriteGate` (0 km/h, fail-closed) + `@RequiresStandstill` on every gated setter | +| Vendor services | `saic.*` — the AIDL the head unit's own HVAC, charging, radio, hands-free and TTS apps use | +| Radio | `SaicRadio` — play, silence, fail-closed play/pause toggle, FM/AM tune, station stepping (DAB included), screen | +| Energy telemetry | `EnergyTelemetryReader`, `EnergySnapshot` — coherent nullable SOC, range, power, temperature, charging, tyre and climate reads | +| Energy accounting | `TripDetector`, `EnergyTripAccumulator`, `ChargingSessionMeter`, `EnergyTripHistoryStore` — tested automatic boundaries, integration and atomic local persistence | +| Firmware | `FirmwareInfo` (detection + capability helpers), `FirmwareGen` | +| Models | `DrivingProfile`, `DriveMode`, `RegenLevel`, `ProfileBackup` | +| Catalogue | `ConditionType`, `ActionType`, `ValueSpec`, `VehicleEnums`, `SnapshotKeys` | +| Compatibility | `@SupportedOn`, `FirmwareSupport`, `FirmwareMatrix` → [docs/firmware-matrix.md](docs/firmware-matrix.md) | +| Diagnostics | `AppLogger` (ring buffer, no ADB needed), `diag.CrashLogger`, `diag.PrivateBin` | + +### The safety gate and `@RequiresStandstill` + +`VehicleWriteGate` refuses any road-behaviour write above 0 km/h, and fails closed when the +speed is unreadable. Every gated setter in `EVHardware` carries `@RequiresStandstill`, and +a unit test asserts that exact set — a setter cannot silently gain or lose gating. Comfort +writes (seat/steering heating, volume, brightness, audio) are not gated and not annotated. + +The threshold is fixed at exactly 0 km/h. It is not configurable, and no secondary signal +(including park) can rescue an unreadable speed. Every decision reads the speed again so a +sequence is cancelled as soon as the vehicle moves or the signal disappears. + +### Firmware compatibility is generated, not written + +Each vehicle catalogue entry carries `@SupportedOn(...)`, derived from `FirmwareInfo` and +`EVHardware`'s per-generation routing. [docs/firmware-matrix.md](docs/firmware-matrix.md) +is rendered from those annotations by `FirmwareMatrix` and checked by a test — never edited +by hand. Consumers use the same annotations to hide entries unsupported on the connected +car. + +> The matrix is derived from code, **not** on-vehicle testing. Consumers should surface a +> diagnostic that reads each signal live; unreadable ≠ unsupported. + +### Climate, glass and charging go through the vendor services + +The AOSP property ids for HVAC and window position match the R69 runtime names but are not +confirmed on every MG4 generation. They are still used for *reads*, where a wrong id returns null +and nothing else happens — but nothing is written through them, because writing an unverified +id to a vehicle is a different kind of mistake. + +Writes go to `saic.*` instead: the binder interfaces the car's own HVAC, charging and vehicle +apps call. The integration contract is known; whether a +given firmware answers is a bind, which consumers report live rather than tabulate. The +electric tailgate stays out — the launcher defines OPEN and CLOSE as the same value, so it is +a pulse whose direction depends on state this cannot read. + +--- + +## How it works +EVHardware is consumed as a **git submodule** exposed as a Gradle subproject. In a +consumer app: + +```bash +git submodule add https://github.com/malys/EVHardware EVHardware +``` + +`settings.gradle.kts`: + +```kotlin +include(":evhardware") +project(":evhardware").projectDir = file("EVHardware/lib") +``` + +`app/build.gradle.kts`: + +```kotlin +dependencies { implementation(project(":evhardware")) } +``` + +Vehicle writes require the app to be signed with the ROM **platform key** (the car +permissions are `signature|privileged`); reads of standard AAOS properties work with less. +The library itself declares no permissions and no `sharedUserId` — that is the consuming +app's decision. + +--- + +### External projects: 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 +`./gradlew :lib:publishToMavenLocal` installs it to `~/.m2` for local experimentation: + +```kotlin +repositories { + mavenLocal() + maven { url = uri("https://maven.pkg.github.com/malys/EVHardware") } // GitHub Packages +} +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 +./gradlew :lib:assembleDebug # the AAR +``` + +JDK 17 (AGP 9.1.1 / Gradle 9.3.1 / compileSdk 36). `android.car` is not on the compile SDK, so all vehicle access is +reflection — there is nothing to test on an emulator; the JVM tests cover the decision +logic (gate, firmware annotations, catalogue consistency, telemetry validation and energy +integration). + +### Adding a condition or action + +One enum entry in `catalog/ConditionType` or `catalog/ActionType`, one string in +`lib/src/main/res/values/strings.xml` (+ `values-fr/`), and — for a vehicle entry — one +`@SupportedOn(...)`. A test fails if a vehicle entry lacks it; the matrix regenerates on the +next test run. + +--- + +## Project documents +| Document | What it covers | +|---|---| +| [DESIGN.md](DESIGN.md) | The EVSuite design system — colour, type, touch targets, icons | +| [AGENTS.md](AGENTS.md) | Context for AI agents working in this repository | +| [CONTRIBUTING.md](CONTRIBUTING.md) | How to build, test and submit a change | +| [SECURITY.md](SECURITY.md) | Threat model and vulnerability disclosure | +| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer — read before installing | +| [docs/radio-action-gap.md](docs/radio-action-gap.md) | Which radio behaviours the catalogue carries, and which were rejected | +| [CHANGELOG.md](CHANGELOG.md) | Release history | +| [LICENSE.md](LICENSE.md) | Licence text | + +## Security +See [SECURITY.md](SECURITY.md). The library is the one place vehicle writes happen for its +consumers, so the gate, the closed action vocabulary, and the per-firmware routing all live +here rather than being reimplemented per app. + +## Contributing +Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. In short: this +code runs in a moving vehicle, so changes stay small, carry tests, and say in the diff +what would break without them. Anything touching the interface follows +[DESIGN.md](DESIGN.md). + +## Legal +Source-available under the PolyForm Noncommercial License 1.0.0. Commercial use is not +permitted. See [LICENSE](LICENSE) and [LICENSE.md](LICENSE.md). Runs on a vehicle; see +[DISCLAIMER.md](DISCLAIMER.md). diff --git a/EVHardware/SECURITY.md b/EVHardware/SECURITY.md new file mode 100644 index 0000000..2b0d7ba --- /dev/null +++ b/EVHardware/SECURITY.md @@ -0,0 +1,40 @@ +# Security policy + +This library is the one place its consumer apps read and write a car. That makes it +security-relevant even though it holds no credentials and opens no network. + +## Reporting a vulnerability + +Please **do not** open a public issue for a vulnerability. Use GitHub's +[private vulnerability reporting](../../security/advisories/new) instead. Say what you were +able to do, on which firmware generation, and whether the vehicle was moving. + +## In scope + +- A road-behaviour write reaching the vehicle above 0 km/h, or on an unreadable speed + (the gate must fail closed). +- A gated setter missing `@RequiresStandstill`, or the gate being bypassable. +- A catalogue entry that maps to a vehicle capability outside its declared `@SupportedOn` + set, or a wrong property id that writes an unintended vehicle property. +- Deserialization of a crafted `DrivingProfile`/`ProfileBackup` causing unintended writes. + +## Not in scope + +- The library declaring no permissions and no `sharedUserId`. Granting vehicle access is + the **consuming app's** decision (platform signature / privileged install); report those + choices against the app, not here. +- Climate/window reads returning null on a firmware that does not expose them — that is the + unverified-and-read-only design. +- OEM firmware vulnerabilities — report to SAIC. + +## Design decisions + +- **The 0 km/h gate lives here**, applied inside the low-level write primitives, so every + consumer inherits it in one place. `@RequiresStandstill` documents each gated setter and + a test asserts the set. +- **No raw "write property 0xNNNN" API** is exposed to consumers beyond the typed setters; + the action catalogue is a closed vocabulary. +- **Climate/window writes are intentionally absent.** Reads are unverified; a write path + waits on on-vehicle confirmation. +- **Reflection targets are kept via `consumer-rules.pro`** so R8 in a consumer app cannot + strip the `android.car`/SAIC/model/catalogue names resolved by name at runtime. diff --git a/EVHardware/build.gradle.kts b/EVHardware/build.gradle.kts new file mode 100644 index 0000000..9ab6951 --- /dev/null +++ b/EVHardware/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + // AGP 9 provides built-in Kotlin compilation; no separate Kotlin plugin is applied. + alias(libs.plugins.android.library) apply false +} diff --git a/EVHardware/docs/catalogue.md b/EVHardware/docs/catalogue.md new file mode 100644 index 0000000..2e94102 --- /dev/null +++ b/EVHardware/docs/catalogue.md @@ -0,0 +1,242 @@ + + +# The EVTasker catalogue + +Every condition a rule can test and every action it can run, straight out of `ConditionType` and `ActionType`. *Firmware* is what the entry is annotated for; "any" means it depends on no vehicle firmware at all, not that it needs nothing — a notification still needs the permission, a call still needs a paired phone. The car's own Diagnostic screen stays the only answer for the car in front of you. + +## Conditions + +### Context + +| Condition | Entry | Tested against | Firmware | +|---|---|---|---| +| Bluetooth phone connected | `BT_DEVICE_CONNECTED` | a paired phone | any | +| Any Bluetooth device connected | `ANY_BT_CONNECTED` | on / off | any | +| Phone on board (Bluetooth) | `BT_DEVICE_ONBOARD` | a paired phone | any | +| Bluetooth hands-free phone | `BT_DEVICE_HANDSFREE` | a paired phone | any | +| Time of day | `TIME_OF_DAY` | a start and an end time | any | +| Day of week | `DAY_OF_WEEK` | weekdays | any | +| Exact date | `DATE` | a calendar date | any | +| Firmware generation | `FIRMWARE_GEN` | a list the app fills in | any | +| Near a place | `LOCATION_WITHIN` | a point and a radius (m) | any | +| Physical buttons | `PHYSICAL_BUTTON` | a button, short or long press | any | +| Wi-Fi network | `WIFI_SSID` | free text | any | +| 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 | +| 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 | + +### Environment + +| Condition | Entry | Tested against | Firmware | +|---|---|---|---| +| Outside temperature | `OUTSIDE_TEMP` | -30…50 °C (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Weather | `WEATHER_NOW` | Clear, Cloudy, Rain, Snow, Thunderstorm, Fog, Wind | any | + +### Driving + +| Condition | Entry | Tested against | Firmware | +|---|---|---|---| +| Ignition state | `IGNITION_STATE` | Locked, Off, Accessory, Running, Starting | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Gear in P | `IN_PARK` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Speed | `SPEED` | 0…200 km/h (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Drive mode | `DRIVE_MODE` | Eco, Normal, Sport, Snow, Custom | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Regeneration | `REGEN_LEVEL` | Low, Medium, High, Adaptive, Off, One pedal | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Odometer | `ODOMETER` | 0…500000 km (=, <, >) | any | +| Energy saving | `ENERGY_SAVING` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | + +### Energy + +| Condition | Entry | Tested against | Firmware | +|---|---|---|---| +| Battery level | `BATTERY_LEVEL` | 0…100 % (=, <, >) | SWI68, SWI165 | +| Charging | `CHARGING` | on / off | SWI68, SWI165 | +| Charge limit | `CHARGE_LIMIT` | 0…100 % (=, <, >) | SWI68, SWI165 | +| Charging state | `CHARGING_STATUS` | Unplugged, Charging (AC), Charging (DC), Plugged in, not charging, Charge complete, Charging fault | SWI68, SWI165 | +| Scheduled charging on | `CHARGE_SCHEDULE_ENABLED` | on / off | SWI68, SWI165 | +| Charging starts at | `CHARGE_WINDOW_START` | a time of day (=, <, >) | SWI68, SWI165 | +| Charging stops at | `CHARGE_WINDOW_STOP` | a time of day (=, <, >) | SWI68, SWI165 | +| Remaining range | `RANGE_ESTIMATE` | 0…600 km (=, <, >) | SWI68, SWI165 | +| Battery pre-heating on | `BATTERY_PREHEAT` | on / off | SWI68, SWI165 | + +### Climate + +| Condition | Entry | Tested against | Firmware | +|---|---|---|---| +| Climate on | `CLIMATE_ON` | on / off | SWI68, SWI165 | +| Air conditioning on | `AC_ON` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Climate AUTO on | `HVAC_AUTO` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| 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 | +| 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 | +| A window is open | `WINDOW_OPEN` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Window opening | `WINDOW_POSITION` | 0…100 % (=, <, >) | SWI68, SWI165 | +| Driver window opening | `WINDOW_DRIVER` | 0…100 % (=, <, >) | SWI68, SWI165 | +| Passenger window opening | `WINDOW_PASSENGER` | 0…100 % (=, <, >) | SWI68, SWI165 | +| Rear left window opening | `WINDOW_REAR_LEFT` | 0…100 % (=, <, >) | SWI68, SWI165 | +| Rear right window opening | `WINDOW_REAR_RIGHT` | 0…100 % (=, <, >) | SWI68, SWI165 | + +### Comfort + +| Condition | Entry | Tested against | Firmware | +|---|---|---|---| +| Left seat heating | `SEAT_HEAT_LEFT` | 0…3 (=, <, >) | SWI133, SWI68, SWI165 | +| Right seat heating | `SEAT_HEAT_RIGHT` | 0…3 (=, <, >) | SWI133, SWI68, SWI165 | +| Steering wheel heating | `STEERING_HEAT` | on / off | SWI133, SWI68, SWI165 | +| Screen brightness | `SCREEN_BRIGHTNESS` | 5…100 % (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | + +### Audio + +| Condition | Entry | Tested against | Firmware | +|---|---|---|---| +| Media playing | `MEDIA_PLAYING` | on / off | any | +| Radio playing | `RADIO_PLAYING` | on / off | SWI68, SWI165 | +| Media volume | `MEDIA_VOLUME` | 0…max (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | + +### Driver assistance + +| Condition | Entry | Tested against | Firmware | +|---|---|---|---| +| Emergency braking (AEB) | `AEB_ENABLED` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| AEB mode | `AEB_MODE` | Alert only, Alert + braking | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| AEB sensitivity | `AEB_SENSITIVITY` | Low, Standard, High | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| ELK mode | `ELK_MODE` | Off, Warning (LDW), Assist (LDP), Emergency keeping (ELK) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| ELK sensitivity | `ELK_SENSITIVITY` | Low, Standard, High | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| ACC / TJA mode | `ACC_TJA_MODE` | Off, Adaptive cruise (ACC), Traffic jam assist (TJA) | SWI68, SWI165, SWI69, SWI131, SWI132 | +| Speed limiter | `LIMITER_MODE` | Off, Manual, Intelligent | SWI68, SWI165, SWI69, SWI131, SWI132 | +| Traffic sign recognition (TSR) | `TSR` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Overspeed alarm | `OVERSPEED_ALARM` | on / off | SWI133, SWI132 | +| Speed limit tone | `SPEED_LIMIT_TONE` | on / off | SWI133, SWI132 | +| ADAS sound warning | `SOUND_WARNING` | on / off | SWI68, SWI165, SWI69, SWI131, SWI132 | + +A reading the car does not answer makes the rule **not evaluable**: it does not fire, and it does not count as false. + +## Actions + +### Profile + +| Action | Entry | Asks for | When stopped only | Firmware | +|---|---|---|:--:|---| +| Apply a profile | `APPLY_PROFILE` | an EVProfile profile | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Ask for a profile | `SHOW_PROFILE_PICKER` | — | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | + +### Driving + +| Action | Entry | Asks for | When stopped only | Firmware | +|---|---|---|:--:|---| +| Drive mode | `SET_DRIVE_MODE` | Eco, Normal, Sport, Snow, Custom | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Regeneration | `SET_REGEN_LEVEL` | Low, Medium, High, Adaptive, Off, One pedal | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| One pedal driving | `SET_ONE_PEDAL` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Energy saving | `SET_ENERGY_SAVING` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | + +### Comfort + +| Action | Entry | Asks for | When stopped only | Firmware | +|---|---|---|:--:|---| +| Left seat heating | `SET_SEAT_HEAT_LEFT` | 0…3 | — | SWI133, SWI68, SWI165 | +| Right seat heating | `SET_SEAT_HEAT_RIGHT` | 0…3 | — | SWI133, SWI68, SWI165 | +| Steering wheel heating | `SET_STEERING_HEAT` | on / off | — | SWI133, SWI68, SWI165 | +| Screen brightness | `SET_SCREEN_BRIGHTNESS` | 5…100 % | — | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | + +### Climate + +| Action | Entry | Asks for | When stopped only | Firmware | +|---|---|---|:--:|---| +| 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 | +| 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 | +| Air recirculation | `SET_RECIRCULATION` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 | +| Fan level | `SET_FAN_LEVEL` | 0…11 | — | SWI68, SWI165, SWI69, SWI131, SWI132 | +| Windscreen defroster | `SET_FRONT_DEFROST` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 | +| Rear window defroster | `SET_REAR_DEFROST` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 | +| All windows ⚠️ | `SET_WINDOWS` | Closed, Open | yes | SWI68, SWI165 | +| Driver window ⚠️ | `SET_WINDOW_DRIVER` | Closed, Open | yes | SWI68, SWI165 | +| Passenger window ⚠️ | `SET_WINDOW_PASSENGER` | Closed, Open | yes | SWI68, SWI165 | +| Rear left window ⚠️ | `SET_WINDOW_REAR_LEFT` | Closed, Open | yes | SWI68, SWI165 | +| Rear right window ⚠️ | `SET_WINDOW_REAR_RIGHT` | Closed, Open | yes | SWI68, SWI165 | +| Door locks | `SET_DOOR_LOCK` | on / off | — | SWI68, SWI165 | + +### Energy + +| Action | Entry | Asks for | When stopped only | Firmware | +|---|---|---|:--:|---| +| 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 | +| Battery pre-heating | `SET_BATTERY_PREHEAT` | on / off | — | SWI68, SWI165 | + +### Audio + +| Action | Entry | Asks for | When stopped only | Firmware | +|---|---|---|:--:|---| +| Media volume | `SET_MEDIA_VOLUME` | 0…max | — | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Media volume, step | `ADJUST_MEDIA_VOLUME` | -30…30 | — | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Left / right balance | `SET_AUDIO_BALANCE` | -9…9 | — | SWI69, SWI131, SWI132 | +| Front / rear fader | `SET_AUDIO_FADER` | -9…9 | — | SWI69, SWI131, SWI132 | +| Tone | `SET_TONE_CONTROL` | -9…9 | — | SWI69, SWI131, SWI132 | +| Bose sound profile | `SET_BOSE_SOUND_TYPE` | 0…3 | — | SWI69, SWI131, SWI132 | +| 3D effect | `SET_3D_EFFECT` | 0…3 | — | SWI69, SWI131, SWI132 | +| Speed-adaptive volume | `SET_SPEED_VOLUME` | 0…3 | — | SWI69, SWI131, SWI132 | +| Play the radio | `PLAY_RADIO` | — | — | SWI68, SWI165 | +| Radio band and station | `TUNE_RADIO` | band (AM, FM, DAB), frequency, play | — | SWI68, SWI165 | +| Next radio station | `RADIO_NEXT_STATION` | — | — | SWI68, SWI165 | +| Previous radio station | `RADIO_PREV_STATION` | — | — | SWI68, SWI165 | +| Silence the radio | `PAUSE_RADIO` | — | — | SWI68, SWI165 | +| Radio play/pause | `RADIO_PLAY_PAUSE` | — | — | SWI68, SWI165 | +| Open the radio screen | `OPEN_RADIO_SCREEN` | — | yes | SWI68, SWI165 | + +### Driver assistance + +| Action | Entry | Asks for | When stopped only | Firmware | +|---|---|---|:--:|---| +| Emergency braking (AEB) | `SET_AEB_ENABLED` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| AEB mode | `SET_AEB_MODE` | Alert only, Alert + braking | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| AEB sensitivity | `SET_AEB_SENSITIVITY` | Low, Standard, High | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Stability control (ESC) | `SET_ESC` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Drowsiness warning | `SET_DROWSINESS` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Drowsiness warning sensitivity | `SET_DROWSINESS_SENSITIVITY` | Low, Standard, High | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| ELK mode | `SET_ELK_MODE` | Off, Warning (LDW), Assist (LDP), Emergency keeping (ELK) | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| ELK sensitivity | `SET_ELK_SENSITIVITY` | Low, Standard, High | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| ACC / TJA mode | `SET_ACC_TJA_MODE` | Off, Adaptive cruise (ACC), Traffic jam assist (TJA) | yes | SWI68, SWI165, SWI69, SWI131, SWI132 | +| Speed limiter | `SET_LIMITER_MODE` | Off, Manual, Intelligent | yes | SWI68, SWI165, SWI69, SWI131, SWI132 | +| Traffic sign recognition (TSR) | `SET_TSR` | on / off | yes | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 | +| Overspeed alarm | `SET_OVERSPEED_ALARM` | on / off | yes | SWI133, SWI132 | +| Speed limit tone | `SET_SPEED_LIMIT_TONE` | on / off | yes | SWI133, SWI132 | +| ADAS sound warning | `SET_SOUND_WARNING` | on / off | yes | SWI68, SWI165, SWI69, SWI131, SWI132 | +| Lane departure sound warning | `SET_LAS_WARNING_SOUND` | on / off | — | SWI132 | +| Lane departure steering vibration | `SET_LAS_WARNING_VIBRATION` | on / off | — | SWI132 | + +### System + +| Action | Entry | Asks for | When stopped only | Firmware | +|---|---|---|:--:|---| +| Launch an app | `LAUNCH_APP` | an installed app | — | any | +| Show a notification | `SHOW_NOTIFICATION` | free text | — | any | +| Speak a message | `SPEAK_TEXT` | free text | — | any | +| Navigate to | `NAVIGATE_TO` | an address or coordinates | — | any | +| Ask for confirmation | `ASK_CONFIRM` | a question, and how long to wait | — | any | +| Send a text message | `SEND_SMS` | a number or a contact, and a message | — | any | +| Enable a rule | `ENABLE_RULE` | one of your rules | — | any | +| Disable a rule | `DISABLE_RULE` | one of your rules | — | any | +| Media control | `MEDIA_CONTROL` | Play / pause, Next track, Previous track | — | any | +| Bluetooth | `SET_BLUETOOTH` | on / off | — | any | +| Wi-Fi | `SET_WIFI` | on / off | — | any | +| Call webhook | `WEBHOOK` | a URL, and an optional body | — | any | +| Wait | `DELAY` | 1…60 s | — | any | +| Call | `CALL_NUMBER` | a number or a contact | — | SWI68, SWI165 | +| Call a contact | `CALL_CONTACT` | a number or a contact | — | SWI68, SWI165 | + +⚠️ — the firmware carries the property, but nothing establishes that writing it does anything (`ActionType.writeProven`). Those actions are hidden from the editor and refused by the executor until one is observed to work. + +The per-generation grid is in [firmware-matrix.md](firmware-matrix.md), generated from the same annotations. diff --git a/EVHardware/docs/energy-signal-candidates.md b/EVHardware/docs/energy-signal-candidates.md new file mode 100644 index 0000000..0fa2b7a --- /dev/null +++ b/EVHardware/docs/energy-signal-candidates.md @@ -0,0 +1,48 @@ +# Public energy-signal candidates + +Reviewed 2026-08-28 for CP-004. This review uses only public AOSP catalogues and the +project's existing runtime read seam. It does not use proprietary artifacts or non-public +property catalogues. + +## Safety and validation boundary + +Every entry below is read-only and starts with `validated: no`. A public property number proves +only that AOSP assigned a number and semantics; it does not prove that an MG4 VHAL implements or +publishes it. No candidate may enter `EnergySnapshot`, a production fallback or a stable UI until +CP-003 records availability, unit, scale, sign and update cadence for every supported firmware +generation. An unavailable or non-finite read stays null. + +## Public catalogue findings + +| Signal | Public declaration | Type / area / unit / mode | AOSP coverage | EVSuite disposition | validated | +|---|---|---|---|---|---| +| `EV_CURRENT_BATTERY_CAPACITY` (`291504909`, `0x1160030D`) | [AOSP snapshot `c80b787`](https://android.googlesource.com/platform/packages/services/Car/+/c80b787d499243d120c2738eb68e7c0520c7238d/car-lib/src/android/car/VehiclePropertyIds.java) | Float / global / Wh / on-change / read-only | Public post-AAOS 9 API; the declaration requires Car UDC with Android 13 or later | Highest-value new candidate: status-aware probe in unstable evidence capture only | no | +| `EV_BATTERY_AVERAGE_TEMPERATURE` (`291504910`, `0x1160030E`) | [AOSP change `5b96f99`](https://android.googlesource.com/platform/packages/services/Car/+/5b96f99271e9209774a312c8f0fc1279dccf0678/car-lib/src/android/car/VehiclePropertyIds.java) | Float / global / °C / continuous / read-only | Added to the public API in December 2023 | Already read by the CP-001 baseline as nullable battery temperature; CP-004 adds no duplicate candidate | no | +| `EV_BATTERY_INSTANTANEOUS_CHARGE_RATE` (`291504908`, `0x1160030C`) | [Android 9 AOSP snapshot `8686c86`](https://android.googlesource.com/platform/packages/services/Car/+/8686c86b38a590712412fea2dd083181aceba4f2/car-lib/src/android/car/VehiclePropertyIds.java) | Float / global / mW / continuous / read-only | Android 9 public catalogue | Existing CP-001 battery-power input; its live scale and sign still require CP-003 evidence | no | + +All cited files are Copyright Android Open Source Project and licensed Apache-2.0 at the cited +commit. The review records public declarations, not copied implementation code. Vehicle coverage +remains unknown for SWI68, SWI69, SWI131, SWI132, SWI133 and SWI165 until CP-003 runs on each one. + +## Explicit negative results + +| Requested quantity | Public catalogue result | Consequence | +|---|---|---| +| HVAC, compressor or PTC electrical power | No Android 9 or reviewed later public vehicle property. `HVAC_POWER_ON` is only a boolean subsystem state. | Climate energy can only be estimated from validated pack power and controlled trials. | +| Battery-heater power | No public power or energy property found. Battery temperature is a temperature, not heater consumption. | No direct measurement candidate. | +| DC-DC or accessory load | No public power or energy property found. | No direct measurement candidate. | +| Cumulative battery discharge | No public cumulative counter found. `EV_BATTERY_LEVEL` is current stored energy. | Integrate validated instantaneous power, retaining uncertainty and gaps. | +| Cumulative regeneration | No public cumulative counter found. Later regenerative-braking properties describe a state, not recovered energy. | Integrate only after CP-003 proves the power sign convention. | +| Per-trip energy | No public per-trip energy property found. | EVSuite trip totals remain derived from validated samples. | + +The search deliberately stops here: sweeping arbitrary property ids would produce ambiguous data +and broaden the runtime surface without provenance. + +## Probe wiring + +`EV_CURRENT_BATTERY_CAPACITY` is read through the same status-aware reflected +`CarPropertyManager.getProperty` path as existing telemetry. The read is explicitly branched over +the supported firmware generations and returns null for unknown firmware, unavailable status, +negative values, exceptions and non-finite values. It is exposed only by +`EnergyTelemetryReader.readEvidence()` and recorded as +`candidate.evCurrentBatteryCapacityWh`; ordinary `read()` and `EnergySnapshot` are unchanged. diff --git a/EVHardware/docs/firmware-matrix.md b/EVHardware/docs/firmware-matrix.md new file mode 100644 index 0000000..f1852b1 --- /dev/null +++ b/EVHardware/docs/firmware-matrix.md @@ -0,0 +1,161 @@ + + +### Conditions + +| Entry | SWI133 | SWI68 | SWI165 | SWI69 | SWI131 | SWI132 | +|---|:--:|:--:|:--:|:--:|:--:|:--:| +| BT_DEVICE_CONNECTED | — | — | — | — | — | — | +| ANY_BT_CONNECTED | — | — | — | — | — | — | +| BT_DEVICE_ONBOARD | — | — | — | — | — | — | +| BT_DEVICE_HANDSFREE | — | — | — | — | — | — | +| TIME_OF_DAY | — | — | — | — | — | — | +| DAY_OF_WEEK | — | — | — | — | — | — | +| DATE | — | — | — | — | — | — | +| FIRMWARE_GEN | — | — | — | — | — | — | +| LOCATION_WITHIN | — | — | — | — | — | — | +| PHYSICAL_BUTTON | — | — | — | — | — | — | +| WIFI_SSID | — | — | — | — | — | — | +| IN_CALL | — | — | — | — | — | — | +| DRIVE_DURATION | — | — | — | — | — | — | +| RANDOM_CHANCE | — | — | — | — | — | — | +| WEATHER_NOW | — | — | — | — | — | — | +| ODOMETER | — | — | — | — | — | — | +| MEDIA_PLAYING | — | — | — | — | — | — | +| DATA_USED_TODAY | — | — | — | — | — | — | +| DATA_USED_MONTH | — | — | — | — | — | — | +| OUTSIDE_TEMP | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| IGNITION_STATE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| IN_PARK | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| DRIVE_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| REGEN_LEVEL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| ENERGY_SAVING | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| BATTERY_LEVEL | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| CHARGING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| CHARGE_LIMIT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| CHARGING_STATUS | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| CHARGE_SCHEDULE_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| CHARGE_WINDOW_START | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| CHARGE_WINDOW_STOP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| RANGE_ESTIMATE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| BATTERY_PREHEAT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| CLIMATE_ON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| AC_ON | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| HVAC_AUTO | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| RECIRC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| FAN_SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| TEMPERATURE_SET | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| ECON_MODE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| FRONT_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| REAR_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| WINDOW_OPEN | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| WINDOW_POSITION | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| WINDOW_DRIVER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| WINDOW_PASSENGER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| WINDOW_REAR_LEFT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| WINDOW_REAR_RIGHT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| DOORS_LOCKED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| FRONT_DOOR_OPEN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| RADIO_PLAYING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| AEB_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| AEB_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| ELK_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| ELK_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| ACC_TJA_MODE | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| LIMITER_MODE | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| TSR | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| OVERSPEED_ALARM | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | +| SPEED_LIMIT_TONE | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | +| SOUND_WARNING | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | + + +### Actions + +| Entry | SWI133 | SWI68 | SWI165 | SWI69 | SWI131 | SWI132 | +|---|:--:|:--:|:--:|:--:|:--:|:--:| +| LAUNCH_APP | — | — | — | — | — | — | +| SHOW_NOTIFICATION | — | — | — | — | — | — | +| SPEAK_TEXT | — | — | — | — | — | — | +| NAVIGATE_TO | — | — | — | — | — | — | +| ASK_CONFIRM | — | — | — | — | — | — | +| SEND_SMS | — | — | — | — | — | — | +| ENABLE_RULE | — | — | — | — | — | — | +| DISABLE_RULE | — | — | — | — | — | — | +| MEDIA_CONTROL | — | — | — | — | — | — | +| SET_BLUETOOTH | — | — | — | — | — | — | +| SET_WIFI | — | — | — | — | — | — | +| WEBHOOK | — | — | — | — | — | — | +| DELAY | — | — | — | — | — | — | +| APPLY_PROFILE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SHOW_PROFILE_PICKER | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_DRIVE_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_REGEN_LEVEL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_ONE_PEDAL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_ENERGY_SAVING | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_CLIMATE_POWER | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_CABIN_TEMP | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_AC | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_ECON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_CLIMATE_AUTO | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_RECIRCULATION | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_FAN_LEVEL | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_FRONT_DEFROST | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_REAR_DEFROST | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_WINDOWS ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_WINDOW_DRIVER ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_WINDOW_PASSENGER ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_WINDOW_REAR_LEFT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_WINDOW_REAR_RIGHT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_DOOR_LOCK | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_CHARGE_LIMIT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_CHARGING_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_CHARGE_SCHEDULE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_CHARGE_WINDOW | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_BATTERY_PREHEAT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| ADJUST_MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_AUDIO_BALANCE | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | +| SET_AUDIO_FADER | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | +| SET_TONE_CONTROL | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | +| SET_BOSE_SOUND_TYPE | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | +| SET_3D_EFFECT | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | +| SET_SPEED_VOLUME | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | +| PLAY_RADIO | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| TUNE_RADIO | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| RADIO_NEXT_STATION | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| RADIO_PREV_STATION | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| PAUSE_RADIO | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| RADIO_PLAY_PAUSE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| OPEN_RADIO_SCREEN | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SET_AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_AEB_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_AEB_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_ESC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_DROWSINESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_DROWSINESS_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_ELK_MODE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_ELK_SENSITIVITY | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_ACC_TJA_MODE | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_LIMITER_MODE | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_TSR | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_OVERSPEED_ALARM | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | +| SET_SPEED_LIMIT_TONE | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | +| SET_SOUND_WARNING | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | +| SET_LAS_WARNING_SOUND | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | +| SET_LAS_WARNING_VIBRATION | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | +| CALL_NUMBER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| CALL_CONTACT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | + +⚠️ — the firmware carries the property, but nothing establishes that writing it has any effect (`ActionType.writeProven`). These actions are hidden from the editors and refused by the executors until one is observed to work. diff --git a/EVHardware/docs/logo.svg b/EVHardware/docs/logo.svg new file mode 100644 index 0000000..e801e89 --- /dev/null +++ b/EVHardware/docs/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + EVHardware + Shared vehicle-access layer · safety gate · catalogue + SAIC MG4 · AAOS 9 · MT2712 + diff --git a/EVHardware/docs/radio-action-gap.md b/EVHardware/docs/radio-action-gap.md new file mode 100644 index 0000000..1506a1c --- /dev/null +++ b/EVHardware/docs/radio-action-gap.md @@ -0,0 +1,102 @@ +# Radio action gap — community references vs the EVSuite catalogue + +Decides every radio candidate raised by the 2026-08-28 community baseline (CR-020). The +comparison is against what `SaicRadio`, `SaicMediaPlayer` and `ActionType` already do, not +against the initial community audit, which under-counted the existing catalogue. + +**Provenance.** CarMediaPlayer (MIT) and the community DAB+ fix (no explicit license at the +baseline review, and it replaces an OEM application) are read as *behaviour* only. No code, +resource or replacement strategy from either is used here. What the head unit's own radio +app does is the evidence that matters, and the binding contract below comes from the vendor +SDK already documented in `SaicMedia.kt`. + +## What already existed + +| Capability | Where | Reachable from a rule | +| --- | --- | --- | +| Play / resume the tuner (`srcPlayRadio`, tx 27) | `SaicRadio.play` | `PLAY_RADIO` | +| Tune FM/AM by frequency (tx 8) | `SaicRadio.tune` | `TUNE_RADIO` | +| Next / previous station (tx 13 / 14) | `SaicRadio.nextStation` / `previousStation` | `RADIO_NEXT_STATION`, `RADIO_PREV_STATION` | +| Read the tuner's play state (tx 19, `RadioBean.state`) | `SaicRadio.isPlaying` | — (internal) | +| Pause / silence the tuner (`srcPauseRadio`, tx 28) | `SaicRadio.pause` | — (internal) | +| Open the radio screen (tx 26) | `SaicRadio.openScreen` | — (internal) | +| Play/pause of *whatever owns the audio* | `SaicMediaPlayer.command` | `MEDIA_CONTROL` | + +Three primitives existed in the library with no way to ask for them from a rule. That, not +a missing service call, is the actual gap. + +## Decisions + +| # | Candidate | Decision | Why | +| --- | --- | --- | --- | +| A | Pause / stop the radio | **add** — `PAUSE_RADIO` | `srcPauseRadio` is implemented and unreachable. `MEDIA_CONTROL` is not a substitute: it addresses whichever source owns the audio, so it silences Bluetooth when Bluetooth is playing. "Silence the tuner" was not expressible. | +| B | Explicit radio play/pause toggle on readable state | **add** — `RADIO_PLAY_PAUSE` | One shortcut for a driver who wants the tuner specifically, whatever the current source. Built on `isPlaying()` (`RadioBean.state`), because `AudioManager.isMusicActive` is **false while the radio plays** — a toggle driven by it would send "play" to a playing radio forever. Unknown state fails closed (see below). | +| C | Open the radio screen | **add, gated** — `OPEN_RADIO_SCREEN` | `startRadioActivity` is implemented and unreachable. It is the one radio action that is not audio-only: throwing a full-screen app in front of a driver at speed is a distraction, so it takes the standstill gate and is refused when speed is unreadable. | +| D | Select a band | **added** (2026-08-29, reversed), **merged into `TUNE_RADIO`** (2026-09-03) | Originally rejected because the binding contract exposes no band-only call. The owner asked for DAB support anyway, and the rejection turned out to be answering the wrong question: a band action does not need a band-only *call*, because `tune(band, frequencyKhz)` already carries the band as its first argument. `SELECT_RADIO_BAND` composes what is established — read the current band from `RadioBean`, tune into the requested one — and guesses no transaction code. The frequency it lands on is the station this car was last heard on for that band (`RadioBandMemory`), or the band floor followed by one `nextStation()` when it has never been there, so it does not strand the driver on an empty frequency. It shipped as its own `SELECT_RADIO_BAND` entry and was folded into `TUNE_RADIO` a week later — see *Merge* below. | +| E | Select a DAB service | **reject** (unchanged) | A DAB service is addressed by ensemble id + service id, not by a frequency, and neither is a stable identifier a driver could enter or a rule could store across a re-scan. No on-vehicle evidence establishes a portable contract. DAB is now reachable as a **band** (candidate D, inside `TUNE_RADIO`) and stepped through with `RADIO_NEXT_STATION` / `RADIO_PREV_STATION`; what stays out is selecting one named service directly. A DAB *block* is a real frequency in Band III, which is what a band switch tunes; a DAB *service* is not. | +| F | Restore the last audible radio state at ignition | **already covered** | Composition, not a new action: an ignition trigger plus `PLAY_RADIO`, whose `srcPlayRadio` resumes the last station by itself. A persistent service that remembered and re-asserted state would be a background watchdog the app does not have and does not want. | +| G | Radio artwork caching, station metadata display | **reject as an action** | Presentation, not automation. A rule cannot usefully "cache artwork"; a screen can show it. If it is ever wanted it belongs to an app's UI, reading `RadioBean`, not to the action catalogue. | +| H | Replace or patch the OEM radio APK (the DAB+ fix's strategy) | **reject** | Out of scope by the ticket and by the suite's boundary: EVSuite talks to the vendor services the car ships. It does not swap the car's applications, and the upstream fix carries no license that would permit reuse anyway. | + +## Failure behaviour of what was added + +- **Missing service.** All three call `SaicRadio`, whose `service.binder()` is null when the + radio service is absent or its bind has not landed. Every call reports false; the executor + turns that into an error rather than a silent success. EVTasker's diagnostic screen answers + `NO_VENDOR_SERVICE` for the whole radio family — including `RADIO_NEXT_STATION` and + `RADIO_PREV_STATION`, which were answering for the AOSP car layer instead. +- **Unreadable play state.** `RADIO_PLAY_PAUSE` reads `isPlaying()` first and sends + **nothing** when it answers null: `ToggleResult.STATE_UNKNOWN`, reported as an error with + its reason. It does not fall back to "assume playing" the way `SaicMediaPlayer.radioCommand` + does — that fallback is right for a media-key shortcut a driver can press twice, and wrong + for a rule that fires unattended and would toggle the audio to the state nobody chose. +- **Unsupported firmware.** `@SupportedOn(SWI68, SWI165)` on all three, the generations where + the radio service is bound. The A9 generations (SWI69, SWI131, SWI132) have no such service; + `ActionCompatibility` refuses the action there before it is attempted, and the editor does + not offer it. + +## Reversal, 2026-08-29 + +Candidate D was reopened at the owner's request after an on-vehicle diagnostic (SWI68). The +original rejection assumed a band action required a band-only transaction, and that assumption +was wrong: `tune` takes the band as an argument, so the band is reachable through a call that +was already proven. The rejection of candidate E stands unchanged — a DAB service still has no +identifier stable across a re-scan, and none was guessed. + +## Merge, 2026-09-03 + +Candidate D shipped as an action of its own, and that was one entry too many. Band, frequency +and playback are three parts of one instruction — "put the radio on FM 103.5 and play it" — and +splitting them left each part unable to say the whole thing: `SELECT_RADIO_BAND` could not name +a station, `TUNE_RADIO` could not reach DAB, and a driver who wanted both wrote two rows with a +delay between them. They are now one entry, `TUNE_RADIO`, carrying the band in `number`, the +frequency in `text` and playback in `flag`. + +Nothing about the vehicle contract changed: a frequency still goes through `tune`, a band with +no frequency still goes through `selectBand`, and DAB is still reachable only as a band. What +changed is which of the two a rule asks for, and that is now read off the rule rather than off +which of two entries it picked. `selectBand` gained `andPlay` so that the merged action's +"enable radio" switch means the same thing on both paths. + +Two things are refused rather than resolved. A typed frequency that disagrees with the picked +band (`AM 103.5` under an FM picker) parses to nothing, as `AM 103.5` already did on its own — +two things were said, and silently keeping one is how a rule lands on a station nobody chose. +And DAB takes no frequency at all: the editor removes the field rather than accept a number it +would have to ignore. + +Rules and exported files that still name `SELECT_RADIO_BAND` are rewritten on load by +EVTasker's `LegacyRuleJson`, keeping their band and their playback — the old band switch always +took the audio focus, so dropping playback would have silenced a rule that used to make a +sound. + +## On-vehicle evidence + +_(pending — CR-020 requires a run. Record, per firmware: `SaicRadio.isAvailable`, whether +`isPlaying()` answered on FM, AM and DAB, whether `PAUSE_RADIO` silenced the tuner, whether +`RADIO_PLAY_PAUSE` toggled in the direction the state predicted, whether +`OPEN_RADIO_SCREEN` was correctly refused while moving, and whether `TUNE_RADIO` reached **DAB** +with its frequency left empty and came back to FM on the station it had left.)_ + +Availability is evidenced on SWI68: the 2026-08-29 diagnostic reported every radio action +`[ OK ]`, with the radio service bound. What is still missing is the behavioural half — that +each one does what its label says. diff --git a/EVHardware/gradle.properties b/EVHardware/gradle.properties new file mode 100644 index 0000000..ea76c66 --- /dev/null +++ b/EVHardware/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 diff --git a/EVHardware/gradle/libs.versions.toml b/EVHardware/gradle/libs.versions.toml new file mode 100644 index 0000000..19f6e8b --- /dev/null +++ b/EVHardware/gradle/libs.versions.toml @@ -0,0 +1,25 @@ +# Aligned with EVProfile / EVTasker: the three share signing and this shared library, +# so there is no reason for the toolchains to diverge. +[versions] +agp = "9.1.1" +kotlin = "2.0.21" +coreKtx = "1.13.1" +annotation = "1.8.0" +gson = "2.10.1" +coroutines = "1.8.1" +junit = "4.13.2" +robolectric = "4.14.1" +androidxTestCore = "1.6.1" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } + +[plugins] +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/EVHardware/gradle/wrapper/gradle-wrapper.jar b/EVHardware/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 zcma&NW0YlEwk;ePwr$(aux;D69T}N{9ky*d!_2U4+qUuIRNZ#Jck8}7U+vcB{`IjNZqX3eq5;s6ddAkU&5{L|^Ow`ym2B0m+K02+~Q)i807X3X94qi>j)C0e$=H zm31v`=T&y}ACuKx7G~yWSYncG=NFB>O2);i9EmJ(9jSamq?Crj$g~1l3m-4M7;BWn zau2S&sSA0b0Rhg>6YlVLQa;D#)1yw+eGs~36Q$}5?avIRne3TQZXb<^e}?T69w<9~ zUmx1cG0uZ?Kd;Brd$$>r>&MrY*3$t^PWF1+J+G_xmpHW=>mly$<>~wHH+Bt3mzN7W zhR)g{_veH6>*KxLJ~~s{9HZm!UeC86d_>42NRqd$ev8zSMq4kt)q*>8kJ8p|^wuKx zq2Is_HJPoQ_apSoT?zJj7vXBp!xejBc^7F|zU0rhy%Ub*Dy#jJs!>1?CmJ-gulPVX zKit>RVmjL=G?>jytf^U@mfnC*1-7EVag@%ROu*#kA+)Rxq?MGK0v-dp^kM?nyMngb z_poL>GLThB7xAO*I7&?4^Nj`<@O@>&0M-QxIi zD@n}s%CYI4Be19C$lAb9Bbm6!R{&A;=yh=#fnFyb`s7S5W3?arZf?$khCwkGN!+GY~GT8-`!6pFr zbFBVEF`kAgtecfjJ`flN2Z!$$8}6hV>Tu;+rN%$X^t8fI>tXQnRn^$UhXO8Gu zt$~QON8`doV&{h}=2!}+xJKrNPcIQid?WuHUC-i%P^F(^z#XB`&&`xTK&L+i8a3a@ zkV-Jy;AnyQ`N=&KONV_^-0WJA{b|c#_l=v!19U@hS~M-*ix16$r01GN3#naZ|DxY2 z76nbjbOnFcx4bKbEoH~^=EikiZ)_*kOb>nW6>_vjf-UCf0uUy~QBb7~WfVO6qN@ns zz=XEG0s5Yp`mlmUad)8!(QDgIzY=OK%_hhPStbyYYd|~zDIc3J4 zy9y%wZOW>}eG4&&;Z>vj&Mjg+>4gL! z(@oCTFf-I^54t=*4AhKRoE-0Ky=qg3XK2Mu!Bmw@z>y(|a#(6PcfbVTw-dUqyx4x4 z3O#+hW1ANwSv-U+9otHE#U9T>(nWx>^7RO_aI>${jvfZQ{mUwiaxHau!H z0Nc}ucJu+bKux?l!dQ2QA(r@(5KZl(Or=U!=2K*8?D=ZT-IAcAX!5OI3w@`sF@$($ zbDk0p&3X0P%B0aKdijO|s})70K&mk1DC|P##b=k@fcJ|lo@JNWRUc>KL?6dJpvtSUK zxR|w8Bo6K&y~Bd}gvuz*3z z@sPJr{(!?mi@okhudaM{t3gp9TJ!|@j4eO1C&=@h#|QLCUKLaKVL z!lls$%N&ZG7yO#jK?U>bJ+^F@K#A4d&Jz4boGmptagnK!Qu{Ob>%+60xRYK>iffd_ z>6%0K)p!VwP$^@Apm%NrS6TpKJwj_Q=k~?4=_*NIe~eh_QtRaqX4t-rJAGYdB{pGq zSXX)-dR8mQ)X|;8@_=J6Dk7MfMp;x)^aZeCtScHs12t3vL+p-6!qhPkOM1OYQ z8YXW5tWp)Th(+$m7SnV_hNGKAP`JF4URkkNc@YV9}FK$9k zR&qgi$Cj#4bC1VK%#U)f%(+oQJ+EqvV{uAq1YG0riLvGxW@)m;*ayU-BSW61COFy0 z(-l>GJqYl;*x1PnRZ(p3Lm}* zlkpWyCoYtg9pAZ5RU^%w=vN{3Y<6WImxj(*SCcJsFj?o6CZ~>cWW^foliM#qN#We{ zwsL!u1$rzC1#4~bILZm*a!T{^kCci$XOJADm)P;y^%x5)#G#_!2uNp^S;cE`*ASCn;}H7pP^RRA z6lfXK(r4dy<_}R|(7%Lyo>QFP#s31E8zsYA${gSUykUV@?lyDNF=KhTeF^*lu7C*{ zBCIjy;bIE;9inJ$IT8_jL%)Q{7itmncYlkf2`lHl(gTwD%LmEPo^gskydVxMd~Do` zO8EzF!yn!r|BEgPjhW#>g(unY#n}=#4J;3FD2ThN5LpO0tI2~pqICaFAGT%%;3Xx$ z>~Ng(64xH-RV^Rj4=A_q1Ee8kcF}8HN{5kjYX0ADh}jq{q18x(pV!23pVsK5S}{M#p8|+LvfKx|_3;9{+6cu7%5o-+R@z>TlTft#kcJ`s2-j zUe4dgpInZU!<}aTGuwgdWJZ#8TPiV9QW<-o!ibBn&)?!ZDomECehvT7GSCRyF#VN2&5GShch9*}4p;8TX~cW*<#( zv-HmU7&+YUWO__NN3UbTFJ&^#3vxW4U9q5=&ORa+2M$4rskA4xV$rFSEYBGy55b{z z!)$_fYXiY?-GWDhGZXgTw}#ilrw=BiN(DGO*W7Vw(} zjUexksYLt_Nq?pl_nVa@c1W#edQKbT>VSN1NK?DulHkFpI-LXl7{;dl@z0#v?x%U& z8k8M1X6%TwR4BQ_eEWJASvMTy?@fQubBU__A_US567I-~;_VcX^NJ-E(ZPR^NASj1 zVP!LIf8QKtcdeH#w6ak50At)e={eF_Ns6J2Iko6dn8Qwa6!NQHZMGsD zhzWeSFK<{hJV*!cIHxjgR+e#lkUHCss-j)$g zF}DyS531TUXKPPIoePo{yH%qEr-dLMOhv^sC&@9YI~uvl?rBp^A-57{aH_wLg0&a|UxKLlYZQ24fpb24Qjil`4OCyt0<1eu>5i1Acv zaZtQRF)Q;?Aw3idg;8Yg9Cb#)03?pQ@O*bCloG zC^|TnJl`GXN*8iI;Ql&_QIY0ik}rqB;cNZ-qagp=qmci9eScHsRXG$zRNdf4SleJ} z7||<#PCW~0>3u8PP=-DjNhD(^(B0AFF+(oKOiQyO5#v4nI|v_D5@c2;zE`}DK!%;H zUn|IZ6P;rl*5`E(srr6@-hpae!jW=-G zC<*R?RLwL;#+hxN4fJ!oP4fX`vC3&)o!#l4y@MrmbmL{t;VP%7tMA-&vju_L zhtHbOL4`O;h*5^e3F{b9(mDwY6JwL8w`oi28xOyj`pVo!75hngQDNg7^D$h4t&1p2 ziWD_!ap3GM(S)?@UwWk=Szym^eDxSx3NaR}+l1~(@0car6tfP#sZRTb~w!WAS{+|SgUN3Tv`J4OMf z9ta_f>-`!`I@KA=CXj_J>CE7T`yGmej0}61sE(%nZa1WC_tV6odiysHA5gzfWN-`uXF46mhJGLpvNTBmx$!i zF67bAz~E|P{L6t1B+K|Cutp&h$fDjyq9JFy$7c_tB(Q$sR)#iMQH3{Og1AyD^lyQwX6#B|*ecl{-_;*B>~WSFInaRE_q6 zpK#uCprrCb`MU^AGddA#SS{P7-OS9h%+1`~9v-s^{s8faWNpt*Pmk_ECjt(wrpr{C_xdAqR(@!ERTSs@F%^DkE@No}wqol~pS^e7>ksF_NhL0?6R4g`P- zk8lMrVir~b(KY+hk5LQngwm`ZQT5t1^7AzHB2My6o)_ejR0{VxU<*r-Gld`l6tfA` zKoj%x9=>Ce|1R|1*aC}|F0R32^KMLAHN}MA<8NNaZ^j?HKxSwxz`N2hK8lEb{jE0& zg4G_6F@#NyDN?=i@=)eidKhlg!nQoA{`PgaH{;t|M#5z}a`u?^gy{5L~I2smLR z*4RmNxHqf9>D>sXSemHK!h4uPwMRb+W`6F>Q6j@isZ>-F=)B2*sTCD9A^jjUy)hjAw71B&$u}R(^R; zY9H3k8$|ounk>)EOi_;JAKV8U8ICSD@NrqB!&=)Ah_5hzp?L9Sw@c>>#f_kUhhm=p z1jRz8X7)~|VwO(MF3PS(|CL++1n|KT3*dhGjg!t_vR|8Yg($ z+$S$K=J`K6eG#^(J54=4&X#+7Car=_aeAuC>dHE+%v9HFu>r%ry|rwkrO-XPhR_#K zS{2Unv!_CvS7}Mb6IIT$D4Gq5v$Pvi5nbYB+1Yc&RY;3;XDihlvhhIG6AhAHsBYsm zK@MgSzs~y|+f|j-lsXKT0(%E2SkEb)p+|EkV5w8=F^!r1&0#0^tGhf9yPZ)iLJ^ zIXOg)HW_Vt{|r0W(`NmMLF$?3ZQpq+^OtjR-DaVLHpz%1+GZ7QGFA?(BIqBlVQ;)k zu)oO|KG&++gD9oL7aK4Zwjwi~5jqk6+w%{T$1`2>3Znh=OFg|kZ z>1cn>CZ>P|iQO%-Pic8wE9c*e%=3qNYKJ+z1{2=QHHFe=u3rqCWNhV_N*qzneN8A5 zj`1Ir7-5`33rjDmyIGvTx4K3qsks(I(;Kgmn%p#p3K zn8r9H8kQu+n@D$<#RZtmp$*T4B&QvT{K&qx(?>t@mX%3Lh}sr?gI#vNi=vV5d(D<=Cp5-y!a{~&y|Uz*PU{qe zI7g}mt!txT)U(q<+Xg_sSY%1wVHy;Dv3uze zJ>BIdSB2a|aK+?o63lR8QZhhP)KyQvV`J3)5q^j1-G}fq=E4&){*&hiam>ssYm!ya z#PsY0F}vT#twY1mXkGYmdd%_Uh12x0*6lN-HS-&5XWbJ^%su)-vffvKZ%rvLHVA<; zJP=h13;x?$v30`T)M)htph`=if#r#O5iC^ZHeXc6J8gewn zL!49!)>3I-q6XOZRG0=zjyQc`tl|RFCR}f-sNtc)I^~?Vv2t7tZZHvgU2Mfc9$LqG z!(iz&xb=q#4otDBO4p)KtEq}8NaIVcL3&pbvm@0Kk-~C@y3I{K61VDF_=}c`VN)3P z+{nBy^;=1N`A=xH$01dPesY_na*zrcnssA}Ix60C=sWg9EY=2>-yH&iqhhm28qq9Z z;}znS4ktr40Lf~G@6D5QxW&?q^R|=1+h!1%G4LhQs54c2Wo~4% zCA||d==lv2bP=9%hd0Dw_a$cz9kk)(Vo}NpSPx!vnV*0Bh9$CYP~ia#lEoLRJ8D#5 zSJS?}ABn1LX>8(Mfg&eefX*c0I5bf4<`gCy6VC{e>$&BbwFSJ0CgVa;0-U7=F81R+ zUmzz&c;H|%G&mSQ0K16Vosh?sjJW(Gp+1Yw+Yf4qOi|BFVbMrdO6~-U8Hr|L@LHeZ z0ALmXHsVm137&xnt#yYF$H%&AU!lf{W436Wq87nC16b%)p?r z70Wua59%7Quak50G7m3lOjtvcS>5}YL_~?Pti_pfAfQ!OxkX$arHRg|VrNx>R_Xyi z`N|Y7KV`z3(ZB2wT9{Dl8mtl zg^UOBv~k>Z(E)O>Z;~Z)W&4FhzwiPjUHE9&T#nlM)@hvAZL>cha-< zQ8_RL#P1?&2Qhk#c9fK9+xM#AneqzE-g(>chLp_Q2Xh$=MAsW z2ScEKr+YOD*R~mzy{bOJjs;X2y1}DVFZi7d_df^~((5a2%p%^4cf>vM_4Sn@@ssVJ z9ChGhs zbanJ+h74)3tWOviXI|v!=HU2mE%3Th$Mpx&lEeGFEBWRy8ogJY`BCXj@7s~bjrOY! z4nIU5S>_NrpN}|waZBC)$6ST8x91U2n?FGV8lS{&LFhHbuHU?SVU{p7yFSP_f#Eyh zJhI@o9lAeEwbZYC=~<(FZ$sJx^6j@gtl{yTOAz`Gj!Ab^y})eG&`Qt2cXdog2^~oOH^K@oHcE(L;wu2QiMv zJuGdhNd+H{t#Tjd<$PknMSfbI>L1YIdZ+uFf*Z=BEM)UPG3oDFe@8roB0h(*XAqRc zoxw`wQD@^nxGFxQXN9@GpkLqd?9@(_ZRS@EFRCO8J5{iuNAQO=!Lo5cCsPtt4=1qZN8z`EA2{ge@SjTyhiJE%ttk{~`SEl%5>s=9E~dUW0uws>&~3PwXJ!f>ShhP~U9dLvE8ElNt3g(6-d zdgtD;rgd^>1URef?*=8BkE&+HmzXD-4w61(p6o~Oxm`XexcHmnR*B~5a|u-Qz$2lf zXc$p91T~E4psJxhf^rdR!b_XmNv*?}!PK9@-asDTaen;p{Rxsa=1E}4kZ*}yQPoT0 zvM}t!CpJvk<`m~^$^1C^o1yM(BzY-Wz2q7C^+wfg-?}1bF?5Hk?S{^#U%wX4&lv0j zkNb)byI+nql(&65xV?_L<0tj!KMHX8Hmh2(udEG>@OPQ}KPtdwEuEb$?acp~yT1&r z|7YU<(v!0as6Xff5^XbKQIR&MpjSE)pmub+ECMZzn7c!|hnm_Rl&H_oXWU2!h7hhf zo&-@cLkZr#eNgUN9>b=QLE1V^b`($EX3RQIyg#45A^=G!jMY`qJ z8qjZ$*-V|?y0=zIM>!2q!Gi*t4J5Otr^OT3XzQ_GjATc(*eM zqllux#QtHhc>YtnswBNiS^t(dTDn|RYSI%i%-|sv1wh&|9jfeyx|IHowW)6uZWR<%n8I}6NidBm zJ>P7#5m`gnXLu;?7jQZ!PwA80d|AS*+mtrU6z+lzms6^vc4)6Zf+$l+Lk3AsEK7`_ zQ9LsS!2o#-pK+V`g#3hC$6*Z~PD%cwtOT8;7K3O=gHdC=WLK-i_DjPO#WN__#YLX|Akw3LnqUJUw8&7pUR;K zqJ98?rKMXE(tnmT`#080w%l1bGno7wXHQbl?QFU=GoK@d!Ov=IgsdHd-iIs4ahcgSj(L@F96=LKZ zeb5cJOVlcKBudawbz~AYk@!^p+E=dT^UhPE`96Q5J~cT-8^tp`J43nLbFD*Nf!w;6 zs>V!5#;?bwYflf0HtFvX_6_jh4GEpa0_s8UUe02@%$w^ym&%wI5_APD?9S4r9O@4m zq^Z5Br8#K)y@z*fo08@XCs;wKBydn+60ks4Z>_+PFD+PVTGNPFPg-V-|``!0l|XrTyUYA@mY?#bJYvD>jX&$o9VAbo?>?#Z^c+Y4Dl zXU9k`s74Sb$OYh7^B|SAVVz*jEW&GWG^cP<_!hW+#Qp|4791Od=HJcesFo?$#0eWD z8!Ib_>H1WQE}shsQiUNk!uWOyAzX>r(-N7;+(O333_ES7*^6z4{`p&O*q8xk{0xy@ zB&9LkW_B}_Y&?pXP-OYNJfqEWUVAPBk)pTP^;f+75Wa(W>^UO_*J05f1k{ zd-}j!4m@q#CaC6mLsQHD1&7{tJ*}LtE{g9LB>sIT7)l^ucm8&+L0=g1E_6#KHfS>A_Z?;pFP96*nX=1&ejZ+XvZ=ML`@oVu>s^WIjn^SY}n zboeP%`O9|dhzvnw%?wAsCw*lvVcv%bmO5M4cas>b%FHd;A6Z%Ej%;jgPuvL$nk=VQ=$-OTwslYg zJQtDS)|qkIs%)K$+r*_NTke8%Rv&w^v;|Ajh5QXaVh}ugccP}3E^(oGC5VO*4`&Q0 z&)z$6i_aKI*CqVBglCxo#9>eOkDD!voCJRFkNolvA2N&SAp^4<8{Y;#Kr5740 za|G`dYGE!9NGU3Ge6C)YByb6Wy#}EN`Ao#R!$LQ&SM#hifEvZp>1PAX{CSLqD4IuO z4#N4AjMj5t2|!yTMrl5r)`_{V6DlqVeTwo|tq4MHLZdZc5;=v9*ibc;IGYh+G|~PB zx2}BAv6p$}?7YpvhqHu7L;~)~Oe^Y)O(G(PJQB<&2AhwMw!(2#AHhjSsBYUd8MDeM z+UXXyV@@cQ`w}mJ2PGs>=jHE{%i44QsPPh(=yorg>jHic+K+S*q3{th6Ik^j=@%xo zXfa9L_<|xTL@UZ?4H`$vt9MOF`|*z&)!mECiuenMW`Eo2VE#|2>2ET7th6+VAmU(o zq$Fz^TUB*@a<}kr6I>r;6`l%8NWtVtkE?}Q<<$BIm*6Z(1EhDtA29O%5d1$0q#C&f zFhFrrss{hOsISjYGDOP*)j&zZUf9`xvR8G)gwxE$HtmKsezo`{Ta~V5u+J&Tg+{bh zhLlNbdzJNF6m$wZNblWNbP6>dTWhngsu=J{);9D|PPJ96aqM4Lc?&6H-J1W15uIpQ ziO{&pEc2}-cqw+)w$`p(k(_yRpmbp-Xcd`*;Y$X=o(v2K+ISW)B1(ZnkV`g4rHQ=s z+J?F9&(||&86pi}snC07Lxi1ja>6kvnut;|Ql3fD)%k+ASe^S|lN69+Ek3UwsSx=2EH)t}K>~ z`Mz-SSVH29@DWyl`ChuGAkG>J;>8ZmLhm>uEmUvLqar~vK3lS;4s<{+ehMsFXM(l- zRt=HT>h9G)JS*&(dbXrM&z;)66C=o{=+^}ciyt8|@e$Y}IREAyd_!2|CqTg=eu}yG z@sI9T;Tjix*%v)c{4G84|0j@8wX^Iig_JsPU|T%(J&KtJ>V zsAR+dcmyT5k&&G{!)VXN`oRS{n;3qd`BgAE9r?%AHy_Gf8>$&X$=>YD7M911?<{qX zkJ;IOfY$nHdy@kKk_+X%g3`T(v|jS;>`pz`?>fqMZ>Fvbx1W=8nvtuve&y`JBfvU~ zr+5pF!`$`TUVsx3^<)48&+XT92U0DS|^X6FwSa-8yviRkZ*@Wu|c*lX!m?8&$0~4T!DB0@)n}ey+ew}T1U>|fH3=W5I!=nfoNs~OkzTY7^x^G&h>M7ewZqmZ=EL0}3#ikWg+(wuoA{7hm|7eJz zNz78l-K81tP16rai+fvXtspOhN-%*RY3IzMX6~8k9oFlXWgICx9dp;`)?Toz`fxV@&m8< z{lzWJG_Y(N1nOox>yG^uDr}kDX_f`lMbtxfP`VD@l$HR*B(sDeE(+T831V-3d3$+% zDKzKnK_W(gLwAK{Saa2}zaV?1QmcuhDu$)#;*4gU(l&rgNXB^WcMuuTki*rt>|M)D zoI;l$FTWIUp}euuZjDidpVw6AS-3dal2TJJaVMGj#CROWr|;^?q>PAo2k^u-27t~v zCv10IL~E)o*|QgdM!GJTaT&|A?oW)m9qk2{=y*7qb@BIAlYgDIe)k(qVH@)#xx6%7 z@)l%aJwz5Joc84Q2jRp71d;=a@NkjSdMyN%L6OevML^(L0_msbef>ewImS=+DgrTk z4ON%Y$mYgcZ^44O*;ctP>_7=}=pslsu>~<-bw=C(jeQ-X`kUo^BS&JDHy%#L32Cj_ zXRzDCfCXKXxGSW9yOGMMOYqPKnU zTF6gDj47!7PoL%z?*{1eyc2IVF*RXX?mj1RS}++hZg_%b@6&PdO)VzvmkXxJ*O7H} z6I7XmJqwX3<>z%M@W|GD%(X|VOZ7A+=@~MxMt8zhDw`yz?V>H%C0&VY+ZZ>9AoDVZeO1c~z$r~!H zA`N_9p`X?z>jm!-leBjW1R13_i2(0&aEY2$l_+-n#powuRO;n2Fr#%jp{+3@`h$c< zcFMr;18Z`UN#spXv+3Ks_V_tSZ1!FY7H(tdAk!v}SkoL9RPYSD3O5w>A3%>7J+C-R zZfDmu=9<1w1CV8rCMEm{qyErCUaA3Q zRYYw_z!W7UDEK)8DF}la9`}8z*?N32-6c-Bwx^Jf#Muwc67sVW24 zJ4nab%>_EM8wPhL=MAN)xx1tozAl zmhXN;*-X%)s>(L=Q@vm$qmuScku>PV(W_x-6E?SFRjSk)A1xVqnml_92fbj0m};UC zcV}lRW-r*wY106|sshV`n#RN{)D9=!>XVH0vMh>od=9!1(U+sWF%#B|eeaKI9RpaW z8Ol_wAJX%j0h5fkvF)WMZ1}?#R(n-OT0CtwsL)|qk;*(!a)5a5ku2nCR9=E*iOZ`9 zy4>LHKt-BgHL@R9CBSG!v4wK zvjF8DORRva)@>nshE~VM@i2c$PKw?3nz(6-iVde;-S~~7R<5r2t$0U8k2_<5C0!$j zQg#lsRYtI#Q1YRs(-%(;F-K7oY~!m&zhuU4LL}>jbLC>B`tk8onRRcmIm{{0cpkD|o@Ixu#x9Wm5J)3oFkbfi62BX8IX1}VTe#{C(d@H|#gy5#Sa#t>sH@8v1h8XFgNGs?)tyF_S^ueJX_-1%+LR`1X@C zS3Oc)o)!8Z9!u9d!35YD^!aXtH;IMNzPp`NS|EcdaQw~<;z`lmkg zE|tQRF7!S!UCsbag%XlQZXmzAOSs= zIUjgY2jcN9`xA6mzG{m|Zw=3kZC4@XY=Bj%k8%D&iadvne$pYNfZI$^2BAB|-MnZW zU4U?*qE3`ZDx-bH})>wz~)a z_SWM!E=-BS#wdrfh;EfPNOS*9!;*+wp-zDthj<>P0a2n?$xfe;YmX~5a;(mNV5nKx zYR86%WtAPsOMIg&*o9uUfD!v&4(mpS6P`bFohPP<&^fZzfA|SvVzPQgbtwwM>IO>Z z75ejU$1_SB1tn!Y-9tajZ~F=Fa~{cnj%Y|$;%z6fJV1XC0080f)Pj|87j142q6`i>#)BCIi+x&jAH9|H#iMvS~?w;&E`y zoarJ)+5HWmZ{&OqlzbdQU=SE3GKmnQq zI{h6f$C@}Mbqf#JDsJyi&7M0O2ORXtEB`#cZ;#AcB zkao0`&|iH8XKvZ_RH|VaK@tAGKMq9x{sdd%p-o`!cJzmd&hb86N!KKxp($2G?#(#BJn5%hF0(^`= z2qRg5?82({w-HyjbffI>eqUXavp&|D8(I6zMOfM}0;h%*D_Dr@+%TaWpIEQX3*$vQ z8_)wkNMDi{rW`L+`yN^J*Gt(l7PExu3_hrntgbW0s}7m~1K=(mFymoU87#{|t*fJ?w8&>Uh zcS$Ny$HNRbT!UCFldTSp2*;%EoW+yhJD8<3FUt8@XSBeJM2dSEz+5}BWmBvdYK(OA zlm`nDDsjKED{$v*jl(&)H7-+*#jWI)W|_X)!em1qpjS_CBbAiyMt;tx*+0P%*m&v< zxV9rlslu8#cS!of#^1O$(ds8aviMFiT`6W+FzMHW{YS+SieJ^?TQb%NT&pasw^kbc znd`=%(bebvrNx3#7vq@vAX-G`4|>cY0svIXopH02{v;GZ{wJM#psz4!m8(IZu<)9D zqR~U7@cz-6H{724_*}-DWwE8Sk+dYBb*O-=c z+wdchFcm6$$^Z0_qGnv0P`)h1=D$_eg8!2-|7Y;o*c)4ax!Me0*EVcioh{wI#!qcb z1&xhOotXMrlo7P6{+C8m;E#4*=8(2y!r0d<6 zKi$d2X;O*zS(&Xiz_?|`ympxITf|&M%^WHp=694g6W@k+BL_T1JtSYX0OZ}o%?Pzu zJ{%P8A$uq?4F!NWGtq>_GLK3*c6dIcGH)??L`9Av&0k$A*14ED9!e9z_SZd3OH6ER zg%5^)3^gw;4DFw(RC;~r`bPJOR}H}?2n60=g4ESUTud$bkBLPyI#4#Ye{5x3@Yw<* z;P5Up>Yn(QdP#momCf=kOzZYzg9E330=67WOPbCMm2-T1%8{=or9L8+HGL{%83lri zODB;Y|LS`@mn#Wmez7t6-x`a2{}U9hE|xY7|BVcFCqoAZQzsEi=dYHB z(bqG3J5?teVSBqTj{aiqe<9}}CEc$HdsJSMp#I;4(EXRy_k|Y8X#5hwkqAaIGKARF zX?$|UO{>3-FU;IlFi80O^t+WMNw4So2nsg}^T1`-Ox&C%Gn_AZ-49Nir=2oYX6 z`uVke@L5PVh)YsvAgFMZfKi{DuSgWnlAaag{RN6t6oLm6{4)H~4xg#Xfcq-e@ALk& z@UP4;uCe(Yjg4jaJZ4pu*+*?4#+XCi%sTrqaT*jNY7|WQ!oR;S8nt)cI27W$Sz!94 z01zoTW`C*P3E?1@6thPe(QpIue$A54gp#C7pmfwRj}GxIw$!!qQetn`nvuwIvMBQ; zfF8K-D~O4aJKmLbNRN1?AZsWY&rp?iy`LP^3KT0UcGNy=Z@7qVM(#5u#Du#w>a&Bs z@f#zU{wk&5n!YF%D11S9*CyaI8%^oX=vq$Ei9cL1&kvv9|8vZD;Mhs1&slm`$A%ED zvz6SQ8aty~`IYp2Xd~G$z%Jf4zwVPKkCtqObrnc2gHKj^jg&-NH|xdNK_;+2d4ZXw zN9j)`jcp7y65&6P@}LsD_OLSi(#GW#hC*qF5KpmeXuQDNS%ZYpuW<;JI<>P6ln!p@ z>KPAM>8^cX|2!n@tV=P)f2Euv?!}UM`^RJ~nTT@W>KC2{{}xXS{}WH{|3najkiEUj z7l;fUWDPCtzQ$?(f)6RvzW~Tqan$bXibe%dv}**BqY!d4J?`1iX`-iy8nPo$s4^mQ z5+@=3xuZAl#KoDF*%>bJ4UrEB2EE8m7sQn!r7Z-ggig`?yy`p~3;&NFukc$`_>?}a z?LMo2LV^n>m!fv^HKKRrDn|2|zk?~S6i|xOHt%K(*TGWkq3{~|9+(G3M-L=;U-YRa zp{kIXZ8P!koE;BN2A;nBx!={yg4v=-xGOMC#~MA07zfR)yZtSF_2W^pDLcXg->*WD zY7Sz5%<_k+lbS^`y)=vX|KaN!gEMQob|(`%nP6huwr$%^?%0^vwr$(CZQD*Jc5?E( zb-q9E`OfoWSJ$rUs$ILfSFg3Mb*-!Ozgaz^%7ZkX@=3km0G;?+e?FQT_l5A9vKr<> z_CoemDo@6YIyl57l*gnJ^7+8xLW5oEGzjLv2P8vj*Q%O1^KOfrsC6eHvk{+$BMLGu z%goP8UY?J7Lj=@jcI$4{m2Sw?1E%_0C7M$lj}w{E#hM4%3QX|;tH6>RJf-TI_1A0w z@KcTEFx(@uitbo?UMMqUaSgt=n`Bu*;$4@cbg9JIS})3#2T;B7S

Z?HZkSa`=MM?n)?|XcM)@e1qmzJ$_4K^?-``~Oi&38`2}sjmP?kK z$yT)K(UU3fJID@~3R;)fU%k%9*4f>oq`y>#t90$(y*sZTzWcW$H=Xv|%^u^?2*n)Csx;35O0v7Nab-REgxDZNf5`cI69k$` zx(&pP6zVxlK5Apn5hAhui}b)(IwZD}D?&)_{_yTL7QgTxL|_X!o@A`)P#!%t9al+# zLD(Rr+?HHJEOl545~m1)cwawqY>cf~9hu-L`crI^5p~-9Mgp9{U5V&dJSwolnl_CM zwAMM1Tl$D@>v?LN2PLe0IZrQL1M zcA%i@Lc)URretFJhtw7IaZXYC6#8slg|*HfUF2Z5{3R_tw)YQ94=dprT`SFAvHB+7 z)-Hd1yE8LB1S+4H7iy$5XruPxq6pc_V)+VO{seA8^`o5{T5s<8bJ`>I3&m%R4cm1S z`hoNk%_=KU2;+#$Y!x7L%|;!Nxbu~TKw?zSP(?H0_b8Qqj4EPrb@~IE`~^#~C%D9k zvJ=ERh`xLgUwvusQbo6S=I5T+?lITYsVyeCCwT9R>DwQa&$e(PxF<}RpLD9Vm2vV# zI#M%ksVNFG1U?;QR{Kx2sf>@y$7sop6SOnBC4sv8S0-`gEt0eHJ{`QSW(_06Uwg*~ zIw}1dZ9c=K$a$N?;j`s3>)AqC$`ld?bOs^^stmYmsWA$XEVhUtGlx&OyziN1~2 z)s5fD(d@gq7htIGX!GCxKT=8aAOHW&DAP=$MpZ)SpeEZhk83}K) z0(Uv)+&pE?|4)D2PX4r6gOGHDY}$8FSg$3eDb*nEVmkFQ#lFpcH~IPeatiH3nPTkP z*xDN7l}r2GM9jwSsl=*!547nRPCS0pb;uE#myTqV+=se>bU=#e)f2}wCp%f-cIrh`FHA$2`monVy?qvJ~o2B6I7IE28bCY4=c#^){*essLG zXUH50W&SWmi{RIG9G^p;PohSPtC}djjXSoC)kyA8`o+L}SjE{i?%;Vh=h;QC{s`T7 zLmmHCr8F}#^O8_~lR)^clv$mMe`e*{MW#Sxd`rDckCnFBo9sC*vw2)dA9Q3lUi*Fy zgDsLt`xt|7G=O6+ms=`_FpD4}37uvelFLc^?snyNUNxbdSj2+Mpv<67NR{(mdtSDNJ3gSD@>gX_7S5 zCD)JP5Hnv!llc-9fwG=4@?=%qu~(4j>YXtgz%gZ#+A9i^H!_R!MxWlFsH(ClP3dU} za&`m(cM0xebj&S170&KLU%39I+XVWOJ_1XpF^ip}3|y()Fn5P@$pP5rvtiEK6w&+w z7uqIxZUj$#qN|<_LFhE@@SAdBy8)xTu>>`xC>VYU@d}E)^sb9k0}YKr=B8-5M?3}d z7&LqQWQ`a&=ihhANxe3^YT>yj&72x#X4NXRTc#+sk;K z=VUp#I(YIRO`g7#;5))p=y=MQ54JWeS(A^$qt>Y#unGRT$0BG=rI(tr>YqSxNm+-x z6n;-y8B>#FnhZX#mhVOT30baJ{47E^j-I6EOp;am;FvTlYRR2_?CjCWY+ypoUD-2S zqnFH6FS+q$H$^7>>(nd^WE+?Zn#@HU3#t|&=JnEDgIU+;CgS+krs+Y8vMo6U zHVkPoReZ-Di3z!xdBu#aW1f{8sC)etjN90`2|Y@{2=Os`(XLL9+ z1$_PE$GgTQrVx`^sx=Y(_y-SvquMF5<`9C=vM52+e+-r=g?D z+E|97MyoaK5M^n1(mnWeBpgtMs8fXOu4Q$89C5q4@YY0H{N47VANA1}M2e zspor6LdndC=kEvxs3YrPGbc;`q}|zeg`f;t3-8na)dGdZ9&d(n{|%mNaHaKJOA~@8 zgP?nkzV-=ULb)L3r`p)vj4<702a5h~Y%byo4)lh?rtu1YXYOY+qyTwzs!59I zL}XLe=q$e<+Wm7tvB$n88#a9LzBkgHhfT<&i#%e*y|}@I z!N~_)vodngB7%CI2pJT*{GX|cI5y>ZBN)}mezK~fFv@$*L`84rb0)V=PvQ2KN}3lTpT@$>a=CP?kcC0S_^PZ#Vd9#CF4 zP&`6{Y!hd^qmL!zr#F~FB0yag-V;qrmW9Jnq~-l>Sg$b%%TpO}{Q+*Pd-@n2suVh_ zSYP->P@# z&gQ^f{?}m(u5B9xqo63pUvDsJDQJi5B~ak+J{tX8$oL!_{Dh zL@=XFzWb+83H3wPbTic+osVp&~UoW3SqK0#P6+BKbOzK65tz)-@AW#g}Ew+pE3@ zVbdJkJ}EM@-Ghxp_4a)|asEk* z5)mMI&EK~BI^aaTMRl)oPJRH^Ld{;1FC&#pS`gh;l3Y;DF*`pR%OSz8U@B@zJxPNX zwyP_&8GsQ7^eYyUO3FEE|9~I~X8;{WTN=DJW0$2OH=3-!KZG=X6TH?>URr(A0l@+d zj^B9G-ACel;yYGZc}G`w9sR$Mo{tzE7&%XKuW$|u7DM<6_z}L>I{o`(=!*1 z{5?1p3F^aBONr6Ws!6@G?XRxJxXt_6b}2%Bp=0Iv5ngnpU^P+?(?O0hKwAK z*|wAisG&8&Td1XY+6qI~-5&+4DE2p|Dj8@do;!40o)F)QuoeUY;*I&QZ0*4?u)$s`VTkNl1WG`}g@J_i zjjmv4L%g&>@U9_|l>8^CN}`@4<D2aMN&?XXD-HNnsVM`irjv$ z^YVNUx3r1{-o6waQfDp=OG^P+vd;qEvd{UUYc;gF0UwaeacXkw32He^qyoYHjZeFS zo(#C9#&NEdFRcFrj7Q{CJgbmDejNS!H%aF6?;|KJQn_*Ps3pkq9yE~G{0wIS*mo0XIEYH zzIiJ>rbmD;sGXt#jlx7AXSGGcjty)5z5lTGp|M#5DCl0q0|~pNQ%1dP!-1>_7^BA~ zwu+uumJmTCcd)r|Hc)uWm7S!+Dw4;E|5+bwPb4i17Ued>NklnnsG+A{T-&}0=sLM- zY;sA9v@YH>b9#c$Vg{j@+>UULBX=jtu~N^%Y#BB5)pB|$?0Mf7msMD<7eACoP1(XY zPO^h5Brvhn$%(0JSo3KFwEPV&dz8(P41o=mo7G~A*P6wLJ@-#|_A z7>k~4&lbqyP1!la!qmhFBfIfT?nIHQ0j2WlohXk^sZ`?8-vwEwV0~uu{RDE^0yfl$ znua{^`VTZ)-h#ch_6^e2{VPaE@o&55|3dx$z_b6gbqduXJ(Lz(zq&ZbJ6qA4Ac4RT zhJO4KBLN!t;h(eW(?cZJw^swf8lP@tWMZ8GD)zg)siA3!2EJYI(j>WI$=pK!mo!Ry z?q&YkTIbTTr<>=}+N8C_EAR0XQL2&O{nNAXb?33iwo8{M``rUHJgnk z8KgZzZLFf|(O6oeugsm<;5m~4N$2Jm5#dph*@TgXC2_k&d%TG0LPY=Fw)=gf(hy9QmY*D6jCAiq44 zo-k2C+?3*+Wu7xm1w*LEAl`Vsq(sYPUMw|MiXrW)92>rVOAse5Pmx^OSi{y%EwPAE zx|csvE{U3c{vA>@;>xcjdCW15pE31F3aoIBsz@OQRvi%_MMfgar2j3Ob`9e@gLQk# zlzznEHgr|Ols%f*a+B-0klD`czi@RWGPPpR1tE@GB|nwe`td1OwG#OjGlTH zfT#^r?%3Ocp^U0F8Kekck6-Vg2gWs|sD_DTJ%2TR<5H3a$}B4ZYpP=p)oAoHxr8I! z1SYJ~v-iP&mNm{ra7!KP^KVpkER>-HFvq*>eG4J#kz1|eu;=~u2|>}TE_5nv2=d!0 z3P~?@blSo^uumuEt{lBsGcx{_IXPO8s01+7DP^yt&>k;<5(NRrF|To2h7hTWBFQ_A z+;?Q$o5L|LlIB>PH(4j)j3`JIb1xA_C@HRFnPnlg{zGO|-RO7Xn}!*2U=Z2V?{5Al z9+iL+n^_T~6Uu{law`R&fFadSVi}da8G>|>D<{(#vi{OU;}1ZnfXy8=etC7)Ae<2S zAlI`&=HkNiHhT0|tQztSLNsRR6v8bmf&$6CI|7b8V4kyJ{=pG#h{1sVeC28&Ho%Fh zwo_FIS}ST-2OF6jNQ$(pjrq)P)@sie#tigN1zSclxJLb-O9V|trp^G8<1rpsj8@+$ z2y27iiM>H8kfd%AMlK|9C>Lkvfs9iSk>k2}tCFlqF~Z_>-uWVQDd$5{3sM%2$du9; z*ukNSo}~@w@DPF)_vS^VaZ)7Mk&8ijX2hNhKom$#PM%bzSA-s$ z0O!broj`!Nuk)Qcp3(>dL|5om#XMx2RUSDMDY9#1|+~fxwP}1I4iYy4j$CGx3jD&eKhf%z`Jn z7mD!y6`nVq%&Q#5yqG`|+e~1$Zkgu!O(~~pWSDTw2^va3u!DOMVRQ8ycq)sk&H%vb z;$a`3gp74~I@swI!ILOkzVK3G&SdTcVe~RzN<+z`u(BY=yuwez{#T3a_83)8>2!X?`^02zVjqx-fN+tW`zCqH^XG>#Ies$qxa!n4*FF0m zxgJlPPYl*q4ylX;DVu3G*I6T&JyWvs`A(*u0+62=+ylt2!u)6LJ=Qe1rA$OWcNCmH zLu7PwMDY#rYQA1!!ONNcz~I^uMvi6N&Lo4dD&HF?1Su5}COTZ-jwR)-zLq=6@bN}X zSP(-MY`TOJ@1O`bLPphMMSWm+YL{Ger>cA$KT~)DuTl+H)!2Lf`c+lZ0ipxd>KfKn zIv;;eEmz(_(nwW24a+>v{K}$)A?=tp+?>zAmfL{}@0r|1>iFQfJ5C*6dKdijK=j16 zQpl4gl93ttF5@d<9e2LoZ~cqkH)aFMgt(el_)#OG4R4Hnqm(@D*Uj>2ZuUCy)o-yy z_J|&S-@o5#2IMcL(}qWF3EL<4n(`cygenA)G%Ssi7k4w)LafelpV5FvS9uJES+(Ml z?rzZ={vYrB#mB-Hd#ID{KS5dKl-|Wh_~v+Lvq3|<@w^MD-RA{q!$gkUUNIvAaex5y z)jIGW{#U=#UWyku7FIAB=TES8>L%Y9*h2N`#Gghie+a?>$CRNth?ORq)!Tde24f5K zKh>cz5oLC;ry*tHIEQEL>8L=zsjG7+(~LUN5K1pT`_Z-4Z}k^m%&H%g3*^e(FDCC{ zBh~eqx%bY?qqu_2qa+9A+oS&yFw^3nLRsN#?FcZvt?*dZhRC_a%Jd{qou(p5AG_Q6 ziOJMu8D~kJ7xEkG(69$Dl3t1J592=Olom%;13uZvYDda08YwzqFlND-;YodmA!SL) z!AOSI=(uCnG#Yo&BgrH(muUemmhQW7?}IHfxI~T`44wuLGFOMdKreQO!a=Z-LkH{T z@h;`A_l2Pp>Xg#`Vo@-?WJn-0((RR4uKM6P2*^-qprHgQhMzSd32@ho>%fFMbp9Y$ zx-#!r8gEu;VZN(fDbP7he+Nu7^o3<+pT!<<>m;m z=FC$N)wx)asxb_KLs}Z^;x*hQM}wQGr((&=%+=#jW^j|Gjn$(qqXwt-o-|>kL!?=T zh0*?m<^>S*F}kPiq@)Cp+^fnKi2)%<-Tw4K3oHwmI-}h}Kc^+%1P!D8aWp!hB@-ZT zybHrRdeYlYulEj>Bk zEIi|PU0eGg&~kWQ{q)gw%~bFT0`Q%k5S|tt!JIZXVXX=>er!7R^w>zeQ%M-(C|eOQG>5i|}i3}X#?aqAg~b1t{-fqwKd(&CyA zmyy)et*E}+q_lEqgbClewiJ=u@bFX}LKe)5o26K9fS;R`!er~a?lUCKf60`4Zq7{2q$L?k?IrAdcDu+ z4A0QJBUiGx&$TBASI2ASM_Wj{?fjv=CORO3GZz;1X*AYY`anM zI`M6C%8OUFSc$tKjiFJ|V74Yj-lK&Epi7F^Gp*rLeDTokfW#o6sl33W^~4V|edbS1 zhx%1PTdnI!C96iYqSA=qu6;p&Dd%)Skjjw0fyl>3k@O?I@x5|>2_7G#_Yc2*1>=^# z|H43bJDx$SS2!vkaMG!;VRGMbY{eJhT%FR{(a+RXDbd4OT?DRoE(`NhiVI6MsUCsT z1gc^~Nv>i;cIm2~_SYOfFpkUvV)(iINXEep;i4>&8@N#|h+_;DgzLqh3I#lzhn>cN zjm;m6U{+JXR2Mi)=~WxM&t9~WShlyA$Pnu+VIW2#;0)4J*C!{1W|y1TP{Q;!tldR< zI7aoH&cMm*apW}~BabBT;`fQ1-9q|!?6nTzmhiIo6fGQlcP{pu)kJh- zUK&Ei9lArSO6ep_SN$Lt_01|Y#@Ksznl@f<+%ku1F|k#Gcwa`(^M<2%M3FAZVb99?Ez4d9O)rqM< zCbYsdZlSo{X#nKqiRA$}XG}1Tw@)D|jGKo1ITqmvE4;ovYH{NAk{h8*Ysh@=nZFiF zmDF`@4do#UDKKM*@wDbwoO@tPx4aExhPF_dvlR&dB5>)W=wG6Pil zq{eBzw%Ov!?D+%8&(uK`m7JV7pqNp-krMd>ECQypq&?p#_3wy){eW{(2q}ij{6bfmyE+-ZO z)G4OtI;ga9;EVyKF6v3kO1RdQV+!*>tV-ditH-=;`n|2T zu(vYR*BJSBsjzFl1Oy#DpL=|pfEY4NM;y5Yly__T*Eg^3Mb_()pHwn)mAsh!7Yz-Z zY`hBLDXS4F^{>x=oOphq|LMo;G!C(b2hS9A6lJqb+e$2af}7C>zW2p{m18@Bdd>iL zoEE$nFUnaz_6p${cMO|;(c1f9nm5G5R;p)m4dcC1?1YD=2Mi&20=4{nu>AV#R^d%A zsmm_RlT#`;g~an9mo#O1dYV)2{mgUWEqb*a@^Ok;ckj;uqy{%*YB^({d{^V)P9VvP zC^qbK&lq~}TWm^RF8d4zbo~bJuw zFV!!}b^4BlJ0>5S3Q>;u*BLC&G6Fa5V|~w&bRZ*-YU>df6%qAvK?%Qf+#=M-+JqLw&w*l4{v7XTstY4j z26z69U#SVzSbY9HBXyD;%P$#vVU7G*Yb-*fy)Qpx?;ed;-P24>-L6U+OAC9Jj63kg zlY`G2+5tg1szc#*9ga3%f9H9~!(^QjECetX-PlacTR+^g8L<#VRovPGvsT)ln3lr= zm5WO@!NDuw+d4MY;K4WJg3B|Sp|WdumpFJO>I2tz$72s4^uXljWseYSAd+vGfjutO z-x~Qlct+BnlI+Iun)fOklxPH?30i&j9R$6g5^f&(x7bIom|FLKq9CUE);w2G>}vye zxWvEaXhx8|~2j)({Rq>0J9}lzdE`yhQ(l$z! z;x%d%_u?^4vlES_>JaIjJBN|N8z5}@l1#PG_@{mh`oWXQOI41_kPG}R_pV+jd^PU) zEor^SHo`VMul*80-K$0mSk|FiI+tHdWt-hzt~S>6!2-!R&rdL_^gGGUzkPe zEZkUKU=EY(5Ex)zeTA4-{Bkbn!Gm?nuaI4jLE%X;zMZ7bwn4FXz(?az;9(Uv;38U6 zi)}rA3xAcD2&6BY<~Pj9Q1~4Dyjs&!$)hyHiiTI@%qXd~+>> zW}$_puSSJ^uWv$jtWakn}}@eX6_LGz|7M#$!3yjY ztS{>HmQ%-8u0@|ig{kzD&CNK~-dIK5e{;@uWOs8$r>J7^c2P~Pwx%QVX0e8~oXK0J zM4HCNK?%t6?v~#;eP#t@tM$@SXRt;(b&kU7uDzlzUuu;+LQ5g%=FqpJPGrX8HJ8CS zITK|(fjhs3@CR}H4@)EjL@J zV_HPexOQ!@k&kvsQG)n;7lZaUh>{87l4NS_=Y-O9Ul3CaKG8iy+xD=QXZSr57a-hb z7jz3Ts-NVsMI783OPEdlE|e&a2;l^h@e>oYMh5@=Lte-9A+20|?!9>Djl~{XkAo>0p9`n&nfWGdGAfT-mSYW z1cvG>GT9dRJdcm7M_AG9JX5AqTCdJ6MRqR3p?+FvMxp(oB-6MZ`lRzSAj%N(1#8@_ zDnIIo9Rtv12(Eo}k_#FILhaZQ`yRD^Vn5tm+IK@hZO>s=t5`@p1#k?Umz2y*R64CF zGM-v&*k}zZ%Xm<_?1=g~<*&3KAy;_^QfccIp~CS7NW24Tn|mSDxb%pvvi}S}(~`2# z3I|kD@||l@lAW06K2%*gHd4x9YKeXWpwU%!ozYcJ+KJeX!s6b94j!Qyy7>S!wb?{qaMa`rpbU1phn0EpF}L zsBdZc|Im#iRiQmJjZwb5#n;`_O{$Zu$I zMXqbfu0yVmt!!Y`Fzl}QV7HUSOPib#da4i@vM$0u2FEYytsvrbR#ui9lrMkZ(AVVJ zMVl^Wi_fSRsEXLA_#rdaG%r(@UCw#o7*yBN)%22b)VSNyng6Lxk|2;XK3Qb=C_<`F zN##8MLHz-s%&O6JE~@P1=iHpj8go@4sC7*AWe99tuf$f7?2~wC&RA^UjB*2`K!%$y zSDzMd7}!vvN|#wDuP%%nuGk8&>N)7eRxtqdMXHD1W%hP7tYW{W>^DJp`3WS>3}i+$ z_li?4AlEj`r=!SPiIc+NNUZ9NCrMv&G0BdQHBO&S7d48aB)LfGi@D%5CC1%)1hVcJ zB~=yNC}LBn(K?cHkPmAX$5^M7JSnNkcc!X!0kD&^F$cJmRP(SJ`9b7}b)o$rj=BZ- zC;BX3IG94%Qz&(V$)7O~v|!=jd-yU1(6wd1u;*$z4DDe6+BFLhz>+8?59?d2Ngxck zm92yR!jk@MP@>>9FtAY2L+Z|MaSp{MnL-;fm}W3~fg!9TRr3;S@ysLf@#<)keHDRO zsJI1tP`g3PNL`2(8hK3!4;r|E-ZQbU0e-9u{(@du`4wjGj|A!QB&9w~?OI1r}M? zw)6tvsknfPfmNijZ;3VZX&HM6=|&W zy6GIe3a?_(pRxdUc==do9?C&v7+6cgIoL4)Ka^bOG9`l;S|QmVzjv%)3^PDi@=-cp z=!R0bU<@_;#*D}e1m@0!%k=VPtyRAkWYW(VFl|eu0LteWH7eDB%P|uF7BQ-|D4`n; z)UpuY1)*s32UwW756>!OoAq#5GAtfrjo*^7YUv^(eiySE?!TQzKxzqXE@jM_bq3Zq zg#1orE*Zd5ZWEpDXW9$=NzuadNSO*NW)ZJ@IDuU`w}j_FRE4-QS*rD4mPVQPH(jGg z+-Ye?3%G%=DT5U1b+TnNHHv(nz-S?3!M4hXtEB@J4WK%%p zkv=Bb`1DHmgUdYo>3kwB(T>Ba#DKv%cLp2h4r8v}p=Np}wL!&PB5J-w4V4REM{kMD z${oSuAw9?*yo3?tNp~X5WF@B^P<6L0HtIW0H7^`R8~9zAXgREH`6H{ntGu$aQ;oNq zig;pB^@KMHNoJcEb0f1fz+!M6sy?hQjof-QoxJgBM`!k^T~cykcmi^s_@1B9 z)t1)Y-ZsV9iA&FDrVoF=L7U#4&inXk{3+Xm9A|R<=ErgxPW~Fq zqu-~x0dIBlR+5_}`IK^*5l3f5$&K@l?J{)_d_*459pvsF*e*#+2guls(cid4!N%DG zl3(2`az#5!^@HNRe3O4(_5nc+){q?ENQG2|uKW0U0$aJ5SQ6hg>G4OyN6os76y%u8qNNHi;}XnRNwpsfn^!6Qt(-4tE`uxaDZ`hQp#aFX373|F?vjEiSEkV>K)cTBG+UL#wDj0_ zM9$H&-86zP=9=5_Q7d3onkqKNr4PAlF<>U^^yYAAEso|Ak~p$3NNZ$~4&kE9Nj^As zQPoo!m*uZ;z1~;#g(?zFECJ$O2@EBy<;F)fnQxOKvH`MojG5T?7thbe%F@JyN^k1K zn3H*%Ymoim)ePf)xhl2%$T)vq3P=4ty%NK)@}po&7Q^~o3l))Zm4<75Y!fFihsXJc z9?vecovF^nYfJVg#W~R3T1*PK{+^YFgb*7}Up2U#)oNyzkfJ#$)PkFxrq_{Ai?0zk zWnjq_ixF~Hs7YS9Y6H&8&k0#2cAj~!Vv4{wCM zi2f1FjQf+F@=BOB)pD|T41a4AEz+8hnH<#_PT#H|Vwm7iQ0-Tw()WMN za0eI-{B2G{sZ7+L+^k@BA)G;mOFWE$O+2nS|DzPSGZ)ede(9%+8kqu4W^wTn!yZPN z7u!Qu0u}K5(0euRZ$7=kn9DZ+llruq5A_l) zOK~wof7_^8Yeh@Qd*=P!gM)lh`Z@7^M?k8Z?t$$vMAuBG>4p56Dt!R$p{)y>QG}it zGG;Ei```7ewXrbGo6Z=!AJNQ!GP8l13m7|FIQTFZTpIg#kpZkl1wj)s1eySXjAAWy zfl;;@{QQ;Qnb$@LY8_Z&7 z6+d98F?z2Zo)sS)z$YoL(zzF>Ey8u#S_%n7)XUX1Pu(>e8gEUU1S;J=EH(#`cWi1+ zoL$5TN+?#NM8=4E7HOk)bf5MXvEo%he5QcB%_5YQ$cu_j)Pd^@5hi}d%nG}x9xXtD-JMQxr;KkC=r_dS-t`lf zF&CS?Lk~>U^!)Y0LZqNVJq+*_#F7W~!UkvZfQhzvW`q;^X&iv~ zEDDGIQ&(S;#Hb(Ej4j+#D#sDS_uHehlY0kZsQpktc?;O z22W1b%wNcdfNza<1M2{*mAkM<{}@(w`VuQ<^lG|iYSuWBD#lYK9+jsdA+&#;Y@=zXLVr840Nq_t5))#7}2s9pK* zg42zd{EY|#sIVMDhg9>t6_Y#O>JoG<{GO&OzTa;iA9&&^6=5MT21f6$7o@nS=w;R) znkgu*7Y{UNPu7B9&B&~q+N@@+%&cO0N`TZ-qQ|@f@e0g2BI+9xO$}NzMOzEbSSJ@v z1uNp(S z-dioXc$5YyA6-My@gW~1GH($Q?;GCHfk{ej-{Q^{iTFs1^Sa67RNd5y{cjX1tG+$& zbGrUte{U1{^Z_qpzW$-V!pJz$dQZrL5i(1MKU`%^= z^)i;xua4w)evDBrFVm)Id5SbXMx2u7M5Df<2L4B`wy4-Y+Wec#b^QJO|J9xF{x#M8 zuLUer`%ZL^m3gy?U&dI+`kgNZ+?bl3H%8)&k84*-=aMfADh&@$xr&IS|4{3$v&K3q zZTn&f{N(#L6<-BZYNs4 zB*Kl*@_IhGXI^_8zfXT^XNmjJ@5E~H*wFf<&er?p7suz85)$-Hqz@C zGMFg1NKs;otNViu)r-u{SOLcqwqc7$poPvm(-^ag1m71}HL#cj5t4Hw(W?*fi4GSH z9962NZ>p^ECPqVc$N}phy>N8rQsWWm%%rc5B4XLATFEtffX&TM2%|8S2Lh_q; zCytXua84HBnSybW-}(j z3Zwv4CaK)jC!{oUvdsFRXK&Sx@t)yGm(h65$!WZ!-jL52no}NX6=E<=H!aZ74h_&> zZ+~c@k!@}Cs84l{u+)%kg4fq~pOeTK3S4)gX~FKJw4t9ba!Ai{_gkKQYQvafZIyKq zX|r4xgC(l%JgmW!tvR&yNt$6uME({M`uNIi7HFiPEQo_UMRkl~12&4c& z^se;dbZWKu7>dLMg`IZq%@b@ME?|@{&xEIZEU(omKNUY? z`JszxNghuO-VA;MrZKEC0|Gi0tz3c#M?aO?WGLy64LkG4T%|PBIt_?bl{C=L@9e;A zia!35TZI7<`R8hr06xF62*rNH5T3N0v^acg+;ENvrLYo|B4!c^eILcn#+lxDZR!%l zjL6!6h9zo)<5GrSPth7+R(rLAW?HF4uu$glo?w1U-y}CR@%v+wSAlsgIXn>e%bc{FE;j@R0AoNIWf#*@BSngZ)HmNqkB z)cs3yN%_PT4f*K+Y1wFl)be=1iq+bb1G-}b|72|gJ|lMt`tf~0Jk}zMbS0+M-Mq}R z>Bv}-W6J%}j#dIz`Z0}zD(DGKn`R;E8A`)$a6qDfr(c@iHKZcCVY_nJEDpcUddGH* z*ct2$&)RelhmV}@jGXY>3Y~vp;b*l9M+hO}&x`e~q*heO8GVkvvJTwyxFetJC8VnhjR`5*+qHEDUNp16g`~$TbdliLLd}AFf}U+Oda1JXwwseRFbj?DN96;VSX~z?JxJSuA^BF}262%Z0)nv<6teKK`F zfm9^HsblS~?Xrb1_~^=5=PD!QH$Y1hD_&qe1HTQnese8N#&C(|Q)CvtAu6{{0Q%ut8ESVdn&& z4y%nsCs!$(#9d{iVjXDR##3UyoMNeY@_W^%qyuZ^K3Oa4(^!tDXOUS?b2P)yRtJ8j zSX}@qGBj+gKf;|6Kb&rq`!}S*cSu-3&S>=pM$eEB{K>PP~I}N|uGE|`3U#{Q6v^kO4nIsaq zfPld}c|4tVPI4!=!ETCNW+LjcbmEoxm0RZ%ieV0`(nVlWKClZW5^>f&h79-~CF(%+ zv|KL(^xQ7$#a}&BSGr9zf{xJ(cCfq>UR*>^-Ou_pmknCt6Y--~!duL{k2D{yLMl__ z!KeMRRg&EsD2s|cmy?xgK&XcGIKeos`&UEVhBTw;mqy|8DlP1M7PYS2z{YmTJ;n!h znPe(Qu?c7+xZz!Tm1AnE8|;&tf7fW$2dArX7ck1Jd(S1+91YB8bjISRZ`UL*?vb{b zMp*!Xq7VaLc0Ogqj5qmop8NREQ{9_iC$;tviZlubGLy1jLlIFBxAymMr@SDLAcx+) z5YRkl$bW**X)W0JzWNcLx9>fTqJj00ipY6Ua?mUlsgQrVVgpmaheE;RgA5U_+WsPh z9+X|PU4zFyNxZ2?Q+V`Mo{xH~(m}OMRZa<&$nCl7o4x`^^|V4?aPz8#KwFm=8T6_} z8=P_4$_rD2a%7}}HT6VQ>ZGKW=QF7zI-2=6oBNZR$HVn|gq`>l$HZ`48lkM7%R$>MS& zghR`WZ9Xrd_6FaDedH6_aKVJhYev*2)UQ>!CRH3PQ_d9nXlO;c z9PeqiKD@aGz^|mvD-tV<{BjfA;)B+76!*+`$CZOJ=#)}>{?!9fAg(Xngbh||n=q*C zU0mGP`NxHn$uY#@)gN<0xr)%Ue80U{-`^FX1~Q@^>WbLraiB|c#4v$5HX)0z!oA#jOXPyWg! z8EC}SBmG7j3T&zCenPLYA{kN(3l62pu}91KOWZl? zg~>T4gQ%1y3AYa^J|>ba$7F5KlVx}_&*~me*q-SYLBCXZFU=U8mHQD4K!?;B61NoX z?VS41SS&jHyhmB~+bC=w0a06V``ZXCkC~}oM9pM{$hU~-s_elYPmT1L!%B`?*<+?( zFQ@TP%y+QL`_&Y0A3679pe5~iL=z)$b)k!oSbJRyw+K};SGAvvE=|<~*aiwJc?uE@2?7a1i9|3=^N%*9smt3ZIhjY>gIsr{Q2rX(NovZ7I1n^V{ z#~(1ze-%`C>fM`^hCV**9BA-04lNuu&3=reevNOMwmX(A{yh`^c8%0mjAKMj{Th05 zXrM(zILwyL-Pcdw^(=gj(ZLVMA95zlzmLa^skb8tQq%8SV&4vp?S>L3+P4^tp`$xA zr38jBw0ItR`VbO5vB1`<3d})}aorkIU1z3*ifYN&Lpp)}|}QJS60th_v-EEkAM zyOREuj!Ou|pVeZEWg;$Hf!x;xAmFu7gB^UR$=L0BuZ~thLC@#moJ(@@wejR|`t_K@ zuQ{XmpAWz%o&~2dk!SIGR$EmpZY)@+r^gvX26%)y>1u2bt~JUPTQzQu&_tB)|{19)&n$m5Fhw0A-8S1^%XpAD%`#a z_ModVxsM|x!m3N1vRt_XEL`O-+J3cMsM1l*dbjT&S0c@}Xxl3I&AeMNT97G3c6%3C zbrZS?2EAKcEq@@Pw?r%eh0YM6z0>&Qe#n+e9hEHK?fzig3v5S#O2IxVLu;a>~c~ZfHVbgLox%_tg)bsC8Rl35P=Jhl+Y=w6zb$ z;*uO%i^U z^mp_QggBILLF$AyjPD41Z0SFdbDj&z&xjq~X|OoM7bCuBfma1CEd!4RKGqPR)K)e}+7^JfFUI_fy63cMyq#&)Z*#w18{S zhC@f9U5k#2S2`d$-)cEoH-eAz{2Qh>YF1Xa)E$rWd52N-@{#lrw3lRqr)z?BGThgO z-Mn>X=RPHQ)#9h{3ciF)<>s{uf_&XdKb&kC!a373l2OCu&y8&n#P%$7YwAVJ_lD-G zX7tgMEV8}dY^mz`R6_0tQ5Eu@CdSOyaI63Vb*mR+rCzxgsjCXLSHOmzt0tA zGoA0Cp&l>rtO@^uQayrkoe#d2@}|?SlQl9W{fmcxY(0*y zHTZ6>FL;$8FEzbb;M(o%mBe-X?o<0+1dH?ZVjcf8)Kyqb07*a zLfP1blbt)=W)TN}4M#dUnt8Gdr4p$QRA<0W)JhWLK3-g82Q~2Drmx4J z;6m4re%igus136VL}MDI-V;WmSfs4guF_(7ifNl#M~Yx5HB!UF)>*-KDQl0U?u4UXV2I*qMhEfsxb%87fi+W;mW5{h?o8!52}VUs*Fpo#aSuXk(Ug z>r>xC#&2<9Uwmao@iJQ|{Vr__?eRT2NB$OcoXQ-jZ{t|?Uy{7q$nU-i|&-R6fHPWJDgHZ69iVbK#Ab@2@y zPD*Gj=hib?PWr8NGf;g$o5I!*n>94Z!IfqRm zLvM>Gx$Y*rEL3Z-+lS42=cnEfXR)h1z`h8a+I%E_ss%qXsrgIV%qv9d|KT>fV5=3e zw>P#ju>2naGc{=6!)9TeHq$S9Pk|>$UCEl}H}lE@;0(jbNT9TXUXyss>al>S4DuGi zVCy;Qt=a2`iu2;TvrIkh2NTvNV}0)qun~9y1yEQMdOf#V#3(e(C?+--8bCsJu={Q1z5qNJIk&yW>ZnVm;A=fL~29lvXQ*4j(SLau?P zi8LC7&**O!6B6=vfY%M;!p2L2tQ+w3Y!am{b?14E`h4kN$1L0XqT5=y=DW8GI_yi% zlIWsjmf0{l#|ei>)>&IM4>jXH)?>!fK?pfWIQn9gT9N(z&w3SvjlD|u*6T@oNQRF6 zU5Uo~SA}ml5f8mvxzX>BGL}c2#AT^6Lo-TM5XluWoqBRin$tiyRQK0wJ!Ro+7S!-K z=S95p-(#IDKOZsRd{l65N(Xae`wOa4Dg9?g|Jx97N-7OfHG(rN#k=yNGW0K$Tia5J zMMX1+!ulc1%8e*FNRV8jL|OSL-_9Nv6O=CH>Ty(W@sm`j=NFa1F3tT$?wM1}GZekB z6F_VLMCSd7(b9T%IqUMo$w9sM5wOA7l8xW<(1w0T=S}MB+9X5UT|+nemtm_;!|bxX z_bnOKN+F30ehJ$459k@=69yTz^_)-hNE4XMv$~_%vlH_y^`P1pLxYF6#_IZyteO`9wpuS> z#%Vyg5mMDt?}j!0}MoBX|9PS0#B zSVo6xLVjujMN57}IVc#A{VB*_yx;#mgM4~yT6wO;Qtm8MV6DX?u(JS~JFA~PvEl%9 z2XI}c>OzPoPn_IoyXa2v}BA(M+sWq=_~L0rZ_yR17I5c^m4;?2&KdCc)3lCs!M|0OzH@(PbG8T6w%N zKzR>%SLxL_C6~r3=xm9VG8<9yLHV6rJOjFHPaNdQHHflp><44l>&;)&7s)4lX%-er znWCv8eJJe1KAi_t1p%c4`bgxD2(1v)jm(gvQLp2K-=04oaIJu{F7SIu8&)gyw7x>+ zbzYF7KXg;T71w!-=C0DjcnF^JP$^o_N>*BAjtH!^HD6t1o?(O7IrmcodeQVDD<*+j zN)JdgB6v^iiJ1q`bZ(^WvN{v@sDqG$M9L`-UV!3q&sWZUnQ{&tAkpX(nZ_L#rMs}>p7l0fU5I5IzArncQi6TWjP#1B=QZ|Uqm-3{)YPn=XFqHW-~Fb z^!0CvIdelQbgcac9;By79%T`uvNhg9tS><pLzXePP=JZzcO@?5GRAdF4)sY*)YGP* zyioMa3=HRQz(v}+cqXc0%2*Q%CQi%e2~$a9r+X*u3J8w^Shg#%4I&?!$})y@ zzg8tQ6_-`|TBa_2v$D;Q(pFutj7@yos0W$&__9$|Yn3DFe*)k{g^|JIV4bqI@2%-4kpb_p? zQ4}qQcA>R6ihbxnVa{c;f7Y)VPV&mRY-*^qm~u3HB>8lf3P&&#GhQk8uIYYgwrugY zei>mp`YdC*R^Cxuv@d0V?$~d*=m-X?1Fqd9@*IM^wQ_^-nQEuc0!OqMr#TeT=8W`JbjjXc-Dh3NhnTj8e82yP;V_B<7LIejij+B{W1ViaJ_)+q?$BaLJpxt_4@&(?rWC3NC-_Z9Sg4JJWc( zX!Y34j67vCMHKB=JcJ1|#UI^D^mn(i=A5rf-iV7y4bR5HhC=I`rFPZv4F>q+h?l34 z4(?KYwZYHwkPG%kK7$A&M#=lpIn3Qo<>s6UFy|J$Zca-s(oM7??dkuKh?f5b2`m57 zJhs4BTcVVmwsswlX?#70uQb*k1Fi3q4+9`V+ikSk{L3K=-5HgN0JekQ=J~549Nd*+H%5+fi6aJuR=K zyD3xW{X$PL7&iR)=wumlTq2gY{LdrngAaPC;Qw_xLfVE0c0Z>y918TQpL!q@?`8{L!el18Qxiki3WZONF=eK$N3)p>36EW)I@Y z7QxbWW_9_7a*`VS&5~4-9!~&g8M+*U9{I2Bz`@TJ@E(YL$l+%<=?FyR#&e&v?Y@@G zqFF`J*v;l$&(A=s`na2>4ExKnxr`|OD+Xd-b4?6xl4mQ94xuk!-$l8*%+1zQU{)!= zTooUhjC0SNBh!&Ne}Q=1%`_r=Vu1c8RuE!|(g4BQGcd5AbpLbvKv_Z~Y`l!mr!sCc zDBupoc{W@U(6KWqW@xV_`;J0~+WDx|t^WeMri#=q0U5ZN7@@FAv<1!hP6!IYX z>UjbhaEv2Fk<6C0M^@J`lH#LgKJ(`?6z5=uH+ImggSQaZtvh52WTK+EBN~-op#EQKYW`$yBmq z4wgLTJPn3;mtbs0m0RO&+EG>?rb*ZECE0#eeSOFL!2YQ$w}cae>sun`<=}m!=go!v zO2jn<0tNh4E-4)ZA(ixh5nIUuXF-qYl>0I_1)K%EAw`D7~la$=gc@6g{iWF=>i_76?Mc zh#l9h7))<|EY=sK!E|54;c!b;Zp}HLd5*-w^6^whxB98v`*P>cj!Nfu1R%@bcp{cb zUZ24(fUXn3d&oc{6H%u(@4&_O?#HO(qd^YH=V`WJ=u*u6Zie8mE^r_Oz zDw`DaXeq4G#m@EK5+p40Xe!Lr!-jTQLCV3?R1|3#`%45h8#WSA!XoLDMS7=t!SluZ4H56;G z6C9D(B6>k^ur_DGfJ@Y-=3$5HkrI zO+3P>R@$6QZ#ATUI3$)xRBEL#5IKs}yhf&fK;ANA#Qj~G zdE|k|`puh$%dyE4R0$7dZd)M*#e7s%*PKPyrS;d%&S(d{_Ktq^!Hpi&bxZx`?9pEw z%sPjo&adHm95F7Z1{RdY#*a!&LcBZVRe{qhn8d{pOUJ{fOu`_kFg7ZVeRYZ(!ezNktT5{Ab z4BZI$vS0$vm3t9q`ECjDK;pmS{8ZTKs`Js~PYv2|=VkDv{Dtt)cLU@9%K6_KqtqfM zaE*e$f$Xm=;IAURNUXw8g%=?jzG2}10ZA5qXzAaJ@eh)yv5B=ETyVwC-a*CD;GgRJ z4J1~zMUey?4iVlS0zW|F-~0nenLiN3S0)l!T2}D%;<}Z9DzeVgcB+MSj;f$KY;uP%UR#f`0u*@6U@tk@jO3N?Fjq< z{cUUhjrr$rmo>qE?52zKe+>6iP5P_tcUfxsLSy{9*)shB(w`UUveNH`a`kr$VEF@} zKh&|lTD;4;m_H6C&)9#D`kRh;S(NTa=Ve^~xe_0~x$6h8Q@B_qu#ee=(lkI9@F6$0m=z@H=4&h%Q{htM>uHs(Sr@2ry`fgLA zKj8lVXdGPyy)2J%A${}Rm_a{){wHnlM?yGPQ7#KO{8*(_l0QZHuV};nO?c%h?qwSL z3wem|w*2tdxW5&PxC(Wd0QG_w|GPbw|0UFK`u$~U%!`QKcME;=Q@?*erh4_>FP~1n zAldwG9h$$u_$RFK6Uxo20GHqJzc}Rl-EwVz3h4n z;3~%DwD84i>)-8#&#y3k)3BG5cNaP3?t4q}F%yfv?*yEiC>sSo}$f>nh0QNZXH1N)-Q7kbk=2uL9OrF)nXrE@F1y%_8Yn c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/EVHardware/gradle/wrapper/gradle-wrapper.properties b/EVHardware/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/EVHardware/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/EVHardware/gradlew b/EVHardware/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/EVHardware/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/EVHardware/gradlew.bat b/EVHardware/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/EVHardware/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/EVHardware/lib/build.gradle.kts b/EVHardware/lib/build.gradle.kts new file mode 100644 index 0000000..e017bad --- /dev/null +++ b/EVHardware/lib/build.gradle.kts @@ -0,0 +1,92 @@ +// AGP 9 provides built-in Kotlin compilation, so no separate Kotlin plugin is applied. +// Plugin ids and dependency coordinates are hardcoded rather than pulled from a version +// catalog: this module is included as a subproject by multiple app repos, and must not +// depend on any of them defining the same catalog aliases. +plugins { + id("com.android.library") + // Publishes a release AAR (GitHub Packages / mavenLocal) so EXTERNAL projects can + // depend on EVHardware as a binary. The EVSuite apps keep the git-submodule + // source dependency; the AAR is purely for outside consumers. + id("maven-publish") +} + +// Coordinates for the published AAR. Version tracks git tags in CI (see publish workflow); +// the default here is for local publishing. +group = "com.evsuite" +version = System.getenv("EVHARDWARE_VERSION") ?: "0.1.0-SNAPSHOT" + +android { + namespace = "com.evsuite.hardware" + compileSdk = 36 + + defaultConfig { + minSdk = 27 + consumerProguardFiles("consumer-rules.pro") + } + + // Required for maven-publish to expose a component from an Android library. + publishing { + singleVariant("release") { + withSourcesJar() + } + } + + buildFeatures { + // The catalogue's string labels (cond_*/act_*) resolve against this library's own R, + // which is why EVHardware is a real Android library and not a shared source set. + resValues = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +// AGP builds the "release" component only after evaluation, so wire the publication there. +publishing { + publications { + register("release") { + groupId = "com.evsuite" + artifactId = "evhardware" + afterEvaluate { from(components["release"]) } + } + } + repositories { + // GitHub Packages when the CI env vars are present; otherwise ./gradlew + // :lib:publishToMavenLocal works for local/external experimentation. + val ghUser = System.getenv("GITHUB_ACTOR") + val ghToken = System.getenv("GITHUB_TOKEN") + if (ghUser != null && ghToken != null) { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/malys/EVHardware") + credentials { username = ghUser; password = ghToken } + } + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.annotation:annotation:1.8.0") + implementation("com.google.code.gson:gson:2.10.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.14.1") + testImplementation("androidx.test:core:1.6.1") +} diff --git a/EVHardware/lib/consumer-rules.pro b/EVHardware/lib/consumer-rules.pro new file mode 100644 index 0000000..6e66817 --- /dev/null +++ b/EVHardware/lib/consumer-rules.pro @@ -0,0 +1,24 @@ +# Rules applied to any app that consumes EVHardware. The library is reflection-heavy on +# both sides — vehicle access reflects android.car, and the catalogue is read by name. + +# EVHardware reflects android.car / SAIC SDK / ServiceManager targets by name. +-keep class com.evsuite.hardware.EVHardware { *; } +-keep class android.car.** { *; } +-keep class com.saicmotor.** { *; } + +# Serialized models (Gson) — fields and constructors read reflectively; keep generic +# signatures so TypeToken> does not decay to LinkedTreeMap. +-keep class com.evsuite.hardware.model.** { *; } +-keepclassmembers class com.evsuite.hardware.model.** { ; (...); } +-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation* + +# Catalogue enums: FirmwareSupport reads @SupportedOn off the enum fields by reflection, +# and enums are persisted by name — obfuscating either breaks the firmware filter and any +# stored rules. +-keep class com.evsuite.hardware.catalog.** { *; } +-keep class com.evsuite.hardware.FirmwareGen { *; } +-keep @interface com.evsuite.hardware.SupportedOn +-keepclassmembers enum com.evsuite.hardware.** { + public static **[] values(); + public static ** valueOf(java.lang.String); +} diff --git a/EVHardware/lib/src/main/AndroidManifest.xml b/EVHardware/lib/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/EVHardware/lib/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/A9Climate.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/A9Climate.kt new file mode 100644 index 0000000..fe530ec --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/A9Climate.kt @@ -0,0 +1,191 @@ +package com.evsuite.hardware + +import android.os.IBinder + +/** + * Climate on the A9 platform (SWI69, SWI131, SWI132), where the vehicle-settings SDK is absent. + * + * `SaicClimate` talks to `IAirConditionService` through the vendor hub. That hub does not + * exist on A9 — which is why every climate action shipped marked SWI68/SWI165 only, on cars + * whose own HVAC screen works perfectly well. It works through `carapi` instead: + * `queryClient(0x7)` hands back the HVAC binder and `CarHvacClient` wraps it, exactly as + * `queryClient(0x8)` already builds the vehicle-settings client in [EVHardware]. + * + * Reflection rather than raw AIDL here, unlike the rest of `saic`: the transaction codes of + * `ICarHvacService` are not established anywhere, while the client class' method names are — + * they are what the launcher calls. Guessing a code writes to the wrong signal; naming a + * method that does not exist fails loudly and reads as unsupported. + * + * ## Switches, not assignments + * + * Several A9 settings are `switch…()` with no argument: they step to the next state. Aiming at + * one is therefore read-step-read until it matches, the same shape as the seat heating, and + * bounded so a firmware where the call does nothing gives up instead of looping. That makes + * every such write **blocking** — up to a second or so — so none of this belongs on the main + * thread. + */ +internal object A9Climate { + + private const val TAG = "EV_CLIMATE_A9" + + private const val SERVICE_CODE = 0x7 + private const val CLIENT_CLASS = "com.saicmotor.carapi.client.CarHvacClient" + + /** Time given to the HVAC controller to apply one step before the state is read back. */ + private const val STEP_SETTLE_MS = 400L + + @Volatile + private var client: Any? = null + + /** True on the generations whose climate can only be reached this way. */ + val isPlatform: Boolean + get() = when (FirmwareInfo.getGeneration()) { + FirmwareInfo.Gen.SWI69, FirmwareInfo.Gen.SWI131, FirmwareInfo.Gen.SWI132 -> true + else -> false + } + + val isAvailable: Boolean get() = isPlatform && client() != null + + private fun client(): Any? { + client?.let { return it } + val loader = EVHardware.a9ClassLoader() ?: return null + val binder: IBinder = EVHardware.a9ClientBinder(SERVICE_CODE) ?: return null + return try { + loader.loadClass(CLIENT_CLASS) + .getConstructor(IBinder::class.java) + .newInstance(binder) + .also { + client = it + AppLogger.i(TAG, "CarHvacClient obtained via queryClient(0x7) ✓") + } + } catch (e: Exception) { + AppLogger.d(TAG, "CarHvacClient unavailable: ${(e.cause ?: e).message}") + null + } + } + + // ── Reads ──────────────────────────────────────────────────────────────── + + fun powerOn(): Boolean? = get("getHvacPowerStatus") as? Boolean + fun acOn(): Boolean? = get("getACStatus") as? Boolean + fun autoOn(): Boolean? = get("getAutoStatus") as? Boolean + fun frontDefrostOn(): Boolean? = get("getFrontDefrostStatus") as? Boolean + fun rearDefrostOn(): Boolean? = get("getRearDefrostStatus") as? Boolean + fun fanLevel(): Int? = (get("getFanSpeed") as? Int)?.takeIf { it >= 0 } + + /** 0 outside air, 1 recirculated, 2 auto — the same encoding the property carries. */ + fun loopMode(): Int? = (get("getAirCirculationStatus") as? Int)?.takeIf { it >= 0 } + + fun recirculationOn(): Boolean? = loopMode()?.let { it == LOOP_INNER } + + /** A float on this platform, where the old SDK answered whole degrees. */ + fun driverTemp(): Int? = + (get("getDriverTemperature") as? Float)?.takeIf { !it.isNaN() && it > 0f }?.toInt() + + // ── Writes ─────────────────────────────────────────────────────────────── + + fun setPower(on: Boolean): Boolean = + cycleTo("power", "getHvacPowerStatus", if (on) 1 else 0, maxSteps = 2, advance = "switchHvacPowerStatus") + + fun setAc(on: Boolean): Boolean = + cycleTo("ac", "getACStatus", if (on) 1 else 0, maxSteps = 2, advance = "switchACStatus") + + fun setAuto(on: Boolean): Boolean = + cycleTo("auto", "getAutoStatus", if (on) 1 else 0, maxSteps = 2, advance = "switchAutoStatus") + + fun setFrontDefrost(on: Boolean): Boolean = + cycleTo("frontDefrost", "getFrontDefrostStatus", if (on) 1 else 0, 2, "switchFrontDefrostStatus") + + fun setRearDefrost(on: Boolean): Boolean = + cycleTo("rearDefrost", "getRearDefrostStatus", if (on) 1 else 0, 2, "switchRearDefrostStatus") + + /** Three modes, so two steps reach any of them from any other. */ + fun setLoopMode(target: Int): Boolean { + if (target !in LOOP_OUTSIDE..LOOP_AUTO) return false + return cycleTo("loopMode", "getAirCirculationStatus", target, 3, "switchAirCirculationStatus") + } + + fun setRecirculation(on: Boolean): Boolean = setLoopMode(if (on) LOOP_INNER else LOOP_OUTSIDE) + + fun setFanLevel(level: Int): Boolean = set("setFanSpeed", level) + + fun setDriverTemp(celsius: Int): Boolean = set("setDriverTemperature", celsius.toFloat()) + + const val LOOP_OUTSIDE = 0 + const val LOOP_INNER = 1 + const val LOOP_AUTO = 2 + + // ── Reflection plumbing ────────────────────────────────────────────────── + + private fun get(method: String): Any? { + val c = client() ?: return null + return try { + c.javaClass.getMethod(method).invoke(c) + } catch (e: Exception) { + AppLogger.d(TAG, "$method(): ${(e.cause ?: e).message}") + null + } + } + + private fun call(method: String): Boolean { + val c = client() ?: return false + return try { + c.javaClass.getMethod(method).invoke(c) + true + } catch (e: Exception) { + AppLogger.w(TAG, "$method(): ${(e.cause ?: e).message}") + false + } + } + + private fun set(method: String, value: Any): Boolean { + val c = client() ?: return false + val type = if (value is Float) Float::class.javaPrimitiveType else Int::class.javaPrimitiveType + return try { + c.javaClass.getMethod(method, type).invoke(c, value) + AppLogger.i(TAG, "$method($value) ✓") + true + } catch (e: Exception) { + AppLogger.w(TAG, "$method($value): ${(e.cause ?: e).message}") + false + } + } + + /** + * Steps [advance] until [getter] reads [target]. + * + * Bounded, because a firmware where the switch does nothing would otherwise spin forever; + * and an unreadable state stops it at once rather than stepping blind, which on a + * three-way switch would leave the setting somewhere nobody asked for. + */ + private fun cycleTo( + label: String, + getter: String, + target: Int, + maxSteps: Int, + advance: String, + sleep: (Long) -> Unit = { Thread.sleep(it) }, + ): Boolean { + var steps = 0 + while (steps <= maxSteps) { + val current = when (val v = get(getter)) { + is Boolean -> if (v) 1 else 0 + is Int -> v + else -> -1 + } + if (current == target) { + AppLogger.i(TAG, "$label = $target reached in $steps step(s)") + return true + } + if (current < 0) { + AppLogger.w(TAG, "$label: state unreadable — not stepping blind") + return false + } + if (!call(advance)) return false + steps++ + sleep(STEP_SETTLE_MS) + } + AppLogger.w(TAG, "$label = $target not reached in $maxSteps step(s)") + return false + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/AppLogger.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/AppLogger.kt new file mode 100644 index 0000000..78474ff --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/AppLogger.kt @@ -0,0 +1,118 @@ +package com.evsuite.hardware + +import android.os.Handler +import android.os.Looper +import android.util.Log +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.CopyOnWriteArrayList + +/** + * In-app log buffer — mirrors every Log.* call to an in-memory ring buffer + * so the ConsoleFragment can display them without ADB. + * + * The buffer is a lock-guarded ArrayDeque: the previous CopyOnWriteArrayList copied all + * 400 entries twice per log line, on the calling thread — i.e. while a profile was being + * applied. Listeners are notified off the hot path. + */ +object AppLogger { + + enum class Level { DEBUG, INFO, WARN, ERROR } + + data class Entry( + val time: String, + val tag: String, + val level: Level, + val msg: String + ) + + private const val MAX_ENTRIES = 400 + private val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) + + private val lock = Any() + private val buffer = ArrayDeque(MAX_ENTRIES) + + /** + * Total entries added since process start — eviction does not decrement it. Lets the UI + * tell what is new without comparing sizes (size stops moving once the buffer is full). + */ + @Volatile + var totalCount: Long = 0L + private set + + /** Instant snapshot of the buffer, oldest to newest entry. */ + val entries: List + get() = synchronized(lock) { buffer.toList() } + + /** Whole buffer as text, for sharing or attaching to a crash report. */ + fun dump(): String = entries.joinToString("\n") { "[${it.time}] ${it.level}/${it.tag}: ${it.msg}" } + + private val listeners = CopyOnWriteArrayList<() -> Unit>() + + private val mainHandler = Handler(Looper.getMainLooper()) + + /** Notification already scheduled: a burst of logs yields a single UI wake-up. */ + @Volatile + private var notifyPending = false + + // ---- Public log methods (mirror android.util.Log) ---- + + fun d(tag: String, msg: String) { add(tag, Level.DEBUG, msg); Log.d(tag, msg) } + fun i(tag: String, msg: String) { add(tag, Level.INFO, msg); Log.i(tag, msg) } + fun w(tag: String, msg: String) { add(tag, Level.WARN, msg); Log.w(tag, msg) } + fun e(tag: String, msg: String) { add(tag, Level.ERROR, msg); Log.e(tag, msg) } + + fun clear() { + synchronized(lock) { + buffer.clear() + totalCount = 0L + } + notifyListeners() + } + + /** + * Entries added since the caller last saw [sinceTotal], or null if the missing entries + * were already evicted — in which case the caller must redraw everything. + */ + fun entriesSince(sinceTotal: Long): List? = synchronized(lock) { + val missing = totalCount - sinceTotal + when { + missing < 0L -> null // buffer cleared in the meantime + missing == 0L -> emptyList() + missing > buffer.size -> null // too late: entries evicted + else -> buffer.toList().takeLast(missing.toInt()) + } + } + + // ---- Listener for live UI updates ---- + + fun addListener(l: () -> Unit) { listeners.add(l) } + fun removeListener(l: () -> Unit) { listeners.remove(l) } + + // ---- Internal ---- + + private fun add(tag: String, level: Level, msg: String) { + val entry = Entry(sdf.format(Date()), tag, level, msg) + synchronized(lock) { + // while, not if: the cap holds even under concurrent adds. + while (buffer.size >= MAX_ENTRIES) buffer.removeFirst() + buffer.addLast(entry) + totalCount++ + } + notifyListeners() + } + + /** + * Wakes the UI on the main thread, at most once per burst: the notification must not + * run on the logging thread. + */ + private fun notifyListeners() { + if (listeners.isEmpty() || notifyPending) return + notifyPending = true + mainHandler.post { + notifyPending = false + listeners.forEach { runCatching { it.invoke() } } + } + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/CarPropertyEvidence.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/CarPropertyEvidence.kt new file mode 100644 index 0000000..0a7d497 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/CarPropertyEvidence.kt @@ -0,0 +1,183 @@ +package com.evsuite.hardware + +/** Exact interpretation used when a trip integrates a validated battery-power signal. */ +data class BatteryPowerEvidence( + val firmware: FirmwareInfo.Gen, + val conversionVersion: Int, +) { + init { + require(firmware != FirmwareInfo.Gen.UNKNOWN) { "power evidence names a known firmware" } + require(conversionVersion > 0) { "power conversion version is positive" } + } + + companion object { + /** EV_INSTANTANEOUS_CHARGE_RATE mW × -1e-6: battery output positive. */ + const val OUTPUT_POSITIVE_MW_V1 = 1 + + /** + * `BMS_PACK_VOL` × `BMS_PACK_CRNT` ÷ 1000, discharge positive — the pack pair, not a + * property a drive has confirmed. + * + * A separate version because it is a separate interpretation. A model trained on this + * must not be mistaken for one trained on a validated signal, and must be thrown away + * the day one arrives. + */ + const val PACK_PAIR_DERIVED_V2 = 2 + } +} + +/** + * What a `CarPropertyValue` proves, and what it only appears to prove. + * + * `CarPropertyManager.getFloatProperty` hands back a bare number. A generation that declares + * a property in its VHAL and never publishes a value for it answers that call with a zero + * shaped exactly like a measured zero — which is how an MG4 reporting 31 °C outside came to + * show a 0 °C traction battery on the dashboard, and a 0 kW pack current next to it. + * + * `getProperty` answers with the value *and* the status the VHAL attaches to it. Only + * [STATUS_AVAILABLE] means the vehicle stands behind the number; the other two states are the + * platform saying it has nothing to offer, and this library's contract is that "nothing" stays + * null all the way to the screen instead of arriving there as a zero. + * + * The rule lives apart from the reflection in [EVHardware] so it can be tested on the JVM: + * `android.car` is not on the compile classpath and cannot be exercised by a unit test. + */ +object CarPropertyEvidence { + + /** Signals whose scale and semantics require per-firmware vehicle evidence. */ + enum class Signal { + BATTERY_POWER_KW, + BATTERY_TEMPERATURE_CELSIUS, + } + + /** `CarPropertyValue.STATUS_AVAILABLE` — the vehicle stands behind this value. */ + const val STATUS_AVAILABLE = 0 + + /** `CarPropertyValue.STATUS_UNAVAILABLE` — the property is declared but not published. */ + const val STATUS_UNAVAILABLE = 1 + + /** `CarPropertyValue.STATUS_ERROR` — the vehicle reports the signal itself as faulty. */ + const val STATUS_ERROR = 2 + + /** + * The value when the vehicle stands behind it, null otherwise. + * + * The publication timestamp is deliberately not part of the decision. Some properties are + * published once and never again — a battery capacity is the same number at every sample — + * so an age rule would discard perfectly good readings. The timestamp is carried into the + * diagnostics report instead, where a person reads it against the rest of the picture. + */ + fun accept(value: Any?, status: Int): Any? = value?.takeIf { status == STATUS_AVAILABLE } + + fun describe(status: Int): String = when (status) { + STATUS_AVAILABLE -> "available" + STATUS_UNAVAILABLE -> "unavailable" + STATUS_ERROR -> "error" + else -> "status $status" + } + + /** + * True only after CP-003 has captured and reviewed this signal on this exact generation. + * + * The catalogue deliberately starts empty. A public property declaration and a finite + * runtime value do not prove the scale or sign on an MG4, and evidence from one generation + * must never be generalized to another. CP-003 adds generations here one by one. + */ + fun isValidated(signal: Signal, firmware: FirmwareInfo.Gen): Boolean = when (signal) { + Signal.BATTERY_POWER_KW -> batteryPowerEvidence(firmware) != null + Signal.BATTERY_TEMPERATURE_CELSIUS -> firmware in batteryTemperatureFirmware + } + + /** Null until CP-003 proves the exact generation and conversion. */ + fun batteryPowerEvidence(firmware: FirmwareInfo.Gen): BatteryPowerEvidence? = + batteryPowerEvidenceByFirmware[firmware] + + private val batteryPowerEvidenceByFirmware: Map = + emptyMap() + + /** + * What a model may be trained on — a different question from what a screen may call measured. + * + * [batteryPowerEvidence] stays empty until a drive proves a conversion, and that is what + * keeps the power figure on the dashboard labelled derived rather than measured. But on + * SWI68 that figure is pack volts times pack amps, off the same vendor block as the state of + * charge this app already trusts, and the product of two readings is arithmetic rather than + * an interpretation — no vehicle has to prove that watts are volts times amps. + * + * Refusing to train on it left the speed comparison and the model-based energy breakdown + * permanently empty on the only generation this project runs on. An estimate whose + * provenance says what it is made of is a better answer to the driver than a dash that never + * resolves. + * + * The version number is the rail. A stored model carries the evidence that produced it and + * is discarded when the two stop matching, so the day a validated conversion arrives the + * pack-pair model is retrained from scratch instead of quietly surviving underneath it. + */ + fun powerModelEvidence(firmware: FirmwareInfo.Gen): BatteryPowerEvidence? = + batteryPowerEvidence(firmware) ?: packPairEvidenceByFirmware[firmware] + + private val packPairEvidenceByFirmware: Map = mapOf( + FirmwareInfo.Gen.SWI68 to + BatteryPowerEvidence(FirmwareInfo.Gen.SWI68, BatteryPowerEvidence.PACK_PAIR_DERIVED_V2), + ) + + /** Exact generations whose battery-temperature unit and semantics CP-003 has proved. */ + private val batteryTemperatureFirmware: Set = emptySet() + + /** + * True where CP-003 observed the property declared by the VHAL and never published. + * + * This is a different answer from "not validated yet", and the difference is what a driver + * reads. An unvalidated signal may start working once someone captures it; one the vehicle + * never publishes will not, and saying "not yet validated" about it promises something that + * is not coming. + * + * A generation is listed only after repeated captures with no reading at all, never after + * a single quiet session. + */ + fun isNeverPublished(signal: Signal, firmware: FirmwareInfo.Gen): Boolean = + firmware in neverPublishedByFirmware[signal].orEmpty() + + /** + * SWI68: three diagnostic bundles from SWI68-29958-1300R67 on 2026-09-04, 51 samples + * 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. + */ + private val neverPublishedByFirmware: Map> = mapOf( + Signal.BATTERY_POWER_KW to setOf(FirmwareInfo.Gen.SWI68), + Signal.BATTERY_TEMPERATURE_CELSIUS to setOf(FirmwareInfo.Gen.SWI68), + ) +} + +/** + * One line per distinct read failure instead of one per sample. + * + * A dashboard samples every second, and a property the firmware does not answer fails the same + * way every single time. One unreadable speed property wrote two identical lines a second and + * pushed everything else out of the 400-entry buffer, so the diagnostics screen showed that one + * failure and nothing else — the log was busiest exactly when it was least useful. A repeat + * earns a line only when what it says has changed. + */ +object ReadFailureLog { + + private val lock = Any() + private val lastReason = HashMap() + + /** True the first time [key] fails this way, and again whenever the reason changes. */ + fun isNew(key: String, reason: String): Boolean = synchronized(lock) { + lastReason.put(key, reason) != reason + } + + /** A property that starts answering again must not silence its next failure. */ + fun clear(key: String) { + synchronized(lock) { lastReason.remove(key) } + } + + fun reset() { + synchronized(lock) { lastReason.clear() } + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/DataUsage.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/DataUsage.kt new file mode 100644 index 0000000..74123fa --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/DataUsage.kt @@ -0,0 +1,98 @@ +package com.evsuite.hardware + +import android.app.usage.NetworkStats +import android.app.usage.NetworkStatsManager +import android.content.Context +import android.net.TrafficStats +import java.util.Calendar + +/** + * How much data the head unit's connection has carried. + * + * These are Android's own counters — the kernel counts per interface and `NetworkStatsService` + * keeps the history — so they are the same numbers the Settings screen shows, not a + * measurement of our own. + * + * The catch is the interface. The head unit's modem presents itself as **Ethernet**, and the + * public `querySummaryForDevice(int, …)` only knows how to build MOBILE and WIFI templates: it + * answers zero here, which reads exactly like a car that used no data. The hidden overload + * that takes a `NetworkTemplate` does answer, so that is the one used, by reflection, with + * `TrafficStats` since boot as the fallback. + */ +object DataUsage { + + private const val TAG = "EV_DATA" + + /** Bytes in and out over a window, with the source that answered — for the diagnostic. */ + data class Usage(val rxBytes: Long, val txBytes: Long, val source: String) { + val totalBytes: Long get() = rxBytes + txBytes + } + + private fun Calendar.atMidnight(): Calendar = apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + fun startOfDay(): Long = Calendar.getInstance().atMidnight().timeInMillis + + /** The first of the current month at midnight — the bound the Settings screen defaults to. */ + fun startOfMonth(): Long = Calendar.getInstance().atMidnight() + .apply { set(Calendar.DAY_OF_MONTH, 1) } + .timeInMillis + + /** + * Ethernet traffic over the window, or null when no route answered. + * + * Null rather than zero, deliberately: "used nothing" and "could not read" must not look + * alike, or a missing permission passes for a quiet month. + */ + fun ethernet(context: Context, start: Long, end: Long): Usage? { + val manager = try { + context.applicationContext.getSystemService(Context.NETWORK_STATS_SERVICE) + as? NetworkStatsManager + } catch (_: Exception) { + null + } ?: return null + + val template = ethernetTemplate() ?: return null + return try { + val method = manager.javaClass.getMethod( + "querySummaryForDevice", template.javaClass, + Long::class.javaPrimitiveType, Long::class.javaPrimitiveType + ) + val bucket = method.invoke(manager, template, start, end) as? NetworkStats.Bucket + if (bucket == null) { + AppLogger.w(TAG, "querySummaryForDevice(template) → null bucket") + null + } else { + Usage(bucket.rxBytes, bucket.txBytes, "NetworkStatsManager/Ethernet") + } + } catch (e: Exception) { + AppLogger.w(TAG, "hidden route unavailable: ${e.javaClass.simpleName} ${e.message}") + null + } + } + + /** Every interface, but only since the last boot — the fallback that always answers. */ + fun sinceBoot(): Usage = Usage( + TrafficStats.getTotalRxBytes().coerceAtLeast(0), + TrafficStats.getTotalTxBytes().coerceAtLeast(0), + "TrafficStats/since boot" + ) + + /** Megabytes over the window, or null when nothing could be read. */ + fun megabytesSince(context: Context, start: Long): Int? = + ethernet(context, start, System.currentTimeMillis()) + ?.let { (it.totalBytes / 1_048_576L).toInt() } + + private fun ethernetTemplate(): Any? = try { + Class.forName("android.net.NetworkTemplate") + .getMethod("buildTemplateEthernet") + .invoke(null) + } catch (e: Exception) { + AppLogger.w(TAG, "buildTemplateEthernet unavailable: ${e.javaClass.simpleName}") + null + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt new file mode 100644 index 0000000..45178fa --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt @@ -0,0 +1,4520 @@ +package com.evsuite.hardware + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.media.AudioManager +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Parcel +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Proxy +import com.evsuite.hardware.AppLogger +import com.evsuite.hardware.model.DriveMode +import com.evsuite.hardware.model.RegenLevel +import com.evsuite.hardware.FirmwareInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Hardware abstraction layer for MG4 vehicle control. + * + * Three runtime communication layers supported by this project: + * Katman1 — android.car.Car → CarPropertyManager / CarHvacManager (async ServiceConnection) + * Katman2 — ServiceManager.getService("vehiclesetting") → raw IBinder (often SELinux-blocked) + * Katman3 — bindService(VehicleService) → IHubService → sub-services (not needed for our use case) + */ +object EVHardware { + + private const val TAG = "EV_HW" + + /** How often the Car-service watchdog retries once the initial retry ladder is done. */ + private const val CAR_WATCHDOG_INTERVAL_MS = 60_000L + + // Area IDs + private const val AREA_GLOBAL = 0x1000000 + private const val AREA_HVAC = 0x75 + + // Vehicle property IDs + private const val PROP_DRIVE_MODE = 0x2140a17c + private const val PROP_REGEN_LEVEL = 0x2140a191 + private const val PROP_ONE_PEDAL = 0x2140a193 + const val PROP_SEAT_HEAT_L = 0x15402513 + const val PROP_SEAT_HEAT_R = 0x15402514 + const val PROP_STEERING_HEAT = 0x1540253a + + // SAIC binder transaction codes + private const val TX_SET_DRIVE_MODE = 0x82 + private const val TX_SET_REGEN_LEVEL = 0xa1 + private const val TX_SET_ONE_PEDAL = 0xa4 + + private const val DESCRIPTOR_VEHICLE = "com.saicmotor.sdk.vehiclesettings.IVehicleSettingService" + private const val DESCRIPTOR_VSM132 = "com.saicmotor.vehiclesetting.IVehicleSettingService" + private const val PREFS_NAME = "drivehub_dort" + private const val KEY_LAST_DRIVE_MODE = "last_drive_mode" + + // ADAS property IDs — SWI133 (getMixProperty / getIntProperty) + private const val PROP_OVERSPEED_ALARM = 0x503004e + private const val PROP_SPEED_LIMIT_TONE = 0x503004f + private const val PROP_MIX_INTELLIGENT_DRIVE = 0x32 + + // ELK — Assistant de sortie de voie (SWI133) + // Access via the runtime IVehicleSettingService binder (sVehicleBinder). + // getLaneKeepingAsstMode() → TX 0x53 (synchrone, reply: readException + readInt) + // setLaneKeepingAsstMode(I) → TX 0x54 (ONEWAY, data: writeInt(value)) + // getLaneKeepingAsstSen() → TX 0x55 (synchrone) + // setLaneKeepingAsstSen(I) → TX 0x56 (ONEWAY) + // Mode : 1=OFF, 2=Alert(LDW), 3=Aider(LDP), 5=Maintien d'urgence(ELK) + // Sen : 1=Low, 2=Standard, 3=High + private const val TX_ELK_GET_MODE = 0x53 + private const val TX_ELK_SET_MODE = 0x54 + private const val TX_ELK_GET_SEN = 0x55 + private const val TX_ELK_SET_SEN = 0x56 + + // SWI132 binder TX codes (IVehicleSettingService — DESCRIPTOR_VSM132, two-way flag=0x0) + // Setters : 0x057=setSLIFWarningState, 0x128=setOverSpeedSoundMode, 0x12a=setSpeedLimitSoundMode + // Getters : 0x058=getSLIFWarningState, 0x129=getOverSpeedSoundMode, 0x12b=getSpeedLimitSoundMode + private const val VSM132_TX_SLIF_WARNING = 0x057 + private const val VSM132_TX_OVERSPEED_SOUND = 0x128 + private const val VSM132_TX_SPEED_LIMIT = 0x12a + private const val VSM132_TX_GET_SLIF = 0x058 + private const val VSM132_TX_GET_OVERSPEED = 0x129 + private const val VSM132_TX_GET_SPEED_LIMIT = 0x12b + + /** Values du mode ELK (LaneKeepingAsstMode). */ + object ElkMode { + const val OFF = 1 // Disabled + const val ALERT = 2 // Alert (LDW) + const val ASSIST = 3 // Aider (LDP) + const val EMERGENCY = 5 // Maintien d'urgence (ELK) + } + + /** Values de sensitivity ELK. */ + object ElkSensitivity { + const val LOW = 1 // Low + const val STANDARD = 2 // Standard + const val HIGH = 3 // High + } + + // AEB — Front collision avoidance system (SWI133) + // PROP_AEB_SWITCH : CarPropertyManager, AREA_GLOBAL, 1=OFF / 2=ON + private const val PROP_AEB_SWITCH = 0x2140a108 // AAD_FRONT_COLLISION_ASST_SYS (CPM) + // PROP_AEB_SYS_MODE : VPM, ID_AAD_FRONT_COLLISION_ASST_SYS, 1=Alert / 2=Alert+Freinage + // PROP_AEB_MODE : VPM, ID_AAD_AUTO_EME_BREAK, 1=Alert / 2=Alert+Freinage + // Both properties are written together to preserve the observed vehicle behaviour. + private const val PROP_AEB_SYS_MODE = 0x302000a // ID_AAD_FRONT_COLLISION_ASST_SYS (VPM) + private const val PROP_AEB_MODE = 0x302000b // ID_AAD_AUTO_EME_BREAK (VPM) + // PROP_AEB_SENSITIVITY : VPM, ForwardCollisionAsstSentItem, 1=Low / 2=Standard / 3=High + private const val PROP_AEB_SENSITIVITY = 0x302000e // ID_AAD_FRONT_COLLISION_ASST_SEN (VPM) + + // TSR — Reconnaissance des panneaux de speed (SLIF Warning) + // SWI133 : VPM toggle 0/1 ; SWI68/SWI165 : VSM setSpeedAsstSlifWarning ; SWI69/SWI131 : VSM setSLIFWarningState (inverted) + private const val PROP_TSR_MODE = 0x5030049 // ID_AAD_SLIF_WARNING + + // Energy saving (Endurance Mode / Longer Endurance) + // SWI133 : VPM PROP_ENERGY_SAVING ; SWI69/SWI131 : VSM setEnduranceMode ; SWI68/SWI165 : VSM setLongerEndurance + private const val PROP_ENERGY_SAVING = 0x5030007 // ID_LONGER_ENDURANCE_MODE + + // SWI68 : VehicleSettingManager class name (loaded via launcher context) + private const val VSM_CLASS = "com.saicmotor.sdk.vehiclesettings.manager.VehicleSettingManager" + private const val LAUNCHER68_PKG = "com.saicmotor.hmi.launcher" + + // Brightness screen — ancien SDK (SWI133/68/165) : GeneralManager.setBrightness(Int)/getBrightness(). + // Runtime range 0..255 on the supported generations. + // A9 (SWI132/131/69) = phase 2 (setScreenBrightness(III), params not decodable without SystemUI). + private const val GENERAL_MANAGER_CLASS = "com.saicmotor.sdk.systemsettings.GeneralManager" + // ESC lives on the *control* manager, not the settings one — get/setEspSwitch exist + // nowhere else, and looking for them on the VehicleSettingManager failed without a sound. + private const val VCONTROL_CLASS = "com.saicmotor.sdk.vehiclesettings.manager.VehicleControlManager" + private const val BRIGHTNESS_NATIVE_MAX = 255 + + // Media volume — ancien SDK (SWI133/68/165) : SmartSoundManager.getVolume/setVolume/getMaxVolume(type). + // Same systemsettings/BaseManager SDK as GeneralManager (singleton sInstance + init(Context, listener)). + private const val SMART_SOUND_MANAGER_CLASS = "com.saicmotor.sdk.systemsettings.SmartSoundManager" + private const val BRIGHTNESS_MIN_PERCENT = 5 // floor de safety : ne jamais turn off l'screen + + // SWI69/SWI131 : access via CarAdapterClient → queryClient(0x8) → CarVehicleSettingClient + // Actual architecture : CarAdapterClient connects to com.saicmotor.caradapter.CarAdapterService, + // puis queryClient(code) returns l'IBinder pour chaque service. + // Runtime service code 0x8 = CarVehicleSettingClient. + private const val LAUNCHER69_PKG = "com.saicmotor.launcher" + private const val CAR_ADAPTER_CLASS = "com.saicmotor.carapi.CarAdapterClient" + private const val VSM69_CLIENT_CLASS = "com.saicmotor.carapi.client.CarVehicleSettingClient" + private const val VSM_SERVICE_CODE = 0x8 // queryClient(0x8) → ICarVehicleSettingService + private const val VEHICLE_SETTING_PKG = "com.saicmotor.vehiclesetting" // SWI131 : carapi dans VS + + // Katman5 — VehicleConditionManager (IVehicleConditionService via IHubService "vehiclecondition") + private const val VCM_CLASS = "com.saicmotor.sdk.vehiclesettings.manager.VehicleConditionManager" + private const val VCM_LISTENER_CLASS = "com.saicmotor.sdk.vehiclesettings.IVehicleConditionListener" + + // Katman5 SWI69/SWI131 — ICarGeneralService via CarAdapterClient (queryClient(0x1)) + private const val CAR_GENERAL_CLIENT_CLASS = "com.saicmotor.carapi.client.CarGeneralClient" + private const val BIND_CODE_CAR_GENERAL = 0x1 // ICarAdapterService.queryClient(0x1) + + // Standard AAOS — VehicleProperty.IGNITION_STATE (compatible tous firmwares via CarPropertyManager) + private const val PROP_IGNITION_STATE = 0x11400409 + + // Standard AAOS — VehicleProperty.PERF_VEHICLE_SPEED (float, m/s). Base du lock + // of writes at 0 km/h (see VehicleWriteGate). + private const val PROP_VEHICLE_SPEED = 0x11600207 + + // Standard AAOS — VehicleProperty.ENV_OUTSIDE_TEMPERATURE (float, °C). + // NON VERIFIED sur vehicle : tous les firmwares MG4 n'exposent pas cette property. + // Les callers doivent traiter null comme « data unavailable », pas comme 0 °C. + private const val PROP_OUTSIDE_TEMP = 0x11600703 + + /** Values de IGNITION_STATE (VehicleIgnitionState) — property AAOS standard. */ + object IgnitionState { + const val UNDEFINED = 0 + const val LOCK = 1 + const val OFF = 2 + const val ACC = 3 + const val ON = 4 // Key detected + brake pressed = READY state + const val START = 5 + } + + /** + * Values returned by IVehicleConditionService.getVehicleIgnition() (Katman5). + * Values exposed by the supported SWI133 runtime interface. + */ + object CarIgnitionItem { + const val OFF = 0x0 // Car offe + const val ACCESSORY = 0x1 // Accessoires only + const val RUN = 0x2 // Key ON / state READY + const val CRANK = 0x3 // Cranking + } + + /** + * Speed limiter (Speed Assist System / SAS) — setting INDEPENDENT of the ACC/TJA mode. + * On SWI132 it is driven by setSasMode (NOT setAccTjaState/SHWA, which does not enable it). + * Values observed on the supported SWI132 runtime interface: + * 0 = Disabled, 2 = Manual, 3 = Intelligent (1 = speed warning, TW models only) + */ + object SasMode { + const val OFF = 0 + const val MANUEL = 2 + const val INTELLIGENT = 3 + } + + /** Values de mode ADAS pour firmware SWI68/SWI132 (CarAccTja constants). */ + object Swi68Mode { + const val OFF = 0x4 // Disable + const val SHWA = 0x3 // Speed Limit Mode (Limiter) — SWI132 only + const val ACC = 0x1 // ACC + const val TJA = 0x2 // TJA (Traffic Jam Assist) = ICA dans l'UI SWI132 + } + + /** Values du mode AEB (communes SWI133 + SWI68). */ + object AebMode { + const val ALARM = 1 // Alert seule (FCW) + const val ALARM_BRAKE = 2 // Alert + Freinage automatique d'urgence + } + + /** Values de sensitivity AEB — SWI133 only (PROP_AEB_SENSITIVITY = 0x302000e). */ + object AebSensitivity { + const val LOW = 1 // Low + const val STANDARD = 2 // Standard + const val HIGH = 3 // High + } + + @Volatile private var sAppContext: Context? = null + @Volatile private var sCar: Any? = null + @Volatile private var sCarPropertyManager: Any? = null + @Volatile private var sCarHvacManager: Any? = null + @Volatile private var sCpmGetIntMethod: java.lang.reflect.Method? = null + @Volatile private var sCpmGetFloatMethod: java.lang.reflect.Method? = null + @Volatile private var sCpmGetBooleanMethod: java.lang.reflect.Method? = null + /** `getProperty(Class, int, int)` — the only read that carries the VHAL's status. */ + @Volatile private var sCpmGetPropertyMethod: java.lang.reflect.Method? = null + /** Set once the getters above have been looked up, present or absent. */ + @Volatile private var sCpmMethodsResolved = false + @Volatile private var sVehicleBinder: IBinder? = null + @Volatile private var sVpm: Any? = null // VehiclePropertyManager instance (SWI133, Katman4) + @Volatile private var sVpmService: Any? = null // mIVehiclePropertyService field value (SWI133) + @Volatile private var sVsm: Any? = null // VehicleSettingManager instance (SWI68, Katman4) + // The A9 adapter, kept after the VSM client is built: it is the door to every other + // carapi client too — the climate one among them — and nothing else can open it. + @Volatile private var sCarAdapter: Any? = null + @Volatile private var sCarAdapterClass: Class<*>? = null + @Volatile private var sVsmService: Any? = null // mVehicleSettingService field value (SWI68) + @Volatile private var sVsm133: Any? = null // VehicleSettingManager instance (SWI133, pour ELK) + @Volatile private var sGeneral: Any? = null // GeneralManager instance (SWI133/68/165, brightness) + @Volatile private var sVcontrol: Any? = null // VehicleControlManager instance (SWI68/165, ESC) + @Volatile private var sSmartSound: Any? = null // SmartSoundManager instance (SWI133/68/165, loudness) + @Volatile private var sCarGeneral: Any? = null // CarGeneralClient instance (A9 SWI132/131/69, brightness) + @Volatile private var sInitialized = false + @Volatile private var sCarBindAttempted = false + @Volatile private var sCarWatchdogArmed = false + @Volatile var logEnabled = true + + @Volatile private var sDriveModeListener: DriveModeListener? = null + @Volatile private var sHvacListener: HvacListener? = null + + // ── Katman5 — IGNITION_STATE via CarPropertyManager (standard AAOS) ────── + @Volatile private var sIgnitionCallbackProxy: Any? = null + @Volatile private var sIgnitionCallbackRegistered = false + private val ignitionCallbacks = java.util.concurrent.CopyOnWriteArrayList<(Int) -> Unit>() + + // ── Katman5 — VehicleConditionManager / ICarGeneralService ─────────────── + @Volatile private var sVcm: Any? = null + @Volatile private var sVcmListener: Any? = null + @Volatile private var sVcmCallbackRegistered = false + @Volatile private var sLastVcmIgnitionState = -1 // filters repeated false RUN events + private val vehicleConditionCallbacks = java.util.concurrent.CopyOnWriteArrayList<(Int) -> Unit>() + private val katman5ReadyListeners = java.util.concurrent.CopyOnWriteArrayList<() -> Unit>() + + /** Listeners notified as soon as Katman1 (CPM + HVAC) is operational. */ + private val katman1ReadyListeners = java.util.concurrent.CopyOnWriteArrayList<() -> Unit>() + + /** Listeners notified as soon as Katman4 (mIVehiclePropertyService) is operational. */ + private val katman4ReadyListeners = java.util.concurrent.CopyOnWriteArrayList<() -> Unit>() + + /** + * Runs [action] as soon as CarPropertyManager et CarHvacManager are available. + * If already ready, runs immediately. Otherwise queued and triggered on connection. + */ + fun whenKatman1Ready(action: () -> Unit) { + if (sCarPropertyManager != null && sCarHvacManager != null) { + action() + } else { + katman1ReadyListeners.add(action) + } + } + + /** + * Runs [action] as soon as the ADAS service (Katman4) is available. + * SWI133 → mIVehiclePropertyService ; SWI68/SWI69/SWI131 → mVehicleSettingService + */ + fun whenKatman4Ready(action: () -> Unit) { + val ready = if (FirmwareInfo.isVsmBased()) sVsmService != null else sVpmService != null + if (ready) action() else katman4ReadyListeners.add(action) + } + + /** Runs [action] as soon as Katman5 (IVehicleConditionService) is operational. */ + fun whenKatman5Ready(action: () -> Unit) { + if (sVcmCallbackRegistered) action() else katman5ReadyListeners.add(action) + } + + /** Registers a callback invoked on every ignition state change (CarIgnitionItem). */ + fun registerVehicleConditionListener(callback: (Int) -> Unit) { + vehicleConditionCallbacks.add(callback) + } + + fun unregisterVehicleConditionListener(callback: (Int) -> Unit) { + vehicleConditionCallbacks.remove(callback) + } + + /** Enregistre un callback sur IGNITION_STATE via CarPropertyManager (standard AAOS). */ + fun registerIgnitionCallback(callback: (Int) -> Unit) { + ignitionCallbacks.add(callback) + if (sCarPropertyManager != null) registerIgnitionPropertyCallback() + } + + fun unregisterIgnitionCallback(callback: (Int) -> Unit) { + ignitionCallbacks.remove(callback) + } + + /** Unregisters the CarPropertyManager proxy (call from Service.onDestroy). */ + fun unregisterIgnitionPropertyCallback() { + val cpm = sCarPropertyManager ?: return + val proxy = sIgnitionCallbackProxy ?: return + try { + val m = cpm.javaClass.methods.firstOrNull { + it.name == "unregisterCallback" && it.parameterCount == 1 + } ?: return + m.invoke(cpm, proxy) + sIgnitionCallbackProxy = null + sIgnitionCallbackRegistered = false + AppLogger.i(TAG, " IGNITION_STATE callback unregistered ✓") + } catch (e: Exception) { + AppLogger.d(TAG, " IGNITION: unregisterCallback error: ${e.message}") + } + } + + /** + * État d'ignition courant, en valeurs [IgnitionState] (AAOS). + * + * CarPropertyManager d'abord. Mais SWI68 n'expose pas IGNITION_STATE via CPM : la + * property revient vide, et l'état d'allumage passait pour illisible alors que Katman5 + * le rapporte correctement — le log le montre (`Katman5: state initial = 2 (RUN/READY)`) + * pendant que le diagnostic classait IGNITION_STATE en NOT_READABLE. + * + * D'où le repli sur le dernier état livré par IVehicleConditionService, converti : + * Katman5 parle en [CarIgnitionItem], cette fonction rend du [IgnitionState], et les + * deux échelles ne coïncident pas (RUN vaut 2 chez l'un, OFF vaut 2 chez l'autre). + * + * Returns -1 si rien n'est lisible, 0 si la property existe mais ne dit rien. + */ + fun getCurrentIgnitionState(): Int { + val v0 = getIntPropertyCPM(PROP_IGNITION_STATE, 0) + if (v0 > 0) return v0 + val global = getIntPropertyCPM(PROP_IGNITION_STATE, AREA_GLOBAL) + if (global > 0) return global + return vcmIgnitionAsAaos() ?: global + } + + /** Le dernier état Katman5 traduit en [IgnitionState], ou null s'il n'y en a pas eu. */ + private fun vcmIgnitionAsAaos(): Int? = when (sLastVcmIgnitionState) { + CarIgnitionItem.OFF -> IgnitionState.OFF + CarIgnitionItem.ACCESSORY -> IgnitionState.ACC + CarIgnitionItem.RUN -> IgnitionState.ON + CarIgnitionItem.CRANK -> IgnitionState.START + else -> null + } + + /** + * Vehicle speed in km/h, or null if it cannot be read (CPM not ready, + * property non supportsde, exception). [VehicleWriteGate] traite null comme un refus. + * + * The property's unit varies by generation — see [VehicleSpeedScale]. + */ + fun getVehicleSpeedKmh(): Float? { + val raw = getFloatPropertyCPM(PROP_VEHICLE_SPEED, AREA_GLOBAL) + ?: getFloatPropertyCPM(PROP_VEHICLE_SPEED, 0) + ?: return null + // The unit is firmware-dependent, not what AAOS specifies: SWI68 already reports + // km/h. [VehicleSpeedScale] carries the evidence and takes the magnitude, since the + // property is signed in reverse and every consumer asks whether the car is moving. + return VehicleSpeedScale.toKmh(raw, FirmwareInfo.getGeneration()) + } + + /** + * Outside temperature in °C, or null if unreadable (CPM not ready, property not + * supported by the firmware, exception). + * + * The null is significant: it means "we do not know", not "it is 0 °C". + * A weather rule that receives null must NOT fire. + */ + fun getOutsideTempCelsius(): Float? = + getFloatPropertyCPM(PROP_OUTSIDE_TEMP, AREA_GLOBAL) + ?: getFloatPropertyCPM(PROP_OUTSIDE_TEMP, 0) + + // Standard AAOS read-only energy signals. Every access is explicitly limited to a + // supported firmware generation; UNKNOWN stays null instead of assuming compatibility. + private const val PROP_EV_INSTANTANEOUS_CHARGE_RATE = 0x1160030C // mW, + charge / - discharge + private const val PROP_EV_CURRENT_BATTERY_CAPACITY = 0x1160030D // Wh, public post-AAOS 9 candidate + private const val PROP_EV_CHARGE_PORT_CONNECTED = 0x1120030B + private const val PROP_EV_BATTERY_AVG_TEMP = 0x1160030E // °C + private const val PROP_EV_BATTERY_LEVEL = 0x11600309 // Wh + private const val PROP_RANGE_REMAINING = 0x11600308 // metres + private const val PROP_INFO_EV_BATTERY_CAPACITY = 0x11600106 // Wh + private const val PROP_ODOMETER = 0x11600204 // km + private const val PROP_CABIN_TEMP = 0x15600502 // °C, zoned + private const val PROP_TIRE_PRESSURE = 0x17600309 // kPa, per wheel + private const val PROP_EV_BATTERY_PCT_SWI68 = 0x2160F404 // vendor float % + private const val PROP_EV_RANGE_KM_SWI68 = 0x2140F41C // vendor int km + private const val PROP_BMS_PACK_VOLT_SWI68 = 0x2160F406 // vendor float V + private const val PROP_BMS_PACK_CURRENT_SWI68 = 0x2160F407 // vendor float A + // Vendor publishes one integer per wheel on the global area; the generic per-wheel + // PROP_TIRE_PRESSURE is declared and never publishes on this vehicle. + private const val PROP_VENDOR_TIRE_FL = 0x21401553 + private const val PROP_VENDOR_TIRE_FR = 0x21401554 + private const val PROP_VENDOR_TIRE_RL = 0x21401555 + private const val PROP_VENDOR_TIRE_RR = 0x21401556 + + /** 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 + + /** + * `HVAC_AMBIENT_TEMPERATURE`, the one in-car candidate left on this firmware. + * + * The vendor map declares it beside `HVAC_TEMPERATURE_OUTCAR`, which rules out its being + * a second name for the outside sensor, and no OEM app on the unit reads it. Declared and + * unread proves nothing — `EV_BATTERY_AVG_TEMP` is declared too and never publishes — so + * this is probed and not read: a wrong guess here puts the outside temperature in the + * cabin tile, which is worse than the dash that is there now. + */ + private const val PROP_HVAC_AMBIENT_TEMP_SWI68 = 0x1560252A // vendor float °C + + /** + * What the vehicle's own charging service treats as an impossible pack current, so a + * reading past it is a signal that never arrived rather than a car drawing that much. + */ + private const val MAX_PACK_CURRENT_A = 2276.75f + + /** A 400 V pack under load; outside this the pair is not the pair we think it is. */ + private val PACK_VOLTAGE_RANGE = 200f..500f + + /** Battery power in kW: positive traction/accessory draw, negative charging/regen. */ + fun getBatteryPowerKw(): Float? = supportedTelemetryRead { + getFloatPropertyCPM(PROP_EV_INSTANTANEOUS_CHARGE_RATE, AREA_GLOBAL) + ?.takeIf { it.isFinite() } + ?.div(-1_000_000f) + ?.takeIf { kotlin.math.abs(it) <= 1_000f } + } + + fun getBatteryTemperatureCelsius(): Float? = supportedTelemetryRead { + getFloatPropertyCPM(PROP_EV_BATTERY_AVG_TEMP, AREA_GLOBAL) + ?.takeIf { it.isFinite() && it in -50f..120f } + } + + fun getBatteryEnergyKwh(): Float? = supportedTelemetryRead { + getFloatPropertyCPM(PROP_EV_BATTERY_LEVEL, AREA_GLOBAL) + ?.takeIf { it.isFinite() && it >= 0f } + ?.div(1_000f) + } + + fun getBatteryCapacityKwh(): Float? = supportedTelemetryRead { + getFloatPropertyCPM(PROP_INFO_EV_BATTERY_CAPACITY, AREA_GLOBAL) + ?.takeIf { it.isFinite() && it > 0f } + ?.div(1_000f) + } + + /** + * Probe-only public AAOS candidate; never use this as a production telemetry fallback until + * CP-003 validates its availability and meaning on every supported firmware generation. + */ + internal fun probeCurrentBatteryCapacityWh(): Float? = supportedTelemetryRead { + getFloatPropertyCPM(PROP_EV_CURRENT_BATTERY_CAPACITY, AREA_GLOBAL) + ?.takeIf { it.isFinite() && it >= 0f } + } + + fun getStandardRangeKm(): Float? = supportedTelemetryRead { + getFloatPropertyCPM(PROP_RANGE_REMAINING, AREA_GLOBAL) + ?.takeIf { it.isFinite() && it >= 0f } + ?.div(1_000f) + } + + /** + * Total mileage in km, from whichever service answers. + * + * `PERF_ODOMETER` is declared and never published on the MG4 — every read on + * SWI68-29958-1300R67 came back with no `CarPropertyValue` — so the odometer read as + * unavailable while the head unit knew it perfectly well. The navigation adapter answers + * it, which is how the 3.6x speed error got an independent reference at all. + * + * The adapter needs [com.evsuite.hardware.saic.SaicNav.connect] to have been called; until + * then it simply answers nothing, like any other unbound service. + */ + fun getOdometerKm(): Float? = supportedTelemetryRead { + getFloatPropertyCPM(PROP_ODOMETER, AREA_GLOBAL) + ?.takeIf { it.isFinite() && it >= 0f } + ?: getIntPropertyCPM(PROP_VENDOR_TOTAL_MILEAGE, AREA_GLOBAL) + .takeIf { it > 0 }?.toFloat() + ?: com.evsuite.hardware.saic.SaicNav.totalMileageKm()?.toFloat() + } + + /** + * The vehicle's charging session state, or null when it publishes none. + * + * 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. + */ + fun getVendorChargeStatus(): Int? = + getIntPropertyCPM(PROP_VENDOR_CHARGE_STATUS, AREA_GLOBAL).takeIf { it >= 0 } + + fun getCabinTemperatureCelsius(): Float? = supportedTelemetryRead { + getFloatPropertyCPM(PROP_CABIN_TEMP, AREA_HVAC) + ?.takeIf { it.isFinite() && it in -50f..100f } + } + + fun isChargePortConnected(): Boolean? = supportedTelemetryRead { + getBooleanPropertyCPM(PROP_EV_CHARGE_PORT_CONNECTED, AREA_GLOBAL) + } + + enum class Wheel(val area: Int) { + FRONT_LEFT(0x01), FRONT_RIGHT(0x02), REAR_LEFT(0x04), REAR_RIGHT(0x08) + } + + /** + * Tyre pressure in kPa, or null when the signal is unavailable. + * + * Tries the vendor's per-wheel integer on the global area first — that is the one this + * vehicle publishes on — then falls back to the generic zoned property for runtimes that + * keep it. + */ + fun getTirePressureKpa(wheel: Wheel): Float? { + val vendorId = vendorTirePropertyId(wheel) + if (vendorId != null) { + val raw = getIntPropertyCPM(vendorId, AREA_GLOBAL) + if (raw > 0) return raw.toFloat() + } + return getFloatPropertyCPM(PROP_TIRE_PRESSURE, wheel.area) + ?.takeIf { it.isFinite() && it in 0f..1_000f } + } + + private fun vendorTirePropertyId(wheel: Wheel): Int? = when (wheel) { + Wheel.FRONT_LEFT -> PROP_VENDOR_TIRE_FL + Wheel.FRONT_RIGHT -> PROP_VENDOR_TIRE_FR + Wheel.REAR_LEFT -> PROP_VENDOR_TIRE_RL + Wheel.REAR_RIGHT -> PROP_VENDOR_TIRE_RR + } + + /** Vendor battery SOC; SWI68 and SWI69 both publish it on the same id. */ + fun getVendorBatterySocPercent(): Float? = + if (FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI68 || + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI69) { + getFloatPropertyCPM(PROP_EV_BATTERY_PCT_SWI68, AREA_GLOBAL) + ?.takeIf { it.isFinite() && it in 0f..100f } + } else null + + /** + * Battery power from the pack's own voltage and current, in kW — SWI68 only. + * + * SWI68 publishes no `EV_BATTERY_INSTANTANEOUS_CHARGE_RATE`, which is why every kWh figure + * in EVSuite has had to be modelled or refused. It does publish the two signals the product + * is made of: `BMS_PACK_VOL` and `BMS_PACK_CRNT`, in the same vendor block as + * `BMS_PACK_SOC_DSP` — the property this app already reads the charge from on this car. The + * vehicle's own charging service reads both and bounds the current at + * [MAX_PACK_CURRENT_A]; the bounds here are that service's, not a guess. + * + * **Sign.** Positive is taken as draw and negative as charge, matching the rest of EVSuite. + * Which way the vehicle signs its current is not proven — settling it needs one charge + * session with a reading on screen — so a caller must publish this as derived, never as a + * measurement, until it is. + */ + fun getVendorBatteryPowerKw(): Float? { + val gen = FirmwareInfo.getGeneration() + if (gen != FirmwareInfo.Gen.SWI68 && gen != FirmwareInfo.Gen.SWI69) return null + val volts = getFloatPropertyCPM(PROP_BMS_PACK_VOLT_SWI68, AREA_GLOBAL) + ?.takeIf { it.isFinite() && it in PACK_VOLTAGE_RANGE } ?: return null + val amps = getFloatPropertyCPM(PROP_BMS_PACK_CURRENT_SWI68, AREA_GLOBAL) + ?.takeIf { it.isFinite() && kotlin.math.abs(it) <= MAX_PACK_CURRENT_A } ?: return null + return volts * amps / 1_000f + } + + /** Vendor range fallback; SWI68 and SWI69 both publish it on the same id. */ + fun getVendorRangeKm(): Int? = + if (FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI68 || + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI69) { + getIntPropertyCPM(PROP_EV_RANGE_KM_SWI68, AREA_GLOBAL).takeIf { it >= 0 } + } else null + + /** One property, its id, and exactly what the vehicle answered for it. */ + data class PropertyReport(val name: String, val propId: Int, val areaId: Int, val outcome: String) { + override fun toString() = "$name 0x${Integer.toHexString(propId)} → $outcome" + } + + /** + * What every read-only energy property answers on the car in front of you, unfiltered. + * + * The dashboard shows an em dash for a signal it does not trust, which is the right thing + * to show a driver and the wrong thing to debug with: an unsupported property, one declared + * and never published, and one this runtime cannot reach all look identical from the seat. + * This reports the three apart — status, raw value and publication timestamp as the vehicle + * gave them — so a firmware generation's actual telemetry surface can be recorded from the + * car rather than inferred. + * + * Deliberately outside [supportedTelemetryRead]: the point is to find out what a generation + * answers, including one the library currently declines to read from. + */ + fun probeTelemetryProperties(): List = listOf( + probeProperty("PERF_VEHICLE_SPEED", PROP_VEHICLE_SPEED, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("EV_BATTERY_AVG_TEMP", PROP_EV_BATTERY_AVG_TEMP, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("EV_INSTANTANEOUS_CHARGE_RATE", PROP_EV_INSTANTANEOUS_CHARGE_RATE, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("EV_BATTERY_LEVEL", PROP_EV_BATTERY_LEVEL, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("INFO_EV_BATTERY_CAPACITY", PROP_INFO_EV_BATTERY_CAPACITY, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("RANGE_REMAINING", PROP_RANGE_REMAINING, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("ENV_OUTSIDE_TEMPERATURE", PROP_OUTSIDE_TEMP, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("PERF_ODOMETER", PROP_ODOMETER, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("HVAC_TEMPERATURE_CURRENT", PROP_CABIN_TEMP, AREA_HVAC, Float::class.javaObjectType), + probeProperty("EV_CHARGE_PORT_CONNECTED", PROP_EV_CHARGE_PORT_CONNECTED, AREA_GLOBAL, Boolean::class.javaObjectType), + // The pair battery power is computed from on SWI68. Probed by name so the diagnostic + // bundle says which of the two answered, rather than only that the product did not. + probeProperty("BMS_PACK_VOL", PROP_BMS_PACK_VOLT_SWI68, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("BMS_PACK_CRNT", PROP_BMS_PACK_CURRENT_SWI68, AREA_GLOBAL, Float::class.javaObjectType), + probeProperty("HVAC_AMBIENT_TEMPERATURE", PROP_HVAC_AMBIENT_TEMP_SWI68, AREA_GLOBAL, Float::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") + cacheCarPropertyMethods(cpm) + val getProperty = sCpmGetPropertyMethod + ?: return PropertyReport(name, propId, areaId, "runtime exposes no getProperty") + return try { + val holder = getProperty.invoke(cpm, boxed, propId, areaId) + ?: return PropertyReport(name, propId, areaId, "no CarPropertyValue") + val status = (holder.javaClass.getMethod("getStatus").invoke(holder) as? Int) + ?.let(CarPropertyEvidence::describe) ?: "no status" + val value = holder.javaClass.getMethod("getValue").invoke(holder) + val timestamp = holder.javaClass.getMethod("getTimestamp").invoke(holder) as? Long + PropertyReport(name, propId, areaId, "$status, value=$value, t=${timestamp ?: "?"}") + } catch (e: Exception) { + PropertyReport(name, propId, areaId, e.readFailureReason()) + } + } + + private inline fun supportedTelemetryRead(read: () -> T?): T? = + when (FirmwareInfo.getGeneration()) { + FirmwareInfo.Gen.SWI68, + FirmwareInfo.Gen.SWI69, + FirmwareInfo.Gen.SWI131, + FirmwareInfo.Gen.SWI132, + FirmwareInfo.Gen.SWI133, + FirmwareInfo.Gen.SWI165 -> read() + FirmwareInfo.Gen.UNKNOWN -> null + } + + // ------------------------------------------------------------------------- + // Climate + windows — READ ONLY. + // + // The numeric property ids below match the public AAOS property names used by the + // supported R69 runtime: HVAC_FAN_SPEED 0x15400500, HVAC_TEMPERATURE_SET 0x15600503, + // HVAC_RECIRC_ON 0x15200508, WINDOW_POS 0x13400BC0. HVAC_AC_ON / HVAC_AUTO_ON use the + // canonical AOSP values (not surfaced in that mapping). What is still UNVERIFIED is the + // area ids and whether a given MG4 generation actually exposes each one live — hence + // read-only, and the EVTasker diagnostic screen exists to check exposure per car. + // Every method returns null when unreadable — treat null as "unknown", never a value. + // No write counterpart on purpose: a wrong id written to the vehicle is the deferred risk. + // ------------------------------------------------------------------------- + + private const val PROP_HVAC_AC_ON = 0x15200505 // HVAC_AC_ON (bool) + private const val PROP_HVAC_AUTO_ON = 0x1520050A // HVAC_AUTO_ON (bool) + private const val PROP_HVAC_RECIRC_ON = 0x15200508 // HVAC_RECIRC_ON (bool) + private const val PROP_HVAC_FAN_SPEED = 0x15400500 // HVAC_FAN_SPEED (int) + private const val PROP_HVAC_TEMPERATURE_SET = 0x15600503 // HVAC_TEMPERATURE_SET (float °C) + private const val PROP_WINDOW_POS = 0x13400BC0 // WINDOW_POS (int, per window area) — R69-confirmed 322964416 + + // Candidate HVAC area ids: the seat-heat area used elsewhere, plus GLOBAL and 0. + private val HVAC_AREA_CANDIDATES = intArrayOf(AREA_HVAC, AREA_GLOBAL, 0) + // VehicleAreaWindow bits: front windshield + the four door windows. + private val WINDOW_AREA_CANDIDATES = intArrayOf(0x0001, 0x0010, 0x0040, 0x0100, 0x0400) + + fun getAcOn(): Boolean? = readHvacBool(PROP_HVAC_AC_ON) + fun getHvacAutoOn(): Boolean? = readHvacBool(PROP_HVAC_AUTO_ON) + fun getRecircOn(): Boolean? = readHvacBool(PROP_HVAC_RECIRC_ON) + fun getFanSpeed(): Int? = readHvacInt(PROP_HVAC_FAN_SPEED) + fun getTemperatureSetCelsius(): Float? = readHvacFloat(PROP_HVAC_TEMPERATURE_SET) + + /** True if any door/windshield window reads as not fully closed; null if none readable. */ + fun isAnyWindowOpen(): Boolean? { + var readAny = false + for (area in WINDOW_AREA_CANDIDATES) { + val pos = readWindowArea(area) ?: continue + readAny = true + if (pos > 0) return true + } + return if (readAny) false else null + } + + private fun readHvacBool(propId: Int): Boolean? = readHvacInt(propId)?.let { it != 0 } + + /** Sweeps HVAC-manager then CPM across the candidate areas; null when nothing answers. */ + private fun readHvacInt(propId: Int): Int? { + for (area in HVAC_AREA_CANDIDATES) { + getIntPropertyHvac(propId, area).takeIf { it >= 0 }?.let { return it } + getIntPropertyCPM(propId, area).takeIf { it >= 0 }?.let { return it } + } + return null + } + + private fun readHvacFloat(propId: Int): Float? { + val hvac = sCarHvacManager + for (area in HVAC_AREA_CANDIDATES) { + if (hvac != null) { + runCatching { + hvac.javaClass.getMethod("getFloatProperty", Int::class.java, Int::class.java) + .invoke(hvac, propId, area) as? Float + }.getOrNull()?.let { return it } + } + getFloatPropertyCPM(propId, area)?.let { return it } + } + return null + } + + private fun readWindowArea(area: Int): Int? = + getIntPropertyCPM(PROP_WINDOW_POS, area).takeIf { it >= 0 } + + /** Contexte applicatif, pour les messages utilisateur du lock d'write. */ + internal fun appContext(): Context? = sAppContext + + interface DriveModeListener { fun onDriveModeChanged(mode: DriveMode) } + interface HvacListener { + fun onSeatHeatChanged(left: Int, right: Int) + fun onSteeringHeatChanged(on: Boolean) + } + + // ------------------------------------------------------------------------- + // Init + // ------------------------------------------------------------------------- + + fun init(context: Context) { + if (sInitialized) return + sInitialized = true + sAppContext = context.applicationContext + // Cached here so the catalogue predicate stays a property read: every editor and the + // diagnostic ask `effectProven` once per entry, several times a screen. + GlassEvidence.load(context) + com.evsuite.hardware.saic.RadioBandMemory.load(context) + AppLogger.i(TAG, "=== EVHardware.init() === uid=${android.os.Process.myUid()} sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.DEVICE}") + bindCarService(context) + sVehicleBinder = getBinderService("vehiclesetting") + when { + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI68 -> initKatman4Swi68(context) + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI165 -> initKatman4Swi68(context) // same SDK que SWI68 + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> initKatman4Swi69(context) // CarVehicleSettingClient, same path que SWI69 + FirmwareInfo.isNewGenVsm() -> initKatman4Swi69(context) // SWI69 + SWI131 + else -> initKatman4(context) + } + // Katman5 — detection IGNITION_STATE push (VehicleConditionManager ou ICarGeneralService) + // SWI132 uses the same observed ICarGeneralService path as SWI69/SWI131. + if (FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132) + initKatman5Swi69(context) + else + initKatman5(context) + if (sVehicleBinder != null) + AppLogger.i(TAG, " ✓ Katman2: vehiclesetting binder OK") + else + AppLogger.w(TAG, " ✗ Katman2: vehiclesetting null (SELinux — expected)") + + // Cranking auto du watcher door au boot si la feature est enabled (tous firmwares). + startDoorWatcherIfEnabled() + // Car connection established even if the feature is OFF → the Diagnostic probe can read the doors. + if (hasDoorVolumeFeature()) connectCarProperty() + + AppLogger.i(TAG, "========================================") + } + + // ------------------------------------------------------------------------- + // Katman1 — android.car.Car (async, mirrors original bindCarService exactly) + // ------------------------------------------------------------------------- + + private fun bindCarService(context: Context) { + if (sCarBindAttempted) return + sCarBindAttempted = true + val carClass: Class<*> + try { + carClass = Class.forName("android.car.Car") + AppLogger.i(TAG, " Katman1: android.car.Car class found ✓") + } catch (e: ClassNotFoundException) { + AppLogger.w(TAG, " Katman1: android.car.Car not found — not Automotive?") + return + } catch (e: Exception) { + AppLogger.e(TAG, " Katman1: forName error: ${e.message}") + return + } + + var car: Any? = null + + // Attempt 1: createCar(Context) + try { + car = carClass.getMethod("createCar", Context::class.java).invoke(null, context) + if (car != null) AppLogger.i(TAG, " Katman1: createCar(Context) → success") + } catch (_: Exception) {} + + // Attempt 2: createCar(Context, Handler) + if (car == null) { + try { + car = carClass.getMethod("createCar", Context::class.java, Handler::class.java) + .invoke(null, context, null) + if (car != null) AppLogger.i(TAG, " Katman1: createCar(Context, Handler) → success") + } catch (_: Exception) {} + } + + // Attempt 3: createCar(Context, ServiceConnection) — async, callback fires when connected + var scMethodFound: java.lang.reflect.Method? = null + try { + scMethodFound = carClass.getMethod("createCar", Context::class.java, ServiceConnection::class.java) + AppLogger.i(TAG, " Katman1: createCar(Context, ServiceConnection) method found") + } catch (_: Exception) {} + + if (car == null && scMethodFound != null) { + try { + val sc = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + AppLogger.i(TAG, " Katman1: ServiceConnection.onServiceConnected") + tryGetManagersFromCar(carClass) + } + override fun onServiceDisconnected(name: ComponentName?) { + AppLogger.w(TAG, " Katman1: Car service disconnected") + sCarPropertyManager = null + sCarHvacManager = null + clearCarPropertyMethods() + armCarServiceWatchdog(context, carClass) + } + } + car = scMethodFound.invoke(null, context, sc) + if (car != null) AppLogger.i(TAG, " Katman1: createCar(Context, SC) → callback pending") + } catch (e: Exception) { + AppLogger.w(TAG, " Katman1: createCar(Context, SC) error: ${e.message}") + } + } + + if (car == null) { + AppLogger.e(TAG, " Katman1: all createCar methods failed") + // Not necessarily fatal: on a cold head unit the Car service is simply not up + // yet. Giving up here would leave the whole process without a vehicle forever. + armCarServiceWatchdog(context, carClass) + return + } + + sCar = car + + // Call car.connect() if available (required on older builds) + try { + carClass.getMethod("connect").invoke(car) + AppLogger.i(TAG, " Katman1: car.connect() called") + } catch (_: NoSuchMethodException) { + // connect() not present on all builds, ignore + } catch (e: Exception) { + AppLogger.w(TAG, " Katman1: car.connect() error: ${e.message}") + } + + // Try sync managers immediately + tryGetManagersFromCar(carClass) + + // Schedule retries — extended delays to cover the slow boot of the SAIC Car service + val h = Handler(Looper.getMainLooper()) + h.postDelayed({ tryGetManagersFromCar(carClass) }, 2_000) + h.postDelayed({ tryGetManagersFromCar(carClass) }, 5_000) + h.postDelayed({ tryGetManagersFromCar(carClass) }, 10_000) + h.postDelayed({ tryGetManagersFromCar(carClass) }, 20_000) + h.postDelayed({ tryGetManagersFromCar(carClass) }, 40_000) + h.postDelayed({ tryGetManagersFromCar(carClass) }, 60_000) + armCarServiceWatchdog(context, carClass) + } + + /** + * Unbounded manager watchdog, at [CAR_WATCHDOG_INTERVAL_MS]. + * + * The fixed ladder above only covers the first minute. This covers everything after it: + * a Car service that comes up late, one that dies and comes back, and the case where + * `createCar` itself failed. Without it, a process started before the vehicle service + * is ready never sees a vehicle again for its whole lifetime — which is why consumers + * used to carry their own reconnect loops. The watchdog belongs next to the binding it + * repairs, not in each app. + */ + private fun armCarServiceWatchdog(context: Context, carClass: Class<*>) { + if (sCarWatchdogArmed) return + sCarWatchdogArmed = true + val appContext = context.applicationContext + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed(object : Runnable { + override fun run() { + if (sCarPropertyManager != null) { + sCarWatchdogArmed = false + return + } + if (sCar == null) { + // No Car object at all — retry the whole bind, not just the managers. + sCarWatchdogArmed = false + sCarBindAttempted = false + bindCarService(appContext) + return + } + tryGetManagersFromCar(carClass) + handler.postDelayed(this, CAR_WATCHDOG_INTERVAL_MS) + } + }, CAR_WATCHDOG_INTERVAL_MS) + } + + private fun tryGetManagersFromCar(carClass: Class<*>) { + val car = sCar ?: return + if (sCarPropertyManager != null && sCarHvacManager != null) return // already done + try { + val connected = try { + (carClass.getMethod("isConnected").invoke(car) as? Boolean) ?: true + } catch (_: Exception) { true } + + AppLogger.i(TAG, " Katman1: isConnected() → $connected") + if (!connected) { + AppLogger.w(TAG, " Katman1: car not yet connected") + return + } + + val getCarManager = carClass.getMethod("getCarManager", String::class.java) + + if (sCarPropertyManager == null) { + try { + val svc = carClass.getField("PROPERTY_SERVICE").get(null) as String + sCarPropertyManager = getCarManager.invoke(car, svc) + cacheCarPropertyMethods(sCarPropertyManager) + AppLogger.i(TAG, " Katman1: CarPropertyManager READY ✓") + } catch (e: Exception) { + AppLogger.w(TAG, " Katman1: CarPropertyManager unavailable: ${e.message}") + } + } + + if (sCarHvacManager == null) { + try { + val svc = carClass.getField("HVAC_SERVICE").get(null) as String + sCarHvacManager = getCarManager.invoke(car, svc) + AppLogger.i(TAG, " Katman1: CarHvacManager READY ✓") + } catch (e: Exception) { + AppLogger.w(TAG, " Katman1: CarHvacManager unavailable: ${e.message}") + } + } + + // Notify whenKatman1Ready subscribers as soon as both managers are ready + if (sCarPropertyManager != null && sCarHvacManager != null && katman1ReadyListeners.isNotEmpty()) { + val toNotify = katman1ReadyListeners.toList() + katman1ReadyListeners.clear() + Handler(Looper.getMainLooper()).post { toNotify.forEach { it() } } + } + + // Tentative d'registersment du callback IGNITION_STATE (best-effort) + if (sCarPropertyManager != null) registerIgnitionPropertyCallback() + } catch (e: Exception) { + AppLogger.e(TAG, " Katman1: tryGetManagersFromCar error: ${e.message}") + } + } + + // ------------------------------------------------------------------------- + // Katman2 — ServiceManager raw binder + // ------------------------------------------------------------------------- + + private fun getBinderService(serviceName: String): IBinder? { + return try { + val sm = Class.forName("android.os.ServiceManager") + val method = sm.getMethod("getService", String::class.java) + (method.invoke(null, serviceName) as? IBinder).also { + AppLogger.d(TAG, "getService($serviceName) → $it") + } + } catch (e: Exception) { + AppLogger.e(TAG, "getService($serviceName) error: ${e.message}") + null + } + } + + // ------------------------------------------------------------------------- + // Katman4 — VehiclePropertyManager via saicmotor.hmi.launcher context + // ------------------------------------------------------------------------- + + private fun initKatman4(context: Context) { + if (sVpm != null) return + val launcherCtx: Context + val vpmClass: Class<*> + try { + launcherCtx = context.createPackageContext( + LAUNCHER68_PKG, + android.content.Context.CONTEXT_INCLUDE_CODE or android.content.Context.CONTEXT_IGNORE_SECURITY + ) + vpmClass = launcherCtx.classLoader + .loadClass("com.saicmotor.sdk.vehiclesettings.manager.VehiclePropertyManager") + } catch (e: Exception) { + AppLogger.w(TAG, " Katman4: package/class error: ${e.message} — will retry") + Handler(Looper.getMainLooper()).postDelayed({ initKatman4(context.applicationContext) }, 5_000) + return + } + + var vpm: Any? = null + + // ---- Constructors (priority) ---- + // 1) ctor(launcherCtx) — most likely for a class bundled in the launcher APK + if (vpm == null) vpm = tryInvoke("ctor(launcherCtx)") { + vpmClass.getConstructor(Context::class.java).newInstance(launcherCtx) + } + // 2) ctor(appCtx) + if (vpm == null) vpm = tryInvoke("ctor(appCtx)") { + vpmClass.getConstructor(Context::class.java).newInstance(context) + } + // 3) ctor() no-arg + if (vpm == null) vpm = tryInvoke("ctor()") { + @Suppress("DEPRECATION") vpmClass.newInstance() + } + + // ---- Static factory methods ---- + if (vpm == null) vpm = tryInvoke("getInstance(launcherCtx)") { + vpmClass.getMethod("getInstance", Context::class.java).invoke(null, launcherCtx) + } + if (vpm == null) vpm = tryInvoke("getInstance(appCtx)") { + vpmClass.getMethod("getInstance", Context::class.java).invoke(null, context) + } + if (vpm == null) vpm = tryInvoke("getInstance()") { + vpmClass.getMethod("getInstance").invoke(null) + } + + if (vpm == null) { + AppLogger.w(TAG, " Katman4: all attempts failed — will retry") + Handler(Looper.getMainLooper()).postDelayed({ initKatman4(context.applicationContext) }, 10_000) + return + } + + sVpm = vpm + + // 1) bindService() — connecte au service vehicle (async) + tryInvoke("vpm.bindService()") { vpm!!.javaClass.getMethod("bindService").invoke(vpm) } + + // 2) init(Context, IVehicleServiceListener) via dynamic proxy — receives onServiceConnected + initWithServiceListener(vpm!!, context, launcherCtx) + + // 3) VehicleSettingManager pour SWI133 (ELK) — same singleton que SWI68 + tryInitVsm133(launcherCtx, context) + + // 3b) GeneralManager pour SWI133 (brightness screen) + tryInitGeneralManager(launcherCtx, context) + tryInitVehicleControlManager(launcherCtx, context) + + // 3c) SmartSoundManager pour SWI133 (loudness audio) + tryInitSmartSoundManager(launcherCtx, context) + + // 4) Retries to obtain mIVehiclePropertyService and VSM133 once the service is connected + val h = Handler(Looper.getMainLooper()) + listOf(2_000L, 5_000L, 10_000L, 15_000L, 20_000L, 30_000L, 45_000L, 60_000L).forEach { delay -> + h.postDelayed({ + if (sVpmService == null) tryGetVpmService(sVpm ?: return@postDelayed) + if (sVsm133 == null) tryInitVsm133(launcherCtx, context) + if (sGeneral == null) tryInitGeneralManager(launcherCtx, context) + if (sVcontrol == null) tryInitVehicleControlManager(launcherCtx, context) + if (sSmartSound == null) tryInitSmartSoundManager(launcherCtx, context) + if (doorVolumeEnabled() && sCarPropMgr == null) startDoorVolumeWatcher() + }, delay) + } + + tryGetVpmService(vpm!!) + AppLogger.i(TAG, " Katman4: VPM ready — mIVehiclePropertyService=${if (sVpmService != null) "OK ✓" else "null (en attente)"}") + } + + private fun initWithServiceListener(vpm: Any, context: Context, launcherCtx: Context) { + val vpmClass = vpm.javaClass + + // Log all methods for diagnostics + val methodSummary = vpmClass.methods.joinToString(", ") { m -> + "${m.name}(${m.parameterTypes.joinToString(",") { it.simpleName }})" + } + AppLogger.d(TAG, " Katman4: VPM methods = $methodSummary") + + // Strategy 1: Inspect the actual init() signature to get the real listener type + val initMethod2 = vpmClass.methods.firstOrNull { m -> + m.name == "init" && m.parameterCount == 2 && + Context::class.java.isAssignableFrom(m.parameterTypes[0]) + } + + if (initMethod2 != null) { + val listenerType = initMethod2.parameterTypes[1] + AppLogger.i(TAG, " Katman4: init() found, listener type = ${listenerType.name}") + + // Try dynamic proxy with the actual listener interface type + if (listenerType.isInterface) { + try { + val proxy = java.lang.reflect.Proxy.newProxyInstance( + listenerType.classLoader, arrayOf(listenerType) + ) { _, method, _ -> + when (method.name) { + "onServiceConnected" -> { + AppLogger.i(TAG, " Katman4: onServiceConnected ✓") + tryGetVpmService(vpm) + } + "onServiceDisconnected" -> { + AppLogger.w(TAG, " Katman4: onServiceDisconnected") + sVpmService = null + } + else -> {} + } + null + } + initMethod2.invoke(vpm, context, proxy) + AppLogger.i(TAG, " Katman4: init(Context, proxy) ✓") + return + } catch (e: Exception) { + AppLogger.d(TAG, " Katman4: init(Context, proxy) failed: ${e.message}") + } + } + + // Fallback: try init(Context, null) — works if listener is nullable + try { + initMethod2.invoke(vpm, context, null) + AppLogger.i(TAG, " Katman4: init(Context, null) ✓") + return + } catch (e: Exception) { + AppLogger.d(TAG, " Katman4: init(Context, null) failed: ${e.message}") + } + } + + // Strategy 2: init(Context) single-param + try { + vpmClass.getMethod("init", Context::class.java).invoke(vpm, context) + AppLogger.i(TAG, " Katman4: init(Context) ✓") + return + } catch (_: NoSuchMethodException) { + } catch (e: Exception) { + AppLogger.d(TAG, " Katman4: init(Context) failed: ${e.message}") + } + + // Strategy 3: init() no-arg + try { + vpmClass.getMethod("init").invoke(vpm) + AppLogger.i(TAG, " Katman4: init() ✓") + return + } catch (_: NoSuchMethodException) { + } catch (e: Exception) { + AppLogger.d(TAG, " Katman4: init() failed: ${e.message}") + } + + AppLogger.w(TAG, " Katman4: aucun init() fonctionnel — mIVehiclePropertyService restera null") + } + + /** Runs [block], returns the result or null, logs the result/error. */ + private fun tryInvoke(label: String, block: () -> Any?): Any? = try { + val r = block() + AppLogger.i(TAG, " Katman4: $label → ${if (r != null) "OK ($r)" else "null"}") + r + } catch (e: Exception) { + AppLogger.d(TAG, " Katman4: $label → ${e.javaClass.simpleName}: ${e.message}") + null + } + + private fun tryGetVpmService(vpm: Any) { + if (sVpmService != null) return + for (cls in generateSequence>(vpm.javaClass) { it.superclass }) { + try { + val f = cls.getDeclaredField("mIVehiclePropertyService") + f.isAccessible = true + val svc = f.get(vpm) + if (svc != null) { + sVpmService = svc + AppLogger.i(TAG, " Katman4: mIVehiclePropertyService READY ✓") + // Notify any pending Katman4 listeners + val toNotify = katman4ReadyListeners.toList() + katman4ReadyListeners.clear() + Handler(Looper.getMainLooper()).post { toNotify.forEach { it() } } + } + return + } catch (_: NoSuchFieldException) { continue } catch (_: Exception) { return } + } + } + + // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // SWI133 — VehicleSettingManager (ELK : getLaneKeepingAsstMode / setLaneKeepingAsstMode) + // Same singleton as SWI68 but initialised in the SWI133 path. + // ------------------------------------------------------------------------- + + private fun tryInitVsm133(launcherCtx: Context, appCtx: Context) { + if (sVsm133 != null) return + try { + val vsmClass = launcherCtx.classLoader.loadClass(VSM_CLASS) + + // Tentative 1 : lire le singleton already initialisesd par le launcher + val f = vsmClass.getDeclaredField("sVehicleSettingManager") + f.isAccessible = true + val singleton = f.get(null) + if (singleton != null) { + sVsm133 = singleton + AppLogger.i(TAG, " SWI133: VehicleSettingManager singleton ✓") + return + } + + // Tentative 2 : appeler init() nous-same (comme SWI68) + val initMethod = vsmClass.methods.firstOrNull { m -> + m.name == "init" && m.parameterCount == 2 && + Context::class.java.isAssignableFrom(m.parameterTypes[0]) + } ?: run { + AppLogger.w(TAG, " SWI133: VSM init() not found, singleton will be null") + return + } + val listenerType = initMethod.parameterTypes[1] + val listener = if (listenerType.isInterface) { + java.lang.reflect.Proxy.newProxyInstance(listenerType.classLoader, arrayOf(listenerType)) { _, method, _ -> + if (method.name == "onServiceConnected") { + AppLogger.i(TAG, " SWI133: VSM onServiceConnected ✓") + try { + val f2 = vsmClass.getDeclaredField("sVehicleSettingManager") + f2.isAccessible = true + sVsm133 = f2.get(null) + AppLogger.i(TAG, " SWI133: sVsm133 = ${if (sVsm133 != null) "OK ✓" else "null"}") + } catch (_: Exception) {} + } + null + } + } else null + initMethod.invoke(null, appCtx, listener) + AppLogger.i(TAG, " SWI133: VehicleSettingManager.init() called") + } catch (e: Exception) { + AppLogger.d(TAG, " SWI133: tryInitVsm133 exc: ${e.message}") + } + } + + // ------------------------------------------------------------------------- + // Brightness screen — GeneralManager (ancien SDK SWI133/68/165) + // GeneralManager.init(Context, ISettingsServiceListener) — singleton sInstance. + // Same pattern as tryInitVsm133; loaded from the launcher com.saicmotor.hmi.launcher. + // ------------------------------------------------------------------------- + + /** + * VehicleControlManager — where ESC lives on SWI68 and SWI165. + * + * Same shape as [tryInitGeneralManager]: a static singleton, or `init(Context, listener)` + * when it has not been built yet. Calling `init` ourselves is not optional — the class + * comes from the launcher's classloader, but statics live per process, so the singleton + * the launcher already built is not ours to see. + */ + private fun tryInitVehicleControlManager(launcherCtx: Context, appCtx: Context) { + if (sVcontrol != null) return + try { + val cls = launcherCtx.classLoader.loadClass(VCONTROL_CLASS) + val f = cls.getDeclaredField("sVehicleControlManager") + f.isAccessible = true + f.get(null)?.let { + sVcontrol = it + AppLogger.i(TAG, " VehicleControlManager singleton \u2713") + return + } + val initMethod = cls.methods.firstOrNull { m -> + m.name == "init" && m.parameterCount == 2 && + Context::class.java.isAssignableFrom(m.parameterTypes[0]) + } ?: run { + AppLogger.w(TAG, " VehicleControlManager init() not found") + return + } + val listenerType = initMethod.parameterTypes[1] + val listener = if (listenerType.isInterface) { + java.lang.reflect.Proxy.newProxyInstance(listenerType.classLoader, arrayOf(listenerType)) { _, method, _ -> + if (method.name == "onServiceConnected") { + try { + f.get(null)?.let { sVcontrol = it } + AppLogger.i(TAG, " VehicleControlManager onServiceConnected \u2014 sVcontrol=${if (sVcontrol != null) "OK" else "null"}") + } catch (_: Exception) {} + } + null + } + } else null + initMethod.invoke(null, appCtx, listener) + f.get(null)?.let { sVcontrol = it } + AppLogger.i(TAG, " VehicleControlManager.init() called \u2014 sVcontrol=${if (sVcontrol != null) "OK" else "null"}") + } catch (e: Exception) { + AppLogger.d(TAG, " tryInitVehicleControlManager exc: ${e.message}") + } + } + + private fun callVcontrol(methodName: String, vararg args: Any?): Any? { + val manager = sVcontrol ?: return null + return try { + val types = args.map { if (it is Int) Int::class.javaPrimitiveType!! else it!!.javaClass }.toTypedArray() + manager.javaClass.getMethod(methodName, *types).invoke(manager, *args) + } catch (e: Exception) { + AppLogger.w(TAG, " VCTRL: $methodName() exc: ${e.message}") + null + } + } + + /** A write like any other, so it takes the standstill gate. */ + private fun callVcontrolVoid(methodName: String, vararg args: Any?): Boolean { + if (!VehicleWriteGate.allow("VCTRL $methodName")) return false + val manager = sVcontrol ?: run { + AppLogger.w(TAG, " VCTRL: $methodName() \u2014 manager not bound") + return false + } + return try { + val types = args.map { if (it is Int) Int::class.javaPrimitiveType!! else it!!.javaClass }.toTypedArray() + manager.javaClass.getMethod(methodName, *types).invoke(manager, *args) + true + } catch (e: Exception) { + AppLogger.w(TAG, " VCTRL: $methodName() exc: ${e.message}") + false + } + } + + private fun tryInitGeneralManager(launcherCtx: Context, appCtx: Context) { + if (sGeneral != null) return + try { + val cls = launcherCtx.classLoader.loadClass(GENERAL_MANAGER_CLASS) + + // Tentative 1 : singleton already initialisesd + val f = cls.getDeclaredField("sInstance") + f.isAccessible = true + f.get(null)?.let { + sGeneral = it + AppLogger.i(TAG, " GeneralManager singleton ✓") + return + } + + // Tentative 2 : init(Context, ISettingsServiceListener) — proxy dynamique + val initMethod = cls.methods.firstOrNull { m -> + m.name == "init" && m.parameterCount == 2 && + Context::class.java.isAssignableFrom(m.parameterTypes[0]) + } ?: run { + AppLogger.w(TAG, " GeneralManager init() not found") + return + } + val listenerType = initMethod.parameterTypes[1] + val listener = if (listenerType.isInterface) { + java.lang.reflect.Proxy.newProxyInstance(listenerType.classLoader, arrayOf(listenerType)) { _, method, _ -> + if (method.name == "onServiceConnected") { + try { + f.get(null)?.let { sGeneral = it } + AppLogger.i(TAG, " GeneralManager onServiceConnected — sGeneral=${if (sGeneral != null) "OK ✓" else "null"}") + } catch (_: Exception) {} + } + null + } + } else null + initMethod.invoke(null, appCtx, listener) + // init() creates sInstance immediately (service connects asynchronously afterwards) + f.get(null)?.let { sGeneral = it } + AppLogger.i(TAG, " GeneralManager.init() called — sGeneral=${if (sGeneral != null) "OK ✓" else "null"}") + } catch (e: Exception) { + AppLogger.d(TAG, " tryInitGeneralManager exc: ${e.message}") + } + } + + // ------------------------------------------------------------------------- + // Loudness — SmartSoundManager (ancien SDK SWI133/68/165) + // Same SDK/pattern as GeneralManager : singleton sInstance + init(Context, ISettingsServiceListener). + // ------------------------------------------------------------------------- + + private fun tryInitSmartSoundManager(launcherCtx: Context, appCtx: Context) { + if (sSmartSound != null) return + try { + val cls = launcherCtx.classLoader.loadClass(SMART_SOUND_MANAGER_CLASS) + + // Tentative 1 : singleton already initialisesd + val f = cls.getDeclaredField("sInstance") + f.isAccessible = true + f.get(null)?.let { + sSmartSound = it + AppLogger.i(TAG, " SmartSoundManager singleton ✓") + return + } + + // Tentative 2 : init(Context, ISettingsServiceListener) — proxy dynamique + val initMethod = cls.methods.firstOrNull { m -> + m.name == "init" && m.parameterCount == 2 && + Context::class.java.isAssignableFrom(m.parameterTypes[0]) + } ?: run { + AppLogger.w(TAG, " SmartSoundManager init() not found") + return + } + val listenerType = initMethod.parameterTypes[1] + val listener = if (listenerType.isInterface) { + java.lang.reflect.Proxy.newProxyInstance(listenerType.classLoader, arrayOf(listenerType)) { _, method, _ -> + if (method.name == "onServiceConnected") { + try { + f.get(null)?.let { sSmartSound = it } + AppLogger.i(TAG, " SmartSoundManager onServiceConnected — sSmartSound=${if (sSmartSound != null) "OK ✓" else "null"}") + } catch (_: Exception) {} + } + null + } + } else null + initMethod.invoke(null, appCtx, listener) + f.get(null)?.let { sSmartSound = it } + AppLogger.i(TAG, " SmartSoundManager.init() called — sSmartSound=${if (sSmartSound != null) "OK ✓" else "null"}") + } catch (e: Exception) { + AppLogger.d(TAG, " tryInitSmartSoundManager exc: ${e.message}") + } + } + + /** A9 (SWI132/131/69) : brightness via CarGeneralClient.setScreenBrightness(mode,day,night). */ + private fun isA9Brightness(): Boolean = + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 + + /** Brightness screen disponible : ancien SDK (133/68/165) ou A9 (132/131/69). */ + fun hasBrightnessControl(): Boolean = FirmwareInfo.getGeneration() != FirmwareInfo.Gen.UNKNOWN + + /** Lit la brightness screen en % (0–100), ou -1 si unavailable. */ + fun getScreenBrightnessPercent(): Int = + if (isA9Brightness()) getBrightnessA9() else getBrightnessOldSdk() + + /** + * Sets screen brightness in % (0–100). Safety floor at BRIGHTNESS_MIN_PERCENT + * pour ne jamais turn off l'screen. + */ + fun setScreenBrightnessPercent(pct: Int): Boolean { + val clamped = pct.coerceIn(BRIGHTNESS_MIN_PERCENT, 100) + return if (isA9Brightness()) setBrightnessA9(clamped) else setBrightnessOldSdk(clamped) + } + + // ── Ancien SDK (SWI133/68/165) — GeneralManager.setBrightness(Int), plage native 0..255 ── + + private fun getBrightnessOldSdk(): Int { + val g = sGeneral ?: return -1 + 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) + AppLogger.d(TAG, " getBrightness native=$native → $pct%") + pct + } catch (e: Exception) { + AppLogger.w(TAG, " getBrightness exc: ${e.message}") + -1 + } + } + + private fun setBrightnessOldSdk(clampedPct: Int): Boolean { + val g = sGeneral ?: return false + val native = (clampedPct * BRIGHTNESS_NATIVE_MAX / 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) + true + } catch (e: Exception) { + AppLogger.w(TAG, " setBrightness exc: ${e.message}") + false + } + } + + // ── A9 (SWI132/131/69) — brightness via Settings.System.SCREEN_BRIGHTNESS ── + // L'app Settings A9 (GeneralModel.setBrightness) pilote realment la dalle par + // Settings.System.putInt("screen_brightness", 0..255) + passage en mode manuel. + // CarGeneralClient.setScreenBrightness(mode,day,night) ne stocke que le jour/nuit + // et n'a AUCUN effet sur la dalle (confirmed par les logs SWI132 : value lue=0, + // no visual change). Being uid.system, the app can write Settings.System. + private const val A9_BRIGHTNESS_NATIVE_MAX = 255 + + private fun getBrightnessA9(): Int { + val resolver = sAppContext?.contentResolver ?: return -1 + val native = try { + android.provider.Settings.System.getInt(resolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) + } catch (e: Exception) { + 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) + } + + 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) + return try { + // Manual mode, otherwise auto-brightness overwrites the value at once + android.provider.Settings.System.putInt(resolver, + android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE, + android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL) + android.provider.Settings.System.putInt(resolver, + android.provider.Settings.System.SCREEN_BRIGHTNESS, native) + if (logEnabled) AppLogger.i(TAG, "A9 brightness → Settings.System.SCREEN_BRIGHTNESS=$native ($clampedPct%)") + true + } catch (e: Exception) { + AppLogger.w(TAG, " A9 setBrightness Settings.System exc: ${e.message}") + false + } + } + + /** + * Appelle une method sur sVsm133 par reflection. + * Returns la value (Int pour getters, null pour setters void) ou null si erreur. + */ + private fun callVsm133(methodName: String, vararg args: Any?): Any? { + val vsm = sVsm133 ?: return null + return try { + val types = args.map { if (it is Int) Int::class.javaPrimitiveType!! else it!!.javaClass }.toTypedArray() + vsm.javaClass.getMethod(methodName, *types).invoke(vsm, *args) + } catch (e: Exception) { + AppLogger.w(TAG, " SWI133/VSM: $methodName() exc: ${e.message}") + null + } + } + + // Katman4 SWI68 — VehicleSettingManager via saicmotor.hmi.launcher context + // ------------------------------------------------------------------------- + + private fun initKatman4Swi68(context: Context) { + if (sVsm != null) return + val launcherCtx: Context + val vsmClass: Class<*> + try { + launcherCtx = context.createPackageContext( + LAUNCHER68_PKG, + android.content.Context.CONTEXT_INCLUDE_CODE or android.content.Context.CONTEXT_IGNORE_SECURITY + ) + vsmClass = launcherCtx.classLoader.loadClass(VSM_CLASS) + AppLogger.i(TAG, " SWI68: VehicleSettingManager class found ✓") + } catch (e: Exception) { + AppLogger.w(TAG, " SWI68: class load error: ${e.message} — retry in 5s") + Handler(Looper.getMainLooper()).postDelayed({ initKatman4Swi68(context.applicationContext) }, 5_000) + return + } + + // Appel static : VehicleSettingManager.init(Context, IVehicleServiceListener) + val initMethod = vsmClass.methods.firstOrNull { m -> + m.name == "init" && m.parameterCount == 2 && + Context::class.java.isAssignableFrom(m.parameterTypes[0]) + } + + if (initMethod != null) { + val listenerType = initMethod.parameterTypes[1] + val listenerArg: Any? = if (listenerType.isInterface) { + try { + java.lang.reflect.Proxy.newProxyInstance( + listenerType.classLoader, arrayOf(listenerType) + ) { _, method, _ -> + if (method.name == "onServiceConnected") { + AppLogger.i(TAG, " SWI68: VehicleSettingManager onServiceConnected ✓") + sVsm?.let { tryGetVsmService(it, vsmClass) } + } + null + } + } catch (e: Exception) { null.also { AppLogger.d(TAG, " SWI68: proxy error: ${e.message}") } } + } else null + + try { + initMethod.invoke(null, context, listenerArg) + AppLogger.i(TAG, " SWI68: VehicleSettingManager.init() called") + } catch (e: Exception) { + AppLogger.w(TAG, " SWI68: init() error: ${e.message}") + } + } else { + AppLogger.w(TAG, " SWI68: init(Context, listener) not found") + } + + // Fetches the singleton from the static field sVehicleSettingManager + try { + val f = vsmClass.getDeclaredField("sVehicleSettingManager") + f.isAccessible = true + sVsm = f.get(null) + AppLogger.i(TAG, " SWI68: sVehicleSettingManager = ${if (sVsm != null) "OK ✓" else "null"}") + } catch (e: Exception) { + AppLogger.w(TAG, " SWI68: sVehicleSettingManager field error: ${e.message}") + } + + sVsm?.let { tryGetVsmService(it, vsmClass) } + + // GeneralManager pour SWI68/SWI165 (brightness screen) — same launcher context + tryInitGeneralManager(launcherCtx, context) + tryInitVehicleControlManager(launcherCtx, context) + + // SmartSoundManager pour SWI68/SWI165 (loudness audio) — same launcher context + tryInitSmartSoundManager(launcherCtx, context) + + // Retries to obtain mVehicleSettingService and the singleton if not ready yet + val h = Handler(Looper.getMainLooper()) + listOf(1_000L, 3_000L, 5_000L, 10_000L, 15_000L, 20_000L, 30_000L).forEach { delay -> + h.postDelayed({ + if (sVsm == null) { + try { + val f = vsmClass.getDeclaredField("sVehicleSettingManager") + f.isAccessible = true + sVsm = f.get(null) + if (sVsm != null) AppLogger.i(TAG, " SWI68: singleton obtained @${delay}ms") + } catch (_: Exception) {} + } + sVsm?.let { if (sVsmService == null) tryGetVsmService(it, vsmClass) } + if (sGeneral == null) tryInitGeneralManager(launcherCtx, context) + if (sVcontrol == null) tryInitVehicleControlManager(launcherCtx, context) + if (sSmartSound == null) tryInitSmartSoundManager(launcherCtx, context) + }, delay) + } + } + + private fun tryGetVsmService(vsm: Any, vsmClass: Class<*>? = null) { + if (sVsmService != null) return + val cls = vsmClass ?: vsm.javaClass + for (c in generateSequence>(cls) { it.superclass }) { + try { + val f = c.getDeclaredField("mVehicleSettingService") + f.isAccessible = true + val svc = f.get(vsm) + if (svc != null) { + sVsmService = svc + AppLogger.i(TAG, " SWI68: mVehicleSettingService READY ✓") + val toNotify = katman4ReadyListeners.toList() + katman4ReadyListeners.clear() + Handler(Looper.getMainLooper()).post { toNotify.forEach { it() } } + } + return + } catch (_: NoSuchFieldException) { continue } catch (_: Exception) { return } + } + } + + // ------------------------------------------------------------------------- + // Katman4 SWI69/SWI131 — CarVehicleSettingClient via CarAdapterClient + // + // Runtime integration contract: + // CarAdapterClient.getInstance(ctx).start() + // → bindService(com.saicmotor.caradapter / CarAdapterService) + // → onResult(0=OK) : queryClient(0x8) → IBinder (ICarVehicleSettingService) + // → new CarVehicleSettingClient(ibinder) + // + // CarVehicleSettingClient expose exactement les same methods que VehicleSettingManager + // (getAccTjaState, setAccTjaState, getLasWarningSound, getFcwState, etc.) + // ------------------------------------------------------------------------- + + private fun initKatman4Swi69(context: Context) { + if (sVsm != null) return + + val launcherCtx: Context + val adapterClass: Class<*> + val clientClass: Class<*> + try { + launcherCtx = context.createPackageContext( + LAUNCHER69_PKG, + android.content.Context.CONTEXT_INCLUDE_CODE or android.content.Context.CONTEXT_IGNORE_SECURITY + ) + adapterClass = launcherCtx.classLoader.loadClass(CAR_ADAPTER_CLASS) + clientClass = launcherCtx.classLoader.loadClass(VSM69_CLIENT_CLASS) + AppLogger.i(TAG, " SWI69: CarAdapterClient + CarVehicleSettingClient classes found ✓") + } catch (e: Exception) { + AppLogger.w(TAG, " SWI69: class load error: ${e.message} — retry in 5s") + Handler(Looper.getMainLooper()).postDelayed({ initKatman4Swi69(context.applicationContext) }, 5_000) + return + } + + // Obtenir le singleton CarAdapterClient + val adapter = tryInvoke("SWI69 CarAdapterClient.getInstance(appCtx)") { + adapterClass.getMethod("getInstance", Context::class.java).invoke(null, context.applicationContext) + } ?: tryInvoke("SWI69 CarAdapterClient.getInstance(launcherCtx)") { + adapterClass.getMethod("getInstance", Context::class.java).invoke(null, launcherCtx) + } + + if (adapter == null) { + AppLogger.w(TAG, " SWI69: CarAdapterClient.getInstance() failed — retry in 10s") + Handler(Looper.getMainLooper()).postDelayed({ initKatman4Swi69(context.applicationContext) }, 10_000) + return + } + + sCarAdapter = adapter + sCarAdapterClass = adapterClass + + // Enregistrer le ServiceConnListener (onResult(0) = connected) + val listenerType = adapterClass.declaredClasses + .firstOrNull { it.simpleName == "ServiceConnListener" } + if (listenerType != null && listenerType.isInterface) { + try { + val proxy = java.lang.reflect.Proxy.newProxyInstance( + listenerType.classLoader, arrayOf(listenerType) + ) { _, method, args -> + if (method.name == "onResult") { + val code = (args?.getOrNull(0) as? Int) ?: -1 + AppLogger.i(TAG, " SWI69: CarAdapterClient.onResult($code)") + if (code == 0) tryInitClientFromAdapter(adapter, adapterClass, clientClass) + } + null + } + adapterClass.getMethod("setConnListener", listenerType).invoke(adapter, proxy) + AppLogger.i(TAG, " SWI69: ServiceConnListener registered ✓") + } catch (e: Exception) { + AppLogger.w(TAG, " SWI69: setConnListener error: ${e.message}") + } + } + + // Start the connection to CarAdapterService + tryInvoke("SWI69 adapter.start()") { + adapterClass.getMethod("start").invoke(adapter) + } + + // Immediate attempt if CarAdapterService was already connected + tryInitClientFromAdapter(adapter, adapterClass, clientClass) + + // Staggered retries + val h = Handler(Looper.getMainLooper()) + listOf(1_000L, 3_000L, 5_000L, 10_000L, 15_000L, 20_000L, 30_000L, 60_000L).forEach { delay -> + h.postDelayed({ + if (sVsm == null) tryInitClientFromAdapter(adapter, adapterClass, clientClass) + }, delay) + } + } + + /** + * Tente d'obtenir un CarVehicleSettingClient via queryClient(0x8). + * Called on connection (onResult=0) and on retries. + */ + private fun tryInitClientFromAdapter(adapter: Any, adapterClass: Class<*>, clientClass: Class<*>) { + if (sVsm != null) return + try { + val ibinder = adapterClass + .getMethod("queryClient", Int::class.javaPrimitiveType!!) + .invoke(adapter, VSM_SERVICE_CODE) as? IBinder + + if (ibinder == null) { + AppLogger.d(TAG, " SWI69: queryClient(0x${VSM_SERVICE_CODE.toString(16)}) → null (pas encore connected)") + return + } + + val client = clientClass + .getConstructor(IBinder::class.java) + .newInstance(ibinder) + + sVsm = client + sVsmService = ibinder + AppLogger.i(TAG, " SWI69: CarVehicleSettingClient READY ✓") + + val toNotify = katman4ReadyListeners.toList() + katman4ReadyListeners.clear() + Handler(Looper.getMainLooper()).post { toNotify.forEach { it() } } + } catch (e: Exception) { + AppLogger.d(TAG, " SWI69: tryInitClientFromAdapter error: ${e.message}") + } + } + + /** + * Whether a carapi adapter client exists at all on this firmware. + * + * [a9ClientBinder] returns null for two entirely different reasons — the code is not served, + * or there is no adapter to ask — and a survey that cannot tell them apart records "absent" + * for a question that was never put to the vehicle. `sCarAdapter` is assigned only by + * `initKatman4Swi69`, so on SWI68 every `queryClient` in a probe fails before it reaches a + * binder. RI-002 was nearly closed on that false negative; the probe now says so out loud. + */ + internal fun hasCarAdapterClient(): Boolean = sCarAdapter != null && sCarAdapterClass != null + + /** + * A carapi client binder by service code, on the A9 platform. + * + * `queryClient` is how every A9 client is obtained — the vehicle settings one (0x8) is + * built above, and climate (0x7) uses the same door. Null until the adapter has connected, + * which is a state, not a failure: the caller retries on its next call. + */ + internal fun a9ClientBinder(serviceCode: Int): IBinder? { + val adapter = sCarAdapter ?: return null + val adapterClass = sCarAdapterClass ?: return null + return try { + adapterClass.getMethod("queryClient", Int::class.javaPrimitiveType!!) + .invoke(adapter, serviceCode) as? IBinder + } catch (e: Exception) { + AppLogger.d(TAG, " A9: queryClient(0x${serviceCode.toString(16)}): ${e.message}") + null + } + } + + /** + * The loader that can see the vendor classes. + * + * They live in the launcher's APK, not on the boot classpath, so only a class already + * pulled from there can name their siblings. + */ + internal fun a9ClassLoader(): ClassLoader? = + sVsm?.javaClass?.classLoader ?: sCarAdapterClass?.classLoader + + /** + * The VSM singleton, for the primitives that live outside this file. + * + * [VsmGlass] is one of them: the glass belongs with the glass, not in the middle of the + * ADAS calls, and it needs the same instance [callVsm] uses rather than a second bind. + */ + internal fun vsmInstance(): Any? = sVsm + + /** Whether the AAOS `Car` object is bound, so a missing manager can be told from a missing car. */ + internal fun isCarBound(): Boolean = sCar != null + + /** + * Any AAOS car manager, named by the `Car` constant that selects its service. + * + * For the `RI-` survey rather than for a feature. `DIAGNOSTIC_SERVICE` is the only + * OBD-shaped surface the platform defines, and it is gated behind a privileged permission — + * so "the constant is not on this platform", "the car is not bound" and "we were refused" + * are three different answers, and a probe that returned a bare null would merge them. + */ + internal fun carManagerNamed(serviceField: String): Any? { + val car = sCar ?: return null + return try { + val service = car.javaClass.getField(serviceField).get(null) as String + car.javaClass.getMethod("getCarManager", String::class.java).invoke(car, service) + } catch (e: Exception) { + AppLogger.d(TAG, " carManagerNamed($serviceField): ${e.message}") + null + } + } + + private fun callVsm(methodName: String, vararg args: Any?): Any? { + val vsm = sVsm ?: return null + return try { + val types = args.map { if (it is Int) Int::class.javaPrimitiveType!! else it!!.javaClass }.toTypedArray() + vsm.javaClass.getMethod(methodName, *types).invoke(vsm, *args) + } catch (e: Exception) { + AppLogger.w(TAG, " SWI68: $methodName() exc: ${e.message}") + null + } + } + + /** + * Appelle une method void sur sVsm par reflection. + * Unlike callVsm(), returns true if the method exists and runs without exception, + * same si invoke() returns null (behaviour normal pour les methods void). + * Returns false if sVsm is null or an exception is thrown (method not found, etc.). + */ + private fun callVsmVoid(methodName: String, vararg args: Any?): Boolean { + // [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable. + if (!VehicleWriteGate.allow("VSM $methodName")) return false + val vsm = sVsm ?: return false + return try { + val types = args.map { if (it is Int) Int::class.javaPrimitiveType!! else it!!.javaClass }.toTypedArray() + vsm.javaClass.getMethod(methodName, *types).invoke(vsm, *args) + true // method found and called without exception → success + } catch (e: Exception) { + AppLogger.w(TAG, " VSM: $methodName() exc: ${e.message}") + false + } + } + + private fun getIntPropertyVpm(propId: Int): Int { + val vpm = sVpm ?: return -1 + return try { + (vpm.javaClass.getMethod("getIntProperty", Int::class.java) + .invoke(vpm, propId) as? Int) ?: -1 + } catch (_: Exception) { -1 } + } + + private fun setIntPropertyVpm(propId: Int, value: Int): Boolean { + // [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable. + if (!VehicleWriteGate.allow("VPM 0x${Integer.toHexString(propId)}")) return false + val vpm = sVpm ?: return false + return try { + vpm.javaClass.getMethod("setIntProperty", Int::class.java, Int::class.java) + .invoke(vpm, propId, value) + true + } catch (_: Exception) { false } + } + + /** Variant with recovery — used by vehiclesettings for the FCW/AEB properties. */ + private fun setIntPropertyVpmRecovery(propId: Int, value: Int): Boolean { + // [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable. + if (!VehicleWriteGate.allow("VPM-recovery 0x${Integer.toHexString(propId)}")) return false + val vpm = sVpm ?: return false + return try { + vpm.javaClass.getMethod("setIntPropertyRecovery", Int::class.java, Int::class.java) + .invoke(vpm, propId, value) + if (logEnabled) AppLogger.i(TAG, " VPM setIntRecovery 0x${propId.toString(16)} value=$value ✓") + true + } catch (e: Exception) { + // Fallback sur setIntProperty si setIntPropertyRecovery absent + AppLogger.d(TAG, " VPM setIntRecovery fallback for 0x${propId.toString(16)}: ${e.message}") + setIntPropertyVpm(propId, value) + } + } + + // ── Alimentation vehicle (mise hors tension, infodivertissement maintenu) ─ + // Reproduit le bouton "Vehicle Power → Off" du launcher MG (onglet Safety). + // Value 2 on all 6 firmwares; only the access path differs : + // • SWI133 : VehiclePropertyManager.setIntPropertyRecovery(0x6030021, 2) + // • SWI68/SWI165 : VehicleSettingManager.setPowerModeSwitch(2) + // • A9 (132/131/69): CarAdapterClient.queryClient(0xf) → CarComfortabletClient.setPowerModeSwitch(2) + // Le vehicle n'runs la coupure qu'when stopped en position P (garde side firmware). + private const val PROP_POWER_MODE_SWITCH = 0x6030021 // ID_POWER_MODE_SWITCH (SWI133) + private const val POWER_MODE_OFF = 2 + private const val COMFORTABLET_CLIENT_CLASS = "com.saicmotor.carapi.client.CarComfortabletClient" + private const val CAR_ADAPTER_CLIENT_CLASS = "com.saicmotor.carapi.CarAdapterClient" + private const val COMFORTABLET_SERVICE_CODE = 0xf + + /** + * Available on all 6 firmwares: each implements a "P position" check (guard for + * safety — le firmware ne garde PAS la commande, donc sans check P une extinction en roulant + * serait possible). Value P = 1 partout (cf. isVehicleInPark) : + * • SWI133 : gear VPM 0x5030043 ✓ + * • A9 (132/131/69) : CarStateClient.getGearState() ✓ (confirmed SWI132) + * • SWI68/165 : VehicleConditionManager.getCarGear() poll ✓ (confirmed SWI68, same SDK 165) + */ + fun hasVehiclePowerOff(): Boolean = + FirmwareInfo.getGeneration() != FirmwareInfo.Gen.UNKNOWN + + /** Coupe l'alimentation du vehicle tout en gardant l'screen/infodivertissement actif. */ + fun vehiclePowerOff(): Boolean { + // Safety guard: never power off outside P (re-verified on send). + if (isVehicleInPark() != true) { + AppLogger.w(TAG, "vehiclePowerOff REFUSED — gear not confirmed in P") + return false + } + val gen = FirmwareInfo.getGeneration() + AppLogger.i(TAG, "vehiclePowerOff (gen=$gen)") + return when { + gen == FirmwareInfo.Gen.SWI133 -> + setIntPropertyVpmRecovery(PROP_POWER_MODE_SWITCH, POWER_MODE_OFF) + gen == FirmwareInfo.Gen.SWI68 || gen == FirmwareInfo.Gen.SWI165 -> + // sVsm = VehicleSettingManager (vieux SDK) + callVsmVoid("setPowerModeSwitch", POWER_MODE_OFF) + FirmwareInfo.isNewGenVsm() || gen == FirmwareInfo.Gen.SWI132 -> + vehiclePowerOffA9() + else -> false + } + } + + /** A9 (SWI132/131/69) : obtient CarComfortabletClient via l'adaptateur carapi et coupe l'alim. */ + private fun vehiclePowerOffA9(): Boolean { + val cl = sVsm?.javaClass?.classLoader ?: run { + AppLogger.w(TAG, " A9 power-off: sVsm/classloader null"); return false + } + return try { + val adapterClass = cl.loadClass(CAR_ADAPTER_CLIENT_CLASS) + val adapter = adapterClass.getMethod("getInstance", Context::class.java).invoke(null, sAppContext) + val binder = adapterClass.getMethod("queryClient", Int::class.javaPrimitiveType) + .invoke(adapter, COMFORTABLET_SERVICE_CODE) as? android.os.IBinder + ?: run { AppLogger.w(TAG, " A9 power-off: queryClient(0xf) null"); return false } + val comfortClass = cl.loadClass(COMFORTABLET_CLIENT_CLASS) + val comfort = comfortClass.getConstructor(android.os.IBinder::class.java).newInstance(binder) + comfortClass.getMethod("setPowerModeSwitch", Int::class.javaPrimitiveType).invoke(comfort, POWER_MODE_OFF) + AppLogger.i(TAG, " A9 power-off: CarComfortabletClient.setPowerModeSwitch($POWER_MODE_OFF) ✓") + true + } catch (e: Exception) { + AppLogger.w(TAG, " A9 power-off error: ${e.javaClass.simpleName}: ${e.message}") + false + } + } + + // ── Garde de safety : levier en position P ? ─────────────────────────── + // L'OEM n'autorise le power-off qu'en P (gear == 1), mais NE garde PAS la commande + // side firmware → c'est l'app qui doit verify (sinon extinction possible en roulant). + // Supported gear paths by firmware: + // • SWI133 : VPM getIntProperty(0x5030043) ; PARK = 1 (CAR_GEAR_PARK_RANGE) + // • SWI68/SWI165 : VehicleConditionBean.getCarGear() (signal condition vehicle) + // • A9 (132/131/69): CarStateClient.getGearState() + // Only SWI133 is implemented AND checkable here; elsewhere returns null → power-off blocked. + private const val PROP_GEAR_STS = 0x5030043 // SENSOR_TYPE_GEAR_STS (VPM, SWI133) + private const val GEAR_PARK_VALUE = 1 + + /** + * Gear in P? true/false if determinable, null if unknown (→ block power-off). + * Value P = 1 on all studied firmwares (SWI133 0x5030043, A9 getGearState confirmed, + * SWI68/165 getCarGear = CarGearValue.PARK). + * + * When the generation-specific path answers nothing, the vendor `vehiclecondition` + * service on the hub is asked directly. Same signal, same P = 1, different route: the + * primary path depends on an in-process manager object that may never have arrived, + * while the service behind it is bound and answering. A null here blocks power-off and + * costs the write gate its standstill fallback, so a second source is worth having. + */ + /** Keeps the repeated gear and ignition states out of the bounded diagnostic log. */ + private val sGearLog = StateChangeLog() + private val sIgnitionLog = StateChangeLog() + + fun isVehicleInPark(): Boolean? { + val gen = FirmwareInfo.getGeneration() + val gear = when { + gen == FirmwareInfo.Gen.SWI133 -> + if (sVpm == null) Int.MIN_VALUE else getIntPropertyVpm(PROP_GEAR_STS) + FirmwareInfo.isNewGenVsm() || gen == FirmwareInfo.Gen.SWI132 -> + readA9GearState() // CarStateClient.getGearState() + gen == FirmwareInfo.Gen.SWI68 || gen == FirmwareInfo.Gen.SWI165 -> + readVcmCarGear() // VehicleConditionManager.getCarGear() + else -> Int.MIN_VALUE + }.takeIf { it >= 0 } + ?: com.evsuite.hardware.saic.SaicVehicleCondition.gearOrNull() + ?: return null + // Read once a second by the telemetry sampler; only gear changes are worth a line. + if (sGearLog.accept(gear)) { + AppLogger.i(TAG, "isVehicleInPark — gen=$gen gear=$gear (P=$GEAR_PARK_VALUE)") + } + return gear == GEAR_PARK_VALUE + } + + // ── Read gear SWI68/165 (poll direct) + A9 (CarStateClient) ─────────── + private const val CAR_STATE_CLIENT_CLASS = "com.saicmotor.carapi.client.CarStateClient" + private const val CAR_STATE_SERVICE_CODE = 0xb + @Volatile private var sCarState: Any? = null // A9 : CarStateClient (lazy) + + /** SWI68/165 : VehicleConditionManager.getCarGear() (poll synchrone via sVcm). */ + private fun readVcmCarGear(): Int { + val vcm = sVcm ?: return Int.MIN_VALUE + return try { + (vcm.javaClass.getMethod("getCarGear").invoke(vcm) as? Int) ?: Int.MIN_VALUE + } catch (e: Exception) { + AppLogger.w(TAG, " getCarGear err: ${e.javaClass.simpleName}: ${e.message}"); Int.MIN_VALUE + } + } + + /** A9 : CarStateClient.getGearState() via CarAdapterClient.queryClient(0xb). */ + private fun readA9GearState(): Int { + val cl = sVsm?.javaClass?.classLoader ?: return Int.MIN_VALUE + return try { + if (sCarState == null) { + val adapterClass = cl.loadClass(CAR_ADAPTER_CLIENT_CLASS) + val adapter = adapterClass.getMethod("getInstance", Context::class.java).invoke(null, sAppContext) + val binder = adapterClass.getMethod("queryClient", Int::class.javaPrimitiveType) + .invoke(adapter, CAR_STATE_SERVICE_CODE) as? android.os.IBinder ?: return Int.MIN_VALUE + val stateClass = cl.loadClass(CAR_STATE_CLIENT_CLASS) + sCarState = stateClass.getConstructor(android.os.IBinder::class.java).newInstance(binder) + } + (sCarState!!.javaClass.getMethod("getGearState").invoke(sCarState) as? Int) ?: Int.MIN_VALUE + } catch (e: Exception) { + AppLogger.w(TAG, " A9 getGearState err: ${e.javaClass.simpleName}: ${e.message}"); Int.MIN_VALUE + } + } + + private fun getMixIntProperty(propId: Int): Int { + val vpm = sVpm ?: return -1 + return try { + // Actual method on VPM: getMixProperty(Class, int) + val result = vpm.javaClass + .getMethod("getMixProperty", Class::class.java, Int::class.java) + .invoke(vpm, Int::class.javaObjectType, propId) + when (result) { + is Int -> result + is Number -> result.toInt() + null -> { AppLogger.d(TAG, " Katman4: getMixProperty(0x${propId.toString(16)}) = null"); -1 } + else -> { AppLogger.d(TAG, " Katman4: getMixProperty(0x${propId.toString(16)}) = $result (${result.javaClass.simpleName})"); -1 } + } + } catch (e: Exception) { + AppLogger.d(TAG, " Katman4: getMixProperty(0x${propId.toString(16)}) exc: ${e.message}") + -1 + } + } + + private fun setMixIntProperty(propId: Int, value: Int): Boolean { + val vpm = sVpm ?: return false + return try { + // Actual method on VPM: setMixProperty(Class, int, Object) + vpm.javaClass + .getMethod("setMixProperty", Class::class.java, Int::class.java, Any::class.java) + .invoke(vpm, Int::class.javaObjectType, propId, value) + AppLogger.i(TAG, " Katman4: setMixProperty(0x${propId.toString(16)}, $value) ✓") + true + } catch (e: Exception) { + AppLogger.d(TAG, " Katman4: setMixProperty(0x${propId.toString(16)}, $value) exc: ${e.message}") + false + } + } + + // ------------------------------------------------------------------------- + // Low-level property accessors + // ------------------------------------------------------------------------- + + private fun getIntPropertyCPM(propId: Int, areaId: Int): Int = + readPropertyCPM(propId, areaId, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int ?: -1 + + /** Read float via CarPropertyManager. null = unreadable (distinct from 0). */ + private fun getFloatPropertyCPM(propId: Int, areaId: Int): Float? = + readPropertyCPM(propId, areaId, Float::class.javaObjectType) { sCpmGetFloatMethod } as? Float + + private fun getBooleanPropertyCPM(propId: Int, areaId: Int): Boolean? = + readPropertyCPM(propId, areaId, Boolean::class.javaObjectType) { sCpmGetBooleanMethod } + as? Boolean + + /** + * One property read, judged before the value is handed back. + * + * Prefers `getProperty`, whose `CarPropertyValue` carries the status the VHAL attaches to + * the property, and only falls back to the bare typed getter on a runtime that does not + * expose it. The typed getters cannot tell a measurement from a property the firmware + * declares and never publishes — both arrive as a plain zero — so the status is what keeps + * an unpublished signal from reaching the screen as a reading. See [CarPropertyEvidence]. + * + * @param boxed the boxed type the property holds; car-lib checks it against the property's + * declared type, so a blanket `Object::class.java` is not accepted. + * @param legacyGetter the typed getter for [boxed], read lazily so it is resolved after + * [cacheCarPropertyMethods] has run. + */ + private fun readPropertyCPM( + propId: Int, + areaId: Int, + boxed: Class<*>, + legacyGetter: () -> java.lang.reflect.Method?, + ): Any? { + val cpm = sCarPropertyManager ?: return null + cacheCarPropertyMethods(cpm) + val key = "0x${Integer.toHexString(propId)}@0x${Integer.toHexString(areaId)}" + + sCpmGetPropertyMethod?.let { getProperty -> + return try { + val holder = getProperty.invoke(cpm, boxed, propId, areaId) + ?: return readFailed(key, "no CarPropertyValue") + val status = holder.javaClass.getMethod("getStatus").invoke(holder) as? Int + ?: return readFailed(key, "no status") + val value = holder.javaClass.getMethod("getValue").invoke(holder) + CarPropertyEvidence.accept(value, status) + ?.also { ReadFailureLog.clear(key) } + ?: readFailed(key, CarPropertyEvidence.describe(status)) + } catch (e: Exception) { + readFailed(key, e.readFailureReason()) + } + } + + val method = legacyGetter() ?: return null + return try { + method.invoke(cpm, propId, areaId)?.also { ReadFailureLog.clear(key) } + } catch (e: Exception) { + readFailed(key, e.readFailureReason()) + } + } + + /** Logs a failed read the first time it says something new, and reads as null. */ + private fun readFailed(key: String, reason: String): Any? { + if (ReadFailureLog.isNew(key, reason)) AppLogger.d(TAG, " CPM $key unreadable: $reason") + return null + } + + /** + * A reflected call fails as `InvocationTargetException`, whose own message is null: the + * sentence that says what happened belongs to the cause. Naming the cause's class when it + * carries no message keeps a `PropertyNotAvailableException` out of the log as "exc: null". + */ + private fun Throwable.readFailureReason(): String { + val root = (this as? java.lang.reflect.InvocationTargetException)?.targetException + ?: cause + ?: this + return root.message?.takeIf { it.isNotBlank() } ?: root.javaClass.simpleName + } + + @Synchronized + private fun cacheCarPropertyMethods(cpm: Any?) { + // Keyed on "resolved", not on "found": a getter this CarPropertyManager does not + // expose would otherwise be looked up again on every single sample. + if (cpm == null || sCpmMethodsResolved) return + val type = cpm.javaClass + sCpmGetIntMethod = runCatching { + type.getMethod("getIntProperty", Int::class.java, Int::class.java) + }.getOrNull() + sCpmGetFloatMethod = runCatching { + type.getMethod("getFloatProperty", Int::class.java, Int::class.java) + }.getOrNull() + sCpmGetBooleanMethod = runCatching { + type.getMethod("getBooleanProperty", Int::class.java, Int::class.java) + }.getOrNull() + sCpmGetPropertyMethod = runCatching { + type.getMethod("getProperty", Class::class.java, Int::class.java, Int::class.java) + }.getOrNull() + sCpmMethodsResolved = true + } + + private fun clearCarPropertyMethods() { + sCpmGetIntMethod = null + sCpmGetFloatMethod = null + sCpmGetBooleanMethod = null + sCpmGetPropertyMethod = null + sCpmMethodsResolved = false + // The next connection is a new vehicle session: nothing carried over should keep a + // first failure from being logged. + ReadFailureLog.reset() + } + + private fun setIntPropertyCPM(propId: Int, areaId: Int, value: Int): Boolean { + // [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable. + if (!VehicleWriteGate.allow("CPM 0x${Integer.toHexString(propId)}")) return false + val cpm = sCarPropertyManager ?: run { + AppLogger.w(TAG, " CPM setInt 0x${Integer.toHexString(propId)} — CPM not ready") + return false + } + return try { + cpm.javaClass + .getMethod("setIntProperty", Int::class.java, Int::class.java, Int::class.java) + .invoke(cpm, propId, areaId, value) + if (logEnabled) AppLogger.i(TAG, " CPM setInt 0x${Integer.toHexString(propId)} area=0x${Integer.toHexString(areaId)} value=$value ✓") + true + } catch (e: Exception) { + AppLogger.w(TAG, " CPM setInt 0x${Integer.toHexString(propId)} error: ${e.message}") + false + } + } + + fun getIntPropertyHvac(propId: Int, areaId: Int): Int { + val hvac = sCarHvacManager ?: return -1 + return try { + (hvac.javaClass.getMethod("getIntProperty", Int::class.java, Int::class.java) + .invoke(hvac, propId, areaId) as? Int) ?: -1 + } catch (_: Exception) { -1 } + } + + private fun setIntPropertyHvac(propId: Int, areaId: Int, value: Int): Boolean { + val hvac = sCarHvacManager ?: return false + return try { + hvac.javaClass + .getMethod("setIntProperty", Int::class.java, Int::class.java, Int::class.java) + .invoke(hvac, propId, areaId, value) + true + } catch (_: Exception) { false } + } + + /** + * SAIC proprietary binder transact (Katman2 fallback). + * Runtime parcel contract: [interfaceToken, AREA_GLOBAL, 1, value, float[], byte[]] + */ + private fun binderTransact(binder: IBinder?, descriptor: String, txCode: Int, value: Int): Boolean { + // [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable. + if (!VehicleWriteGate.allow("binder tx=0x${Integer.toHexString(txCode)}")) return false + if (binder == null) { + AppLogger.w(TAG, " Binder TX=$txCode — binder null") + return false + } + val data = Parcel.obtain() + val reply = Parcel.obtain() + return try { + data.writeInterfaceToken(descriptor) + data.writeInt(AREA_GLOBAL) + data.writeInt(1) + data.writeInt(value) + data.writeFloatArray(FloatArray(0)) + data.writeByteArray(ByteArray(0)) + binder.transact(txCode, data, reply, 0) + val status = if (reply.dataAvail() > 0) reply.readInt() else 0 + if (logEnabled) AppLogger.i(TAG, " Binder TX=$txCode value=$value → status=$status ${if (status == 0) "✓" else "✗ REJECTED"}") + status == 0 + } catch (e: Exception) { + AppLogger.e(TAG, " binderTransact error: ${e.message}") + false + } finally { + data.recycle() + reply.recycle() + } + } + + /** + * HVAC toggle — cycles the property by sending value=1 until target is reached. + * Timeout: 7 seconds, matching the supported runtime behaviour. + */ + fun setHvacLevelWithToggle(propId: Int, areaId: Int, targetLevel: Int): Boolean { + val deadline = System.currentTimeMillis() + 7_000L + var lastClickMs = 0L + while (System.currentTimeMillis() < deadline) { + val current = getIntPropertyHvac(propId, areaId) + if (current == targetLevel) { + if (logEnabled) AppLogger.i(TAG, "HVAC target reached: $targetLevel") + return true + } + val now = System.currentTimeMillis() + if (now - lastClickMs >= 500L) { + if (logEnabled) AppLogger.i(TAG, "HVAC click → current=$current target=$targetLevel") + setIntPropertyHvac(propId, areaId, 1) + lastClickMs = now + try { Thread.sleep(200) } catch (_: InterruptedException) {} + } else { + try { Thread.sleep(250) } catch (_: InterruptedException) {} + } + } + AppLogger.e(TAG, "HVAC timeout! prop=0x${Integer.toHexString(propId)}") + return false + } + + // ------------------------------------------------------------------------- + // Public vehicle control API + // ------------------------------------------------------------------------- + + @RequiresStandstill + fun setDriveMode(mode: DriveMode): Boolean { + if (logEnabled) AppLogger.i(TAG, "setDriveMode → ${mode.label} (${mode.value})") + val ok = setIntPropertyCPM(PROP_DRIVE_MODE, AREA_GLOBAL, mode.value) + if (!ok) binderTransact(sVehicleBinder, DESCRIPTOR_VEHICLE, TX_SET_DRIVE_MODE, mode.value) + sAppContext?.getSharedPreferences(PREFS_NAME, 0)?.edit() + ?.putInt(KEY_LAST_DRIVE_MODE, mode.value)?.apply() + return ok + } + + @RequiresStandstill + fun setRegenLevel(level: RegenLevel): Boolean { + if (logEnabled) AppLogger.i(TAG, "setRegenLevel → ${level.label} (${level.value})") + return if (level == RegenLevel.ONE_PEDAL) { + val ok = setOnePedal(true) + if (!ok) { + setIntPropertyCPM(PROP_REGEN_LEVEL, AREA_GLOBAL, level.value) + binderTransact(sVehicleBinder, DESCRIPTOR_VEHICLE, TX_SET_REGEN_LEVEL, level.value) + } + ok + } else { + setOnePedal(false) + val ok = setIntPropertyCPM(PROP_REGEN_LEVEL, AREA_GLOBAL, level.value) + if (!ok) binderTransact(sVehicleBinder, DESCRIPTOR_VEHICLE, TX_SET_REGEN_LEVEL, level.value) + ok + } + } + + @RequiresStandstill + fun setOnePedal(enabled: Boolean): Boolean { + if (logEnabled) AppLogger.i(TAG, "setOnePedal → ${if (enabled) "On" else "Off"}") + val intVal = if (enabled) 1 else 0 + val ok = setIntPropertyCPM(PROP_ONE_PEDAL, AREA_GLOBAL, intVal) + if (!ok) binderTransact(sVehicleBinder, DESCRIPTOR_VEHICLE, TX_SET_ONE_PEDAL, intVal) + return ok + } + + /* + * `…OrNull` readers: null means "could not be read", which is not the same as off/zero. + * + * A consumer that drives a switch (EVProfile) wants a Boolean and treats unreadable as + * off; a consumer that evaluates a rule (EVTasker) must not, or a car whose firmware + * never answers looks exactly like a car with the feature turned off — and the rule + * writes on that assumption. So the routing lives in the nullable reader and the + * historical non-null getter is a thin `?: false` / `?: 0` over it, unchanged for its + * callers. + */ + fun seatHeatLeftOrNull(): Int? = getIntPropertyHvac(PROP_SEAT_HEAT_L, AREA_HVAC).takeIf { it >= 0 } + fun seatHeatRightOrNull(): Int? = getIntPropertyHvac(PROP_SEAT_HEAT_R, AREA_HVAC).takeIf { it >= 0 } + fun steeringHeatOnOrNull(): Boolean? = + getIntPropertyHvac(PROP_STEERING_HEAT, AREA_HVAC).takeIf { it >= 0 }?.let { it > 0 } + + fun getSeatHeatLeft(): Int = seatHeatLeftOrNull() ?: 0 + 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 getRegenLevel(): RegenLevel? { + val cpm = sCarPropertyManager ?: return null + return try { + val raw = (cpm.javaClass + .getMethod("getIntProperty", Int::class.java, Int::class.java) + .invoke(cpm, PROP_REGEN_LEVEL, AREA_GLOBAL) as? Int) ?: return null + RegenLevel.fromValue(raw) + } catch (_: Exception) { null } + } + + fun setSeatHeatLeft(level: Int): Boolean { + if (logEnabled) AppLogger.i(TAG, "setSeatHeatLeft → $level") + return setHvacLevelWithToggle(PROP_SEAT_HEAT_L, AREA_HVAC, level) + } + + fun setSeatHeatRight(level: Int): Boolean { + if (logEnabled) AppLogger.i(TAG, "setSeatHeatRight → $level") + return setHvacLevelWithToggle(PROP_SEAT_HEAT_R, AREA_HVAC, level) + } + + fun setSteeringHeat(on: Boolean): Boolean { + if (logEnabled) AppLogger.i(TAG, "setSteeringHeat → $on") + val current = getIntPropertyHvac(PROP_STEERING_HEAT, AREA_HVAC) + if ((current > 0) == on) return true + // Send a single click and wait for state confirmation (avoids on/off oscillation) + setIntPropertyHvac(PROP_STEERING_HEAT, AREA_HVAC, 1) + val deadline = System.currentTimeMillis() + 2_000L + while (System.currentTimeMillis() < deadline) { + try { Thread.sleep(300) } catch (_: InterruptedException) {} + if ((getIntPropertyHvac(PROP_STEERING_HEAT, AREA_HVAC) > 0) == on) return true + } + return false + } + + // ------------------------------------------------------------------------- + // ADAS API (Katman4) + // ------------------------------------------------------------------------- + + fun overspeedAlarmOnOrNull(): Boolean? { + if (FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132) { + // VSM priority (CarVehicleSettingClient) on supported SWI132 firmware. + // getOverSpeedSoundMode() : 0=OFF, 1/2/3=ON + val vsm = callVsm("getOverSpeedSoundMode") as? Int + if (vsm != null) { + AppLogger.d(TAG, " SWI132 overspeed GET via VSM → $vsm") + return vsm > 0 + } + return swi132BinderGet(VSM132_TX_GET_OVERSPEED).takeIf { it >= 0 }?.let { it == 1 } + } + return getIntPropertyVpm(PROP_OVERSPEED_ALARM).takeIf { it >= 0 }?.let { it > 0 } + } + + fun isOverspeedAlarmOn(): Boolean = overspeedAlarmOnOrNull() ?: false + + @RequiresStandstill + fun setOverspeedAlarm(on: Boolean): Boolean { + if (logEnabled) AppLogger.i(TAG, "setOverspeedAlarm → $on") + if (FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132) { + // Essai 1 : CarVehicleSettingClient — setOverSpeedSoundMode(I)V est une method void ; + // callVsmVoid() detects success even when invoke() returns null (normal behaviour). + if (callVsmVoid("setOverSpeedSoundMode", if (on) 1 else 0)) { + AppLogger.i(TAG, " SWI132 overspeed → VSM OK") + return true + } + AppLogger.w(TAG, " SWI132 overspeed: VSM failed — essai binder") + // Attempt 2: direct binder (TX 0x128, SELinux-blocked on some builds) + if (swi132BinderSet(VSM132_TX_OVERSPEED_SOUND, if (on) 1 else 0)) return true + AppLogger.w(TAG, " SWI132 overspeed: all paths failed") + return false + } + return setIntPropertyVpm(PROP_OVERSPEED_ALARM, if (on) 1 else 0) + } + + fun speedLimitToneOnOrNull(): Boolean? { + if (FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132) { + // getSpeedLimitSoundMode() : 0=OFF, value positive=ON + val vsm = callVsm("getSpeedLimitSoundMode") as? Int + if (vsm != null) { + AppLogger.d(TAG, " SWI132 speedLimit GET via VSM → $vsm") + return vsm > 0 + } + return swi132BinderGet(VSM132_TX_GET_SPEED_LIMIT).takeIf { it >= 0 }?.let { it == 1 } + } + return getIntPropertyVpm(PROP_SPEED_LIMIT_TONE).takeIf { it >= 0 }?.let { it > 0 } + } + + fun isSpeedLimitToneOn(): Boolean = speedLimitToneOnOrNull() ?: false + + @RequiresStandstill + fun setSpeedLimitTone(on: Boolean): Boolean { + if (logEnabled) AppLogger.i(TAG, "setSpeedLimitTone → $on") + if (FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132) { + // Essai 1 : CarVehicleSettingClient — setSpeedLimitSoundMode(I)V (method void) + if (callVsmVoid("setSpeedLimitSoundMode", if (on) 1 else 0)) { + AppLogger.i(TAG, " SWI132 speedLimit → VSM OK") + return true + } + AppLogger.w(TAG, " SWI132 speedLimit: VSM failed — essai binder") + // Essai 2 : binder direct (TX 0x12a) + if (swi132BinderSet(VSM132_TX_SPEED_LIMIT, if (on) 1 else 0)) return true + AppLogger.w(TAG, " SWI132 speedLimit: all paths failed") + return false + } + return setIntPropertyVpm(PROP_SPEED_LIMIT_TONE, if (on) 1 else 0) + } + + /** Returns 0–4 (Off/Limiter/Auto/ACC/ICA), or -1 if Katman4 not ready. */ + fun getMixedIntelligentDrive(): Int = getMixIntProperty(PROP_MIX_INTELLIGENT_DRIVE) + @RequiresStandstill + fun setMixedIntelligentDrive(value: Int): Boolean { + if (logEnabled) AppLogger.i(TAG, "setMixedIntelligentDrive → $value") + // Primary runtime path: setMixProperty. Fallback: setIntProperty if unavailable. + if (setMixIntProperty(PROP_MIX_INTELLIGENT_DRIVE, value)) return true + return setIntPropertyVpm(PROP_MIX_INTELLIGENT_DRIVE, value) + } + + // ── SWI68 / SWI69 ADAS API — VehicleSettingManager (different method names) ── + + /** + * Returns le mode ACC/TJA actuel (0x4=Off, 0x1=ACC, 0x2=TJA), ou -1 si pas ready. + * SWI68/SWI165 : getAccTjaMode() SWI69/SWI131/SWI132 : getAccTjaState() + */ + 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 + } + + /** SWI68/SWI165 : setAccTjaMode(I)V SWI69/SWI131/SWI132 : setAccTjaState(I)V — void */ + @RequiresStandstill + fun setAccTjaMode(mode: Int): Boolean { + val useNewApi = FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 + val method = if (useNewApi) "setAccTjaState" else "setAccTjaMode" + if (logEnabled) AppLogger.i(TAG, "$method → 0x${mode.toString(16)}") + return callVsmVoid(method, mode) // void method — callVsmVoid avoids the false negative + } + + /** + * Speed limiter — unified API for all VSM firmwares. + * Le limiteur est un setting INDEPENDENT of the ACC/TJA mode, avec les same values partout + * (0=Disabled, 2=Manual, 3=Intelligent on supported firmware), + * only the binder method name differs : + * SWI132/SWI131/SWI69 : getSasMode/setSasMode (CarVehicleSettingClient) + * SWI68/SWI165 : getSpeedAsstMode/setSpeedAsstMode (VehicleSettingManager) + */ + private fun useSasApi(): Boolean = + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 + + /** 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 + } + + /** Configure le mode du limiteur de speed. 0=Disabled, 2=Manual, 3=Intelligent. */ + @RequiresStandstill + fun setSpeedLimiterMode(mode: Int): Boolean { + val method = if (useSasApi()) "setSasMode" else "setSpeedAsstMode" + if (logEnabled) AppLogger.i(TAG, "$method → $mode") + return callVsmVoid(method, mode) + } + + /** + * SWI68/SWI165 : getLaneKeepingWarningSound() + * SWI69/SWI131/SWI132 : getLasWarningSound() + * Values : 2=ON / 1=OFF + */ + fun soundWarningOnOrNull(): Boolean? { + val method = if (FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132) + "getLasWarningSound" else "getLaneKeepingWarningSound" + return (callVsm(method) as? Int)?.let { it == 2 } + } + + fun isSoundWarningOn(): Boolean = soundWarningOnOrNull() ?: false + + /** SWI68 : setLaneKeepingWarningSound(I) SWI69/SWI131/SWI132 : setLasWarningSound(I) — void */ + @RequiresStandstill + fun setSoundWarning(on: Boolean): Boolean { + if (logEnabled) AppLogger.i(TAG, "setSoundWarning → $on") + val method = if (FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132) + "setLasWarningSound" else "setLaneKeepingWarningSound" + return callVsmVoid(method, if (on) 2 else 1) + } + + // ── AEB — Front collision avoidance system ────────────────────────────────── + + /** + * Returns true if the front collision avoidance system is enabled. + * SWI133 : lit PROP_AEB_SWITCH (2=ON, 1=OFF) via CarPropertyManager. + * SWI68 / SWI165 : getFcwAlarmMode() == 2 (FCW_ALARM_ON=2, FCW_ALARM_OFF=1) + * Verified in SafeSettingsRepository SWI165 — same API as SWI68. + * SWI69 / SWI131 : getFcwState() — 1=DISABLED, 2=ENABLED + */ + fun aebEnabledOrNull(): Boolean? { + return when { + // SWI69 / SWI131 / SWI132 — CarVehicleSettingClient : getFcwState() (1=OFF, 2=ON) + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> + (callVsm("getFcwState") as? Int)?.let { it == 2 } + FirmwareInfo.isVsmBased() -> (callVsm("getFcwAlarmMode") as? Int)?.let { it == 2 } // SWI68 / SWI165 + else -> getIntPropertyCPM(PROP_AEB_SWITCH, AREA_GLOBAL).takeIf { it >= 0 }?.let { it == 0x2 } // SWI133 + } + } + + fun isAebEnabled(): Boolean = aebEnabledOrNull() ?: false + + @RequiresStandstill + fun setAebEnabled(on: Boolean): Boolean { + if (logEnabled) AppLogger.i(TAG, "setAebEnabled → $on") + return when { + // SWI69 / SWI131 / SWI132 — CarVehicleSettingClient (same API) + // setFcwState(I)V et setFcwAutoBrakeMode(I)V sont des methods VOID → + // callVsmVoid() is used to avoid the false negative of callVsm() != null. + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> { + // OFF : setFcwState(1) + setFcwAutoBrakeMode(1) + setFcwSensitivity(0) + // ON : setFcwState(2) + setFcwAutoBrakeMode(curMode) + // The launcher gates its display on fcwState==1 AND autoBreakState==1 + // → sans setFcwAutoBrakeMode, son switch reste ON same quand l'AEB est disabled + if (on) { + val sOk = callVsmVoid("setFcwState", 2) + val curMode = (callVsm("getFcwAutoBrakeMode") as? Int) ?: 1 + val mOk = callVsmVoid("setFcwAutoBrakeMode", curMode) + sOk || mOk + } else { + callVsmVoid("setFcwState", 1) + callVsmVoid("setFcwAutoBrakeMode", 1) + callVsmVoid("setFcwSensitivity", 0) + } + } + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI68 || + FirmwareInfo.isSWI165() -> { + // SWI68 / SWI165 : setFcwAlarmMode(2=ON / 1=OFF) + setFcwAutoBrakeMode(1) si OFF + // Verified in SafeSettingsRepository SWI165 — same API as SWI68, + // setAutoEmergencyBraking() is never used by the official app. + // setFcwAlarmMode(I)V et setFcwAutoBrakeMode(I)V sont void → callVsmVoid() + if (on) callVsmVoid("setFcwAlarmMode", 2) + else { callVsmVoid("setFcwAlarmMode", 1) or callVsmVoid("setFcwAutoBrakeMode", 1) } + } + else -> setIntPropertyCPM(PROP_AEB_SWITCH, AREA_GLOBAL, if (on) 0x2 else 0x1) + } + } + + /** + * Returns le mode AEB courant (1=Alert, 2=Alert+Freinage), ou -1 si pas ready. + * SWI133 : PROP_AEB_MODE (0x302000b) via VehiclePropertyManager. + * SWI68/SWI69/SWI131 : getFcwAutoBrakeMode() (1=Alert, 2=Alert+Freinage). + */ + fun getAebMode(): Int { + return if (FirmwareInfo.isVsmBased()) { + (callVsm("getFcwAutoBrakeMode") as? Int) ?: AebMode.ALARM + } else { + val raw = getIntPropertyVpm(PROP_AEB_MODE) + if (raw < 1) -1 else raw + } + } + + /** + * Same routing as [getAebMode], without the ALARM default the VSM branch falls back to. + * That default is a sane switch position for a UI, and a lie for a rule: it would make + * "AEB mode is Alert" true on a car that never answered. + */ + fun aebModeOrNull(): Int? = + if (FirmwareInfo.isVsmBased()) (callVsm("getFcwAutoBrakeMode") as? Int)?.takeIf { it > 0 } + else getIntPropertyVpm(PROP_AEB_MODE).takeIf { it >= 1 } + + @RequiresStandstill + fun setAebMode(mode: Int): Boolean { + if (logEnabled) AppLogger.i(TAG, "setAebMode → $mode") + return when { + // SWI69 / SWI131 / SWI132 — CarVehicleSettingClient : fixer mode puis enable + // setFcwAutoBrakeMode(I)V et setFcwState(I)V sont void → callVsmVoid() + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> { + // L'ordre : 1) fixer le mode, 2) enable (commit le mode). + val modeVal = if (mode == AebMode.ALARM_BRAKE) 2 else 1 + val mOk = callVsmVoid("setFcwAutoBrakeMode", modeVal) + val sOk = callVsmVoid("setFcwState", 2) + mOk || sOk + } + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI68 || + FirmwareInfo.isSWI165() -> { + // SWI68 / SWI165 : setFcwAutoBrakeMode only (1=Alert, 2=Alert+Freinage) + callVsm("setFcwAutoBrakeMode", if (mode == AebMode.ALARM_BRAKE) 2 else 1) != null + } + else -> { + // SWI133 runtime path. + if (mode == AebMode.ALARM_BRAKE) { + val r1 = setIntPropertyVpmRecovery(PROP_AEB_SYS_MODE, AebMode.ALARM_BRAKE) + val r2 = setIntPropertyVpmRecovery(PROP_AEB_MODE, AebMode.ALARM_BRAKE) + r1 || r2 + } else { + setIntPropertyVpmRecovery(PROP_AEB_MODE, AebMode.ALARM) + } + } + } + } + + /** + * Returns la sensitivity AEB courante (1=Low, 2=Standard, 3=High), ou -1 si pas ready. + * SWI133 : PROP_AEB_SENSITIVITY (0x302000e, VPM) + * SWI68/SWI165 : VehicleSettingManager.getFcwSensitivity() + * SWI69/SWI131 : CarVehicleSettingClient.getFcwSensitivity() + */ + fun getAebSensitivity(): Int { + return if (FirmwareInfo.isVsmBased()) { + (callVsm("getFcwSensitivity") as? Int)?.takeIf { it > 0 }?.also { + AppLogger.d(TAG, " AEB GET sensitivity=$it via VSM ✓") + } ?: -1 + } else { + val raw = getIntPropertyVpm(PROP_AEB_SENSITIVITY) + if (raw < 1) -1 else raw + } + } + + /** + * Sets the AEB sensitivity (1=Low, 2=Standard, 3=High). + * SWI133 : PROP_AEB_SENSITIVITY (0x302000e, VPM) + * SWI68/SWI165 : VehicleSettingManager.setFcwSensitivity(I) + * SWI69/SWI131 : CarVehicleSettingClient.setFcwSensitivity(I) + */ + @RequiresStandstill + fun setAebSensitivity(level: Int): Boolean { + // setFcwSensitivity(I)V is void → callVsmVoid() to avoid the false negative + return if (FirmwareInfo.isVsmBased()) { + AppLogger.i(TAG, " AEB SET sensitivity=$level via VSM") + callVsmVoid("setFcwSensitivity", level) + } else { + AppLogger.i(TAG, " AEB SET sensitivity=$level via VPM") + setIntPropertyVpmRecovery(PROP_AEB_SENSITIVITY, level) + } + } + + // ------------------------------------------------------------------------- + // ELK — Assistant de sortie de voie (SWI133 only pour l'instant) + // Utilise IVehicleSettingService via sVehicleBinder (TX 0x53–0x56) + // ------------------------------------------------------------------------- + + /** + * Returns le mode ELK courant (1=OFF, 2=Alert, 3=Aider, 5=Maintien d'urgence). + * Routage par firmware : + * SWI133 → VSM133 (getLaneKeepingAsstMode) → binder TX 0x53 + * SWI68/SWI165 → VSM (getLaneKeepingAsstMode) + * SWI69/SWI131 → VSM (getLasMode) + */ + fun getElkMode(): Int = when { + !FirmwareInfo.isVsmBased() -> { + val vsm = callVsm133("getLaneKeepingAsstMode") + if (vsm is Int && vsm > 0) { + AppLogger.d(TAG, " ELK GET mode=$vsm via VSM133 ✓") + vsm + } else elkBinderGet(TX_ELK_GET_MODE) + } + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> { + // SWI69/SWI131/SWI132 — CarVehicleSettingClient + (callVsm("getLasMode") as? Int)?.takeIf { it > 0 }?.also { + AppLogger.d(TAG, " ELK GET mode=$it via VSM (Las) ✓") + } ?: -1 + } + else -> { + // SWI68/SWI165 — VehicleSettingManager + (callVsm("getLaneKeepingAsstMode") as? Int)?.takeIf { it > 0 }?.also { + AppLogger.d(TAG, " ELK GET mode=$it via VSM ✓") + } ?: -1 + } + } + + /** + * Sets the ELK mode. + * Routing identical to getElkMode(). + */ + @RequiresStandstill + fun setElkMode(mode: Int): Boolean = when { + !FirmwareInfo.isVsmBased() -> { + if (sVsm133 != null) { + AppLogger.i(TAG, " ELK SET mode=$mode via VSM133") + if (!VehicleWriteGate.allow("VSM133 setLaneKeepingAsstMode")) { + false + } else { + callVsm133("setLaneKeepingAsstMode", mode) + true + } + } else elkBinderSet(TX_ELK_SET_MODE, mode) + } + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> { + // SWI69/SWI131/SWI132 — CarVehicleSettingClient + AppLogger.i(TAG, " ELK SET mode=$mode via VSM (Las)") + callVsm("setLasMode", mode) + true + } + else -> { + // SWI68/SWI165 — VehicleSettingManager + AppLogger.i(TAG, " ELK SET mode=$mode via VSM") + callVsm("setLaneKeepingAsstMode", mode) + true + } + } + + /** + * Returns la sensitivity ELK courante (1=Low, 2=Standard, 3=High). + * Routing per firmware — identical to getElkMode(). + */ + fun getElkSensitivity(): Int = when { + !FirmwareInfo.isVsmBased() -> { + val vsm = callVsm133("getLaneKeepingAsstSen") + if (vsm is Int && vsm > 0) { + AppLogger.d(TAG, " ELK GET sen=$vsm via VSM133 ✓") + vsm + } else elkBinderGet(TX_ELK_GET_SEN) + } + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> { + // SWI69/SWI131/SWI132 — CarVehicleSettingClient + (callVsm("getLasSensitivity") as? Int)?.takeIf { it > 0 }?.also { + AppLogger.d(TAG, " ELK GET sen=$it via VSM (Las) ✓") + } ?: -1 + } + else -> { + // SWI68/SWI165 — VehicleSettingManager + (callVsm("getLaneKeepingAsstSen") as? Int)?.takeIf { it > 0 }?.also { + AppLogger.d(TAG, " ELK GET sen=$it via VSM ✓") + } ?: -1 + } + } + + /** + * Sets the ELK sensitivity. + * Routing identical to getElkMode(). + */ + @RequiresStandstill + fun setElkSensitivity(level: Int): Boolean = when { + !FirmwareInfo.isVsmBased() -> { + if (sVsm133 != null) { + AppLogger.i(TAG, " ELK SET sensitivity=$level via VSM133") + if (!VehicleWriteGate.allow("VSM133 setLaneKeepingAsstSen")) { + false + } else { + callVsm133("setLaneKeepingAsstSen", level) + true + } + } else elkBinderSet(TX_ELK_SET_SEN, level) + } + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> { + // SWI69/SWI131/SWI132 — CarVehicleSettingClient + AppLogger.i(TAG, " ELK SET sensitivity=$level via VSM (Las)") + callVsm("setLasSensitivity", level) + true + } + else -> { + // SWI68/SWI165 — VehicleSettingManager + AppLogger.i(TAG, " ELK SET sensitivity=$level via VSM") + callVsm("setLaneKeepingAsstSen", level) + true + } + } + + /** Returns true si l'ELK est enabled (mode ≠ OFF). */ + fun isElkEnabled(): Boolean { + val mode = getElkMode() + return mode > 0 && mode != ElkMode.OFF + } + + /** + * SWI132 — Alert sound (LAS Warning Sound) : 0=OFF, 1=ON. + * Returns -1 si erreur ou firmware non SWI132. + */ + fun getLasWarningSound(): Int { + if (FirmwareInfo.getGeneration() != FirmwareInfo.Gen.SWI132) return -1 + return (callVsm("getLasWarningSound") as? Int)?.also { + AppLogger.d(TAG, " LAS GET sound=$it via VSM ✓") + } ?: -1 + } + + fun setLasWarningSound(enabled: Boolean): Boolean { + if (FirmwareInfo.getGeneration() != FirmwareInfo.Gen.SWI132) return false + AppLogger.i(TAG, " LAS SET sound=${if (enabled) "ON" else "OFF"} via VSM") + callVsm("setLasWarningSound", if (enabled) 1 else 0) + return true + } + + /** + * SWI132 — Rappel par vibration (LAS Warning Vibration) : 0=OFF, 1=ON. + * Returns -1 si erreur ou firmware non SWI132. + */ + fun getLasWarningVibration(): Int { + if (FirmwareInfo.getGeneration() != FirmwareInfo.Gen.SWI132) return -1 + return (callVsm("getLasWarningVibration") as? Int)?.also { + AppLogger.d(TAG, " LAS GET vibration=$it via VSM ✓") + } ?: -1 + } + + fun setLasWarningVibration(enabled: Boolean): Boolean { + if (FirmwareInfo.getGeneration() != FirmwareInfo.Gen.SWI132) return false + AppLogger.i(TAG, " LAS SET vibration=${if (enabled) "ON" else "OFF"} via VSM") + callVsm("setLasWarningVibration", if (enabled) 1 else 0) + return true + } + + // ------------------------------------------------------------------------- + // Drowsiness warning (UDW) and ESC + // + // Three different routes, and the differences are the whole difficulty: + // • SWI133 : VPM properties; + // • SWI68/165 : named methods — and ESC on the **VehicleControlManager**, not the + // settings one, which is why looking for it on sVsm found nothing; + // • A9 : the CarVehicleSettingClient, where ESC is spelled "Eps". + // ------------------------------------------------------------------------- + + // UDW = "Unfit Driver Warning". 0x3010005 (UDW_MAIN_SWITCH) and NOT 0x3010001 + // (DMS_SWITCH): both exist and their labels read alike, but DMS_SWITCH drives the CAMERA + // monitoring and writing it had no visible effect. The switch and its sensitivity must + // come from the same family — a setDmsStatus next to a setUdwSensitivity is two different + // settings wearing one name. + private const val PROP_UDW_MAIN_SWITCH = 0x3010005 + private const val PROP_DMS_SENSITIVITY = 0x3010007 + private const val PROP_ESC = 0x4020003 + + private const val UDW_OFF = 1 + private const val UDW_ON = 2 + + /** How many agreeing reads before acting on ESC. */ + private const val ESC_READS = 3 + + /** Between two of those reads. */ + private const val ESC_READ_INTERVAL_MS = 300L + + /** Time left to the controller to apply the toggle before the effect is checked. */ + private const val ESC_SETTLE_MS = 600L + + /** Drowsiness alert sensitivity — the same three steps as the AEB one. */ + object DrowsinessSensitivity { + const val LOW = 1 + const val MEDIUM = 2 + const val HIGH = 3 + } + + /** False on a firmware none of the three routes covers, where a write would go nowhere. */ + fun hasDrowsinessAndEsc(): Boolean = FirmwareInfo.getGeneration() != FirmwareInfo.Gen.UNKNOWN + + /** + * True on the carapi platform, where the method names differ from the old SDK. + * + * Not [FirmwareInfo.isNewGenVsm]: that one covers SWI69 and SWI131 and leaves SWI132 out, + * which is an A9 all the same. + */ + private fun isA9Vsm(): Boolean = when (FirmwareInfo.getGeneration()) { + FirmwareInfo.Gen.SWI69, FirmwareInfo.Gen.SWI131, FirmwareInfo.Gen.SWI132 -> true + else -> false + } + + private fun nUdwSet() = if (isA9Vsm()) "setUdwStatus" else "setUnsteadyDrivingWarning" + private fun nUdwGet() = if (isA9Vsm()) "getUdwStatus" else "getUnsteadyDrivingWarning" + private fun nSenSet() = if (isA9Vsm()) "setUdwSensitivityState" else "setUnsteadyDrivingWarningSen" + private fun nSenGet() = if (isA9Vsm()) "getUdwSensitivityState" else "getUnsteadyDrivingWarningSen" + private fun nEscSet() = if (isA9Vsm()) "setDrivingEpsMode" else "setEspSwitch" + private fun nEscGet() = if (isA9Vsm()) "getDrivingEpsMode" else "getEspSwitch" + + private fun readSafety(prop: Int, method: String): Int = + if (FirmwareInfo.isVsmBased()) (callVsm(method) as? Int) ?: -1 + else getIntPropertyVpm(prop) + + private fun writeSafety(prop: Int, method: String, value: Int): Boolean = + if (FirmwareInfo.isVsmBased()) callVsmVoid(method, value) + else setIntPropertyVpmRecovery(prop, value) + + /** + * Drowsiness warning: true ON, false OFF, null unreadable. + * + * **0 counts as OFF**, not as unreadable — which is what the stock UI does (1 OFF, 2 ON, + * 0 OFF, anything else an error). Treating it as unreadable answered null on a perfectly + * valid state, so nothing lit up on screen. Only -1, the read failure sentinel, is + * genuinely "could not tell". + */ + fun isDrowsinessOn(): Boolean? { + if (!hasDrowsinessAndEsc()) return null + return when (readSafety(PROP_UDW_MAIN_SWITCH, nUdwGet())) { + UDW_ON -> true + UDW_OFF, 0 -> false + else -> null + } + } + + @RequiresStandstill + fun setDrowsiness(on: Boolean): Boolean { + if (!hasDrowsinessAndEsc()) return false + val value = if (on) UDW_ON else UDW_OFF + AppLogger.i(TAG, " UDW SET switch=$value (${if (on) "ON" else "OFF"}) via ${nUdwSet()}") + return writeSafety(PROP_UDW_MAIN_SWITCH, nUdwSet(), value) + } + + /** 1 low, 2 medium, 3 high. -1 when unreadable. */ + fun getDrowsinessSensitivity(): Int { + if (!hasDrowsinessAndEsc()) return -1 + return readSafety(PROP_DMS_SENSITIVITY, nSenGet()).takeIf { it in 1..3 } ?: -1 + } + + @RequiresStandstill + fun setDrowsinessSensitivity(level: Int): Boolean { + if (!hasDrowsinessAndEsc() || level !in 1..3) return false + AppLogger.i(TAG, " UDW SET sensitivity=$level via ${nSenSet()}") + return writeSafety(PROP_DMS_SENSITIVITY, nSenSet(), level) + } + + private fun readEscRaw(): Int = when { + isA9Vsm() -> (callVsm(nEscGet()) as? Int) ?: -1 + FirmwareInfo.isVsmBased() -> (callVcontrol(nEscGet()) as? Int) ?: -1 + else -> getIntPropertyVpm(PROP_ESC) + } + + private fun writeEscRaw(value: Int): Boolean = when { + isA9Vsm() -> callVsmVoid(nEscSet(), value) + FirmwareInfo.isVsmBased() -> callVcontrolVoid(nEscSet(), value) + else -> setIntPropertyVpmRecovery(PROP_ESC, value) + } + + /** ESC: true ON, false OFF, null unreadable. Read as 0 OFF, 1 ON, 2 OFF. */ + fun isEscOn(): Boolean? { + if (!hasDrowsinessAndEsc()) return null + return when (readEscRaw()) { + 1 -> true + 0, 2 -> false + else -> null + } + } + + /** + * The same reading [ESC_READS] times running, or nothing. + * + * The write is a **toggle** driven by this read, so a single wrong reading does not + * produce a failed write — it produces the opposite of what was asked, silently. + */ + private fun readEscStable(): Boolean? { + val first = isEscOn() ?: return null + repeat(ESC_READS - 1) { + try { Thread.sleep(ESC_READ_INTERVAL_MS) } catch (_: InterruptedException) {} + if (isEscOn() != first) { + AppLogger.w(TAG, " ESC: readings disagree — not acting on a toggle") + return null + } + } + return first + } + + /** + * Turns ESC on or off. Blocking for about a second — never on the main thread. + * + * Two guards, both earned: + * + * **Ignition.** Getting in while the infotainment is already up but the cluster is not, + * the ESC property does not yet reflect reality. Since the write is a toggle driven by + * that read, aiming at ON from a false OFF *inverts* an ESC that was active — genuinely + * disabled, in silence. So a known non-RUN ignition is refused. An unreadable one is not: + * on a firmware where the property does not answer, refusing would cost the user the + * setting and protect nothing. Nothing is lost by waiting either — the car puts ESC back + * on at every start, and the rule runs again once the ignition reaches RUN. + * + * **Agreement.** [readEscStable] must see the same state [ESC_READS] times before the + * toggle is sent, and the result is checked afterwards rather than assumed. + */ + @RequiresStandstill + fun setEsc(on: Boolean): Boolean { + if (!hasDrowsinessAndEsc()) return false + + // [IgnitionState], not [CarIgnitionItem]: getCurrentIgnitionState() answers on the + // AAOS scale, where the two do not line up — RUN is 2 for Katman5 and OFF is 2 here. + // Unreadable is 0 or -1 and is deliberately not a refusal, per the KDoc above. + val ignition = getCurrentIgnitionState() + if (ignition > 0 && ignition != IgnitionState.ON) { + AppLogger.w(TAG, " ESC: vehicle not in RUN (ignition=$ignition) — no action, the " + + "reading cannot be trusted yet") + return false + } + + val stable = readEscStable() ?: return false + if (stable == on) { + AppLogger.i(TAG, " ESC already ${if (on) "ON" else "OFF"} (confirmed $ESC_READS times)") + return true + } + + AppLogger.i(TAG, " ESC -> ${if (on) "ON" else "OFF"} via ${nEscSet()}") + if (!writeEscRaw(1)) return false + try { Thread.sleep(ESC_SETTLE_MS) } catch (_: InterruptedException) {} + val after = isEscOn() + AppLogger.i(TAG, " ESC after toggle = $after (wanted $on)") + return after == on + } + + /** + * GET via the runtime IVehicleSettingService binder contract: + * data : [writeInterfaceToken] + * transact(code, data, reply, 0) + * reply: readException() + readInt() + */ + private fun elkBinderGet(txCode: Int): Int { + val binder = sVehicleBinder ?: run { + AppLogger.d(TAG, " ELK GET TX=0x${txCode.toString(16)} — sVehicleBinder null") + return -1 + } + val data = Parcel.obtain() + val reply = Parcel.obtain() + return try { + data.writeInterfaceToken(DESCRIPTOR_VEHICLE) + val ok = binder.transact(txCode, data, reply, 0) + if (!ok) { + AppLogger.d(TAG, " ELK GET TX=0x${txCode.toString(16)} — transact returned false") + return -1 + } + reply.readException() + val result = reply.readInt() + AppLogger.d(TAG, " ELK GET TX=0x${txCode.toString(16)} = $result") + result + } catch (e: Exception) { + AppLogger.d(TAG, " ELK GET TX=0x${txCode.toString(16)} exc: ${e.message}") + -1 + } finally { + data.recycle() + reply.recycle() + } + } + + /** + * SET via the runtime IVehicleSettingService binder contract: + * data : [writeInterfaceToken, writeInt(value)] + * transact(code, data, null, FLAG_ONEWAY=1) + */ + private fun elkBinderSet(txCode: Int, value: Int): Boolean { + val binder = sVehicleBinder ?: run { + AppLogger.d(TAG, " ELK SET TX=0x${txCode.toString(16)} — sVehicleBinder null") + return false + } + val data = Parcel.obtain() + return try { + data.writeInterfaceToken(DESCRIPTOR_VEHICLE) + data.writeInt(value) + binder.transact(txCode, data, null, IBinder.FLAG_ONEWAY) + AppLogger.i(TAG, " ELK SET TX=0x${txCode.toString(16)} value=$value ✓") + true + } catch (e: Exception) { + AppLogger.d(TAG, " ELK SET TX=0x${txCode.toString(16)} exc: ${e.message}") + false + } finally { + data.recycle() + } + } + + /** + * GET via IVehicleSettingService SWI132 (DESCRIPTOR_VSM132, two-way flag=0x0). + * Returns the integer value read, or -1 on error. + */ + private fun swi132BinderGet(txCode: Int): Int { + val binder = sVehicleBinder ?: run { + AppLogger.d(TAG, " SWI132 GET TX=0x${txCode.toString(16)} — sVehicleBinder null") + return -1 + } + val data = Parcel.obtain() + val reply = Parcel.obtain() + return try { + data.writeInterfaceToken(DESCRIPTOR_VSM132) + val ok = binder.transact(txCode, data, reply, 0) + if (!ok) { + AppLogger.d(TAG, " SWI132 GET TX=0x${txCode.toString(16)} — transact false") + return -1 + } + reply.readException() + val result = reply.readInt() + AppLogger.d(TAG, " SWI132 GET TX=0x${txCode.toString(16)} = $result") + result + } catch (e: Exception) { + AppLogger.d(TAG, " SWI132 GET TX=0x${txCode.toString(16)} exc: ${e.message}") + -1 + } finally { + data.recycle() + reply.recycle() + } + } + + /** + * SET via IVehicleSettingService SWI132 (DESCRIPTOR_VSM132, two-way flag=0x0). + * Different from elkBinderSet: uses the right DESCRIPTOR and a two-way flag. + */ + private fun swi132BinderSet(txCode: Int, value: Int): Boolean { + val binder = sVehicleBinder ?: run { + AppLogger.d(TAG, " SWI132 SET TX=0x${txCode.toString(16)} — sVehicleBinder null") + return false + } + val data = Parcel.obtain() + val reply = Parcel.obtain() + return try { + data.writeInterfaceToken(DESCRIPTOR_VSM132) + data.writeInt(value) + binder.transact(txCode, data, reply, 0) + reply.readException() + AppLogger.i(TAG, " SWI132 SET TX=0x${txCode.toString(16)} value=$value ✓") + true + } catch (e: Exception) { + AppLogger.d(TAG, " SWI132 SET TX=0x${txCode.toString(16)} exc: ${e.message}") + false + } finally { + data.recycle() + reply.recycle() + } + } + + // ------------------------------------------------------------------------- + // TSR — Reconnaissance des panneaux de speed + // ------------------------------------------------------------------------- + + fun tsrOnOrNull(): Boolean? = when { + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> + // CarVehicleSettingClient priority (vehiclesetting binder SELinux-blocked) + // Convention identique SWI69/SWI131 : 0=ON, 1=OFF + (callVsm("getSLIFWarningState") as? Int)?.let { raw -> + AppLogger.d(TAG, " SWI132 TSR GET via VSM → $raw") + raw == 0 + // fallback binder (rarement accessible) + } ?: swi132BinderGet(VSM132_TX_GET_SLIF).takeIf { it >= 0 }?.let { it == 1 } + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI133 -> + getIntPropertyVpm(PROP_TSR_MODE).takeIf { it >= 0 }?.let { it > 0 } + FirmwareInfo.isNewGenVsm() -> // SWI69 + SWI131 — convention invertede : 0=ON, 1=OFF + (callVsm("getSLIFWarningState") as? Int)?.let { it == 0 } + FirmwareInfo.isVsmBased() -> // SWI68 + SWI165 + (callVsm("getSpeedAsstSlifWarning") as? Int)?.let { it == 1 } + else -> null + } + + fun isTsrOn(): Boolean = tsrOnOrNull() ?: false + + @RequiresStandstill + fun setTsrMode(enabled: Boolean): Boolean { + AppLogger.i(TAG, "setTsrMode → $enabled") + return when { + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> { + // CarVehicleSettingClient priority on supported SWI132 firmware. + // Convention identique SWI69/SWI131 : 0=enable, 1=disablesr + if (callVsmVoid("setSLIFWarningState", if (enabled) 0 else 1)) { + AppLogger.i(TAG, " SWI132 TSR → VSM OK") + true + } else { + // Direct binder fallback (SELinux-blocked on most SWI132 builds) + AppLogger.w(TAG, " SWI132 TSR: VSM failed — essai binder TX 0x057") + swi132BinderSet(VSM132_TX_SLIF_WARNING, if (enabled) 1 else 0) + } + } + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI133 -> { + // SWI133: the firmware resets OVERSPEED and SPEED_TONE to ON when SLIF is re-enabled + // → on sauvegarde avant et on restaure after. + val prefs = sAppContext?.getSharedPreferences("ev_settings", 0) + if (!enabled) { + val overspeedOn = isOverspeedAlarmOn() + val speedToneOn = isSpeedLimitToneOn() + prefs?.edit() + ?.putBoolean("tsr_saved_overspeed", overspeedOn) + ?.putBoolean("tsr_saved_speed_tone", speedToneOn) + ?.apply() + AppLogger.i(TAG, " TSR OFF — sauvegarde overspeed=$overspeedOn speedTone=$speedToneOn") + } + val ok = setIntPropertyVpmRecovery(PROP_TSR_MODE, if (enabled) 1 else 0) + if (enabled && ok) { + Thread.sleep(400) + val savedOverspeed = prefs?.getBoolean("tsr_saved_overspeed", true) ?: true + val savedSpeedTone = prefs?.getBoolean("tsr_saved_speed_tone", true) ?: true + AppLogger.i(TAG, " TSR ON — restauration overspeed=$savedOverspeed speedTone=$savedSpeedTone") + setOverspeedAlarm(savedOverspeed) + setSpeedLimitTone(savedSpeedTone) + } + ok + } + FirmwareInfo.isNewGenVsm() -> { // SWI69 + SWI131 — convention invertede : 0=enable, 1=disablesr + // setSLIFWarningState(I)V est void → callVsmVoid() + callVsmVoid("setSLIFWarningState", if (enabled) 0 else 1) + } + FirmwareInfo.isVsmBased() -> { // SWI68 + SWI165 + // The warning sound may be reset to ON when re-enabling TSR + // → on sauvegarde avant et on restaure after. + val prefs = sAppContext?.getSharedPreferences("ev_settings", 0) + if (!enabled) { + val soundOn = isSoundWarningOn() + prefs?.edit()?.putBoolean("tsr_saved_sound_warning", soundOn)?.apply() + AppLogger.i(TAG, " TSR OFF — sauvegarde soundWarning=$soundOn") + } + // setSpeedAsstSlifWarning(I)V est void → callVsmVoid() + if (!callVsmVoid("setSpeedAsstSlifWarning", if (enabled) 1 else 0)) return false + if (enabled) { + Thread.sleep(400) + val savedSound = prefs?.getBoolean("tsr_saved_sound_warning", true) ?: true + AppLogger.i(TAG, " TSR ON — restauration soundWarning=$savedSound") + setSoundWarning(savedSound) + } + true + } + else -> false + } + } + + /** + * SWI133: returns (overspeed, speedTone) as saved at the last TSR OFF. + * Used by the UI to update the switches after re-enabling TSR, without + * relire le hardware (le VPM a une latence de propagation qui renverrait encore ON + * pendant ~500–1000ms after les writes internes de setTsrMode). + */ + fun savedTsrAlerts(): Pair { + val prefs = sAppContext?.getSharedPreferences("ev_settings", 0) + return Pair( + prefs?.getBoolean("tsr_saved_overspeed", true) ?: true, + prefs?.getBoolean("tsr_saved_speed_tone", true) ?: true + ) + } + + // ------------------------------------------------------------------------- + // Energy saving (Endurance Mode) + // ------------------------------------------------------------------------- + + fun energySavingOnOrNull(): Boolean? = when { + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI133 -> + getIntPropertyVpm(PROP_ENERGY_SAVING).takeIf { it >= 0 }?.let { it == 1 } + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> // SWI69 + SWI131 + SWI132 + (callVsm("getEnduranceMode") as? Int)?.let { it == 1 } + FirmwareInfo.isVsmBased() -> // SWI68 + SWI165 + (callVsm("getLongerEndurance") as? Int)?.let { it == 1 } + else -> null + } + + fun isEnergySavingOn(): Boolean = energySavingOnOrNull() ?: false + + @RequiresStandstill + fun setEnergySavingMode(enabled: Boolean): Boolean { + AppLogger.i(TAG, "setEnergySavingMode → $enabled") + return when { + FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI133 -> + setIntPropertyVpmRecovery(PROP_ENERGY_SAVING, if (enabled) 1 else 0) + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 -> { // SWI69 + SWI131 + SWI132 + // setEnduranceMode(I)V est void → callVsmVoid() + callVsmVoid("setEnduranceMode", if (enabled) 1 else 0) + } + FirmwareInfo.isVsmBased() -> { // SWI68 + SWI165 + // setLongerEndurance(I)V est void → callVsmVoid() + callVsmVoid("setLongerEndurance", if (enabled) 1 else 0) + } + else -> false + } + } + + fun isKatman4Ready(): Boolean = + if (FirmwareInfo.isVsmBased()) sVsm != null && sVsmService != null + else sVpm != null && sVpmService != null + fun isKatman4VpmCreated(): Boolean = sVpm != null || sVsm != null + fun isCarPropertyManagerReady(): Boolean = sCarPropertyManager != null + fun isCarHvacManagerReady(): Boolean = sCarHvacManager != null + + // ------------------------------------------------------------------------- + // IGNITION_STATE — CarPropertyManager callback (standard AAOS, tous firmwares) + // ------------------------------------------------------------------------- + + /** + * Enregistre un CarPropertyEventCallback sur PROP_IGNITION_STATE via reflection. + * Guarded by [sIgnitionCallbackRegistered] — runs only once. + * Lit l'state courant immediatement after l'registersment : CPM ne notifie que sur changement, + * so if the car is already READY at bind time, no event would arrive without this read. + */ + private fun registerIgnitionPropertyCallback() { + if (sIgnitionCallbackRegistered) return + val cpm = sCarPropertyManager ?: return + try { + val allRegMethods = cpm.javaClass.methods + .filter { it.name == "registerCallback" } + .joinToString(" | ") { m -> + "(${m.parameterTypes.joinToString(",") { it.simpleName }})" + } + AppLogger.i(TAG, " IGNITION: CPM.registerCallback variants: $allRegMethods") + + val registerMethod = cpm.javaClass.methods.firstOrNull { m -> + m.name == "registerCallback" && m.parameterCount == 3 + } ?: run { + AppLogger.w(TAG, " IGNITION: NO 3-param registerCallback! Available: $allRegMethods") + return + } + + val callbackType = registerMethod.parameterTypes[0] + AppLogger.i(TAG, " IGNITION: callbackType=${callbackType.simpleName} isInterface=${callbackType.isInterface}") + + if (!callbackType.isInterface) { + AppLogger.w(TAG, " IGNITION: ${callbackType.name} NOT interface — proxy impossible") + return + } + + val proxy = java.lang.reflect.Proxy.newProxyInstance( + callbackType.classLoader, arrayOf(callbackType) + ) { _, method, args -> + if (method.name == "onChangeEvent" && args != null) { + val cpv = args[0] ?: return@newProxyInstance null + try { + val value = cpv.javaClass.getMethod("getValue").invoke(cpv) as? Int + if (value != null) { + AppLogger.i(TAG, "IGNITION_STATE event → $value (${ignitionStateName(value)})") + dispatchIgnitionState(value) + } else { + AppLogger.w(TAG, " IGNITION: onChangeEvent getValue() returned null") + } + } catch (e: Exception) { + AppLogger.w(TAG, " IGNITION: onChangeEvent parse error: ${e.message}") + } + } else if (method.name == "onErrorEvent") { + AppLogger.w(TAG, " IGNITION: onErrorEvent args=${args?.joinToString()}") + } + null + } + + sIgnitionCallbackProxy = proxy // strong reference to avoid GC + registerMethod.invoke(cpm, proxy, PROP_IGNITION_STATE, 0f) + sIgnitionCallbackRegistered = true + AppLogger.i(TAG, " IGNITION_STATE callback registered ✓ (propId=0x${PROP_IGNITION_STATE.toString(16)})") + + // Read immediate de l'state courant + Handler(Looper.getMainLooper()).postDelayed({ + val currentState = getCurrentIgnitionState() + AppLogger.i(TAG, " IGNITION: state initial lu = $currentState (${ignitionStateName(currentState)})") + if (currentState > 0) { + dispatchIgnitionState(currentState) + } + }, 300L) + + } catch (e: Exception) { + AppLogger.w(TAG, " IGNITION: registerCallback error: ${e.javaClass.simpleName}: ${e.message}") + } + } + + private fun dispatchIgnitionState(state: Int) { + val toNotify = ignitionCallbacks.toList() + if (toNotify.isEmpty()) return + Handler(Looper.getMainLooper()).post { toNotify.forEach { it(state) } } + } + + private fun ignitionStateName(state: Int) = when (state) { + IgnitionState.ON -> "ON/READY" + IgnitionState.OFF -> "OFF" + IgnitionState.ACC -> "ACC" + IgnitionState.LOCK -> "LOCK" + IgnitionState.START -> "START" + IgnitionState.UNDEFINED -> "UNDEFINED" + else -> "?" + } + + // ------------------------------------------------------------------------- + // Katman5 SWI69/SWI131 — ICarGeneralService via CarAdapterClient (queryClient(0x1)) + // ------------------------------------------------------------------------- + + private data class Swi69Ctx( + val ctx: Context, + val adapterClass: Class<*>, + val generalClientClass: Class<*> + ) + + private fun findSwi69Classes(context: Context): Swi69Ctx? { + for (pkg in listOf(LAUNCHER69_PKG, VEHICLE_SETTING_PKG)) { + try { + val ctx = context.createPackageContext( + pkg, + android.content.Context.CONTEXT_INCLUDE_CODE or android.content.Context.CONTEXT_IGNORE_SECURITY + ) + return Swi69Ctx( + ctx, + ctx.classLoader.loadClass(CAR_ADAPTER_CLASS), + ctx.classLoader.loadClass(CAR_GENERAL_CLIENT_CLASS) + ) + } catch (_: Exception) {} + } + return null + } + + private fun initKatman5Swi69(context: Context) { + if (sVcmCallbackRegistered) return + + val classes = findSwi69Classes(context) ?: run { + AppLogger.w(TAG, " Katman5 SWI69: CarAdapterClient/CarGeneralClient introuvable — retry in 10s") + Handler(Looper.getMainLooper()).postDelayed({ initKatman5Swi69(context.applicationContext) }, 10_000) + return + } + val (_, adapterClass, generalClientClass) = classes + AppLogger.i(TAG, " Katman5 SWI69: classes loaded ✓") + + fun trySetupGeneralClient(): Boolean { + if (sVcmCallbackRegistered) return true + + val adapter = try { + adapterClass.getMethod("getInstance", Context::class.java) + .invoke(null, context.applicationContext) + } catch (_: Exception) { null } ?: return false + + val ibinder = try { + adapterClass.getMethod("queryClient", Int::class.javaPrimitiveType!!) + .invoke(adapter, BIND_CODE_CAR_GENERAL) as? IBinder + } catch (_: Exception) { null } ?: run { + AppLogger.d(TAG, " Katman5 SWI69: queryClient(0x${BIND_CODE_CAR_GENERAL.toString(16)}) → null") + return false + } + + val client = try { + generalClientClass.getConstructor(IBinder::class.java).newInstance(ibinder) + } catch (e: Exception) { + AppLogger.w(TAG, " Katman5 SWI69: CarGeneralClient ctor error: ${e.message}") + return false + } + sCarGeneral = client // kept for A9 screen brightness (setScreenBrightness) + + val registMethod = generalClientClass.methods.firstOrNull { + it.name == "registListener" && it.parameterCount == 1 + } ?: run { + AppLogger.w(TAG, " Katman5 SWI69: registListener not found") + return false + } + + val callbackType = registMethod.parameterTypes[0] + if (!callbackType.isInterface) { + AppLogger.w(TAG, " Katman5 SWI69: callback non-interface — proxy impossible") + return false + } + + // Binder real required pour l'registersment cross-process via AIDL. + // ICarGeneralService est dans un processus distant (com.saicmotor.caradapter) : + // registListener() appelle writeStrongBinder(callback.asBinder()) — un proxy qui + // returns null pour asBinder() transmettrait un binder null au service, qui ne + // would ever call back. So we create a concrete Binder implementing onTransact + // pour le code 0x7 (TRANSACTION_onIgnitionStateChange, identique sur SWI69 et SWI131). + val callbackBinder = object : android.os.Binder() { + override fun onTransact( + code: Int, data: android.os.Parcel, + reply: android.os.Parcel?, flags: Int + ): Boolean { + return when (code) { + 0x7 -> { // TRANSACTION_onIgnitionStateChange + data.enforceInterface("com.saicmotor.carapi.general.ICarGeneralCallback") + val ignition = data.readInt() + AppLogger.i(TAG, " Katman5 SWI69: onTransact ignition=$ignition (${carIgnitionName(ignition)})") + dispatchVehicleConditionIgnition(ignition) + reply?.writeNoException() + true + } + else -> super.onTransact(code, data, reply, flags) + } + } + } + + val proxy = try { + java.lang.reflect.Proxy.newProxyInstance( + callbackType.classLoader, arrayOf(callbackType) + ) { _, method, args -> + when (method.name) { + "onIgnitionStateChange" -> { + // Chemin in-process (rare) — le service appelle directement l'interface + val ignition = args?.get(0) as? Int + if (ignition != null) { + AppLogger.i(TAG, " Katman5 SWI69: ignition=$ignition (${carIgnitionName(ignition)})") + dispatchVehicleConditionIgnition(ignition) + } + } + "asBinder" -> return@newProxyInstance callbackBinder + } + null + } + } catch (e: Exception) { + AppLogger.w(TAG, " Katman5 SWI69: proxy creation error: ${e.message}") + return false + } + + return try { + registMethod.invoke(client, proxy) + sVcmListener = callbackBinder // strong reference to the Binder to avoid GC + sVcmCallbackRegistered = true + AppLogger.i(TAG, " Katman5 SWI69: ICarGeneralCallback registered ✓") + + val toNotify = katman5ReadyListeners.toList() + katman5ReadyListeners.clear() + Handler(Looper.getMainLooper()).post { toNotify.forEach { it() } } + + Handler(Looper.getMainLooper()).postDelayed({ + try { + val ignition = generalClientClass.getMethod("getIgnitionState").invoke(client) as? Int + if (ignition != null) { + AppLogger.i(TAG, " Katman5 SWI69: state initial ignition=$ignition") + dispatchVehicleConditionIgnition(ignition) + } + } catch (e: Exception) { + AppLogger.d(TAG, " Katman5 SWI69: getIgnitionState error: ${e.message}") + } + }, 500L) + + true + } catch (e: Exception) { + AppLogger.w(TAG, " Katman5 SWI69: registListener error: ${e.message}") + false + } + } + + if (!trySetupGeneralClient()) { + val h = Handler(Looper.getMainLooper()) + listOf(2_000L, 5_000L, 10_000L, 20_000L, 30_000L, 60_000L).forEach { delay -> + h.postDelayed({ if (!sVcmCallbackRegistered) trySetupGeneralClient() }, delay) + } + } + } + + // ------------------------------------------------------------------------- + // Katman5 — VehicleConditionManager (IVehicleConditionService via IHubService) + // SWI133 / SWI68 / SWI165 + // ------------------------------------------------------------------------- + + private fun initKatman5(context: Context) { + if (sVcmCallbackRegistered) return + + val launcherCtx = listOf(LAUNCHER68_PKG, LAUNCHER69_PKG).firstNotNullOfOrNull { pkg -> + try { + context.createPackageContext( + pkg, + android.content.Context.CONTEXT_INCLUDE_CODE or android.content.Context.CONTEXT_IGNORE_SECURITY + ) + } catch (_: Exception) { null } + } ?: run { + AppLogger.w(TAG, " Katman5: launcher package introuvable") + return + } + + val vcmClass = try { + launcherCtx.classLoader.loadClass(VCM_CLASS) + } catch (e: Exception) { + AppLogger.w(TAG, " Katman5: VCM class not found: ${e.message} — retry in 10s") + Handler(Looper.getMainLooper()).postDelayed({ initKatman5(context.applicationContext) }, 10_000) + return + } + + // Tentative 1 : singleton already initialisesd par le launcher + val existing = try { + val f = vcmClass.getDeclaredField("sVehicleConditionManager") + f.isAccessible = true + f.get(null) + } catch (_: Exception) { null } + + if (existing != null) { + AppLogger.i(TAG, " Katman5: singleton VCM already existant ✓") + sVcm = existing + setupVcmCallback(existing, launcherCtx) + return + } + + // Tentative 2 : appeler init(Context, IVehicleServiceListener) + val initMethod = vcmClass.methods.firstOrNull { m -> + m.name == "init" && m.parameterCount == 2 && + Context::class.java.isAssignableFrom(m.parameterTypes[0]) + } + + if (initMethod != null) { + val listenerType = initMethod.parameterTypes[1] + val listenerArg: Any? = if (listenerType.isInterface) { + try { + java.lang.reflect.Proxy.newProxyInstance( + listenerType.classLoader, arrayOf(listenerType) + ) { _, method, args -> + when (method.name) { + "onServiceConnected" -> { + AppLogger.i(TAG, " Katman5: onServiceConnected ✓") + val mgr = args?.getOrNull(0) + ?.takeIf { it.javaClass.name.contains("VehicleConditionManager") } + val instance = mgr ?: try { + val f = vcmClass.getDeclaredField("sVehicleConditionManager") + f.isAccessible = true + f.get(null) + } catch (_: Exception) { null } + if (instance != null) { + sVcm = instance + setupVcmCallback(instance, launcherCtx) + } + } + "onServiceDisconnected" -> { + AppLogger.w(TAG, " Katman5: onServiceDisconnected") + sVcmCallbackRegistered = false + sVcmListener = null + } + } + null + } + } catch (e: Exception) { + AppLogger.d(TAG, " Katman5: proxy init error: ${e.message}") + null + } + } else null + + try { + initMethod.invoke(null, context.applicationContext, listenerArg) + AppLogger.i(TAG, " Katman5: VehicleConditionManager.init() called") + } catch (e: Exception) { + AppLogger.w(TAG, " Katman5: init() error: ${e.message}") + } + } else { + AppLogger.w(TAG, " Katman5: init(Context, listener) not found") + } + + // Retries — singleton disponible after connection asynchrone + val h = Handler(Looper.getMainLooper()) + listOf(2_000L, 5_000L, 10_000L, 20_000L, 30_000L, 60_000L).forEach { delay -> + h.postDelayed({ + if (!sVcmCallbackRegistered) { + val mgr = try { + val f = vcmClass.getDeclaredField("sVehicleConditionManager") + f.isAccessible = true + f.get(null) + } catch (_: Exception) { null } + if (mgr != null && sVcm == null) { + AppLogger.i(TAG, " Katman5: singleton obtained @${delay}ms") + sVcm = mgr + setupVcmCallback(mgr, launcherCtx) + } else if (sVcm != null && !sVcmCallbackRegistered) { + setupVcmCallback(sVcm!!, launcherCtx) + } + } + }, delay) + } + } + + private fun setupVcmCallback(vcm: Any, launcherCtx: Context) { + if (sVcmCallbackRegistered) return + + val registerMethod = vcm.javaClass.methods.firstOrNull { m -> + m.name == "registerVehicleConditionCallback" && m.parameterCount == 1 + } ?: vcm.javaClass.methods.firstOrNull { m -> + m.name.startsWith("register") && m.parameterCount == 1 && + m.parameterTypes[0].isInterface && + m.parameterTypes[0].methods.any { it.name.contains("ConditionChange", ignoreCase = true) } + } ?: run { + AppLogger.w(TAG, " Katman5: registerVehicleConditionCallback not found — methods: ${ + vcm.javaClass.methods.filter { it.name.startsWith("register") }.joinToString { it.name } + }") + return + } + + val callbackType = registerMethod.parameterTypes[0] + AppLogger.i(TAG, " Katman5: callback type = ${callbackType.name}") + + val proxy = try { + java.lang.reflect.Proxy.newProxyInstance( + callbackType.classLoader, arrayOf(callbackType) + ) { _, method, args -> + if (method.name.contains("ChangeEvent", ignoreCase = true) && args != null) { + val bean = args[0] ?: return@newProxyInstance null + try { + val ignition = bean.javaClass.getMethod("getVehicleIgnition").invoke(bean) as? Int + if (ignition != null) { + // The callback repeats at about 10 Hz whether or not anything + // moved. Only the transitions are logged; every event is still + // dispatched, because suppressing one would change behaviour. + if (sIgnitionLog.accept(ignition)) { + AppLogger.i(TAG, " Katman5 event: ignition=$ignition (${carIgnitionName(ignition)})") + } + dispatchVehicleConditionIgnition(ignition) + } + } catch (e: Exception) { + AppLogger.w(TAG, " Katman5: onChangeEvent parse error: ${e.message}") + } + } + null + } + } catch (e: Exception) { + AppLogger.w(TAG, " Katman5: proxy creation error: ${e.message}") + return + } + + try { + registerMethod.invoke(vcm, proxy) + sVcmListener = proxy + sVcmCallbackRegistered = true + AppLogger.i(TAG, " Katman5: callback registered ✓") + + val toNotify = katman5ReadyListeners.toList() + katman5ReadyListeners.clear() + Handler(Looper.getMainLooper()).post { toNotify.forEach { it() } } + + // Immediate read (the callback only fires on CHANGE) + Handler(Looper.getMainLooper()).postDelayed({ + val ignition = try { + vcm.javaClass.getMethod("getVehicleIgnition").invoke(vcm) as? Int + } catch (_: Exception) { null } + if (ignition != null) { + AppLogger.i(TAG, " Katman5: state initial = $ignition (${carIgnitionName(ignition)})") + dispatchVehicleConditionIgnition(ignition) + } + }, 500L) + + } catch (e: Exception) { + AppLogger.w(TAG, " Katman5: registerVehicleConditionCallback error: ${e.message}") + } + } + + private fun dispatchVehicleConditionIgnition(state: Int) { + // Only dispatch if the state really changes — avoids repeated false RUN events + // that VehicleConditionManager sends on every vehicle condition change + // (changement de rapport D/N/R, etc.) alors que la car est already en RUN. + if (state == sLastVcmIgnitionState) return + sLastVcmIgnitionState = state + val toNotify = vehicleConditionCallbacks.toList() + if (toNotify.isEmpty()) return + Handler(Looper.getMainLooper()).post { toNotify.forEach { it(state) } } + } + + private fun carIgnitionName(state: Int) = when (state) { + CarIgnitionItem.OFF -> "OFF" + CarIgnitionItem.ACCESSORY -> "ACC" + CarIgnitionItem.RUN -> "RUN/READY" + CarIgnitionItem.CRANK -> "CRANK" + else -> "?(${state})" + } + + // ------------------------------------------------------------------------- + // Listener management + // ------------------------------------------------------------------------- + + fun setDriveModeListener(listener: DriveModeListener?) { sDriveModeListener = listener } + fun setHvacListener(listener: HvacListener?) { sHvacListener = listener } + + // ------------------------------------------------------------------------- + // Diagnostic + // ------------------------------------------------------------------------- + + /** Returns true si le binder IVehicleSettingService est disponible. */ + fun isVehicleBinderAvailable(): Boolean = sVehicleBinder != null + + /** + * Generates a full diagnostic report : + * state des services, tests binder SWI132 en temps real, dump AppLogger. + * Call on Dispatchers.IO (binder TX are blocking). + */ + fun buildDiagnosticReport(appVersion: String): String { + val sb = StringBuilder() + val gen = FirmwareInfo.getGeneration() + val sdf = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()) + val now = sdf.format(java.util.Date()) + + sb.appendLine("══ EVProfile — Diagnostic ══") + sb.appendLine("Generated : $now") + sb.appendLine("App : v$appVersion") + sb.appendLine("Firmware : ${gen.name}") + sb.appendLine() + + sb.appendLine("── Services ──") + sb.appendLine("Katman1 CPM : ${if (sCarPropertyManager != null) "✓" else "✗"}") + sb.appendLine("Katman1 HVAC : ${if (sCarHvacManager != null) "✓" else "✗"}") + sb.appendLine("Katman4 created : ${if (isKatman4VpmCreated()) "✓" else "✗"}") + sb.appendLine("Katman4 ready : ${if (isKatman4Ready()) "✓" else "✗"}") + sb.appendLine("Binder vsett : ${if (sVehicleBinder != null) "✓ OK" else "✗ null"}") + val katman5Path = if (FirmwareInfo.isNewGenVsm() || gen == FirmwareInfo.Gen.SWI132) + "ICarGeneralService" else "VehicleConditionMgr" + sb.appendLine("Katman5 ready : ${if (sVcmCallbackRegistered) "✓ ($katman5Path)" else "✗ ($katman5Path)"}") + sb.appendLine("Katman5 ign : ${carIgnitionName(sLastVcmIgnitionState)} ($sLastVcmIgnitionState)") + sb.appendLine() + + if (gen == FirmwareInfo.Gen.SWI132) { + + // ── Binder IVehicleSettingService ───────────────────────────── + sb.appendLine("── SWI132 Binder (IVehicleSettingService) ──") + val binderOk = sVehicleBinder != null + sb.appendLine("Binder present : ${if (binderOk) "✓" else "✗ null → all alerts KO"}") + if (binderOk) { + val alive = try { sVehicleBinder!!.pingBinder() } catch (_: Exception) { false } + sb.appendLine("pingBinder : ${if (alive) "✓ vivant" else "✗ mort"}") + val actualDesc = try { sVehicleBinder!!.interfaceDescriptor ?: "null" } + catch (_: Exception) { "exception" } + sb.appendLine("Descriptor attendu : $DESCRIPTOR_VSM132") + sb.appendLine("Descriptor real : $actualDesc${ + if (actualDesc == DESCRIPTOR_VSM132) " ✓" else " ← MISMATCH !"}") + } + sb.appendLine() + + // ── Alerts — read brute + interprstateion ────────────────── + fun fmtRaw(raw: Int, onVal: Int = 1): String = when { + raw < 0 -> "$raw ← ERREUR (SELinux ? binder mort ?)" + raw == onVal -> "$raw → ON ✓" + raw == 0 -> "$raw → OFF" + else -> "$raw → ?" + } + + sb.appendLine("── SWI132 Alerts (GET) ──") + val rawOverspeed = swi132BinderGet(VSM132_TX_GET_OVERSPEED) + val rawSlif = swi132BinderGet(VSM132_TX_GET_SLIF) + val rawSpeedLimit = swi132BinderGet(VSM132_TX_GET_SPEED_LIMIT) + sb.appendLine("getOverSpeedSoundMode (0x129) : ${fmtRaw(rawOverspeed)}") + sb.appendLine("getSLIFWarningState (0x058) : ${fmtRaw(rawSlif)}") + sb.appendLine("getSpeedLimitSoundMode (0x12b) : ${fmtRaw(rawSpeedLimit)}") + sb.appendLine() + + // ── Alerts — SET round-trip test (writes the current value) ─ + // If the raw value is readable (≥ 0), rewrite the same value to test + // que le SET passe (sans modifier l'state real de la car). + sb.appendLine("── SWI132 Alerts (SET round-trip) ──") + if (rawOverspeed >= 0) { + val setOk = swi132BinderSet(VSM132_TX_OVERSPEED_SOUND, rawOverspeed) + val verify = swi132BinderGet(VSM132_TX_GET_OVERSPEED) + sb.appendLine("setOverSpeedSoundMode (0x128) : ${if (setOk) "✓ written" else "✗ failure"}" + + " → reread : ${fmtRaw(verify)}") + } else { + sb.appendLine("setOverSpeedSoundMode (0x128) : skip (GET KO)") + } + if (rawSpeedLimit >= 0) { + val setOk = swi132BinderSet(VSM132_TX_SPEED_LIMIT, rawSpeedLimit) + val verify = swi132BinderGet(VSM132_TX_GET_SPEED_LIMIT) + sb.appendLine("setSpeedLimitSoundMode (0x12a) : ${if (setOk) "✓ written" else "✗ failure"}" + + " → reread : ${fmtRaw(verify)}") + } else { + sb.appendLine("setSpeedLimitSoundMode (0x12a) : skip (GET KO)") + } + if (rawSlif >= 0) { + val setOk = swi132BinderSet(VSM132_TX_SLIF_WARNING, rawSlif) + val verify = swi132BinderGet(VSM132_TX_GET_SLIF) + sb.appendLine("setSLIFWarningState (0x057) : ${if (setOk) "✓ written" else "✗ failure"}" + + " → reread : ${fmtRaw(verify)}") + } else { + sb.appendLine("setSLIFWarningState (0x057) : skip (GET KO)") + } + sb.appendLine() + + // ── CarVehicleSettingClient (Katman4) ───────────────────────── + sb.appendLine("── SWI132 CarVehicleSettingClient (sVsm=${if (sVsm != null) "✓" else "✗ null"}) ──") + fun vsmGet(method: String): Int = try { + (callVsm(method) as? Int) ?: -1 + } catch (_: Exception) { -1 } + fun fmtVsm(v: Int, ok: String) = if (v >= 0) "$v → $ok" else "-1 ← ERREUR (method absente ou sVsm null)" + + // ACC/TJA + val accTjaRaw = getAccTjaMode() + sb.appendLine("getAccTjaState : ${when (accTjaRaw) { + Swi68Mode.OFF -> "4 → OFF" + Swi68Mode.ACC -> "1 → ACC" + Swi68Mode.TJA -> "2 → TJA" + -1 -> "-1 ← ERREUR" + else -> "$accTjaRaw → ?" + }}") + + // AEB + val fcwRaw = vsmGet("getFcwState") + sb.appendLine("getFcwState : ${when (fcwRaw) { + 2 -> "2 → AEB ON" ; 1 -> "1 → AEB OFF" ; -1 -> "-1 ← ERREUR" ; else -> "$fcwRaw → ?" + }}") + val fcwModeRaw = vsmGet("getFcwAutoBrakeMode") + sb.appendLine("getFcwAutoBreak : ${when (fcwModeRaw) { + 1 -> "1 → Alert" ; 2 -> "2 → Al.+Frein" ; -1 -> "-1 ← ERREUR" ; else -> "$fcwModeRaw → ?" + }}") + val fcwSenRaw = vsmGet("getFcwSensitivity") + sb.appendLine("getFcwSensitiv. : ${when (fcwSenRaw) { + 1 -> "1 → Low" ; 2 -> "2 → Standard" ; 3 -> "3 → High" ; -1 -> "-1 ← ERREUR" ; else -> "$fcwSenRaw → ?" + }}") + + // ELK + val lasRaw = vsmGet("getLasMode") + sb.appendLine("getLasMode (ELK) : ${when (lasRaw) { + 1 -> "1 → OFF" ; 2 -> "2 → Alert" ; 3 -> "3 → Assist" ; 5 -> "5 → Urgence" + -1 -> "-1 ← ERREUR" ; else -> "$lasRaw → ?" + }}") + + // ── Alerts via the SWI132 VSM runtime path ────────────────────── + sb.appendLine() + sb.appendLine("── SWI132 Alerts via VSM (ICarVehicleSettingService) ──") + val vsmOverspeed = vsmGet("getOverSpeedSoundMode") + sb.appendLine("getOverSpeedSoundMode : ${when { + vsmOverspeed < 0 -> "-1 ← ERREUR (method absente ?)" + vsmOverspeed == 0 -> "0 → OFF" + else -> "$vsmOverspeed → ON" + }}") + val vsmSpeedLimit = vsmGet("getSpeedLimitSoundMode") + sb.appendLine("getSpeedLimitSoundMode : ${when { + vsmSpeedLimit < 0 -> "-1 ← ERREUR (method absente ?)" + vsmSpeedLimit == 0 -> "0 → OFF" + else -> "$vsmSpeedLimit → ON" + }}") + + // TSR / SLIF + val vsmSlif = vsmGet("getSLIFWarningState") + sb.appendLine("getSLIFWarningState : ${when { + vsmSlif < 0 -> "-1 ← ERREUR (method absente ?)" + vsmSlif == 0 -> "0 → TSR ON (convention SWI69)" + vsmSlif == 1 -> "1 → TSR OFF (convention SWI69)" + else -> "$vsmSlif → ?" + }}") + + // Son d'alert de voie (LAS) + val vsmLasSound = vsmGet("getLasWarningSound") + sb.appendLine("getLasWarningSound : ${when { + vsmLasSound < 0 -> "-1 ← ERREUR (method absente ?)" + vsmLasSound == 2 -> "2 → ON" + vsmLasSound == 1 -> "1 → OFF" + else -> "$vsmLasSound → ?" + }}") + + // SET round-trip test via VSM (without changing real state: rewrites the current value) + sb.appendLine() + sb.appendLine("── SWI132 SET round-trip via VSM ──") + if (vsmOverspeed >= 0) { + val setOk = callVsmVoid("setOverSpeedSoundMode", vsmOverspeed) + val verify = vsmGet("getOverSpeedSoundMode") + sb.appendLine("setOverSpeedSoundMode : ${if (setOk) "✓" else "✗"} → reread : $verify${ + if (setOk && verify == vsmOverspeed) " ✓ consistent" else if (setOk) " ← value changed !" else ""}") + } else { + sb.appendLine("setOverSpeedSoundMode : skip (GET KO)") + } + if (vsmSpeedLimit >= 0) { + val setOk = callVsmVoid("setSpeedLimitSoundMode", vsmSpeedLimit) + val verify = vsmGet("getSpeedLimitSoundMode") + sb.appendLine("setSpeedLimitSoundMode : ${if (setOk) "✓" else "✗"} → reread : $verify${ + if (setOk && verify == vsmSpeedLimit) " ✓ consistent" else if (setOk) " ← value changed !" else ""}") + } else { + sb.appendLine("setSpeedLimitSoundMode : skip (GET KO)") + } + if (vsmSlif >= 0) { + val setOk = callVsmVoid("setSLIFWarningState", vsmSlif) + val verify = vsmGet("getSLIFWarningState") + sb.appendLine("setSLIFWarningState : ${if (setOk) "✓" else "✗"} → reread : $verify${ + if (setOk && verify == vsmSlif) " ✓ consistent" else if (setOk) " ← value changed !" else ""}") + } else { + sb.appendLine("setSLIFWarningState : skip (GET KO)") + } + + // Energy saving + val enduranceRaw = vsmGet("getEnduranceMode") + sb.appendLine() + sb.appendLine("getEnduranceMode : ${when (enduranceRaw) { + 1 -> "1 → Eco ON" ; 0 -> "0 → Eco OFF" ; -1 -> "-1 ← ERREUR" ; else -> "$enduranceRaw → ?" + }}") + sb.appendLine() + } + + sb.appendLine("── AppLogger (${AppLogger.entries.size} entries) ──") + AppLogger.entries.forEach { e -> + sb.appendLine("[${e.time}] ${e.tag}: ${e.msg}") + } + + return sb.toString() + } + + // ── Contrôle audio (CarAdapterService vendor SAIC) — A9 only ────────── + // + // Le service vendor `com.saicmotor.caradapter` (descripteur ICarAudioService) + // n'existe QUE sur la famille A9 (SWI69/131/132). Sur old-SDK (SWI133/68/165) + // il est absent → on ne tente same pas le bind (cf. hasAudioControl / initAudio). + // Codes de transaction verifieds identiques sur les 3 A9 (ICarAudioService$Stub). + + /** A9 (SWI69/131/132) : loudness via le service vendor caradapter (ICarAudioService). */ + private fun isA9Sound(): Boolean = + FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132 + + /** Ancien SDK (SWI133/68/165) : loudness via SmartSoundManager (SDK systemsettings). */ + private fun isOldSdkSound(): Boolean { + val gen = FirmwareInfo.getGeneration() + return gen == FirmwareInfo.Gen.SWI133 || gen == FirmwareInfo.Gen.SWI68 || gen == FirmwareInfo.Gen.SWI165 + } + + /** Audio tab (volume drop on door opening): only where functional. */ + fun hasAudioControl(): Boolean = hasDoorVolumeFeature() + + private const val DESCRIPTOR_CARADAPTER = "com.saicmotor.carapi.ICarAdapterService" + private const val TX_QUERY_AUDIO_CLIENT = 1 + private const val HELPER_AUDIO_CODE = 10 + + private const val AUDIO_SET_FADER_FRONT = 12 + private const val AUDIO_SET_BALANCE_RIGHT = 13 + private const val AUDIO_SET_SPEED_VOL = 17 + private const val AUDIO_GET_SPEED_VOL = 18 + private const val AUDIO_SET_3D_EFFECT = 26 + private const val AUDIO_GET_3D_EFFECT = 27 + private const val AUDIO_SET_SOUND_FIELD = 30 + private const val AUDIO_GET_BALANCE = 31 + private const val AUDIO_GET_FADER = 32 + private const val AUDIO_SET_BOSE_SOUND = 36 + private const val AUDIO_GET_BOSE_SOUND = 37 + private const val AUDIO_SET_TONE = 40 + private const val AUDIO_GET_TONE = 41 + + @Volatile private var sCarAdapterBinder: IBinder? = null + @Volatile private var sAudioHelper: IBinder? = null + @Volatile private var sAudioDescriptor: String = "" + @Volatile private var sAudioServiceConn: ServiceConnection? = null + + val isAudioAvailable: Boolean get() = sAudioHelper?.isBinderAlive == true + + fun initAudio(context: Context) { + // Le bind caradapter ne concerne que l'A9. Sur old-SDK, le loudness passe par + // SmartSoundManager (initialised in the Katman4 flow), so nothing to bind here. + if (!isA9Sound()) return + if (sAudioHelper?.isBinderAlive == true) return + if (sAudioServiceConn != null) return + val intent = Intent().apply { + setClassName("com.saicmotor.caradapter", "com.saicmotor.caradapter.service.CarAdapterService") + } + val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + sCarAdapterBinder = binder + AppLogger.i(TAG, " Audio: CarAdapterService connected ✓") + CoroutineScope(Dispatchers.IO).launch { tryGetAudioHelper() } + } + override fun onServiceDisconnected(name: ComponentName?) { + sCarAdapterBinder = null; sAudioHelper = null + } + } + sAudioServiceConn = conn + try { + val bound = context.applicationContext.bindService(intent, conn, Context.BIND_AUTO_CREATE) + if (!bound) { + sAudioServiceConn = null + Handler(Looper.getMainLooper()).postDelayed({ if (sAudioHelper == null) initAudio(context.applicationContext) }, 10_000L) + } + } catch (e: Exception) { sAudioServiceConn = null; AppLogger.w(TAG, " Audio: bindService error: ${e.message}") } + } + + private fun tryGetAudioHelper() { + val svc = sCarAdapterBinder ?: return + val data = Parcel.obtain(); val reply = Parcel.obtain() + try { + data.writeInterfaceToken(DESCRIPTOR_CARADAPTER) + data.writeInt(HELPER_AUDIO_CODE) + if (svc.transact(TX_QUERY_AUDIO_CLIENT, data, reply, 0)) { + reply.readException() + val helper = reply.readStrongBinder() + if (helper != null && helper.isBinderAlive) { + sAudioHelper = helper; sAudioDescriptor = helper.interfaceDescriptor ?: "" + AppLogger.i(TAG, " Audio: helper OK descriptor='$sAudioDescriptor'") + } else { + Handler(Looper.getMainLooper()).postDelayed({ CoroutineScope(Dispatchers.IO).launch { tryGetAudioHelper() } }, 5_000) + } + } + } finally { data.recycle(); reply.recycle() } + } + + private fun audioGet(txCode: Int): Int { + val h = sAudioHelper ?: return -1 + if (!h.isBinderAlive) { sAudioHelper = null; return -1 } + val data = Parcel.obtain(); val reply = Parcel.obtain() + return try { + data.writeInterfaceToken(sAudioDescriptor) + if (h.transact(txCode, data, reply, 0)) { reply.readException(); reply.readInt() } else -1 + } catch (_: Exception) { -1 } finally { data.recycle(); reply.recycle() } + } + + private fun audioSet(txCode: Int, value: Int): Boolean { + val h = sAudioHelper ?: return false + if (!h.isBinderAlive) { sAudioHelper = null; return false } + val data = Parcel.obtain(); val reply = Parcel.obtain() + return try { + data.writeInterfaceToken(sAudioDescriptor) + data.writeInt(value) + h.transact(txCode, data, reply, 0).also { if (it) reply.readException() } + } catch (_: Exception) { false } finally { data.recycle(); reply.recycle() } + } + + private const val AUDIO_TYPE_MIN = 0 + private const val AUDIO_TYPE_MAX = 3 + private const val AUDIO_LEVEL_MIN = -9 + private const val AUDIO_LEVEL_MAX = 9 + + fun getBoseSoundType(): Int = audioGet(AUDIO_GET_BOSE_SOUND) + fun setBoseSoundType(t: Int): Boolean = audioSet(AUDIO_SET_BOSE_SOUND, t.coerceIn(AUDIO_TYPE_MIN, AUDIO_TYPE_MAX)) + fun getAudioBalance(): Int = audioGet(AUDIO_GET_BALANCE) + fun setAudioBalance(v: Int): Boolean = audioSet(AUDIO_SET_BALANCE_RIGHT, v.coerceIn(AUDIO_LEVEL_MIN, AUDIO_LEVEL_MAX)) + fun getAudioFader(): Int = audioGet(AUDIO_GET_FADER) + fun setAudioFader(v: Int): Boolean = audioSet(AUDIO_SET_FADER_FRONT, v.coerceIn(AUDIO_LEVEL_MIN, AUDIO_LEVEL_MAX)) + // ── Media volume — VOL_TYPE_MEDIA=0 ──────────────────────────────────────── + // Routage par firmware : old-SDK (133/68/165) → SmartSoundManager (reflection) ; + // A9 (69/131/132) → ICarAudioService via binder caradapter (tx getMax=0x5 / getVol=0x6 / setVol=0x7). + private const val VOL_TYPE_MEDIA = 0 + private const val AUDIO_GET_MAX_VOL = 0x5 + private const val AUDIO_GET_VOLUME = 0x6 + private const val AUDIO_SET_VOLUME = 0x7 + + private const val VOL_TAG = "EV_VOL" + + /** Max du volume media (borne le slider). -1 si unavailable. */ + fun getMediaVolumeMax(): Int { + val v = when { + isOldSdkSound() -> smartSoundGetInt("getMaxVolume", VOL_TYPE_MEDIA) + isA9Sound() -> audioGetArg(AUDIO_GET_MAX_VOL, VOL_TYPE_MEDIA) + else -> -1 + } + AppLogger.i(VOL_TAG, "getMediaVolumeMax = $v [oldSdk=${isOldSdkSound()} a9=${isA9Sound()} smartSound=${sSmartSound != null} audioHelper=${sAudioHelper != null}]") + logMediaVolumeDiag() // A9 : compare type-0 vs group-id (no-op ailleurs) + return v + } + + /** Media volume courant. -1 si unavailable. */ + fun getMediaVolume(): Int { + val v = when { + isOldSdkSound() -> smartSoundGetInt("getVolume", VOL_TYPE_MEDIA) + isA9Sound() -> audioGetArg(AUDIO_GET_VOLUME, VOL_TYPE_MEDIA) + else -> -1 + } + AppLogger.i(VOL_TAG, "getMediaVolume = $v") + return v + } + + /** Fixe le volume media. setVolume(type, niveau, flags=0). */ + fun setMediaVolume(level: Int): Boolean { + val ok = when { + isOldSdkSound() -> smartSoundSetVolume(level) + isA9Sound() -> audioSet3(AUDIO_SET_VOLUME, VOL_TYPE_MEDIA, level, 0) + else -> false + } + AppLogger.i(VOL_TAG, "setMediaVolume($level) = $ok") + return ok + } + + /** + * Moves the media volume by [delta] steps, clamped to the vehicle's own range. + * + * A relative step rather than a target, for the case a target cannot serve: a wheel button + * or a rule that says "one quieter" does not know what the volume is, and reading it first + * from the caller would race the driver's own thumb. + * + * Falls back to `AudioManager` when the vendor level is unreadable — the volume the driver + * hears is the platform's either way, and a step nobody can measure is still a step. Being + * already at an end is reported as success: nothing had to change, and calling that a + * failure would fill a rule's history with refusals for working as asked. + */ + fun adjustMediaVolume(delta: Int): Boolean { + val current = getMediaVolume() + val max = getMediaVolumeMax() + if (current >= 0 && max > 0) { + val target = (current + delta).coerceIn(0, max) + if (target == current) { + AppLogger.i(VOL_TAG, "adjustMediaVolume($delta): already at the end ($current/$max)") + return true + } + AppLogger.i(VOL_TAG, "adjustMediaVolume($delta): $current -> $target (max $max)") + return setMediaVolume(target) + } + val audio = sAppContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audio == null) { + AppLogger.w(VOL_TAG, "adjustMediaVolume($delta): no vendor level and no AudioManager") + return false + } + return try { + repeat(kotlin.math.abs(delta)) { + audio.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + if (delta > 0) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER, + AudioManager.FLAG_SHOW_UI + ) + } + AppLogger.i(VOL_TAG, "adjustMediaVolume($delta) via AudioManager (vendor level unreadable)") + true + } catch (e: Exception) { + AppLogger.w(VOL_TAG, "adjustMediaVolume($delta): ${(e.cause ?: e).message}") + false + } + } + + // old-SDK : SmartSoundManager (reflection) + private fun smartSoundGetInt(method: String, arg: Int): Int { + val m = sSmartSound ?: return -1 + return try { m.javaClass.getMethod(method, Int::class.javaPrimitiveType).invoke(m, arg) as? Int ?: -1 } + catch (_: Exception) { -1 } + } + private fun smartSoundSetVolume(level: Int): Boolean { + val m = sSmartSound ?: return false + return try { + m.javaClass.getMethod("setVolume", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + .invoke(m, VOL_TYPE_MEDIA, level, 0); true + } catch (e: Exception) { AppLogger.w(TAG, "setMediaVolume oldSdk exc: ${e.message}"); false } + } + + // A9 : ICarAudioService via binder. getVolume(usage)/getMaxVolume(usage) = 1 arg ; setVolume(usage,val,flags) = 3 args. + private fun audioGetArg(txCode: Int, arg: Int): Int { + val h = sAudioHelper ?: return -1 + if (!h.isBinderAlive) { sAudioHelper = null; return -1 } + val data = Parcel.obtain(); val reply = Parcel.obtain() + return try { + data.writeInterfaceToken(sAudioDescriptor); data.writeInt(arg) + if (h.transact(txCode, data, reply, 0)) { reply.readException(); reply.readInt() } else -1 + } catch (_: Exception) { -1 } finally { data.recycle(); reply.recycle() } + } + private fun audioSet3(txCode: Int, a: Int, b: Int, c: Int): Boolean { + val h = sAudioHelper ?: return false + if (!h.isBinderAlive) { sAudioHelper = null; return false } + val data = Parcel.obtain(); val reply = Parcel.obtain() + return try { + data.writeInterfaceToken(sAudioDescriptor); data.writeInt(a); data.writeInt(b); data.writeInt(c) + h.transact(txCode, data, reply, 0).also { if (it) reply.readException() } + } catch (_: Exception) { false } finally { data.recycle(); reply.recycle() } + } + + // Diagnostic A9 : le param de getVolume/getMaxVolume est-il un "type" (0=media) ou un + // "group id" (AAOS) ? On logge les deux pour comparer au max real de la car. + private const val AUDIO_GET_GROUP_FOR_USAGE = 0xe // getVolumeGroupIdForUsage(usage) + private const val USAGE_MEDIA_AAOS = 1 // AudioAttributes.USAGE_MEDIA + fun logMediaVolumeDiag() { + if (!isA9Sound()) return + val maxT = audioGetArg(AUDIO_GET_MAX_VOL, VOL_TYPE_MEDIA) + val volT = audioGetArg(AUDIO_GET_VOLUME, VOL_TYPE_MEDIA) + val grp = audioGetArg(AUDIO_GET_GROUP_FOR_USAGE, USAGE_MEDIA_AAOS) + val maxG = if (grp in 0..64) audioGetArg(AUDIO_GET_MAX_VOL, grp) else -1 + val volG = if (grp in 0..64) audioGetArg(AUDIO_GET_VOLUME, grp) else -1 + AppLogger.i(VOL_TAG, "A9 diag: parType0[max=$maxT vol=$volT] groupForUsage(MEDIA)=$grp parGroup[max=$maxG vol=$volG]") + } + + // ── Volume drop when a front door opens (v1: SWI133) ────────── + // Detection via the AOSP Car API **CarPropertyManager** (service "property") + permission + // CAR_VENDOR_EXTENSION (already declared). FRONT door IDs confirmed by a user : + // FL/FR "ratio" (taux d'openure, >0 = opene) + "mode". Le SDK SAIC (getIntProperty) + // did not read them. Poll ~700ms, change-only (log EV_DOOR), drop on opening. + // Pas de restauration. Connexion Car async (createCar + ServiceConnection). + + private const val DOORWATCH_TAG = "EV_DOOR" + // Signal d'openure confirmed sur SWI133 : DLOCK_DOOR_OPEN_STS (property de zone PORTE), + // areaId 0x1 = door AV-gauche, 0x4 = AV-droite ; value 1 = opene, 0 = closede. + private const val DOOR_OPEN_PROP = 0x2640c623 + private val DOOR_FRONT_AREAS = intArrayOf(0x1, 0x4) + private val sDoorReadLast = HashMap() + @Volatile private var sDoorSubProperty = false // path A: property.registerListener attached + @Volatile private var sDoorSubDoorlock = false // path B: doorlock.registerCallback attached + @Volatile private var sDoorWatcherOn = false + @Volatile private var sCarInstance: Any? = null + @Volatile private var sCarPropMgr: Any? = null // CarPropertyManager (service "property") + @Volatile private var sCarDoorMgr: Any? = null // CarDoorLockManager (service "doorlock") + @Volatile private var sDoorConnecting = false + @Volatile private var sAnyFrontOpenPrev = false + @Volatile private var sVolumeBeforeDrop = -1 // volume saved at opening (for restore) + + /** Volume drop on door opening: DLOCK_DOOR_OPEN_STS detection via CarPropertyManager. + * Readable/functional only on SWI132 and SWI133; elsewhere the prop is not exposed to the app. */ + fun hasDoorVolumeFeature(): Boolean { + val gen = FirmwareInfo.getGeneration() + return gen == FirmwareInfo.Gen.SWI132 || gen == FirmwareInfo.Gen.SWI133 + } + + private fun doorVolumeEnabled(): Boolean = + sAppContext?.getSharedPreferences("ev_settings", 0)?.getBoolean("door_volume_enabled", false) ?: false + + private fun doorVolumeLevel(): Int = + sAppContext?.getSharedPreferences("ev_settings", 0)?.getInt("door_volume_level", 0) ?: 0 + + private fun doorRestoreEnabled(): Boolean = + sAppContext?.getSharedPreferences("ev_settings", 0)?.getBoolean("door_volume_restore", false) ?: false + + /** areaIds des doors choisies par l'utilisateur (gauche=0x1, droite=0x4). */ + private fun doorTriggerAreas(): IntArray { + val p = sAppContext?.getSharedPreferences("ev_settings", 0) ?: return DOOR_FRONT_AREAS + val list = ArrayList(2) + if (p.getBoolean("door_volume_left", true)) list += 0x1 + if (p.getBoolean("door_volume_right", true)) list += 0x4 + return list.toIntArray() + } + + /** Cranking auto au boot (called par init) : ne lance le watcher que si la feature est enabled. */ + fun startDoorWatcherIfEnabled() { + if (hasDoorVolumeFeature() && doorVolumeEnabled()) startDoorVolumeWatcher() + } + + fun startDoorVolumeWatcher() { + if (!hasDoorVolumeFeature()) return + sDoorWatcherOn = true + connectCarProperty() + } + + fun stopDoorVolumeWatcher() { + sDoorWatcherOn = false // poll kept; the drop is no longer triggered + AppLogger.i(TAG, " DoorVolumeWatcher: trigger disabled") + } + + /** + * True while either front door is open, null when neither area answers. + * + * Reads the same property the volume-drop watcher polls, and needs no watcher running: + * [init] connects the Car API on any firmware carrying the door feature, whether or not + * the volume drop itself is enabled. Null rather than false where nothing answers — a + * rule must not read "unreadable" as "shut". + */ + fun frontDoorOpenOrNull(): Boolean? { + if (!hasDoorVolumeFeature()) return null + var answered = false + var open = false + for (area in DOOR_FRONT_AREAS) { + val value = readDoorOpen(area) ?: continue + answered = true + if (value == 1) open = true + } + return if (answered) open else null + } + + /** Diagnostic button probe: logs the volume + door states at click time. */ + fun runDoorVolumeDiag() { + AppLogger.i(VOL_TAG, "── DIAG (bouton Diagnostic) ──") + getMediaVolumeMax() + getMediaVolume() + connectCarProperty() // idempotent ; normalement already connected depuis l'init + registerDoorCallback() // retries the subscription if not attached yet + probeDoorSnapshot() + } + + private fun probeDoorSnapshot() { + if (sCarPropMgr == null && sCarDoorMgr == null) { + AppLogger.w(DOORWATCH_TAG, "DIAG door: aucun manager Car (createCar failure ?)") + return + } + AppLogger.i(DOORWATCH_TAG, "DIAG door: property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null}") + for (area in DOOR_FRONT_AREAS) { + val v = readDoorOpen(area) + AppLogger.i(DOORWATCH_TAG, "DIAG area=0x${area.toString(16)} = ${v ?: "illisible"}") + } + AppLogger.i(DOORWATCH_TAG, "DIAG souscription: property=$sDoorSubProperty doorlock=$sDoorSubDoorlock state=" + + if (sDoorReadLast.isEmpty()) "(no event received)" + else sDoorReadLast.entries.joinToString { "0x${it.key.toString(16)}=${it.value}" }) + } + + /** Connection (async) to the AOSP Car API → CarPropertyManager ("property") AND CarDoorLockManager + * ("doorlock"). Depending on the firmware, the door is exposed by one or the other → read via both. */ + private fun connectCarProperty() { + if (sCarPropMgr != null || sCarDoorMgr != null || sDoorConnecting) return + val ctx = sAppContext ?: return + sDoorConnecting = true + try { + val carCls = ctx.classLoader.loadClass("android.car.Car") + val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + try { + val car = sCarInstance ?: return + val getMgr = carCls.getMethod("getCarManager", String::class.java) + sCarPropMgr = try { getMgr.invoke(car, "property") } catch (_: Exception) { null } + sCarDoorMgr = try { getMgr.invoke(car, "doorlock") } catch (_: Exception) { null } + AppLogger.i(DOORWATCH_TAG, "managers: property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null}") + if (sCarPropMgr != null || sCarDoorMgr != null) startDoorPolling() + else AppLogger.w(DOORWATCH_TAG, "aucun manager door disponible") + } catch (e: Exception) { AppLogger.w(DOORWATCH_TAG, "getCarManager failure: ${e.message}") } + } + override fun onServiceDisconnected(name: ComponentName?) { sCarPropMgr = null; sCarDoorMgr = null; sDoorSubProperty = false; sDoorSubDoorlock = false } + } + val car = carCls.getMethod("createCar", Context::class.java, ServiceConnection::class.java) + .invoke(null, ctx, conn) + sCarInstance = car + try { carCls.getMethod("connect").invoke(car) } catch (_: Exception) {} + AppLogger.i(DOORWATCH_TAG, "connection Car (property + doorlock)…") + } catch (e: Exception) { + sDoorConnecting = false + AppLogger.w(DOORWATCH_TAG, "Car createCar failure: ${e.message}") + } + } + + // Lit DLOCK_DOOR_OPEN_STS(areaId) via CarPropertyManager.getIntProperty puis, en fallback, + // via CarDoorLockManager.getProperty(Integer, propId, areaId).getValue(). null si aucun. + private fun readDoorOpen(area: Int): Int? { + sCarPropMgr?.let { m -> + try { + return m.javaClass.getMethod("getIntProperty", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + .invoke(m, DOOR_OPEN_PROP, area) as? Int + } catch (_: Exception) {} + } + sCarDoorMgr?.let { m -> + try { + val cpv = m.javaClass.getMethod("getProperty", Class::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + .invoke(m, Integer::class.java, DOOR_OPEN_PROP, area) ?: return null + return cpv.javaClass.getMethod("getValue").invoke(cpv) as? Int + } catch (_: Exception) {} + } + return null + } + + private fun startDoorPolling() { + registerDoorCallback() // ON_CHANGE subscription (complements the poll) + AppLogger.i(DOORWATCH_TAG, "watcher door actif (property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null})") + val h = Handler(Looper.getMainLooper()) + val poll = object : Runnable { + override fun run() { + if (sCarPropMgr == null && sCarDoorMgr == null) return + // DLOCK_DOOR_OPEN_STS sur les doors avant (0x1 gauche / 0x4 droite). + for (area in DOOR_FRONT_AREAS) { + val v = readDoorOpen(area) ?: continue + onDoorAreaValue(area, v) + } + h.postDelayed(this, 500L) + } + } + h.post(poll) + } + + /** Single entry point for a door value (poll OR subscription event). + * Updates the state, logs change-only, then re-evaluates the volume trigger. */ + @Synchronized + private fun onDoorAreaValue(area: Int, v: Int) { + val prev = sDoorReadLast[area] + if (prev == null || prev != v) { + sDoorReadLast[area] = v + AppLogger.i(DOORWATCH_TAG, "area=0x${area.toString(16)} ${prev ?: "?"} → $v") + } + evaluateDoorTrigger() + } + + /** Recomputes "at least one selected door open" from the accumulated state and applies + * la baisse (front d'openure) / la restauration (front de fermeture). Idempotent. */ + private fun evaluateDoorTrigger() { + if (!(sDoorWatcherOn && doorVolumeEnabled())) return + val triggerAreas = doorTriggerAreas() + val anyOpen = triggerAreas.any { sDoorReadLast[it] == 1 } + if (anyOpen && !sAnyFrontOpenPrev) { + val level = doorVolumeLevel() + CoroutineScope(Dispatchers.IO).launch { + sVolumeBeforeDrop = getMediaVolume() + val ok = setMediaVolume(level) + AppLogger.i(DOORWATCH_TAG, "door opene → vol $sVolumeBeforeDrop→$level = $ok") + } + } else if (!anyOpen && sAnyFrontOpenPrev) { + val restore = sVolumeBeforeDrop + sVolumeBeforeDrop = -1 + if (doorRestoreEnabled() && restore >= 0) { + CoroutineScope(Dispatchers.IO).launch { + val ok = setMediaVolume(restore) + AppLogger.i(DOORWATCH_TAG, "door closede → restauration vol $restore = $ok") + } + } + } + sAnyFrontOpenPrev = anyOpen + } + + /** InvocationHandler commun aux deux interfaces de callback (onChangeEvent/onErrorEvent). + * Filters DLOCK_DOOR_OPEN_STS and feeds onDoorAreaValue. Also handles Object methods. */ + private fun doorEventHandler(src: String) = InvocationHandler { proxy, method, args -> + when (method.name) { + "onChangeEvent" -> { + try { + val cpv = args?.getOrNull(0) + if (cpv != null) { + val pid = cpv.javaClass.getMethod("getPropertyId").invoke(cpv) as? Int + val area = cpv.javaClass.getMethod("getAreaId").invoke(cpv) as? Int ?: 0 + val raw = cpv.javaClass.getMethod("getValue").invoke(cpv) + val v = when (raw) { + is Boolean -> if (raw) 1 else 0 + is Number -> raw.toInt() + else -> null + } + if (pid == DOOR_OPEN_PROP && v != null) { + AppLogger.i(DOORWATCH_TAG, "EVENT[$src] area=0x${area.toString(16)} = $v") + onDoorAreaValue(area, v) + } + } + } catch (e: Exception) { AppLogger.w(DOORWATCH_TAG, "onChangeEvent: ${e.message}") } + null + } + "onErrorEvent" -> { AppLogger.w(DOORWATCH_TAG, "EVENT erreur door"); null } + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.getOrNull(0) + "toString" -> "EVDoorListener@" + Integer.toHexString(System.identityHashCode(proxy)) + else -> null + } + } + + /** + * Subscribes to DLOCK_DOOR_OPEN_STS changes. Needed on SWI69/131/68/165 where the prop + * is NOT readable on demand (getProperty throws IllegalArgumentException "Failed to get value") + * but pushed via ON_CHANGE. IMPORTANT: the Proxy must be defined by the APP classloader + * (android.car est en BootClassLoader → Proxy.newProxyInstance y fails). On tente DEUX voies : + * A) CarPropertyManager.registerListener (service "property") + * B) CarDoorLockManager.registerCallback (service "doorlock", comme la SystemUI d'origine) + */ + private fun registerDoorCallback() { + val cl = sAppContext?.classLoader ?: return + + // Voie A — CarPropertyManager.registerListener(CarPropertyEventListener, propId, rate). + // rate=5f (not 0f): if the VHAL declares the prop CONTINUOUS, rate 0 = no updates. + if (!sDoorSubProperty) sCarPropMgr?.let { m -> + try { + val iface = cl.loadClass("android.car.hardware.property.CarPropertyManager\$CarPropertyEventListener") + val proxy = Proxy.newProxyInstance(cl, arrayOf(iface), doorEventHandler("property")) + val ok = m.javaClass.getMethod("registerListener", iface, + Int::class.javaPrimitiveType, Float::class.javaPrimitiveType) + .invoke(m, proxy, DOOR_OPEN_PROP, 5f) + sDoorSubProperty = true + AppLogger.i(DOORWATCH_TAG, "souscription door (property.registerListener rate=5) OK=$ok") + } catch (e: Exception) { + val c = e.cause ?: e + AppLogger.w(DOORWATCH_TAG, "property.registerListener failure: ${c.javaClass.simpleName}: ${c.message}") + } + } + + // Voie B — CarDoorLockManager.registerCallback(CarDoorLockEventCallback) — voie de l'OEM + if (!sDoorSubDoorlock) sCarDoorMgr?.let { m -> + try { + val iface = cl.loadClass("android.car.hardware.doorlock.CarDoorLockManager\$CarDoorLockEventCallback") + val proxy = Proxy.newProxyInstance(cl, arrayOf(iface), doorEventHandler("doorlock")) + m.javaClass.getMethod("registerCallback", iface).invoke(m, proxy) + sDoorSubDoorlock = true + AppLogger.i(DOORWATCH_TAG, "souscription door (doorlock.registerCallback) OK") + } catch (e: Exception) { + val c = e.cause ?: e + AppLogger.w(DOORWATCH_TAG, "doorlock.registerCallback failure: ${c.javaClass.simpleName}: ${c.message}") + } + } + } + + fun getSpeedVolumeLevel(): Int = audioGet(AUDIO_GET_SPEED_VOL) + fun setSpeedVolumeLevel(l: Int): Boolean = audioSet(AUDIO_SET_SPEED_VOL, l.coerceIn(AUDIO_TYPE_MIN, AUDIO_TYPE_MAX)) + fun getSoundFieldType(): Int = -1 + fun setSoundFieldType(t: Int): Boolean = audioSet(AUDIO_SET_SOUND_FIELD, t) + fun get3dEffectType(): Int = audioGet(AUDIO_GET_3D_EFFECT) + fun set3dEffectType(t: Int): Boolean = audioSet(AUDIO_SET_3D_EFFECT, t.coerceIn(AUDIO_TYPE_MIN, AUDIO_TYPE_MAX)) + fun getToneControl(): Int = audioGet(AUDIO_GET_TONE) + fun setToneControl(v: Int): Boolean = audioSet(AUDIO_SET_TONE, v.coerceIn(AUDIO_LEVEL_MIN, AUDIO_LEVEL_MAX)) +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareGen.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareGen.kt new file mode 100644 index 0000000..f5376db --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareGen.kt @@ -0,0 +1,10 @@ +package com.evsuite.hardware + +/** + * MG4 firmware generations, mirroring `FirmwareInfo.Gen` in EVProfile. + * + * The names match the strings EVProfile sends in the snapshot + * ([com.evsuite.hardware.catalog.SnapshotKeys.KEY_FIRMWARE_GEN]), so + * `FirmwareGen.valueOf(snapshotString)` round-trips. + */ +enum class FirmwareGen { SWI133, SWI132, SWI68, SWI69, SWI131, SWI165 } diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareInfo.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareInfo.kt new file mode 100644 index 0000000..011874b --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareInfo.kt @@ -0,0 +1,148 @@ +package com.evsuite.hardware + +import android.content.Context + +/** + * Detects the firmware generation from ro.build.mt2712.version. + * + * SWI133 : "SWI133-xxxxx" — ADAS via getMixProperty(0x32), 5 modes, 2 alerts, seat + steering heating + * SWI132 : "SWI132-xxxxx" — ADAS via CarVehicleSettingClient (acc/tja getAccTjaState), alerts via direct + * IVehicleSettingService binder (TX 0x128/0x12a), same UI section as SWI68 + * SWI68 : "SWI68-xxxxx" — ADAS via VehicleSettingManager (acc/tja), seat + steering heating + * SWI69 : "SWI69-xxxxx" — ADAS via "new-gen" VehicleSettingManager, no seat/steering heating + * SWI131 : "SWI131-xxxxx" — Same as SWI69 (same package, same API), no seat/steering heating + * SWI165 : "SWI165-xxxxx" — ADAS via VehicleSettingManager (same SDK as SWI68), AEB via setFcwAlarmMode, + * seat + steering heating available + * UNKNOWN : unrecognised firmware — the user can force a compatibility mode + */ +object FirmwareInfo { + + enum class Gen { SWI133, SWI132, SWI68, SWI69, SWI131, SWI165, UNKNOWN } + + private const val PREF_NAME = "ev_settings" + private const val PREF_FORCED_GEN = "forced_firmware_gen" + + @Volatile private var cached: Gen? = null + @Volatile private var detectedString: String? = null + + /** + * Call first in MainActivity.onCreate(), before any fragment inflation. + * Reads the forced choice (if any) from SharedPreferences and applies it to the cache. + */ + fun initWithContext(context: Context) { + val forced = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .getString(PREF_FORCED_GEN, null) ?: return + cached = runCatching { Gen.valueOf(forced) }.getOrDefault(Gen.UNKNOWN) + } + + /** Returns the active generation (forced or auto-detected). */ + fun getGeneration(): Gen { + cached?.let { return it } + val version = readProp("ro.build.mt2712.version") + ?: readProp("ro.build.version.incremental") + detectedString = version + val gen = generationOf(version) + cached = gen + return gen + } + + /** Pure generation parser, shared by detection and the fail-closed write policy. */ + internal fun generationOf(version: String?): Gen = + when { + version == null -> Gen.UNKNOWN + version.startsWith("SWI165") -> Gen.SWI165 + version.startsWith("SWI133") -> Gen.SWI133 + version.startsWith("SWI132") -> Gen.SWI132 // before SWI131 — startsWith("SWI13") would be ambiguous + version.startsWith("SWI131") -> Gen.SWI131 + version.startsWith("SWI68") -> Gen.SWI68 + version.startsWith("SWI69") -> Gen.SWI69 + else -> Gen.UNKNOWN + } + + /** + * True only when the firmware string read from the vehicle identifies a supported + * generation. A forced compatibility choice does not turn an unknown vehicle into a + * verified one and therefore cannot open the write gate. + */ + fun isDetectedGenerationSupported(): Boolean = + generationOf(getDetectedString().takeUnless { it == "?" }) != Gen.UNKNOWN + + /** + * Raw string read from system properties (e.g. "SWI131-12345-xxx"). + * Useful to show the exact version to the user in the warning dialog. + * + * Read here rather than through [getGeneration], which returns early on a forced + * generation and so never reaches the property. Forcing a compatibility mode changes + * which code path runs; it does not change which firmware the head unit is running, + * and that is the one thing this function exists to report. + */ + fun getDetectedString(): String { + detectedString?.let { return it } + val version = readProp("ro.build.mt2712.version") + ?: readProp("ro.build.version.incremental") + detectedString = version + return version ?: "?" + } + + /** + * Force the compatibility mode manually. + * The choice is persisted in SharedPreferences and survives reboots. + */ + fun forceGeneration(context: Context, gen: Gen) { + cached = gen + context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .edit().putString(PREF_FORCED_GEN, gen.name).apply() + } + + /** + * Returns true if the compatibility mode was forced manually (as opposed to a + * successful auto-detection). + */ + fun isForced(context: Context): Boolean = + context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .contains(PREF_FORCED_GEN) + + // ── Capability helpers ──────────────────────────────────────────────────── + + /** + * SWI69 and SWI131 use the same "new generation" VSM API: + * package com.saicmotor.vehiclesetting.service + * direct-IBinder constructor + * methods: setAccTjaState, setFcwState, setLasWarningSound… + */ + fun isNewGenVsm(): Boolean { + val gen = getGeneration() + return gen == Gen.SWI69 || gen == Gen.SWI131 + } + + /** + * SWI68, SWI69, SWI131 and SWI165 all use the SAIC VehicleSettingManager for ADAS + * (ACC/TJA) and sound alerts. SWI133 uses VehiclePropertyManager (getMixProperty). + */ + fun isVsmBased(): Boolean { + val gen = getGeneration() + return gen == Gen.SWI68 || gen == Gen.SWI69 || gen == Gen.SWI131 || gen == Gen.SWI132 || gen == Gen.SWI165 + } + + /** + * SWI133, SWI68 and SWI165 have heated seats and a heated steering wheel. + * SWI69 and SWI131 are Standard/SE trims without that equipment. + */ + fun hasHeatFeatures(): Boolean { + val gen = getGeneration() + return gen == Gen.SWI133 || gen == Gen.SWI68 || gen == Gen.SWI165 + } + + /** + * SWI165 uses the same VehicleSettingManager SDK as SWI68. + * AEB is controlled by setFcwAlarmMode() (same API as SWI68). + * setAutoEmergencyBraking() exists in the SDK but is not used by the official app. + */ + fun isSWI165(): Boolean = getGeneration() == Gen.SWI165 + + private fun readProp(key: String): String? = try { + val sp = Class.forName("android.os.SystemProperties") + val get = sp.getMethod("get", String::class.java, String::class.java) + (get.invoke(null, key, "") as? String)?.takeIf { it.isNotBlank() && it != "0" } + } catch (_: Exception) { null } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareMatrix.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareMatrix.kt new file mode 100644 index 0000000..ddaaa2e --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareMatrix.kt @@ -0,0 +1,61 @@ +package com.evsuite.hardware + +import com.evsuite.hardware.catalog.ConditionType +import com.evsuite.hardware.catalog.ActionType +/** + * Generates the firmware compatibility matrix from the [SupportedOn] annotations. + * + * This is the single source of truth for the README table: rather than a hand-maintained + * grid that drifts from the code, the table is rendered from the same annotations that + * drive the runtime filter. A test writes it out and checks the README against it. + * + * Entry labels come from resource ids, which are not available on the JVM, so the matrix + * uses the enum constant names — stable identifiers that also appear in the code. + */ +object FirmwareMatrix { + + /** Column order for the table. */ + private val COLUMNS = listOf( + FirmwareGen.SWI133, FirmwareGen.SWI68, FirmwareGen.SWI165, + FirmwareGen.SWI69, FirmwareGen.SWI131, FirmwareGen.SWI132 + ) + + /** Marks an action the firmware carries but whose write was never shown to do anything. */ + private const val UNPROVEN = "⚠️" + + fun render(): String = buildString { + appendLine("") + appendLine() + appendLine(section("Conditions", ConditionType.entries.map { it.name to FirmwareSupport.gensOf(it) })) + appendLine() + append(section("Actions", ActionType.entries.map { + it.name + (if (it.writeProven) "" else " $UNPROVEN") to FirmwareSupport.gensOf(it) + })) + // A ✅ says the firmware carries the property, which is not the same as the write + // doing something. Without this the table promises actions the apps refuse to run. + if (ActionType.entries.any { !it.writeProven }) { + appendLine() + appendLine( + "$UNPROVEN — the firmware carries the property, but nothing establishes that " + + "writing it has any effect (`ActionType.writeProven`). These actions are " + + "hidden from the editors and refused by the executors until one is observed " + + "to work." + ) + } + } + + private fun section(title: String, rows: List?>>): String = buildString { + appendLine("### $title") + appendLine() + appendLine("| Entry | " + COLUMNS.joinToString(" | ") { it.name } + " |") + appendLine("|---|" + COLUMNS.joinToString("") { ":--:|" }) + // Firmware-independent entries (no annotation) are listed once, not per column. + rows.filter { it.second == null }.forEach { (name, _) -> + appendLine("| $name | " + COLUMNS.joinToString(" | ") { "—" } + " |") + } + rows.filter { it.second != null }.forEach { (name, gens) -> + val cells = COLUMNS.joinToString(" | ") { if (it in gens!!) "✅" else "❌" } + appendLine("| $name | $cells |") + } + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareSupport.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareSupport.kt new file mode 100644 index 0000000..fc19c3e --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/FirmwareSupport.kt @@ -0,0 +1,43 @@ +package com.evsuite.hardware + +import com.evsuite.hardware.catalog.ConditionType +import com.evsuite.hardware.catalog.ActionType +/** + * Reads [SupportedOn] off catalogue entries and answers "does this run on that car?". + * + * The reflection is done once per entry and memoised: enum entries are a closed set, and + * re-reading annotations on every editor keystroke would be waste. + */ +object FirmwareSupport { + + private val conditionCache = HashMap?>() + private val actionCache = HashMap?>() + + /** Supported generations, or null when the entry is firmware-independent (works on all). */ + fun gensOf(type: ConditionType): Set? = + conditionCache.getOrPut(type) { read(ConditionType::class.java, type.name) } + + fun gensOf(type: ActionType): Set? = + actionCache.getOrPut(type) { read(ActionType::class.java, type.name) } + + /** + * Is [type] usable on [gen]? A null [gen] means the firmware is unknown (no bridge, or + * EVProfile did not report it) — in that case nothing is filtered out, because hiding + * entries on a guess would be worse than showing one that later refuses. + */ + fun isSupported(type: ConditionType, gen: FirmwareGen?): Boolean = + supported(gensOf(type), gen) + + fun isSupported(type: ActionType, gen: FirmwareGen?): Boolean = + supported(gensOf(type), gen) + + /** Parses the snapshot firmware string; null when absent or unrecognised. */ + fun parse(gen: String?): FirmwareGen? = + gen?.let { runCatching { FirmwareGen.valueOf(it.uppercase()) }.getOrNull() } + + private fun supported(gens: Set?, gen: FirmwareGen?): Boolean = + gens == null || gen == null || gen in gens + + private fun read(enumClass: Class, name: String): Set? = + enumClass.getField(name).getAnnotation(SupportedOn::class.java)?.gens?.toSet() +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/GlassEvidence.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/GlassEvidence.kt new file mode 100644 index 0000000..6d2d9ec --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/GlassEvidence.kt @@ -0,0 +1,111 @@ +package com.evsuite.hardware + +import android.content.Context +import com.evsuite.hardware.catalog.ActionType + +/** + * What this car was *observed* to do with a glass command. + * + * [ActionType.writeProven] is a claim about the catalogue: nobody has ever seen one of the + * eight window commands move a window, so the actions ship refused everywhere. That claim is + * true of the project, not of a given car — and it can only ever be settled on a car, by + * sending a command and watching the position read back change. + * + * [GlassProbe] does exactly that, and what it saw is recorded here. From then on this car + * knows which command opens and which closes, and the window actions stop being refused + * **on this car only**: the evidence never leaves it, and it is dropped as soon as the + * firmware generation changes, since a command proven on one generation proves nothing about + * another. + * + * Loaded once at [EVHardware.init] and cached, so the catalogue predicate stays a pure + * property read — the editors and the diagnostic ask it for every entry they draw. + */ +object GlassEvidence { + + private const val PREFS = "ev_settings" + private const val KEY_OPEN = "glass_open_command" + private const val KEY_CLOSE = "glass_close_command" + private const val KEY_GEN = "glass_proof_generation" + private const val KEY_AT = "glass_proof_at" + + private const val NONE = -1 + + /** The actions this evidence unlocks — the ones [ActionType.writeProven] holds back. */ + val ACTIONS: Set = setOf( + ActionType.SET_WINDOWS, + ActionType.SET_WINDOW_DRIVER, + ActionType.SET_WINDOW_PASSENGER, + ActionType.SET_WINDOW_REAR_LEFT, + ActionType.SET_WINDOW_REAR_RIGHT, + ) + + @Volatile + private var cached: Proof? = null + + /** + * @param generation the firmware the commands were observed on. Evidence from another + * generation is ignored rather than deleted: a car that is rolled back to its previous + * firmware finds its proof again. + */ + data class Proof( + val openCommand: Int, + val closeCommand: Int, + val generation: String, + val observedAtMillis: Long, + ) + + /** The proof for the firmware now running, or null while nothing has been observed. */ + val proof: Proof? get() = cached?.takeIf { it.generation == FirmwareInfo.getGeneration().name } + + val isProven: Boolean get() = proof != null + + fun load(context: Context) { + val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val open = prefs.getInt(KEY_OPEN, NONE) + val close = prefs.getInt(KEY_CLOSE, NONE) + val gen = prefs.getString(KEY_GEN, null) + cached = if (open == NONE || close == NONE || gen == null) null else { + Proof(open, close, gen, prefs.getLong(KEY_AT, 0L)) + } + AppLogger.i(TAG, "glass evidence: ${cached ?: "none"}") + } + + /** + * Records what the probe watched happen. Both directions or nothing: a car that can be + * opened and not closed is worse than one the actions never touch. + */ + fun record(context: Context, openCommand: Int, closeCommand: Int) { + val gen = FirmwareInfo.getGeneration().name + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putInt(KEY_OPEN, openCommand) + .putInt(KEY_CLOSE, closeCommand) + .putString(KEY_GEN, gen) + .putLong(KEY_AT, System.currentTimeMillis()) + .apply() + cached = Proof(openCommand, closeCommand, gen, System.currentTimeMillis()) + AppLogger.i(TAG, "glass evidence recorded: open=$openCommand close=$closeCommand on $gen") + } + + fun clear(context: Context) { + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .remove(KEY_OPEN).remove(KEY_CLOSE).remove(KEY_GEN).remove(KEY_AT) + .apply() + cached = null + } + + /** Test seam: sets the cache without touching storage. */ + internal fun setForTest(proof: Proof?) { cached = proof } + + private const val TAG = "EV_GLASS" +} + +/** + * Whether writing this action has been shown to do something *here*. + * + * [ActionType.writeProven] answers it for every car at once, from what the project knows. + * This answers it for the car the code is running on, which is the only place the glass + * question could ever be settled. Everything user-facing — the picker, the diagnostic, the + * executor — asks this one rather than the raw property. + */ +val ActionType.effectProven: Boolean + get() = writeProven || (this in GlassEvidence.ACTIONS && GlassEvidence.isProven) diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/GlassProbe.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/GlassProbe.kt new file mode 100644 index 0000000..2e7d889 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/GlassProbe.kt @@ -0,0 +1,198 @@ +package com.evsuite.hardware + +import com.evsuite.hardware.saic.SaicVehicleControl +import com.evsuite.hardware.saic.SaicVehicleControl.Window +import kotlin.math.abs + +/** + * Finds out which glass command opens a window and which closes it, by trying them. + * + * The window actions ship refused because nothing on the firmware says what the eight + * commands in `0..`[SaicVehicleControl.WINDOW_COMMAND_MAX] do: the vendor service accepts a + * value, drops what it does not like, and reports the same success either way. No amount of + * reading settles it. Sending one and watching the position read back does. + * + * ## What it does + * + * Commands are tried one at a time, with the position read before and after each. Two passes, + * because a single one cannot see both directions: from a closed window only an *opening* + * command moves anything, and a closing command looks identical to a command that does + * nothing. The second pass retries exactly those — the window is open by then, so a closing + * command now shows. It stops as soon as both directions are known. + * + * ## What it refuses + * + * Standstill is re-checked before **every** command, not once at the start: a sweep takes + * half a minute, and a window that opens as the car pulls away is the accident this whole + * gate exists to prevent. An unreadable position ends it too — without the read-back there is + * no evidence, only a command sent into the dark, which is the situation this exists to get + * out of. + * + * It puts the glass back where it found it when it can, and says so when it could not. + */ +object GlassProbe { + + /** + * How much the position must change to count as movement, in percent. + * + * The read is a percentage that jitters by a point or so while the motor settles; + * anything smaller than this would let noise pass for proof. + */ + const val MOVEMENT_PERCENT = 3 + + /** Time given to a window to finish travelling before the position is read back. */ + const val SETTLE_MS = 3_000L + + /** The car, or a stand-in for it in tests. */ + interface Glass { + fun position(window: Window): Int? + fun send(window: Window, command: Int): Boolean + } + + /** One command sent, with the position on either side of it. */ + data class Attempt(val command: Int, val before: Int, val after: Int) { + val delta: Int get() = after - before + val moved: Boolean get() = abs(delta) >= MOVEMENT_PERCENT + } + + enum class Refusal { + /** The car was moving, or its speed was unreadable. */ + NOT_STOPPED, + + /** Ignition is not in RUN: the windows have no power and prove nothing. */ + IGNITION_NOT_RUN, + + /** The position stopped answering — no read, no evidence. */ + UNREADABLE, + + /** Every command was sent and the glass never budged. */ + NOTHING_MOVED, + + /** One direction found, the other never appeared. */ + ONE_DIRECTION_ONLY, + } + + data class Result( + val window: Window, + val attempts: List, + val openCommand: Int?, + val closeCommand: Int?, + val refusal: Refusal?, + /** True when the glass ended where the probe found it. */ + val restored: Boolean, + ) { + val proven: Boolean get() = openCommand != null && closeCommand != null + } + + /** + * Runs the sweep on [window]. Blocking, and long — call it off the main thread. + * + * Every dependency is a parameter so the sequence itself can be tested without a car: + * what makes this worth testing is the pass logic and the refusals, not the AIDL call. + */ + fun run( + window: Window, + glass: Glass = VehicleGlass, + stopped: () -> Boolean = { VehicleWriteGate.decideNow() == VehicleWriteGate.Decision.ALLOWED }, + // [IgnitionState], not [CarIgnitionItem]: getCurrentIgnitionState() answers on the AAOS + // scale, and the two do not line up — RUN is 2 for Katman5, where 2 is OFF here. Read + // against the wrong one, a car with the ignition on refused the probe as if it were off. + ignitionRun: () -> Boolean = { EVHardware.getCurrentIgnitionState() == EVHardware.IgnitionState.ON }, + sleep: (Long) -> Unit = { Thread.sleep(it) }, + ): Result { + val attempts = mutableListOf() + fun result(open: Int?, close: Int?, refusal: Refusal?, restored: Boolean = false) = + Result(window, attempts.toList(), open, close, refusal, restored) + + if (!ignitionRun()) return result(null, null, Refusal.IGNITION_NOT_RUN) + if (!stopped()) return result(null, null, Refusal.NOT_STOPPED) + val start = glass.position(window) ?: return result(null, null, Refusal.UNREADABLE) + + var open: Int? = null + var close: Int? = null + + outer@ for (pass in 0..1) { + for (command in 0..SaicVehicleControl.WINDOW_COMMAND_MAX) { + if (open != null && close != null) break@outer + // A command already shown to move the glass has nothing left to tell us, and + // re-sending it would only travel the window again. + if (command == open || command == close) continue + + if (!stopped()) return result(open, close, Refusal.NOT_STOPPED) + val before = glass.position(window) ?: return result(open, close, Refusal.UNREADABLE) + // A command the service refuses outright is not evidence of anything: it was + // never sent, so it is left out of the record rather than logged as inert. + if (!glass.send(window, command)) continue + sleep(SETTLE_MS) + val after = glass.position(window) ?: return result(open, close, Refusal.UNREADABLE) + + val attempt = Attempt(command, before, after) + attempts += attempt + AppLogger.i( + TAG, + "pass $pass ${window.name} command $command: $before% → $after% " + + if (attempt.moved) "(moved ${attempt.delta})" else "(no movement)" + ) + when { + attempt.delta >= MOVEMENT_PERCENT -> if (open == null) open = command + attempt.delta <= -MOVEMENT_PERCENT -> if (close == null) close = command + } + } + } + + val restored = restore(window, glass, start, close, open, sleep) + val refusal = when { + open != null && close != null -> null + open == null && close == null -> Refusal.NOTHING_MOVED + else -> Refusal.ONE_DIRECTION_ONLY + } + return result(open, close, refusal, restored) + } + + /** + * Puts the glass back, when a command in the needed direction is known. + * + * Best effort by nature: the commands travel the window to an end, so "back where it was" + * means closed again after a probe that started closed — which is the normal case, and the + * one that matters. Anything else is reported as not restored rather than papered over, + * because a window left open is something the driver has to be told about. + */ + private fun restore( + window: Window, + glass: Glass, + start: Int, + close: Int?, + open: Int?, + sleep: (Long) -> Unit, + ): Boolean { + val now = glass.position(window) ?: return false + if (abs(now - start) < MOVEMENT_PERCENT) return true + val command = if (now > start) close else open + if (command == null) return false + glass.send(window, command) + sleep(SETTLE_MS) + val settled = glass.position(window) ?: return false + return abs(settled - start) < MOVEMENT_PERCENT + } + + /** + * How long each command is held down before the position is read back. + * + * Shorter than a close, because the sweep sends up to sixteen of them and the driver is + * sitting through all of it — long enough for a window to travel further than + * [MOVEMENT_PERCENT], which is all the probe has to see. + */ + const val HOLD_MS = 1_500L + + /** The real car. */ + object VehicleGlass : Glass { + override fun position(window: Window): Int? = SaicVehicleControl.windowPercent(window) + // Held, not sent once: the commands that move this glass are switches rather than + // orders, and a single write travels the window by less than the read can resolve. + // Holding a command that *is* an order costs nothing — it simply arrives again. + override fun send(window: Window, command: Int): Boolean = + SaicVehicleControl.holdWindow(window, command, HOLD_MS) + } + + private const val TAG = "EV_GLASS" +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/PhysicalButtonEventDecoder.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/PhysicalButtonEventDecoder.kt new file mode 100644 index 0000000..ed17187 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/PhysicalButtonEventDecoder.kt @@ -0,0 +1,95 @@ +package com.evsuite.hardware + +import com.evsuite.hardware.catalog.SnapshotKeys + +/** + * Decodes `com.saic.keyevent.hardkey.report` payloads. + * + * 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. + */ +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), + 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), + VOLUME_DOWN(setOf(25), SnapshotKeys.KEY_VOLUME_DOWN_SHORT, SnapshotKeys.KEY_VOLUME_DOWN_LONG), + MEDIA_NEXT(setOf(87), SnapshotKeys.KEY_MEDIA_NEXT_SHORT, SnapshotKeys.KEY_MEDIA_NEXT_LONG), + MEDIA_PREVIOUS(setOf(88), SnapshotKeys.KEY_MEDIA_PREVIOUS_SHORT, SnapshotKeys.KEY_MEDIA_PREVIOUS_LONG), + 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) + } + enum class Press { SHORT, LONG, DOUBLE } + data class Event(val button: Button, val press: Press) { + val value: String get() = "${button.name}:${press.name}" + fun readings(): Map = mapOf(SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT to value) + } + private enum class State { DOWN, LONG_REPORTED } + private val states = mutableMapOf() + + /** + * How long a second press has to arrive within to count as a double. + * + * 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() + + /** + * @param atMillis when the event happened. A monotonic default, and JVM-only so the + * decoder stays testable without a device — the caller may pass its own clock. + */ + fun accept( + keyCode: Int, + down: Boolean, + longPress: Boolean, + atMillis: Long = System.nanoTime() / 1_000_000, + ): Event? { + val button = Button.entries.firstOrNull { keyCode in it.codes } ?: return null + return when { + down && longPress && states[button] != State.LONG_REPORTED -> { + states[button] = 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) + } + down -> { states.putIfAbsent(button, State.DOWN); null } + states.remove(button) == State.DOWN -> { + val previous = lastShortAt[button] + 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) + } else { + lastShortAt[button] = atMillis + Event(button, Press.SHORT) + } + } + else -> null + } + } + + companion object { + /** + * The double-tap window, in milliseconds. + * + * A [Press.SHORT] is emitted as soon as the button is released, so a double tap + * produces a SHORT and then a DOUBLE. Holding every single press back for this long + * would tax the common case to serve the rare one; suppressing the leading SHORT is + * the caller's business, and only worth doing when a rule is actually waiting on the + * double — see EVTasker's vehicle service. + */ + const val DOUBLE_TAP_MS = 300L + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/RequiresStandstill.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/RequiresStandstill.kt new file mode 100644 index 0000000..a11ae80 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/RequiresStandstill.kt @@ -0,0 +1,22 @@ +package com.evsuite.hardware + +/** + * Marks an [EVHardware] write that changes road behaviour and is therefore permitted + * **only at 0 km/h**. + * + * This is documentation with teeth: it does not itself enforce anything — enforcement is + * [VehicleWriteGate], applied inside the low-level write primitives — but it names, at the + * method, exactly which writes are speed-gated. A test in this module asserts the set of + * annotated methods, so a setter cannot silently lose or gain gating without the change + * being noticed. + * + * Comfort writes (seat/steering heating via CarHvacManager, media volume, screen + * brightness, audio tuning) are deliberately NOT annotated: they do not alter how the car + * behaves on the road, and the gate does not apply to them. + * + * SOURCE retention: the annotation is a compile-time contract read by the guard test via + * the method list, not needed at runtime. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.SOURCE) +annotation class RequiresStandstill diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/StateChangeLog.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/StateChangeLog.kt new file mode 100644 index 0000000..d844df1 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/StateChangeLog.kt @@ -0,0 +1,38 @@ +package com.evsuite.hardware + +import java.util.concurrent.atomic.AtomicReference + +/** + * Lets a repeated state through only when it actually changed. + * + * Two vehicle callbacks report a state rather than an event: the ignition condition arrives + * about ten times a second and the gear is read once a second, and both were logged every + * time. On a 400-line retained diagnostic log that is under a minute of history — the bundle + * a driver sends back is then almost entirely one repeated line, and whatever went wrong + * before that minute has already been evicted. It also spends the head unit's CPU and log + * bandwidth continuously to say nothing new. + * + * A state worth logging is a state that changed. The transitions are what a diagnostic reads + * anyway; the repeats between them carry no information the timestamp of the next transition + * does not already give. + * + * Only logging is gated. Callers must still dispatch every callback they receive — dropping a + * dispatch would change behaviour, dropping a log line changes only noise. + */ +class StateChangeLog { + + private val last = AtomicReference(NOTHING) + + /** + * @return true when [value] differs from the previous accepted one, or nothing was seen yet. + */ + fun accept(value: T): Boolean = last.getAndSet(value) != value + + /** Forgets what was seen, so the next value logs as if it were the first. */ + fun reset() = last.set(NOTHING) + + private companion object { + /** Distinct from null, so a first value of null still counts as a change. */ + val NOTHING = Any() + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/SupportedOn.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/SupportedOn.kt new file mode 100644 index 0000000..ade6e2e --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/SupportedOn.kt @@ -0,0 +1,22 @@ +package com.evsuite.hardware + +/** + * Declares which firmware generations a catalogue entry works on. + * + * Placed on the entries of [ConditionType] and [ActionType]. It is the single source of + * truth for three things: + * • self-documenting source — the support set sits next to the entry + * • the README compatibility matrix — generated from these annotations, never + * hand-maintained (see `FirmwareMatrix`) + * • the runtime filter — the editor hides an entry on a car whose firmware is not + * listed (see `FirmwareSupport`) + * + * A catalogue entry with NO `@SupportedOn` is firmware-independent (time, day, Bluetooth, + * launch app, notify): it works everywhere and is never filtered out. + * + * RUNTIME retention because the filter and the generator read it by reflection. + * FIELD target because Kotlin enum entries are compiled to fields. + */ +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class SupportedOn(vararg val gens: FirmwareGen) diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleSpeedEvidence.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleSpeedEvidence.kt new file mode 100644 index 0000000..5b19835 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleSpeedEvidence.kt @@ -0,0 +1,54 @@ +package com.evsuite.hardware + +/** + * Which speed conversion produced a recorded distance. + * + * A trip distance is the integral of speed, so it inherits whatever the speed conversion was + * at the time — and that conversion has already been wrong once. Every trip recorded before + * [KMH_DIRECT_V2] on a generation that reports km/h carries a distance 3.6 times too long, and + * nothing in the stored number says so: 8.46 km for a 2.4 km route reads exactly like 8.46 km + * for an 8.46 km route. + * + * [BatteryPowerEvidence] exists for the same reason on the energy side. This is its + * counterpart for distance, so a model can refuse a trip whose distance it cannot vouch for + * instead of quietly training on it. + */ +data class VehicleSpeedEvidence( + val firmware: FirmwareInfo.Gen, + val conversionVersion: Int, +) { + init { + require(firmware != FirmwareInfo.Gen.UNKNOWN) { "speed evidence names a known firmware" } + require(conversionVersion > 0) { "speed conversion version is positive" } + } + + /** + * True when this distance was produced by the conversion currently believed correct. + * + * The generation is a parameter rather than a read of [FirmwareInfo.getGeneration] so the + * check can be exercised off a vehicle. A correctness gate that only runs on a car is a + * gate nobody has tested. + */ + fun matchesCurrent(generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration()): Boolean = + conversionVersion == CURRENT && firmware == generation + + companion object { + /** `PERF_VEHICLE_SPEED` read as m/s and multiplied by 3.6, as AAOS specifies. */ + const val MPS_TIMES_3_6_V1 = 1 + + /** The property read as the km/h it already carries, per generation evidence. */ + const val KMH_DIRECT_V2 = 2 + + /** What a distance recorded now is produced by. */ + const val CURRENT = KMH_DIRECT_V2 + + /** + * The evidence for a distance recorded right now, or null on an unknown firmware — + * where no claim about the conversion can honestly be made. + */ + fun current(): VehicleSpeedEvidence? = + FirmwareInfo.getGeneration() + .takeIf { it != FirmwareInfo.Gen.UNKNOWN } + ?.let { VehicleSpeedEvidence(it, CURRENT) } + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleSpeedScale.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleSpeedScale.kt new file mode 100644 index 0000000..189d28d --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleSpeedScale.kt @@ -0,0 +1,44 @@ +package com.evsuite.hardware + +import kotlin.math.abs + +/** + * What `PERF_VEHICLE_SPEED` is actually in, per firmware generation. + * + * AAOS defines the property as metres per second, so every read was multiplied by 3.6. On + * SWI68 that is wrong: the property already reports km/h, and multiplying a second time + * scaled every derived distance by the same factor. A 2.4 km drive was recorded twice as + * 8.46 km and 7.69 km — 124 and 129 km/h average over four minutes of town driving — and + * dividing by 3.6 gives 2.35 km at 34 km/h, which is the drive that happened. + * + * The conversion is therefore evidence-gated like every other firmware-dependent read here. + * A generation is listed only once a vehicle has shown it, and an unlisted generation keeps + * the specified conversion: unproven, but wrong in the conservative direction, because a + * speed read too high makes [VehicleWriteGate] refuse more often rather than less. + */ +object VehicleSpeedScale { + + /** Metres per second to km/h, for generations that really do report the specified unit. */ + private const val MPS_TO_KMH = 3.6f + + /** + * Generations proven on a vehicle to report km/h directly from `PERF_VEHICLE_SPEED`. + * + * SWI68: bundle of 2026-09-04, firmware SWI68-29958-1300R67. Two recorded trips over a + * route of known length, both over by the same 3.5x, with the adapter odometer as the + * independent reference. Add a generation here only with the same kind of evidence. + */ + private val REPORTS_KMH = setOf(FirmwareInfo.Gen.SWI68) + + /** True when this generation needs no conversion at all. */ + fun reportsKmh(generation: FirmwareInfo.Gen): Boolean = generation in REPORTS_KMH + + /** + * The raw property value as km/h. + * + * The property is signed — negative in reverse — and every consumer here asks whether the + * car is moving, so the magnitude is what comes back. + */ + fun toKmh(raw: Float, generation: FirmwareInfo.Gen): Float = + if (reportsKmh(generation)) abs(raw) else abs(raw) * MPS_TO_KMH +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleWriteGate.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleWriteGate.kt new file mode 100644 index 0000000..99f954b --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/VehicleWriteGate.kt @@ -0,0 +1,112 @@ +package com.evsuite.hardware + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.widget.Toast + +/** + * [T-904] Policy: a vehicle setting write is permitted **only when stopped** (0 km/h). + * + * Changing AEB, ELK, ACC/TJA or the drive mode while moving alters how the car behaves + * under the driver. The rule is therefore: 0 km/h or refuse — and refuse ALSO when the + * speed is unreadable (fail closed), because an unknown speed could be any speed. + * + * Comfort writes (seat/steering heating via CarHvacManager) are NOT affected: they do not + * change road behaviour. Setters that are gated carry [RequiresStandstill]. + * + * This object lives in the shared `mg4-hardware` module, compiled into both EVProfile and + * EVTasker. It carries no dependency on either app's resources: the refusal message shown + * to the driver comes from [messageProvider], which an app may set to its own localized + * strings; the English fallback keeps the module self-contained. + */ +object VehicleWriteGate { + + private const val TAG = "EV_GATE" + + /** Anti-spam on the user message: at most one refusal toast per second. */ + private const val TOAST_THROTTLE_MS = 1_000L + + @Volatile + private var lastToastMs = 0L + + /** + * Optional localized message source. An app sets this at init to surface its own + * strings; when null, the English fallback below is used. Returning null suppresses the + * toast for that decision. + */ + @Volatile + var messageProvider: ((Decision) -> String?)? = null + + enum class Decision { + /** Vehicle stopped — write allowed. */ + ALLOWED, + /** Vehicle moving — write refused. */ + REFUSED_MOVING, + /** Speed unreadable — write refused (fail closed). */ + REFUSED_UNKNOWN_SPEED + } + + /** + * Pure decision from a speed in km/h, [speedKmh] null when unreadable. + * + * A negative speed is treated as unreadable: the VHAL does not produce a negative speed + * moving forward, and an aberrant value must never open the gate. + */ + fun decide(speedKmh: Float?): Decision = + when { + speedKmh == null || speedKmh.isNaN() -> Decision.REFUSED_UNKNOWN_SPEED + speedKmh < 0f -> Decision.REFUSED_UNKNOWN_SPEED + speedKmh == 0f -> Decision.ALLOWED + else -> Decision.REFUSED_MOVING + } + + /** + * The decision for the vehicle as it is right now — what [allow] enforces, exposed so a + * caller can report the same verdict it is about to get instead of guessing at it. + * + * The speed is read immediately for this decision. No other signal can rescue an + * unreadable value: park may be stale, so using it would weaken the fail-closed rule. + */ + fun decideNow(): Decision = decide(EVHardware.getVehicleSpeedKmh()) + + /** + * True if the [operation] write is allowed now. On refusal it logs and notifies the + * user — a silent refusal would make the setting look applied when it was not. + */ + fun allow(operation: String): Boolean { + if (!FirmwareInfo.isDetectedGenerationSupported()) { + AppLogger.w(TAG, "Write refused ($operation): unsupported firmware") + notifyUser("Setting change refused: firmware is not supported") + return false + } + val decision = decideNow() + if (decision == Decision.ALLOWED) return true + + AppLogger.w(TAG, "Write refused ($operation): $decision") + notifyUser(decision) + return false + } + + private fun notifyUser(decision: Decision) { + val message = messageProvider?.invoke(decision) ?: defaultMessage(decision) ?: return + notifyUser(message) + } + + private fun notifyUser(message: String) { + val context: Context = EVHardware.appContext() ?: return + val now = System.currentTimeMillis() + if (now - lastToastMs < TOAST_THROTTLE_MS) return + lastToastMs = now + + Handler(Looper.getMainLooper()).post { + runCatching { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } + } + } + + private fun defaultMessage(decision: Decision): String? = when (decision) { + Decision.REFUSED_MOVING -> "Setting change refused: vehicle is moving" + Decision.REFUSED_UNKNOWN_SPEED -> "Setting change refused: speed unreadable" + Decision.ALLOWED -> null + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/VsmGlass.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/VsmGlass.kt new file mode 100644 index 0000000..af2ae31 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/VsmGlass.kt @@ -0,0 +1,208 @@ +package com.evsuite.hardware + +import java.lang.reflect.Method + +/** + * The glass, through the VSM — the interface the car's own window switch goes through. + * + * EVHardware reached the windows through the vendor hub until now + * ([com.evsuite.hardware.saic.SaicVehicleControl]), and that path is the whole reason the + * glass actions shipped refused: its setter takes a value no application on the head unit + * ever writes, so what the eight accepted values do was never more than a guess, and a value + * the service drops looks exactly like one it obeys. + * + * The VSM carries the same glass under two methods whose meaning is settled by use: + * + * • `setVehicleWindowStatus(area, command)` — the commands in [Command] + * • `getVehicleWindowValue(area)` — a position, 0 closed to [VALUE_MAX] open + * + * Areas are `0..3`: front left, front right, rear left, rear right. + * + * **Per generation.** VSM-based firmwares only. SWI68 and SWI165 reach the VSM through + * `VehicleSettingManager`, SWI69, SWI131 and SWI132 through `CarVehicleSettingClient`, and + * both expose these names. SWI133 is VPM-based and carries neither, so [isAvailable] answers + * false there and the caller keeps the hub instead of writing into nothing. + */ +object VsmGlass { + + /** The scale `getVehicleWindowValue` answers on: 0 fully closed, 255 fully open. */ + const val VALUE_MAX = 255 + + /** The largest command `setVehicleWindowStatus` carries. */ + const val COMMAND_MAX = 7 + + /** How often a held command is re-sent while the glass travels. */ + const val PULSE_MS = 120L + + /** How long [hold] keeps a command down when the caller names no duration. */ + const val HOLD_MS = 5_000L + + /** + * The commands whose effect is established. + * + * [UP] is a switch held down, not an order: the window travels while the command keeps + * arriving and stops the instant it does not — which is why sending it once and waiting + * looked exactly like a command that does nothing at all. [AUTO_UP] is the one-shot close + * the door switch performs, and only the trims whose motors carry a position sensor obey + * it; [hold] works on both, so it is what a close falls back to. + * + * No opening command is named here. Nothing on the head unit sends one, so any value + * written down would be a guess — [GlassProbe] finds them on the car instead. + */ + object Command { + const val STOP = 0 + const val UP = 1 + const val AUTO_UP = 3 + } + + private val INT = Int::class.javaPrimitiveType!! + private val AREAS = 0..3 + + // Looked up once per VSM class rather than per call: a hold writes eight times a second, + // and `getMethod` on every one of them is forty lookups for a single close. + @Volatile private var owner: Class<*>? = null + @Volatile private var setter: Method? = null + @Volatile private var getter: Method? = null + + /** The VSM with its two methods resolved, or null when there is nothing to call. */ + @Synchronized + private fun vsm(): Any? { + val vsm = EVHardware.vsmInstance() ?: return null + if (owner !== vsm.javaClass) { + owner = vsm.javaClass + setter = method(vsm, "setVehicleWindowStatus", INT, INT) + getter = method(vsm, "getVehicleWindowValue", INT) + } + return vsm + } + + private fun method(vsm: Any, name: String, vararg types: Class<*>): Method? = try { + vsm.javaClass.getMethod(name, *types) + } catch (e: Exception) { + AppLogger.w(TAG, " VSM: $name absent — ${e.javaClass.simpleName}") + null + } + + /** True when this firmware's VSM carries the glass setter. */ + val isAvailable: Boolean + get() = FirmwareInfo.isVsmBased() && vsm() != null && setter != null + + /** + * One window's position, 0 closed to [VALUE_MAX] open, or null when there is no reading. + * + * Null means the method is absent, the call threw, or the answer fell outside `0..` + * [VALUE_MAX] — which is how the sensorless trims seen so far report having no position. + * + * **What this cannot rule out.** A motor that answers a constant *inside* the scale is + * indistinguishable from one reporting a real position, from a single read, and 0 is the + * obvious candidate for such a constant — it would read as "fully closed" on a window that + * is wide open. Nothing here can settle that, and guessing at it would be the same mistake + * the raw hub setter made. [GlassProbe] is what settles it on a car: it holds a command and + * watches this value change, so a car with [GlassEvidence] recorded has been shown to + * report positions that move. A car without it has not, and a caller deciding whether to + * skip a close should weigh the reading accordingly. + */ + fun value(area: Int): Int? { + val vsm = vsm() ?: return null + val get = getter ?: return null + if (area !in AREAS) return null + return try { + (get.invoke(vsm, area) as? Number)?.toInt()?.takeIf { it in 0..VALUE_MAX } + } catch (e: Exception) { + AppLogger.w(TAG, " VSM: getVehicleWindowValue($area) exc: ${e.message}") + null + } + } + + /** The same position in percent — the scale the catalogue and the rules speak in. */ + fun percent(area: Int): Int? = value(area)?.let { it * 100 / VALUE_MAX } + + /** Sends [command] once to each of [areas]. One write, for the commands that are orders. */ + fun send(areas: List, command: Int): Boolean = + gate(areas, command)?.let { targets -> targets.map { write(it, command) }.all { it } } ?: false + + /** + * Holds [command] down on [areas] for [durationMs], then releases it with [Command.STOP]. + * + * This is what [Command.UP] needs: it moves the glass only while it keeps arriving, so a + * single write travels the window by whatever it manages before the next frame — nothing + * a position read can see. A command that is an order rather than a switch is unharmed by + * being repeated, which is what lets [GlassProbe] sweep both kinds the same way. + * + * Blocking for [durationMs] — call it off the main thread. + * + * The gate is re-checked before every pulse, not only before the first. Glass is + * standstill-gated outright on this project (see the 1.6.0 note: with the direction of a + * command unknown, an unknown direction is not one to allow at speed), and a hold that + * checked once wrote for five more seconds after the car started moving — five seconds in + * which the policy said no and the glass kept travelling. On refusal the command is + * released with [Command.STOP] rather than simply abandoned, so the motor stops where it + * is instead of continuing on the last command it received. + */ + fun hold( + areas: List, + command: Int, + durationMs: Long = HOLD_MS, + sleep: (Long) -> Unit = { Thread.sleep(it) }, + ): Boolean { + val targets = gate(areas, command) ?: return false + return runHold( + targets = targets, + command = command, + durationMs = durationMs, + nowMs = System::currentTimeMillis, + allowed = { VehicleWriteGate.allow("VSM setVehicleWindowStatus (hold)") }, + write = ::write, + sleep = sleep, + ) + } + + /** Hardware-free seam proving a moving/unreadable transition stops and releases the glass. */ + internal fun runHold( + targets: List, + command: Int, + durationMs: Long, + nowMs: () -> Long, + allowed: () -> Boolean, + write: (Int, Int) -> Boolean, + sleep: (Long) -> Unit, + ): Boolean { + var ok = true + val deadline = nowMs() + durationMs + while (nowMs() < deadline) { + if (!allowed()) { + ok = false + break + } + targets.forEach { if (!write(it, command)) ok = false } + sleep(PULSE_MS) + } + targets.forEach { write(it, Command.STOP) } + return ok + } + + /** The areas worth writing to, or null when the call is refused before any write. */ + private fun gate(areas: List, command: Int): List? { + if (command !in 0..COMMAND_MAX) return null + val targets = areas.filter { it in AREAS } + if (targets.isEmpty()) return null + if (vsm() == null || setter == null) return null + // [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable. + if (!VehicleWriteGate.allow("VSM setVehicleWindowStatus")) return null + return targets + } + + private fun write(area: Int, command: Int): Boolean { + val vsm = EVHardware.vsmInstance() ?: return false + val set = setter ?: return false + return try { + set.invoke(vsm, area, command) + true + } catch (e: Exception) { + AppLogger.w(TAG, " VSM: setVehicleWindowStatus($area, $command) exc: ${e.message}") + false + } + } + + private const val TAG = "EV_GLASS" +} 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 new file mode 100644 index 0000000..519c491 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/ActionType.kt @@ -0,0 +1,852 @@ +package com.evsuite.hardware.catalog + +import androidx.annotation.StringRes +import com.evsuite.hardware.R +import com.evsuite.hardware.catalog.SnapshotKeys +import com.evsuite.hardware.catalog.VehicleEnums +import com.evsuite.hardware.SupportedOn +import com.evsuite.hardware.FirmwareGen.SWI131 +import com.evsuite.hardware.FirmwareGen.SWI132 +import com.evsuite.hardware.FirmwareGen.SWI133 +import com.evsuite.hardware.FirmwareGen.SWI165 +import com.evsuite.hardware.FirmwareGen.SWI68 +import com.evsuite.hardware.FirmwareGen.SWI69 +import com.evsuite.hardware.catalog.ValueSpec.Companion.number + +/** Grouping in the action picker. */ +enum class ActionGroup(@StringRes val labelRes: Int) { + PROFILE(R.string.group_profile), + DRIVING(R.string.group_driving), + COMFORT(R.string.group_comfort), + CLIMATE(R.string.group_climate), + ENERGY(R.string.group_energy), + AUDIO(R.string.group_audio), + ADAS(R.string.group_adas), + SYSTEM(R.string.group_system) +} + +/** + * Catalogue of executable actions. + * + * [bridgeAction] is the identifier sent to `TaskerBridgeService.applyAction`. It is null + * for actions handled locally by EVTasker (launch an app, notify), which never touch the + * vehicle and therefore have no business in the bridge. + * + * [gated] marks writes that change road behaviour. EVProfile refuses them while the car + * is moving or when its speed is unreadable. The editor shows the mark so the user knows + * up front that such an action only applies when stopped, instead of discovering a + * refusal in the history afterwards. + * + * The [SupportedOn] annotation declares firmware support (from EVProfile routing); it + * drives the generated README matrix and the editor's runtime filter. Local actions carry + * no annotation — they are firmware-independent. + * + * Climate and charging writes go through the SAIC vendor services + * (`com.evsuite.hardware.saic`), not through AOSP property ids. That distinction is the reason + * they exist at all: the AOSP climate ids EVHardware reads are standard ids that no MG4 + * confirmed, so writing them would have been a guess. The vendor calls + * are the ones the car's own HVAC and charging screens make. + * + * Window and door-lock writes go through `vehiclecontrol` on the same hub. The glass carries + * a **command** in 0..7, not a position: the service reads back a percentage but accepts + * nothing above 7 on the way in, and drops what it does not accept without saying so (see + * `SaicVehicleControl`). Which command moves which way is not established by anything on the + * firmware, so every glass write takes the standstill gate — an unknown direction cannot be + * the one that is safe at speed, and every glass write is [writeProven] `= false` until one + * command is observed to move a window. The electric tailgate stays out — the launcher defines its + * OPEN and CLOSE as the same value, so it is a pulse whose direction depends on state this + * cannot read. + * + * Also deliberately absent: + * • `VEHICLE_POWER_OFF` — cutting the vehicle must stay an explicit human gesture. + * • `SET_SOUND_FIELD` — EVHardware.getSoundFieldType() always returns -1, so the + * state is never readable back and a rule's effect would be + * unverifiable. + */ +enum class ActionType( + @StringRes val labelRes: Int, + val group: ActionGroup, + val spec: ValueSpec, + val bridgeAction: String?, + val gated: Boolean = false, + /** + * Snapshot key holding what this action controls, right now, when the car can report it. + * + * The editor opens the control on that value instead of on the bottom of its range. A + * brightness slider that starts at 5% invites the driver to confirm 5% by accident; one + * that starts where the screen already is makes the rule an edit of the present state, + * which is what the user is thinking about. Null where nothing reads it back. + */ + val currentKey: String? = null, + /** + * False when nothing establishes that this write does what its label says. + * + * Firmware support and effect are two different questions. `@SupportedOn` says the + * property exists on this generation; this says whether writing it has ever been shown + * to move anything. The glass is the case that made the distinction necessary: the + * service accepts a command in 0..7, no head-unit application sends one, and which of + * the eight raises a window is written down nowhere — so a write returns success while + * the glass stays where it was. + * + * An unproven action is offered nowhere and executed nowhere: EVTasker's diagnostic + * blocks it, the rule editor does not list it, and the executor refuses it rather than + * report a success it cannot back. Flip the flag back to true in the same commit that + * records the evidence — a command sent, the position read back, the glass observed to + * have moved. + */ + val writeProven: Boolean = true +) { + + // ── Profile ────────────────────────────────────────────────────────────── + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + APPLY_PROFILE( + R.string.act_apply_profile, ActionGroup.PROFILE, + ValueSpec(ValueKind.PROFILE), bridgeAction = "APPLY_PROFILE", gated = true + ), + /** + * Opens EVProfile's profile picker and leaves the choice to the driver. + * + * The counterpart of [APPLY_PROFILE], for the rules that must not decide alone: arriving + * at a charger, the profile to apply depends on what the driver is about to do, which no + * condition reads. Gated like the profile it leads to — EVProfile refuses to put the + * picker in front of a moving driver, so offering it without the mark would promise a + * dialog that never appears. + */ + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SHOW_PROFILE_PICKER( + R.string.act_show_profile_picker, ActionGroup.PROFILE, + ValueSpec.NONE, bridgeAction = "SHOW_PROFILE_PICKER", gated = true + ), + + // ── Driving (gated) ────────────────────────────────────────────────────── + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_DRIVE_MODE( + R.string.act_drive_mode, ActionGroup.DRIVING, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.DRIVE_MODES), + "SET_DRIVE_MODE", gated = true, currentKey = SnapshotKeys.KEY_DRIVE_MODE + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_REGEN_LEVEL( + R.string.act_regen, ActionGroup.DRIVING, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.REGEN_LEVELS), + "SET_REGEN_LEVEL", gated = true, currentKey = SnapshotKeys.KEY_REGEN_LEVEL + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_ONE_PEDAL( + R.string.act_one_pedal, ActionGroup.DRIVING, + ValueSpec.BOOL, "SET_ONE_PEDAL", gated = true + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_ENERGY_SAVING( + R.string.act_energy_saving, ActionGroup.DRIVING, + ValueSpec.BOOL, "SET_ENERGY_SAVING", gated = true, currentKey = SnapshotKeys.KEY_ENERGY_SAVING + ), + + // ── Comfort (not gated: does not alter road behaviour) ─────────────────── + @SupportedOn(SWI133, SWI68, SWI165) + SET_SEAT_HEAT_LEFT( + R.string.act_seat_heat_l, ActionGroup.COMFORT, + number(0, 3), "SET_SEAT_HEAT_LEFT", currentKey = SnapshotKeys.KEY_SEAT_HEAT_L + ), + @SupportedOn(SWI133, SWI68, SWI165) + SET_SEAT_HEAT_RIGHT( + R.string.act_seat_heat_r, ActionGroup.COMFORT, + number(0, 3), "SET_SEAT_HEAT_RIGHT", currentKey = SnapshotKeys.KEY_SEAT_HEAT_R + ), + @SupportedOn(SWI133, SWI68, SWI165) + SET_STEERING_HEAT( + R.string.act_steering_heat, ActionGroup.COMFORT, + ValueSpec.BOOL, "SET_STEERING_HEAT", currentKey = SnapshotKeys.KEY_STEERING_HEAT + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_SCREEN_BRIGHTNESS( + R.string.act_brightness, ActionGroup.COMFORT, + number(VehicleEnums.BRIGHTNESS_MIN, VehicleEnums.BRIGHTNESS_MAX, R.string.unit_percent), + "SET_SCREEN_BRIGHTNESS", currentKey = SnapshotKeys.KEY_BRIGHTNESS + ), + + // ── 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 + // that pre-heats on the motorway is a legitimate one. + // + // Two paths behind one name: the vendor hub on SWI68/SWI165, and `carapi`'s CarHvacClient + // on the A9 generations, which have no such hub (`A9Climate`). The exceptions are ECON and + // the passenger's own target — the A9 client exposes neither, so those two stay where they + // were rather than being offered on a car that would refuse them. + @SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132) + SET_CLIMATE_POWER( + R.string.act_climate_power, ActionGroup.CLIMATE, + ValueSpec.BOOL, "SET_CLIMATE_POWER", currentKey = SnapshotKeys.KEY_CLIMATE_ON + ), + @SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132) + SET_CABIN_TEMP( + R.string.act_cabin_temp, ActionGroup.CLIMATE, + number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius), + "SET_CABIN_TEMP", currentKey = SnapshotKeys.KEY_TEMPERATURE_SET + ), + /** + * The passenger side's target, where [SET_CABIN_TEMP] is the driver's. + * + * 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. + */ + @SupportedOn(SWI68, SWI165) + SET_PASSENGER_TEMP( + R.string.act_passenger_temp, ActionGroup.CLIMATE, + number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius), + "SET_PASSENGER_TEMP", currentKey = SnapshotKeys.KEY_PASSENGER_TEMP + ), + @SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132) + SET_AC( + R.string.act_ac, ActionGroup.CLIMATE, + ValueSpec.BOOL, "SET_AC", currentKey = SnapshotKeys.KEY_AC_ON + ), + @SupportedOn(SWI68, SWI165) + SET_ECON( + R.string.act_econ, ActionGroup.CLIMATE, + ValueSpec.BOOL, "SET_ECON", currentKey = SnapshotKeys.KEY_ECON + ), + @SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132) + SET_CLIMATE_AUTO( + R.string.act_climate_auto, ActionGroup.CLIMATE, + ValueSpec.BOOL, "SET_CLIMATE_AUTO", currentKey = SnapshotKeys.KEY_HVAC_AUTO + ), + @SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132) + SET_RECIRCULATION( + R.string.act_recirc, ActionGroup.CLIMATE, + ValueSpec.BOOL, "SET_RECIRCULATION", currentKey = SnapshotKeys.KEY_RECIRC + ), + @SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132) + SET_FAN_LEVEL( + R.string.act_fan_level, ActionGroup.CLIMATE, + number(0, VehicleEnums.FAN_LEVEL_MAX), "SET_FAN_LEVEL", currentKey = SnapshotKeys.KEY_FAN_SPEED + ), + @SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132) + SET_FRONT_DEFROST( + R.string.act_front_defrost, ActionGroup.CLIMATE, + ValueSpec.BOOL, "SET_FRONT_DEFROST", currentKey = SnapshotKeys.KEY_FRONT_DEFROST + ), + @SupportedOn(SWI68, SWI165, SWI69, SWI131, SWI132) + SET_REAR_DEFROST( + R.string.act_rear_defrost, ActionGroup.CLIMATE, + ValueSpec.BOOL, "SET_REAR_DEFROST", currentKey = SnapshotKeys.KEY_REAR_DEFROST + ), + + /** + * Sends one glass command to all four windows. + * + * **The value is a command in 0..7, not a position.** The service accepts nothing above 7 + * and drops what it will not accept in silence, so the percentage this used to carry was + * discarded on every write above 7 while the history said the action had been applied. + * The reading stays a percentage — [SnapshotKeys.KEY_WINDOW_PERCENT] is what the car + * reports back — which is what makes a command identifiable: send one, read the position. + * + * Which command opens and which closes is not written down anywhere on the firmware and + * no head-unit application sends one, so the whole action is standstill-gated: a write + * whose direction is unknown is not a write to allow at speed. + */ + @SupportedOn(SWI68, SWI165) + SET_WINDOWS( + R.string.act_windows, ActionGroup.CLIMATE, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOWS", + currentKey = null, + gated = true, writeProven = false + ), + /** + * One window's glass command — the counterpart of [SET_WINDOWS], same 0..7 scale. + * + * Both are worth having. Commanding the glass as one gesture belongs in one action that + * cannot leave three answered and one forgotten; addressing the driver's window alone + * cannot be written with an all-or-nothing call. + */ + @SupportedOn(SWI68, SWI165) + SET_WINDOW_DRIVER( + R.string.act_window_driver, ActionGroup.CLIMATE, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOW_DRIVER", + currentKey = null, + gated = true, writeProven = false + ), + @SupportedOn(SWI68, SWI165) + SET_WINDOW_PASSENGER( + R.string.act_window_passenger, ActionGroup.CLIMATE, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOW_PASSENGER", + currentKey = null, + gated = true, writeProven = false + ), + @SupportedOn(SWI68, SWI165) + SET_WINDOW_REAR_LEFT( + R.string.act_window_rear_left, ActionGroup.CLIMATE, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOW_REAR_LEFT", + currentKey = null, + gated = true, writeProven = false + ), + @SupportedOn(SWI68, SWI165) + SET_WINDOW_REAR_RIGHT( + R.string.act_window_rear_right, ActionGroup.CLIMATE, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.WINDOW_COMMANDS), "SET_WINDOW_REAR_RIGHT", + currentKey = null, + gated = true, writeProven = false + ), + @SupportedOn(SWI68, SWI165) + SET_DOOR_LOCK( + R.string.act_door_lock, ActionGroup.CLIMATE, + ValueSpec.BOOL, "SET_DOOR_LOCK", currentKey = SnapshotKeys.KEY_DOORS_LOCKED + ), + + // ── Energy (vendor service — com.evsuite.hardware.saic.SaicCharging) ───────── + @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 + ), + @SupportedOn(SWI68, SWI165) + SET_CHARGING_ENABLED( + R.string.act_charging_enabled, ActionGroup.ENERGY, + ValueSpec.BOOL, "SET_CHARGING_ENABLED" + ), + @SupportedOn(SWI68, SWI165) + SET_CHARGE_SCHEDULE( + R.string.act_charge_schedule, ActionGroup.ENERGY, + ValueSpec.BOOL, "SET_CHARGE_SCHEDULE", currentKey = SnapshotKeys.KEY_CHARGE_SCHEDULE + ), + /** + * The scheduled charging window. A range rather than two actions: start and stop are + * four separate vehicle signals, and setting only half of them leaves the car with a + * window nobody intended. + */ + @SupportedOn(SWI68, SWI165) + SET_CHARGE_WINDOW( + R.string.act_charge_window, ActionGroup.ENERGY, + ValueSpec(ValueKind.TIME_RANGE), "SET_CHARGE_WINDOW" + ), + @SupportedOn(SWI68, SWI165) + SET_BATTERY_PREHEAT( + R.string.act_battery_preheat, ActionGroup.ENERGY, + ValueSpec.BOOL, "SET_BATTERY_PREHEAT", currentKey = SnapshotKeys.KEY_BATTERY_PREHEAT + ), + + // ── Audio ──────────────────────────────────────────────────────────────── + // Everything below the media volume goes through the SAIC `caradapter` audio helper, + // which EVHardware binds only on the A9 platform (SWI69 / SWI131 / SWI132). These + // entries used to be annotated SWI133 + SWI132, read off `hasAudioControl()` — but that + // predicate describes the door-volume feature, a different thing. The effect was an + // action offered on SWI133, where the helper is never bound and the write silently + // returns false, and hidden on SWI69 / SWI131, where it works. + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_MEDIA_VOLUME( + R.string.act_media_volume, ActionGroup.AUDIO, + ValueSpec.dynamicNumber(0, VehicleEnums.MEDIA_VOLUME_FALLBACK_MAX), + "SET_MEDIA_VOLUME", currentKey = SnapshotKeys.KEY_MEDIA_VOLUME + ), + + /** + * Moves the volume by a number of steps instead of setting one. + * + * The two are not interchangeable. A target is what a rule wants at ignition — "start the + * day at 12". A step is what a *button* wants, and what a rule wants when it must not + * discard what the driver chose: "two quieter when the phone rings" keeps their setting, + * where a target throws it away. Negative goes down, and either end of the range is + * reported as done rather than refused. + */ + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + ADJUST_MEDIA_VOLUME( + R.string.act_adjust_media_volume, ActionGroup.AUDIO, + number(-VehicleEnums.MEDIA_VOLUME_FALLBACK_MAX, VehicleEnums.MEDIA_VOLUME_FALLBACK_MAX), + "ADJUST_MEDIA_VOLUME" + ), + @SupportedOn(SWI69, SWI131, SWI132) + SET_AUDIO_BALANCE( + R.string.act_balance, ActionGroup.AUDIO, + number(VehicleEnums.AUDIO_LEVEL_MIN, VehicleEnums.AUDIO_LEVEL_MAX), + "SET_AUDIO_BALANCE" + ), + @SupportedOn(SWI69, SWI131, SWI132) + SET_AUDIO_FADER( + R.string.act_fader, ActionGroup.AUDIO, + number(VehicleEnums.AUDIO_LEVEL_MIN, VehicleEnums.AUDIO_LEVEL_MAX), + "SET_AUDIO_FADER" + ), + @SupportedOn(SWI69, SWI131, SWI132) + SET_TONE_CONTROL( + R.string.act_tone, ActionGroup.AUDIO, + number(VehicleEnums.AUDIO_LEVEL_MIN, VehicleEnums.AUDIO_LEVEL_MAX), + "SET_TONE_CONTROL" + ), + @SupportedOn(SWI69, SWI131, SWI132) + SET_BOSE_SOUND_TYPE( + R.string.act_bose, ActionGroup.AUDIO, + number(VehicleEnums.AUDIO_TYPE_MIN, VehicleEnums.AUDIO_TYPE_MAX), + "SET_BOSE_SOUND_TYPE" + ), + @SupportedOn(SWI69, SWI131, SWI132) + SET_3D_EFFECT( + R.string.act_3d, ActionGroup.AUDIO, + number(VehicleEnums.AUDIO_TYPE_MIN, VehicleEnums.AUDIO_TYPE_MAX), + "SET_3D_EFFECT" + ), + @SupportedOn(SWI69, SWI131, SWI132) + SET_SPEED_VOLUME( + R.string.act_speed_volume, ActionGroup.AUDIO, + number(VehicleEnums.AUDIO_TYPE_MIN, VehicleEnums.AUDIO_TYPE_MAX), + "SET_SPEED_VOLUME" + ), + + /** + * Makes radio the current audio source, resuming the last station. + * + * Nothing to configure: `srcPlayRadio` takes no argument, and it resumes the last + * station. [TUNE_RADIO] is the one to use when the station matters. + * + * It resumes rather than opening the radio screen: a rule firing at ignition wants the + * sound, not a screen thrown in front of the driver. + * + * Vendor service, so it carries firmware support like any other vehicle entry — the + * radio is not an ordinary Android app that could be launched instead. + */ + @SupportedOn(SWI68, SWI165) + PLAY_RADIO( + R.string.act_play_radio, ActionGroup.AUDIO, + ValueSpec.NONE, "PLAY_RADIO" + ), + + /** + * Puts the tuner where the rule says: a **band**, an optional **frequency**, and whether + * the radio then **plays**. + * + * The three used to be two actions and a switch, and that split was the wrong seam. A + * band alone could not carry a station, a frequency alone could not reach **DAB**, and a + * driver saying "put the radio on FM 103.5" was made to write two rows. They are one + * instruction, so they are one entry. + * + * `Action.number` is the band ([VehicleEnums.RADIO_BANDS], the vendor `RadioType` values), + * `Action.text` the frequency, and `Action.flag` the "enable radio" switch. `0` is the + * band of every rule saved before the merge: it means "whatever the frequency says", which + * is exactly what those rules did. + * + * The frequency is free text rather than a slider: the driver knows their station as + * "103.5", and an FM slider covering 87.5–108.0 in 50 kHz steps is 410 positions to drag + * past on a touchscreen. It is parsed leniently — "103.5", "FM 103.5", "103,5", "1080 AM" + * all land — and a value that parses to nothing is reported as unsupported with what was + * typed, so the history names the typo. Bands are AM (522–1620 kHz) and FM (87.5–108.0 + * MHz), from `RadioConstants.AM_RANGE` / `FM_RANGE`. + * + * **Leaving the frequency empty is not an omission**, it is the band-only instruction — + * `SaicRadio.selectBand` reads where the tuner is and moves it, landing on the station + * this car was last heard on for that band. It is the only form DAB has: `tuneDab` takes + * an ensemble and a service id, not a frequency, so there is nothing there for a driver to + * type, and the editor stops asking once DAB is chosen. + * + * Audio-only, so it is not gated: changing station or band is what the wheel's own buttons + * already do at speed. + */ + @SupportedOn(SWI68, SWI165) + TUNE_RADIO( + R.string.act_tune_radio, ActionGroup.AUDIO, + ValueSpec( + ValueKind.RADIO, + options = VehicleEnums.RADIO_BANDS, + hintRes = R.string.value_radio_hint + ), + "TUNE_RADIO" + ), + + /** + * Steps through the tuner's stations, on whichever band it is already on. + * + * The one way to choose a **DAB** station: a DAB service is addressed by ensemble and + * service id, so there is no frequency for a driver to type. [TUNE_RADIO] reaches the DAB + * *band*, and this is what then moves along it — the tuner's own list is the right one. + * + * Separate from [ActionType.MEDIA_CONTROL] on purpose. That one skips a *track* on + * whatever owns the audio; this one changes *station* whether or not the radio is the + * current source, which is a different intent and a different service call. + */ + @SupportedOn(SWI68, SWI165) + RADIO_NEXT_STATION( + R.string.act_radio_next_station, ActionGroup.AUDIO, + ValueSpec.NONE, "RADIO_NEXT_STATION" + ), + + @SupportedOn(SWI68, SWI165) + RADIO_PREV_STATION( + R.string.act_radio_prev_station, ActionGroup.AUDIO, + ValueSpec.NONE, "RADIO_PREV_STATION" + ), + + /** + * Silences the tuner — `srcPauseRadio`, the counterpart of [PLAY_RADIO]. + * + * Not [MEDIA_CONTROL]'s play/pause, which addresses whichever source owns the audio and + * would therefore stop Bluetooth when Bluetooth is the one playing. This one names the + * radio, so a rule that silences the news on arrival silences the news. + * + * It mutes rather than handing the audio focus back: the vendor service only abandons + * focus when it loses it, so whatever played before the radio does not resume on its own. + * Silence is the most the service offers, and the label says no more than that. + */ + @SupportedOn(SWI68, SWI165) + PAUSE_RADIO( + R.string.act_pause_radio, ActionGroup.AUDIO, + ValueSpec.NONE, "PAUSE_RADIO" + ), + + /** + * Toggles the tuner between playing and silent, on the state the tuner reports. + * + * One entry for a driver who has one button and wants the radio specifically — again not + * [MEDIA_CONTROL], which follows the current source wherever it went. + * + * The direction is read, never assumed. `AudioManager.isMusicActive` is **false while the + * radio plays**, its stream not being the music one, so the obvious substitute would send + * "play" to a playing radio forever; `SaicRadio.isPlaying` reads `RadioBean.state` + * instead. When even that cannot be read the action sends nothing and reports why: a + * media shortcut can be pressed a second time, an unattended rule cannot, and a toggle + * that guessed wrong leaves the car silent — or playing — with nothing saying the + * direction was invented. + */ + @SupportedOn(SWI68, SWI165) + RADIO_PLAY_PAUSE( + R.string.act_radio_play_pause, ActionGroup.AUDIO, + ValueSpec.NONE, "RADIO_PLAY_PAUSE" + ), + + /** + * Brings the radio screen up — `startRadioActivity`, a different intent from playing. + * + * The one radio action that is not audio-only, and the only one that is [gated]. Skipping + * a station changes what comes out of the speakers; this replaces what is on the screen, + * and a full-screen app thrown in front of a driver at 110 km/h takes their eyes off the + * road for as long as it takes to understand what happened. It follows the standstill + * gate for the same reason the glass does, refusal on an unreadable speed included — + * which is the gate the rest of the audio family is deliberately outside of, and stays + * outside of. + */ + @SupportedOn(SWI68, SWI165) + OPEN_RADIO_SCREEN( + R.string.act_open_radio_screen, ActionGroup.AUDIO, + ValueSpec.NONE, "OPEN_RADIO_SCREEN", gated = true + ), + + // ── ADAS (gated) ───────────────────────────────────────────────────────── + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_AEB_ENABLED( + R.string.act_aeb, ActionGroup.ADAS, + ValueSpec.BOOL, "SET_AEB_ENABLED", gated = true, currentKey = SnapshotKeys.KEY_AEB_ENABLED + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_AEB_MODE( + R.string.act_aeb_mode, ActionGroup.ADAS, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.AEB_MODES), + "SET_AEB_MODE", gated = true, currentKey = SnapshotKeys.KEY_AEB_MODE + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_AEB_SENSITIVITY( + R.string.act_aeb_sensitivity, ActionGroup.ADAS, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.SENSITIVITIES), + "SET_AEB_SENSITIVITY", gated = true, currentKey = SnapshotKeys.KEY_AEB_SENSITIVITY + ), + + /** + * Electronic stability control. + * + * Gated like every driving write, and refused by EVHardware for a second reason of its + * own: the write is a **toggle** driven by a read, so it only runs on an ignition known to + * be in RUN and on three agreeing readings. Getting in while the cluster is still dark, + * the property does not yet reflect reality, and aiming at ON from a false OFF turns off + * an ESC that was on — silently. A rule that wants it off is simply honoured a moment + * later, once the car is awake. + */ + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_ESC( + R.string.act_esc, ActionGroup.ADAS, + ValueSpec.BOOL, "SET_ESC", gated = true, currentKey = SnapshotKeys.KEY_ESC + ), + + /** + * The drowsiness warning — UDW, the one the car raises when the driving gets unsteady. + * + * Deliberately the UDW switch and not the camera-based DMS one: both exist, their labels + * read alike, and writing the camera one changed nothing visible. + */ + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_DROWSINESS( + R.string.act_drowsiness, ActionGroup.ADAS, + ValueSpec.BOOL, "SET_DROWSINESS", gated = true, currentKey = SnapshotKeys.KEY_DROWSINESS + ), + + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_DROWSINESS_SENSITIVITY( + R.string.act_drowsiness_sensitivity, ActionGroup.ADAS, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.SENSITIVITIES), + "SET_DROWSINESS_SENSITIVITY", gated = true, + currentKey = SnapshotKeys.KEY_DROWSINESS_SENSITIVITY + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_ELK_MODE( + R.string.act_elk_mode, ActionGroup.ADAS, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.ELK_MODES), + "SET_ELK_MODE", gated = true, currentKey = SnapshotKeys.KEY_ELK_MODE + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_ELK_SENSITIVITY( + R.string.act_elk_sensitivity, ActionGroup.ADAS, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.SENSITIVITIES), + "SET_ELK_SENSITIVITY", gated = true, currentKey = SnapshotKeys.KEY_ELK_SENSITIVITY + ), + @SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165) + SET_ACC_TJA_MODE( + R.string.act_acc_tja, ActionGroup.ADAS, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.ACC_TJA_MODES), + "SET_ACC_TJA_MODE", gated = true, currentKey = SnapshotKeys.KEY_ACC_TJA_MODE + ), + @SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165) + SET_LIMITER_MODE( + R.string.act_limiter, ActionGroup.ADAS, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.LIMITER_MODES), + "SET_LIMITER_MODE", gated = true, currentKey = SnapshotKeys.KEY_LIMITER_MODE + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SET_TSR( + R.string.act_tsr, ActionGroup.ADAS, + ValueSpec.BOOL, "SET_TSR", gated = true, currentKey = SnapshotKeys.KEY_TSR + ), + @SupportedOn(SWI133, SWI132) + SET_OVERSPEED_ALARM( + R.string.act_overspeed, ActionGroup.ADAS, + ValueSpec.BOOL, "SET_OVERSPEED_ALARM", gated = true, currentKey = SnapshotKeys.KEY_OVERSPEED_ALARM + ), + @SupportedOn(SWI133, SWI132) + SET_SPEED_LIMIT_TONE( + R.string.act_speed_limit_tone, ActionGroup.ADAS, + ValueSpec.BOOL, "SET_SPEED_LIMIT_TONE", gated = true, currentKey = SnapshotKeys.KEY_SPEED_LIMIT_TONE + ), + @SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165) + SET_SOUND_WARNING( + R.string.act_sound_warning, ActionGroup.ADAS, + ValueSpec.BOOL, "SET_SOUND_WARNING", gated = true, currentKey = SnapshotKeys.KEY_SOUND_WARNING + ), + @SupportedOn(SWI132) + SET_LAS_WARNING_SOUND( + R.string.act_las_sound, ActionGroup.ADAS, + ValueSpec.BOOL, "SET_LAS_WARNING_SOUND" + ), + @SupportedOn(SWI132) + SET_LAS_WARNING_VIBRATION( + R.string.act_las_vibration, ActionGroup.ADAS, + ValueSpec.BOOL, "SET_LAS_WARNING_VIBRATION" + ), + + // ── System (local, no vehicle access, firmware-independent) ────────────── + LAUNCH_APP( + R.string.act_launch_app, ActionGroup.SYSTEM, + ValueSpec(ValueKind.APP), bridgeAction = null + ), + SHOW_NOTIFICATION( + R.string.act_notify, ActionGroup.SYSTEM, + ValueSpec(ValueKind.TEXT), bridgeAction = null + ), + /** + * Speaks the text through the platform TTS engine. Local like the two above: nothing + * is written to the vehicle, so no firmware annotation and no bridge action. + */ + SPEAK_TEXT( + R.string.act_speak, ActionGroup.SYSTEM, + ValueSpec(ValueKind.TEXT), bridgeAction = null + ), + /** + * Hands a destination to whatever navigation app the head unit has, through the standard + * `geo:` intent. Local and firmware-independent: no vendor service is involved, and the + * MG4's navigation is not part of the SAIC vehicle SDK. + * + * [Action.text] is an address or "latitude,longitude". + */ + NAVIGATE_TO( + R.string.act_navigate, ActionGroup.SYSTEM, + ValueSpec(ValueKind.DESTINATION), bridgeAction = null + ), + /** + * Puts a yes/no question on screen and stops the rule there when the answer is no. + * + * The only action whose result decides whether the rest of the rule runs. That is the + * point of it: a rule that opens the windows or unlocks the doors on arrival is right + * most of the time and wrong the once, and the driver is the only one who knows which + * this is. Placed first, it turns an automatic rule into a proposed one. + * + * `Action.text` is the question and `Action.number` how many seconds it waits. `0` means + * [ASK_CONFIRM_DEFAULT_SECONDS]: rules saved before the wait was configurable carry no + * value, and the field they never set must not read as an instant refusal. + * + * Silence counts as **no** by default: the actions behind a confirmation are the ones the + * user did not want applied unattended. `Action.yesOnNoAnswer` turns that around for the + * other half of the need — a rule that acts unless it is stopped, where the question is a + * chance to object rather than a permission to ask for. It says nothing about a + * *deliberate* no: tapping "no", or leaving the question, still stops the rule, and a + * question that could never be put on screen still counts as no whatever the setting. + */ + ASK_CONFIRM( + R.string.act_ask_confirm, ActionGroup.SYSTEM, + ValueSpec( + ValueKind.CONFIRM, + min = 5, max = 60, + unitRes = R.string.unit_second, hintRes = R.string.act_ask_confirm_hint + ), + bridgeAction = null + ), + /** + * Sends a text message through the paired phone. + * + * Same reasoning as [CALL_NUMBER], one profile further out: the head unit has no SIM, so + * there is nothing here to send from. The vendor hands-free service does not help either + * — its `IBtCall` interface is calls only, with no message transaction to address — so + * the message goes out over the Bluetooth Message Access Profile, which the car's own + * Bluetooth settings manage as "MAP Client". Whether that profile is up is a bind, not a + * table: the action reports itself unsupported on a car or a phone that does not carry it. + * + * `Action.text` is the number, `Action.displayName` the contact it was picked from, and + * `Action.payload` the message. + * + * Not standstill-gated, for the reason a call is not: it writes nothing to the vehicle, + * and the message was written when the rule was, not at the wheel. + */ + SEND_SMS( + R.string.act_send_sms, ActionGroup.SYSTEM, + ValueSpec(ValueKind.SMS, hintRes = R.string.act_send_sms_hint), bridgeAction = null + ), + /** + * Turns another of the user's rules on, or off. + * + * The cheapest thing in the catalogue that changes what rules can express: a rule that + * only applies during a trip is one rule enabling a second at departure and disabling it + * on arrival, with no state to store and nothing new to evaluate. `Action.text` is the + * target rule's id, and a rule that no longer exists is reported as unsupported — never + * silently skipped, because a chain whose middle link vanished is exactly what a user + * needs told. + * + * A rule cannot disable itself into a state it can never leave: the switch is the same one + * the rule list shows, so the user can always put it back. + */ + ENABLE_RULE( + R.string.act_enable_rule, ActionGroup.SYSTEM, + ValueSpec(ValueKind.RULE), bridgeAction = null + ), + DISABLE_RULE( + R.string.act_disable_rule, ActionGroup.SYSTEM, + ValueSpec(ValueKind.RULE), bridgeAction = null + ), + /** + * Play/pause, next or previous — sent to the source that is actually playing. + * + * Not a media key, which is the implementation this started as. The car publishes one + * Android media session (`com.android.bluetooth`), so the key either reaches nothing or + * reaches Bluetooth, wakes a phone that was not playing and **changes the audio source** + * while the radio was on. `SaicMediaPlayer` asks the vendor service which source owns the + * audio and commands that source's own player, falling back to media sessions on the A9 + * generations, which have no such service. + * + * The values are unchanged (85/87/88, the Android key codes): saved rules store the + * number, and repurposing them would silently redefine every existing rule. + */ + MEDIA_CONTROL( + R.string.act_media_control, ActionGroup.SYSTEM, + ValueSpec(ValueKind.ENUM, options = MediaCommand.OPTIONS), bridgeAction = null + ), + /** + * The head unit's own radios. + * + * Turning Bluetooth off ends the hands-free link, and with it every Bluetooth condition — + * a rule that switches it off and then asks which phone is on board gets "unreadable", + * which is the truth. + */ + SET_BLUETOOTH( + R.string.act_set_bluetooth, ActionGroup.SYSTEM, + ValueSpec.BOOL, bridgeAction = null + ), + SET_WIFI( + R.string.act_set_wifi, ActionGroup.SYSTEM, + ValueSpec.BOOL, bridgeAction = null + ), + /** + * Calls an HTTP(S) endpoint. [Action.flag] carries the verb — false for GET, true for + * POST — and [Action.payload] is the POST body, ignored for GET. + */ + WEBHOOK( + R.string.act_webhook, ActionGroup.SYSTEM, + ValueSpec(ValueKind.WEBHOOK), bridgeAction = null + ), + + /** + * Waits, in seconds, before the rule's next action runs. + * + * A rule's actions are executed one after the other, so a rule that switches the climate + * on and then sets the fan level asks for the second value while the car is still acting + * on the first — and the vehicle answers from the state it had. This is the pause between + * the two, placed by the user where they know one write needs to land before the next. + * + * Bounded to a minute: the wait holds the cycle thread, and a rule that pauses longer + * than the ignition transition it reacts to is a rule whose later actions may never run. + */ + DELAY( + R.string.act_delay, ActionGroup.SYSTEM, + number(1, 60, R.string.unit_second), bridgeAction = null + ), + + /** + * Calls a number through the car's own hands-free stack. + * + * A vehicle action despite looking like a phone one: the head unit has no SIM and no + * dialer, so the call is placed by the vendor Bluetooth service on the paired handset. + * `ACTION_CALL` would find nothing to handle it. + */ + @SupportedOn(SWI68, SWI165) + CALL_NUMBER( + R.string.act_call, ActionGroup.SYSTEM, + ValueSpec(ValueKind.CONTACT), "CALL_NUMBER" + ), + /** + * Kept only so rules saved by releases that exposed a separate contact action still load. + * New rules use [CALL_NUMBER], whose editor accepts either a number or a contact. + */ + @Deprecated("Compatibility alias for saved rules; use CALL_NUMBER") + @SupportedOn(SWI68, SWI165) + CALL_CONTACT( + R.string.act_call_contact, ActionGroup.SYSTEM, + ValueSpec(ValueKind.CONTACT), "CALL_CONTACT" + ); + + companion object { + @Suppress("DEPRECATION") + fun byGroup(): Map> = + entries.filterNot { it == CALL_CONTACT }.groupBy { it.group } + + /** + * The wait [ASK_CONFIRM] uses when its action carries none. + * + * The spec's own floor (5 s) is not zero: a question that closes before it can be + * read is a refusal dressed as a choice. Its ceiling (60 s) holds a rule's cycle for + * at most a minute, the same order as the delay budget the engine already enforces. + */ + const val ASK_CONFIRM_DEFAULT_SECONDS = 10 + } +} + +/** + * What [ActionType.MEDIA_CONTROL] sends. + * + * The values are the platform's own media key codes, so the runner has nothing to translate + * and a wrong constant cannot become a different key by accident. Only the three a driver + * asks a rule for: stop and rewind are the wheel's job, not a rule's. + */ +object MediaCommand { + const val PLAY_PAUSE = 85 + const val NEXT = 87 + const val PREVIOUS = 88 + + val OPTIONS = listOf( + EnumOption(PLAY_PAUSE, R.string.media_play_pause), + EnumOption(NEXT, R.string.media_next), + EnumOption(PREVIOUS, R.string.media_previous) + ) +} 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 new file mode 100644 index 0000000..88667dc --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/ConditionType.kt @@ -0,0 +1,596 @@ +package com.evsuite.hardware.catalog + +import androidx.annotation.StringRes +import com.evsuite.hardware.R +import com.evsuite.hardware.catalog.SnapshotKeys +import com.evsuite.hardware.catalog.VehicleEnums +import com.evsuite.hardware.SupportedOn +import com.evsuite.hardware.FirmwareGen.SWI131 +import com.evsuite.hardware.FirmwareGen.SWI132 +import com.evsuite.hardware.FirmwareGen.SWI133 +import com.evsuite.hardware.FirmwareGen.SWI165 +import com.evsuite.hardware.FirmwareGen.SWI68 +import com.evsuite.hardware.FirmwareGen.SWI69 +import com.evsuite.hardware.catalog.ValueSpec.Companion.enum +import com.evsuite.hardware.catalog.ValueSpec.Companion.number + +/** Grouping in the condition picker. */ +enum class ConditionGroup(@StringRes val labelRes: Int) { + CONTEXT(R.string.group_context), + ENVIRONMENT(R.string.group_environment), + DRIVING(R.string.group_driving), + ENERGY(R.string.group_energy), + CLIMATE(R.string.group_climate), + COMFORT(R.string.group_comfort), + AUDIO(R.string.group_audio), + ADAS(R.string.group_adas) +} + +/** + * Catalogue of evaluable conditions. + * + * [snapshotKey] is the key read from the snapshot EVProfile returns. When it is absent + * from the snapshot the condition is UNAVAILABLE — not false. The distinction is + * deliberate: on a firmware that does not expose outside temperature, a weather rule must + * show as "cannot be evaluated" and do nothing, never behave as if it were 0 °C. + * + * [snapshotKey] is null for conditions computed locally (time, day, Bluetooth), which do + * not depend on the vehicle. + * + * The [SupportedOn] annotation on each vehicle entry declares which firmware generations + * it works on, derived from EVProfile's `FirmwareInfo` and `EVHardware` routing. It + * drives the README matrix (generated) and the editor's runtime filter. Context entries + * carry no annotation — they are firmware-independent. + */ +enum class ConditionType( + @StringRes val labelRes: Int, + val group: ConditionGroup, + val spec: ValueSpec, + val snapshotKey: String?, + /** true when comparison is meaningful (otherwise only equality is offered). */ + val comparable: Boolean = false, + /** True when the condition itself supplies the event that addresses the rule. */ + val eventDriven: Boolean = false +) { + + // ── Context (firmware-independent) ─────────────────────────────────────── + BT_DEVICE_CONNECTED( + R.string.cond_bt_device, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.BT_DEVICE), snapshotKey = null + ), + ANY_BT_CONNECTED( + R.string.cond_bt_any, ConditionGroup.CONTEXT, + ValueSpec.BOOL, snapshotKey = null + ), + /** + * A phone that is *in the car*, not merely within radio range of it. + * + * [BT_DEVICE_CONNECTED] answers "the link is up", which a phone left in the house + * answers just as well when the car is parked ten metres away — and it is what made + * arrival rules fire on the driveway. This one is only true of a device still connected + * once the car has actually moved, so a phone that stayed behind drops out of it. + * + * Unknowable until the car has driven: before that the condition is UNAVAILABLE, never + * false, and rules that must act at ignition should gate on [BT_DEVICE_CONNECTED] plus + * a vehicle signal instead. + */ + BT_DEVICE_ONBOARD( + R.string.cond_bt_onboard, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.BT_DEVICE), snapshotKey = null + ), + /** + * The phone the head unit has made its hands-free device. + * + * The head unit picks one, whatever the number of phones in range, and that choice is + * available from the first second of the drive — which is what [BT_DEVICE_ONBOARD] + * cannot offer. With two phones both in range it is the head unit's answer, not a + * measurement, so it is a hint about which phone is driving, not proof. + */ + BT_DEVICE_HANDSFREE( + R.string.cond_bt_handsfree, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.BT_DEVICE), snapshotKey = null + ), + TIME_OF_DAY( + R.string.cond_time, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.TIME_RANGE), snapshotKey = null + ), + DAY_OF_WEEK( + R.string.cond_day, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.DAYS), snapshotKey = null + ), + DATE( + R.string.cond_date, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.DATE), snapshotKey = null + ), + FIRMWARE_GEN( + R.string.cond_firmware, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.ENUM), snapshotKey = SnapshotKeys.KEY_FIRMWARE_GEN + ), + /** + * Inside a radius of a saved point. Context, not a vehicle signal: the fix comes from + * the platform location provider, so it works the same whatever the firmware exposes. + */ + LOCATION_WITHIN( + R.string.cond_location, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.LOCATION, min = 50, max = 2000, unitRes = R.string.unit_metre), + snapshotKey = null + ), + PHYSICAL_BUTTON( + R.string.cond_physical_button, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.PHYSICAL_BUTTON), SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT, + eventDriven = true + ), + /** + * The network the head unit is joined to, by name. + * + * The one context signal that says "the car is at a place it knows" without a satellite: + * a home network answers the instant the car is on the drive, where a GPS fix does not. + * Unreadable — radio off, or the platform declining to name it — leaves the condition + * unavailable rather than saying "some other network". + */ + WIFI_SSID( + R.string.cond_wifi_ssid, ConditionGroup.CONTEXT, + ValueSpec(ValueKind.TEXT, hintRes = R.string.cond_wifi_ssid_hint), + SnapshotKeys.KEY_WIFI_SSID + ), + /** + * A call is in progress through the car. + * + * "In a call", not "ringing": what this reads is the audio route the head unit has taken, + * which a ringing phone has not taken yet. A rule that must not talk over a call is served + * by it; one that wants to react to an incoming call is not, and nothing here pretends + * otherwise. + */ + IN_CALL( + R.string.cond_in_call, ConditionGroup.CONTEXT, + ValueSpec.BOOL, SnapshotKeys.KEY_IN_CALL + ), + /** + * Minutes since the ignition came on. + * + * Counted from the transition the service already listens for, so it costs no poll. It is + * unreadable until that transition has been seen — a rule cycle that starts on a car + * already running does not know when the drive began, and must not answer 0. + */ + DRIVE_DURATION( + R.string.cond_drive_duration, ConditionGroup.CONTEXT, + number(0, 600, R.string.unit_minute), SnapshotKeys.KEY_DRIVE_MINUTES, comparable = true + ), + /** + * Matches with the given probability, in percent. + * + * Reads nothing, so it has no snapshot key: it is drawn at evaluation time. It exists for + * the rules whose point is not to be the same every day — one greeting in five, a + * different radio station now and then. + */ + RANDOM_CHANCE( + R.string.cond_random_chance, ConditionGroup.CONTEXT, + number(1, 100, R.string.unit_percent), snapshotKey = null + ), + + // ── Environment ────────────────────────────────────────────────────────── + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + OUTSIDE_TEMP( + R.string.cond_outside_temp, ConditionGroup.ENVIRONMENT, + number(-30, 50, R.string.unit_celsius), + SnapshotKeys.KEY_OUTSIDE_TEMP, comparable = true + ), + + /** + * What the head unit's weather service says the sky is doing where the car is, as one of + * [WeatherConditions]' seven states. + * + * A list, not free text, and the reason is that the free-text version could not be + * written: the service answers a phrase of its own choosing ("Light rain", "Rain + * showers") in the head unit's own language, so a rule had to guess the provider's exact + * wording and stopped matching the day the display language changed. The phrase is + * classified on the way into the snapshot instead, and the rule stores a state the + * catalogue owns. + * + * A phrase no fragment recognises leaves the key absent, so the condition is + * **unavailable** rather than "some other weather" — same as a service that does not + * answer at all. + * + * Like [ODOMETER], no firmware annotation: it is an app service, available or not. + */ + WEATHER_NOW( + R.string.cond_weather, ConditionGroup.ENVIRONMENT, + ValueSpec(ValueKind.ENUM, options = WeatherConditions.OPTIONS), + SnapshotKeys.KEY_WEATHER_CONDITION + ), + + // ── Driving ────────────────────────────────────────────────────────────── + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + IGNITION_STATE( + R.string.cond_ignition, ConditionGroup.DRIVING, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.IGNITION_STATES), + SnapshotKeys.KEY_IGNITION + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + IN_PARK( + R.string.cond_in_park, ConditionGroup.DRIVING, + ValueSpec.BOOL, SnapshotKeys.KEY_IN_PARK + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SPEED( + R.string.cond_speed, ConditionGroup.DRIVING, + number(0, 200, R.string.unit_kmh), + SnapshotKeys.KEY_SPEED_KMH, comparable = true + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + DRIVE_MODE( + R.string.cond_drive_mode, ConditionGroup.DRIVING, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.DRIVE_MODES), + SnapshotKeys.KEY_DRIVE_MODE + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + REGEN_LEVEL( + R.string.cond_regen, ConditionGroup.DRIVING, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.REGEN_LEVELS), + SnapshotKeys.KEY_REGEN_LEVEL + ), + /** + * The odometer, in kilometres. + * + * No firmware annotation, and it is not an oversight: this comes from the head unit's + * navigation adapter, an app service rather than part of the vehicle SDK, so nothing + * routes it per generation — it answers or it does not, exactly like the message action. + */ + ODOMETER( + R.string.cond_odometer, ConditionGroup.DRIVING, + number(0, 500_000, R.string.unit_km), SnapshotKeys.KEY_ODOMETER_KM, comparable = true + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + ENERGY_SAVING( + R.string.cond_energy_saving, ConditionGroup.DRIVING, + ValueSpec.BOOL, SnapshotKeys.KEY_ENERGY_SAVING + ), + + // ── Energy (vendor charging service — see com.evsuite.hardware.saic.SaicCharging) ── + @SupportedOn(SWI68, SWI165) + BATTERY_LEVEL( + R.string.cond_battery, ConditionGroup.ENERGY, + number(0, 100, R.string.unit_percent), + SnapshotKeys.KEY_BATTERY_PERCENT, comparable = true + ), + @SupportedOn(SWI68, SWI165) + CHARGING( + R.string.cond_charging, ConditionGroup.ENERGY, + ValueSpec.BOOL, SnapshotKeys.KEY_CHARGING + ), + @SupportedOn(SWI68, SWI165) + CHARGE_LIMIT( + R.string.cond_charge_limit, ConditionGroup.ENERGY, + number(0, 100, R.string.unit_percent), + SnapshotKeys.KEY_CHARGE_LIMIT, comparable = true + ), + /** + * The charging state itself, where [CHARGING] is only "current is flowing". + * + * The two answer different questions and both are worth asking: a rule that pre-heats the + * cabin wants "charging", while a rule that warns before the driver walks away wants + * "plugged in but not charging" — a state the boolean cannot name. + */ + @SupportedOn(SWI68, SWI165) + CHARGING_STATUS( + R.string.cond_charging_status, ConditionGroup.ENERGY, + ValueSpec(ValueKind.ENUM, options = VehicleEnums.CHARGING_STATUSES), + SnapshotKeys.KEY_CHARGING_STATUS + ), + @SupportedOn(SWI68, SWI165) + CHARGE_SCHEDULE_ENABLED( + R.string.cond_charge_schedule, ConditionGroup.ENERGY, + ValueSpec.BOOL, SnapshotKeys.KEY_CHARGE_SCHEDULE + ), + /** + * The scheduled window's two ends, read back from the car. + * + * Two conditions rather than the one range [ActionType.SET_CHARGE_WINDOW] writes: the + * write has to move both ends together or the car is left with a window nobody intended, + * while a rule reading them asks about one end at a time ("charging starts after + * midnight"). Comparable, so "before" and "after" are available and not only equality. + */ + @SupportedOn(SWI68, SWI165) + CHARGE_WINDOW_START( + R.string.cond_charge_window_start, ConditionGroup.ENERGY, + ValueSpec(ValueKind.TIME), SnapshotKeys.KEY_CHARGE_WINDOW_START, comparable = true + ), + @SupportedOn(SWI68, SWI165) + CHARGE_WINDOW_STOP( + R.string.cond_charge_window_stop, ConditionGroup.ENERGY, + ValueSpec(ValueKind.TIME), SnapshotKeys.KEY_CHARGE_WINDOW_STOP, comparable = true + ), + /** + * Remaining range, in kilometres. + * + * The cluster's own number rather than a percentage turned into distance: a rule that + * diverts to a charger is about distance, and battery percentage does not answer it on a + * cold motorway. + */ + @SupportedOn(SWI68, SWI165) + RANGE_ESTIMATE( + R.string.cond_range, ConditionGroup.ENERGY, + number(0, 600, R.string.unit_km), SnapshotKeys.KEY_RANGE_KM, comparable = true + ), + @SupportedOn(SWI68, SWI165) + BATTERY_PREHEAT( + R.string.cond_battery_preheat, ConditionGroup.ENERGY, + ValueSpec.BOOL, SnapshotKeys.KEY_BATTERY_PREHEAT + ), + + // ── Climate + windows (read only, unverified on MG4 — see EVHardware) ──── + /** + * Whether the climate system is running, from the vendor service rather than an AOSP + * property id — the same source the car's own HVAC screen reads. + */ + @SupportedOn(SWI68, SWI165) + CLIMATE_ON( + R.string.cond_climate_on, ConditionGroup.CLIMATE, + ValueSpec.BOOL, SnapshotKeys.KEY_CLIMATE_ON + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + AC_ON( + R.string.cond_ac, ConditionGroup.CLIMATE, + ValueSpec.BOOL, SnapshotKeys.KEY_AC_ON + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + HVAC_AUTO( + R.string.cond_hvac_auto, ConditionGroup.CLIMATE, + ValueSpec.BOOL, SnapshotKeys.KEY_HVAC_AUTO + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + RECIRC( + R.string.cond_recirc, ConditionGroup.CLIMATE, + ValueSpec.BOOL, SnapshotKeys.KEY_RECIRC + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + // Bounds from the vendor HMI (HvacConst.AIR_VOLUME_MAX, and the 17…33 clamp in + // HvacActivity where the ends read as LO and HI), not from the AOSP property range. + FAN_SPEED( + R.string.cond_fan_speed, ConditionGroup.CLIMATE, + number(0, VehicleEnums.FAN_LEVEL_MAX), SnapshotKeys.KEY_FAN_SPEED, comparable = true + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + TEMPERATURE_SET( + R.string.cond_temperature_set, ConditionGroup.CLIMATE, + number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius), + SnapshotKeys.KEY_TEMPERATURE_SET, comparable = true + ), + /** + * The passenger side's own target, where [TEMPERATURE_SET] is the driver's. + * + * 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. + */ + @SupportedOn(SWI68, SWI165) + PASSENGER_TEMP( + R.string.cond_passenger_temp, ConditionGroup.CLIMATE, + number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius), + SnapshotKeys.KEY_PASSENGER_TEMP, comparable = true + ), + @SupportedOn(SWI68, SWI165) + ECON_MODE( + R.string.cond_econ, ConditionGroup.CLIMATE, + ValueSpec.BOOL, SnapshotKeys.KEY_ECON + ), + @SupportedOn(SWI68, SWI165) + FRONT_DEFROST( + R.string.cond_front_defrost, ConditionGroup.CLIMATE, + ValueSpec.BOOL, SnapshotKeys.KEY_FRONT_DEFROST + ), + @SupportedOn(SWI68, SWI165) + REAR_DEFROST( + R.string.cond_rear_defrost, ConditionGroup.CLIMATE, + ValueSpec.BOOL, SnapshotKeys.KEY_REAR_DEFROST + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + WINDOW_OPEN( + R.string.cond_window_open, ConditionGroup.CLIMATE, + ValueSpec.BOOL, SnapshotKeys.KEY_WINDOW_OPEN + ), + /** + * How far the widest-open window is, in percent — the vendor read, unlike [WINDOW_OPEN] + * which is an unverified AOSP property. "Any window open" is `> 0`; a rule that closes + * them cares about the worst case, which is what this reports. + */ + @SupportedOn(SWI68, SWI165) + WINDOW_POSITION( + R.string.cond_window_position, ConditionGroup.CLIMATE, + number(0, 100, R.string.unit_percent), + SnapshotKeys.KEY_WINDOW_PERCENT, comparable = true + ), + /** + * One window's own opening, where [WINDOW_POSITION] is the widest of the four. + * + * Four entries rather than one with a chooser: the same shape [SEAT_HEAT_LEFT] and + * [SEAT_HEAT_RIGHT] already use, and it keeps the editor free of a control that would + * exist for this one family. + */ + @SupportedOn(SWI68, SWI165) + WINDOW_DRIVER( + R.string.cond_window_driver, ConditionGroup.CLIMATE, + number(0, 100, R.string.unit_percent), + SnapshotKeys.KEY_WINDOW_DRIVER, comparable = true + ), + @SupportedOn(SWI68, SWI165) + WINDOW_PASSENGER( + R.string.cond_window_passenger, ConditionGroup.CLIMATE, + number(0, 100, R.string.unit_percent), + SnapshotKeys.KEY_WINDOW_PASSENGER, comparable = true + ), + @SupportedOn(SWI68, SWI165) + WINDOW_REAR_LEFT( + R.string.cond_window_rear_left, ConditionGroup.CLIMATE, + number(0, 100, R.string.unit_percent), + SnapshotKeys.KEY_WINDOW_REAR_LEFT, comparable = true + ), + @SupportedOn(SWI68, SWI165) + WINDOW_REAR_RIGHT( + R.string.cond_window_rear_right, ConditionGroup.CLIMATE, + number(0, 100, R.string.unit_percent), + SnapshotKeys.KEY_WINDOW_REAR_RIGHT, comparable = true + ), + @SupportedOn(SWI68, SWI165) + DOORS_LOCKED( + R.string.cond_doors_locked, ConditionGroup.CONTEXT, + ValueSpec.BOOL, SnapshotKeys.KEY_DOORS_LOCKED + ), + /** + * Either front door standing open. + * + * Front doors only, and only where the door status property answers: those are the two + * the library reads today, for the volume-drop feature. The rear doors are not claimed + * here rather than guessed at, and a firmware that does not answer leaves the condition + * UNAVAILABLE instead of reporting the doors shut. + */ + @SupportedOn(SWI133) + FRONT_DOOR_OPEN( + R.string.cond_front_door_open, ConditionGroup.CONTEXT, + ValueSpec.BOOL, SnapshotKeys.KEY_FRONT_DOOR_OPEN + ), + + // ── Comfort ────────────────────────────────────────────────────────────── + @SupportedOn(SWI133, SWI68, SWI165) + SEAT_HEAT_LEFT( + R.string.cond_seat_heat_l, ConditionGroup.COMFORT, + number(0, 3), SnapshotKeys.KEY_SEAT_HEAT_L, comparable = true + ), + @SupportedOn(SWI133, SWI68, SWI165) + SEAT_HEAT_RIGHT( + R.string.cond_seat_heat_r, ConditionGroup.COMFORT, + number(0, 3), SnapshotKeys.KEY_SEAT_HEAT_R, comparable = true + ), + @SupportedOn(SWI133, SWI68, SWI165) + STEERING_HEAT( + R.string.cond_steering_heat, ConditionGroup.COMFORT, + ValueSpec.BOOL, SnapshotKeys.KEY_STEERING_HEAT + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + SCREEN_BRIGHTNESS( + R.string.cond_brightness, ConditionGroup.COMFORT, + number(VehicleEnums.BRIGHTNESS_MIN, VehicleEnums.BRIGHTNESS_MAX, R.string.unit_percent), + SnapshotKeys.KEY_BRIGHTNESS, comparable = true + ), + + // ── 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 + // the one about the volume, not three groups apart. + /** Something is coming out of the speakers, whichever app is playing it. */ + MEDIA_PLAYING( + R.string.cond_media_playing, ConditionGroup.AUDIO, + ValueSpec.BOOL, SnapshotKeys.KEY_MEDIA_PLAYING + ), + /** + * The **tuner** is playing — not "something is". + * + * [MEDIA_PLAYING] cannot answer this and never could: it reads + * `AudioManager.isMusicActive`, which is **false while the radio plays**, the tuner's + * stream not being the music one. So a car with the radio on reads as silent there, and + * "only if the radio is already on" was not expressible. This reads `RadioBean.state`, the + * same value [ActionType.RADIO_PLAY_PAUSE] refuses to guess at. + * + * The pair is worth having: a rule that must not talk over the driver's music wants + * [MEDIA_PLAYING], and a rule that changes station, silences the news on arrival or steps + * to the next station wants this one — those are wrong on a car playing Bluetooth. + * + * Unreadable leaves it unavailable rather than false: a radio service that has not bound + * is not a radio that is off. + */ + @SupportedOn(SWI68, SWI165) + RADIO_PLAYING( + R.string.cond_radio_playing, ConditionGroup.AUDIO, + ValueSpec.BOOL, SnapshotKeys.KEY_RADIO_PLAYING + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + MEDIA_VOLUME( + R.string.cond_media_volume, ConditionGroup.AUDIO, + ValueSpec.dynamicNumber(0, VehicleEnums.MEDIA_VOLUME_FALLBACK_MAX), + SnapshotKeys.KEY_MEDIA_VOLUME, comparable = true + ), + + // ── Driver assistance ──────────────────────────────────────────────────── + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + AEB_ENABLED( + R.string.cond_aeb, ConditionGroup.ADAS, + ValueSpec.BOOL, SnapshotKeys.KEY_AEB_ENABLED + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + AEB_MODE( + R.string.cond_aeb_mode, ConditionGroup.ADAS, + enum(*VehicleEnums.AEB_MODES.toTypedArray()), SnapshotKeys.KEY_AEB_MODE + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + AEB_SENSITIVITY( + R.string.cond_aeb_sensitivity, ConditionGroup.ADAS, + enum(*VehicleEnums.SENSITIVITIES.toTypedArray()), SnapshotKeys.KEY_AEB_SENSITIVITY + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + ELK_MODE( + R.string.cond_elk_mode, ConditionGroup.ADAS, + enum(*VehicleEnums.ELK_MODES.toTypedArray()), SnapshotKeys.KEY_ELK_MODE + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + ELK_SENSITIVITY( + R.string.cond_elk_sensitivity, ConditionGroup.ADAS, + enum(*VehicleEnums.SENSITIVITIES.toTypedArray()), SnapshotKeys.KEY_ELK_SENSITIVITY + ), + @SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165) + ACC_TJA_MODE( + R.string.cond_acc_tja, ConditionGroup.ADAS, + enum(*VehicleEnums.ACC_TJA_MODES.toTypedArray()), SnapshotKeys.KEY_ACC_TJA_MODE + ), + @SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165) + LIMITER_MODE( + R.string.cond_limiter, ConditionGroup.ADAS, + enum(*VehicleEnums.LIMITER_MODES.toTypedArray()), SnapshotKeys.KEY_LIMITER_MODE + ), + @SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165) + TSR( + R.string.cond_tsr, ConditionGroup.ADAS, + ValueSpec.BOOL, SnapshotKeys.KEY_TSR + ), + @SupportedOn(SWI133, SWI132) + OVERSPEED_ALARM( + R.string.cond_overspeed, ConditionGroup.ADAS, + ValueSpec.BOOL, SnapshotKeys.KEY_OVERSPEED_ALARM + ), + @SupportedOn(SWI133, SWI132) + SPEED_LIMIT_TONE( + R.string.cond_speed_limit_tone, ConditionGroup.ADAS, + ValueSpec.BOOL, SnapshotKeys.KEY_SPEED_LIMIT_TONE + ), + @SupportedOn(SWI132, SWI68, SWI69, SWI131, SWI165) + SOUND_WARNING( + R.string.cond_sound_warning, ConditionGroup.ADAS, + ValueSpec.BOOL, SnapshotKeys.KEY_SOUND_WARNING + ), + + // ── Connection ─────────────────────────────────────────────────────────── + /** + * Data the head unit has carried, in megabytes — today, and since the first of the month. + * + * The counters are Android's own, the ones the Settings screen shows, so a rule that stops + * a heavy upload late in the month is reading the same number the driver would. + * + * No `@SupportedOn`: this is the head unit's connection, not a vehicle signal, and it does + * not vary by firmware generation. It varies by *permission* instead — without + * READ_NETWORK_USAGE_HISTORY there is no reading at all, and the condition reports + * unavailable rather than zero, which would read as a car that used no data. + */ + DATA_USED_TODAY( + R.string.cond_data_today, ConditionGroup.CONTEXT, + number(0, 100_000, R.string.unit_megabyte), SnapshotKeys.KEY_DATA_TODAY_MB, + comparable = true + ), + DATA_USED_MONTH( + R.string.cond_data_month, ConditionGroup.CONTEXT, + number(0, 100_000, R.string.unit_megabyte), SnapshotKeys.KEY_DATA_MONTH_MB, + comparable = true + ); + + companion object { + fun byGroup(): Map> = entries.groupBy { it.group } + } +} 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 new file mode 100644 index 0000000..06a92b7 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/SnapshotKeys.kt @@ -0,0 +1,169 @@ +package com.evsuite.hardware.catalog + +/** + * Stable string keys identifying each vehicle signal a [ConditionType] reads. + * + * These are the canonical identifiers for the catalogue, owned by this library. A consumer + * that snapshots the vehicle (e.g. over IPC) must key its bundle with exactly these + * strings; keeping them here — not in an app — is what lets the catalogue live in the + * shared library without depending on any app's transport. + * + * A key absent from a snapshot means "unreadable", never a value: the whole evaluation + * model rests on that distinction. + */ +object SnapshotKeys { + const val KEY_PHYSICAL_BUTTON_EVENT = "physicalButtonEvent" + const val KEY_FIRMWARE_GEN = "firmwareGen" + const val KEY_STAR_LEFT_SHORT = "starLeftShortPress" + const val KEY_STAR_LEFT_LONG = "starLeftLongPress" + const val KEY_STAR_RIGHT_SHORT = "starRightShortPress" + const val KEY_STAR_RIGHT_LONG = "starRightLongPress" + const val KEY_ASSISTANT_SHORT = "assistantShortPress" + const val KEY_ASSISTANT_LONG = "assistantLongPress" + const val KEY_PHONE_SHORT = "phoneShortPress" + const val KEY_PHONE_LONG = "phoneLongPress" + const val KEY_CENTER_SHORT = "centerShortPress" + const val KEY_CENTER_LONG = "centerLongPress" + const val KEY_VOLUME_UP_SHORT = "volumeUpShortPress" + const val KEY_VOLUME_UP_LONG = "volumeUpLongPress" + const val KEY_VOLUME_DOWN_SHORT = "volumeDownShortPress" + const val KEY_VOLUME_DOWN_LONG = "volumeDownLongPress" + const val KEY_MEDIA_NEXT_SHORT = "mediaNextShortPress" + const val KEY_MEDIA_NEXT_LONG = "mediaNextLongPress" + const val KEY_MEDIA_PREVIOUS_SHORT = "mediaPreviousShortPress" + const val KEY_MEDIA_PREVIOUS_LONG = "mediaPreviousLongPress" + const val KEY_MUTE_SHORT = "muteShortPress" + const val KEY_MUTE_LONG = "muteLongPress" + const val KEY_UP_SHORT = "upShortPress"; const val KEY_UP_LONG = "upLongPress" + const val KEY_DOWN_SHORT = "downShortPress"; const val KEY_DOWN_LONG = "downLongPress" + const val KEY_OK_SHORT = "okShortPress"; const val KEY_OK_LONG = "okLongPress" + const val KEY_LEFT_SHORT = "leftShortPress"; const val KEY_LEFT_LONG = "leftLongPress" + const val KEY_RIGHT_SHORT = "rightShortPress"; const val KEY_RIGHT_LONG = "rightLongPress" + const val KEY_SOURCE_SHORT = "sourceShortPress"; const val KEY_SOURCE_LONG = "sourceLongPress" + const val KEY_OUTSIDE_TEMP = "outsideTempC" + const val KEY_IGNITION = "ignition" + const val KEY_IN_PARK = "inPark" + const val KEY_SPEED_KMH = "speedKmh" + const val KEY_SPEED_READABLE = "speedReadable" + const val KEY_DRIVE_MODE = "driveMode" + const val KEY_REGEN_LEVEL = "regenLevel" + const val KEY_ENERGY_SAVING = "energySaving" + const val KEY_AC_ON = "acOn" + const val KEY_HVAC_AUTO = "hvacAuto" + const val KEY_RECIRC = "recirc" + const val KEY_FAN_SPEED = "fanSpeed" + const val KEY_TEMPERATURE_SET = "temperatureSetC" + const val KEY_WINDOW_OPEN = "windowOpen" + const val KEY_SEAT_HEAT_L = "seatHeatLeft" + const val KEY_SEAT_HEAT_R = "seatHeatRight" + const val KEY_STEERING_HEAT = "steeringHeat" + const val KEY_MEDIA_VOLUME = "mediaVolume" + const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax" + const val KEY_BRIGHTNESS = "brightnessPct" + const val KEY_AEB_ENABLED = "aebEnabled" + const val KEY_AEB_MODE = "aebMode" + const val KEY_AEB_SENSITIVITY = "aebSensitivity" + const val KEY_ELK_MODE = "elkMode" + const val KEY_ELK_SENSITIVITY = "elkSensitivity" + const val KEY_ACC_TJA_MODE = "accTjaMode" + const val KEY_LIMITER_MODE = "limiterMode" + const val KEY_TSR = "tsr" + const val KEY_OVERSPEED_ALARM = "overspeedAlarm" + const val KEY_SPEED_LIMIT_TONE = "speedLimitTone" + const val KEY_SOUND_WARNING = "soundWarning" + const val KEY_ESC = "esc" + const val KEY_DROWSINESS = "drowsiness" + const val KEY_DROWSINESS_SENSITIVITY = "drowsinessSensitivity" + + // ── Head-unit connection ──────────────────────────────────────────────── + const val KEY_DATA_TODAY_MB = "dataTodayMb" + const val KEY_DATA_MONTH_MB = "dataMonthMb" + + // ── Vendor vehicle service (com.evsuite.hardware.saic) ─────────────────────── + // Read from the same service the car's own HVAC and charging screens use, so unlike + // the AOSP climate ids above these are confirmed rather than inferred. + const val KEY_CLIMATE_ON = "climateOn" + const val KEY_BATTERY_PERCENT = "batteryPct" + const val KEY_CHARGING = "charging" + const val KEY_CHARGE_LIMIT = "chargeLimitPct" + /** Widest-open window, 0–100 %. Replaces the unverified AOSP window read where it answers. */ + const val KEY_WINDOW_PERCENT = "windowPct" + const val KEY_DOORS_LOCKED = "doorsLocked" + const val KEY_ECON = "econOn" + const val KEY_PASSENGER_TEMP = "passengerTempC" + const val KEY_FRONT_DEFROST = "frontDefrost" + const val KEY_REAR_DEFROST = "rearDefrost" + /** + * The vendor charging state, kept as its own number next to [KEY_CHARGING]. + * + * [KEY_CHARGING] is a boolean and has to stay one — every rule already written against it + * reads it as such. But "plugged in" and "charging" are not the same state, and the + * boolean cannot tell a driver which of the two the car is in. This is the state itself, + * so a rule can ask. + */ + const val KEY_CHARGING_STATUS = "chargingStatus" + const val KEY_CHARGE_SCHEDULE = "chargeScheduleOn" + /** Scheduled charging window, minutes since midnight. */ + const val KEY_CHARGE_WINDOW_START = "chargeWindowStart" + const val KEY_CHARGE_WINDOW_STOP = "chargeWindowStop" + const val KEY_BATTERY_PREHEAT = "batteryPreheat" + /** Remaining range in kilometres, as the cluster shows it. */ + const val KEY_RANGE_KM = "rangeKm" + /** + * Each window's own position, 0–100 %. + * + * Alongside [KEY_WINDOW_PERCENT], not instead of it: the widest-open window is the one + * number a "close the windows" rule wants, and these four are what a rule about one + * window needs. + */ + const val KEY_WINDOW_DRIVER = "windowDriverPct" + const val KEY_WINDOW_PASSENGER = "windowPassengerPct" + const val KEY_WINDOW_REAR_LEFT = "windowRearLeftPct" + const val KEY_WINDOW_REAR_RIGHT = "windowRearRightPct" + + // ── Head-unit app services (bind, not firmware routing) ────────────────── + // The navigation adapter and the weather service belong to the head unit's own apps, not + // to the vehicle SDK, so nothing routes them per generation: they answer or they do not. + /** Odometer in kilometres. */ + const val KEY_ODOMETER_KM = "odometerKm" + /** + * The sky where the car is, as one of [WeatherConditions]' states. + * + * The service answers a phrase in the head unit's language; what travels here is the + * state that phrase was classified into, because a rule cannot be written against wording + * the driver has never seen. A phrase nothing recognises leaves the key **absent**, which + * reads as "cannot tell" rather than as fine weather. + * + * Now, and only now. There is no forecast key: the map service answers current conditions + * and nothing else on this firmware — see `SaicWeather`. + */ + const val KEY_WEATHER_CONDITION = "weatherCondition" + + /** + * Whether the *tuner* is playing, from `RadioBean.state`. + * + * Not a duplicate of [KEY_MEDIA_PLAYING], and the difference is the reason this exists: + * `AudioManager.isMusicActive` is **false while the radio plays**, the tuner's stream not + * being the music one. So the media key answers "no" on a car with the radio on, and no + * rule could ask about the radio at all. + * + * A vendor service reading, so it carries a firmware annotation where the media one does + * not. Absent when the service will not answer — never false, which would read as a radio + * that is off. + */ + const val KEY_RADIO_PLAYING = "radioPlaying" + + // ── Platform context (no vehicle involved) ─────────────────────────────── + // Read from Android, not from the car, so they carry no firmware annotation. Absent + // still means unreadable: a head unit whose audio state cannot be read leaves the + // media condition unavailable rather than claiming silence. + const val KEY_MEDIA_PLAYING = "mediaPlaying" + const val KEY_WIFI_SSID = "wifiSsid" + const val KEY_IN_CALL = "inCall" + /** Minutes since the ignition came on, absent before the first transition is seen. */ + const val KEY_DRIVE_MINUTES = "driveMinutes" + + // ── AOSP door status (SWI133) ──────────────────────────────────────────── + /** True while either front door reads as open. Absent where the property is unreadable. */ + const val KEY_FRONT_DOOR_OPEN = "frontDoorOpen" +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/ValueSpec.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/ValueSpec.kt new file mode 100644 index 0000000..f5e7368 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/ValueSpec.kt @@ -0,0 +1,140 @@ +package com.evsuite.hardware.catalog + +import androidx.annotation.StringRes + +/** + * The kind of value a condition or action manipulates. + * + * This is what keeps the UI small: the editor knows no individual condition or action, it + * only knows how to draw one control per [ValueKind]. Adding a catalogue entry is + * therefore one enum line, not another screen. + */ +enum class ValueKind { + /** On/off switch. */ + BOOL, + /** Bounded slider (see [ValueSpec.min] / [ValueSpec.max]). */ + NUMBER, + /** Closed list of named values ([ValueSpec.options]). */ + ENUM, + /** Paired Bluetooth device, identified by MAC address. */ + BT_DEVICE, + /** Start → end time range. */ + TIME_RANGE, + /** + * One time of day, stored as minutes since midnight in `number`. + * + * Not a [NUMBER] with a 0…1439 range: the charging window the car answers is a clock + * time, and a slider asking for "1 380" is unanswerable at the wheel. The editor draws + * the same picker [TIME_RANGE] uses, minus the second half. + */ + TIME, + /** Day-of-week selection. */ + DAYS, + /** One exact local calendar date, stored as ISO-8601 (yyyy-MM-dd). */ + DATE, + /** EVProfile driving profile, identified by id. */ + PROFILE, + /** + * Another of the user's own rules, identified by id. + * + * Not [APP] with a rule id in it: the chooser has to list rules, and a rule that names a + * rule which no longer exists must be reported as such rather than silently doing nothing. + */ + RULE, + /** Installed application, identified by package name. */ + APP, + /** Phone-book entry; the rule stores the selected number, not a mutable contact id. */ + CONTACT, + /** Free text (notification message). */ + TEXT, + /** + * A navigation destination: an address or "latitude,longitude", in `text`. + * + * Free text would have been enough to store it, and was — but nobody types coordinates + * at the wheel. The kind exists so the editor can offer the two things that make the + * field answerable there: the car's own position, and the places already saved. + */ + DESTINATION, + /** Webhook URL plus an optional POST body. */ + WEBHOOK, + /** + * A recipient and a message: the number in `text`, the contact it was picked from in + * `displayName`, and the message itself in the field a webhook body already uses. + * + * Two controls rather than one, and no new model field — a message has an addressee, and + * it is the same phone-book field [CONTACT] offers, so a number can still be typed where + * no contact exists. + */ + SMS, + /** + * A yes/no question in `text`, and how long it waits for an answer in `number` seconds. + * + * The wait belongs to the rule, not to the app: a question asked before the doors unlock + * is answered at once or not at all, while one asked at the end of a drive has to survive + * the driver looking away. `0` — every rule saved before the field existed — means the + * default wait. + */ + CONFIRM, + /** + * A point and a radius: `text` holds "latitude,longitude", `number` the radius in + * metres. Two controls rather than one, but no new model field — the flat union in + * `Condition` already has both. + */ + LOCATION, + /** + * A tuner setting: the band in `number` ([ValueSpec.options]), an optional frequency in + * `text`, and whether the radio starts playing in `flag`. + * + * One kind rather than three actions, because they are three halves of the same sentence: + * "put the radio on FM 103.5 and play it" is what a driver means, and splitting it made + * the band reachable only through an action that could not carry a station. The frequency + * is optional — naming a band alone is a complete instruction, and on **DAB** it is the + * only one possible, a DAB service being addressed by ensemble and service id rather than + * by anything a driver could type. + */ + RADIO, + /** Physical button + short/long event, edited with two dropdowns. */ + PHYSICAL_BUTTON, + /** Nothing to enter. */ + NONE +} + +/** One named value of a [ValueKind.ENUM]. */ +data class EnumOption(val value: Int, @StringRes val labelRes: Int) + +/** + * Description of the input control. + * + * [max] is -1 when the bound is only known at runtime — the maximum media volume depends + * on the firmware and is readable only from the vehicle snapshot. The editor substitutes + * the real value, falling back to [fallbackMax] when the car does not answer. + */ +data class ValueSpec( + val kind: ValueKind, + val min: Int = 0, + val max: Int = 0, + @StringRes val unitRes: Int = 0, + val options: List = emptyList(), + val fallbackMax: Int = 0, + /** + * Placeholder for a free-text field, when the field alone does not say what to type. + * "103.5" is obvious once you know the box wants a frequency and impossible to guess + * before that. + */ + @StringRes val hintRes: Int = 0 +) { + companion object { + val NONE = ValueSpec(ValueKind.NONE) + val BOOL = ValueSpec(ValueKind.BOOL) + + fun number(min: Int, max: Int, @StringRes unitRes: Int = 0) = + ValueSpec(ValueKind.NUMBER, min = min, max = max, unitRes = unitRes) + + /** Upper bound resolved at runtime from the vehicle snapshot. */ + fun dynamicNumber(min: Int, fallbackMax: Int, @StringRes unitRes: Int = 0) = + ValueSpec(ValueKind.NUMBER, min = min, max = -1, unitRes = unitRes, fallbackMax = fallbackMax) + + fun enum(vararg options: EnumOption) = + ValueSpec(ValueKind.ENUM, options = options.toList()) + } +} 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 new file mode 100644 index 0000000..d8b36e6 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/VehicleEnums.kt @@ -0,0 +1,179 @@ +package com.evsuite.hardware.catalog + +import com.evsuite.hardware.R + +/** + * Numeric values the vehicle expects, copied from EVProfile. + * + * These are protocol values, not display indices: reordering or renumbering them changes + * the setting applied to the car. Sources in EVProfile: `model/DriveMode.kt`, + * `model/RegenLevel.kt`, and `hardware/EVHardware.kt` (objects `AebMode`, `Swi68Mode`, + * plus the ELK / SAS comments). + */ +object VehicleEnums { + + /** DriveMode.value — EVProfile model/DriveMode.kt */ + val DRIVE_MODES = listOf( + EnumOption(2, R.string.drive_eco), + EnumOption(3, R.string.drive_normal), + EnumOption(4, R.string.drive_sport), + EnumOption(6, R.string.drive_snow), + EnumOption(7, R.string.drive_custom) + ) + + /** RegenLevel.value — EVProfile model/RegenLevel.kt */ + val REGEN_LEVELS = listOf( + EnumOption(0, R.string.regen_low), + EnumOption(1, R.string.regen_medium), + EnumOption(2, R.string.regen_high), + EnumOption(3, R.string.regen_adaptive), + EnumOption(5, R.string.regen_off), + EnumOption(6, R.string.regen_one_pedal) + ) + + /** EVHardware.AebMode */ + val AEB_MODES = listOf( + EnumOption(1, R.string.aeb_alert_only), + EnumOption(2, R.string.aeb_alert_brake) + ) + + /** AEB / ELK sensitivity — 1 low, 2 standard, 3 high (0 means "not configured"). */ + val SENSITIVITIES = listOf( + EnumOption(1, R.string.sensitivity_low), + EnumOption(2, R.string.sensitivity_standard), + EnumOption(3, R.string.sensitivity_high) + ) + + /** ELK — see EVHardware.getElkMode / setElkMode. */ + val ELK_MODES = listOf( + EnumOption(1, R.string.elk_off), + EnumOption(2, R.string.elk_warn), + EnumOption(3, R.string.elk_assist), + EnumOption(5, R.string.elk_full) + ) + + /** EVHardware.Swi68Mode — ACC/TJA. OFF is 0x4, not 0. */ + val ACC_TJA_MODES = listOf( + EnumOption(4, R.string.acc_off), + EnumOption(1, R.string.acc_acc), + EnumOption(2, R.string.acc_tja) + ) + + /** SAS speed limiter — 0 off, 2 manual, 3 intelligent. */ + val LIMITER_MODES = listOf( + EnumOption(0, R.string.limiter_off), + EnumOption(2, R.string.limiter_manual), + EnumOption(3, R.string.limiter_smart) + ) + + /** VehicleIgnitionState (standard AAOS) — useful values only. */ + val IGNITION_STATES = listOf( + EnumOption(1, R.string.ignition_lock), + EnumOption(2, R.string.ignition_off), + EnumOption(3, R.string.ignition_acc), + EnumOption(4, R.string.ignition_on), + EnumOption(5, R.string.ignition_start) + ) + + /** Firmware generations — FirmwareInfo.Gen in EVProfile. */ + val FIRMWARE_GENS = listOf("SWI133", "SWI132", "SWI68", "SWI69", "SWI131", "SWI165") + + // Audio bounds — EVHardware AUDIO_TYPE_MIN/MAX and AUDIO_LEVEL_MIN/MAX. + const val AUDIO_TYPE_MIN = 0 + const val AUDIO_TYPE_MAX = 3 + const val AUDIO_LEVEL_MIN = -9 + const val AUDIO_LEVEL_MAX = 9 + + /** EVProfile's brightness floor: never black out the vehicle screen. */ + const val BRIGHTNESS_MIN = 5 + const val BRIGHTNESS_MAX = 100 + + /** Fallback when the vehicle does not report its real maximum volume. */ + const val MEDIA_VOLUME_FALLBACK_MAX = 30 + + // ── Climate — from the vendor HMI, not from the AOSP property ranges ───── + // HvacConst.AIR_VOLUME_MAX and the 17…33 clamp in + // HvacActivity.onLongTouch where the two ends display as LO and HI. + const val FAN_LEVEL_MAX = 11 + const val CABIN_TEMP_MIN = 17 + const val CABIN_TEMP_MAX = 33 + + /** Charge limit the vehicle accepts, in percent. */ + const val CHARGE_LIMIT_MIN = 40 + const val CHARGE_LIMIT_MAX = 100 + + /** + * The charging states the vendor service reports, as the car's own charging screen + * groups them. + * + * Only the states a rule can act on are named. The service answers others — bookkeeping + * values a driver has no rule for — and a condition comparing against one of those simply + * does not match, which is the honest outcome for a state the catalogue does not claim. + * + * [CHARGING_UNPLUGGED] is why this exists as a number rather than as the boolean + * [SnapshotKeys.KEY_CHARGING]: a cable plugged in with charging stopped is neither + * "charging" nor "unplugged", and the boolean has to pick one. + */ + const val CHARGING_UNPLUGGED = 0 + const val CHARGING_AC = 1 + const val CHARGING_DONE = 2 + const val CHARGING_FAULT = 4 + const val CHARGING_PLUGGED_IDLE = 7 + const val CHARGING_DC = 10 + + val CHARGING_STATUSES = listOf( + EnumOption(CHARGING_UNPLUGGED, R.string.charging_unplugged), + EnumOption(CHARGING_AC, R.string.charging_ac), + EnumOption(CHARGING_DC, R.string.charging_dc), + EnumOption(CHARGING_PLUGGED_IDLE, R.string.charging_plugged_idle), + EnumOption(CHARGING_DONE, R.string.charging_done), + EnumOption(CHARGING_FAULT, R.string.charging_fault) + ) + + /** + * The states in which current is actually flowing into the battery. + * + * What [SnapshotKeys.KEY_CHARGING] means, and the reason it is derived from this set + * rather than from `status != 0`: a cable plugged in with charging stopped + * ([CHARGING_PLUGGED_IDLE]), a finished charge ([CHARGING_DONE]) and a fault + * ([CHARGING_FAULT]) are all non-zero, and all three would make a "when charging" rule + * fire on a car that is not charging. + */ + val CHARGING_ACTIVE_STATES = setOf(CHARGING_AC, CHARGING_DC) + + /** + * What a glass action asks for: open the window, or close it. Nothing in between. + * + * The vendor service takes a **command**, not a position — it reads a percentage back + * and accepts nothing above 7 on the way in — so the only two states a rule can reach are + * fully open and fully closed. A percentage control would offer a hundred and one values + * of which ninety-nine are unreachable, and the history would report "applied" for a + * position the glass never went to. + * + * Which raw command opens and which closes is **not** encoded here, because it is not a + * property of the catalogue: it is a property of the car, established by `GlassProbe` and + * stored in `GlassEvidence`. The executor translates. That is why these values are 0 and 1 + * rather than the observed commands — a rule saved on one car must not carry another car's + * command numbers. + */ + const val WINDOW_CLOSE = 0 + const val WINDOW_OPEN = 1 + + val WINDOW_COMMANDS = listOf( + EnumOption(WINDOW_CLOSE, R.string.window_close), + EnumOption(WINDOW_OPEN, R.string.window_open) + ) + + /** + * The tuner's bands, as `RadioType` numbers them. + * + * DAB is in the list because stepping already reaches it and the band is what a rule + * cannot otherwise change: `TUNE_RADIO` takes a frequency, and a DAB service is addressed + * by ensemble and service id, so there is no DAB station a driver could type. + */ + val RADIO_BANDS = listOf( + EnumOption(1, R.string.radio_band_am), + EnumOption(2, R.string.radio_band_fm), + EnumOption(4, R.string.radio_band_dab) + ) +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/WeatherConditions.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/WeatherConditions.kt new file mode 100644 index 0000000..7ae020b --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/catalog/WeatherConditions.kt @@ -0,0 +1,110 @@ +package com.evsuite.hardware.catalog + +import com.evsuite.hardware.R +import java.text.Normalizer + +/** + * The sky, as a short list a rule can be written against. + * + * The head unit's weather service answers a **phrase**, in the head unit's language: "Light + * rain", "Rain showers", "Pluie faible". That phrase was once what a rule compared itself + * against, and it made the condition unwritable — the driver had to guess the provider's + * exact wording, in the exact language the car was set to, and a rule written on a French car + * stopped matching the day the display language changed. Nobody can type a value they have + * never seen. + * + * So the phrase is classified here, once, into one of seven states the picker can offer as a + * list. The provider's wording stays the provider's business; the catalogue's vocabulary is + * what a rule stores, which is why a rule keeps meaning "when it rains" whatever language the + * car speaks afterwards. + * + * **Keywords, not a table of phrases.** The service is free to say "Heavy rain" today and + * "Rain, heavy" tomorrow, and it says it in six languages; matching a fragment survives both, + * where an exact list would need every phrase the provider owns. A phrase that matches + * nothing is **not** classified — [classify] answers null and the condition stays + * unavailable, which is the same answer a car that will not talk to its weather service + * gives. Guessing "clear" from a phrase we did not recognise would be the one wrong answer: + * it reads as good weather. + */ +object WeatherConditions { + + const val CLEAR = 1 + const val CLOUDY = 2 + const val RAIN = 3 + const val SNOW = 4 + const val STORM = 5 + const val FOG = 6 + const val WIND = 7 + + val OPTIONS = listOf( + EnumOption(CLEAR, R.string.weather_clear), + EnumOption(CLOUDY, R.string.weather_cloudy), + EnumOption(RAIN, R.string.weather_rain), + EnumOption(SNOW, R.string.weather_snow), + EnumOption(STORM, R.string.weather_storm), + EnumOption(FOG, R.string.weather_fog), + EnumOption(WIND, R.string.weather_wind) + ) + + /** + * Fragments per state, in the languages the apps ship, **in the order they are tried**. + * + * The order is the whole design: real phrases name more than one thing ("thunderstorms + * with rain", "snow showers", "Schneeschauer"), and the first match wins, so the states + * are tried from the most specific weather to the least. A thunderstorm is a storm before + * it is rain; falling snow is snow before it is a shower; the sky being clear is what is + * left when nothing else matched. + */ + private val KEYWORDS: List>> = listOf( + STORM to listOf( + "thunder", "storm", "squall", + "orage", "tempete", "gewitter", "sturm", "tormenta", "temporale", "tempesta", + "trovoada", "tempestade" + ), + SNOW to listOf( + "snow", "sleet", "blizzard", "hail", + "neige", "grele", "verglas", "schnee", "hagel", "nieve", "granizo", "aguanieve", + "neve", "grandine" + ), + RAIN to listOf( + "rain", "drizzle", "shower", + "pluie", "bruine", "averse", "ondee", "regen", "niesel", "schauer", "lluvia", + "llovizna", "chubasco", "piogg", "rovesc", "chuva", "chuvisco", "aguaceiro" + ), + FOG to listOf( + "fog", "mist", "haze", + "brouillard", "brume", "nebel", "dunst", "niebla", "neblina", "calima", "nebbia", + "foschia", "nevoeiro" + ), + WIND to listOf( + "wind", "gale", "breeze", + "vent", "boen", "viento" + ), + CLOUDY to listOf( + "cloud", "overcast", + "nuage", "couvert", "wolk", "bedeckt", "nub", "cubierto", "nuvol", "coperto", + "nuvem", "encoberto" + ), + CLEAR to listOf( + "clear", "sun", "fair", + "soleil", "clair", "degage", "sonn", "klar", "heiter", "sol", "despejado", + "sereno", "limpo" + ) + ) + + /** + * The state [phrase] describes, or null when nothing here recognises it. + * + * Accents are stripped before matching, so "Pluie éparse" and "Bewölkt" are read by the + * same fragments that a head unit writing them without accents would produce. + */ + fun classify(phrase: String): Int? { + val normalised = normalise(phrase) + if (normalised.isBlank()) return null + return KEYWORDS.firstOrNull { (_, fragments) -> fragments.any { it in normalised } }?.first + } + + private fun normalise(phrase: String): String = + Normalizer.normalize(phrase.lowercase(), Normalizer.Form.NFD) + .replace(Regex("\\p{Mn}+"), "") +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/diag/CrashLogger.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/diag/CrashLogger.kt new file mode 100644 index 0000000..35f9a86 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/diag/CrashLogger.kt @@ -0,0 +1,163 @@ +package com.evsuite.hardware.diag + +import android.content.Context +import androidx.annotation.VisibleForTesting +import com.evsuite.hardware.AppLogger +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Captures uncaught exceptions to a file in `filesDir`, surfaced on the next launch. + * + * On a vehicle there is no crash reporter and usually no cable. Without this, a crash at + * ignition leaves nothing behind: the process dies, the user sees the app "not working", + * and there is no way to find out why after the fact. + * + * It lives here rather than in each app because every app in the suite crashes on the same + * head unit for the same reasons, and the two copies that preceded this one had each fixed + * a bug the other still carried — one chained the previous handler, the other counted bytes + * instead of characters when truncating. + * + * The previous handler is always chained, so the system still gets to do its job. + */ +object CrashLogger { + + private const val TAG = "Crash" + private const val FILE_NAME = "last_crash.txt" + + /** + * Reports are truncated by keeping the HEAD, not the tail. + * + * The exception and its top frames are at the top of the report — that is the part + * that identifies the bug. Cutting from the start would discard exactly what is + * needed and keep the least interesting frames. + */ + private const val MAX_BYTES = 64 * 1024 + + /** How far down the `cause` chain to walk, and how many frames to keep per cause. */ + private const val MAX_CAUSE_DEPTH = 5 + private const val CAUSE_FRAMES = 20 + + /** + * Installs the handler. [appName] only titles the report — it is read by a human, and + * the package name alone does not say which app of the suite produced it. + */ + fun install(context: Context, appName: String) { + val appContext = context.applicationContext + val previous = Thread.getDefaultUncaughtExceptionHandler() + + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + // Logging is inside the guard too: AppLogger formats through a shared + // SimpleDateFormat, so a concurrent log call can make this throw — and a + // throw here would cost both the report and the previous handler. + runCatching { + AppLogger.e(TAG, "uncaught exception on thread '${thread.name}': $throwable") + } + runCatching { write(appContext, appName, thread, throwable) } + } finally { + // Chain, never swallow: the platform still needs to terminate the process, + // and swallowing here would leave the app in an undefined state instead. + previous?.uncaughtException(thread, throwable) + } + } + } + + fun hasReport(context: Context): Boolean = file(context).exists() + + fun read(context: Context): String? = + file(context).takeIf { it.exists() }?.runCatching { readText() }?.getOrNull() + + fun clear(context: Context) { + runCatching { file(context).delete() } + } + + @VisibleForTesting + internal fun write(context: Context, appName: String, thread: Thread, throwable: Throwable) { + val stackTrace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString() + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date()) + + val report = buildString { + appendLine("$appName crash report") + appendLine("time : $timestamp") + appendLine("thread : ${thread.name}") + appendLine("version : ${version(context)}") + appendLine("android : ${android.os.Build.VERSION.SDK_INT}") + appendLine("device : ${android.os.Build.DEVICE}") + appendLine() + appendLine("-- Stack trace --") + appendLine(stackTrace) + appendCauses(throwable) + appendLine("-- Recent log (AppLogger) --") + appendLine(AppLogger.dump()) + } + + writeAtomically(file(context), truncate(report)) + } + + /** + * `printStackTrace` already prints the causes, but abbreviates them to the frames that + * differ from the enclosing trace. On a reflection layer the interesting frame is + * usually one of the common ones, so the chain is spelled out again in full. + */ + private fun StringBuilder.appendCauses(throwable: Throwable) { + var cause = throwable.cause + var depth = 0 + while (cause != null && depth < MAX_CAUSE_DEPTH) { + appendLine("-- Caused by --") + appendLine(cause.toString()) + cause.stackTrace.take(CAUSE_FRAMES).forEach { appendLine(" at $it") } + appendLine() + cause = cause.cause + depth++ + } + } + + /** + * Truncates to [MAX_BYTES] keeping the BEGINNING, and counts bytes rather than + * characters: the log buffer is full of non-ASCII, which is two bytes in UTF-8, so a + * character count writes up to twice the announced ceiling. + */ + @VisibleForTesting + internal fun truncate(content: String): ByteArray { + val bytes = content.toByteArray(Charsets.UTF_8) + if (bytes.size <= MAX_BYTES) return bytes + + val marker = "\n... report truncated (${bytes.size} bytes) ...\n".toByteArray(Charsets.UTF_8) + // Never cut mid-character: back up over UTF-8 continuation bytes (10xxxxxx). + var end = MAX_BYTES - marker.size + while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end-- + return bytes.copyOfRange(0, end) + marker + } + + /** + * Temp file then rename over the target. + * + * Never delete-then-write: that leaves a window with no file at all, and this runs + * while the process is already dying — a second failure mid-write would destroy the + * previous report without producing a new one. + */ + private fun writeAtomically(target: File, content: ByteArray) { + val temp = File(target.parentFile, "${target.name}.${System.nanoTime()}.tmp") + try { + temp.writeBytes(content) + if (!temp.renameTo(target)) { + temp.delete() + AppLogger.w(TAG, "could not rename crash report into place") + } + } catch (e: Exception) { + temp.delete() + AppLogger.w(TAG, "could not write crash report: ${e.message}") + } + } + + private fun version(context: Context): String = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull() ?: "?" + + private fun file(context: Context) = File(context.filesDir, FILE_NAME) +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/diag/PrivateBin.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/diag/PrivateBin.kt new file mode 100644 index 0000000..68c75cb --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/diag/PrivateBin.kt @@ -0,0 +1,206 @@ +package com.evsuite.hardware.diag + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.net.HttpURLConnection +import java.net.URL +import java.security.SecureRandom +import java.util.Base64 +import java.util.zip.Deflater +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Uploads a report to a PrivateBin instance. + * + * PrivateBin is zero-knowledge: the server stores ciphertext and never sees the key, which + * travels in the URL fragment and is therefore never sent in an HTTP request. That is what + * makes pasting a vehicle diagnostic — VIN-free, but full of firmware and rule detail — + * acceptable at all. The extra password means the link alone is not enough either. + * + * The format is PrivateBin's v2 API and every constant here is load-bearing: + * + * - the key is 32 random bytes, base58 in the fragment; + * - PBKDF2-HMAC-SHA256 over `key ‖ password`, 100 000 iterations, 8-byte salt; + * - AES-256-GCM, 16-byte IV, 128-bit tag; + * - the authenticated data is the **exact** JSON text of `adata` as it is sent, because the + * browser that later decrypts re-serialises the array it received with `JSON.stringify`. + * Reordering it, adding a space, or letting a JSON library format it breaks decryption on + * the reader's side while the upload still succeeds — so `adata` is built as a string + * here and embedded verbatim; + * - the plaintext is raw DEFLATE (no zlib header) of `{"paste":"…"}`. + * + * No Android dependency: the whole encoding is unit-testable on the JVM, and [poster] is + * injectable so a test can assert the wire format without a network. + */ +object PrivateBin { + + /** PrivateBin's own defaults; the instance may shorten [expire], never lengthen it. */ + data class Config( + val baseUrl: String, + val password: String, + val expire: String, + val formatter: String, + ) + + sealed interface Outcome { + /** The paste URL, key fragment included — the only form that can be read back. */ + data class Ok(val url: String) : Outcome + data class Failed(val reason: String) : Outcome + } + + private const val ITERATIONS = 100_000 + private const val KEY_BITS = 256 + private const val TAG_BITS = 128 + private const val KEY_BYTES = 32 + private const val IV_BYTES = 16 + private const val SALT_BYTES = 8 + + private const val CONNECT_TIMEOUT_MS = 15_000 + private const val READ_TIMEOUT_MS = 30_000 + + fun paste( + text: String, + config: Config, + random: SecureRandom = SecureRandom(), + poster: (String, String) -> String = ::post, + ): Outcome { + val key = ByteArray(KEY_BYTES).also(random::nextBytes) + val iv = ByteArray(IV_BYTES).also(random::nextBytes) + val salt = ByteArray(SALT_BYTES).also(random::nextBytes) + + val adata = adataJson(iv, salt, config.formatter) + val derived = deriveKey(key, config.password, salt) + // Built element by element rather than reflected off a map: a consumer has already + // been bitten once by R8 stripping the generic signatures Gson's reflective path + // needs, and a payload that only fails in a minified build is the worst kind. + val payload = JsonObject().apply { addProperty("paste", text) }.toString() + val plain = rawDeflate(payload.toByteArray()) + val cipherText = encrypt(derived, iv, plain, adata.toByteArray()) + + val body = """{"v":2,"adata":$adata,"ct":"${base64(cipherText)}",""" + + """"meta":{"expire":"${config.expire}"}}""" + + val response = try { + poster(config.baseUrl, body) + } catch (e: Exception) { + return Outcome.Failed(e.message ?: e.javaClass.simpleName) + } + + val json = runCatching { JsonParser.parseString(response).asJsonObject }.getOrNull() + ?: return Outcome.Failed("unreadable response") + if (json.get("status")?.asInt != 0) { + return Outcome.Failed(json.get("message")?.asString ?: "refused by the server") + } + val path = json.get("url")?.asString ?: return Outcome.Failed("no url in the response") + return Outcome.Ok( + config.baseUrl.trimEnd('/') + "/" + path.trimStart('/') + "#" + base58(key) + ) + } + + // ------------------------------------------------------------------------- + + /** + * `JSON.stringify` of the metadata array, byte for byte: no spaces, this field order. + * Both the server's signature check and the reader's decryption depend on it. + */ + private fun adataJson(iv: ByteArray, salt: ByteArray, formatter: String): String = + """[["${base64(iv)}","${base64(salt)}",$ITERATIONS,$KEY_BITS,$TAG_BITS,""" + + """"aes","gcm","zlib"],"$formatter",0,0]""" + + /** + * PBKDF2-HMAC-SHA256 over the raw key bytes with the password appended. + * + * Hand-rolled rather than `SecretKeyFactory`, which takes a `char[]` and would have to + * encode 32 random bytes as text to accept them — there is no encoding that survives + * that round trip, and PrivateBin hashes the bytes. + */ + private fun deriveKey(key: ByteArray, password: String, salt: ByteArray): ByteArray { + val secret = key + password.toByteArray() + val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret, "HmacSHA256")) } + val out = ByteArray(KEY_BITS / 8) + var offset = 0 + var block = 1 + while (offset < out.size) { + var u = mac.doFinal(salt + byteArrayOf(0, 0, 0, block.toByte())) + val acc = u.copyOf() + repeat(ITERATIONS - 1) { + u = mac.doFinal(u) + for (i in acc.indices) acc[i] = (acc[i].toInt() xor u[i].toInt()).toByte() + } + val take = minOf(acc.size, out.size - offset) + acc.copyInto(out, offset, 0, take) + offset += take + block++ + } + return out + } + + private fun encrypt(key: ByteArray, iv: ByteArray, plain: ByteArray, aad: ByteArray): ByteArray = + Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, iv)) + updateAAD(aad) + doFinal(plain) + } + + /** Raw DEFLATE — no zlib wrapper, which is what PrivateBin's "zlib" compression means. */ + private fun rawDeflate(data: ByteArray): ByteArray { + val deflater = Deflater(Deflater.BEST_COMPRESSION, true) + return try { + deflater.setInput(data) + deflater.finish() + val out = ByteArrayOutputStream(data.size) + val buffer = ByteArray(8 * 1024) + while (!deflater.finished()) out.write(buffer, 0, deflater.deflate(buffer)) + out.toByteArray() + } finally { + deflater.end() + } + } + + private fun base64(data: ByteArray): String = Base64.getEncoder().encodeToString(data) + + /** Bitcoin alphabet, as PrivateBin's fragment encoding uses. */ + private fun base58(data: ByteArray): String { + val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + var value = BigInteger(1, data) + val fifty8 = BigInteger.valueOf(58) + val out = StringBuilder() + while (value.signum() > 0) { + val (quotient, remainder) = value.divideAndRemainder(fifty8) + out.append(alphabet[remainder.toInt()]) + value = quotient + } + // Leading zero bytes carry no magnitude, so they must be re-added explicitly. + for (byte in data) { + if (byte.toInt() != 0) break + out.append(alphabet[0]) + } + return out.reverse().toString() + } + + private fun post(url: String, body: String): String { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + setRequestProperty("Content-Type", "application/json") + // PrivateBin answers HTML to anything that does not claim to be its own client. + setRequestProperty("X-Requested-With", "JSONHttpRequest") + } + return try { + connection.outputStream.use { it.write(body.toByteArray()) } + val stream = if (connection.responseCode in 200..299) connection.inputStream + else connection.errorStream + stream?.bufferedReader()?.use { it.readText() } + ?: throw IllegalStateException("HTTP ${connection.responseCode}, empty body") + } 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 new file mode 100644 index 0000000..1f74e6b --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/model/DriveMode.kt @@ -0,0 +1,16 @@ +package com.evsuite.hardware.model + +import androidx.annotation.StringRes +import com.evsuite.hardware.R + +enum class DriveMode(val value: Int, val label: String, @StringRes val labelRes: Int) { + ECO(2, "Eco", R.string.drive_eco), + NORMAL(3, "Normal", R.string.drive_normal), + SPORT(4, "Sport", R.string.drive_sport), + SNOW(6, "Snow", R.string.drive_snow), + CUSTOM(7, "Custom", R.string.drive_custom); + + companion object { + fun fromValue(v: Int): DriveMode = values().firstOrNull { it.value == v } ?: NORMAL + } +} 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 new file mode 100644 index 0000000..f3afbcc --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/model/DrivingProfile.kt @@ -0,0 +1,41 @@ +package com.evsuite.hardware.model + +import java.util.UUID + +data class DrivingProfile( + val id: String = UUID.randomUUID().toString(), + val name: String, + val driveMode: DriveMode, + val regenLevel: RegenLevel, + val steeringHeat: Boolean = false, + val seatHeatLeft: Int = 0, // 0=off, 1, 2, 3 + val seatHeatRight: Int = 0, + // ADAS SWI133 (Katman4) — default OFF for compatibility with existing profiles + val overspeedAlarm: Boolean = false, + val speedLimitTone: Boolean = false, + val adasMode: Int = 0, // 0=Off, 1=Limiter, 2=Auto, 3=ACC, 4=ICA + // ADAS SWI68 — separate fields to isolate per-firmware configurations + val soundWarning: Boolean = false, + val swi68AdasMode: Int = 0x4, // ACC/TJA mode (CarAccTja): 0x4=Off, 0x1=ACC, 0x2=TJA/ICA + // Speed limiter (SAS) — INDEPENDENT of the ACC/TJA mode (SWI132). + // swi132LimiterConfigured=false (default + profiles created before this feature) → the + // limiter is NOT touched when the profile is applied (no regression on car state). + val swi132LimiterConfigured: Boolean = false, + val swi132SasMode: Int = 0, // SAS: 0=Disabled, 2=Manual, 3=Intelligent + // AEB — front collision avoidance system (common to SWI133 + SWI68) + val aebEnabled: Boolean = false, // false=OFF, true=ON + val aebMode: Int = 1, // 1=Alert only, 2=Alert + auto braking + val aebSensitivity: Int = 0, // 0=not configured, 1=Low, 2=Standard, 3=High (SWI133) + // ELK — lane departure assist + val elkMode: Int = 0, // 0=not configured, 1=OFF, 2=Warn(LDW), 3=Assist(LDP), 5=ELK + val elkSensitivity: Int = 0, // 0=not configured, 1=Low, 2=Standard, 3=High + // ELK SWI132 — sound alert + vibration (SWI132-specific) + val lasAudibleWarning: Boolean = true, // true=ON (default ON in the car) + val lasVibrationReminder: Boolean = true, // true=ON (default ON in the car) + // Energy saving + TSR + val energySaving: Boolean = false, + 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 +) diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/model/ProfileBackup.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/model/ProfileBackup.kt new file mode 100644 index 0000000..a95f3ad --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/model/ProfileBackup.kt @@ -0,0 +1,17 @@ +package com.evsuite.hardware.model + +/** + * Serialized format of the profile backup (a file in the car's storage). + * + * [schemaVersion] lets the format evolve without breaking older backups (Gson ignores + * unknown fields and applies defaults for missing ones). + */ +data class ProfileBackup( + val schemaVersion: Int = CURRENT_SCHEMA_VERSION, + val defaultId: String? = null, + val profiles: List = emptyList() +) { + companion object { + const val CURRENT_SCHEMA_VERSION = 1 + } +} 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 new file mode 100644 index 0000000..77268b2 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/model/RegenLevel.kt @@ -0,0 +1,17 @@ +package com.evsuite.hardware.model + +import androidx.annotation.StringRes +import com.evsuite.hardware.R + +enum class RegenLevel(val value: Int, val label: String, @StringRes val labelRes: Int) { + LOW(0, "Low", R.string.regen_low), + MEDIUM(1, "Medium", R.string.regen_medium), + HIGH(2, "High", R.string.regen_high), + ADAPTIVE(3, "Adaptive", R.string.regen_adaptive), + OFF(5, "Off", R.string.regen_off), + ONE_PEDAL(6, "One Pedal", R.string.regen_one_pedal); + + companion object { + fun fromValue(v: Int): RegenLevel = values().firstOrNull { it.value == v } ?: MEDIUM + } +} 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 new file mode 100644 index 0000000..9f42156 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/probe/RuntimeInterfaceProbe.kt @@ -0,0 +1,535 @@ +package com.evsuite.hardware.probe + +import android.content.Context +import android.os.IBinder +import com.evsuite.hardware.EVHardware +import com.evsuite.hardware.FirmwareInfo +import com.evsuite.hardware.saic.SaicAidl +import com.evsuite.hardware.saic.SaicHub +import com.evsuite.hardware.saic.SaicService + +/** + * What happened when we asked a vendor interface whether it exists. + * + * The three outcomes are not interchangeable and the `RI-` survey turns on telling them apart. + * A service that is not on this head unit is a closed question. A service that is there and + * refuses an unprivileged caller is an open one with a different answer — the capability exists + * and EVSuite is not allowed it, worth knowing before anyone spends a drive on it. Only a + * service that answers is worth reading values from. + */ +enum class BindVerdict { ABSENT, DENIED, ANSWERED } + +/** + * One interface, surveyed. + * + * [values] holds raw reads keyed by the method that produced them, as strings, because a probe + * that converts before it records has already made the assumption it exists to test. A null + * value means the call reached the service and came back empty — a reading, and a different one + * from the method not being called at all. + */ +data class InterfaceProbe( + val name: String, + val descriptor: String?, + val verdict: BindVerdict, + val values: Map = emptyMap(), + val note: String? = null, +) + +/** + * The `RI-` survey: which of the interfaces the tickets name does this head unit publish, and + * what do they say on this car. + * + * Nothing here is production. Every entry point returns a record rather than a value a caller + * could act on, and none is referenced from any production path in this library — the isolation + * CP-004 settled on for exactly this. Call it only from an app's unstable capture. A probe + * result must never become a fallback: the point is to find out whether a value is real, and + * code consuming it before the answer arrives has skipped the question. + * + * **No setter is called from this file, on any interface.** Several of these binders carry + * writes that would move a car — charge control, screen sleep, focus requests, a vehicle power + * switch. None is referenced here, so no later edit reaches one by accident from a probe path. + */ +object RuntimeInterfaceProbe { + + /** + * Bind both hubs, then let them come up before surveying. + * + * Without this every hub name reads [BindVerdict.ABSENT], which is the one false negative + * this exercise cannot afford: it looks exactly like "this head unit does not publish the + * interface" and would close questions that were never asked. Binding is asynchronous, so + * a survey run in the same breath as this call still reads absent — leave a moment, or run + * it from a capture that has been up for a while. + */ + fun connect(context: Context) { + SaicHub.connect(context) + engMode.connect(context) + } + + /** + * `queryClient(code)` across [CODE_SWEEP], recording the interface descriptor of whatever + * comes back. + * + * This is the prerequisite the whole series shares. EVHardware uses a handful of codes + * because a handful were needed and the rest were never established, which is why RI-002 + * knows what it wants to ask the battery service and has nowhere to send it. Asking each + * code for a binder and reading back its descriptor settles the map on the actual car. + * + * Safe in a way a transaction sweep would not be: `queryClient` hands back a handle and + * [IBinder.getInterfaceDescriptor] asks that handle its own name. Neither is a call *into* + * the interface, so nothing of unknown meaning is invoked. That distinction is why a sweep + * is acceptable here and was refused in CP-004. + */ + fun adapterClientMap(): List { + // Asked once, ahead of the sweep: without an adapter client every code below fails + // before it reaches a binder, and nineteen ABSENT entries then look exactly like a head + // unit that publishes nothing. That is the false negative RI-002 was nearly closed on. + val note = if (EVHardware.hasCarAdapterClient()) null else NO_ADAPTER_CLIENT + return CODE_SWEEP.map { code -> + describe("queryClient(0x${code.toString(16)})", EVHardware.a9ClientBinder(code), note) + } + } + + /** + * The same question of the vehicle-service hub, which resolves sub-services by name. + * + * EVHardware already uses `vehiclesetting` and `aircondition`. `vehiclescreen` is the one + * RI-004 wants and that nothing in EVSuite has ever asked for. + */ + fun hubServiceMap(): List = HUB_NAMES.map { name -> + describe("hub:$name", SaicHub.service(name)) + } + + /** + * RI-001: the vehicle option getters, on the config client. + * + * Raw integers and nothing else. `-1` comes back from these getters when the call itself + * fails, so it cannot be read as a not-fitted code — a probe that mapped these to booleans + * would turn a dead service into "no options fitted". + */ + fun carConfig(): InterfaceProbe = read( + label = "ICarConfigService", + descriptor = DESC_CONFIG, + binder = EVHardware.a9ClientBinder(CODE_CONFIG), + calls = CONFIG_CALLS, + note = "queryClient(0x2); -1 is the vendor wrapper's error value, not a not-fitted code", + ) + + /** + * RI-004: the head unit's own power state, on the hub's `carPower` sub-service. + * + * Three reads and no more. This binder also carries a shutdown request, a restart request + * and a backlight switch; none is named here. The backlight *status* is a boolean over the + * wire and arrives as `0` or `1`, which is why it reads through the same integer path as + * the other two. + * + * The screen sub-service is deliberately not probed. It publishes no getter at all — every + * transaction it answers either moves the screen or registers a callback — so there is + * nothing on it a read-only survey may call, and guessing a code to find out is the exact + * thing this file must not do. Observing screen state needs a listener, which is a larger + * change than a survey and belongs to RI-004's follow-up rather than here. + */ + fun carPower(): InterfaceProbe = read( + label = "ICarPowerService", + descriptor = DESC_CAR_POWER, + binder = SaicHub.service(HUB_CAR_POWER), + calls = POWER_CALLS, + note = "hub:carPower; reads only, the shutdown, restart and backlight writes are not called", + ) + + /** + * RI-005: what the head unit says its own part numbers are. + * + * `FirmwareGen` keys every branch in this library off a generation the project inferred + * from a build string. These four are identity the vehicle asserts instead, and the reason + * to read them is not to replace the generation model but to find out whether the two move + * together. Two cars on one inferred generation with different software part numbers would + * mean the label is coarser than the behaviour it gates. + * + * Reached through the engineering-mode hub, which is a plausible place for an unprivileged + * caller to be refused. [BindVerdict.DENIED] is a legitimate answer here, not a failure. + */ + fun deviceIdentity(): InterfaceProbe = read( + label = "IDIDManager", + descriptor = DESC_DID, + binder = engModeService(ENG_DID), + calls = DID_CALLS, + note = "engmode hub:did; an engineering interface may legitimately refuse us", + ) + + /** + * RI-008: a second opinion on vehicle speed. + * + * [VehicleWriteGate][com.evsuite.hardware.VehicleWriteGate] refuses writes on a moving car + * and fails closed when speed is unreadable, off one source whose scale CP-003 settled. One + * settled source is not a corroborated one. This one reports speed as a float where the + * existing source is integral, which either confirms the scale independently or exposes a + * rounding assumption nobody has had to question. + * + * The other three reads come along because they cost one transaction each and describe the + * same moment the speed was taken in — a speed without its gear and power state is harder + * to interpret afterwards than one with them. + */ + fun evsVehicleState(): InterfaceProbe = read( + label = "ICarEvsService", + descriptor = DESC_EVS, + binder = EVHardware.a9ClientBinder(CODE_EVS), + calls = EVS_CALLS, + note = "queryClient(0x3); speed is a float here and integral in the existing source", + ) + + /** + * RI-003: the typed property channel, reached by name instead of by reflection. + * + * The ticket opens by asking whether the SWI133 acquisition sequence works on SWI68. The + * hub answers sub-services by name, `vehicleproperty` is one of the names it resolves, and + * this asks that way round — if it answers here, the reflection path was never the question. + * + * The ids are chosen so the capture can be read without a second drive. Four are values the + * app already reads through `CarPropertyManager`, so agreement or disagreement is checkable + * against a known good number rather than taken on faith. `EV_BATTERY_AVG_TEMP` is the one + * SWI68 declares and has never published — if it answers *here*, the battery temperature the + * energy screen leaves blank has a source. `POWER_MODE_SWITCH` is the SWI133 id, read to find + * out whether the numbering carries over, and read only: no setter on this interface is + * named anywhere in this file. [PROP_NOT_PUBLISHED] is an id nothing publishes, and exists + * to answer scope item 4 — whether absence is distinguishable from a property reading zero. + * + * The batch `getPropertys` is not covered: its argument is an int array and its reply a typed + * list, neither of which the vararg convention carries, and one transaction per id settles + * the same questions. + */ + fun vehicleProperty(): InterfaceProbe = read( + label = "IVehiclePropertyService", + descriptor = DESC_VEHICLE_PROPERTY, + binder = SaicHub.service(HUB_VEHICLE_PROPERTY), + calls = VEHICLE_PROPERTY_CALLS, + note = "hub:vehicleproperty; getters only, and 0x7FFFFFFF is an id nothing publishes", + ) + + /** + * RI-002: the charging service's own energy counters, with the validity flags beside them. + * + * RI-002 stalled on a unit nobody could prove: a counter read 25.1 where the state of charge + * said about 18.4, and a number whose unit is a guess must not reach a screen. The interface + * publishes a `…V` flag next to several of these values, which is the vehicle's own statement + * about whether the number beside it means anything — reading the pair is what turns the + * question from an argument into a measurement. + * + * Voltage and current come along because the energy screen now derives battery power from the + * property pair, and this service publishes the same two quantities through a different path. + * Two routes to one number is how a sign convention gets settled without a lab. + * + * Reads only. This binder also carries charge start and stop, the charge current limit and + * the reservation schedule — a driver could be left plugged in and not charging by a careless + * transaction code — and none of them is named here. + */ + fun vehicleCharging(): InterfaceProbe = read( + label = "IVehicleChargingService", + descriptor = DESC_VEHICLE_CHARGING, + binder = SaicHub.service(HUB_VEHICLE_CHARGING), + calls = CHARGING_CALLS, + note = "hub:vehiclecharging; the V suffixes are the vehicle's own validity flags", + ) + + /** + * Can this head unit read the car's diagnostics — the OBD question, asked of the platform. + * + * There is no OBD or UDS service anywhere in the head unit's own applications; the vendor + * side of that question is closed. What is left is `CarDiagnosticManager`, the only + * OBD-shaped surface AAOS itself defines, and it is gated behind a privileged permission + * this app will not hold. The expected answer is therefore a refusal — and a recorded + * refusal is worth more than an assumed one, because it is the difference between "we + * checked" and "we decided not to look". + */ + fun carDiagnostics(): InterfaceProbe { + val bound = EVHardware.isCarBound() + val manager = EVHardware.carManagerNamed(CAR_DIAGNOSTIC_SERVICE)?.javaClass?.name + return InterfaceProbe( + name = "CarDiagnosticManager", + descriptor = DESC_CAR_DIAGNOSTIC, + verdict = verdictFor(bound, listOf(manager)), + values = mapOf("car_bound" to bound.toString(), "diagnostic_manager" to manager), + note = "android.car DIAGNOSTIC_SERVICE; a privileged permission we do not hold", + ) + } + + /** + * Everything above in one pass, for an unstable capture to write out. + * + * The two maps come first: on the first car that runs this they are what make the rest of + * the series answerable, and they are worth having even if every typed read below them + * comes back empty. + */ + fun survey(): List = buildList { + add( + InterfaceProbe( + "firmware", null, BindVerdict.ANSWERED, + mapOf("generation" to FirmwareInfo.getGeneration().name), + ) + ) + addAll(adapterClientMap()) + addAll(hubServiceMap()) + addAll(engModeMap()) + add(carConfig()) + add(carPower()) + add(deviceIdentity()) + add(evsVehicleState()) + add(vehicleProperty()) + add(vehicleCharging()) + add(carDiagnostics()) + } + + /** + * Reached, and said something? + * + * The one rule the enum exists for: a binder that came back and then answered nothing at + * all is [BindVerdict.DENIED], not [BindVerdict.ANSWERED] with a page of nulls. In practice + * that is a permission refusal or a descriptor mismatch, and recording it as "answered, + * empty" would let a later session read absence of data as data. One answer is enough to + * call an interface alive. + */ + fun verdictFor(reached: Boolean, answers: Collection): BindVerdict = when { + !reached -> BindVerdict.ABSENT + answers.any { it != null } -> BindVerdict.ANSWERED + else -> BindVerdict.DENIED + } + + /** + * The engineering-mode hub's own sub-services, surveyed the same way as the vehicle hub's. + * + * A second hub, with the same shape and a different bind point. Only `did` is asked for a + * value; the rest are named so that the map records whether they are there, which is what + * makes a later ticket about any of them answerable without another drive. + */ + fun engModeMap(): List = ENG_NAMES.map { name -> + describe("engmode:$name", engModeService(name)) + } + + /** The engineering hub resolves sub-services by name, exactly as the vehicle hub does. */ + private fun engModeService(name: String): IBinder? = + SaicAidl.callBinder(engMode.binder(), DESC_ENG_MODE, TX_ENG_GET_SERVICE, name) + + private val engMode = SaicService(ENG_PACKAGE, ENG_ACTION, "EV_RI_ENGMODE") + + /** A binder that will not name itself was reached and refused, which is not the same as absent. */ + private fun describe(label: String, binder: IBinder?, note: String? = null): InterfaceProbe { + val descriptor = binder?.let { runCatching { it.interfaceDescriptor }.getOrNull() } + return InterfaceProbe( + label, descriptor, verdictFor(binder != null, listOf(descriptor)), note = note, + ) + } + + /** How a reply is laid out. A wrong choice here reads a valid parcel as nonsense. */ + internal enum class ReadAs { INT, FLOAT, STRING } + + /** + * One transaction worth calling, how to read what it sends back, and what it takes. + * + * [args] exists for the property channel, where the transaction is `getIntProperty` and the + * question is the id written into it. Everywhere else it stays empty. + */ + internal data class Call( + val code: Int, + val readAs: ReadAs = ReadAs.INT, + val args: List = emptyList(), + ) + + /** + * Call each named transaction and record what came back, as text. + * + * Booleans go through [ReadAs.INT] on purpose: AIDL puts them on the wire as `0` or `1`, + * and recording the wire value keeps the capture one step away from an interpretation. + */ + private fun read( + label: String, + descriptor: String, + binder: IBinder?, + calls: Map, + note: String, + ): InterfaceProbe { + val target = binder + ?: return InterfaceProbe(label, descriptor, BindVerdict.ABSENT, note = note) + val values = calls.mapValues { (_, call) -> + val args = call.args.toTypedArray() + when (call.readAs) { + ReadAs.INT -> SaicAidl.callInt(target, descriptor, call.code, *args)?.toString() + ReadAs.FLOAT -> SaicAidl.callFloat(target, descriptor, call.code, *args)?.toString() + ReadAs.STRING -> SaicAidl.callString(target, descriptor, call.code, *args) + } + } + return InterfaceProbe(label, descriptor, verdictFor(true, values.values), values, note) + } + + /** + * Adapter service codes to sweep. The low range, because the map is the point — kept + * explicit and short rather than open-ended. + */ + private val CODE_SWEEP = (0x0..0x12).toList() + + /** + * 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 + * preamble, and closing RI-001, RI-002 or RI-008 on a field that was never set is the exact + * mistake this sentence exists to prevent. + */ + internal const val NO_ADAPTER_CLIENT = + "no CarAdapterClient on this firmware — sCarAdapter is assigned only by " + + "initKatman4Swi69, so this call failed before it reached a binder. ABSENT here is " + + "not a reading of the vehicle." + + /** + * Every sub-service name the hub is known to resolve. + * + * `vehicleproperty` is the one to watch: a typed property channel reached by name rather + * than through the reflection path EVHardware uses on SWI133, which is the question RI-003 + * opens with. Whether it answers on the VSM generations is the whole of that ticket. + */ + private val HUB_NAMES = listOf( + "aircondition", "carPower", "vehicleAudio", "vehiclecharging", "vehiclecondition", + "vehiclecontrol", "vehicleproperty", "vehiclescreen", "vehiclesetting", "vehicleTbox", + ) + + /** The engineering hub's sub-services. Only `did` is read; the rest are mapped. */ + private val ENG_NAMES = listOf( + "avm", "did", "log", "system_hardware", "system_setting", "tuner", + ) + + private const val ENG_PACKAGE = "com.saicmotor.service.engmode" + private const val ENG_ACTION = "com.saicmotor.service.engmode.EngineeringModeService" + private const val ENG_DID = "did" + private const val TX_ENG_GET_SERVICE = 1 + + private const val HUB_CAR_POWER = "carPower" + private const val HUB_VEHICLE_PROPERTY = "vehicleproperty" + private const val HUB_VEHICLE_CHARGING = "vehiclecharging" + private const val CODE_CONFIG = 0x2 + private const val CODE_EVS = 0x3 + private const val CAR_DIAGNOSTIC_SERVICE = "DIAGNOSTIC_SERVICE" + + private const val DESC_CONFIG = "com.saicmotor.carapi.config.ICarConfigService" + private const val DESC_CAR_POWER = "com.saicmotor.sdk.vehiclesettings.ICarPowerService" + private const val DESC_ENG_MODE = "com.saicmotor.sdk.engmode.IEngineeringMode" + private const val DESC_DID = "com.saicmotor.sdk.engmode.IDIDManager" + private const val DESC_EVS = "com.saicmotor.carapi.evs.ICarEvsService" + private const val DESC_VEHICLE_PROPERTY = + "com.saicmotor.sdk.vehiclesettings.IVehiclePropertyService" + private const val DESC_VEHICLE_CHARGING = + "com.saicmotor.sdk.vehiclesettings.IVehicleChargingService" + private const val DESC_CAR_DIAGNOSTIC = "android.car.diagnostic.CarDiagnosticManager" + + /** The two typed getters RI-003 needs; every setter on this interface stays unnamed. */ + private const val TX_PROPERTY_GET_INT = 6 + private const val TX_PROPERTY_GET_FLOAT = 5 + + /** + * The transactions on the property interface that write. Named only so a test can prove + * [VEHICLE_PROPERTY_CALLS] stays clear of them — nothing calls them. + */ + internal val PROPERTY_WRITES = setOf(1, 2, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24) + + /** + * The transactions on the charging interface that change something: charge and discharge + * control, the current limit, the reservation schedule, the target states of charge and the + * pile list edits. Named only so a test can prove [CHARGING_CALLS] stays clear of them. + */ + internal val CHARGING_WRITES = setOf( + 1, 2, 12, 14, 16, 18, 20, 22, 24, 26, 28, 38, 40, 44, 47, 50, 51, 53, 54, 55, 57, 58, + 59, 60, 81, + ) + + /** An id nothing on this firmware publishes, so absence has something to look like. */ + private const val PROP_NOT_PUBLISHED = 0x7FFFFFFF + + /** + * The transactions on the power interface that change something: the backlight switch, a + * shutdown request and a restart request. Named here only so a test can prove [POWER_CALLS] + * stays clear of them — nothing calls them. + */ + internal val POWER_WRITES = setOf(1, 4, 5) + + /** RI-004's reads. Every other transaction on this interface changes something. */ + internal val POWER_CALLS = mapOf( + "getBackLightStatus" to Call(2), + "getBootReason" to Call(3), + "getCurrentPowerMode" to Call(11), + ) + + /** RI-005's four identity strings. */ + private val DID_CALLS = mapOf( + "getAssemblyPartNum" to Call(1, ReadAs.STRING), + "getHardwarePartNum" to Call(2, ReadAs.STRING), + "getSoftwarePartNum" to Call(3, ReadAs.STRING), + "getProductSerialNum" to Call(4, ReadAs.STRING), + ) + + /** RI-008's speed, and the three reads that say what moment it was taken in. */ + internal val EVS_CALLS = mapOf( + "getVehicleSpeed" to Call(38, ReadAs.FLOAT), + "getReverseReq" to Call(13), + "getSteeringAngle" to Call(16), + "getSystemPowerState" to Call(37), + ) + + /** + * RI-003's reads, keyed by the property each one asks for. + * + * The type per id is the one `CarPropertyManager` uses for the same id where the app already + * reads it, so a disagreement is about the channel and not about how the reply was decoded. + */ + internal val VEHICLE_PROPERTY_CALLS = mapOf( + // Four the app already reads another way: the comparison RI-003 scope item 2 asks for. + "EV_BATTERY_PCT 0x2160F404" to Call(TX_PROPERTY_GET_INT, args = listOf(0x2160F404)), + "EV_RANGE_KM 0x2140F41C" to Call(TX_PROPERTY_GET_INT, args = listOf(0x2140F41C)), + "BMS_PACK_VOL 0x2160F406" to Call(TX_PROPERTY_GET_FLOAT, ReadAs.FLOAT, listOf(0x2160F406)), + "BMS_PACK_CRNT 0x2160F407" to Call(TX_PROPERTY_GET_FLOAT, ReadAs.FLOAT, listOf(0x2160F407)), + // The two the energy screen has no source for, asked of a channel that has never been tried. + "EV_BATTERY_AVG_TEMP 0x1160030E" to + Call(TX_PROPERTY_GET_FLOAT, ReadAs.FLOAT, listOf(0x1160030E)), + "HVAC_AMBIENT_TEMP 0x1560252A" to + Call(TX_PROPERTY_GET_FLOAT, ReadAs.FLOAT, listOf(0x1560252A)), + // Scope item 3: an id that means something on SWI133, read to see what it means here. + "POWER_MODE_SWITCH 0x6030021" to Call(TX_PROPERTY_GET_INT, args = listOf(0x6030021)), + // Scope item 4: what absence looks like, so a zero can be told from a silence. + "unpublished id" to Call(TX_PROPERTY_GET_INT, args = listOf(PROP_NOT_PUBLISHED)), + ) + + /** + * RI-002's reads: the counters, the validity flag beside each one that has one, and the two + * quantities the energy screen already derives battery power from. + */ + internal val CHARGING_CALLS = mapOf( + "getChargingStatus" to Call(9), + "getCurrentElectricQuantity" to Call(3, ReadAs.FLOAT), + "getCurrentElectricQuantityV" to Call(29), + "getPowerBatteryVol" to Call(64, ReadAs.FLOAT), + "getPowerBatteryVolV" to Call(65), + "getChargingCurrent" to Call(11), + "getAcVoltage" to Call(91, ReadAs.FLOAT), + "getAcCurrent" to Call(90, ReadAs.FLOAT), + "getAccConsumptionAfterStart" to Call(74, ReadAs.FLOAT), + "getTotalConsumptionAfterStart" to Call(76, ReadAs.FLOAT), + "getTotalRegenEnrgAfterStart" to Call(78, ReadAs.FLOAT), + "getTotalRegenRngAfterStart" to Call(80, ReadAs.FLOAT), + "getElecCsumpPerKm" to Call(66, ReadAs.FLOAT), + "getElecCsumpPerKmV" to Call(67), + "getElectricityLevel" to Call(92), + "getDrivingBatteryHeat" to Call(37), + "getEnergyFlowInfo" to Call(72), + // RI-009, for the price of one transaction: a typed list puts its element count on the + // wire first, so reading the reply as an integer says how many charging stations the + // vehicle's own dataset has here — -1 for a null list, 0 for an empty one. Whether the + // regional variant answers in Europe at all is the whole of that ticket's first question. + "getChargingPileList (count)" to Call(61), + ) + + /** The option getters RI-001 names as decisive, with their transaction codes. */ + internal val CONFIG_CALLS = mapOf( + "getTpmsConfigData" to Call(1), + "getHvacConfigData" to Call(7), + "getCarModelConfigData" to Call(8), + "getWheelPosition" to Call(9), + "getSeatHeatingConfigData" to Call(23), + "getCarRegionConfig" to Call(28), + "getAmbientLightConfig" to Call(41), + "getOnePedalConfig" to Call(43), + ) +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavGuidance.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavGuidance.kt new file mode 100644 index 0000000..c5700b6 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavGuidance.kt @@ -0,0 +1,183 @@ +package com.evsuite.hardware.saic + +import com.evsuite.hardware.FirmwareInfo + +/** + * What the head unit's navigation stack last said about the trip in progress. + * + * Every field is nullable and every field is raw. The adapter service logs these values + * without naming a unit, and no unit has been proven on a vehicle yet, so nothing here is + * converted, scaled or rounded — a kilometre read as a metre is the mistake that turns an + * arrival forecast into a stranding. Consumers must treat the distances as unvalidated until + * an on-vehicle run says otherwise, which is what CP-040 exists to collect. + * + * Absence is absence: a field that never arrived stays null rather than becoming zero. + */ +data class NavGuidance( + /** Raw `guideStatusChange` code. The mapping to "guiding"/"idle" is not yet proven. */ + val guideStatus: Int? = null, + /** Raw `remainingDistanceChange` value — distance to destination, unit unproven. */ + val remainingDistanceRaw: Int? = null, + /** Raw `remainingTimesChange` value. The service logs it as `remainingMinutes`. */ + val remainingMinutes: Int? = null, + /** Raw `guideInfosChange` distance — to the next manoeuvre, not to the destination. */ + val nextTurnDistanceRaw: Int? = null, + /** Raw `guideInfosChange` icon code. */ + val nextTurnIcon: Int? = null, + /** `guideInfosChange` direction text, as the navigation app worded it. */ + val direction: String? = null, + /** `roadInfoChange` text. */ + val road: String? = null, + /** How many callbacks have been folded in. The liveness signal a capture needs. */ + val events: Int = 0, + /** Monotonic time of the last accepted callback, or null before the first one. */ + val updatedAtElapsedMs: Long? = null, +) { + /** + * Remaining distance to the destination in km, when the raw value's unit is known. + * + * The adapter names no unit. A capture on 2026-09-04, SWI68-29958-1300R67, read 9788 + * against a remaining time of 25 — and `MapService` logs that second value as + * `remainingMinutes`, so 9788 metres is 23,5 km/h to the destination and 9788 kilometres + * is not a number about a car. Metres is the only reading that survives. + * + * One observation, so this is gated per generation like every other unit here: an + * unlisted firmware keeps the raw value and no kilometres are claimed. A drive confirms + * it by comparing against the odometer span. + */ + fun remainingDistanceKm(generation: FirmwareInfo.Gen): Double? = + remainingDistanceRaw + ?.takeIf { generation in REPORTS_METRES } + ?.let { it / 1000.0 } + companion object { + val EMPTY = NavGuidance() + + /** Generations observed to report `getRemainingDistance` in metres. */ + private val REPORTS_METRES = setOf(FirmwareInfo.Gen.SWI68) + } +} + +/** + * Folds one adapter-service callback into [NavGuidance]. + * + * Pure and Android-free so the transaction map is testable on the JVM: the binder shell does + * the parcel reading, this decides what the numbers mean. Transaction codes come from + * `IGeneralNotificationListener` in `saicadapterservice_overseas_eh32` (R69 EH32); an + * unrecognised code leaves the state untouched rather than guessing at its payload. + */ +object NavGuidanceReducer { + + const val TX_GUIDE_STATUS_CHANGE = 1 + const val TX_GUIDE_INFOS_CHANGE = 2 + const val TX_ROAD_INFO_CHANGE = 3 + const val TX_REMAINING_TIMES_CHANGE = 12 + const val TX_REMAINING_DISTANCE_CHANGE = 13 + + /** Every code this decoder claims to understand. Anything else is left alone. */ + val KNOWN_TRANSACTIONS = setOf( + TX_GUIDE_STATUS_CHANGE, + TX_GUIDE_INFOS_CHANGE, + TX_ROAD_INFO_CHANGE, + TX_REMAINING_TIMES_CHANGE, + TX_REMAINING_DISTANCE_CHANGE, + ) + + /** + * @param first the callback's first int argument, when it has one. + * @param second the second int argument — only `guideInfosChange` carries one. + * @param text the string argument, for the two callbacks that carry one. + * @return the new state, or [state] unchanged when [code] is not one this understands. + */ + fun fold( + state: NavGuidance, + code: Int, + first: Int? = null, + second: Int? = null, + text: String? = null, + atElapsedMs: Long, + ): NavGuidance { + val folded = when (code) { + TX_GUIDE_STATUS_CHANGE -> state.copy(guideStatus = first) + // icon, then distance to the next manoeuvre, then the direction wording. + TX_GUIDE_INFOS_CHANGE -> state.copy( + nextTurnIcon = first, + nextTurnDistanceRaw = second, + direction = text, + ) + TX_ROAD_INFO_CHANGE -> state.copy(road = text) + TX_REMAINING_TIMES_CHANGE -> state.copy(remainingMinutes = first) + TX_REMAINING_DISTANCE_CHANGE -> state.copy(remainingDistanceRaw = first) + else -> return state + } + return folded.copy(events = state.events + 1, updatedAtElapsedMs = atElapsedMs) + } +} + +/** + * How many times each transaction code arrived, decoded or not. + * + * The transaction map was read from an R69 build; a vehicle may run another revision of the + * same generation. If SAIC inserted a method into `IGeneralNotificationListener` between two + * revisions, every later code shifts by one — and a shifted map does not fail, it silently + * reports the wrong number as a distance. That is the one failure this instrument must not + * have, so the census records *every* code the adapter sends. A capture whose census shows + * traffic on codes this build does not decode, or silence on the ones it does, has caught the + * shift before anyone builds an arrival forecast on it. + * + * Counting is a bare array write on the adapter's fan-out thread: no lock, no allocation, no + * blocking. Callbacks may arrive on several binder threads at once and a lost increment is + * acceptable here — this counts what kind of traffic exists, not how much of it exactly. + */ +class TransactionCensus(private val ceiling: Int = DEFAULT_CEILING) { + + private val counts = IntArray(ceiling) + + /** + * The size of the last parcel seen on each code, or -1 for a code never seen. + * + * A count says a code exists; a size says whether this build reads it correctly. The + * 2026-09-07 capture is why: with guidance running, code 13 decoded as a remaining distance + * of zero, and nothing in the artifact could tell a genuine zero from a payload of the wrong + * shape read as an int. A parcel carrying one int is the descriptor plus four bytes, and + * anything larger settles it without decoding a single field. + * + * A byte count is not a place. This is the whole reason it can be exported. + */ + private val lastBytes = IntArray(ceiling) { UNSEEN } + + /** Codes at or above [ceiling]. Kept separate rather than growing the array. */ + @Volatile + var beyondCeiling: Int = 0 + private set + + fun record(code: Int, payloadBytes: Int = UNSEEN) { + if (code !in 0 until ceiling) { + beyondCeiling++ + return + } + counts[code]++ + if (payloadBytes >= 0) lastBytes[code] = payloadBytes + } + + /** Codes seen at least once, with their counts. Allocates — for reporting, not the hot path. */ + fun snapshot(): Map = + counts.indices.filter { counts[it] > 0 }.associateWith { counts[it] } + + /** The same codes, with the size of the last parcel each carried. Absent when never measured. */ + fun payloadBytes(): Map = + lastBytes.indices.filter { lastBytes[it] >= 0 }.associateWith { lastBytes[it] } + + fun clear() { + counts.fill(0) + lastBytes.fill(UNSEEN) + beyondCeiling = 0 + } + + private companion object { + /** `IGeneralNotificationListener` declares well under this many methods on R69. */ + const val DEFAULT_CEILING = 128 + + const val UNSEEN = -1 + } +} + diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavLegChain.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavLegChain.kt new file mode 100644 index 0000000..37d6bbd --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavLegChain.kt @@ -0,0 +1,117 @@ +package com.evsuite.hardware.saic + +/** + * A trip driven one leg at a time, because the command that starts guidance carries one point. + * + * **Why a chain exists at all.** A plan with a charging stop is a stop and then a destination, and + * nothing on this head unit has been seen to drive both from one handover. + * [SaicNavGuidance.goTo] takes a single point by construction. [SaicNavGuidance.startNavFromEvRoute] + * takes a pathway and a destination and, on SWI68 on 2026-09-09, is the channel that actually + * starts guidance — but whether Telenav drives *through* the pathway or straight to the last point + * is not known, and no drive with a charging stop has happened yet to say. The chain covers both: + * if the map drives the whole route, guidance never stops early and this sends nothing. + * + * **What "over" means here.** The navigation app's own flag — `isMapNavigating` — going false + * after having been true for this leg. That is arrival in every ordinary case, and it is the only + * signal available without a network request, a coordinate comparison or a second permission. + * A driver who *cancels* guidance mid-leg produces the same signal, so the next leg starts under + * them; they cancel that one too and the chain gives up. That is the known ceiling of this + * design, and the alternative — comparing the car's position against the leg's — would put + * coordinates on a background timer for a case the driver can undo with one tap. + * + * **Nothing here is persisted.** The chain lives in memory for the length of the drive. It holds + * place names and coordinates, which is precisely why it is never written down: the followed-plan + * store on the other side of this trip keeps none of that, and a chain on disk would be the same + * data by another route. A process that dies loses the chain, and the driver hands over the + * remaining leg from the screen, which is what they did before this class existed. + * + * Pure and Android-free: the caller does the binder reads and this decides what they mean. + */ +class NavLegChain(legs: List) { + + /** What the caller should do after a tick. */ + sealed interface Step { + /** Nothing to do: the leg is still being driven, or guidance has yet to appear. */ + data object Wait : Step + + /** + * Hand this point over — the previous leg ended. + * + * [number] and [count] are one-based and are the only things about a leg that may be + * written to a probe: a name or a coordinate on a diagnostic file leaves the car. + */ + data class Send( + val poi: NavigationHandoff.Poi, + val number: Int, + val count: Int, + ) : Step + + /** The chain is over and every later tick returns this. [arrived] tells why. */ + data class Done(val arrived: Boolean) : Step + } + + private val legs = legs.toList() + + /** The leg the car was last sent to. Zero is the one the tap itself handed over. */ + private var index = 0 + + /** Whether guidance has been seen running for [index]. Nothing chains before it has. */ + private var sawGuidance = false + + /** Consecutive readings with no guidance — the grace before a start, the settle after one. */ + private var silentTicks = 0 + + private var done: Step.Done? = null + + /** + * Folds one reading of the navigation app's flag. + * + * @param guiding what `isMapNavigating` answered. Do not call with a guess: a caller that + * could not reach the adapter should skip the tick entirely rather than pass false, or the + * grace below expires against a question that was never asked. + */ + fun tick(guiding: Boolean): Step { + done?.let { return it } + if (legs.size < 2) return finish(arrived = true) + if (guiding) { + sawGuidance = true + silentTicks = 0 + return Step.Wait + } + if (!sawGuidance) { + // Guidance never started for this leg. Chaining onto a leg that was refused would + // send the car to the destination while it is still standing at the charger. + silentTicks++ + return if (silentTicks >= GIVE_UP_TICKS) finish(arrived = false) else Step.Wait + } + // One false reading is not an arrival. The drive of 2026-09-09 16:34 watched a route + // handed over through `startNavFromEVRout` run for twenty-five minutes while the adapter + // stopped answering a remaining distance seven minutes in and its notification listener + // heard nothing at all. Whether the guiding flag itself is that shaky is not yet known, + // and the cost of being wrong is a car sent onward from a charger it is still driving to. + // Consecutive readings cost seconds; a genuine arrival stays false for the rest of the day. + if (++silentTicks < SETTLE_TICKS) return Step.Wait + index++ + if (index > legs.lastIndex) return finish(arrived = true) + sawGuidance = false + silentTicks = 0 + return Step.Send(legs[index], number = index + 1, count = legs.size) + } + + private fun finish(arrived: Boolean): Step.Done = + Step.Done(arrived).also { done = it } + + companion object { + /** + * How many silent ticks before a leg that never started is abandoned. + * + * The caller ticks every few seconds, so this is a couple of minutes: long enough for a + * navigation app to come up and start drawing, short enough that a chain armed by a tap + * nobody followed does not sit waiting for the rest of the drive. + */ + const val GIVE_UP_TICKS = 24 + + /** Consecutive readings with no guidance before a leg that was running counts as over. */ + const val SETTLE_TICKS = 3 + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavigationHandoff.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavigationHandoff.kt new file mode 100644 index 0000000..9f29319 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/NavigationHandoff.kt @@ -0,0 +1,115 @@ +package com.evsuite.hardware.saic + +import java.util.Locale + +/** + * A destination, in the one form every Android navigation app has agreed to understand. + * + * **This is the library's first write towards the car.** Everything else here reads: the hub, + * the nav adapter, the guidance getters all answer questions and change nothing. This builds a + * string that will move the screen a driver is about to follow at 130 km/h, and it is separated + * from the sending on purpose — the format is the part that can be wrong, and the part a JVM + * test can prove right without a head unit. + * + * **Two forms, because the car answers one of them.** `geo:` is the platform's own answer and + * every Android map understands it; on SWI68 nothing declares a filter for it, so two drives + * produced a map that opened at its default view with the destination left behind. [poi] is the + * other form — the points as the head unit's own adapter carries them, sent by + * [SaicNavGuidance.startNavFromEvRoute]. Both are built here, and only here, because the format + * is the part that can be wrong and the part a JVM test can prove right without a head unit. + * + * **What `geo:` cannot do.** It carries one point. There are no waypoints in it, so a route + * planned *through* somewhere cannot be pinned by handing over its endpoint — the caller hands + * over the charging stop instead, which is the leg the forecast is about, and says so on screen. + * The adapter form has a pathway list and does not have that limit. + */ +object NavigationHandoff { + + /** + * The destination as a `geo:` URI, or null when the coordinates are not a place on Earth. + * + * Refusing out-of-range coordinates is not defensive padding: latitude and longitude are two + * doubles of the same type and a swap routes to the wrong continent while looking like an + * ordinary number the whole way. Half the swaps land outside ±90 and this catches those. + * + * [Locale.ROOT] because a head unit set to French formats 43.5 as `43,5`, and a comma is a + * field separator in this URI. The destination would parse, silently, as somewhere else. + */ + fun geoUri(latitude: Double, longitude: Double, label: String? = null): String? { + if (!latitude.isFinite() || !longitude.isFinite()) return null + if (latitude < -90.0 || latitude > 90.0) return null + if (longitude < -180.0 || longitude > 180.0) return null + val point = String.format(Locale.ROOT, "%.6f,%.6f", latitude, longitude) + val name = label?.let(::sanitise)?.takeIf { it.isNotEmpty() } + // The `q=` form is what carries a name; the leading point is what an app that ignores + // `q=` still shows. Both are the same place, so neither reading is wrong. + return if (name == null) "geo:$point?q=$point" else "geo:$point?q=$point($name)" + } + + /** + * A label reduced to what cannot change the meaning of the URI around it. + * + * The name comes from a geocoder or a charger dataset — text this project did not write and + * cannot constrain. A parenthesis, an ampersand or a percent in it would end the label early + * or introduce a parameter, so the allowlist is letters, digits, spaces and hyphens, and + * everything else becomes a space. Accented letters are kept: a French place name without + * them is a different name. + */ + private fun sanitise(label: String): String = + label.take(MAX_LABEL_CHARS) + .map { if (it.isLetterOrDigit() || it == ' ' || it == '-') it else ' ' } + .joinToString("") + .trim() + .replace(SPACES, "%20") + + /** Long enough for "Ionity Béziers Est", short enough not to be a payload. */ + private const val MAX_LABEL_CHARS = 48 + + private val SPACES = Regex(" +") + + /** + * One point of a route, in the shape the adapter's own `EVRoutPoiInfo` bean carries: + * latitude, longitude, name. Nothing else fits in it, which is the whole of what leaves. + */ + data class Poi(val latitude: Double, val longitude: Double, val name: String) + + /** + * A validated point, or null when the coordinates are not a place on Earth. + * + * The same range check as [geoUri] and for the same reason — two doubles of one type, and a + * swap that routes to another continent while looking like an ordinary number. + * + * The name is *not* sanitised the way a URI label is. This one is a parcel field, not part + * of a URI, so a parenthesis or an ampersand cannot end it early and stripping them would + * only mangle the name the driver reads on the map. What is removed is what a text field + * has no business carrying: control characters, and runs of whitespace that would make two + * different names look alike. An empty name falls back to the coordinates, because + * `EVRoutPoiInfo` has nowhere to put "unnamed" and a map showing a blank label is worse + * than one showing a point. + */ + fun poi(latitude: Double, longitude: Double, label: String? = null): Poi? { + if (!latitude.isFinite() || !longitude.isFinite()) return null + if (latitude < -90.0 || latitude > 90.0) return null + if (longitude < -180.0 || longitude > 180.0) return null + val name = label.orEmpty() + .take(MAX_LABEL_CHARS) + .map { if (it.isISOControl()) ' ' else it } + .joinToString("") + .replace(SPACES, " ") + .trim() + .ifEmpty { String.format(Locale.ROOT, "%.6f,%.6f", latitude, longitude) } + return Poi(latitude, longitude, name) + } + + /** + * The pathway list, capped. + * + * A charging plan has one stop today and may have three; a list long enough to matter to a + * binder transaction is a list this app never built. The cap is what keeps a malformed plan + * from becoming a parcel the adapter has to fan out to every listener it holds a lock over. + */ + fun pathway(stops: List): List = stops.take(MAX_PATHWAY_POINTS) + + /** More waypoints than a charging plan has ever produced, and few enough to stay a message. */ + const val MAX_PATHWAY_POINTS = 8 +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/RadioFrequency.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/RadioFrequency.kt new file mode 100644 index 0000000..6cb716d --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/RadioFrequency.kt @@ -0,0 +1,70 @@ +package com.evsuite.hardware.saic + +/** + * Turns what a driver types into the (band, kHz) pair `IRadioAppService.tune` wants. + * + * Nobody types "87500". People type "103.5", and about as often "FM 103,5" or "1080 AM". + * All of those name exactly one station, so all of them are accepted; anything that names + * no station at all is rejected rather than guessed at, because tuning the wrong frequency + * is a worse answer than saying the text made no sense. + * + * Android-free on purpose: this is the part worth testing, and it runs on the JVM. + * + * Bounds and steps follow the project's radio contract: `RadioConstants.AM_RANGE` / + * `FM_RANGE`, and the tuning scale's own step (`NormalRadioInfoFragment`: 9 kHz on AM, + * 50 kHz on FM). + */ +object RadioFrequency { + + const val AM_MIN_KHZ = 522 + const val AM_MAX_KHZ = 1620 + const val FM_MIN_KHZ = 87_500 + const val FM_MAX_KHZ = 108_000 + + /** A tuned station: the band constant [SaicRadio] expects, and a frequency in kHz. */ + data class Station(val band: Int, val frequencyKhz: Int) + + private val NUMBER = Regex("""\d+(?:[.,]\d+)?""") + + /** + * @param text what the driver typed. + * @param band the band the rule picked, or null when it picked none. + * @return the station, or null when [text] names none on [band]. + * + * The band is taken from the rule when it names one, then from the text when the text says + * one, and inferred from the number otherwise — the two ranges do not overlap in any unit, + * so "103.5" can only be FM and "1080" can only be AM. + * + * A band that disagrees with the text (`AM 103.5`, or a picker on AM over a typed "103.5") + * is rejected rather than resolved: two things were said, and silently picking one of them + * is how a rule ends up on a station nobody chose. Only AM and FM name a frequency at all, + * so a [band] of DAB never parses — the caller reaches DAB by naming the band alone. + */ + fun parse(text: String, band: Int? = null): Station? { + val upper = text.uppercase() + val typed = when { + "FM" in upper -> SaicRadio.BAND_FM + "AM" in upper -> SaicRadio.BAND_AM + else -> null + } + // Both said, and not the same thing: the disagreement is the answer. + if (band != null && typed != null && band != typed) return null + val stated = band ?: typed + val raw = NUMBER.find(upper)?.value?.replace(',', '.')?.toDoubleOrNull() ?: return null + + // Megahertz or kilohertz, both written by real people: 103.5, 103500, 1080, 1.08. + val candidates = listOf( + Station(SaicRadio.BAND_FM, Math.round(raw * 1000).toInt()), + Station(SaicRadio.BAND_FM, Math.round(raw).toInt()), + Station(SaicRadio.BAND_AM, Math.round(raw).toInt()), + Station(SaicRadio.BAND_AM, Math.round(raw * 1000).toInt()), + ) + return candidates.firstOrNull { it.inRange() && (stated == null || it.band == stated) } + } + + private fun Station.inRange(): Boolean = when (band) { + SaicRadio.BAND_FM -> frequencyKhz in FM_MIN_KHZ..FM_MAX_KHZ + SaicRadio.BAND_AM -> frequencyKhz in AM_MIN_KHZ..AM_MAX_KHZ + else -> false + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicIpc.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicIpc.kt new file mode 100644 index 0000000..e674678 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicIpc.kt @@ -0,0 +1,219 @@ +package com.evsuite.hardware.saic + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.Parcel +import com.evsuite.hardware.AppLogger + +/** + * Talking to the SAIC vendor services the head unit's own apps use. + * + * Those apps ship an SDK (`com.saicmotor.sdk.*`) that wraps each service, but the SDK lives + * inside their APKs, not on the boot classpath — reflection cannot reach it from here. What + * *is* reachable is the AIDL underneath: a service to bind, an interface descriptor, and a + * transaction code per method. Keep every constant documented here as an implementation + * detail and validate compatibility on each supported firmware generation. + * + * The binding is asynchronous and survives for the process' lifetime. A call made before the + * service is up returns null rather than blocking: the rule engine has a cycle to finish, and + * a vendor service that is slow to come up must not hold it. + */ +class SaicService private constructor( + private val packageName: String, + private val action: String?, + private val className: String?, + private val tag: String, +) { + + /** Most vendor services publish an action; this is the usual way in. */ + constructor(packageName: String, action: String, tag: String) : + this(packageName, action, null, tag) + + + + @Volatile + private var binder: IBinder? = null + + @Volatile + private var connecting = false + + val isReady: Boolean get() = binder?.isBinderAlive == true + + fun binder(): IBinder? = binder?.takeIf { it.isBinderAlive }.also { + // A service that died leaves a stale handle behind; drop it so the next connect() + // rebinds instead of transacting into nothing. + if (it == null && binder != null) binder = null + } + + /** Idempotent: binds once, and again only after the service has died. */ + fun connect(context: Context) { + if (isReady || connecting) return + connecting = true + val intent = (if (action != null) Intent(action) else Intent()).apply { + if (className != null) setClassName(packageName, className) else setPackage(packageName) + } + val bound = runCatching { + context.applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) + }.getOrDefault(false) + if (!bound) { + connecting = false + AppLogger.w(TAG, "$tag: bindService($packageName/${className ?: action}) refused") + } + } + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + connecting = false + binder = service + AppLogger.i(TAG, "$tag: connected") + } + + override fun onServiceDisconnected(name: ComponentName?) { + binder = null + AppLogger.w(TAG, "$tag: disconnected") + } + } + + companion object { + private const val TAG = "EV_SAIC" + + /** + * For a service with no intent filter, reached by explicit component — how the car's + * own apps bind the TTS service, and the only way it answers. + */ + fun byComponent(packageName: String, className: String, tag: String) = + SaicService(packageName, null, className, tag) + + /** + * For one component publishing several interfaces, each behind its own action — the + * media service, where the action selects which player answers. + * + * Both halves are needed: the action alone would leave the platform to resolve the + * component, and this service exports several; the component alone would bind but + * hand back whichever interface the service returns for a null action, which is not + * the one asked for. + */ + fun byActionAndComponent( + packageName: String, + action: String, + className: String, + tag: String, + ) = SaicService(packageName, action, className, tag) + } +} + +/** + * The AIDL calling convention the generated proxies use, written once. + * + * Every SAIC interface follows the same shape: interface token, arguments in declaration + * order, `transact(code, …, 0)`, then `readException()` before the result. Nothing here is + * SAIC-specific — it is what `aidl` generates — but having it in one place is what keeps the + * typed wrappers below down to a line each. + */ +object SaicAidl { + + private const val TAG = "EV_SAIC" + + /** @return true when the call reached the service and it did not throw. */ + fun callVoid(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): Boolean = + transact(binder, descriptor, code, args) { true } ?: false + + fun callInt(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): Int? = + transact(binder, descriptor, code, args) { it.readInt() } + + fun callBoolean(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): Boolean? = + transact(binder, descriptor, code, args) { it.readInt() != 0 } + + fun callFloat(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): Float? = + transact(binder, descriptor, code, args) { it.readFloat() } + + fun callString(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): String? = + transact(binder, descriptor, code, args) { it.readString() } + + fun callBinder(binder: IBinder?, descriptor: String, code: Int, vararg args: Any?): IBinder? = + transact(binder, descriptor, code, args) { it.readStrongBinder() } + + /** + * For the replies a typed helper cannot cover: a vendor bean, which has to be read field + * by field in the order its own `writeToParcel` wrote them. + * + * Coupling to a vendor class' serialisation is a liability, so it is confined here and to + * its callers: a bean that changes shape throws inside [transact] and the call reads as + * unanswered, never as a guessed value. + */ + fun callParcel( + binder: IBinder?, + descriptor: String, + code: Int, + readReply: (Parcel) -> T, + ): T? = transact(binder, descriptor, code, {}, readReply) + + /** + * For the arguments a vararg cannot carry: a typed list of a vendor bean, which has to be + * written field by field in the order that bean's own `writeToParcel` reads them. + * + * The mirror image of [callParcel] and the same liability, confined the same way: the + * caller owns the layout, a mismatch throws inside [transact], and the call reads as + * unanswered rather than as a half-written argument the service tries to interpret. + * + * @return true when the call reached the service and it did not throw. + */ + fun callWriting( + binder: IBinder?, + descriptor: String, + code: Int, + writeData: (Parcel) -> Unit, + ): Boolean = transact(binder, descriptor, code, writeData) { true } ?: false + + private fun transact( + binder: IBinder?, + descriptor: String, + code: Int, + args: Array, + readReply: (Parcel) -> T, + ): T? = transact(binder, descriptor, code, { data -> + args.forEach { arg -> + when (arg) { + is Int -> data.writeInt(arg) + is Boolean -> data.writeInt(if (arg) 1 else 0) + is Float -> data.writeFloat(arg) + is String -> data.writeString(arg) + is Double -> data.writeDouble(arg) + // A callback binder, for the one service here that answers asynchronously. + is IBinder -> data.writeStrongBinder(arg) + else -> error("unsupported AIDL argument: $arg") + } + } + }, readReply) + + private fun transact( + binder: IBinder?, + descriptor: String, + code: Int, + writeData: (Parcel) -> Unit, + readReply: (Parcel) -> T, + ): T? { + val target = binder ?: return null + val data = Parcel.obtain() + val reply = Parcel.obtain() + return try { + data.writeInterfaceToken(descriptor) + writeData(data) + if (!target.transact(code, data, reply, 0)) { + AppLogger.d(TAG, "$descriptor#$code: transact returned false") + return null + } + reply.readException() + readReply(reply) + } catch (e: Exception) { + AppLogger.d(TAG, "$descriptor#$code: ${e.message}") + null + } finally { + reply.recycle() + data.recycle() + } + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicMedia.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicMedia.kt new file mode 100644 index 0000000..c530ffb --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicMedia.kt @@ -0,0 +1,355 @@ +package com.evsuite.hardware.saic + +import android.content.Context + +/** + * The head unit's radio, as its own radio app drives it. + * + * Binding contract: `RadioOptionManager` (bind target, from + * `RadioConstants.SERVICE_PACKAGE` / `SERVICE_ACTION`), `IRadioAppService` (transaction + * codes) and `RadioType` (the band values). + */ +object SaicRadio { + + private const val PACKAGE = "com.saicmotor.service.radio" + private const val ACTION = "com.saicmotor.service.radio.radioservice" + private const val DESCRIPTOR = "com.saicmotor.sdk.radio.IRadioAppService" + + private const val TX_TUNE = 8 + private const val TX_NEXT = 13 + private const val TX_PREVIOUS = 14 + private const val TX_CURRENT_INFO = 19 + private const val TX_START_ACTIVITY = 26 + private const val TX_SRC_PLAY = 27 + private const val TX_SRC_PAUSE = 28 + + /** `RadioType`: 1 = AM, 2 = FM, 4 = DAB. */ + const val BAND_AM = 1 + const val BAND_FM = 2 + const val BAND_DAB = 4 + + private val service = SaicService(PACKAGE, ACTION, "radio") + + val isAvailable: Boolean get() = service.isReady + + fun connect(context: Context) = service.connect(context) + + /** + * Makes radio the current audio source, resuming the last station. + * + * `srcPlayRadio` rather than launching the activity: a rule that fires at ignition wants + * the sound, not a screen thrown in front of whatever the driver was looking at. + */ + fun play(): Boolean = SaicAidl.callBoolean(service.binder(), DESCRIPTOR, TX_SRC_PLAY) ?: false + + /** Brings the radio screen up — separate, because it is a different intent entirely. */ + fun openScreen(): Boolean = + SaicAidl.callBoolean(service.binder(), DESCRIPTOR, TX_START_ACTIVITY) ?: false + + /** + * Silences the radio and marks it stopped — `srcPauseRadio`, the counterpart of [play]. + * + * It mutes the tuner rather than handing the audio focus back: the vendor service only + * abandons focus when it loses it, so whatever was playing before the radio took over + * does not resume on its own. Silence is the most this service offers. + */ + fun pause(): Boolean = SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_SRC_PAUSE) + + /** + * Tunes a frequency in kHz (FM 87500–108000), playing it or leaving it silent. + * + * [andPlay] is not optional decoration on top of the tune: `tune` itself calls + * `AudioController.requestMuted(false)` inside the vendor service, which requests the + * audio focus and unmutes the tuner, so the radio starts playing whether or not anyone + * asked it to. A caller that wants the station set without the sound has to undo that, + * which is why the choice is made here rather than left to a second call the caller can + * forget: `andPlay = false` pauses the radio again as soon as it is tuned. + */ + fun tune(band: Int, frequencyKhz: Int, andPlay: Boolean): Boolean { + if (!SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_TUNE, band, frequencyKhz)) return false + return if (andPlay) play() else pause() + } + + /** + * Steps to the next station of the band the tuner is already on. + * + * This is the one way to reach a **DAB** station from a rule: [tune] takes a frequency, + * and a DAB service is addressed by ensemble and service id, not by one — so there is + * nothing for a driver to type. Stepping needs neither, and the tuner's own list is + * already the right one. + */ + fun nextStation(): Boolean = SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_NEXT) + + fun previousStation(): Boolean = SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_PREVIOUS) + + /** What [playPause] did, or why it did nothing. */ + enum class ToggleResult { + /** The tuner was silent and was asked to play. */ + PLAYED, + + /** The tuner was playing and was asked to stop. */ + PAUSED, + + /** + * The tuner would not say whether it was playing, so nothing was sent. + * + * The distinction from [REFUSED] is the one a caller has to report: the service + * refused nothing, it was never asked. + */ + STATE_UNKNOWN, + + /** The state was read, and the play or pause call that followed was not accepted. */ + REFUSED + } + + /** + * Toggles the tuner between playing and silent, addressing the radio whatever owns the + * audio right now. + * + * Deliberately not [SaicMediaPlayer]'s play/pause, which commands the *current source* — + * that one silences Bluetooth when Bluetooth is playing, which is right for a media + * shortcut and wrong for "toggle the radio". + * + * **Fails closed on an unreadable state.** [SaicMediaPlayer.radioCommand] treats a null + * as playing, and is right to: a driver pressing a wheel button sees nothing happen and + * presses again. A rule has no second press. Guessing wrong there leaves the car silent + * on a morning commute, or playing at a hospital car park, with nothing in the history + * saying the direction was invented — so an unreadable state sends nothing at all. + */ + fun playPause(): ToggleResult { + val playing = isPlaying() ?: return ToggleResult.STATE_UNKNOWN + val ok = if (playing) pause() else play() + return when { + !ok -> ToggleResult.REFUSED + playing -> ToggleResult.PAUSED + else -> ToggleResult.PLAYED + } + } + + /** + * Is the tuner playing? Null when the question could not be asked. + * + * `isPlaying()` in the vendor SDK is `getCurrentRadioInfo().getRadioState() == 1`, so the + * `RadioBean` has to be unwound in its `writeToParcel` order: enable(byte), name(String), + * rds(byte), cover(String), frequency(int), band(int), **state(int)**. + * + * Worth the coupling because the obvious substitute is wrong: `AudioManager.isMusicActive` + * is **false while the radio plays**, its stream not being the music one. A play/pause + * shortcut driven by it would send "play" to a radio that is already playing, forever. + */ + fun isPlaying(): Boolean? = currentStation()?.playing + + /** What the tuner is on right now, or null when the question could not be put. */ + data class Tuned(val band: Int, val frequencyKhz: Int, val playing: Boolean) + + /** + * The whole `RadioBean`, not just its play state. + * + * [Tuned.band] is what makes a band action possible without a new transaction: the service + * exposes no "switch band" call, but it says which band it is on, and [tune] can put it on + * another one. See [selectBand]. + */ + fun currentStation(): Tuned? = + SaicAidl.callParcel(service.binder(), DESCRIPTOR, TX_CURRENT_INFO) { reply -> + if (reply.readInt() == 0) return@callParcel null // null bean: nothing to conclude + reply.readByte(); reply.readString() // enable, station name + reply.readByte(); reply.readString() // rds, cover art + val frequency = reply.readInt() + val band = reply.readInt() + Tuned(band = band, frequencyKhz = frequency, playing = reply.readInt() == 1) + } + + /** Whether [band] is one this class knows how to reach. */ + fun isKnownBand(band: Int) = band == BAND_AM || band == BAND_FM || band == BAND_DAB + + /** What [selectBand] did, or why it did nothing. */ + enum class BandResult { + /** The tuner moved to the requested band. */ + SWITCHED, + + /** It was already there; nothing was sent. */ + ALREADY_ON_BAND, + + /** The tuner would not say which band it is on, so nothing was sent. */ + STATE_UNKNOWN, + + /** The band is not one the tuner offers. */ + UNSUPPORTED_BAND, + + /** The tune call was not accepted. */ + REFUSED + } + + /** + * Puts the tuner on [band], including **DAB**. + * + * `IRadioAppService` exposes no band-only call that this project has observed, and + * guessing a transaction code is the one thing the vehicle layer must never do — a wrong + * code on a vendor binder does something unknown to a car. So this composes calls that are + * already established instead: read the current band from `RadioBean`, and if it is not the + * one asked for, [tune] to a frequency **inside** the requested band. Tuning implies the + * band, because `tune` takes the band as its first argument. + * + * Which frequency: the last one this car was seen on for that band, remembered by + * [RadioBandMemory] every time the tuner is read. With no memory yet it lands on the + * bottom of the band and steps to the first real station, because the bottom of a band is + * usually noise — a driver asking for DAB wants a programme, not 174.928 MHz of silence. + * + * DAB is addressed here by its **block frequency**, which is a real frequency in Band III, + * not by an ensemble and service id. That is why a band switch can reach DAB while + * [tune] from a driver's typed text cannot. + * + * [andPlay] carries the caller's "enable radio" the same way [tune] does, and for the same + * reason: the vendor `tune` takes the audio focus from inside itself, so a caller that + * wants the band without the sound has to say so here rather than by not asking to play. + * [BandResult.ALREADY_ON_BAND] sends nothing at all, playing or silent included — the + * tuner is where it was asked to be, and a rule that also wants sound has [play] behind it. + */ + fun selectBand(band: Int, andPlay: Boolean = true): BandResult { + if (!isKnownBand(band)) return BandResult.UNSUPPORTED_BAND + val current = currentStation() ?: return BandResult.STATE_UNKNOWN + RadioBandMemory.remember(current.band, current.frequencyKhz) + if (current.band == band) return BandResult.ALREADY_ON_BAND + + val remembered = RadioBandMemory.frequencyFor(band) + val target = remembered ?: bandFloorKhz(band) + if (!tune(band, target, andPlay = andPlay)) return BandResult.REFUSED + // Nothing was remembered, so the tuner is sitting at the bottom of the band, which is + // where stations are not. Stepping finds the first one the car itself lists. + if (remembered == null) nextStation() + return BandResult.SWITCHED + } + + /** + * The bottom of each band, used only when this car has never been seen on it. + * + * FM and AM come from `RadioConstants`; DAB is block 5A at 174.928 MHz, the bottom of + * Band III as the standard defines it — not a value read off this firmware, which is why + * a band switch that lands there immediately steps to a real station rather than staying. + */ + private fun bandFloorKhz(band: Int): Int = when (band) { + BAND_AM -> RadioFrequency.AM_MIN_KHZ + BAND_DAB -> DAB_BAND_III_MIN_KHZ + else -> RadioFrequency.FM_MIN_KHZ + } + + const val DAB_BAND_III_MIN_KHZ = 174_928 +} + +/** + * The last frequency this car was seen on, per band. + * + * Switching band needs a frequency, and the least surprising one is the station the driver + * was last listening to on that band. Kept beside the glass evidence in the same preferences, + * and for the same reason: it is an observation about this car, worth nothing anywhere else + * and never sent anywhere. + */ +object RadioBandMemory { + + private const val PREFS = "ev_settings" + private const val KEY_PREFIX = "radio_last_khz_band_" + + @Volatile + private var prefs: android.content.SharedPreferences? = null + + /** Cache so a read on the sampling path is not a disk hit. */ + private val cache = java.util.concurrent.ConcurrentHashMap() + + fun load(context: Context) { + prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + } + + fun frequencyFor(band: Int): Int? = + cache[band] ?: prefs?.getInt(KEY_PREFIX + band, -1)?.takeIf { it > 0 }?.also { + cache[band] = it + } + + /** Records where the tuner was, ignoring a band or frequency that says nothing. */ + fun remember(band: Int, frequencyKhz: Int) { + if (!SaicRadio.isKnownBand(band) || frequencyKhz <= 0) return + if (cache[band] == frequencyKhz) return + cache[band] = frequencyKhz + prefs?.edit()?.putInt(KEY_PREFIX + band, frequencyKhz)?.apply() + } +} + +/** + * Placing a call over the car's own hands-free stack. + * + * Not `ACTION_CALL`: the head unit has no SIM and no dialer of its own — the call is placed + * by the vehicle's Bluetooth hands-free service through the paired phone, which is what its + * `placeCall` does. Going through the platform telephony intent would find nothing to handle + * it. + * + * Binding contract: `BtCallManager` (bind target, from + * `BtConstant.SERVICE_PACKAGE` / `SERVICE_ACTION`) and `IBtCall` (transaction codes). + */ +object SaicPhone { + + private const val PACKAGE = "com.saicmotor.service.btcall" + private const val ACTION = "com.saicmotor.service.btcall.BtCallService" + private const val DESCRIPTOR = "com.saicmotor.sdk.btcall.IBtCall" + + private const val TX_PLACE_CALL = 2 + private const val TX_GET_BLUETOOTH_STATE = 12 + private const val TX_GET_CONN_DEVICE_NAME = 14 + + private val service = SaicService(PACKAGE, ACTION, "btcall") + + val isAvailable: Boolean get() = service.isReady + + fun connect(context: Context) = service.connect(context) + + /** @return true when the hands-free stack accepted the number. */ + fun placeCall(number: String): Boolean = + SaicAidl.callBoolean(service.binder(), DESCRIPTOR, TX_PLACE_CALL, number) ?: false + + fun bluetoothState(): Int? = SaicAidl.callInt(service.binder(), DESCRIPTOR, TX_GET_BLUETOOTH_STATE) + + /** Name of the phone the car is paired with, for the diagnostic report. */ + fun connectedDeviceName(): String? = + SaicAidl.callString(service.binder(), DESCRIPTOR, TX_GET_CONN_DEVICE_NAME) + ?.takeIf { it.isNotBlank() } +} + +/** + * The vehicle's own voice — the one that says "left front tyre abnormality". + * + * This is what makes the "speak" action work on a head unit with no Android TTS engine + * installed, which is the normal case: the car talks, but through a vendor service rather + * than through `android.speech.tts`. Probing for a platform engine and finding none was + * therefore the right answer to the wrong question. + * + * Bound by component name, not by action — the service declares no intent filter, and the + * car's own voice app reaches it exactly this way. + * + * Binding contract: `VoiceAnnounceModel` (component name and + * the call it makes) and `ITtsService` (descriptor, transaction codes). + */ +object SaicTts { + + private const val PACKAGE = "com.saicmotor.voicetts" + private const val CLASS = "com.saicmotor.voicetts.TtsService" + private const val DESCRIPTOR = "com.saicmotor.voicetts.ITtsService" + + private const val TX_PROMPT = 1 + private const val TX_STOP = 3 + + private val service = SaicService.byComponent(PACKAGE, CLASS, "voicetts") + + val isAvailable: Boolean get() = service.isReady + + fun connect(context: Context) = service.connect(context) + + /** + * Speaks [text]. + * + * [interrupt] true stops whatever the car is currently announcing — false queues behind + * it, which is what a rule message should do: a tyre warning outranks "profile applied". + * The tag is the caller's package, as the vendor apps pass their own. + */ + fun speak(text: String, interrupt: Boolean = false, tag: String = "com.evsuite.tasker"): Boolean = + SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_PROMPT, text, interrupt, tag) + + fun stop(): Boolean = SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_STOP) +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicMediaPlayer.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicMediaPlayer.kt new file mode 100644 index 0000000..82ee2b2 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicMediaPlayer.kt @@ -0,0 +1,475 @@ +package com.evsuite.hardware.saic + +import android.content.Context +import android.media.AudioManager +import android.media.session.MediaController +import android.media.session.MediaSessionManager +import android.media.session.PlaybackState +import android.os.SystemClock +import android.view.KeyEvent +import com.evsuite.hardware.AppLogger + +/** + * Next track, previous track, play/pause — addressed to the source that is actually playing. + * + * ## Why this is not one `dispatchMediaKeyEvent` + * + * The obvious implementation is the wrong one on this head unit, and quietly so. The car + * publishes a single Android media session (`com.android.bluetooth`), so a media key either + * reaches nothing — radio, USB and the projection stacks do not listen for one — or it + * reaches Bluetooth, **wakes a phone that was not playing, and changes the audio source under + * the driver's hands** while the radio was on. A key sent from a rule at 110 km/h is exactly + * the wrong place to discover that. + * + * What the car's own launcher does instead is ask which source is current and talk to that + * source's player. That is what this does. + * + * ## The rule that keeps it safe + * + * **No cascade between sources.** Trying each player in turn looks robust and is the bug: a + * player that is not playing answers "yes" to `play` and starts, which is the source change + * again. One source is chosen, one player is addressed, and a failure is reported as a + * failure. Fallbacks exist only *within* the chosen source, or where nothing at all could be + * identified. + * + * ## Firmware split + * + * SWI68/SWI165 answer through the SAIC media service below. The A9 generations (SWI69, + * SWI131, SWI132) have no such service — their launcher drives the framework's media + * sessions, so [sessionCommand] is the normal path there, not a consolation prize. Both ends + * fall back to the media key, and only while something is audible. + * + * Binding contract: `MediaService` (bind target and the per-player actions), + * `IMediaPlayerBinderInterface` and its siblings (transaction codes), `MediaConstants` + * (source and player-status values), and `com.allgo.rui.IRemoteUIService` for the projection + * stack, which is not part of the SAIC SDK at all. + */ +object SaicMediaPlayer { + + private const val TAG = "EV_MEDIA" + + private const val MEDIA_PACKAGE = "com.saicmotor.service.media" + private const val MEDIA_CLASS = "com.saicmotor.service.media.MediaService" + + private const val ACTION_MEDIA = "com.saicmotor.service.media.MEDIA_PLAYER_ACTION" + private const val ACTION_CPAA = "com.saicmotor.service.media.CPAA_PLAYER_ACTION" + private const val ACTION_BT = "com.saicmotor.service.media.BT_MUSIC_ACTION" + private const val ACTION_USB = "com.saicmotor.service.media.MUSIC_PLAYER_ACTION" + private const val ACTION_ONLINE = "com.saicmotor.service.media.ONLINE_MUSIC_ACTION" + private const val ACTION_STATUS = "com.saicmotor.service.media.PLAY_STATUS_ACTION" + + private const val DESC_MEDIA = "com.saicmotor.sdk.media.IMediaPlayerBinderInterface" + private const val DESC_CPAA = "com.saicmotor.sdk.media.ICpAaBinderInterface" + private const val DESC_BT = "com.saicmotor.sdk.media.IBtMusicBinderInterface" + private const val DESC_USB = "com.saicmotor.sdk.media.IMusicPlayerBinderInterface" + private const val DESC_ONLINE = "com.saicmotor.sdk.media.IOnlineMusicBinderInterface" + private const val DESC_STATUS = "com.saicmotor.sdk.media.IPlayStatusBinderInterface" + + /** + * `MediaConstants` source codes. 1..4 are the tuner's own bands — 1 radio, 2 FM, 3 AM, + * **4 DAB** — which is why DAB needs no separate handling here: it is the radio. + */ + private const val SRC_RADIO_MIN = 1 + private const val SRC_RADIO_MAX = 4 + private const val SRC_BT = 5 + private const val SRC_ONLINE = 6 + private const val SRC_USB = 7 + private const val SRC_USB_VIDEO = 0x12 + private const val SRC_CARPLAY = 0x32 + private const val SRC_AA = 0x46 + + /** Of the player statuses, only START means "playing". */ + private const val PLAYER_STATUS_START = 3 + + // Status interface — which source owns the audio right now. + private const val TX_CURRENT_SOURCE = 0x9 + + // Per-player transaction codes, each in its own interface. + private const val TX_BT_PLAY_STATE = 0x9 + private const val TX_BT_PAUSE = 1 + private const val TX_BT_PLAY = 2 + private const val TX_BT_PREVIOUS = 4 + private const val TX_BT_NEXT = 5 + + private const val TX_USB_PLAY_STATE = 0x1e + private const val TX_USB_PLAY_PAUSE = 0xc + private const val TX_USB_PREVIOUS = 0x19 + private const val TX_USB_NEXT = 0x1a + + private const val TX_ONLINE_STATUS = 0x6 + private const val TX_ONLINE_PAUSE = 1 + private const val TX_ONLINE_PLAY = 2 + private const val TX_ONLINE_PREVIOUS = 3 + private const val TX_ONLINE_NEXT = 4 + + private const val TX_CPAA_INFO = 0x7 + private const val TX_CPAA_PLAY = 1 + private const val TX_CPAA_PAUSE = 2 + private const val TX_CPAA_PREVIOUS = 3 + private const val TX_CPAA_NEXT = 4 + + private const val TX_GENERIC_PLAY = 2 + private const val TX_GENERIC_PAUSE = 3 + private const val TX_GENERIC_PREVIOUS = 4 + private const val TX_GENERIC_NEXT = 5 + + // The projection stack: allgo's service, outside the SAIC SDK entirely. + private const val RUI_PACKAGE = "com.allgo.rui" + private const val RUI_CLASS = "com.allgo.rui.RemoteUIService" + private const val DESC_RUI = "com.allgo.rui.IRemoteUIService" + private const val TX_RUI_MEDIA_KEY = 0x12 + + /** + * `sendMediaPlayControlKey` values. Skip carries a press and a release: leaving a key + * held reads as fast-forward, which is what the two codes are for. + */ + private const val RUI_PLAY = 1 + private const val RUI_PAUSE = 2 + private const val RUI_NEXT_DOWN = 7 + private const val RUI_PREVIOUS_DOWN = 8 + private const val RUI_NEXT_UP = 11 + private const val RUI_PREVIOUS_UP = 12 + + enum class Command { PLAY_PAUSE, NEXT, PREVIOUS } + + private val status = mediaService(ACTION_STATUS, "media-status") + private val generic = mediaService(ACTION_MEDIA, "media-generic") + private val bluetooth = mediaService(ACTION_BT, "media-bt") + private val usb = mediaService(ACTION_USB, "media-usb") + private val online = mediaService(ACTION_ONLINE, "media-online") + private val projection = mediaService(ACTION_CPAA, "media-cpaa") + private val remoteUi = SaicService.byComponent(RUI_PACKAGE, RUI_CLASS, "media-rui") + + private fun mediaService(action: String, tag: String) = + SaicService.byActionAndComponent(MEDIA_PACKAGE, action, MEDIA_CLASS, tag) + + @Volatile + private var appContext: Context? = null + + /** + * Binds every player interface, and the projection service beside them. + * + * All of them, up front, rather than on demand: a bind is asynchronous, and a shortcut + * pressed on the steering wheel cannot wait for one. On a firmware where a given service + * does not exist the bind is refused once, logged, and that interface simply never + * answers — which is the same thing the dispatch below already handles. + */ + fun connect(context: Context) { + appContext = context.applicationContext + listOf(status, generic, bluetooth, usb, online, projection, remoteUi) + .forEach { it.connect(context) } + } + + val isAvailable: Boolean get() = status.isReady + + fun next(): Boolean = command(Command.NEXT) + + fun previous(): Boolean = command(Command.PREVIOUS) + + fun playPause(): Boolean = command(Command.PLAY_PAUSE) + + /** + * Sends [cmd] to whatever is playing. + * + * A source code below zero means the SAIC service did not answer — either it does not + * exist (A9) or its bind has not landed yet. Both take the session path, which is safe + * whichever it was: it commands only a session that declares itself playing. + */ + fun command(cmd: Command): Boolean { + val source = currentSource() + + if (source < 0) { + AppLogger.i(TAG, "no SAIC media source — driving media sessions instead") + if (sessionCommand(cmd)) return true + + // Sessions cover radio, Bluetooth and USB here. What they do not cover is the + // projection stack, which declares neither skip action — hence allgo, the path + // the A9 launcher takes for exactly this. + if (projectionCommand(cmd, musicActive())) return true + + // Last resort, when enumerating sessions is refused outright. Only while + // something is audible: sent while the radio plays (`isMusicActive` false, as + // measured) it would wake the Bluetooth session and change the source — the very + // defect this class exists to avoid, let back in through the side door. The + // accepted cost is that it cannot resume a playback that had stopped. + if (!musicActive()) { + AppLogger.i(TAG, "no usable session and nothing playing — no key sent") + return false + } + return sendMediaKey( + when (cmd) { + Command.NEXT -> KeyEvent.KEYCODE_MEDIA_NEXT + Command.PREVIOUS -> KeyEvent.KEYCODE_MEDIA_PREVIOUS + Command.PLAY_PAUSE -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + } + ) + } + + val target = if (isKnown(source)) source else playingSource()?.also { + AppLogger.i(TAG, "source $source not in the table — player answering: ${sourceName(it)}") + } ?: source + + return when { + target in SRC_RADIO_MIN..SRC_RADIO_MAX -> radioCommand(cmd) + + target == SRC_BT -> transact(bluetooth, DESC_BT, when (cmd) { + Command.NEXT -> TX_BT_NEXT + Command.PREVIOUS -> TX_BT_PREVIOUS + Command.PLAY_PAUSE -> if (isPlaying(target)) TX_BT_PAUSE else TX_BT_PLAY + }) + + target == SRC_USB -> transact(usb, DESC_USB, when (cmd) { + Command.NEXT -> TX_USB_NEXT + Command.PREVIOUS -> TX_USB_PREVIOUS + // A real toggle, so the state never has to be read for this one. + Command.PLAY_PAUSE -> TX_USB_PLAY_PAUSE + }) + + target == SRC_ONLINE -> transact(online, DESC_ONLINE, when (cmd) { + Command.NEXT -> TX_ONLINE_NEXT + Command.PREVIOUS -> TX_ONLINE_PREVIOUS + Command.PLAY_PAUSE -> if (isPlaying(target)) TX_ONLINE_PAUSE else TX_ONLINE_PLAY + }) + + target == SRC_CARPLAY || target == SRC_AA -> { + // Read the state only where the command depends on it: a skip has no use for + // a binder round trip. + val playing = cmd == Command.PLAY_PAUSE && isPlaying(target) + // SWI133 answers through the SAIC SDK, every other firmware through allgo. + // The two are exclusive by generation; the absent one fails without effect. + transact(projection, DESC_CPAA, when (cmd) { + Command.NEXT -> TX_CPAA_NEXT + Command.PREVIOUS -> TX_CPAA_PREVIOUS + Command.PLAY_PAUSE -> if (playing) TX_CPAA_PAUSE else TX_CPAA_PLAY + }) || projectionCommand(cmd, playing) + } + + else -> { + AppLogger.i(TAG, "source ${sourceName(target)} has no known player — generic facade") + genericCommand(cmd, musicActive()) + } + } + } + + private fun isKnown(source: Int) = + source in SRC_RADIO_MIN..SRC_RADIO_MAX || + source == SRC_BT || source == SRC_ONLINE || source == SRC_USB || + source == SRC_CARPLAY || source == SRC_AA + + /** + * The tuner has its own service: skip changes station, and `srcPlayRadio` / + * `srcPauseRadio` are what the launcher calls. + * + * An unreadable state counts as playing — the normal state of a tuner that owns the + * audio. One pause too many is undone by a second press; the opposite reading would leave + * the shortcut doing nothing at all. + */ + private fun radioCommand(cmd: Command): Boolean { + val playing = SaicRadio.isPlaying() != false + val ok = when (cmd) { + Command.NEXT -> SaicRadio.nextStation() + Command.PREVIOUS -> SaicRadio.previousStation() + Command.PLAY_PAUSE -> if (playing) SaicRadio.pause() else SaicRadio.play() + } + // Fallback *within* the source: the generic facade addresses whichever source is + // current, so it cannot wake another one. + return ok || genericCommand(cmd, playing) + } + + private fun genericCommand(cmd: Command, playing: Boolean): Boolean = + transact(generic, DESC_MEDIA, when (cmd) { + Command.NEXT -> TX_GENERIC_NEXT + Command.PREVIOUS -> TX_GENERIC_PREVIOUS + Command.PLAY_PAUSE -> if (playing) TX_GENERIC_PAUSE else TX_GENERIC_PLAY + }) + + /** Which source owns the audio, or -1 when the service did not answer. */ + private fun currentSource(): Int { + val v = SaicAidl.callInt(status.binder(), DESC_STATUS, TX_CURRENT_SOURCE) ?: return -1 + AppLogger.i(TAG, "current source = $v (${sourceName(v)})") + return v + } + + /** + * Is [source] playing? + * + * The **source** is asked wherever it can answer, and `isMusicActive` only when none can: + * that one stays true for a second or two after a stop, which is what made a second press + * send a second pause instead of a play. + */ + private fun isPlaying(source: Int): Boolean { + val reported = when (source) { + SRC_BT -> readInt(bluetooth, DESC_BT, TX_BT_PLAY_STATE)?.let { it != 0 } + SRC_ONLINE -> readInt(online, DESC_ONLINE, TX_ONLINE_STATUS)?.let { it == PLAYER_STATUS_START } + SRC_CARPLAY, SRC_AA -> projectionPlaying() + else -> null + } + if (reported != null) { + AppLogger.i(TAG, "${sourceName(source)} reports playing = $reported") + return reported + } + val fallback = musicActive() + AppLogger.i(TAG, "${sourceName(source)} does not report state — isMusicActive = $fallback") + return fallback + } + + /** + * Which player declares itself playing, when the source code is one this does not know. + * + * Asking the players is what keeps the dispatch honest despite an incomplete table: only + * a player that says it is playing gets commanded, so a sleeping source is never woken. + */ + private fun playingSource(): Int? { + if (readInt(bluetooth, DESC_BT, TX_BT_PLAY_STATE)?.let { it != 0 } == true) return SRC_BT + if (readInt(usb, DESC_USB, TX_USB_PLAY_STATE)?.let { it != 0 } == true) return SRC_USB + if (readInt(online, DESC_ONLINE, TX_ONLINE_STATUS) == PLAYER_STATUS_START) return SRC_ONLINE + return null + } + + /** + * Is CarPlay / Android Auto playing? Null when nothing can be concluded. + * + * `getLastCpAaAudioInfoBean` returns an `AudioInfoBean` whose play state is + * [PLAYER_STATUS_START] while playing — the launcher's own media card compares it to that + * exact value. The bean is unwound in its `writeToParcel` order: id(long), duration(long), + * seven strings, two longs, one string, then **state(int)**. + * + * Without this read the projection fell back to `isMusicActive`, which stays true for two + * or three seconds after a pause: the shortcut answered a pause with another pause, and + * the driver had to wait before the toggle worked again. + */ + private fun projectionPlaying(): Boolean? = + SaicAidl.callParcel(projection.binder(), DESC_CPAA, TX_CPAA_INFO) { reply -> + if (reply.readInt() == 0) return@callParcel null // null bean: nothing to conclude + reply.readLong(); reply.readLong() // id, duration + repeat(7) { reply.readString() } // name, art, path, artist, user, avatar, album + reply.readLong(); reply.readLong() // added, last played + reply.readString() // readable position + reply.readInt() == PLAYER_STATUS_START + } + + /** + * Drives the framework's media sessions — the Android path, and the A9 launcher's own. + * + * Safe by construction where a media key is not: only a session whose state *declares* it + * is playing gets commanded, so this cannot wake a sleeping source. The single exception + * is resuming, which acts only when no session is playing at all. + * + * A session also declares what it supports, and ignoring that declaration is talking into + * the void: `skipToNext()` on a session without `ACTION_SKIP_TO_NEXT` throws nothing and + * does nothing. Reporting success there would stop the caller trying the path that works. + */ + private fun sessionCommand(cmd: Command): Boolean { + val ctx = appContext ?: return false + val manager = ctx.getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager + ?: return false + val sessions: List = try { + manager.getActiveSessions(null) + } catch (e: SecurityException) { + AppLogger.w(TAG, "sessions unreadable — MEDIA_CONTENT_CONTROL not held: ${e.message}") + return false + } catch (e: Exception) { + AppLogger.w(TAG, "sessions unreadable: ${(e.cause ?: e).message}") + return false + } + + val playing = sessions.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING } + val target = playing + ?: if (cmd == Command.PLAY_PAUSE) { + sessions.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PAUSED } + } else null + + if (target == null) { + AppLogger.i(TAG, "no usable session among ${sessions.size}") + return false + } + + val declared = target.playbackState?.actions ?: 0L + val required = when (cmd) { + Command.NEXT -> PlaybackState.ACTION_SKIP_TO_NEXT + Command.PREVIOUS -> PlaybackState.ACTION_SKIP_TO_PREVIOUS + Command.PLAY_PAUSE -> PlaybackState.ACTION_PLAY_PAUSE or + (if (playing != null) PlaybackState.ACTION_PAUSE else PlaybackState.ACTION_PLAY) + } + if (declared and required == 0L) { + AppLogger.i(TAG, "${cmd.name} not declared by ${target.packageName} — session path given up") + return false + } + + return try { + when (cmd) { + Command.NEXT -> target.transportControls.skipToNext() + Command.PREVIOUS -> target.transportControls.skipToPrevious() + Command.PLAY_PAUSE -> + if (playing != null) target.transportControls.pause() + else target.transportControls.play() + } + AppLogger.i(TAG, "${cmd.name} sent to session ${target.packageName}") + true + } catch (e: Exception) { + AppLogger.w(TAG, "session refused ${cmd.name}: ${(e.cause ?: e).message}") + false + } + } + + private fun projectionCommand(cmd: Command, playing: Boolean): Boolean = when (cmd) { + Command.NEXT -> remoteUiKey(RUI_NEXT_DOWN) && remoteUiKey(RUI_NEXT_UP) + Command.PREVIOUS -> remoteUiKey(RUI_PREVIOUS_DOWN) && remoteUiKey(RUI_PREVIOUS_UP) + Command.PLAY_PAUSE -> remoteUiKey(if (playing) RUI_PAUSE else RUI_PLAY) + } + + private fun remoteUiKey(value: Int): Boolean = + SaicAidl.callVoid(remoteUi.binder(), DESC_RUI, TX_RUI_MEDIA_KEY, value).also { + AppLogger.i(TAG, "projection: sendMediaPlayControlKey($value) → $it") + } + + private fun sendMediaKey(keyCode: Int): Boolean { + val audio = appContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audio == null) { + AppLogger.w(TAG, "no AudioManager — media key not sent") + return false + } + // Both halves or nothing: an app given a key-down and never the matching key-up can + // sit on a held key. + return try { + val now = SystemClock.uptimeMillis() + audio.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0)) + audio.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0)) + AppLogger.i(TAG, "sent ${KeyEvent.keyCodeToString(keyCode)}") + true + } catch (e: Exception) { + AppLogger.w(TAG, "media key refused: ${(e.cause ?: e).message}") + false + } + } + + private fun musicActive(): Boolean = + (appContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.isMusicActive == true + + /** + * Calls a no-argument player method. + * + * Deliberately outside the vehicle write gate: changing track is not a driving setting, + * and blocking it above a given speed would make no sense. A true here means the call was + * *received*, not that it had an effect — which is why every step is logged. + */ + private fun transact(service: SaicService, descriptor: String, code: Int): Boolean = + SaicAidl.callVoid(service.binder(), descriptor, code).also { + AppLogger.i(TAG, "${descriptor.substringAfterLast('.')} tx=0x${Integer.toHexString(code)} → $it") + } + + /** A null here is not a zero: it means the question could not be put. */ + private fun readInt(service: SaicService, descriptor: String, code: Int): Int? = + SaicAidl.callInt(service.binder(), descriptor, code) + + private fun sourceName(v: Int): String = when (v) { + in SRC_RADIO_MIN..SRC_RADIO_MAX -> "radio/DAB" + SRC_BT -> "bluetooth" + SRC_ONLINE -> "online" + SRC_USB -> "usb" + SRC_USB_VIDEO -> "usb-video" + SRC_CARPLAY -> "carplay" + SRC_AA -> "android-auto" + else -> "unknown($v)" + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicNav.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicNav.kt new file mode 100644 index 0000000..a018727 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicNav.kt @@ -0,0 +1,89 @@ +package com.evsuite.hardware.saic + +import android.content.Context +import android.os.IBinder + +/** + * The head unit's navigation adapter — the service its map, cluster and voice apps share. + * + * A different bind from [SaicHub]: the vehicle hub answers about the car's own systems, + * while this one sits between the head unit and whichever navigation app is installed. What + * is useful here is the small set of things it answers *synchronously*; the rest of its + * surface is a stream of callbacks from the running navigation app, which is a subscription + * rather than a reading and is deliberately not wired up. + * + * That distinction is the reason trip distance is absent. Remaining distance and time only + * arrive as notifications *into* this service from the navigation app, so reading them means + * registering as a listener and holding that registration for the life of the process. + * [totalMileageKm] is a getter, and the odometer is what a "service due" rule actually wants. + */ +object SaicNav { + + private const val PACKAGE = "com.saicmotor.adapterservice" + private const val CLASS = "com.saicmotor.adapterservice.services.MapService" + private const val DESCRIPTOR = "com.saicmotor.adapterservice.IMapService" + + private const val TX_GET_TOTAL_MILEAGE = 11 + + private val service = SaicService.byComponent(PACKAGE, CLASS, "nav-adapter") + + val isAvailable: Boolean get() = binder() != null + + fun connect(context: Context) = service.connect(context) + + private fun binder(): IBinder? = service.binder() + + /** + * The odometer, in kilometres. + * + * The service answers 0 when its own energy manager is not up, which is the same answer a + * brand-new car would give — so 0 is treated as unreadable. A car that has genuinely + * driven nothing is not a case any rule needs, and reporting "unknown" there is the + * cheaper mistake. + * + * Kilometres because that is the signal; the head unit's distance-unit setting changes + * what its screens display, not what the bus carries. + */ + fun totalMileageKm(): Int? = + SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_TOTAL_MILEAGE)?.takeIf { it > 0 } + + /** + * The two transactions CP-040 saw declared on this interface and never called. + * + * They are the candidates for "where is the driver already going", which would remove the + * typed address, the keyboard and a geocoding request from the charge-stop screen. + */ + val DESTINATION_TRANSACTIONS = listOf(38, 39) + + /** + * What a transaction actually replies, recorded rather than interpreted. + * + * CP-051 exists because the transaction numbers are known and the answer is not. Reading + * the reply as a typed value would be guessing the shape; a latitude read as a longitude + * routes to the wrong continent and looks like an ordinary number while doing it. So this + * returns the bytes, and a session at a desk decides what they mean. + * + * **The reply may contain the destination that is set in the car's navigation.** That is + * the point of the probe, and it is why the artifact carrying this says so — the file + * leaves the car on a USB stick. + * + * @return one line, always: unbound, unanswered, or the payload in hex. + */ + fun probeTransaction(code: Int, maxBytes: Int = MAX_PROBE_BYTES): String { + val target = binder() ?: return "tx=$code unbound" + return SaicAidl.callParcel(target, DESCRIPTOR, code) { reply -> + val start = reply.dataPosition() + val available = reply.dataAvail() + val hex = runCatching { + reply.marshall() + .drop(start) + .take(maxBytes) + .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + }.getOrElse { "unmarshallable(${it.javaClass.simpleName})" } + "tx=$code answered bytes=$available hex=$hex" + } ?: "tx=$code no answer" + } + + /** Enough for a coordinate pair, a name and their headers; not enough to be a log dump. */ + private const val MAX_PROBE_BYTES = 128 +} 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 new file mode 100644 index 0000000..d8a7a8e --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicNavGuidance.kt @@ -0,0 +1,325 @@ +package com.evsuite.hardware.saic + +import android.content.Context +import android.os.Binder +import android.os.IBinder +import android.os.Parcel +import android.os.SystemClock +import com.evsuite.hardware.AppLogger + +/** + * Remaining distance and time to the destination, as the head unit's own navigation reports it. + * + * [SaicNav] explains why the map adapter answers no synchronous question about a trip: the + * navigation app *pushes* guidance into the adapter service, which fans it out to whoever + * registered. This is that registration, and it is the only read-only route source found on + * the vehicle that costs no Android permission and no network. + * + * It is also, since the owner scoped the read-only rule to the car's driving and safety + * settings, where a route goes *out* to the navigation app: [startNavFromEvRoute] is the one + * call in this object that sends. Everything else here listens or asks. + * + * **The service does not wait politely.** `IGeneralNotificationListener` is not `oneway` — + * the proxy calls `transact(code, data, reply, 0)` and then `readException()`, and the + * adapter fans out to every listener while holding the lock on its callback list. A slow + * listener therefore stalls the instrument cluster, not just this app. Everything + * [onTransact][ListenerBinder.onTransact] does is one parcel read and one volatile write; it + * must stay that way. No I/O, no lock, no allocation beyond the state object, ever. + * + * **Registration is additive.** The adapter keeps a `RemoteCallbackList`, so registering here + * neither displaces the navigation app nor the cluster, and the list drops this listener on + * its own if the process dies. + * + * **Development instrument.** Nothing here is validated on a vehicle: the distance callbacks + * carry no unit and the status codes carry no documented meaning, so [latest] is evidence to + * be captured and read, not a value to display. CP-040 is the ticket that turns it into + * either a decision or a deletion. + */ +object SaicNavGuidance { + + private const val TAG = "EV_SAIC" + + private const val PACKAGE = "com.saicmotor.adapterservice" + private const val CLASS = "com.saicmotor.adapterservice.services.GeneralService" + private const val DESCRIPTOR = "com.saicmotor.adapterservice.IGeneralService" + private const val LISTENER_DESCRIPTOR = + "com.saicmotor.adapterservice.IGeneralNotificationListener" + + private const val TX_REGISTER_LISTENER = 1 + private const val TX_UNREGISTER_LISTENER = 2 + + // Synchronous getters on the same interface. SaicNav's note that the head unit answers no + // synchronous question about a trip was read off IMapService; IGeneralService does answer. + private const val TX_IS_MAP_NAVIGATING = 18 + private const val TX_GET_ROAD_NAME = 29 + private const val TX_GET_GUIDE_STATUS = 31 + private const val TX_GET_REMAINING_TIMES = 33 + private const val TX_GET_REMAINING_DISTANCE = 34 + + // The two transactions here that send rather than ask. See [goTo], [startNavFromEvRoute]. + private const val TX_GO_TO = 23 + private const val TX_START_NAV_FROM_EV_ROUTE = 48 + + private val service = SaicService.byComponent(PACKAGE, CLASS, "nav-guidance") + + /** + * The folded state. Written only from a binder thread, read from anywhere, so it is + * volatile and the value it points at is immutable. + */ + @Volatile + private var state: NavGuidance = NavGuidance.EMPTY + + @Volatile + private var registered: Boolean = false + + /** + * Every transaction code the adapter sent, decoded or not. + * + * The transaction map came from an R69 build and a vehicle may run another revision, so + * this is what proves the map still lines up. It is read by a capture, never by a value. + */ + private val census = TransactionCensus() + + val isAvailable: Boolean get() = binder() != null + + /** True once the adapter accepted this listener. */ + val isListening: Boolean get() = registered + + fun connect(context: Context) = service.connect(context) + + private fun binder(): IBinder? = service.binder() + + /** + * The last guidance seen, or [NavGuidance.EMPTY] before anything arrived. + * + * `events == 0` means the listener registered but the navigation app has said nothing — + * which is the answer when no guidance is running, and is itself the finding CP-040 needs. + */ + fun latest(): NavGuidance = state + + /** + * The current guidance, read rather than waited for. + * + * The five callbacks are change notifications: a car standing still with a guidance + * already running publishes nothing, because nothing changed. That made a parked capture + * unable to tell "the adapter says nothing" from "nothing happened to say", which is the + * distinction CP-040 turns on. These getters answer on demand instead. + * + * Values seen so far by the listener are kept where a getter answers nothing, so a caller + * gets the best of both without choosing between them. + */ + fun readNow(): NavGuidance { + val target = binder() ?: return state + fun int(code: Int) = SaicAidl.callInt(target, DESCRIPTOR, code) + // The service initialises these to 0 and answers 0 before any guidance has run, which + // is the same answer an arrived route would give. Unknown is the cheaper reading: a + // remaining distance of exactly zero is not a case any forecast needs. + val distance = int(TX_GET_REMAINING_DISTANCE)?.takeIf { it > 0 } + val minutes = int(TX_GET_REMAINING_TIMES)?.takeIf { it > 0 } + val status = int(TX_GET_GUIDE_STATUS) + val road = SaicAidl.callString(target, DESCRIPTOR, TX_GET_ROAD_NAME)?.takeIf { + it.isNotBlank() + } + val seen = state + return seen.copy( + guideStatus = status ?: seen.guideStatus, + remainingDistanceRaw = distance ?: seen.remainingDistanceRaw, + remainingMinutes = minutes ?: seen.remainingMinutes, + road = road ?: seen.road, + ) + } + + /** + * Whether the head unit is guiding, as the navigation app itself says so. + * + * Not an inference from a distance or a road name: `MapService` keeps this flag and the + * navigation app sets it through `IMapService.isMapNavigating(boolean)`, its own report of + * its own state. That makes it the one thing on this interface that can confirm a handoff + * worked — a command accepted by the adapter says only that the adapter took it, and the + * fan-out swallows a refusal, so this flag is the difference between "sent" and "started". + * + * @return null when the adapter is not there to ask. + */ + fun isMapNavigating(): Boolean? = + SaicAidl.callBoolean(binder(), DESCRIPTOR, TX_IS_MAP_NAVIGATING) + + /** + * Asks the navigation app to guide to one point — the vendor's own "take me there". + * + * **Why this exists beside [startNavFromEvRoute].** The route handoff put the destination on + * the map and left it there: the driver saw the place and no guidance ever started. `goTo` is + * the other command, and it is the one the head unit's voice assistant uses — `GeneralService` + * turns it into `MapService.goToPoi(null, address, null, latitude, longitude)`, the same call + * `IVoiceVuiService` makes when someone says "emmène-moi à". Where the route handoff hands + * over points to draw, this one names a destination to drive to. + * + * **One point, no pathway.** The interface has room for nothing else, so a plan with a + * charging stop must choose: guide to the stop, which is the leg being driven, and leave the + * rest of the plan to the screen that planned it. + * + * Same boundary as [startNavFromEvRoute] — a navigation write, not a vehicle write — and the + * same two cautions: the adapter logs the point, and this is a synchronous fan-out that must + * not run on the main thread. + * + * @return true when the adapter took the call, which is not the map confirming it obeyed. + */ + fun goTo(point: NavigationHandoff.Poi): Boolean = + SaicAidl.callWriting(binder(), DESCRIPTOR, TX_GO_TO) { data -> data.writeGoTo(point) } + + /** + * `goTo(String address, double latitude, double longitude)`, in the order the adapter's stub + * reads it. Latitude before longitude, and the name first: swap any two and the car drives to + * a place nobody asked for, which no compiler and no crash would have said. + */ + internal fun Parcel.writeGoTo(point: NavigationHandoff.Poi) { + writeString(point.name) + writeDouble(point.latitude) + writeDouble(point.longitude) + } + + /** + * Hands a planned route to whichever navigation app this head unit runs. + * + * **The one write in this object, and it is not a vehicle write.** `VehicleWriteGate` exists + * for the settings that change how the car behaves under the driver — AEB, ELK, ACC/TJA, the + * drive mode — and gates them on standstill. A destination is not one of those any more than + * the comfort writes that gate already exempts: it moves a map, and the driver asked for it. + * + * **It is a call in, not an impersonation.** `IMapNotificationListener` is the channel the + * head unit uses to *command* the navigation app, and registering on it to send one + * destination would mean claiming to be a navigation provider. This is the other end of the + * same wire: `IGeneralService` transaction 48 hands the points to `GeneralService`, which + * forwards to `MapService`, which fans them out over its `RemoteCallbackList` to the app + * that did register. The navigation app stays the navigation app. + * + * **What leaves the car.** Two lists of latitude, longitude and name — the pathway, then the + * destination — and nothing else; `EVRoutPoiInfo` has no other field. They do not leave the + * *car*, in fact: this is one binder transaction to a service on the same head unit. + * **The adapter logs them.** `MapService.startNavFromEVRout` writes the whole of both lists + * to the head unit's own logcat before fanning out. That is the vendor's log, not this + * app's, and it is the one place a destination appears in text — worth knowing, not worth + * refusing a working handoff over. + * + * **Call it off the main thread.** The transaction is not `oneway`, and the service fans out + * to every registered listener, synchronously, while holding the lock on its callback list. + * + * @param destination where the driver is going. + * @param pathway points to pass through on the way — the charging stop, in this app's case. + * Capped by [NavigationHandoff.pathway] before it gets here. + * @return true when the adapter took the call. It does not mean the map drew a route: the + * fan-out swallows whatever the navigation app throws, so a listener that refused the + * points is indistinguishable from one that followed them. Only the driver can say. + */ + fun startNavFromEvRoute( + destination: NavigationHandoff.Poi, + pathway: List = emptyList(), + ): Boolean = SaicAidl.callWriting(binder(), DESCRIPTOR, TX_START_NAV_FROM_EV_ROUTE) { data -> + data.writeEvRoutPoiList(pathway) + data.writeEvRoutPoiList(listOf(destination)) + } + + /** + * 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. + */ + internal fun Parcel.writeEvRoutPoiList(pois: List) { + writeInt(pois.size) + pois.forEach { poi -> + writeInt(1) + writeDouble(poi.latitude) + writeDouble(poi.longitude) + writeString(poi.name) + } + } + + /** Transaction codes seen so far, with counts. See [TransactionCensus]. */ + fun census(): Map = census.snapshot() + + /** Codes the census could not index. Non-zero means the interface is not the one expected. */ + fun censusBeyondCeiling(): Int = census.beyondCeiling + + /** The last parcel size seen on each code. See [TransactionCensus.payloadBytes]. */ + fun censusPayloadBytes(): Map = census.payloadBytes() + + /** + * Starts listening. Idempotent. + * + * @return true when the adapter accepted the registration. + */ + fun start(): Boolean { + if (registered) return true + val target = binder() ?: return false + val accepted = SaicAidl.callVoid(target, DESCRIPTOR, TX_REGISTER_LISTENER, listener) + registered = accepted + if (!accepted) AppLogger.w(TAG, "nav-guidance: registration refused") + return accepted + } + + /** + * Stops listening and forgets what was seen. + * + * Callers must reach this on the way out of any screen that called [start]: a listener + * left registered keeps this process on the adapter's fan-out path for no reason. + */ + fun stop() { + val target = binder() + if (registered && target != null) { + SaicAidl.callVoid(target, DESCRIPTOR, TX_UNREGISTER_LISTENER, listener) + } + registered = false + state = NavGuidance.EMPTY + census.clear() + } + + /** + * The callback the adapter calls. + * + * Unrecognised transactions are answered without being parsed. Letting them fall through + * to `super.onTransact` would return false, which the adapter turns into an exception it + * logs for every unrelated callback it makes — noise on the head unit's log for no gain. + */ + private val listener = object : Binder() { + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + // The size before anything is read: `dataPosition` is zero here, so this is the whole + // parcel. Recorded for every code, decoded or not — the undecoded ones are exactly + // the ones whose shape nobody knows. + census.record(code, runCatching { data.dataSize() }.getOrDefault(-1)) + if (code !in NavGuidanceReducer.KNOWN_TRANSACTIONS) { + reply?.writeNoException() + return true + } + runCatching { + data.enforceInterface(LISTENER_DESCRIPTOR) + state = when (code) { + NavGuidanceReducer.TX_GUIDE_INFOS_CHANGE -> NavGuidanceReducer.fold( + state, code, + first = data.readInt(), + second = data.readInt(), + text = data.readString(), + atElapsedMs = SystemClock.elapsedRealtime(), + ) + NavGuidanceReducer.TX_ROAD_INFO_CHANGE -> NavGuidanceReducer.fold( + state, code, + text = data.readString(), + atElapsedMs = SystemClock.elapsedRealtime(), + ) + else -> NavGuidanceReducer.fold( + state, code, + first = data.readInt(), + atElapsedMs = SystemClock.elapsedRealtime(), + ) + } + }.onFailure { + // A payload shaped differently on another firmware is a null reading, not a + // crash on the adapter's fan-out thread. + AppLogger.d(TAG, "nav-guidance#$code: undecodable: ${it.message}") + } + reply?.writeNoException() + return true + } + + override fun getInterfaceDescriptor(): String = LISTENER_DESCRIPTOR + } +} 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 new file mode 100644 index 0000000..bbb6c72 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicVehicle.kt @@ -0,0 +1,591 @@ +package com.evsuite.hardware.saic + +import android.content.Context +import android.os.IBinder +import com.evsuite.hardware.A9Climate +import com.evsuite.hardware.GlassProbe +import com.evsuite.hardware.VsmGlass + +/** + * The vendor vehicle service the head unit's HVAC and charging screens talk to. + * + * One bound service exposes a *hub*: `getService(name)` hands back the binder for a named + * sub-service. Both consumers below go through it, so the bind happens once. + * + * Binding contract: `com.saicmotor.sdk.vehiclesettings.manager.BaseManager` + * (bind target), `IHubService` (the lookup), `AirConditionManager` and + * `VehicleChargingManager` (the two names used here). + */ +object SaicHub { + + private const val PACKAGE = "com.saicmotor.service.vehicle" + private const val ACTION = "com.saicmotor.service.vehicle.VehicleService" + private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IHubService" + private const val TX_GET_SERVICE = 1 + + private val service = SaicService(PACKAGE, ACTION, "vehicle-hub") + private val resolved = HashMap() + + val isReady: Boolean get() = service.isReady + + fun connect(context: Context) = service.connect(context) + + /** Sub-service binder, resolved once and re-resolved after the hub reconnects. */ + @Synchronized + fun service(name: String): IBinder? { + val hub = service.binder() ?: run { resolved.clear(); return null } + resolved[name]?.takeIf { it.isBinderAlive }?.let { return it } + val sub = SaicAidl.callBinder(hub, DESCRIPTOR, TX_GET_SERVICE, name) ?: return null + resolved[name] = sub + return sub + } +} + +/** + * Climate control, as the stock HVAC app performs it. + * + * This is the write path the project deliberately did without until now: EVHardware could + * only *read* climate through standard AOSP property ids that no MG4 confirmed, so there + * was no honest way to change anything. These calls are the ones + * the car's own HVAC screen makes, so what they do is not in doubt — only whether the + * service answers on a given firmware, which the Diagnostic tab now reports. + * + * Temperatures are whole degrees Celsius. The stock UI clamps to 17–33, where the ends read + * as LO and HI (`HvacActivity.onLongTouch`), so the same bounds apply here. + */ +object SaicClimate { + + private const val NAME = "aircondition" + private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IAirConditionService" + + const val TEMP_MIN = 17 + const val TEMP_MAX = 33 + + // Transaction codes used by the supported runtime IAirConditionService contract. + private const val TX_SET_POWER = 4 + private const val TX_OPEN_POWER = 5 + private const val TX_CLOSE_POWER = 6 + private const val TX_SET_AUTO = 7 + private const val TX_SET_AC = 8 + private const val TX_SET_LOOP_MODE = 9 + private const val TX_SET_ECON = 13 + private const val TX_SET_AIR_VOLUME = 15 + private const val TX_SET_DRV_TEMP = 41 + private const val TX_SET_PSG_TEMP = 42 + private const val TX_OPEN_FRONT_DEFROST = 48 + private const val TX_CLOSE_FRONT_DEFROST = 49 + private const val TX_OPEN_BACK_DEFROST = 50 + private const val TX_CLOSE_BACK_DEFROST = 51 + private const val TX_GET_POWER = 53 + private const val TX_GET_AUTO = 54 + private const val TX_GET_AC = 55 + private const val TX_GET_LOOP_MODE = 56 + private const val TX_GET_ECON = 57 + private const val TX_GET_AIR_VOLUME = 59 + private const val TX_GET_MAX_AIR_VOLUME = 60 + private const val TX_GET_DRV_TEMP = 81 + private const val TX_GET_PSG_TEMP = 82 + private const val TX_GET_OUT_CAR_TEMP = 88 + private const val TX_GET_FRONT_DEFROST = 92 + private const val TX_GET_BACK_DEFROST = 93 + + /** `AirConditionBinder.TEMPERATURE_ERROR_CONST`: the signal is not available. */ + private const val TEMP_ERROR = -10000f + + /** Loop mode: 0 = fresh air, 1 = recirculation (`openLoopInner` / `openLoopOutside`). */ + const val LOOP_OUTSIDE = 0 + const val LOOP_INNER = 1 + + /** + * Available when either path answers. + * + * Two paths, because the vendor hub this object talks to does not exist on the A9 + * generations, whose climate is reached through `carapi` instead — see [A9Climate]. The + * split is made here, once, so nothing above has to know which car it is running on. + */ + val isAvailable: Boolean get() = if (A9Climate.isPlatform) A9Climate.isAvailable else binder() != null + + private fun binder(): IBinder? = SaicHub.service(NAME) + + private val a9: Boolean get() = A9Climate.isPlatform + + // ── Writes ─────────────────────────────────────────────────────────────── + fun setPower(on: Boolean): Boolean = + if (a9) A9Climate.setPower(on) + else SaicAidl.callVoid(binder(), DESCRIPTOR, if (on) TX_OPEN_POWER else TX_CLOSE_POWER) + + fun setAc(on: Boolean): Boolean = + if (a9) A9Climate.setAc(on) + else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_AC, if (on) 1 else 0) + + fun setAuto(on: Boolean): Boolean = + if (a9) A9Climate.setAuto(on) + else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_AUTO, if (on) 1 else 0) + + /** + * ECON has no counterpart on A9: the HVAC client exposes no such switch, and there is + * nothing to substitute — reporting the write as refused is the truth. + */ + fun setEcon(on: Boolean): Boolean = + if (a9) false + else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_ECON, if (on) 1 else 0) + + fun setRecirculation(on: Boolean): Boolean = + if (a9) A9Climate.setRecirculation(on) + else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_LOOP_MODE, if (on) LOOP_INNER else LOOP_OUTSIDE) + + fun setFanLevel(level: Int): Boolean = + if (a9) A9Climate.setFanLevel(level) + else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_AIR_VOLUME, level) + + /** Driver-side target. The passenger side follows unless dual zone is on. */ + fun setDriverTemp(celsius: Int): Boolean = + if (a9) A9Climate.setDriverTemp(celsius.coerceIn(TEMP_MIN, TEMP_MAX)) + else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_DRV_TEMP, celsius.coerceIn(TEMP_MIN, TEMP_MAX)) + + /** + * The passenger's own target, which the A9 client does not expose separately — its + * `setDriverTemperature` is the whole cabin unless dual zone is on, and there is no + * honest way to set one side alone. + */ + fun setPassengerTemp(celsius: Int): Boolean = + if (a9) false + else SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_PSG_TEMP, celsius.coerceIn(TEMP_MIN, TEMP_MAX)) + + fun setFrontDefrost(on: Boolean): Boolean = + if (a9) A9Climate.setFrontDefrost(on) + else SaicAidl.callVoid(binder(), DESCRIPTOR, if (on) TX_OPEN_FRONT_DEFROST else TX_CLOSE_FRONT_DEFROST) + + fun setRearDefrost(on: Boolean): Boolean = + if (a9) A9Climate.setRearDefrost(on) + else SaicAidl.callVoid(binder(), DESCRIPTOR, if (on) TX_OPEN_BACK_DEFROST else TX_CLOSE_BACK_DEFROST) + + // ── Reads (null = the service did not answer) ──────────────────────────── + // The service answers -1 for a signal it holds no value for, which is not a state. + fun powerOn(): Boolean? = if (a9) A9Climate.powerOn() else flag(TX_GET_POWER) + fun acOn(): Boolean? = if (a9) A9Climate.acOn() else flag(TX_GET_AC) + fun autoOn(): Boolean? = if (a9) A9Climate.autoOn() else flag(TX_GET_AUTO) + fun econOn(): Boolean? = if (a9) null else flag(TX_GET_ECON) + fun recirculationOn(): Boolean? = + if (a9) A9Climate.recirculationOn() else level(TX_GET_LOOP_MODE)?.let { it == LOOP_INNER } + fun fanLevel(): Int? = if (a9) A9Climate.fanLevel() else level(TX_GET_AIR_VOLUME) + /** A9 states no maximum of its own; the catalogue's is the measured one. */ + 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) + 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. */ + fun outsideTempCelsius(): Float? = + SaicAidl.callFloat(binder(), DESCRIPTOR, TX_GET_OUT_CAR_TEMP)?.takeIf { it != TEMP_ERROR } + + private fun level(code: Int): Int? = + SaicAidl.callInt(binder(), DESCRIPTOR, code)?.takeIf { it >= 0 } + + private fun flag(code: Int): Boolean? = level(code)?.let { it == 1 } +} + +/** + * Charging and battery settings, as the stock charging screen performs them. + * + * `ChargingCloseSoc` is the charge limit in percent, `ReserChrg*` the scheduled window, and + * `DrivingBatteryHeat` the battery pre-heat switch — the three things the vehicle's own + * screen offers. + * + * The same binder also answers the energy counters the vehicle keeps for itself, which is the + * only measured kilowatt-hour on a car that publishes no battery power. They are reads; this + * object writes nothing but the charging settings the stock screen already writes. + * + * Binding contract: `VehicleChargingManager` and `IVehicleChargingService`. + */ +object SaicCharging { + + private const val NAME = "vehiclecharging" + private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IVehicleChargingService" + + private const val TX_GET_SOC = 3 + private const val TX_GET_ENDURANCE_KM = 4 + private const val TX_GET_CHARGING_STATUS = 9 + private const val TX_GET_CLOSE_SOC = 13 + private const val TX_SET_CLOSE_SOC = 14 + private const val TX_GET_CONTROL_SWITCH = 17 + private const val TX_SET_CONTROL_SWITCH = 18 + private const val TX_GET_RESERVE_CONTROL = 19 + private const val TX_SET_RESERVE_CONTROL = 20 + private const val TX_GET_RESERVE_START_HOUR = 21 + private const val TX_SET_RESERVE_START_HOUR = 22 + private const val TX_GET_RESERVE_START_MINUTE = 23 + private const val TX_SET_RESERVE_START_MINUTE = 24 + private const val TX_GET_RESERVE_STOP_HOUR = 25 + private const val TX_SET_RESERVE_STOP_HOUR = 26 + private const val TX_GET_RESERVE_STOP_MINUTE = 27 + private const val TX_SET_RESERVE_STOP_MINUTE = 28 + private const val TX_GET_BATTERY_HEAT = 37 + private const val TX_SET_BATTERY_HEAT = 38 + private const val TX_GET_CONSUMPTION_PER_KM = 66 + private const val TX_GET_CONSUMPTION_PER_KM_VALID = 67 + private const val TX_GET_CONSUMPTION_PER_KM_LIST = 68 + private const val TX_GET_ACC_CONSUMPTION_AFTER_CHARGE = 73 + private const val TX_GET_ACC_CONSUMPTION_AFTER_START = 74 + private const val TX_GET_TOTAL_CONSUMPTION_AFTER_CHARGE = 75 + private const val TX_GET_TOTAL_CONSUMPTION_AFTER_START = 76 + private const val TX_GET_TOTAL_REGEN_AFTER_CHARGE = 77 + private const val TX_GET_TOTAL_REGEN_AFTER_START = 78 + + /** The service's own `*V` companion signals: 1 is a value it stands behind. */ + private const val SIGNAL_VALID = 1 + + val isAvailable: Boolean get() = binder() != null + + private fun binder(): IBinder? = SaicHub.service(NAME) + + /** State of charge in percent. */ + fun stateOfChargePercent(): Float? = + SaicAidl.callFloat(binder(), DESCRIPTOR, TX_GET_SOC)?.takeIf { it >= 0f } + + /** + * Remaining range in kilometres — the number on the car's own cluster, not a calculation. + * + * The service answers -1 for a range it holds no value for, and clamps its own signal + * against a maximum before answering, so an implausible reading never reaches here as a + * number. `read` already drops the negative. + */ + fun rangeKm(): Int? = read(TX_GET_ENDURANCE_KM) + + fun chargingStatus(): Int? = read(TX_GET_CHARGING_STATUS) + + /** Charge limit in percent — charging stops here. */ + fun chargeLimitPercent(): Int? = read(TX_GET_CLOSE_SOC) + fun setChargeLimitPercent(percent: Int): Boolean = + SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CLOSE_SOC, percent.coerceIn(0, 100)) + + fun chargingEnabled(): Boolean? = read(TX_GET_CONTROL_SWITCH)?.let { it == 1 } + fun setChargingEnabled(on: Boolean): Boolean = + SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CONTROL_SWITCH, if (on) 1 else 0) + + fun scheduleEnabled(): Boolean? = read(TX_GET_RESERVE_CONTROL)?.let { it == 1 } + fun setScheduleEnabled(on: Boolean): Boolean = + SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_RESERVE_CONTROL, if (on) 1 else 0) + + fun scheduleStartMinutes(): Int? = minutes(TX_GET_RESERVE_START_HOUR, TX_GET_RESERVE_START_MINUTE) + fun scheduleStopMinutes(): Int? = minutes(TX_GET_RESERVE_STOP_HOUR, TX_GET_RESERVE_STOP_MINUTE) + + /** Hour and minute are two separate signals; both must land for the window to change. */ + fun setScheduleStart(minutesOfDay: Int): Boolean = + setTime(TX_SET_RESERVE_START_HOUR, TX_SET_RESERVE_START_MINUTE, minutesOfDay) + + fun setScheduleStop(minutesOfDay: Int): Boolean = + setTime(TX_SET_RESERVE_STOP_HOUR, TX_SET_RESERVE_STOP_MINUTE, minutesOfDay) + + fun batteryPreheatOn(): Boolean? = read(TX_GET_BATTERY_HEAT)?.let { it == 1 } + fun setBatteryPreheat(on: Boolean): Boolean = + SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_BATTERY_HEAT, if (on) 1 else 0) + + /** + * Energy the car itself counted since the last ignition-on, in kWh. + * + * This is not derived from battery power, and that is the point: SWI68 publishes no + * `EV_BATTERY_INSTANTANEOUS_CHARGE_RATE`, so every kWh figure in EVSuite has had to be + * modelled or refused. The charging service keeps its own counters, fed by the vehicle's + * energy properties, and hands them out over the same binder the charge limit uses. A + * counter read at the start of a trip and again at the end gives measured kilowatt-hours + * with no pack capacity assumed and nothing integrated. + * + * The counter resets at ignition-on, so a trip that spans a stop cannot be measured by + * subtracting the ends: the second reading starts again from zero and the difference goes + * negative. Callers must treat a drop as a reset, not as regeneration. + */ + fun consumedKwhSinceStart(): Float? = energy(TX_GET_TOTAL_CONSUMPTION_AFTER_START) + + /** Same counter, reset at the end of the last charge rather than at ignition-on. */ + fun consumedKwhSinceCharge(): Float? = energy(TX_GET_TOTAL_CONSUMPTION_AFTER_CHARGE) + + fun regeneratedKwhSinceStart(): Float? = energy(TX_GET_TOTAL_REGEN_AFTER_START) + fun regeneratedKwhSinceCharge(): Float? = energy(TX_GET_TOTAL_REGEN_AFTER_CHARGE) + + /** + * The `AccConsumption` counter, alongside `TotalConsumption`, over the same period. + * + * **What this counts is not established.** The service validates it against the same + * bounds as the total and names it no further, so `Acc` reads equally as *accessory* — + * the auxiliary share the total also contains, which would be a measured climate figure — + * or as *accumulated*, a second framing of the same energy. The two readings differ by + * everything: one is a breakdown, the other is a duplicate. + * + * Until a drive with the climate off and a drive with it on are compared on the car, this + * is exposed as the raw counter under the vehicle's own name and nothing is attributed to + * it. Naming it `climateKwh` here would be inventing the evidence. + */ + fun accCounterKwhSinceStart(): Float? = energy(TX_GET_ACC_CONSUMPTION_AFTER_START) + fun accCounterKwhSinceCharge(): Float? = energy(TX_GET_ACC_CONSUMPTION_AFTER_CHARGE) + + /** + * Consumption per kilometre as the cluster shows it, or null when the car disowns it. + * + * The value carries a companion validity signal, and the service answers the last value + * it held whatever that signal says. Reading one without the other reports a stale number + * as a current one, so both are read and a value the car does not stand behind is dropped. + */ + fun consumptionPerKm(): Float? = + if (read(TX_GET_CONSUMPTION_PER_KM_VALID) != SIGNAL_VALID) null + else SaicAidl.callFloat(binder(), DESCRIPTOR, TX_GET_CONSUMPTION_PER_KM) + ?.takeIf { it.isFinite() && it >= 0f } + + /** + * The last fifty per-kilometre readings, oldest first — the cluster's own history. + * + * The service persists them as a JSON array of floats and drops the oldest past fifty, so + * this is a window the car maintains, not a series this app has to record. + */ + fun consumptionPerKmHistory(): List = + parseHistory(SaicAidl.callString(binder(), DESCRIPTOR, TX_GET_CONSUMPTION_PER_KM_LIST)) + + /** Kept apart from the binder so the car's own wire format can be tested without one. */ + internal fun parseHistory(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return json.trim().removeSurrounding("[", "]") + .split(',') + .mapNotNull { it.trim().toFloatOrNull()?.takeIf(Float::isFinite) } + } + + /** A counter the car never started is 0, and a negative one is not a counter. */ + private fun energy(code: Int): Float? = + SaicAidl.callFloat(binder(), DESCRIPTOR, code)?.takeIf { it.isFinite() && it >= 0f } + + private fun read(code: Int): Int? = + SaicAidl.callInt(binder(), DESCRIPTOR, code)?.takeIf { it >= 0 } + + private fun minutes(hourCode: Int, minuteCode: Int): Int? { + val hour = read(hourCode) ?: return null + val minute = read(minuteCode) ?: return null + return hour * 60 + minute + } + + private fun setTime(hourCode: Int, minuteCode: Int, minutesOfDay: Int): Boolean { + val clamped = minutesOfDay.coerceIn(0, 24 * 60 - 1) + val hourOk = SaicAidl.callVoid(binder(), DESCRIPTOR, hourCode, clamped / 60) + val minuteOk = SaicAidl.callVoid(binder(), DESCRIPTOR, minuteCode, clamped % 60) + return hourOk && minuteOk + } +} + +/** + * Windows and door locks, through the same hub as climate and charging. + * + * **Reading and writing the glass are two different scales, and this is the whole story of + * the feature.** The service validates every value against a maximum of its own and silently + * drops anything above it, and the two maxima are not the same: + * + * • reading — `max_vehicle_window_get = 100`: a position in percent, 0 closed to 100 open. + * • writing — `max_vehicle_window_set = 7`: a three-bit command, not a position. A write + * of 100 is above the maximum, so `VehicleControlBinder.setDriveWindow` drops it without + * touching the property and without answering anything. The binder call still succeeds: + * the method returns void, so "accepted" and "ignored" look identical from here. + * + * That is why glass writes scaled as a percentage reported success and moved nothing. The + * same shape holds elsewhere in the service — the tailgate is `get = 100`, `set = 1` — so a + * status range and a command range simply are not the same thing on this interface. + * + * **The hub is the fallback, not the path.** No head-unit application writes this property, + * so the firmware offers no example to read the seven values off, and the command is passed + * through as the caller gave it with anything outside 0–7 refused here rather than sent to + * be dropped. The VSM carries the same glass under a setter the car's own window switch + * goes through, where the commands *are* established — [VsmGlass]. Every read and write + * below prefers it and falls back to the hub only on a firmware whose VSM does not have it. + * + * The electric tailgate is deliberately absent: the launcher defines OPEN and CLOSE as the + * same value (1.0f), so it is a pulse whose direction depends on state this cannot read. + * Firing it blind could open a boot at the wrong moment. + * + * Binding contract: `IVehicleControlService` (codes) and + * `VehicleControlBinder` (the CarCabinManager properties behind them); + * `VehicleConstant` (value semantics). + */ +object SaicVehicleControl { + + private const val NAME = "vehiclecontrol" + private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IVehicleControlService" + + private const val TX_GET_DOOR_LOCK = 3 + private const val TX_SET_DOOR_LOCK = 4 + private const val TX_GET_DRIVER_WINDOW = 5 + private const val TX_SET_DRIVER_WINDOW = 6 + private const val TX_GET_PASSENGER_WINDOW = 7 + private const val TX_SET_PASSENGER_WINDOW = 8 + private const val TX_GET_LEFT_REAR_WINDOW = 9 + private const val TX_SET_LEFT_REAR_WINDOW = 10 + private const val TX_GET_RIGHT_REAR_WINDOW = 11 + private const val TX_SET_RIGHT_REAR_WINDOW = 12 + + /** `VehicleConstant.DoorLockStateItem` — 1 locked, 2 unlocked. */ + private const val DOOR_LOCKED = 1 + private const val DOOR_UNLOCKED = 2 + + /** Position read, in percent — `max_vehicle_window_get` in the service's own resources. */ + const val WINDOW_POSITION_MAX = 100 + + /** + * The largest command the service will pass on — `max_vehicle_window_set`. Anything + * above it is dropped there in silence, which is why it is enforced here instead. + */ + const val WINDOW_COMMAND_MAX = 7 + + /** + * The four windows, each with the pair of codes that reads and moves it. + * + * `setAllWindows` was here first and stays: closing the glass is one gesture, and asking + * a caller to make four calls for it would let three succeed and one be forgotten. This + * is the other half — a rule that vents the driver's window alone cannot be written with + * an all-or-nothing call. + */ + enum class Window( + internal val readCode: Int, + internal val writeCode: Int, + /** The same window's number on the VSM, which addresses glass by area — [VsmGlass]. */ + internal val area: Int, + ) { + DRIVER(TX_GET_DRIVER_WINDOW, TX_SET_DRIVER_WINDOW, 0), + PASSENGER(TX_GET_PASSENGER_WINDOW, TX_SET_PASSENGER_WINDOW, 1), + REAR_LEFT(TX_GET_LEFT_REAR_WINDOW, TX_SET_LEFT_REAR_WINDOW, 2), + REAR_RIGHT(TX_GET_RIGHT_REAR_WINDOW, TX_SET_RIGHT_REAR_WINDOW, 3) + } + + private val WINDOW_READS = Window.entries.map { it.readCode } + private val WINDOW_WRITES = Window.entries.map { it.writeCode } + + val isAvailable: Boolean get() = binder() != null + + private fun binder(): IBinder? = SaicHub.service(NAME) + + /** + * The widest-open window, in percent — the one number a rule about glass wants. "Any + * window open" is `> 0`, and a rule closing them cares about the worst case. + */ + fun widestWindowPercent(): Int? = + if (VsmGlass.isAvailable) Window.entries.mapNotNull { VsmGlass.percent(it.area) }.maxOrNull() + else WINDOW_READS.mapNotNull { code -> SaicAidl.callFloat(binder(), DESCRIPTOR, code) } + .takeIf { it.isNotEmpty() } + ?.max() + ?.toInt() + ?.takeIf { it >= 0 } + + /** + * One window's position in percent, 0 closed to 100 open. Null when it does not answer. + */ + fun windowPercent(window: Window): Int? = + if (VsmGlass.isAvailable) VsmGlass.percent(window.area) + else SaicAidl.callFloat(binder(), DESCRIPTOR, window.readCode)?.toInt()?.takeIf { it >= 0 } + + /** + * Sends one glass command to one window. + * + * [command] is a command in `0..`[WINDOW_COMMAND_MAX], **not** a position — see the class + * KDoc. A value outside that range is refused here and returns false: the service would + * accept the call and drop the value, which is indistinguishable from having moved. + * + * [VsmGlass.Command.UP] is held down rather than written once, because that is the only + * way it moves anything — see [holdWindow]. The hub path has no such notion and cannot + * acquire one: it is one write there whatever the value. + */ + fun setWindow(window: Window, command: Int): Boolean { + if (command !in 0..WINDOW_COMMAND_MAX) return false + if (!VsmGlass.isAvailable) + return SaicAidl.callVoid(binder(), DESCRIPTOR, window.writeCode, command.toFloat()) + return if (command == VsmGlass.Command.UP) VsmGlass.hold(listOf(window.area), command) + else VsmGlass.send(listOf(window.area), command) + } + + /** + * Holds [command] on one window for [durationMs], then releases it. + * + * What [GlassProbe] sweeps with, and the reason a close works on the trims whose motors + * have no position sensor: those obey nothing but a switch held down. Falls back to a + * single write on the hub, which is all that path offers. + */ + fun holdWindow(window: Window, command: Int, durationMs: Long = VsmGlass.HOLD_MS): Boolean { + if (command !in 0..WINDOW_COMMAND_MAX) return false + return if (VsmGlass.isAvailable) VsmGlass.hold(listOf(window.area), command, durationMs) + else SaicAidl.callVoid(binder(), DESCRIPTOR, window.writeCode, command.toFloat()) + } + + /** Each window individually, for the diagnostic — this is what confirms the scale. */ + fun windowPercents(): List = + WINDOW_READS.map { code -> SaicAidl.callFloat(binder(), DESCRIPTOR, code) } + + /** + * Sends the same glass command to all four windows. + * + * @return true when every one of them was accepted. + */ + fun setAllWindows(command: Int): Boolean { + if (command !in 0..WINDOW_COMMAND_MAX) return false + if (VsmGlass.isAvailable) { + val areas = Window.entries.map { it.area } + return if (command == VsmGlass.Command.UP) VsmGlass.hold(areas, command) + else VsmGlass.send(areas, command) + } + val value = command.toFloat() + // Every window attempted even if one refuses: three closed beats none closed, and + // the caller still learns that it was not complete. + return WINDOW_WRITES.map { code -> SaicAidl.callVoid(binder(), DESCRIPTOR, code, value) } + .all { it } + } + + fun doorsLocked(): Boolean? = + doorLockState() + ?.takeIf { it == DOOR_LOCKED || it == DOOR_UNLOCKED } + ?.let { it == DOOR_LOCKED } + + /** + * The raw lock state, for the diagnostic only. + * + * The service answers a status in `0..7` (`max_vehicle_lock_get`) of which only 1 and 2 + * are named, so [doorsLocked] discards everything else. Reporting "unreadable" without + * saying what actually came back is what makes that a dead end: the number is what tells + * whether the state is simply unnamed here or the read is not answering at all. + */ + fun doorLockState(): Int? = SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_DOOR_LOCK) + + fun setDoorsLocked(locked: Boolean): Boolean = + SaicAidl.callVoid( + binder(), DESCRIPTOR, TX_SET_DOOR_LOCK, + if (locked) DOOR_LOCKED else DOOR_UNLOCKED + ) +} + +/** + * The gear, read from the same hub as climate, charging and glass. + * + * A second source for something [com.evsuite.hardware.EVHardware.isVehicleInPark] already + * reads. It exists because the primary path on SWI68/165 goes through the vendor + * `VehicleConditionManager` object held in-process, and when that object never arrived the + * gear was simply unknown — while the very same service was sitting on the hub, answering. + * + * Only gear 1 is named, and only because three independent places agree on it: EVHardware's + * own `GEAR_PARK_VALUE`, two head-unit apps using `carGear == 1` as the condition for playing + * video on the centre screen (`UsbVideoView`, `VideoListFragment`), and + * `IBackUpBinder.isSecureForBackUp`, which accepts gears 1 and 3 alongside `speed == 0`. + * Nothing pins 2, 3 and 4, so nothing here names them. + * + * `getCarSpeed` is on this interface too and is deliberately unused: the binder returns a + * float with no unit in any source, and a speed read in the wrong unit would open the write + * gate at 50 km/h believing it was 14. Speed keeps coming from the AOSP property, whose unit + * is specified. + * + * Binding contract: `IVehicleConditionService` (codes) and + * `VehicleConditionBinder` (the CarSensorManager signals behind them). + */ +object SaicVehicleCondition { + + private const val NAME = "vehiclecondition" + private const val DESCRIPTOR = "com.saicmotor.sdk.vehiclesettings.IVehicleConditionService" + + private const val TX_GET_CAR_GEAR = 5 + + val isAvailable: Boolean get() = binder() != null + + private fun binder(): IBinder? = SaicHub.service(NAME) + + /** Raw gear, or null when unreadable — the binder answers -1 when the signal is absent. */ + fun gearOrNull(): Int? = SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_CAR_GEAR)?.takeIf { it > 0 } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicWeather.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicWeather.kt new file mode 100644 index 0000000..d88ae7a --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/saic/SaicWeather.kt @@ -0,0 +1,200 @@ +package com.evsuite.hardware.saic + +import android.content.Context +import android.os.Binder +import android.os.IBinder +import android.os.Parcel +import com.evsuite.hardware.AppLogger +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * The head unit's own weather, from the service its map stack asks. + * + * The launcher shows weather too, but it broadcasts what it has for the city it settled on. + * This is the source underneath: a query for a position, answered for that position. A rule + * about rain is about where the car is, so the position query is the one worth binding. + * + * **Asynchronous, unlike every other service here.** The call takes a callback binder and + * answers on it, so the reading is made synchronous with a bounded wait — a rule cycle has to + * finish, and a network round-trip inside the head unit must not hold it open. A query that + * does not answer in time is a null reading, which the catalogue already treats as + * "unreadable" rather than as "no rain". + * + * **Current conditions only.** The service declares seven transactions and none of them + * answers a forecast: its `WeatherFuture` parcelable is carried by `Result` but nothing on + * this firmware ever fills it. The daily outlook the weather app shows is fetched by that app + * itself, with credentials that are not reachable from here, and broadcast as + * `com.saicmotor.weather` carrying today's temperature and phrase alone. A forecast condition + * would therefore be permanently unreadable, so there is none. + * + * Binding contract: `com.saicmotor.mapservice.SaicService` (bind target), + * `ISaicService` (the queries) and `ICallBack` (the single-method answer). + */ +object SaicWeather { + + private const val TAG = "EV_SAIC" + + private const val PACKAGE = "com.saicmotor.mapservice" + private const val CLASS = "com.saicmotor.mapservice.SaicService" + private const val DESCRIPTOR = "com.saicmotor.mapservice.ISaicService" + private const val CALLBACK_DESCRIPTOR = "com.saicmotor.mapservice.ICallBack" + + private const val TX_QUERY_WEATHER_BY_POSITION = 2 + private const val TX_CALLBACK_ON_RESULT = 1 + + /** + * `Result` carries its payload behind a type tag, and only this one is decoded. Another + * tag is another shape, and answering with a half-read parcel would be worse than saying + * nothing. + */ + private const val DATA_OBJECT_WEATHER = "data_object_weather" + + /** + * How long a reading may hold the rule cycle. + * + * The query can reach the network, so it is not instant, and a rule evaluated at ignition + * would otherwise wait on a cold radio. Two seconds is longer than a warm answer and short + * enough that a cold one simply comes back unreadable. + */ + private const val TIMEOUT_MS = 2_000L + + private val service = SaicService.byComponent(PACKAGE, CLASS, "weather") + + val isAvailable: Boolean get() = binder() != null + + fun connect(context: Context) = service.connect(context) + + private fun binder(): IBinder? = service.binder() + + /** + * Current conditions where the car is. + * + * [languageCode] reaches the provider and decides the language of [Reading.text], which is + * the field a rule compares against — so it is the caller's, not a constant here. + */ + fun currentAt(latitude: Double, longitude: Double, languageCode: String): Reading? = + // Longitude first, then latitude, and both as strings — the query takes them in that + // order, so swapping them would ask about a plausible-looking wrong place rather than + // failing outright. + query(TX_QUERY_WEATHER_BY_POSITION, ::readCurrent) { target, callback -> + SaicAidl.callVoid( + target, DESCRIPTOR, TX_QUERY_WEATHER_BY_POSITION, + longitude.toString(), latitude.toString(), languageCode, callback + ) + } + + /** + * One query, made synchronous. + * + * The service takes a callback binder and answers on it, so every reading here is a send + * plus a bounded wait. A binder that never calls back leaves the latch untripped and the + * reading null, which the catalogue treats as "cannot tell" — the same as any other + * unreadable signal. + */ + private fun query( + code: Int, + decode: (Parcel) -> T?, + send: (IBinder, Binder) -> Boolean + ): T? { + val target = binder() ?: return null + var answer: T? = null + val answered = CountDownLatch(1) + + val callback = object : Binder() { + override fun onTransact(tx: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + if (tx != TX_CALLBACK_ON_RESULT) return super.onTransact(tx, data, reply, flags) + data.enforceInterface(CALLBACK_DESCRIPTOR) + answer = runCatching { decodeArgument(data, decode) } + .onFailure { AppLogger.d(TAG, "weather#$code: undecodable answer: ${it.message}") } + .getOrNull() + answered.countDown() + reply?.writeNoException() + return true + } + + override fun getInterfaceDescriptor(): String = CALLBACK_DESCRIPTOR + } + + if (!send(target, callback)) return null + return if (answered.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) answer else null + } + + /** + * The `Result` argument, past the flag that says whether there is one. + * + * A nullable parcelable argument is written as a presence int and then the object, so the + * payload starts one int later than the object's own first field. Reading from the wrong + * offset is not an error the parcel reports — every field simply lands in the next one's + * place, the type tag no longer matches, and the answer is silently discarded. That is + * exactly how a weather reading that arrived was reported as unreadable. + */ + private fun decodeArgument(parcel: Parcel, decode: (Parcel) -> T?): T? = + if (parcel.readInt() == 0) null else decode(parcel) + + /** + * `Result`: a code, a message, a tag naming what follows, then the tagged object. + * + * Read by hand because the classes it names live in the head unit's own APKs, not on the + * boot classpath — the same reason every transaction here is written out rather than + * called through an SDK. + */ + private fun readCurrent(parcel: Parcel): Reading? { + if (!openPayload(parcel, DATA_OBJECT_WEATHER)) return null + val text = parcel.readString() + val icon = parcel.readString() + parcel.readByte() // daytime + val temperature = parcel.readDouble() + val unit = parcel.readString() + parcel.readInt() // unit type + val city = parcel.readString() + if (text.isNullOrBlank()) return null + return Reading( + text = text, + icon = icon.orEmpty(), + temperatureCelsius = toCelsius(temperature, unit), + city = city.orEmpty() + ) + } + + /** + * Reads the `Result` envelope and stops unless it holds the expected payload. + * + * A code, a message, a tag naming what follows, then the tagged object. Another tag is + * another shape, and answering from a half-read parcel would be worse than saying nothing. + */ + private fun openPayload(parcel: Parcel, expectedTag: String): Boolean { + parcel.readInt() // code — the payload's presence is the real answer + parcel.readString() // message + if (parcel.readString() != expectedTag) return false + parcel.readString() // the parcelable's own class name + return true + } + + /** + * The service answers in whatever unit it was configured with and names it, so the name is + * what decides. Anything it does not name is left alone rather than assumed Celsius: a + * Fahrenheit value passed through unconverted would read as a mild summer day in winter. + */ + private fun toCelsius(value: Double?, unit: String?): Double? { + if (value == null) return null + return when (unit?.trim()?.uppercase()) { + "C", "°C" -> value + "F", "°F" -> (value - 32.0) * 5.0 / 9.0 + else -> null + } + } + + /** + * One answer. + * + * [text] is the provider's own description ("Rain", "Partly cloudy"), which is what a rule + * compares against; [temperatureCelsius] is null when the answer named no unit. + */ + data class Reading( + val text: String, + val icon: String, + val temperatureCelsius: Double?, + val city: String + ) +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimator.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimator.kt new file mode 100644 index 0000000..875d44d --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimator.kt @@ -0,0 +1,124 @@ +package com.evsuite.hardware.telemetry + +import kotlin.math.max +import kotlin.math.sqrt + +/** + * A deliberately small range model fitted only from the driver's recent completed averages. + * + * Each trip contributes one observation so a long motorway drive does not silently erase every + * other driving context. The newest observations win, the current trip is considered newest, + * and the fixed-size window keeps both memory and model history explicit. + */ +class AdaptiveRangeEstimator( + private val maxConsumptionSamples: Int = MAX_CONSUMPTION_SAMPLES, + private val minimumConsumptionSamples: Int = MINIMUM_CONSUMPTION_SAMPLES, +) { + /** + * Whether this vehicle has ever published battery power to this instance. + * + * The capability question is "can this firmware measure power at all", and the answer does + * not change between two polls. Asking the live reading instead made a single unreadable + * sample blank an estimate computed from stored trips and the pack's charge — neither of + * which had moved — so the figure flickered for a reason that had nothing to do with it. + */ + private var powerEverPublished = false + + init { + require(minimumConsumptionSamples >= 2) { "at least two samples are needed for spread" } + require(maxConsumptionSamples >= minimumConsumptionSamples) { + "the range window must hold the minimum sample count" + } + } + + /** + * Estimates remaining range without replacing [EnergySnapshot.rangeKm]. + * + * Battery power is the capability gate, latched rather than sampled: a firmware that has + * never published it must not reuse another firmware's stored history to claim an adaptive + * range, but one that published it a second ago has not lost the ability since. + */ + fun estimate( + snapshot: EnergySnapshot, + currentTrip: EnergyTripSummary?, + recentTrips: List, + missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT, + ): Provenanced { + if (snapshot.batteryPowerKw?.isFinite() == true) powerEverPublished = true + if (!powerEverPublished) return Provenanced.unavailable(missingReason) + + val samples = consumptionSamples(currentTrip, recentTrips) + if (samples.size < minimumConsumptionSamples) { + return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + + val usableEnergyKwh = usableEnergy(snapshot) + ?: return Provenanced.unavailable(missingReason) + val meanConsumption = samples.average() + val estimatedKm = usableEnergyKwh * 100.0 / meanConsumption + if (!estimatedKm.isFinite()) return Provenanced.unavailable(missingReason) + + // Sample standard deviation describes the consumption observations we actually saw. + // First-order propagation through range = energy * 100 / consumption converts that + // spread to the symmetric kilometre half-band Provenanced exposes. + val variance = samples.sumOf { sample -> + val delta = sample - meanConsumption + delta * delta + } / (samples.size - 1).toDouble() + val consumptionSpread = sqrt(variance) + val observedBandKm = estimatedKm * consumptionSpread / meanConsumption + if (!observedBandKm.isFinite()) return Provenanced.unavailable(missingReason) + // A floor, because the spread of at most eight of one driver's trips is not the model's + // error. Trips that happened to agree produce a band of exactly zero, and "266.7 ± 0.0 + // km" reads as a measurement — the one thing Provenance exists to stop an estimate + // doing. The floor is what the model cannot claim to know better than. + val rangeUncertaintyKm = max(observedBandKm, estimatedKm * MIN_RELATIVE_UNCERTAINTY) + return Provenanced.estimated(estimatedKm, rangeUncertaintyKm) + } + + private fun consumptionSamples( + currentTrip: EnergyTripSummary?, + recentTrips: List, + ): List { + val current = currentTrip?.averageConsumptionKwhPer100Km + ?.takeIf(::isUsableConsumption) + val history = recentTrips.asSequence() + .sortedByDescending { it.endedAtMs } + .mapNotNull { it.averageConsumptionKwhPer100Km } + .filter(::isUsableConsumption) + return sequenceOf(current).filterNotNull() + .plus(history) + .take(maxConsumptionSamples) + .toList() + } + + private fun usableEnergy(snapshot: EnergySnapshot): Double? { + snapshot.batteryEnergyKwh?.toDouble() + ?.takeIf { it.isFinite() && it >= 0.0 } + ?.let { return it } + val soc = snapshot.socPercent?.toDouble() + ?.takeIf { it.isFinite() && it in 0.0..100.0 } ?: return null + val capacity = snapshot.batteryCapacityKwh?.toDouble() + ?.takeIf { it.isFinite() && it > 0.0 } ?: return null + return capacity * soc / 100.0 + } + + private fun isUsableConsumption(value: Double): Boolean = value.isFinite() && value > 0.0 + + companion object { + /** Current trip plus the seven most recent valid stored-trip averages. */ + const val MAX_CONSUMPTION_SAMPLES = 8 + + /** Three observations permit a centre and a non-degenerate observed spread. */ + const val MINIMUM_CONSUMPTION_SAMPLES = 3 + + /** + * The narrowest band this model is allowed to claim, as a fraction of the estimate. + * + * Eight trips of one driver do not measure the error of extrapolating to a whole pack: + * terrain, load, weather and speed all sit outside the fit. Ten percent is the width + * below which the figure would invite more trust than the method can carry. + */ + const val MIN_RELATIVE_UNCERTAINTY = 0.10 + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ArrivalSocForecast.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ArrivalSocForecast.kt new file mode 100644 index 0000000..5aac39c --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ArrivalSocForecast.kt @@ -0,0 +1,82 @@ +package com.evsuite.hardware.telemetry + +/** + * The state of charge on arrival, and how wrong it might be. + * + * An arrival-SOC point estimate is how drivers get stranded, so this never returns one: the + * value always carries a band, and the band always widens with distance because the rate it + * rests on is itself uncertain. Where the band grows wider than [MAX_BAND_PERCENT] the + * forecast is refused outright rather than shown wide — a number spanning half the pack tells + * a driver nothing while looking like it does. + * + * A negative arrival figure is deliberately not clamped. It means the destination is beyond + * what the charge covers, which is the single most useful thing this can say. + */ +object ArrivalSocForecast { + + /** + * Widest band worth showing, in points of charge. + * + * Past this the honest answer is "not enough evidence", and saying so is more use than a + * figure the driver has to discount themselves. + */ + const val MAX_BAND_PERCENT = 15.0 + + /** + * @param socPercent the vehicle's current state of charge, 0..100. + * @param remainingDistanceKm distance still to cover, from the head unit's navigation. + * @param rate what a kilometre costs, from [SocRateEstimator]. + */ + fun of( + socPercent: Double?, + remainingDistanceKm: Double?, + rate: SocRate?, + ): Provenanced { + if (socPercent == null || !socPercent.isFinite() || socPercent !in 0.0..100.0) { + return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT) + } + if (remainingDistanceKm == null || !remainingDistanceKm.isFinite() || + remainingDistanceKm < 0.0 + ) { + return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT) + } + if (rate == null) { + return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + val arrival = socPercent - rate.percentPerKm * remainingDistanceKm + // The band comes from the rate alone: the distance is the head unit's own figure and + // the current charge is a vehicle reading, so neither contributes an error bar here. + val band = rate.uncertaintyPercentPerKm * remainingDistanceKm + if (!arrival.isFinite() || !band.isFinite()) { + return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + if (band > MAX_BAND_PERCENT) { + return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + return Provenanced.estimated(arrival, uncertainty = band) + } + + /** + * How far the charge reaches at this rate, for the same inputs. + * + * The complement of [of]: an arrival figure answers "will I get there", this one answers + * "how far does this get me", and a driver deciding whether to stop wants both. + */ + fun rangeAtRateKm(socPercent: Double?, rate: SocRate?): Provenanced { + if (socPercent == null || !socPercent.isFinite() || socPercent !in 0.0..100.0) { + return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT) + } + if (rate == null || rate.percentPerKm <= 0.0) { + return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + val km = socPercent / rate.percentPerKm + // A rate known to ±x% puts the distance between soc/(rate+x) and soc/(rate-x); the + // near edge is the half-width worth quoting, because overstating range is the danger. + val worst = socPercent / (rate.percentPerKm + rate.uncertaintyPercentPerKm) + val band = km - worst + if (!km.isFinite() || !band.isFinite() || band < 0.0) { + return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + return Provenanced.estimated(km, uncertainty = band) + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryCapacityConfig.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryCapacityConfig.kt new file mode 100644 index 0000000..5cd77e0 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryCapacityConfig.kt @@ -0,0 +1,89 @@ +package com.evsuite.hardware.telemetry + +/** + * The pack size, as the driver declares it, because the vehicle will not say. + * + * `INFO_EV_BATTERY_CAPACITY` is declared and never published on the MG4, and deriving the + * capacity from an energy total needs battery power, which is itself unvalidated. Without a + * capacity there is no bridge between kWh and state of charge, and every energy model stops + * at kWh/100 km. A declared figure is the only one available, so it is taken — and labelled + * for exactly what it is. + * + * **Usable, not nameplate.** State of charge spans the usable window, not the gross pack: the + * buffers at each end are outside it. The MG4 Long Range is sold as a 64 kWh car and its usable + * capacity is 61,7 kWh — EVKX's specification sheet for that model, reviewed 2026-09-04, and a + * citation rather than a measurement of any particular pack. Entering the nameplate figure + * would overstate every arrival forecast by the size of those buffers, in the optimistic + * direction. The field asks for the usable figure and nothing here converts one into the other: + * the relationship is model-specific and this layer serves more than one model. + * + * See the workspace `AGENTS.md` for how a specification sheet may be used — it corroborates a + * declared figure and never stands in for on-vehicle evidence. + * + * **Everything derived from this is [Provenance.ESTIMATED].** A dealer's state-of-health + * reading is itself an estimate, the usable figure is a specification rather than a + * measurement of this pack, and neither was taken today. [RELATIVE_UNCERTAINTY] carries that, + * so a value built on a declared capacity can never be drawn like a vehicle reading. + */ +data class BatteryCapacityConfig( + /** kWh between 0 % and 100 % state of charge on a healthy pack — the usable figure. */ + val usableCapacityKwhWhenNew: Double, + /** State of health in percent, as measured by whoever measured it. */ + val stateOfHealthPercent: Double, +) { + /** What 100 % state of charge is worth on this pack today. */ + val effectiveUsableKwh: Double + get() = usableCapacityKwhWhenNew * stateOfHealthPercent / 100.0 + + /** + * The energy behind a state of charge. + * + * @param socPercent the vehicle's own reading, 0..100. + */ + fun energyAtSocKwh(socPercent: Double?): Provenanced { + if (socPercent == null || !socPercent.isFinite() || socPercent !in 0.0..100.0) { + return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT) + } + val kwh = effectiveUsableKwh * socPercent / 100.0 + return Provenanced.estimated(kwh, uncertainty = kwh * RELATIVE_UNCERTAINTY) + } + + /** How many state-of-charge points an amount of energy is worth on this pack. */ + fun socPercentForEnergy(kwh: Double?): Provenanced { + if (kwh == null || !kwh.isFinite() || effectiveUsableKwh <= 0.0) { + return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + val soc = kwh * 100.0 / effectiveUsableKwh + return Provenanced.estimated(soc, uncertainty = kotlin.math.abs(soc) * RELATIVE_UNCERTAINTY) + } + + companion object { + /** + * How wrong a declared capacity may reasonably be. + * + * It covers a dealer's state-of-health figure, a usable capacity taken from a + * specification rather than from this pack, and the drift since either was taken. It + * is deliberately not smaller: a narrow band on a number nobody measured today would + * be a false precision, and arrival forecasts are where that gets someone stranded. + */ + const val RELATIVE_UNCERTAINTY = 0.05 + + private val CAPACITY_RANGE = 10.0..250.0 + private val HEALTH_RANGE = 50.0..100.0 + + /** + * A configuration, or null when the figures are not usable. + * + * Out-of-range input is refused rather than clamped: a clamped capacity is a number + * the driver did not enter, silently standing in for the one they did. + */ + fun of(usableCapacityKwhWhenNew: Double?, stateOfHealthPercent: Double?): + BatteryCapacityConfig? { + if (usableCapacityKwhWhenNew == null || stateOfHealthPercent == null) return null + if (!usableCapacityKwhWhenNew.isFinite() || !stateOfHealthPercent.isFinite()) return null + if (usableCapacityKwhWhenNew !in CAPACITY_RANGE) return null + if (stateOfHealthPercent !in HEALTH_RANGE) return null + return BatteryCapacityConfig(usableCapacityKwhWhenNew, stateOfHealthPercent) + } + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryExposure.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryExposure.kt new file mode 100644 index 0000000..fd820b5 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryExposure.kt @@ -0,0 +1,157 @@ +package com.evsuite.hardware.telemetry + +/** One rise in charge: a charge session, identified the only way this car makes possible. */ +data class BatteryChargeSession( + val startedAtMs: Long, + val endedAtMs: Long, + val startSocPercent: Double, + val endSocPercent: Double, + /** + * Mean and peak points of charge per hour. The rate is what separates a wall box from a + * fast charger here: the car publishes no charger type, and a DC session is an order of + * magnitude faster than an AC one on the same pack. + */ + val meanPercentPerHour: Double, + val peakPercentPerHour: Double, + /** Outside temperature, named as such — the pack's own is unpublished on this firmware. */ + val meanOutsideTempCelsius: Double?, +) { + val gainedPercent: Double get() = endSocPercent - startSocPercent + val durationHours: Double get() = (endedAtMs - startedAtMs) / 3_600_000.0 +} + +data class BatteryExposureReport( + val spanDays: Double, + /** Hours the ledger actually covers, which is not the same as the span it stretches over. */ + val observedHours: Double, + val hoursAboveHighSoc: Double, + val hoursBelowLowSoc: Double, + val meanSocPercent: Double, + val equivalentFullCycles: Double, + val kmPerEquivalentCycle: Double?, + val sessions: List, +) + +/** + * What this pack has been subjected to — which is not the same question as how healthy it is. + * + * Calendar ageing is driven by time spent at a high charge and by temperature, cycle ageing by + * throughput and rate. The car reports none of it and keeps no history a driver can read, while + * this app has been recording the raw material since the ledger existed. + * + * Exposure is never combined with [StateOfHealthEstimate] into a score. They are two + * measurements with two provenances, and a single number would hide which one moved. + */ +class BatteryExposure( + private val highSocPercent: Double = HIGH_SOC_PERCENT, + private val lowSocPercent: Double = LOW_SOC_PERCENT, +) { + fun analyse(entries: List): BatteryExposureReport? { + val ordered = entries.filter { it.socPercent.isFinite() }.sortedBy { it.atMs } + if (ordered.size < 2) return null + + var observedHours = 0.0 + var aboveHours = 0.0 + var belowHours = 0.0 + var socHours = 0.0 + var spentPercent = 0.0 + for (index in 1 until ordered.size) { + val previous = ordered[index - 1] + val entry = ordered[index] + val hours = (entry.atMs - previous.atMs) / MS_PER_HOUR + if (hours <= 0.0) continue + val start = previous.socPercent.toDouble() + val end = entry.socPercent.toDouble() + observedHours += hours + aboveHours += hours * fractionAbove(start, end, highSocPercent) + belowHours += hours * (1.0 - fractionAbove(start, end, lowSocPercent)) + socHours += hours * (start + end) / 2.0 + if (start > end) spentPercent += start - end + } + if (observedHours <= 0.0) return null + + val cycles = spentPercent / 100.0 + val odometers = ordered.mapNotNull { it.odometerKm } + val km = if (odometers.size >= 2) (odometers.last() - odometers.first()).toDouble() else null + return BatteryExposureReport( + spanDays = (ordered.last().atMs - ordered.first().atMs) / MS_PER_DAY, + observedHours = observedHours, + hoursAboveHighSoc = aboveHours, + hoursBelowLowSoc = belowHours, + meanSocPercent = socHours / observedHours, + equivalentFullCycles = cycles, + kmPerEquivalentCycle = km?.takeIf { cycles > 0.0 && it >= 0.0 }?.div(cycles), + sessions = sessions(ordered), + ) + } + + /** + * The share of a segment spent above a threshold, taking the charge as a straight line + * between its two ends. + * + * Attributing the whole segment to whichever side its mean fell on would count a night that + * ended at 85 % as entirely below 80 %, and the overnight hours at a high charge are exactly + * what calendar ageing is about. + */ + private fun fractionAbove(start: Double, end: Double, threshold: Double): Double { + if (start >= threshold && end >= threshold) return 1.0 + if (start <= threshold && end <= threshold) return 0.0 + return ((if (start > threshold) start else end) - threshold) / kotlin.math.abs(end - start) + } + + /** Consecutive rises, merged: one session, however many entries the ledger wrote during it. */ + private fun sessions(ordered: List): List { + val result = ArrayList() + var index = 1 + while (index < ordered.size) { + if (ordered[index].socPercent <= ordered[index - 1].socPercent + RISE_EPSILON) { + index++ + continue + } + val start = ordered[index - 1] + var peak = 0.0 + var temps = 0.0 + var tempCount = 0 + var last = start + while (index < ordered.size && + ordered[index].socPercent > last.socPercent + RISE_EPSILON + ) { + val entry = ordered[index] + val hours = (entry.atMs - last.atMs) / MS_PER_HOUR + if (hours > 0.0) { + val rate = (entry.socPercent - last.socPercent) / hours + if (rate > peak) peak = rate + } + entry.outsideTempCelsius?.let { temps += it.toDouble(); tempCount++ } + last = entry + index++ + } + val hours = (last.atMs - start.atMs) / MS_PER_HOUR + if (hours > 0.0) { + result.add( + BatteryChargeSession( + startedAtMs = start.atMs, + endedAtMs = last.atMs, + startSocPercent = start.socPercent.toDouble(), + endSocPercent = last.socPercent.toDouble(), + meanPercentPerHour = (last.socPercent - start.socPercent) / hours, + peakPercentPerHour = peak, + meanOutsideTempCelsius = (temps / tempCount).takeIf { tempCount > 0 }, + ) + ) + } + } + return result + } + + companion object { + const val HIGH_SOC_PERCENT = 80.0 + const val LOW_SOC_PERCENT = 10.0 + + /** Same step the health estimator calls a charge, so the two agree on what a rise is. */ + private const val RISE_EPSILON = 0.6f + + private const val MS_PER_HOUR = 3_600_000.0 + private const val MS_PER_DAY = 86_400_000.0 + } +} 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 new file mode 100644 index 0000000..83e98b8 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/BatteryLedger.kt @@ -0,0 +1,235 @@ +package com.evsuite.hardware.telemetry + +import com.google.gson.Gson +import java.io.File +import java.io.FileOutputStream + +/** + * One moment in the pack's life, including the ones a trip never sees. + * + * The trip store answers "what did that drive cost". Battery health asks a different question — + * what did the charge do between two charges — and the answer spans the parking, the overnight + * standing and the drives in between. None of that is in `trips.json`: a trip begins when the + * car moves and a pack loses charge whether or not it does. + * + * Every field except the charge itself is nullable and stays that way. An entry with no charge + * reading says nothing at all and is never written; everything else is recorded when the car + * offers it and left absent when it does not. + */ +data class BatteryLedgerEntry( + val atMs: Long, + val socPercent: Float, + val odometerKm: Float? = null, + val outsideTempCelsius: Float? = null, + /** + * The vehicle's own energy counters, as they read at this moment — `SaicCharging` + * transactions 75 and 77, reset by the car at the end of a charge. Measured, nothing + * integrated here. A drop between two entries is that reset, never regeneration. + */ + val vehicleConsumedKwh: Float? = null, + val vehicleRegeneratedKwh: Float? = null, + /** + * This app's own running integral of pack power, cumulative since the ledger began. + * + * Cumulative rather than per-entry so a window is one subtraction, and so a missed sample + * costs the window that contains it instead of every window after it. + */ + val packEnergyKwh: Double? = null, + /** Distance this app actually integrated, cumulative, beside [odometerKm] which is the car's. */ + val integratedDistanceKm: Double? = null, + val chargingStatus: Int? = null, + val chargePortConnected: Boolean? = null, + val parked: Boolean? = null, +) + +/** The ledger file's envelope, stating its own version exactly as the trip history does. */ +data class BatteryLedgerFile( + val schemaVersion: Int = SCHEMA_VERSION, + val entries: List, +) { + companion object { + const val SCHEMA_VERSION = 1 + } +} + +/** + * Bounded app-private ledger, replaced atomically after every successful write. + * + * Same shape as [EnergyTripHistoryStore] and for the same reasons: a file this build cannot + * read is quarantined rather than deleted, and the bound is a count rather than a byte budget + * because an entry here is a fixed handful of numbers. + */ +class BatteryLedgerStore( + private val target: File, + private val maxEntries: Int = MAX_ENTRIES, + private val gson: Gson = Gson(), +) { + /** Oldest first, which is the order every reader of this file walks it in. */ + fun read(): List { + if (!target.exists()) return emptyList() + val text = runCatching { target.readText() }.getOrNull() ?: return emptyList() + return parse(text) ?: run { + quarantine() + emptyList() + } + } + + fun append(entry: BatteryLedgerEntry): Boolean = write(bound(read() + entry)) + + fun clear(): Boolean = write(emptyList()) + + @Suppress("SENSELESS_COMPARISON") + private fun parse(text: String): List? { + val envelope = runCatching { gson.fromJson(text, LedgerEnvelope::class.java) } + .getOrNull() ?: return null + if (envelope.schemaVersion != BatteryLedgerFile.SCHEMA_VERSION) return null + // Gson writes straight through Kotlin's nullability: a truncated file can hold an entry + // with no charge reading at all, and the comparison against null is not senseless here. + return envelope.entries.orEmpty() + .filter { it != null && it.socPercent != null && it.socPercent.isFinite() } + .sortedBy { it.atMs } + } + + private data class LedgerEnvelope( + val schemaVersion: Int = 0, + val entries: List? = null, + ) + + /** Newest entries win: a health estimate is about the pack as it is now. */ + private fun bound(entries: List): List = + if (entries.size <= maxEntries) entries + else entries.subList(entries.size - maxEntries.coerceAtLeast(1), entries.size) + + private fun quarantine() { + val kept = File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}") + if (target.renameTo(kept)) return + if (runCatching { target.copyTo(kept, overwrite = true) }.isSuccess) target.delete() + else kept.delete() + } + + private fun write(entries: List): Boolean { + val bytes = gson.toJson(BatteryLedgerFile(entries = entries)).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 + } + } + + companion object { + /** About a year of ordinary use at one entry per point of charge moved. */ + const val MAX_ENTRIES = 2_000 + const val FILE_NAME = "battery-ledger.json" + } +} + +/** + * Turns the 1 Hz sample stream into the handful of entries a health estimate needs. + * + * Two jobs, and they are one class because the second depends on the first. It keeps the + * running integrals — pack energy and distance — that no single sample carries, and it decides + * when the ledger has learnt something worth a disk write. Writing every sample would be 3 600 + * rewrites an hour of a file that would say the same thing as one. + * + * An entry is earned by a change: the charge moved by a point, the charging state changed, or + * enough quiet time passed that the next reader cannot tell a parked car from a stopped app. + * + * **The integrals are cumulative and survive a restart by being re-seeded from the last entry, + * not by being recomputed.** Whatever happened while this process was not running is lost, and + * that is exactly what [BatteryLedgerEntry.integratedDistanceKm] against the car's own odometer + * lets a reader detect — a window whose integral covers less ground than the odometer moved is + * refused rather than believed. + */ +class BatteryLedgerRecorder( + private val store: BatteryLedgerStore, + private val socStepPercent: Float = SOC_STEP_PERCENT, + private val quietIntervalMs: Long = QUIET_INTERVAL_MS, + private val maxSampleGapMs: Long = MAX_SAMPLE_GAP_MS, +) { + private var last: EnergySnapshot? = null + private var lastEntry: BatteryLedgerEntry? = null + private var packEnergyKwh: Double = 0.0 + private var integratedDistanceKm: Double = 0.0 + private var hasPowerInterval = false + private var hasSpeedInterval = false + private var seeded = false + + /** @return true when this sample earned an entry and it was written. */ + fun observe(snapshot: EnergySnapshot): Boolean { + seed() + integrate(snapshot) + val soc = snapshot.socPercent?.takeIf { it.isFinite() } ?: return false + val previous = lastEntry + val earned = previous == null || + kotlin.math.abs(soc - previous.socPercent) >= socStepPercent || + snapshot.chargingStatus != previous.chargingStatus || + snapshot.timestampMs - previous.atMs >= quietIntervalMs + if (!earned) return false + val entry = BatteryLedgerEntry( + atMs = snapshot.timestampMs, + socPercent = soc, + odometerKm = snapshot.odometerKm, + outsideTempCelsius = snapshot.outsideTempCelsius, + vehicleConsumedKwh = snapshot.vehicleConsumedKwh, + vehicleRegeneratedKwh = snapshot.vehicleRegeneratedKwh, + packEnergyKwh = packEnergyKwh.takeIf { hasPowerInterval }, + integratedDistanceKm = integratedDistanceKm.takeIf { hasSpeedInterval }, + chargingStatus = snapshot.chargingStatus, + chargePortConnected = snapshot.chargePortConnected, + parked = snapshot.parked, + ) + if (!store.append(entry)) return false + lastEntry = entry + return true + } + + private fun seed() { + if (seeded) return + seeded = true + val previous = store.read().lastOrNull() ?: return + lastEntry = previous + packEnergyKwh = previous.packEnergyKwh ?: 0.0 + integratedDistanceKm = previous.integratedDistanceKm ?: 0.0 + hasPowerInterval = previous.packEnergyKwh != null + hasSpeedInterval = previous.integratedDistanceKm != null + } + + /** Trapezoid over adjacent samples only, with the same five-second bound trips use. */ + private fun integrate(snapshot: EnergySnapshot) { + val previous = last + last = snapshot + if (previous == null) return + val gapMs = snapshot.timestampMs - previous.timestampMs + if (gapMs <= 0L || gapMs > maxSampleGapMs) return + val hours = gapMs / 3_600_000.0 + val previousPower = previous.batteryPowerKw + val power = snapshot.batteryPowerKw + if (previousPower != null && power != null) { + hasPowerInterval = true + packEnergyKwh += (previousPower + power) / 2.0 * hours + } + val previousSpeed = previous.speedKmh + val speed = snapshot.speedKmh + if (previousSpeed != null && speed != null) { + hasSpeedInterval = true + integratedDistanceKm += (previousSpeed + speed) / 2.0 * hours + } + } + + companion object { + /** One point of charge is the smallest step this car's gauge actually takes. */ + const val SOC_STEP_PERCENT = 1.0f + const val QUIET_INTERVAL_MS = 15 * 60 * 1000L + const val MAX_SAMPLE_GAP_MS = 5_000L + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/CalibrationDrift.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/CalibrationDrift.kt new file mode 100644 index 0000000..70ab9e9 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/CalibrationDrift.kt @@ -0,0 +1,152 @@ +package com.evsuite.hardware.telemetry + +/** What the ledger says about the charge gauge's own reference. */ +enum class CalibrationVerdict { + /** The gauge has seen an end of the pack recently; nothing to suggest. */ + SETTLED, + + /** Drifting towards having nothing to anchor on, with no evidence that it has. */ + WATCH, + + /** Either the anchor is long gone, or a step says the reference already moved. */ + RE_ANCHOR_SUGGESTED, +} + +/** + * A charge step no energy explains. + * + * The clean case is a parked car: it stood still, drove nowhere, and woke up several points + * lower. Nothing consumed that charge — the BMS re-estimated it, and the size of the step is + * the error that had accumulated. + */ +data class UnexplainedSocStep( + val atMs: Long, + val dropPercent: Double, + val elapsedHours: Double, + val parked: Boolean, +) + +data class CalibrationDriftReport( + val verdict: CalibrationVerdict, + val lastFullChargeAtMs: Long?, + val lastLowChargeAtMs: Long?, + val daysSinceFullCharge: Double?, + val equivalentCyclesSinceFullCharge: Double, + val steps: List, + /** Width of the health estimate's band in points, when there is an estimate. */ + val estimateSpreadPercentPoints: Double?, + val ledgerSpanDays: Double, +) + +/** + * When the charge gauge last had anything to correct itself against. + * + * Every figure this project produces is anchored on state of charge, and that estimate is + * counted between rare anchor points — a full charge, a deep discharge, a long rest. A car + * charged to 80 % and never run below 30 % visits none of them, and the error accumulates + * silently until the gauge jumps. + * + * **This notices; it never acts.** No interface on this head unit recalibrates a BMS, and + * re-anchoring is something the pack does for itself during a full charge. Nor are the app's + * own forecasts corrected against an observed step: a corrected figure would be this app's + * opinion of the charge, drawn beside the car's own gauge saying something else, with no way + * for a driver to tell which is which. The gauge is the car's. This says it moved. + */ +class CalibrationDrift( + private val fullSocPercent: Double = FULL_SOC_PERCENT, + private val lowSocPercent: Double = LOW_SOC_PERCENT, + private val settledDays: Double = SETTLED_DAYS, + private val reAnchorDays: Double = RE_ANCHOR_DAYS, + private val stepPercent: Double = STEP_PERCENT, + private val selfDischargePercentPerDay: Double = SELF_DISCHARGE_PERCENT_PER_DAY, +) { + fun analyse( + entries: List, + nowMs: Long, + estimate: StateOfHealthEstimate? = null, + ): CalibrationDriftReport? { + val ordered = entries.filter { it.socPercent.isFinite() }.sortedBy { it.atMs } + if (ordered.size < 2) return null + + val lastFull = ordered.lastOrNull { it.socPercent >= fullSocPercent } + val lastLow = ordered.lastOrNull { it.socPercent <= lowSocPercent } + val daysSinceFull = lastFull?.let { (nowMs - it.atMs) / MS_PER_DAY } + val cyclesSinceFull = cycles(ordered.filter { lastFull == null || it.atMs >= lastFull.atMs }) + val steps = steps(ordered) + val spread = estimate?.stateOfHealthPercent?.uncertainty?.times(2.0) + + val verdict = when { + steps.isNotEmpty() -> CalibrationVerdict.RE_ANCHOR_SUGGESTED + daysSinceFull == null || daysSinceFull > reAnchorDays -> + CalibrationVerdict.RE_ANCHOR_SUGGESTED + daysSinceFull <= settledDays -> CalibrationVerdict.SETTLED + else -> CalibrationVerdict.WATCH + } + return CalibrationDriftReport( + verdict = verdict, + lastFullChargeAtMs = lastFull?.atMs, + lastLowChargeAtMs = lastLow?.atMs, + daysSinceFullCharge = daysSinceFull, + equivalentCyclesSinceFullCharge = cyclesSinceFull, + steps = steps, + estimateSpreadPercentPoints = spread, + ledgerSpanDays = (ordered.last().atMs - ordered.first().atMs) / MS_PER_DAY, + ) + } + + /** + * Steps between two entries where the car went nowhere. + * + * A parked pack does lose charge — slowly, and the allowance below says how slowly. What is + * refused is the reading that a car standing still for an hour spent four points on nothing. + * The odometer has to be readable at both ends and identical: without it, a drive the app + * did not sample is indistinguishable from a step, and calling that a calibration problem + * would be an invented fact rather than an observed one. + */ + private fun steps(ordered: List): List { + val result = ArrayList() + for (index in 1 until ordered.size) { + val previous = ordered[index - 1] + val entry = ordered[index] + val startOdometer = previous.odometerKm ?: continue + val endOdometer = entry.odometerKm ?: continue + if (endOdometer != startOdometer) continue + if (previous.parked == false || entry.parked == false) continue + val drop = (previous.socPercent - entry.socPercent).toDouble() + if (drop < stepPercent) continue + val hours = (entry.atMs - previous.atMs) / MS_PER_HOUR + if (hours <= 0.0) continue + val allowance = selfDischargePercentPerDay * hours / 24.0 + if (drop <= allowance) continue + result.add(UnexplainedSocStep(entry.atMs, drop, hours, parked = true)) + } + return result + } + + /** Equivalent full cycles: every point of charge spent, over a hundred. */ + private fun cycles(ordered: List): Double { + var spent = 0.0 + for (index in 1 until ordered.size) { + val drop = ordered[index - 1].socPercent - ordered[index].socPercent + if (drop > 0f) spent += drop.toDouble() + } + return spent / 100.0 + } + + companion object { + /** What counts as a full charge for anchoring: the gauge rarely reports a clean 100. */ + const val FULL_SOC_PERCENT = 97.0 + const val LOW_SOC_PERCENT = 15.0 + const val SETTLED_DAYS = 30.0 + const val RE_ANCHOR_DAYS = 90.0 + + /** Smaller than this and a step is quantisation plus a cold night, not a re-estimate. */ + const val STEP_PERCENT = 3.0 + + /** A parked MG4 is allowed to lose this much a day before a drop needs explaining. */ + const val SELF_DISCHARGE_PERCENT_PER_DAY = 1.0 + + private const val MS_PER_DAY = 86_400_000.0 + private const val MS_PER_HOUR = 3_600_000.0 + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ChargeStopPlan.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ChargeStopPlan.kt new file mode 100644 index 0000000..ef1e23e --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ChargeStopPlan.kt @@ -0,0 +1,287 @@ +package com.evsuite.hardware.telemetry + +import kotlin.math.abs + +/** + * Whether a route needs a charging stop, and in how many kilometres. + * + * This is the sentence the whole project was described by — *"il faudra recharger une fois dans + * N km"* — and it is arithmetic, not a planner. [chain] walks a route long enough to need the + * sentence twice, and that is all it does: there is deliberately no optimisation here, no + * charging curve, no cost function and no search over which chargers to use. ABRP does that and + * does it better; what the car cannot tell its driver is the one thing this answers. + * + * It plans to a **reserve**, never to zero. A forecast that says the car arrives at 3 % is not + * saying the trip works, it is saying the trip works if nothing goes wrong, and something goes + * wrong. The reserve is a stated number the driver can change rather than an optimism built into + * the arithmetic where nobody can see it. + * + * Uncertainty is the reason this refuses. [SocRate] carries a band, the band grows with + * distance, and past some distance "you will arrive between 5 % and 45 %" is not a plan. The + * refusal is visible; a wide answer rendered as a plan is not. + */ +object ChargeStopPlan { + + /** What a careful driver keeps in the pack. Not a physical limit — a stated choice. */ + const val DEFAULT_RESERVE_PERCENT = 10.0 + + /** + * Wider than this and the plan is refused. Looser than [ArrivalSocForecast.MAX_BAND_PERCENT] + * because a stop decision survives more uncertainty than a printed arrival figure: "you will + * need to charge" holds even when "you will arrive at 12 %" does not. + */ + const val MAX_BAND_PERCENT = 25.0 + + /** + * What a stop is planned to leave with. + * + * Not a full pack: the last fifth of a rapid charge takes as long as the first three, and a + * plan that assumed it would be waited for is a plan about a driver nobody knows. The driver + * declares it (CP-054) and this is only the figure a plan starts from. + */ + const val DEFAULT_DEPARTURE_PERCENT = 80.0 + + /** + * How many driving legs a chain may hold. + * + * A trip needing a fourth stop is a day's drive, and this screen is not the place it should + * be planned. The cap is also what guarantees the walk terminates whatever the arithmetic + * does. + */ + const val MAX_LEGS = 4 + + /** Under this a leg is not a drive, it is the same leg again. */ + const val MIN_LEG_KM = 1.0 + + sealed interface Plan { + /** The route completes with the reserve intact. */ + data class NoStop( + val arrivalPercent: Double, + val marginPercent: Double, + val bandPercent: Double, + ) : Plan + + /** A stop is needed, this far along the route. */ + data class Stop( + val afterKm: Double, + val bandKm: Double, + val shortfallKm: Double, + val bandPercent: Double, + ) : Plan + + /** Nothing worth acting on, and the reason. */ + data class Refused(val reason: Reason) : Plan + } + + /** + * [NO_PROGRESS] and [TOO_MANY_LEGS] are [chain]'s alone: one leg either goes somewhere or the + * chain is not a plan, and a trip past [MAX_LEGS] is one this screen should not answer. + */ + enum class Reason { NO_CHARGE, NO_ROUTE, NO_RATE, BAND_TOO_WIDE, NO_PROGRESS, TOO_MANY_LEGS } + + /** + * One leg of a chained plan: the drive from [startKm] to [endKm] on [startPercent] of charge. + * + * Distances are measured from the origin, not from the start of the leg, because that is + * where everything else on the screen measures from — the charger search, the route geometry + * and the waypoints handed to the car. + * + * A leg whose [plan] is [Plan.Refused] ends where it started: it is the point the trip stops + * being planned, kept in the list so the legs before it still read. + */ + data class Leg( + val startKm: Double, + val endKm: Double, + val startPercent: Double, + val plan: Plan, + ) { + /** True when the leg ends at a charging stop rather than at the destination. */ + val isStop: Boolean get() = plan is Plan.Stop + } + + /** Every leg of one trip, in the order they are driven. */ + data class Chain(val legs: List) { + + /** The legs that end at a charging stop — as many as the trip needs, and never more. */ + val stops: List get() = legs.filter { it.isStop } + + /** The arrival at the destination, or null when the chain never got that far. */ + val arrival: Plan.NoStop? get() = legs.lastOrNull()?.plan as? Plan.NoStop + + /** Why the chain stopped short, or null when it reaches the destination. */ + val refusal: Reason? get() = (legs.lastOrNull()?.plan as? Plan.Refused)?.reason + } + + /** + * @param socPercent charge now. + * @param routeKm the whole remaining route. + * @param rate charge spent per kilometre, with its own uncertainty. + * @param reservePercent the charge the driver refuses to go below. + * @param grade what the route's climb and descent cost, from [RouteGrade], or null when no + * elevation profile came back. Null is not zero: a plan with no profile is exactly the + * plan this made before profiles existed. + */ + fun of( + socPercent: Double?, + routeKm: Double?, + rate: SocRate?, + reservePercent: Double = DEFAULT_RESERVE_PERCENT, + grade: RouteGrade.Cost? = null, + ): Plan { + if (socPercent == null || !socPercent.isFinite()) return Plan.Refused(Reason.NO_CHARGE) + if (routeKm == null || !routeKm.isFinite() || routeKm <= 0.0) { + return Plan.Refused(Reason.NO_ROUTE) + } + if (rate == null || rate.percentPerKm <= 0.0) return Plan.Refused(Reason.NO_RATE) + + return leg( + socPercent, + routeKm, + effectiveRate(rate, routeKm, grade), + reservePercent, + bandOverLeg = false, + ) + } + + /** + * The same trip planned as far as it goes, charging on the way. + * + * [of] answers one question — does this charge reach the far end — and a route long enough to + * need two stops makes it answer it about a car that never stops. Over 680 km the band on + * that non-existent drive is tens of percent wide and the plan is refused, which is honest + * about the wrong trip: whether a stop is needed near Brive is not uncertain at all. + * + * So the route is walked leg by leg. Each leg is planned on the charge held at its start, and + * **each leg carries the band of its own distance** — that, not a looser cap, is what makes a + * long trip plannable. [MAX_BAND_PERCENT] is unchanged; it now measures the drive it was + * always about. + * + * What this is still not: an optimiser. There is no charging curve, no cost function and no + * search over which chargers to use. The charge a stop is left with is [departurePercent], a + * figure the driver declares, because how long they plug in is theirs and modelling it would + * be inventing a number this app cannot measure. + * + * @param departurePercent the charge each stop is planned to leave with. + * @param maxLegs how many driving legs may be planned before the trip is refused as one this + * screen should not be answering. The refusal itself is appended as a further leg. + */ + fun chain( + socPercent: Double?, + routeKm: Double?, + rate: SocRate?, + reservePercent: Double = DEFAULT_RESERVE_PERCENT, + grade: RouteGrade.Cost? = null, + departurePercent: Double = DEFAULT_DEPARTURE_PERCENT, + maxLegs: Int = MAX_LEGS, + ): Chain { + // The refusals that are about the trip rather than about a leg of it. One leg holding + // them keeps the shape of the answer the same whatever went wrong. + if (socPercent == null || !socPercent.isFinite()) return refusal(Reason.NO_CHARGE) + if (routeKm == null || !routeKm.isFinite() || routeKm <= 0.0) { + return refusal(Reason.NO_ROUTE) + } + if (rate == null || rate.percentPerKm <= 0.0) return refusal(Reason.NO_RATE) + + // Folded once, over the whole route, exactly as [of] folds it. A leg that spread a + // whole-route total of climb over its own length would charge the col to every leg. + val totalKm: Double = routeKm + val effective = effectiveRate(rate, totalKm, grade) + val leaveWith = departurePercent.coerceIn(0.0, 100.0) + val legs = ArrayList() + var startKm = 0.0 + var socAtStart: Double = socPercent + while (legs.size < maxLegs) { + val step = leg(socAtStart, totalKm - startKm, effective, reservePercent, true) + if (step !is Plan.Stop) { + // NoStop ends at the destination; a refusal ends where the driver would be + // standing when the plan stopped being one. + val endKm = if (step is Plan.NoStop) totalKm else startKm + legs += Leg(startKm, endKm, socAtStart, step) + return Chain(legs) + } + // A leg that gets nowhere never ends: the charge it leaves with buys no distance, and + // another turn of this loop would plan the same leg again. Said once, as a refusal. + if (step.afterKm < MIN_LEG_KM) { + legs += Leg(startKm, startKm, socAtStart, Plan.Refused(Reason.NO_PROGRESS)) + return Chain(legs) + } + legs += Leg(startKm, startKm + step.afterKm, socAtStart, step) + startKm += step.afterKm + socAtStart = leaveWith + } + legs += Leg(startKm, startKm, socAtStart, Plan.Refused(Reason.TOO_MANY_LEGS)) + return Chain(legs) + } + + /** + * One leg's plan. + * + * @param bandOverLeg where the band is measured. [of] measures it over the whole route, + * because a single-stop plan is a claim about arriving at the far end of it. [chain] + * measures it over the distance this leg actually drives, which is the stop it is about. + */ + private fun leg( + socPercent: Double, + routeKm: Double, + effective: SocRate, + reservePercent: Double, + bandOverLeg: Boolean, + ): Plan { + val spendable = socPercent - reservePercent + val needed = effective.percentPerKm * routeKm + if (spendable >= needed) { + val band = 2.0 * effective.uncertaintyPercentPerKm * routeKm + if (band > MAX_BAND_PERCENT) return Plan.Refused(Reason.BAND_TOO_WIDE) + return Plan.NoStop( + arrivalPercent = socPercent - needed, + marginPercent = spendable - needed, + bandPercent = band, + ) + } + + // Pessimistic on purpose: the distance the car reaches if it spends at the top of the + // band. Being told to charge earlier than strictly necessary is a cost of minutes; + // being told to charge later is a cost of a tow truck. + val worstRate = effective.percentPerKm + effective.uncertaintyPercentPerKm + val reachKm = (spendable / worstRate).coerceAtLeast(0.0) + val band = + 2.0 * effective.uncertaintyPercentPerKm * (if (bandOverLeg) reachKm else routeKm) + if (band > MAX_BAND_PERCENT) return Plan.Refused(Reason.BAND_TOO_WIDE) + val bestRate = + (effective.percentPerKm - effective.uncertaintyPercentPerKm).coerceAtLeast(1e-6) + val optimisticKm = spendable / bestRate + return Plan.Stop( + afterKm = reachKm, + bandKm = (optimisticKm - reachKm).coerceAtLeast(0.0), + shortfallKm = routeKm - reachKm, + bandPercent = band, + ) + } + + private fun refusal(reason: Reason) = + Chain(listOf(Leg(0.0, 0.0, 0.0, Plan.Refused(reason)))) + + /** + * The rate with the climb folded into it, so the rest of the arithmetic never learns that + * grade exists. + * + * The profile is a total and not a position: the col may be at kilometre 10 or at 400 and + * this spreads it evenly over the route either way, which makes a stop before a late col + * slightly early and one after an early col slightly late. Early is the safe direction and + * the error is small next to the rate's own band; the upgrade, when a route needs it, is a + * per-segment profile rather than two cumulative numbers. + * + * Public because [PlanDrift] has to compare a drive against the rate the plan was actually + * made with, and a second copy of this fold would drift away from the one that planned. + */ + fun effectiveRate(rate: SocRate, routeKm: Double, grade: RouteGrade.Cost?): SocRate { + if (grade == null) return rate + return rate.copy( + // Floored, not clamped away: a descent long enough to make the net rate negative + // would otherwise plan a car that gains charge for ever. + percentPerKm = (rate.percentPerKm + grade.percent / routeKm).coerceAtLeast(1e-6), + uncertaintyPercentPerKm = rate.uncertaintyPercentPerKm + + abs(grade.uncertaintyPercent) / routeKm, + ) + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ChargingSessionMeter.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ChargingSessionMeter.kt new file mode 100644 index 0000000..9b0b17c --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ChargingSessionMeter.kt @@ -0,0 +1,62 @@ +package com.evsuite.hardware.telemetry + +/** + * Integrates charge power into energy received during the current charging session. + * + * The meter is intentionally stateful and not thread-safe: one sampling owner must call + * [sample] in timestamp order. Missing samples keep an open session alive without inventing + * energy, and long gaps are skipped. + */ +class ChargingSessionMeter @JvmOverloads constructor( + private val maxSampleGapMs: Long = MAX_SAMPLE_GAP_MS +) { + private var sessionOpen = false + private var lastSampleMs = 0L + private var lastRateKw: Float? = null + private var chargedKwh = 0.0 + + /** + * @param charging true while energy is flowing, false when the session ended, null when + * the state is temporarily unreadable + * @param chargeRateKw positive power entering the pack; negative noise is clamped to zero + * @return session energy in kWh, or null when no charging session is open + */ + fun sample(nowMs: Long, charging: Boolean?, chargeRateKw: Float?): Double? { + if (charging == false) { + sessionOpen = false + lastRateKw = null + return null + } + if (charging == null) { + if (!sessionOpen) return null + lastRateKw = null + lastSampleMs = nowMs + return chargedKwh.rounded() + } + + val rateKw = chargeRateKw?.coerceAtLeast(0f) + if (!sessionOpen) { + sessionOpen = true + chargedKwh = 0.0 + lastSampleMs = nowMs + lastRateKw = rateKw + return 0.0 + } + + val gapMs = nowMs - lastSampleMs + val previousRate = lastRateKw + if (rateKw != null && previousRate != null && gapMs in 1..maxSampleGapMs) { + chargedKwh += (previousRate + rateKw) / 2.0 * (gapMs / MS_PER_HOUR) + } + lastSampleMs = nowMs + lastRateKw = rateKw + return chargedKwh.rounded() + } + + private fun Double.rounded(): Double = kotlin.math.round(this * 100.0) / 100.0 + + companion object { + const val MAX_SAMPLE_GAP_MS = 1_200_000L + private const val MS_PER_HOUR = 3_600_000.0 + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ConsumptionCalculator.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ConsumptionCalculator.kt new file mode 100644 index 0000000..f43422f --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/ConsumptionCalculator.kt @@ -0,0 +1,182 @@ +package com.evsuite.hardware.telemetry + +import kotlin.math.exp + +/** Instantaneous and short-window consumption derived only from measured power and speed. */ +class ConsumptionCalculator( + private val rollingWindowMs: Long = ROLLING_WINDOW_MS, + private val maxWindowSamples: Int = MAX_WINDOW_SAMPLES, +) { + data class Reading( + /** Unfiltered arithmetic, exposed for diagnostics; trip integration never consumes it. */ + val rawInstantaneous: Provenanced, + /** Exponential smoothing used only for the glanceable dashboard number. */ + val smoothedInstantaneous: Provenanced, + /** Consumed energy divided by distance over the bounded recent window. */ + val rollingAverage: Provenanced, + ) + + private data class Sample( + val atMs: Long, + val speedKmh: Double?, + val batteryPowerKw: Double?, + ) + + private val window = ArrayDeque() + private var smoothedValue: Double? = null + private var smoothedAtMs: Long? = null + + init { + require(rollingWindowMs > 0L) { "rolling window must be positive" } + require(maxWindowSamples >= 2) { "rolling window needs at least two samples" } + } + + fun add( + snapshot: EnergySnapshot, + missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT, + ): Reading = add( + timestampMs = snapshot.timestampMs, + batteryPowerKw = snapshot.batteryPowerKw, + speedKmh = snapshot.speedKmh, + missingReason = missingReason, + ) + + fun add( + timestampMs: Long, + batteryPowerKw: Float?, + speedKmh: Float?, + missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT, + ): Reading { + if (window.lastOrNull()?.atMs?.let { timestampMs <= it } == true) reset() + + val raw = instantaneous(batteryPowerKw, speedKmh, missingReason) + val smoothed = smooth(timestampMs, raw) + window.addLast( + Sample( + atMs = timestampMs, + speedKmh = speedKmh?.toDouble()?.takeIf { it.isFinite() && it >= 0.0 }, + batteryPowerKw = batteryPowerKw?.toDouble()?.takeIf(Double::isFinite), + ) + ) + trim(timestampMs) + return Reading(raw, smoothed, rollingAverage(timestampMs)) + } + + fun reset() { + window.clear() + smoothedValue = null + smoothedAtMs = null + } + + private fun smooth(timestampMs: Long, raw: Provenanced): Provenanced { + val current = raw.value ?: run { + smoothedValue = null + smoothedAtMs = null + return Provenanced.unavailable(checkNotNull(raw.reason)) + } + val previous = smoothedValue + val previousAt = smoothedAtMs + val deltaMs = previousAt?.let { timestampMs - it } + val signChanged = previous != null && previous * current < 0.0 + val value = if ( + previous == null || deltaMs == null || deltaMs <= 0L || + deltaMs > MAX_SAMPLE_GAP_MS || signChanged + ) { + current + } else { + val alpha = 1.0 - exp(-deltaMs.toDouble() / SMOOTHING_TIME_CONSTANT_MS) + previous + alpha * (current - previous) + } + smoothedValue = value + smoothedAtMs = timestampMs + return Provenanced.derived(value) + } + + private fun trim(nowMs: Long) { + val cutoff = nowMs - rollingWindowMs + // Keep one predecessor so an interval crossing the window edge can be clipped. + while (window.size > 1 && window.elementAt(1).atMs <= cutoff) window.removeFirst() + while (window.size > maxWindowSamples) window.removeFirst() + } + + private fun rollingAverage(nowMs: Long): Provenanced { + if (window.size < 2) return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + val cutoff = nowMs - rollingWindowMs + var distanceKm = 0.0 + var consumedKwh = 0.0 + var hasSpeedInterval = false + var hasPowerInterval = false + val samples = window.toList() + for (index in 1 until samples.size) { + val first = samples[index - 1] + val second = samples[index] + val fullGapMs = second.atMs - first.atMs + if (fullGapMs <= 0L || fullGapMs > MAX_SAMPLE_GAP_MS) continue + val fromMs = maxOf(first.atMs, cutoff) + val gapMs = second.atMs - fromMs + if (gapMs <= 0L) continue + val fraction = (fromMs - first.atMs).toDouble() / fullGapMs.toDouble() + val hours = gapMs / 3_600_000.0 + + if (first.speedKmh != null && second.speedKmh != null) { + val speedAtEdge = interpolate(first.speedKmh, second.speedKmh, fraction) + distanceKm += ((speedAtEdge + second.speedKmh) / 2.0) * hours + hasSpeedInterval = true + } + if (first.batteryPowerKw != null && second.batteryPowerKw != null) { + val powerAtEdge = interpolate( + first.batteryPowerKw, + second.batteryPowerKw, + fraction, + ) + val intervalKwh = ((powerAtEdge + second.batteryPowerKw) / 2.0) * hours + if (intervalKwh >= 0.0) consumedKwh += intervalKwh + hasPowerInterval = true + } + } + val average = if ( + hasSpeedInterval && hasPowerInterval && distanceKm >= MIN_AVERAGE_DISTANCE_KM + ) { + consumedKwh * 100.0 / distanceKm + } else null + return Provenanced.derived(average) + } + + private fun interpolate(first: Double, second: Double, fraction: Double): Double = + first + (second - first) * fraction + + companion object { + /** Below 5 km/h, power / speed diverges and stationary accessory use has no distance. */ + const val LOW_SPEED_FLOOR_KMH = 5.0 + + /** Five-second EMA time constant: stable at a glance without hiding sustained changes. */ + const val SMOOTHING_TIME_CONSTANT_MS = 5_000.0 + + /** Two recent minutes describe current driving without growing with trip duration. */ + const val ROLLING_WINDOW_MS = 120_000L + + /** Extra hard bound if a caller samples faster than the dashboard's expected 1 Hz. */ + const val MAX_WINDOW_SAMPLES = 256 + + /** Matches the trip accumulator: longer gaps represent motion the app did not observe. */ + const val MAX_SAMPLE_GAP_MS = 5_000L + + /** Avoids presenting a ratio dominated by rounding after only a few metres. */ + const val MIN_AVERAGE_DISTANCE_KM = 0.1 + + fun instantaneous( + batteryPowerKw: Float?, + speedKmh: Float?, + missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT, + ): Provenanced { + val power = batteryPowerKw?.toDouble()?.takeIf(Double::isFinite) + ?: return Provenanced.unavailable(missingReason) + val speed = speedKmh?.toDouble()?.takeIf(Double::isFinite) + ?: return Provenanced.unavailable(missingReason) + if (speed < LOW_SPEED_FLOOR_KMH) { + return Provenanced.unavailable(UnavailableReason.SPEED_TOO_LOW) + } + return Provenanced.derived(power * 100.0 / speed) + } + } +} 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 new file mode 100644 index 0000000..54eae63 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergySnapshot.kt @@ -0,0 +1,70 @@ +package com.evsuite.hardware.telemetry + +import com.evsuite.hardware.FirmwareInfo + +/** One immutable, nullable, read-only view of the vehicle's energy state. */ +data class EnergySnapshot( + val timestampMs: Long, + val firmware: FirmwareInfo.Gen, + val socPercent: Float?, + val rangeKm: Float?, + val speedKmh: Float?, + /** Positive means energy leaves the battery; negative means charging or regeneration. */ + val batteryPowerKw: Float?, + val outsideTempCelsius: Float?, + val cabinTempCelsius: Float?, + val batteryTempCelsius: Float?, + val batteryEnergyKwh: Float?, + val batteryCapacityKwh: Float?, + val odometerKm: Float?, + val chargePortConnected: Boolean?, + val chargingStatus: Int?, + /** + * The vehicle's own kWh counters since the end of the last charge, when it keeps any. + * + * Defaulted rather than required: they arrived with the battery ledger, every existing + * caller builds this snapshot positionally, and a counter this car may not keep must not + * become a parameter every one of them has to pass as null. + */ + val vehicleConsumedKwh: Float? = null, + val vehicleRegeneratedKwh: Float? = null, + val parked: Boolean?, + val climate: ClimateSnapshot, + val tirePressures: TirePressureSnapshot, +) { + val hasVehicleData: Boolean + get() = socPercent != null || rangeKm != null || speedKmh != null || + batteryPowerKw != null || outsideTempCelsius != null || cabinTempCelsius != null || + batteryTempCelsius != null || batteryEnergyKwh != null || batteryCapacityKwh != null || + odometerKm != null || chargePortConnected != null || chargingStatus != null || + vehicleConsumedKwh != null || vehicleRegeneratedKwh != null || + parked != null || climate.hasData || tirePressures.hasData +} + +data class TirePressureSnapshot( + val frontLeftKpa: Float?, + val frontRightKpa: Float?, + val rearLeftKpa: Float?, + val rearRightKpa: Float?, +) { + val hasData: Boolean + get() = frontLeftKpa != null || frontRightKpa != null || + rearLeftKpa != null || rearRightKpa != null +} + +data class ClimateSnapshot( + val powerOn: Boolean?, + val acOn: Boolean?, + val autoOn: Boolean?, + val econOn: Boolean?, + val recirculationOn: Boolean?, + val fanLevel: Int?, + val fanLevelMax: Int?, + val driverTargetCelsius: Float?, + val passengerTargetCelsius: Float?, +) { + val hasData: Boolean + get() = powerOn != null || acOn != null || autoOn != null || econOn != null || + recirculationOn != null || fanLevel != null || fanLevelMax != null || + driverTargetCelsius != null || passengerTargetCelsius != null +} 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 new file mode 100644 index 0000000..92c9b12 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTelemetryReader.kt @@ -0,0 +1,134 @@ +package com.evsuite.hardware.telemetry + +import android.content.Context +import com.evsuite.hardware.EVHardware +import com.evsuite.hardware.FirmwareInfo +import com.evsuite.hardware.saic.SaicCharging +import com.evsuite.hardware.saic.SaicClimate +import com.evsuite.hardware.saic.SaicHub + +/** Injectable signal seam: production uses [EvHardwareEnergySignalSource], tests use a fake. */ +interface EnergySignalSource { + fun firmware(): FirmwareInfo.Gen + fun socPercent(): Float? + fun rangeKm(): Float? + fun speedKmh(): Float? + fun batteryPowerKw(): Float? + fun outsideTempCelsius(): Float? + fun cabinTempCelsius(): Float? + fun batteryTempCelsius(): Float? + fun batteryEnergyKwh(): Float? + fun batteryCapacityKwh(): Float? + fun odometerKm(): Float? + fun chargePortConnected(): Boolean? + fun chargingStatus(): Int? + /** The vehicle's own counters since its last charge; null where the car keeps none. */ + fun vehicleConsumedKwh(): Float? = null + fun vehicleRegeneratedKwh(): Float? = null + fun parked(): Boolean? + fun climate(): ClimateSnapshot + fun tirePressures(): TirePressureSnapshot + /** Probe-only values excluded from [EnergySnapshot] and normal consumers. */ + fun evidenceProbes(): List = emptyList() +} + +class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource { + init { + val appContext = context.applicationContext + EVHardware.init(appContext) + SaicHub.connect(appContext) + } + + override fun firmware() = FirmwareInfo.getGeneration() + override fun socPercent() = + SaicCharging.stateOfChargePercent() ?: EVHardware.getVendorBatterySocPercent() + override fun rangeKm() = + SaicCharging.rangeKm()?.toFloat() + ?: EVHardware.getStandardRangeKm() + ?: EVHardware.getVendorRangeKm()?.toFloat() + override fun speedKmh() = EVHardware.getVehicleSpeedKmh() + // The standard property first, the vendor pack pair second: SWI68 answers no + // EV_INSTANTANEOUS_CHARGE_RATE but does publish the voltage and the current it is made of. + override fun batteryPowerKw() = + EVHardware.getBatteryPowerKw() ?: EVHardware.getVendorBatteryPowerKw() + override fun outsideTempCelsius() = + SaicClimate.outsideTempCelsius() ?: EVHardware.getOutsideTempCelsius() + override fun cabinTempCelsius() = EVHardware.getCabinTemperatureCelsius() + override fun batteryTempCelsius() = EVHardware.getBatteryTemperatureCelsius() + override fun batteryEnergyKwh() = EVHardware.getBatteryEnergyKwh() + override fun batteryCapacityKwh() = EVHardware.getBatteryCapacityKwh() + override fun odometerKm() = EVHardware.getOdometerKm() + override fun chargePortConnected() = EVHardware.isChargePortConnected() + // The charging service first, the vendor property second: it answers on units where + // the service does not bind. + override fun chargingStatus() = + SaicCharging.chargingStatus() ?: EVHardware.getVendorChargeStatus() + override fun vehicleConsumedKwh() = SaicCharging.consumedKwhSinceCharge() + override fun vehicleRegeneratedKwh() = SaicCharging.regeneratedKwhSinceCharge() + override fun parked() = EVHardware.isVehicleInPark() + override fun tirePressures() = TirePressureSnapshot( + frontLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_LEFT), + frontRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_RIGHT), + rearLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.REAR_LEFT), + rearRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.REAR_RIGHT), + ) + override fun evidenceProbes() = listOf( + TelemetryEvidenceProbe( + TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH, + EVHardware.probeCurrentBatteryCapacityWh()?.toDouble(), + ) + ) + override fun climate() = ClimateSnapshot( + powerOn = SaicClimate.powerOn(), + acOn = SaicClimate.acOn(), + autoOn = SaicClimate.autoOn(), + econOn = SaicClimate.econOn(), + recirculationOn = SaicClimate.recirculationOn(), + fanLevel = SaicClimate.fanLevel(), + fanLevelMax = SaicClimate.fanLevelMax(), + driverTargetCelsius = SaicClimate.driverTemp()?.toFloat() + ?: 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(), + ) +} + +/** Builds a coherent snapshot from one shared source; every unavailable signal remains null. */ +class EnergyTelemetryReader(private val source: EnergySignalSource) { + constructor(context: Context) : this(EvHardwareEnergySignalSource(context)) + + fun read(nowMs: Long = System.currentTimeMillis()) = EnergySnapshot( + timestampMs = nowMs, + firmware = source.firmware(), + socPercent = source.socPercent()?.takeIf { it.isFinite() && it in 0f..100f }, + rangeKm = source.rangeKm()?.takeIf { it.isFinite() && it >= 0f }, + speedKmh = source.speedKmh()?.takeIf { it.isFinite() && it >= 0f }, + batteryPowerKw = source.batteryPowerKw()?.takeIf { it.isFinite() }, + outsideTempCelsius = source.outsideTempCelsius()?.takeIf { it.isFinite() }, + cabinTempCelsius = source.cabinTempCelsius()?.takeIf { it.isFinite() }, + batteryTempCelsius = source.batteryTempCelsius()?.takeIf { it.isFinite() }, + batteryEnergyKwh = source.batteryEnergyKwh()?.takeIf { it.isFinite() && it >= 0f }, + batteryCapacityKwh = source.batteryCapacityKwh()?.takeIf { it.isFinite() && it > 0f }, + odometerKm = source.odometerKm()?.takeIf { it.isFinite() && it >= 0f }, + chargePortConnected = source.chargePortConnected(), + chargingStatus = source.chargingStatus(), + vehicleConsumedKwh = source.vehicleConsumedKwh()?.takeIf { it.isFinite() && it >= 0f }, + vehicleRegeneratedKwh = source.vehicleRegeneratedKwh()?.takeIf { it.isFinite() && it >= 0f }, + parked = source.parked(), + climate = source.climate(), + tirePressures = source.tirePressures(), + ) + + /** Reads CP-004 candidates only when an unstable evidence capture explicitly requests them. */ + fun readEvidence(nowMs: Long = System.currentTimeMillis()) = TelemetryEvidenceSample( + snapshot = read(nowMs), + probes = source.evidenceProbes().map { probe -> + probe.copy(value = probe.value?.takeIf { it.isFinite() }) + }, + ) +} 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 new file mode 100644 index 0000000..ea96b6a --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTrip.kt @@ -0,0 +1,142 @@ +package com.evsuite.hardware.telemetry + +import com.evsuite.hardware.BatteryPowerEvidence +import com.evsuite.hardware.CarPropertyEvidence +import com.evsuite.hardware.VehicleSpeedEvidence + +data class EnergyTripSummary( + val startedAtMs: Long, + val endedAtMs: Long, + /** + * Time actually covered by usable samples, not wall clock. A trip whose sampler was + * suspended — the dashboard went to the background, the process was paused — keeps a + * duration consistent with its distance and energy, which is what the averages divide. + */ + val durationMs: Long, + val distanceKm: Double, + val startSocPercent: Float?, + val endSocPercent: Float?, + val consumedKwh: Double?, + val regeneratedKwh: Double?, + /** + * `null` means a pre-field record whose numeric distance remains authoritative for + * compatibility. New accumulators write false until at least one usable speed interval + * exists, so an unreadable signal can no longer masquerade as 0 km. + */ + val distanceAvailable: Boolean? = null, + /** Null for legacy or unvalidated records; models must not reuse their energy totals. */ + val batteryPowerEvidence: BatteryPowerEvidence? = null, + /** + * Which speed conversion produced [distanceKm]. Null on records written before the + * conversion was versioned — their distance may carry the 3.6x error and no model may + * train on it. See [VehicleSpeedEvidence]. + */ + val speedEvidence: VehicleSpeedEvidence? = null, +) { + /** + * The distance, only when the conversion behind it is the one believed correct today. + * + * `recordedDistanceKm` stays what the trip reported, because history is what it is and a + * driver's own record should not silently change. This is the one a model may use. + */ + fun modellableDistanceKm( + generation: com.evsuite.hardware.FirmwareInfo.Gen = + com.evsuite.hardware.FirmwareInfo.getGeneration(), + ): Double? = recordedDistanceKm?.takeIf { speedEvidence?.matchesCurrent(generation) == true } + + val recordedDistanceKm: Double? + get() = distanceKm.takeIf { distanceAvailable != false } + + val averageConsumptionKwhPer100Km: Double? + get() = if (consumedKwh != null && recordedDistanceKm != null && distanceKm >= 0.1) { + consumedKwh * 100.0 / distanceKm + } else null +} + +/** A finished trip: its totals, and the track they were integrated from. */ +data class RecordedTrip( + val summary: EnergyTripSummary, + val samples: List, +) + +/** Trapezoidal integration with bounded gaps so sleep/resume never invents energy. */ +class EnergyTripAccumulator( + private val startedAtMs: Long, + private val startSocPercent: Float?, + private val track: TripSampleTrack = TripSampleTrack(), + private val batteryPowerEvidence: BatteryPowerEvidence? = null, + private val speedEvidence: VehicleSpeedEvidence? = VehicleSpeedEvidence.current(), +) { + private var last: EnergySnapshot? = null + private var distanceKm = 0.0 + private var consumedKwh = 0.0 + private var regeneratedKwh = 0.0 + private var hasSpeedInterval = false + private var hasPowerInterval = false + private var recordedMs = 0L + private var latestSocPercent = startSocPercent + + fun add(sample: EnergySnapshot) { + track.add(sample) + latestSocPercent = sample.socPercent ?: latestSocPercent + val previous = last + last = sample + if (previous == null) return + val gapMs = sample.timestampMs - previous.timestampMs + if (gapMs <= 0L || gapMs > MAX_SAMPLE_GAP_MS) return + recordedMs += gapMs + val hours = gapMs / 3_600_000.0 + if (previous.speedKmh != null && sample.speedKmh != null) { + hasSpeedInterval = true + distanceKm += ((previous.speedKmh + sample.speedKmh) / 2.0) * hours + } + if (previous.batteryPowerKw != null && sample.batteryPowerKw != null) { + hasPowerInterval = true + val intervalKwh = + ((previous.batteryPowerKw + sample.batteryPowerKw) / 2.0) * hours + if (intervalKwh >= 0.0) consumedKwh += intervalKwh + else regeneratedKwh += -intervalKwh + } + } + + fun samples(): List = track.samples() + + fun snapshot(nowMs: Long) = EnergyTripSummary( + startedAtMs = startedAtMs, + endedAtMs = nowMs, + durationMs = recordedMs, + distanceKm = distanceKm, + startSocPercent = startSocPercent, + endSocPercent = latestSocPercent, + consumedKwh = consumedKwh.takeIf { hasPowerInterval }, + regeneratedKwh = regeneratedKwh.takeIf { hasPowerInterval }, + distanceAvailable = hasSpeedInterval, + speedEvidence = speedEvidence.takeIf { hasSpeedInterval }, + batteryPowerEvidence = batteryPowerEvidence.takeIf { hasPowerInterval }, + ) + + private companion object { const val MAX_SAMPLE_GAP_MS = 5_000L } +} + +/** Process-local recording state shared by any energy dashboard in the same app process. */ +object EnergyTripSession { + private var accumulator: EnergyTripAccumulator? = null + val isRecording: Boolean get() = accumulator != null + + @Synchronized fun start(sample: EnergySnapshot) { + if (accumulator != null) return + accumulator = EnergyTripAccumulator( + sample.timestampMs, + sample.socPercent, + batteryPowerEvidence = CarPropertyEvidence.powerModelEvidence(sample.firmware), + ) + .also { it.add(sample) } + } + @Synchronized fun add(sample: EnergySnapshot) = accumulator?.add(sample) + @Synchronized fun current(nowMs: Long) = accumulator?.snapshot(nowMs) + @Synchronized fun stop(nowMs: Long): RecordedTrip? { + val finished = accumulator ?: return null + accumulator = null + return RecordedTrip(finished.snapshot(nowMs), finished.samples()) + } +} 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 new file mode 100644 index 0000000..5d6f17b --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTripHistoryStore.kt @@ -0,0 +1,183 @@ +package com.evsuite.hardware.telemetry + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.io.FileOutputStream + +/** A stored trip: always its totals, and its track for as long as there is room for it. */ +data class StoredTrip( + val summary: EnergyTripSummary, + val samples: List? = null, +) + +/** + * The history file's envelope. + * + * The first version of this file was a bare JSON array of summaries, with no room to say what + * it was. Trips now carry a sample track, and the track will change again as models arrive, so + * the file states its own version and a reader can decide whether it understands it. + */ +data class TripHistoryFile( + val schemaVersion: Int = SCHEMA_VERSION, + val trips: List, +) { + companion object { + const val SCHEMA_VERSION = 2 + } +} + +/** + * Bounded app-private history, replaced atomically after every successful write. + * + * Three bounds apply, in the order that keeps the most meaning per byte: + * • at most [maxTrips] trips, newest first; + * • at most [maxBytes] on disk, reached by dropping sample tracks oldest-first — a trip + * keeps its totals even when its track is evicted, because the totals are what the driver + * reads and the track is what a model would like; + * • only then, if a single trip still does not fit, by dropping the oldest trips. + * + * A file this build cannot read is quarantined rather than deleted. It is the only copy of + * whatever it holds, and a future build — or a person — may be able to read it. + */ +class EnergyTripHistoryStore( + private val target: File, + private val maxTrips: Int = 200, + private val maxBytes: Int = 512 * 1024, + private val gson: Gson = Gson(), +) { + /** Trips with whatever tracks survived eviction, newest first. */ + fun read(): List { + if (!target.exists()) return emptyList() + val text = runCatching { target.readText() }.getOrNull() ?: return emptyList() + return parse(text) ?: run { + quarantine() + emptyList() + } + } + + fun readSummaries(): List = read().map { it.summary } + + fun append(summary: EnergyTripSummary, samples: List = emptyList()): Boolean { + val entry = StoredTrip(summary, samples.takeIf { it.isNotEmpty() }) + val updated = bound(listOf(entry) + read()) + return write(TripHistoryFile(trips = updated)) + } + + /** Removes exactly one trip identified by its recording start, preserving every other entry. */ + fun deleteTrip(startedAtMs: Long): Boolean { + val trips = read() + val index = trips.indexOfFirst { it.summary.startedAtMs == startedAtMs } + if (index < 0) return false + val updated = trips.toMutableList().also { it.removeAt(index) } + return write(TripHistoryFile(trips = updated)) + } + + /** Atomically replaces the history with a valid empty v2 envelope. */ + fun clear(): Boolean = write(TripHistoryFile(trips = emptyList())) + + /** + * Reads both shapes: the v2 envelope, and the v1 bare array that shipped before it. + * + * Returns null when the text is neither — a truncated write, or a file from a build that + * knows something this one does not. The caller quarantines it; nothing is deleted, and + * nothing is guessed at. + */ + @Suppress("SENSELESS_COMPARISON") + private fun parse(text: String): List? { + if (text.trimStart().startsWith("[")) return parseLegacy(text) + val envelope = runCatching { gson.fromJson(text, HistoryEnvelope::class.java) } + .getOrNull() ?: return null + if (envelope.schemaVersion != TripHistoryFile.SCHEMA_VERSION) return null + // Gson writes straight through Kotlin's nullability, so a truncated or hand-edited file + // can hold a trip with no summary at all. The comparison is not senseless here. + return envelope.trips.orEmpty().filter { it != null && it.summary != null } + } + + /** The shape that shipped first: a bare array of summaries, with no envelope and no track. */ + private fun parseLegacy(text: String): List? = runCatching { + val type = object : TypeToken>() {}.type + gson.fromJson>(text, type)?.map { StoredTrip(it) } + }.getOrNull() + + private data class HistoryEnvelope( + val schemaVersion: Int = 0, + val trips: List? = null, + ) + + /** + * The unreadable file keeps its contents under a new name; the history starts again. + * + * The original has to go, by rename or by copy-then-delete. Leaving it in place made every + * later read fail to parse it again and write another quarantine copy — one file per read, + * without a bound, in storage the driver cannot see. When even the copy fails the partial + * is removed rather than left behind: nothing was preserved, so nothing should accumulate. + */ + private fun quarantine() { + val kept = File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}") + if (target.renameTo(kept)) return + if (runCatching { target.copyTo(kept, overwrite = true) }.isSuccess) target.delete() + else kept.delete() + } + + /** + * Applies the three bounds, costing one serialisation per trip rather than one per step. + * + * The envelope is `{...,"trips":[t1,t2,...]}`, so its size is the empty envelope plus every + * trip's own JSON plus one comma between each pair. Keeping that arithmetic lets eviction + * subtract as it goes. Measuring the whole file again after every dropped track re-encoded + * a graph of up to two hundred trips and four thousand samples each, once per step, on the + * path that ends a drive. + */ + private fun bound(trips: List): List { + val candidate = trips.take(maxTrips.coerceAtLeast(1)).toMutableList() + val sizes = candidate.mapTo(ArrayList()) { sizeOf(it) } + var total = totalOf(sizes) + + // Tracks first: a trip without its track is still a trip, a missing trip is not. + while (total > maxBytes) { + val oldest = candidate.indexOfLast { it.samples != null } + if (oldest < 0) break + val stripped = candidate[oldest].copy(samples = null) + candidate[oldest] = stripped + sizes[oldest] = sizeOf(stripped) + total = totalOf(sizes) + } + while (total > maxBytes && candidate.size > 1) { + candidate.removeAt(candidate.lastIndex) + sizes.removeAt(sizes.lastIndex) + total = totalOf(sizes) + } + return candidate + } + + /** Empty envelope, plus every trip, plus the comma between each pair. */ + private fun totalOf(sizes: List): Int = + emptyEnvelopeBytes + sizes.sum() + (sizes.size - 1).coerceAtLeast(0) + + private val emptyEnvelopeBytes: Int by lazy { + gson.toJson(TripHistoryFile(trips = emptyList())).toByteArray(Charsets.UTF_8).size + } + + private fun sizeOf(trip: StoredTrip): Int = + gson.toJson(trip).toByteArray(Charsets.UTF_8).size + + private fun write(file: TripHistoryFile): Boolean { + val bytes = gson.toJson(file).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 + } + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/PlanDrift.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/PlanDrift.kt new file mode 100644 index 0000000..c72541d --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/PlanDrift.kt @@ -0,0 +1,186 @@ +package com.evsuite.hardware.telemetry + +/** + * Whether the plan the driver chose at the kerb is still the plan they are driving. + * + * A plan is a prediction, and predictions go wrong on the road: a headwind, a cold pack, traffic, + * a driver going faster than the row they picked. The moment worth a line is the one where the + * charge left on arrival has fallen under the reserve while there is still a charger behind the + * car rather than ahead of it, and nobody notices that by watching a percentage move. + * + * **This costs nothing.** Charge and the odometer are already being read once a second, and the + * plan was written down when it was chosen. There is no request here, no quota and no network — + * the whole of it is a division. Only a *re-proposal* costs a request, and that happens on a tap + * somewhere else. + * + * **The alarm sits outside the model's own band, never on a point.** The rate a drive is actually + * spending is measured through two quantised instruments — a gauge that steps a tenth of a + * percent and an odometer that answers whole kilometres — so it arrives with a band of its own, and the plan + * arrived with one too. Drift is the two bands failing to overlap. Anything narrower would fire on + * a hill. + * + * **A drift that costs nothing is not reported.** Driving faster than the row that was chosen is + * the driver's business, and a companion that says so while still arriving at 30 % is a companion + * that gets ignored by the time it matters. So [Verdict.Short] needs both halves: the spending + * left the plan's band *and* the arrival no longer clears the reserve. + */ +object PlanDrift { + + /** + * Below this the instruments' own steps dominate whatever they measured. + * + * The odometer sets this, not the gauge: at 15 km its whole kilometre is already 0,015 %/km + * of band at this car's rate, and under that the measurement says less than the plan it would + * be contradicting. + */ + const val MIN_DISTANCE_KM = 15.0 + + /** + * The finest step the charge gauge publishes. Two readings differ by at most this. + * + * Measured, not assumed: the 2026-09-05 drive on SWI68 recorded 457 samples stepping between + * 53,9 % and 52,9 % in tenths, refreshed every 25 s or so. A whole percent — the figure this + * started with — would have widened every band tenfold and made the companion deaf. + */ + const val SOC_RESOLUTION_PERCENT = 0.1 + + /** + * A rise past this is a charging session rather than a long descent. + * + * Deliberately not [SOC_RESOLUTION_PERCENT]: quantisation and regeneration are different + * quantities, and sharing one constant would make every descent look like a charge. Regen down + * a col gives back single-figure percent on this pack; the shortest plausible plug-in gives + * back far more, so anything between the two separates them. + * + * Wrong low, a long descent silences the line until the gauge falls back under the departure + * figure. Wrong high, a real mid-drive charge leaves the departure figure anchoring nothing. + * The first failure is quiet and the second is not, which is why this sits nearer the descent. + */ + const val MAX_REGEN_RECOVERY_PERCENT = 5.0 + + /** The adapter's odometer answers whole kilometres, so a difference of them carries one. */ + const val ODOMETER_RESOLUTION_KM = 1.0 + + /** + * The plan as it was handed to the car, frozen at the moment the driver chose it. + * + * @param legKm the leg being driven — to the charging stop where the plan has one, and to the + * destination where it does not. Never the whole route past a stop: arriving under the + * reserve at a destination the plan says to charge before is not drift, it is the plan. + * @param plannedRatePercentPerKm the rate the plan was made with, climb already folded in by + * [ChargeStopPlan.effectiveRate]. + * @param reservePercent the charge the driver refuses to go below. + */ + data class Followed( + val legKm: Double, + val socAtDeparturePercent: Double, + val plannedRatePercentPerKm: Double, + val plannedUncertaintyPercentPerKm: Double, + val reservePercent: Double, + ) + + /** Why there is nothing to say, kept apart because the reasons read differently. */ + enum class Reason { + /** Nothing was chosen, or it was forgotten. */ + NO_PLAN, + + /** The gauge is unreadable. */ + NO_CHARGE, + + /** Nothing can say how far this car has come since the plan. */ + NO_DISTANCE, + + /** Too little road behind the car for the measurement to mean anything. */ + TOO_SOON, + + /** The car has more charge than it left with; the departure figure no longer anchors. */ + CHARGED_EN_ROUTE, + + /** The leg is behind the car. */ + ARRIVED, + } + + /** What the drive is actually doing, whatever the verdict on it. */ + data class Reading( + val drivenKm: Double, + val remainingKm: Double, + /** Charge per kilometre this drive has actually spent. */ + val observedPercentPerKm: Double, + /** Charge left at the end of the leg if the rest is spent like the part behind. */ + val arrivalPercent: Double, + /** Half-width on [arrivalPercent]. */ + val bandPercent: Double, + ) + + sealed interface Verdict { + /** The plan is still true: the spending is inside its band, or it costs the arrival nothing. */ + data class Holding(val reading: Reading) : Verdict + + /** + * The plan stopped being true. + * + * @param shortfallPercent how far under the reserve the pessimistic edge of the arrival + * falls. Always positive, and it is what a slower speed has to give back. + */ + data class Short(val reading: Reading, val shortfallPercent: Double) : Verdict + + data class Unavailable(val reason: Reason) : Verdict + } + + /** + * @param drivenKm road covered since [plan] was chosen — an odometer difference, not a trip. + * @param remainingKm the car's own remaining distance where the head unit is guiding on this + * leg, which is the better number because it knows about the road actually taken. Null + * falls back to the leg's own arithmetic. + */ + fun check( + plan: Followed?, + drivenKm: Double?, + socNowPercent: Double?, + remainingKm: Double? = null, + ): Verdict { + if (plan == null || !plan.legKm.isFinite() || plan.legKm <= 0.0) { + return Verdict.Unavailable(Reason.NO_PLAN) + } + if (plan.plannedRatePercentPerKm <= 0.0) return Verdict.Unavailable(Reason.NO_PLAN) + if (socNowPercent == null || !socNowPercent.isFinite() || socNowPercent !in 0.0..100.0) { + return Verdict.Unavailable(Reason.NO_CHARGE) + } + if (drivenKm == null || !drivenKm.isFinite() || drivenKm < 0.0) { + return Verdict.Unavailable(Reason.NO_DISTANCE) + } + if (drivenKm < MIN_DISTANCE_KM) return Verdict.Unavailable(Reason.TOO_SOON) + + val spent = plan.socAtDeparturePercent - socNowPercent + // A gauge that rose down a long descent is regeneration, not a charging session. Only a + // rise bigger than a descent can give back means the car was plugged in and the departure + // figure stopped anchoring anything. + if (spent < -MAX_REGEN_RECOVERY_PERCENT) { + return Verdict.Unavailable(Reason.CHARGED_EN_ROUTE) + } + + val remaining = remainingKm?.takeIf { it.isFinite() } ?: (plan.legKm - drivenKm) + if (remaining <= 0.0) return Verdict.Unavailable(Reason.ARRIVED) + + val observed = (spent / drivenKm).coerceAtLeast(0.0) + // Both instruments quantise, and both errors land on the same division. Added rather + // than combined in quadrature: they are two systematic steps, not two noise sources, + // and the wide reading is the one that refuses to raise an alarm it cannot support. + val observedBand = + (SOC_RESOLUTION_PERCENT + observed * ODOMETER_RESOLUTION_KM) / drivenKm + + val arrival = socNowPercent - observed * remaining + val band = observedBand * remaining + val reading = Reading(drivenKm, remaining, observed, arrival, band) + + val outsideBand = + observed - plan.plannedRatePercentPerKm > + plan.plannedUncertaintyPercentPerKm + observedBand + val shortfall = plan.reservePercent - (arrival - band) + return if (outsideBand && shortfall > 0.0) { + Verdict.Short(reading, shortfall) + } else { + Verdict.Holding(reading) + } + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/Provenance.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/Provenance.kt new file mode 100644 index 0000000..6abe63f --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/Provenance.kt @@ -0,0 +1,177 @@ +package com.evsuite.hardware.telemetry + +/** + * What a number on an energy screen actually is. + * + * A dashboard that shows a vehicle's state of charge next to a model's opinion of the + * remaining range is showing two different kinds of claim in the same typeface. The MG4 + * publishes no per-consumer energy counters, so anything past traction totals — how much + * the climate system took, what a slower motorway speed would have saved, the state of + * charge on arrival — can only ever be inferred. Those figures are worth showing, and they + * are not measurements. + * + * The distinction is a type rather than a convention because conventions are kept by + * whoever wrote the line last. A screen renders a [Provenanced] value, and the class it + * carries decides how it is drawn. + */ +enum class Provenance { + /** Read from the vehicle, with the VHAL standing behind it (see `CarPropertyEvidence`). */ + MEASURED, + + /** Arithmetic on measured values only — kWh/100 km from power and speed, and nothing else. */ + DERIVED, + + /** A model's output. Always carries an uncertainty band; never rendered like a measurement. */ + ESTIMATED, + + /** No value, and a reason why. Rendered as an em dash, never as zero. */ + UNAVAILABLE, +} + +/** Why a value is missing. The screen says which of these it is instead of only "—". */ +enum class UnavailableReason { + /** The firmware generation does not offer this signal at all. */ + UNSUPPORTED_FIRMWARE, + + /** A read path exists, but no on-vehicle evidence confirms its unit, sign or cadence yet. */ + UNVALIDATED_FIRMWARE, + + /** Declared by the VHAL and not published right now: an unavailable status, or a null read. */ + SIGNAL_ABSENT, + + /** A derived or estimated value whose inputs have not accumulated enough samples. */ + INSUFFICIENT_SAMPLES, + + /** A speed-normalised value is undefined or unstable below its documented speed floor. */ + SPEED_TOO_LOW, + + /** An estimator with no fit to predict from. */ + MODEL_NOT_TRAINED, +} + +/** + * A value, what kind of claim it is, and — for an estimate — how wrong it might be. + * + * Construction is validated rather than trusted: an [Provenance.ESTIMATED] value cannot be + * built without a band, and an [Provenance.UNAVAILABLE] one cannot carry a number. That is + * the whole point of the class. The constructor enforces those rules, so it is safe to call + * directly, but the factories below read better at the call site and pick the right reason + * when a signal turns out to be missing. + * + * @param uncertainty half-width of the band, in the value's own unit: 14.2 ± 1.6 kWh/100 km. + */ +data class Provenanced( + val value: T?, + val provenance: Provenance, + val uncertainty: Double? = null, + val reason: UnavailableReason? = null, +) { + init { + when (provenance) { + Provenance.UNAVAILABLE -> { + require(value == null) { "an unavailable value carries no number" } + require(reason != null) { "an unavailable value must say why" } + require(uncertainty == null) { "an unavailable value has no band" } + } + Provenance.ESTIMATED -> { + require(value != null) { "an estimate with no value is unavailable instead" } + require(uncertainty != null) { "an estimate must carry its uncertainty band" } + require(uncertainty.isFinite() && uncertainty >= 0.0) { + "an uncertainty band is finite and non-negative, was $uncertainty" + } + require(reason == null) { "an available value has no unavailability reason" } + } + Provenance.MEASURED, Provenance.DERIVED -> { + require(value != null) { "a missing reading is unavailable instead" } + require(uncertainty == null) { + "only an estimate carries a band; the vehicle publishes no error bars" + } + require(reason == null) { "an available value has no unavailability reason" } + } + } + } + + val isAvailable: Boolean get() = provenance != Provenance.UNAVAILABLE + + /** The band's lower and upper edge, or null when there is no band. */ + val bandLow: Double? get() = boundsOrNull()?.first + val bandHigh: Double? get() = boundsOrNull()?.second + + private fun boundsOrNull(): Pair? { + val centre = (value as? Number)?.toDouble() ?: return null + val half = uncertainty ?: return null + return centre - half to centre + half + } + + companion object { + /** A vehicle reading. A null becomes unavailable for [absent], never a zero. */ + fun measured( + value: T?, + absent: UnavailableReason = UnavailableReason.SIGNAL_ABSENT, + ): Provenanced = + if (value == null) unavailable(absent) + else Provenanced(value, Provenance.MEASURED) + + /** Arithmetic on measured inputs. Prefer the [derive] overloads, which propagate gaps. */ + fun derived( + value: T?, + absent: UnavailableReason = UnavailableReason.INSUFFICIENT_SAMPLES, + ): Provenanced = + if (value == null) unavailable(absent) + else Provenanced(value, Provenance.DERIVED) + + /** A model output. The band is not optional. */ + fun estimated( + value: T?, + uncertainty: Double, + absent: UnavailableReason = UnavailableReason.MODEL_NOT_TRAINED, + ): Provenanced = + if (value == null) unavailable(absent) + else Provenanced(value, Provenance.ESTIMATED, uncertainty = uncertainty) + + fun unavailable(reason: UnavailableReason): Provenanced = + Provenanced(null, Provenance.UNAVAILABLE, reason = reason) + + /** + * Derives from one measured input, carrying its gap forward. + * + * A derivation over a missing input is not a smaller number, it is no number: the + * consumption of a car whose speed is unreadable is unknown, not zero. The reason + * travels with the gap so the screen can still explain itself. + */ + fun derive( + a: Provenanced, + transform: (A) -> R?, + ): Provenanced { + requireNotEstimated(a) + val value = a.value ?: return unavailable(a.reasonOrAbsent()) + return derived(transform(value)) + } + + /** Derives from two inputs; the first unavailable one decides the reason. */ + fun derive( + a: Provenanced, + b: Provenanced, + transform: (A, B) -> R?, + ): Provenanced { + requireNotEstimated(a) + requireNotEstimated(b) + val first = a.value ?: return unavailable(a.reasonOrAbsent()) + val second = b.value ?: return unavailable(b.reasonOrAbsent()) + return derived(transform(first, second)) + } + + /** + * A derivation whose input is itself an estimate is an estimate, and its band has to + * be computed by whoever knows the model — not silently dropped here. + */ + private fun requireNotEstimated(input: Provenanced<*>) { + require(input.provenance != Provenance.ESTIMATED) { + "deriving from an estimate loses its band; propagate the band explicitly" + } + } + + private fun Provenanced<*>.reasonOrAbsent(): UnavailableReason = + reason ?: UnavailableReason.SIGNAL_ABSENT + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/RouteGrade.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/RouteGrade.kt new file mode 100644 index 0000000..9d0b147 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/RouteGrade.kt @@ -0,0 +1,105 @@ +package com.evsuite.hardware.telemetry + +import kotlin.math.abs + +/** + * What the climbing costs, and how little of it comes back. + * + * CP-031 refused a grade source on 2026-09-01 and was right to: the vehicle publishes none, a + * barometric reference is unproven, power-derived grade is circular, and sampled GPS altitude + * describes the road *behind* the car. CP-047 changed the input rather than the argument — a + * route carries an elevation profile of the road **ahead**, which is the only kind a forecast + * can use. Toulouse to Alès crosses the Cévennes, and a forecast that does not know that is + * wrong in the direction that strands people. + * + * This is physics, not a fit. Potential energy for a stated mass, a stated drivetrain + * efficiency on the way up and a stated regeneration efficiency on the way down — every one of + * them a declared constant with a band, none of them a parameter tuned against data this + * project does not have. When there are trips over known profiles a fitted coefficient can + * replace it; until then a fitted number would only be a guess wearing a measurement's clothes. + * + * **Regeneration is not free.** A descent returns charge, but less than the same climb spent, + * because the round trip pays the drivetrain twice and pays the brakes wherever the descent is + * steeper than the car's drag. That asymmetry is the whole point: a route over a col comes back + * costing more than the flat one, never the same and never less. + */ +object RouteGrade { + + /** + * The car and what is in it, in kilograms. + * + * MG4 Long Range kerb mass is about 1685 kg on the manufacturer's specification; a driver + * and their luggage is the rest. A specification describes the model and not this car, so + * this is a stated assumption carried in [RELATIVE_UNCERTAINTY] rather than a measurement — + * see `AGENTS.md` on specification references. + */ + const val MASS_KG = 1750.0 + + const val GRAVITY_M_PER_S2 = 9.80665 + + /** Battery to wheels going up: motor, inverter and reduction gear, all of them lossy. */ + const val DRIVETRAIN_EFFICIENCY = 0.85 + + /** + * Wheels back to battery going down, and deliberately pessimistic. + * + * The chain loses as much as the way up, and on top of that some of the descent is spent on + * drag rather than recovered, and a steep one reaches the regeneration limit and goes to + * the friction brakes. Overstating this is how a forecast promises charge that never + * arrives, so it is understated instead. + */ + const val REGENERATION_EFFICIENCY = 0.60 + + /** + * How wrong the three constants above may be, together. + * + * Mass is a specification plus a guess at the load, and neither efficiency was measured on + * this car. Wide on purpose: a narrow band on numbers nobody measured is a false precision, + * and this one feeds a stop decision. + */ + const val RELATIVE_UNCERTAINTY = 0.40 + + private const val JOULES_PER_KWH = 3_600_000.0 + + /** + * What the profile costs in points of charge, with its band. + * + * Negative on a route that descends more than it climbs, which is a real effect and is not + * clamped away — a car crossing the Alps towards the sea does arrive with more than the flat + * arithmetic says. + */ + data class Cost(val percent: Double, val uncertaintyPercent: Double) + + /** + * The grade term, or null when there is nothing to say. + * + * Null rather than zero, always: a silent zero grade term is a claim that the road is flat, + * and this cannot tell a flat road from a missing profile. + * + * @param ascentMetres cumulative climb over the route, metres. + * @param descentMetres cumulative descent over the route, metres. + * @param capacity the pack the charge is a percentage of; without it there is no bridge + * between joules and state of charge. + */ + fun of( + ascentMetres: Double?, + descentMetres: Double?, + capacity: BatteryCapacityConfig?, + ): Cost? { + if (ascentMetres == null || descentMetres == null || capacity == null) return null + if (!ascentMetres.isFinite() || !descentMetres.isFinite()) return null + if (ascentMetres < 0.0 || descentMetres < 0.0) return null + + val potentialPerMetreKwh = MASS_KG * GRAVITY_M_PER_S2 / JOULES_PER_KWH + val spentKwh = potentialPerMetreKwh * ascentMetres / DRIVETRAIN_EFFICIENCY + val recoveredKwh = potentialPerMetreKwh * descentMetres * REGENERATION_EFFICIENCY + + val soc = capacity.socPercentForEnergy(spentKwh - recoveredKwh) + val percent = soc.value ?: return null + if (!percent.isFinite()) return null + // The capacity's own band on top of the constants', added rather than combined in + // quadrature: they are not independent errors and the wider answer is the safe one. + val band = abs(percent) * RELATIVE_UNCERTAINTY + (soc.uncertainty ?: 0.0) + return Cost(percent, band) + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/SocRate.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/SocRate.kt new file mode 100644 index 0000000..432a4fc --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/SocRate.kt @@ -0,0 +1,123 @@ +package com.evsuite.hardware.telemetry + +import kotlin.math.abs +import kotlin.math.sqrt + +/** + * How much state of charge a kilometre costs. + * + * The obvious route to an arrival forecast runs through the energy model: kWh/100 km, times a + * distance, divided by a pack capacity. On this vehicle it is a dead end — the model trains on + * energy integrated from battery power, and `EV_INSTANTANEOUS_CHARGE_RATE` publishes nothing. + * + * State of charge per kilometre needs no energy unit at all. Both of its inputs read on a car + * where battery power does not, which is why the forecast is built on it instead. + */ +data class SocRate( + /** Percentage points of charge per kilometre. Always positive for a car that is driving. */ + val percentPerKm: Double, + /** Half-width of the band on [percentPerKm], in the same unit. */ + val uncertaintyPercentPerKm: Double, + val source: Source, + /** Trips behind the figure, or 0 when it came from the vehicle's own range. */ + val sampleCount: Int = 0, +) { + enum class Source { + /** + * State of charge divided by the vehicle's own remaining range. Available from the + * first reading, and only as good as the car's own estimate. + */ + VEHICLE_RANGE, + + /** Measured across recorded trips: what this driver actually spends per kilometre. */ + TRIP_HISTORY, + } +} + +/** Builds a [SocRate] from whichever evidence exists, preferring what the driver has driven. */ +object SocRateEstimator { + + /** + * How wrong the vehicle's own range estimate may be. + * + * The car publishes a range and no error bar. It is a model like any other — it reacts to + * recent driving, temperature and its own assumptions — so a fifth is a stated assumption + * rather than a measurement, and it is deliberately wide. Trip history replaces it with a + * band that was actually observed. + */ + const val VEHICLE_RANGE_RELATIVE_UNCERTAINTY = 0.20 + + /** Below this a trip's own state-of-charge resolution dominates whatever it measured. */ + const val MIN_TRIP_DISTANCE_KM = 1.0 + + /** Fewer than this and the spread across trips is not a band, it is noise. */ + const val MIN_TRIPS = 3 + + /** A floor on the observed spread, so a run of similar trips cannot imply false precision. */ + const val MIN_TRIP_RELATIVE_UNCERTAINTY = 0.10 + + /** + * The rate implied by the vehicle's own range reading. + * + * @param socPercent the vehicle's state of charge, 0..100. + * @param rangeKm the vehicle's own remaining range. + */ + fun fromVehicleRange(socPercent: Double?, rangeKm: Double?): SocRate? { + if (socPercent == null || rangeKm == null) return null + if (!socPercent.isFinite() || !rangeKm.isFinite()) return null + if (socPercent !in 0.0..100.0 || rangeKm <= 0.0) return null + val rate = socPercent / rangeKm + if (!rate.isFinite() || rate <= 0.0) return null + return SocRate( + percentPerKm = rate, + uncertaintyPercentPerKm = rate * VEHICLE_RANGE_RELATIVE_UNCERTAINTY, + source = SocRate.Source.VEHICLE_RANGE, + ) + } + + /** + * The rate this driver has actually spent, across trips whose distance can be vouched for. + * + * Only trips carrying [EnergyTripSummary.modellableDistanceKm] count: a distance recorded + * under the old speed conversion is 3.6 times too long, and averaging it in would make the + * car look three and a half times more efficient than it is. + * + * Charging stops are excluded by construction — a trip whose charge went up is not a trip + * whose consumption means anything. + */ + fun fromTrips( + trips: List, + generation: com.evsuite.hardware.FirmwareInfo.Gen = + com.evsuite.hardware.FirmwareInfo.getGeneration(), + ): SocRate? { + val rates = trips.mapNotNull { trip -> + val km = trip.modellableDistanceKm(generation)?.takeIf { it >= MIN_TRIP_DISTANCE_KM } + ?: return@mapNotNull null + val start = trip.startSocPercent ?: return@mapNotNull null + val end = trip.endSocPercent ?: return@mapNotNull null + val spent = (start - end).toDouble() + if (spent <= 0.0) return@mapNotNull null + (spent / km).takeIf { it.isFinite() && it > 0.0 } + } + if (rates.size < MIN_TRIPS) return null + val mean = rates.average() + if (!mean.isFinite() || mean <= 0.0) return null + val variance = rates.sumOf { (it - mean) * (it - mean) } / (rates.size - 1) + val observed = sqrt(variance) + return SocRate( + percentPerKm = mean, + uncertaintyPercentPerKm = maxOf(observed, abs(mean) * MIN_TRIP_RELATIVE_UNCERTAINTY), + source = SocRate.Source.TRIP_HISTORY, + sampleCount = rates.size, + ) + } + + /** Trip history when there is enough of it, the vehicle's own range otherwise. */ + fun best( + socPercent: Double?, + rangeKm: Double?, + trips: List, + generation: com.evsuite.hardware.FirmwareInfo.Gen = + com.evsuite.hardware.FirmwareInfo.getGeneration(), + ): SocRate? = fromTrips(trips, generation) ?: fromVehicleRange(socPercent, rangeKm) +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/StateOfHealth.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/StateOfHealth.kt new file mode 100644 index 0000000..d0e9003 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/StateOfHealth.kt @@ -0,0 +1,319 @@ +package com.evsuite.hardware.telemetry + +import kotlin.math.abs + +/** Where a window's kilowatt-hours came from. Two sources are never averaged together. */ +enum class SohEnergySource { + /** + * The charging service's own counters (transactions 75 and 77). Measured by the car, with + * nothing integrated here — the best source, and the one no drive has yet proven moves. + */ + VEHICLE_COUNTERS, + + /** + * This app's integral of `BMS_PACK_VOL × BMS_PACK_CRNT`, carried by + * `BatteryPowerEvidence.PACK_PAIR_DERIVED_V2`. Arithmetic on two vehicle readings, with the + * sign convention still unproven on any generation. + */ + PACK_PAIR_INTEGRAL, +} + +/** + * One discharge between two charges: what it cost, and what the charge gauge said it cost. + * + * The ratio is a usable capacity, and it needs no capacity to compute — which is the whole + * reason this exists. Every other kWh figure in EVSuite multiplies a percentage by a number + * from a specification sheet. + */ +data class SohWindow( + val startedAtMs: Long, + val endedAtMs: Long, + val socDropPercent: Double, + val netKwh: Double, + val source: SohEnergySource, + val meanOutsideTempCelsius: Double?, + val distanceKm: Double?, +) { + val usableCapacityKwh: Double get() = netKwh * 100.0 / socDropPercent +} + +/** The pack as this history sees it, always estimated and always with its band. */ +data class StateOfHealthEstimate( + val usableCapacityKwh: Provenanced, + val stateOfHealthPercent: Provenanced, + val source: SohEnergySource, + val windowCount: Int, + val coldWindowsExcluded: Int, + val spanDays: Double, + val spanKm: Double?, + /** + * Newest third minus oldest third, in points of health, or null when the history cannot + * support the comparison. This is the figure the screen leads with: a node bias that moves + * the absolute cancels out of a difference between two estimates made the same way. + */ + val trendPercentPoints: Double?, +) + +sealed interface StateOfHealthResult { + data class Ready(val estimate: StateOfHealthEstimate) : StateOfHealthResult + + data class Unavailable( + val reason: UnavailableReason, + val windowsSeen: Int = 0, + val coldWindowsExcluded: Int = 0, + ) : StateOfHealthResult +} + +/** + * State of health as a ratio, because this car publishes no capacity to read. + * + * `INFO_EV_BATTERY_CAPACITY`, `EV_BATTERY_LEVEL` and the `EV_CURRENT_BATTERY_CAPACITY` + * candidate are declared and never published on SWI68 — null in all 612 snapshots of the + * 2026-09-13 drive — so [BatteryCapacityConfig] asks the driver instead. The energy that left + * the pack over the charge it cost is a capacity, and both halves of it are recorded in the + * [BatteryLedgerEntry] stream. + * + * **What this can and cannot see.** A charge step on this car is about one point, so a window + * of 25 points carries ±4 % before anything else goes wrong, and a year of real degradation is + * two or three points. One window is therefore never a health figure: the estimate is the + * median of at least [MIN_WINDOWS], and the band never claims to be tighter than the + * quantisation allows. The absolute figure also inherits whatever node the source measures at + * — pack terminals or somewhere downstream, unknowable from here — which is why + * [StateOfHealthEstimate.trendPercentPoints] is the figure worth reading and the absolute one + * is shown beside it with its source named. + */ +class StateOfHealthEstimator( + private val minSocDropPercent: Double = MIN_SOC_DROP_PERCENT, + private val minWindows: Int = MIN_WINDOWS, + private val minTempCelsius: Double = MIN_TEMP_CELSIUS, + private val distanceTolerance: Double = DISTANCE_TOLERANCE, +) { + fun estimate( + entries: List, + usableCapacityKwhWhenNew: Double, + ): StateOfHealthResult { + if (!usableCapacityKwhWhenNew.isFinite() || usableCapacityKwhWhenNew <= 0.0) { + return StateOfHealthResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + val all = windows(entries) + val cold = all.count { it.isColderThan(minTempCelsius) } + val usable = all.filterNot { it.isColderThan(minTempCelsius) } + if (usable.isEmpty()) { + return StateOfHealthResult.Unavailable( + UnavailableReason.INSUFFICIENT_SAMPLES, all.size, cold, + ) + } + // Preference, not popularity: a counter the car keeps outranks an integral this app + // made, however many windows the integral managed to produce. + val chosen = SOURCE_PREFERENCE + .map { source -> usable.filter { it.source == source } } + .firstOrNull { it.size >= minWindows } + ?: return StateOfHealthResult.Unavailable( + UnavailableReason.INSUFFICIENT_SAMPLES, usable.size, cold, + ) + + val capacities = chosen.map { it.usableCapacityKwh }.sorted() + val capacity = median(capacities) + val drops = chosen.map { it.socDropPercent }.sorted() + // Two floors, and the wider wins: what the windows disagree about, and what a gauge + // that moves a point at a time can resolve at all. + val spread = (percentile(capacities, 0.75) - percentile(capacities, 0.25)) / 2.0 + val quantisation = capacity * SOC_STEP_PERCENT / median(drops) + val resolution = sourceStepKwh(chosen.first().source) * 100.0 / median(drops) + val band = maxOf(spread, quantisation, resolution) + val health = capacity * 100.0 / usableCapacityKwhWhenNew + val healthBand = band * 100.0 / usableCapacityKwhWhenNew + + val ordered = chosen.sortedBy { it.endedAtMs } + val spanDays = (ordered.last().endedAtMs - ordered.first().startedAtMs) / MS_PER_DAY + val distances = ordered.mapNotNull { it.distanceKm } + return StateOfHealthResult.Ready( + StateOfHealthEstimate( + usableCapacityKwh = Provenanced.estimated(capacity, band), + stateOfHealthPercent = Provenanced.estimated(health, healthBand), + source = chosen.first().source, + windowCount = chosen.size, + coldWindowsExcluded = cold, + spanDays = spanDays, + spanKm = distances.sum().takeIf { distances.isNotEmpty() }, + trendPercentPoints = trend(ordered, usableCapacityKwhWhenNew, spanDays), + ) + ) + } + + /** + * Every discharge in the ledger that can be measured, newest last. + * + * A window runs from one entry to the last entry before the charge rises again. A rise is + * how a charge is detected — the charging status' semantics are unproven on this car and + * the port-connected property is never published, but a charge that puts energy into a + * pack raises its state of charge on every vehicle ever built. + */ + internal fun windows(entries: List): List { + val ordered = entries.filter { it.socPercent.isFinite() }.sortedBy { it.atMs } + if (ordered.size < 2) return emptyList() + val result = ArrayList() + var anchor = ordered.first() + var previous = anchor + for (index in 1 until ordered.size) { + val entry = ordered[index] + if (entry.socPercent > previous.socPercent + SOC_RISE_EPSILON) { + measure(anchor, previous)?.let(result::add) + anchor = entry + } + previous = entry + } + measure(anchor, previous)?.let(result::add) + return result + } + + /** The window, or null when nothing in it can be trusted to say what it cost. */ + private fun measure(start: BatteryLedgerEntry, end: BatteryLedgerEntry): SohWindow? { + val drop = (start.socPercent - end.socPercent).toDouble() + if (drop < minSocDropPercent) return null + val temp = meanTemp(start, end) + val distance = distanceOf(start, end) + vehicleCounterKwh(start, end)?.let { net -> + return SohWindow( + start.atMs, end.atMs, drop, net, SohEnergySource.VEHICLE_COUNTERS, temp, distance, + ) + } + packPairKwh(start, end)?.let { net -> + return SohWindow( + start.atMs, end.atMs, drop, net, SohEnergySource.PACK_PAIR_INTEGRAL, temp, distance, + ) + } + return null + } + + /** + * The car's own counters over the window, or null. + * + * A counter that went backwards was reset, which means the car charged inside a window the + * charge gauge said it did not. That contradiction is refused rather than reconciled. + */ + private fun vehicleCounterKwh(start: BatteryLedgerEntry, end: BatteryLedgerEntry): Double? { + val consumed = delta(start.vehicleConsumedKwh, end.vehicleConsumedKwh) ?: return null + val regenerated = delta(start.vehicleRegeneratedKwh, end.vehicleRegeneratedKwh) ?: return null + if (consumed < 0.0 || regenerated < 0.0) return null + return (consumed - regenerated).takeIf { it > 0.0 } + } + + /** + * The app's own integral over the window, or null. + * + * Guarded by the odometer, which is the point: this integral only counts the seconds the + * app was awake and sampling, and a window where the car drove further than the app + * watched is missing energy it has no way to notice. Without an odometer at both ends there + * is no way to check that, so the window is refused rather than believed. + */ + private fun packPairKwh(start: BatteryLedgerEntry, end: BatteryLedgerEntry): Double? { + val energy = delta(start.packEnergyKwh, end.packEnergyKwh)?.takeIf { it > 0.0 } ?: return null + val odometer = delta(start.odometerKm, end.odometerKm) ?: return null + val integrated = delta(start.integratedDistanceKm, end.integratedDistanceKm) ?: return null + if (odometer < 0.0 || integrated < 0.0) return null + if (odometer > 0.0 && abs(integrated - odometer) > distanceTolerance * odometer) return null + return energy + } + + private fun trend( + ordered: List, + capacityWhenNew: Double, + spanDays: Double, + ): Double? { + if (ordered.size < MIN_TREND_WINDOWS || spanDays < MIN_TREND_DAYS) return null + val third = ordered.size / 3 + val oldest = ordered.take(third).map { it.usableCapacityKwh }.sorted() + val newest = ordered.takeLast(third).map { it.usableCapacityKwh }.sorted() + if (oldest.isEmpty() || newest.isEmpty()) return null + return (median(newest) - median(oldest)) * 100.0 / capacityWhenNew + } + + private fun meanTemp(start: BatteryLedgerEntry, end: BatteryLedgerEntry): Double? { + val first = start.outsideTempCelsius?.toDouble() ?: return null + val second = end.outsideTempCelsius?.toDouble() ?: return null + return (first + second) / 2.0 + } + + private fun distanceOf(start: BatteryLedgerEntry, end: BatteryLedgerEntry): Double? = + delta(start.odometerKm, end.odometerKm)?.takeIf { it >= 0.0 } + ?: delta(start.integratedDistanceKm, end.integratedDistanceKm)?.takeIf { it >= 0.0 } + + private fun delta(start: Float?, end: Float?): Double? = + if (start == null || end == null || !start.isFinite() || !end.isFinite()) null + else (end - start).toDouble() + + private fun delta(start: Double?, end: Double?): Double? = + if (start == null || end == null || !start.isFinite() || !end.isFinite()) null + else end - start + + private fun SohWindow.isColderThan(limit: Double): Boolean = + meanOutsideTempCelsius != null && meanOutsideTempCelsius < limit + + companion object { + /** + * Below this a window says more about the gauge's resolution than about the pack: + * one point of quantisation at each end is ±4 % over 25 points, and real degradation + * is two or three points a year. + */ + const val MIN_SOC_DROP_PERCENT = 25.0 + + /** A median needs a majority to mean anything, and six is where the band stops moving. */ + const val MIN_WINDOWS = 6 + + /** A cold pack delivers less usable energy, and that is weather rather than health. */ + const val MIN_TEMP_CELSIUS = 5.0 + + /** How far the app's own integral may fall short of the odometer before the window is refused. */ + const val DISTANCE_TOLERANCE = 0.10 + + /** The step this car's charge gauge takes, and so one floor under every band. */ + const val SOC_STEP_PERCENT = 1.0 + + /** + * The step the vehicle's own counter takes — a whole kilowatt-hour. + * + * Not a guess: the 2026-09-13 drive spent 1,1 % of the pack and read zero on + * `saic_consumed_kwh_since_start`, and the 2026-09-15 drive moved it. RI-002's verdict is + * that the counter was below its *resolution* rather than below its bind point, and that + * resolution is a whole kWh. Over a sixty-point window that is a couple of percent of the + * capacity, which is the same order as a year of degradation — so it belongs in the band + * rather than in a footnote. A median over several windows averages the rounding down; the + * floor states what a single window could not have resolved. + */ + const val COUNTER_STEP_KWH = 1.0 + + /** What one window's energy figure could be wrong by, before anything else goes wrong. */ + internal fun sourceStepKwh(source: SohEnergySource): Double = when (source) { + SohEnergySource.VEHICLE_COUNTERS -> COUNTER_STEP_KWH + // An integral of a float property has no step of its own; what it can be wrong about + // is coverage, and the odometer guard is what refuses a window for that. + SohEnergySource.PACK_PAIR_INTEGRAL -> 0.0 + } + + const val MIN_TREND_WINDOWS = 12 + const val MIN_TREND_DAYS = 60.0 + + /** A charge rise past the gauge's own step. Noise at the step is not a charge. */ + private const val SOC_RISE_EPSILON = 0.6f + + private const val MS_PER_DAY = 86_400_000.0 + + private val SOURCE_PREFERENCE = listOf( + SohEnergySource.VEHICLE_COUNTERS, + SohEnergySource.PACK_PAIR_INTEGRAL, + ) + + internal fun median(sorted: List): Double = percentile(sorted, 0.5) + + /** Nearest-rank, which needs no interpolation rule to argue about on six samples. */ + internal fun percentile(sorted: List, fraction: Double): Double { + if (sorted.isEmpty()) return Double.NaN + if (sorted.size == 1) return sorted.first() + val rank = (fraction * (sorted.size - 1)).toInt().coerceIn(0, sorted.size - 1) + val next = (rank + 1).coerceAtMost(sorted.size - 1) + val weight = fraction * (sorted.size - 1) - rank + return sorted[rank] + (sorted[next] - sorted[rank]) * weight + } + } +} 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 new file mode 100644 index 0000000..03ca1bb --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/TelemetryEvidence.kt @@ -0,0 +1,309 @@ +package com.evsuite.hardware.telemetry + +import com.google.gson.Gson + +/** Whether a signal is a quantity or a flag; a flag has no meaningful mean. */ +enum class SignalKind { NUMERIC, BOOLEAN } + +/** A read-only candidate carried beside, but never inside, the production snapshot. */ +data class TelemetryEvidenceProbe(val signal: String, val value: Double?) + +data class TelemetryEvidenceSample( + val snapshot: EnergySnapshot, + val probes: List, +) + +/** + * What one signal did during a capture, in the terms a decision needs. + * + * Every energy model downstream divides by a number whose unit, sign and update rate are + * currently a hypothesis: `EVHardware.getBatteryPowerKw` divides the raw property by + * -1,000,000 because that is what the value looked like, not because a car confirmed it. + * This is the record that turns the hypothesis into evidence — or refutes it. + * + * A signal the vehicle never answered has zero samples and no statistics at all. It does not + * have a minimum of zero, or a mean of zero, because it has nothing. + */ +data class SignalEvidence( + val signal: String, + val kind: SignalKind, + /** Reads that produced a value. */ + val samples: Int, + /** Reads that produced nothing. */ + val nulls: Int, + val min: Double? = null, + val max: Double? = null, + val mean: Double? = null, + /** Sign split, numeric signals only: how a power reading proves its own convention. */ + val positive: Int? = null, + val negative: Int? = null, + val zero: Int? = null, + val firstSeenMs: Long? = null, + val lastSeenMs: Long? = null, + /** How many times the value actually changed — the basis for the update period. */ + val changes: Int = 0, + val updatePeriodMinMs: Long? = null, + val updatePeriodMedianMs: Long? = null, + val updatePeriodMaxMs: Long? = null, +) { + val available: Boolean get() = samples > 0 +} + +/** + * One capture: what was recorded, on which firmware, over what window. + * + * [schemaVersion] is written into the file because these captures outlive the build that + * produced them — a capture from a car six months ago is still the only evidence anyone has + * for that generation, and a reader has to know how to read it. + */ +data class EvidenceCapture( + val schemaVersion: Int = SCHEMA_VERSION, + val firmware: String, + val startedAtMs: Long?, + val endedAtMs: Long?, + val snapshots: Int, + val signals: List, +) { + companion object { + const val SCHEMA_VERSION = 1 + } +} + +/** + * Reads every signal of every snapshot and remembers what it saw. + * + * Thread-confined by construction: a sampler owns one recorder and no one else touches it, + * which is why nothing here is synchronized. Per sample it does arithmetic on primitives and + * allocates nothing, because it runs beside a 1 Hz read on a head unit that has other work. + */ +class TelemetryEvidenceRecorder { + + private val accumulators = LinkedHashMap() + private var snapshots = 0 + private var firstSnapshotMs: Long? = null + private var lastSnapshotMs: Long? = null + private var firmware: String? = null + + fun record(snapshot: EnergySnapshot) { + snapshots++ + if (firstSnapshotMs == null) firstSnapshotMs = snapshot.timestampMs + lastSnapshotMs = snapshot.timestampMs + firmware = snapshot.firmware.name + val at = snapshot.timestampMs + + record(SOC_PERCENT, snapshot.socPercent, at) + record(RANGE_KM, snapshot.rangeKm, at) + record(SPEED_KMH, snapshot.speedKmh, at) + record(BATTERY_POWER_KW, snapshot.batteryPowerKw, at) + record(BATTERY_TEMP_C, snapshot.batteryTempCelsius, at) + record(BATTERY_ENERGY_KWH, snapshot.batteryEnergyKwh, at) + record(BATTERY_CAPACITY_KWH, snapshot.batteryCapacityKwh, at) + record(ODOMETER_KM, snapshot.odometerKm, at) + record(OUTSIDE_TEMP_C, snapshot.outsideTempCelsius, at) + record(CABIN_TEMP_C, snapshot.cabinTempCelsius, at) + record(CHARGING_STATUS, snapshot.chargingStatus?.toDouble(), at) + record(CHARGE_PORT_CONNECTED, snapshot.chargePortConnected, at) + record(PARKED, snapshot.parked, at) + + val climate = snapshot.climate + record(CLIMATE_POWER_ON, climate.powerOn, at) + record(CLIMATE_AC_ON, climate.acOn, at) + record(CLIMATE_AUTO_ON, climate.autoOn, at) + record(CLIMATE_ECON_ON, climate.econOn, at) + record(CLIMATE_RECIRCULATION_ON, climate.recirculationOn, at) + record(CLIMATE_FAN_LEVEL, climate.fanLevel?.toDouble(), at) + record(CLIMATE_FAN_LEVEL_MAX, climate.fanLevelMax?.toDouble(), at) + record(CLIMATE_DRIVER_TARGET_C, climate.driverTargetCelsius, at) + record(CLIMATE_PASSENGER_TARGET_C, climate.passengerTargetCelsius, at) + } + + fun record(sample: TelemetryEvidenceSample) { + record(sample.snapshot) + sample.probes.forEach { probe -> + record(probe.signal, probe.value, sample.snapshot.timestampMs) + } + } + + /** For a candidate property being probed alongside the snapshot (see the CP-004 findings). */ + fun record(signal: String, value: Double?, atMs: Long) = + accumulator(signal, SignalKind.NUMERIC).add(value, atMs) + + fun record(signal: String, value: Float?, atMs: Long) = + record(signal, value?.toDouble(), atMs) + + fun record(signal: String, value: Boolean?, atMs: Long) = + accumulator(signal, SignalKind.BOOLEAN).add(value?.let { if (it) 1.0 else 0.0 }, atMs) + + fun evidence(): List = accumulators.map { (name, it) -> it.evidence(name) } + + fun capture(): EvidenceCapture = EvidenceCapture( + firmware = firmware ?: "UNKNOWN", + startedAtMs = firstSnapshotMs, + endedAtMs = lastSnapshotMs, + snapshots = snapshots, + signals = evidence(), + ) + + private fun accumulator(signal: String, kind: SignalKind): Accumulator = + accumulators.getOrPut(signal) { Accumulator(kind) } + + private class Accumulator(private val kind: SignalKind) { + private var samples = 0 + private var nulls = 0 + private var min = Double.MAX_VALUE + private var max = -Double.MAX_VALUE + private var sum = 0.0 + private var positive = 0 + private var negative = 0 + private var zero = 0 + private var firstSeenMs: Long? = null + private var lastSeenMs: Long? = null + private var lastValue: Double? = null + private var lastChangeMs: Long? = null + private var changes = 0 + /** + * Intervals between value *changes*, not between reads. A 1 Hz sampler over a signal + * the vehicle republishes every five seconds must report five seconds, or every model + * built on it will assume a resolution the car does not have. + */ + private val intervals = LongArray(MAX_INTERVALS) + private var intervalCount = 0 + + fun add(value: Double?, atMs: Long) { + if (value == null) { + nulls++ + return + } + samples++ + sum += value + if (value < min) min = value + if (value > max) max = value + when { + value > 0.0 -> positive++ + value < 0.0 -> negative++ + else -> zero++ + } + if (firstSeenMs == null) firstSeenMs = atMs + lastSeenMs = atMs + + val previous = lastValue + if (previous == null) { + lastChangeMs = atMs + } else if (previous != value) { + changes++ + lastChangeMs?.let { since -> + val interval = atMs - since + if (interval > 0L && intervalCount < MAX_INTERVALS) { + intervals[intervalCount++] = interval + } + } + lastChangeMs = atMs + } + lastValue = value + } + + fun evidence(signal: String): SignalEvidence { + if (samples == 0) { + return SignalEvidence(signal, kind, samples = 0, nulls = nulls) + } + val sorted = intervals.copyOf(intervalCount).sortedArray() + return SignalEvidence( + signal = signal, + kind = kind, + samples = samples, + nulls = nulls, + min = min, + max = max, + mean = sum / samples, + positive = positive.takeIf { kind == SignalKind.NUMERIC }, + negative = negative.takeIf { kind == SignalKind.NUMERIC }, + zero = zero.takeIf { kind == SignalKind.NUMERIC }, + firstSeenMs = firstSeenMs, + lastSeenMs = lastSeenMs, + changes = changes, + updatePeriodMinMs = sorted.firstOrNull(), + updatePeriodMedianMs = sorted.medianOrNull(), + updatePeriodMaxMs = sorted.lastOrNull(), + ) + } + + private fun LongArray.medianOrNull(): Long? = + if (isEmpty()) null else this[size / 2] + + private companion object { + /** A long capture proves the cadence early; the tail adds bytes, not knowledge. */ + const val MAX_INTERVALS = 1024 + } + } + + companion object { + const val SOC_PERCENT = "socPercent" + const val RANGE_KM = "rangeKm" + const val SPEED_KMH = "speedKmh" + const val BATTERY_POWER_KW = "batteryPowerKw" + const val BATTERY_TEMP_C = "batteryTempCelsius" + const val BATTERY_ENERGY_KWH = "batteryEnergyKwh" + const val BATTERY_CAPACITY_KWH = "batteryCapacityKwh" + const val ODOMETER_KM = "odometerKm" + const val OUTSIDE_TEMP_C = "outsideTempCelsius" + const val CABIN_TEMP_C = "cabinTempCelsius" + const val CHARGING_STATUS = "chargingStatus" + const val CHARGE_PORT_CONNECTED = "chargePortConnected" + const val PARKED = "parked" + const val CLIMATE_POWER_ON = "climate.powerOn" + const val CLIMATE_AC_ON = "climate.acOn" + const val CLIMATE_AUTO_ON = "climate.autoOn" + const val CLIMATE_ECON_ON = "climate.econOn" + const val CLIMATE_RECIRCULATION_ON = "climate.recirculationOn" + const val CLIMATE_FAN_LEVEL = "climate.fanLevel" + const val CLIMATE_FAN_LEVEL_MAX = "climate.fanLevelMax" + const val CLIMATE_DRIVER_TARGET_C = "climate.driverTargetCelsius" + const val CLIMATE_PASSENGER_TARGET_C = "climate.passengerTargetCelsius" + const val CANDIDATE_CURRENT_BATTERY_CAPACITY_WH = + "candidate.evCurrentBatteryCapacityWh" + } +} + +/** + * The capture, written for two readers: a future build, and a person. + * + * The JSON is what a later session parses; the Markdown is what gets pasted into the + * validation analysis, where the conclusion is written by someone reading a table. + */ +object TelemetryEvidenceFormat { + + private val gson = Gson() + + fun toJson(capture: EvidenceCapture): String = gson.toJson(capture) + + fun fromJson(text: String): EvidenceCapture? = + runCatching { gson.fromJson(text, EvidenceCapture::class.java) } + .getOrNull() + ?.takeIf { it.schemaVersion == EvidenceCapture.SCHEMA_VERSION } + + fun toMarkdown(capture: EvidenceCapture): String = buildString { + appendLine("# Telemetry evidence — ${capture.firmware}") + appendLine() + appendLine("- snapshots: ${capture.snapshots}") + appendLine("- window: ${capture.startedAtMs ?: "—"} → ${capture.endedAtMs ?: "—"}") + appendLine() + appendLine("| signal | samples | nulls | min | max | mean | +/-/0 | period med (ms) |") + appendLine("| --- | ---: | ---: | ---: | ---: | ---: | --- | ---: |") + capture.signals.forEach { evidence -> + val split = if (evidence.kind == SignalKind.NUMERIC) { + "${evidence.positive ?: 0}/${evidence.negative ?: 0}/${evidence.zero ?: 0}" + } else { + "—" + } + appendLine( + "| `${evidence.signal}` | ${evidence.samples} | ${evidence.nulls} | " + + "${evidence.min.orDash()} | ${evidence.max.orDash()} | " + + "${evidence.mean.orDash()} | $split | " + + "${evidence.updatePeriodMedianMs ?: "—"} |" + ) + } + } + + private fun Double?.orDash(): String = + this?.let { String.format(java.util.Locale.ROOT, "%.3f", it) } ?: "—" +} 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 new file mode 100644 index 0000000..a3f4ecb --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/TripDetector.kt @@ -0,0 +1,139 @@ +package com.evsuite.hardware.telemetry + +/** + * Pure automatic-trip state machine. It observes telemetry but never owns recording or storage. + * + * Speed is the mandatory signal. Park/charge confirmation is used at the end when either signal + * can answer; a firmware with no confirmation signals may fall back to the long standstill timer. + */ +class TripDetector { + enum class State { IDLE, ARMED, RECORDING, ENDING } + enum class Event { START, STOP } + + data class Result(val state: State, val event: Event? = null) + + @Volatile + var state: State = State.IDLE + private set + + private var stateSinceMs: Long? = null + private var lastTimestampMs: Long? = null + + @Synchronized + fun add(snapshot: EnergySnapshot): Result { + val timestampMs = snapshot.timestampMs + val previousTimestamp = lastTimestampMs + if (previousTimestamp != null && ( + timestampMs <= previousTimestamp || + timestampMs - previousTimestamp > MAX_OBSERVATION_GAP_MS + )) { + // Debounce proves sustained observations, not merely elapsed wall time. A clock + // discontinuity or telemetry hole invalidates partial evidence in either direction. + invalidatePartialEvidence() + } + lastTimestampMs = timestampMs + + val speedKmh = snapshot.speedKmh?.toDouble() + ?.takeIf { it.isFinite() && it >= 0.0 } + if (speedKmh == null) { + // Missing speed can neither prove motion nor prove a stop. Drop partial debounce + // evidence, preserve an active recording, and leave manual control available. + state = if (state == State.RECORDING || state == State.ENDING) { + State.RECORDING + } else { + State.IDLE + } + stateSinceMs = null + return Result(state) + } + + return when (state) { + State.IDLE -> { + if (speedKmh >= START_SPEED_KMH) transition(State.ARMED, timestampMs) + Result(state) + } + State.ARMED -> { + if (speedKmh < START_SPEED_KMH) { + transition(State.IDLE, timestampMs) + Result(state) + } else if (elapsed(timestampMs) >= START_DEBOUNCE_MS) { + transition(State.RECORDING, timestampMs) + Result(state, Event.START) + } else { + Result(state) + } + } + State.RECORDING -> { + if (speedKmh <= STOP_SPEED_KMH) transition(State.ENDING, timestampMs) + Result(state) + } + State.ENDING -> { + when { + speedKmh > STOP_SPEED_KMH -> { + transition(State.RECORDING, timestampMs) + Result(state) + } + elapsed(timestampMs) >= STOP_DEBOUNCE_MS && endConfirmed(snapshot) -> { + transition(State.IDLE, timestampMs) + Result(state, Event.STOP) + } + else -> Result(state) + } + } + } + } + + /** Keeps detector state aligned when the parked-only manual control starts a trip. */ + @Synchronized + fun markRecording() { + state = State.RECORDING + stateSinceMs = null + } + + /** Used after a manual stop or when automatic detection is disabled. */ + @Synchronized + fun reset() { + state = State.IDLE + stateSinceMs = null + lastTimestampMs = null + } + + private fun transition(next: State, timestampMs: Long) { + state = next + stateSinceMs = timestampMs + } + + private fun invalidatePartialEvidence() { + state = if (state == State.RECORDING || state == State.ENDING) { + State.RECORDING + } else { + State.IDLE + } + stateSinceMs = null + } + + private fun elapsed(timestampMs: Long): Long = + stateSinceMs?.let { (timestampMs - it).coerceAtLeast(0L) } ?: 0L + + private fun endConfirmed(snapshot: EnergySnapshot): Boolean { + val confirmationAvailable = snapshot.parked != null || snapshot.chargePortConnected != null + return !confirmationAvailable || 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 + + /** Five consecutive seconds reject a single stale or bouncing speed sample. */ + const val START_DEBOUNCE_MS = 5_000L + + /** Separate 1 km/h stop threshold provides hysteresis after a trip has started. */ + const val STOP_SPEED_KMH = 1.0 + + /** Longer than the ticket's 90-second traffic stop while still practical at arrival. */ + const val STOP_DEBOUNCE_MS = 120_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 new file mode 100644 index 0000000..061c741 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/TripSample.kt @@ -0,0 +1,99 @@ +package com.evsuite.hardware.telemetry + +/** + * One moment of a trip, kept so the trip can be re-examined later. + * + * A summary cannot be un-summarised. Every model this project will grow — what a slower + * motorway speed would have saved, what the climate system took, what the state of charge + * will be on arrival — is fitted from the *shape* of past trips, and a shape that was never + * recorded cannot be recovered from totals. So the track starts being written before there + * is anything to fit, because the alternative is a model with no history to learn from. + * + * Every field is nullable and stays that way: a car that publishes no pack temperature + * stores no pack temperature, rather than a column of zeros that a fit would happily + * believe. + */ +data class TripSample( + val atMs: Long, + val speedKmh: Float?, + val batteryPowerKw: Float?, + val socPercent: Float?, + val outsideTempCelsius: Float?, + val cabinTempCelsius: Float?, + val batteryTempCelsius: Float?, + val climatePowerOn: Boolean?, + val climateAcOn: Boolean?, + val climateFanLevel: Int?, +) { + companion object { + fun of(snapshot: EnergySnapshot) = TripSample( + atMs = snapshot.timestampMs, + speedKmh = snapshot.speedKmh, + batteryPowerKw = snapshot.batteryPowerKw, + socPercent = snapshot.socPercent, + outsideTempCelsius = snapshot.outsideTempCelsius, + cabinTempCelsius = snapshot.cabinTempCelsius, + batteryTempCelsius = snapshot.batteryTempCelsius, + climatePowerOn = snapshot.climate.powerOn, + climateAcOn = snapshot.climate.acOn, + climateFanLevel = snapshot.climate.fanLevel, + ) + } +} + +/** + * Collects a trip's track at a bounded resolution and a bounded length. + * + * The sampler reads at 1 Hz because the dashboard wants a live number; the track does not + * need that, and storing it would cost fourteen thousand samples for a four-hour drive. It + * keeps one sample per [intervalMs] instead. + * + * When a drive outlasts even that, the track is decimated rather than truncated: every + * second sample is dropped and the interval doubles. A long trip keeps its whole shape at a + * coarser resolution, where cutting the tail would keep the first hours and silently discard + * the motorway stretch that a model most wants to see. + */ +class TripSampleTrack( + private val startIntervalMs: Long = DEFAULT_INTERVAL_MS, + private val maxSamples: Int = DEFAULT_MAX_SAMPLES, +) { + private val samples = ArrayList() + private var intervalMs = startIntervalMs + private var lastKeptMs: Long? = null + + fun add(snapshot: EnergySnapshot) { + val last = lastKeptMs + if (last != null && snapshot.timestampMs - last < intervalMs) return + samples.add(TripSample.of(snapshot)) + lastKeptMs = snapshot.timestampMs + if (samples.size >= maxSamples) decimate() + } + + fun samples(): List = samples.toList() + + /** + * Halves the track in place and doubles the interval, keeping first and last. + * + * The last sample is kept explicitly rather than by parity. Decimation is triggered by the + * sample that has just been added, and on an even-sized track that sample sits at an odd + * index — taking every second one from the start would throw away the newest moment of the + * trip at every doubling, which is the one a model is least able to do without. + */ + private fun decimate() { + val newest = samples.lastIndex + var write = 0 + for (read in samples.indices) { + if (read % 2 == 0 && read != newest) samples[write++] = samples[read] + } + samples[write++] = samples[newest] + while (samples.size > write) samples.removeAt(samples.size - 1) + intervalMs *= 2 + } + + companion object { + /** One sample per five seconds: the resolution a consumption fit actually needs. */ + const val DEFAULT_INTERVAL_MS = 5_000L + /** About five and a half hours at the default interval before the first decimation. */ + const val DEFAULT_MAX_SAMPLES = 4_096 + } +} 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 new file mode 100644 index 0000000..3bb52e0 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyAttribution.kt @@ -0,0 +1,226 @@ +package com.evsuite.hardware.telemetry.model + +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 kotlin.math.abs +import kotlin.math.max + +enum class ResidualContext { CLIMATE_ACTIVE, CLIMATE_INACTIVE, CLIMATE_UNKNOWN } + +enum class ResidualFinding { + DISTINGUISHABLE, + NOT_DISTINGUISHABLE_FROM_ZERO, + NEGATIVE_MODEL_ERROR, +} + +/** Model-derived energy. Construction requires the uncertainty needed to render it honestly. */ +data class AttributedEnergyEstimate( + val valueKwh: Double, + val uncertaintyKwh: Double, +) { + init { + require(valueKwh.isFinite()) + require(uncertaintyKwh.isFinite() && uncertaintyKwh >= 0.0) + } + + val bandLowKwh: Double get() = valueKwh - uncertaintyKwh + val bandHighKwh: Double get() = valueKwh + uncertaintyKwh +} + +data class ResidualAttribution( + val context: ResidualContext, + val estimate: AttributedEnergyEstimate, + val finding: ResidualFinding, + val intervalCount: Int, + val averageFanLevel: Double?, + val averageCabinOutsideDeltaCelsius: Double?, +) + +data class EnergyAttribution( + val totalConsumedKwh: Provenanced, + val modelledTraction: AttributedEnergyEstimate, + val residuals: List, + val measuredRegenerationKwh: Provenanced, + val netBatteryEnergyKwh: Provenanced, + /** Measured total not covered by a modelled interval. Never hidden inside a residual. */ + val unmodelledDiscrepancyKwh: Provenanced, + val reconciliationErrorKwh: Double, +) + +sealed interface EnergyAttributionResult { + data class Ready(val attribution: EnergyAttribution) : EnergyAttributionResult + data class Unavailable(val reason: UnavailableReason) : EnergyAttributionResult +} + +/** + * Splits a measured trip total without claiming a nonexistent per-consumer vehicle counter. + * + * Each model-covered interval contributes traction and a residual. Climate state selects the + * residual's context, not its value: an HVAC-off residual is model error/other auxiliary load, + * never climate energy. Missing or out-of-envelope intervals remain a visible discrepancy. + */ +object EnergyAttributionCalculator { + fun calculate(trip: StoredTrip, model: EnergyModel?): EnergyAttributionResult { + val summary = trip.summary + if (model == null || summary.batteryPowerEvidence != model.evidence) { + return EnergyAttributionResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + val totalConsumed = summary.consumedKwh + val regeneration = summary.regeneratedKwh + if (totalConsumed == null || regeneration == null || + !totalConsumed.isFinite() || !regeneration.isFinite() || + totalConsumed < 0.0 || regeneration < 0.0 + ) { + return EnergyAttributionResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + val samples = trip.samples.orEmpty() + if (samples.size < 2) { + return EnergyAttributionResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + + var tractionKwh = 0.0 + var tractionUncertaintyKwh = 0.0 + var coveredMeasuredKwh = 0.0 + val groups = ResidualContext.entries.associateWith { ResidualAccumulator() } + for (index in 1 until samples.size) { + val previous = samples[index - 1] + val current = samples[index] + val durationMs = current.atMs - previous.atMs + if (durationMs !in 1..MAX_SAMPLE_GAP_MS) continue + val previousSpeed = previous.speedKmh?.toDouble() + val currentSpeed = current.speedKmh?.toDouble() + val previousPower = previous.batteryPowerKw?.toDouble() + val currentPower = current.batteryPowerKw?.toDouble() + val previousTemp = previous.outsideTempCelsius?.toDouble() + val currentTemp = current.outsideTempCelsius?.toDouble() + if (previousSpeed == null || currentSpeed == null || + previousPower == null || currentPower == null || + previousTemp == null || currentTemp == null || + !previousSpeed.isFinite() || !currentSpeed.isFinite() || + !previousPower.isFinite() || !currentPower.isFinite() || + !previousTemp.isFinite() || !currentTemp.isFinite() + ) { + continue + } + val hours = durationMs / MILLIS_PER_HOUR + val speed = (previousSpeed + currentSpeed) / 2.0 + val distanceKm = speed * hours + if (!distanceKm.isFinite() || distanceKm <= 0.0) continue + val temperature = (previousTemp + currentTemp) / 2.0 + val prediction = model.predict(speed, temperature) + val predictedConsumption = prediction.value ?: continue + val predictedUncertainty = prediction.uncertainty ?: continue + 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) + + tractionKwh += modelled + tractionUncertaintyKwh += uncertainty + coveredMeasuredKwh += measured + groups.getValue(context).add(previous, current, measured - modelled, uncertainty) + } + val coveredIntervals = groups.values.sumOf { it.intervalCount } + if (coveredIntervals == 0) { + return EnergyAttributionResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + + val residuals = ResidualContext.entries.mapNotNull { context -> + groups.getValue(context).build(context) + } + val discrepancy = totalConsumed - coveredMeasuredKwh + val netBatteryEnergy = totalConsumed - regeneration + val reconciledNet = tractionKwh + residuals.sumOf { it.estimate.valueKwh } + + discrepancy - regeneration + return EnergyAttributionResult.Ready( + EnergyAttribution( + totalConsumedKwh = Provenanced.derived(totalConsumed), + modelledTraction = AttributedEnergyEstimate( + tractionKwh, + tractionUncertaintyKwh, + ), + residuals = residuals, + measuredRegenerationKwh = Provenanced.derived(regeneration), + netBatteryEnergyKwh = Provenanced.derived(netBatteryEnergy), + unmodelledDiscrepancyKwh = Provenanced.derived(discrepancy), + reconciliationErrorKwh = netBatteryEnergy - reconciledNet, + ), + ) + } + + private fun climateContext(previous: TripSample, current: TripSample): ResidualContext { + val states = listOf(climateActive(previous), climateActive(current)) + return when { + states.any { it == true } -> ResidualContext.CLIMATE_ACTIVE + states.all { it == false } -> ResidualContext.CLIMATE_INACTIVE + else -> ResidualContext.CLIMATE_UNKNOWN + } + } + + private fun climateActive(sample: TripSample): Boolean? = when { + sample.climatePowerOn == true || sample.climateAcOn == true || + sample.climateFanLevel?.let { it > 0 } == true -> true + sample.climatePowerOn == false -> false + else -> null + } + + private class ResidualAccumulator { + var valueKwh = 0.0 + var uncertaintyKwh = 0.0 + var intervalCount = 0 + private var fanTotal = 0.0 + private var fanCount = 0 + private var temperatureDeltaTotal = 0.0 + private var temperatureDeltaCount = 0 + + fun add( + previous: TripSample, + current: TripSample, + residualKwh: Double, + residualUncertaintyKwh: Double, + ) { + valueKwh += residualKwh + uncertaintyKwh += residualUncertaintyKwh + intervalCount++ + listOf(previous.climateFanLevel, current.climateFanLevel).forEach { fan -> + if (fan != null) { + fanTotal += fan + fanCount++ + } + } + listOf(previous, current).forEach { sample -> + val cabin = sample.cabinTempCelsius?.toDouble() + val outside = sample.outsideTempCelsius?.toDouble() + if (cabin != null && outside != null && cabin.isFinite() && outside.isFinite()) { + temperatureDeltaTotal += abs(cabin - outside) + temperatureDeltaCount++ + } + } + } + + fun build(context: ResidualContext): ResidualAttribution? { + if (intervalCount == 0) return null + val estimate = AttributedEnergyEstimate(valueKwh, uncertaintyKwh) + val finding = when { + valueKwh < 0.0 -> ResidualFinding.NEGATIVE_MODEL_ERROR + valueKwh <= uncertaintyKwh -> + ResidualFinding.NOT_DISTINGUISHABLE_FROM_ZERO + else -> ResidualFinding.DISTINGUISHABLE + } + return ResidualAttribution( + context = context, + estimate = estimate, + finding = finding, + intervalCount = intervalCount, + averageFanLevel = fanTotal.takeIf { fanCount > 0 }?.div(fanCount), + averageCabinOutsideDeltaCelsius = temperatureDeltaTotal + .takeIf { temperatureDeltaCount > 0 }?.div(temperatureDeltaCount), + ) + } + } + + private const val MAX_SAMPLE_GAP_MS = 120_000L + private const val MILLIS_PER_HOUR = 3_600_000.0 +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModel.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModel.kt new file mode 100644 index 0000000..9860df4 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModel.kt @@ -0,0 +1,91 @@ +package com.evsuite.hardware.telemetry.model + +import com.evsuite.hardware.BatteryPowerEvidence +import com.evsuite.hardware.FirmwareInfo +import com.evsuite.hardware.telemetry.Provenanced +import com.evsuite.hardware.telemetry.UnavailableReason +import kotlin.math.abs +import kotlin.math.max + +/** Exact speed and temperature region observed by one fit. */ +data class EnergyModelEnvelope( + val minSpeedKmh: Double, + val maxSpeedKmh: Double, + val minOutsideTempCelsius: Double, + val maxOutsideTempCelsius: Double, +) { + fun contains(speedKmh: Double, outsideTempCelsius: Double): Boolean = + speedKmh.isFinite() && outsideTempCelsius.isFinite() && + speedKmh in (minSpeedKmh - EDGE_TOLERANCE)..(maxSpeedKmh + EDGE_TOLERANCE) && + outsideTempCelsius in + (minOutsideTempCelsius - EDGE_TOLERANCE)..(maxOutsideTempCelsius + EDGE_TOLERANCE) + + internal fun isValid(): Boolean = + minSpeedKmh.isFinite() && maxSpeedKmh.isFinite() && minSpeedKmh > 0.0 && + minSpeedKmh <= maxSpeedKmh && + minOutsideTempCelsius.isFinite() && maxOutsideTempCelsius.isFinite() && + minOutsideTempCelsius <= maxOutsideTempCelsius + + private companion object { + /** + * The bounds are means of measured intervals, so an edge lands on 90.000000000000011 + * rather than on 90. Refusing to predict the very speed the fit was trained on, by a + * ten-thousandth of a millionth of a km/h, is a floating-point artifact and not a + * statement about what the model knows. A micro-degree and a micro-km/h are nothing + * physically; anything wider would be a claim. + */ + const val EDGE_TOLERANCE = 1e-6 + } +} + +/** + * Driver-specific consumption fit in kWh/100 km. + * + * `rolling + aero × speed² + thermal × |outside - 20 °C|` is intentionally the whole model. + * Grade is absent until CP-031 decides on a trustworthy input, and HVAC attribution remains a + * residual owned by CP-033. Every prediction is structurally [Provenanced] as estimated. + */ +data class EnergyModel( + val schemaVersion: Int = SCHEMA_VERSION, + val evidence: BatteryPowerEvidence, + val rollingKwhPer100Km: Double, + val aeroKwhPer100KmPerSpeedSquared: Double, + val thermalKwhPer100KmPerDegree: Double, + val residualRmseKwhPer100Km: Double, + val sampleCount: Int, + val envelope: EnergyModelEnvelope, +) { + fun predict(speedKmh: Double, outsideTempCelsius: Double): Provenanced { + if (!isValid() || !envelope.contains(speedKmh, outsideTempCelsius)) { + return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + val value = rollingKwhPer100Km + + aeroKwhPer100KmPerSpeedSquared * speedKmh * speedKmh + + thermalKwhPer100KmPerDegree * abs(outsideTempCelsius - COMFORT_TEMP_CELSIUS) + if (!value.isFinite() || value <= 0.0) { + return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + val band = max( + CONFIDENCE_MULTIPLIER * residualRmseKwhPer100Km, + value * MIN_RELATIVE_UNCERTAINTY, + ) + return Provenanced.estimated(value, band) + } + + internal fun isValid(): Boolean = + schemaVersion == SCHEMA_VERSION && + evidence.firmware != FirmwareInfo.Gen.UNKNOWN && evidence.conversionVersion > 0 && + rollingKwhPer100Km.isFinite() && rollingKwhPer100Km > 0.0 && + aeroKwhPer100KmPerSpeedSquared.isFinite() && + aeroKwhPer100KmPerSpeedSquared >= 0.0 && + thermalKwhPer100KmPerDegree.isFinite() && thermalKwhPer100KmPerDegree >= 0.0 && + residualRmseKwhPer100Km.isFinite() && residualRmseKwhPer100Km >= 0.0 && + sampleCount >= EnergyModelTrainer.MIN_TRAINING_SAMPLES && envelope.isValid() + + companion object { + const val SCHEMA_VERSION = 1 + const val COMFORT_TEMP_CELSIUS = 20.0 + const val MIN_RELATIVE_UNCERTAINTY = 0.05 + private const val CONFIDENCE_MULTIPLIER = 1.96 + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModelStore.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModelStore.kt new file mode 100644 index 0000000..7e780e8 --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModelStore.kt @@ -0,0 +1,44 @@ +package com.evsuite.hardware.telemetry.model + +import com.google.gson.Gson +import java.io.File +import java.io.FileOutputStream + +/** One small fitted model, replaced atomically; no history or raw samples are duplicated. */ +class EnergyModelStore( + private val target: File, + private val gson: Gson = Gson(), +) { + fun read(): EnergyModel? { + if (!target.isFile || target.length() !in 1..MAX_MODEL_BYTES.toLong()) return null + val model = runCatching { gson.fromJson(target.readText(), EnergyModel::class.java) } + .getOrNull() ?: return null + @Suppress("SENSELESS_COMPARISON") + if (model.evidence == null || model.envelope == null || !model.isValid()) return null + return model + } + + fun write(model: EnergyModel): Boolean { + if (!model.isValid()) return false + val parent = target.parentFile ?: return false + if (!parent.isDirectory && !parent.mkdirs()) return false + val bytes = gson.toJson(model).toByteArray(Charsets.UTF_8) + if (bytes.size > MAX_MODEL_BYTES) return false + val temp = File(parent, ".${target.name}.${System.nanoTime()}.tmp") + return try { + FileOutputStream(temp).use { output -> + output.write(bytes) + output.fd.sync() + } + temp.renameTo(target) + } catch (_: Exception) { + false + } finally { + if (temp.exists()) temp.delete() + } + } + + companion object { + const val MAX_MODEL_BYTES = 16 * 1024 + } +} 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 new file mode 100644 index 0000000..90e834d --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModelTrainer.kt @@ -0,0 +1,139 @@ +package com.evsuite.hardware.telemetry.model + +import com.evsuite.hardware.BatteryPowerEvidence +import com.evsuite.hardware.CarPropertyEvidence +import com.evsuite.hardware.FirmwareInfo +import com.evsuite.hardware.telemetry.StoredTrip +import com.evsuite.hardware.telemetry.UnavailableReason +import kotlin.math.abs +import kotlin.math.sqrt + +sealed interface EnergyModelTrainingResult { + data class Ready(val model: EnergyModel) : EnergyModelTrainingResult + data class Unavailable(val reason: UnavailableReason) : EnergyModelTrainingResult +} + +/** Bounded O(n), O(1)-working-memory least-squares fit. Call from a worker thread. */ +class EnergyModelTrainer( + private val maxSamples: Int = MAX_TRAINING_SAMPLES, + private val maxResidualRmse: Double = MAX_RESIDUAL_RMSE_KWH_PER_100_KM, +) { + init { + require(maxSamples >= MIN_TRAINING_SAMPLES) + require(maxResidualRmse.isFinite() && maxResidualRmse > 0.0) + } + + /** Production entry point: the empty pre-CP-003 catalogue makes training fail closed. */ + fun train( + trips: List, + firmware: FirmwareInfo.Gen, + ): EnergyModelTrainingResult { + if (firmware == FirmwareInfo.Gen.UNKNOWN) { + return EnergyModelTrainingResult.Unavailable(UnavailableReason.UNSUPPORTED_FIRMWARE) + } + val evidence = CarPropertyEvidence.powerModelEvidence(firmware) + ?: return EnergyModelTrainingResult.Unavailable(UnavailableReason.UNVALIDATED_FIRMWARE) + return fit(trips, evidence) + } + + internal fun fit( + trips: List, + evidence: BatteryPowerEvidence, + ): EnergyModelTrainingResult { + val equations = LeastSquares3() + 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) + 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 + ) { + return EnergyModelTrainingResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + + val coefficients = equations.solve() + ?: return EnergyModelTrainingResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) + val rolling = coefficients[0] + val aero = coefficients[1] + val thermal = coefficients[2] + if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 || + coefficients.any { !it.isFinite() } + ) { + return EnergyModelTrainingResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + + var squaredError = 0.0 + val residualCount = forEachUsable(trips, evidence) { speed, temp, consumption -> + val prediction = rolling + aero * speed * speed + + thermal * abs(temp - EnergyModel.COMFORT_TEMP_CELSIUS) + val error = consumption - prediction + squaredError += error * error + } + val rmse = sqrt(squaredError / residualCount) + if (!rmse.isFinite() || rmse > maxResidualRmse) { + return EnergyModelTrainingResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + + val model = EnergyModel( + evidence = evidence, + rollingKwhPer100Km = rolling, + aeroKwhPer100KmPerSpeedSquared = aero, + thermalKwhPer100KmPerDegree = thermal, + residualRmseKwhPer100Km = rmse, + sampleCount = count, + envelope = EnergyModelEnvelope(minSpeed, maxSpeed, minTemp, maxTemp), + ) + return EnergyModelTrainingResult.Ready(model) + } + + private inline fun forEachUsable( + trips: List, + evidence: BatteryPowerEvidence, + block: (speedKmh: Double, outsideTempCelsius: Double, consumption: Double) -> Unit, + ): Int { + 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 + ) { + 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) + accepted++ + if (accepted == maxSamples) return accepted + } + } + return accepted + } + + companion object { + const val MIN_TRAINING_SAMPLES = 60 + const val MAX_TRAINING_SAMPLES = 50_000 + 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 + private const val MIN_TEMP_CELSIUS = -40.0 + private const val MAX_TEMP_CELSIUS = 60.0 + private const val MIN_SPEED_SPAN_KMH = 20.0 + private const val MIN_TEMPERATURE_SPAN_CELSIUS = 5.0 + private const val MAX_CONSUMPTION_KWH_PER_100_KM = 100.0 + } +} diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/LeastSquares3.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/LeastSquares3.kt new file mode 100644 index 0000000..3e93f2f --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/LeastSquares3.kt @@ -0,0 +1,80 @@ +package com.evsuite.hardware.telemetry.model + +import kotlin.math.abs + +/** + * The three-coefficient normal-equations fit both consumption models are built on. + * + * `constant + a × speed² + b × |outside − comfort|` is the same shape whether the left-hand + * side is kWh per 100 km or percent of charge per 100 km, and the arithmetic that solves it + * has no opinion about units. It lives here so there is one of it: a second copy would drift + * from this one on the day someone fixes a pivot and only remembers one file. + * + * O(1) working memory, O(n) in samples. Call from a worker thread. + */ +internal class LeastSquares3(private val features: Int = FEATURES) { + private val matrix = Array(features) { DoubleArray(features) } + private val vector = DoubleArray(features) + + init { + require(features in 2..FEATURES) { "two or three features" } + } + + fun add(speedKmh: Double, outsideTempCelsius: Double, consumption: Double) { + val speedSquared = speedKmh * speedKmh + val temperatureOffset = abs(outsideTempCelsius - EnergyModel.COMFORT_TEMP_CELSIUS) + for (row in 0 until features) { + val rowValue = feature(row, speedSquared, temperatureOffset) + vector[row] += rowValue * consumption + for (column in 0 until features) { + matrix[row][column] += rowValue * + feature(column, speedSquared, temperatureOffset) + } + } + } + + private fun feature(index: Int, speedSquared: Double, temperatureOffset: Double) = + when (index) { + 0 -> 1.0 + 1 -> speedSquared + else -> temperatureOffset + } + + /** + * The coefficients, or null when the points do not determine them. + * + * Two features is not a lesser fit, it is a different claim: a driver who has only ever + * recorded trips at one outside temperature has no evidence about temperature, and a + * thermal coefficient invented from that would be read as knowledge. The envelope keeps + * the narrow range, so such a model refuses the cold rather than guessing at it. + */ + fun solve(): DoubleArray? { + val augmented = Array(features) { row -> + DoubleArray(features + 1) { column -> + if (column == features) vector[row] else matrix[row][column] + } + } + for (column in 0 until features) { + val pivot = (column until features).maxBy { abs(augmented[it][column]) } + if (abs(augmented[pivot][column]) < MIN_PIVOT) return null + val swap = augmented[column] + augmented[column] = augmented[pivot] + augmented[pivot] = swap + val divisor = augmented[column][column] + for (index in column..features) augmented[column][index] /= divisor + for (row in 0 until features) { + if (row == column) continue + val factor = augmented[row][column] + for (index in column..features) { + augmented[row][index] -= factor * augmented[column][index] + } + } + } + return DoubleArray(features) { augmented[it][features] } + } + + private companion object { + const val FEATURES = 3 + const val MIN_PIVOT = 1e-9 + } +} 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 new file mode 100644 index 0000000..2d41e4c --- /dev/null +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/model/SocConsumptionModel.kt @@ -0,0 +1,351 @@ +package com.evsuite.hardware.telemetry.model + +import com.evsuite.hardware.FirmwareInfo +import com.evsuite.hardware.VehicleSpeedEvidence +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 kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt +import kotlin.math.sqrt + +/** + * Driver-specific consumption in **percent of charge per 100 km**. + * + * [EnergyModel] answers the same question in kWh and cannot be trained on a car that does not + * publish battery power — which is this car. `CarPropertyEvidence` records the evidence and + * the conclusion: SWI68 publishes no battery power, so the kWh fit can never train, and an + * arrival forecast here has to come from state of charge instead. + * + * **The unit is the point.** Converting a state of charge into kWh needs a pack capacity, and + * on this vehicle the capacity is a specification sheet, not a measurement — an unmeasured + * multiplier in front of every number the model produces. Fitting in percent removes it + * entirely: state of charge is published by the car, distance is the speed integral the app + * already gates on [VehicleSpeedEvidence], and every consumer of this — the charging-stop + * plan, the reserve, the route what-if — already thinks in percent. Nothing is converted, so + * nothing has to be assumed. + * + * It is not an SWI68 workaround. Every EV publishes a state of charge, so this fit works on + * generations that publish power too; [EnergyModel] remains for the energy breakdown, which + * genuinely needs to split kWh and has nothing to split without power. + * + * Same shape as [EnergyModel]: `rolling + aero × speed² + thermal × |outside − 20 °C|`, every + * prediction structurally [Provenanced] as estimated, and nothing predicted outside the + * envelope the fit actually saw. + */ +data class SocConsumptionModel( + val schemaVersion: Int = SCHEMA_VERSION, + /** The distance conversion behind every segment this was fitted from. */ + val speedEvidence: VehicleSpeedEvidence, + val rollingPercentPer100Km: Double, + val aeroPercentPer100KmPerSpeedSquared: Double, + val thermalPercentPer100KmPerDegree: Double, + val residualRmsePercentPer100Km: Double, + val segmentCount: Int, + val envelope: EnergyModelEnvelope, +) { + /** Percent of charge per 100 km at this speed and this outside temperature. */ + fun predict(speedKmh: Double, outsideTempCelsius: Double): Provenanced { + if (!isValid() || !envelope.contains(speedKmh, outsideTempCelsius)) { + return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + val value = rollingPercentPer100Km + + aeroPercentPer100KmPerSpeedSquared * speedKmh * speedKmh + + thermalPercentPer100KmPerDegree * abs(outsideTempCelsius - EnergyModel.COMFORT_TEMP_CELSIUS) + if (!value.isFinite() || value <= 0.0) { + return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + val band = max( + CONFIDENCE_MULTIPLIER * residualRmsePercentPer100Km, + value * EnergyModel.MIN_RELATIVE_UNCERTAINTY, + ) + return Provenanced.estimated(value, band) + } + + internal fun isValid(): Boolean = + schemaVersion == SCHEMA_VERSION && + rollingPercentPer100Km.isFinite() && rollingPercentPer100Km > 0.0 && + aeroPercentPer100KmPerSpeedSquared.isFinite() && + aeroPercentPer100KmPerSpeedSquared >= 0.0 && + thermalPercentPer100KmPerDegree.isFinite() && + thermalPercentPer100KmPerDegree >= 0.0 && + residualRmsePercentPer100Km.isFinite() && residualRmsePercentPer100Km >= 0.0 && + segmentCount >= SocConsumptionFitter.MIN_SEGMENTS && envelope.isValid() + + companion object { + const val SCHEMA_VERSION = 1 + private const val CONFIDENCE_MULTIPLIER = 1.96 + } +} + +sealed interface SocConsumptionFitResult { + data class Ready(val model: SocConsumptionModel) : SocConsumptionFitResult + data class Unavailable(val reason: UnavailableReason) : SocConsumptionFitResult +} + +/** + * Turns recorded tracks into the segments a percent-per-100-km fit can be built from. + * + * State of charge is a coarse signal — a step is around 1 % of the pack, which is four or five + * kilometres of motorway — so a per-sample reading says nothing and a fit built on one would + * be fitting quantisation. Segments are therefore accumulated until the charge has actually + * moved by [minSocDropPercent], and only then does one training point exist: the distance the + * speed integral covered, the mean temperature over it, and the charge it cost. + * + * A hole in the track ends the current segment rather than being bridged. A sampler suspended + * in a tunnel or by the driver leaving the screen loses distance it never recorded, and a + * segment spanning that hole would charge the missing kilometres' energy to the ones on either + * side of it. + * + * Bounded and allocation-light: one pass per trip, one small object per emitted segment, and + * a cap on how many segments a fit will look at. + */ +class SocConsumptionFitter( + private val minSocDropPercent: Double = MIN_SOC_DROP_PERCENT, + private val maxSegments: Int = MAX_SEGMENTS, + private val maxResidualRmse: Double = MAX_RESIDUAL_RMSE_PERCENT_PER_100_KM, +) { + /** One training point: what a stretch of road cost, and the conditions it cost it in. */ + internal data class Segment( + val distanceKm: Double, + val meanSpeedKmh: Double, + val meanOutsideTempCelsius: Double, + val socDropPercent: Double, + ) { + val percentPer100Km: Double get() = socDropPercent * 100.0 / distanceKm + } + + /** + * Whether a trip's stored distance was integrated under the conversion in force now. + * + * The distance is the speed integral, so a trip recorded under a conversion that is no + * longer believed correct contributes nothing. CP-036's 3.6x error is invisible in the + * stored number and would be fitted as a real consumption. + */ + private fun usable(trip: StoredTrip, generation: FirmwareInfo.Gen): Boolean = + trip.summary.speedEvidence?.matchesCurrent(generation) == true + + /** The training points a history yields, in one pass, capped at [maxSegments]. */ + private fun collectAll(trips: List, generation: FirmwareInfo.Gen): List { + val segments = ArrayList() + for (trip in trips) { + if (!usable(trip, generation)) continue + collect(trip.samples.orEmpty(), segments) + if (segments.size >= maxSegments) break + } + return segments + } + + /** + * What the history looks like to [fit], as one line for a diagnostic bundle. + * + * Two different gates answer [UnavailableReason.INSUFFICIENT_SAMPLES] — too few segments, + * and a speed span too narrow to separate rolling from aero — and the reason code cannot + * tell them apart. Two validation bundles reported that one word while asking the driver + * for opposite things: more kilometres, or one motorway leg. A third cause is silent + * 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. + */ + fun describe( + trips: List, + generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(), + ): String { + if (generation == FirmwareInfo.Gen.UNKNOWN) return "firmware=unknown" + val segments = collectAll(trips, generation) + val head = "used=${trips.count { usable(it, generation) }}/${trips.size} " + + "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" + } + + fun fit( + trips: List, + generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(), + ): SocConsumptionFitResult { + if (generation == FirmwareInfo.Gen.UNKNOWN) { + return SocConsumptionFitResult.Unavailable(UnavailableReason.UNSUPPORTED_FIRMWARE) + } + // Built from the passed generation rather than read from the device, so the gate can + // be exercised on the JVM. A correctness gate that only runs on a car is untested. + val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT) + + val segments = collectAll(trips, generation) + if (segments.size < MIN_SEGMENTS) { + return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES) + } + + 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 equations = LeastSquares3(features) + for (segment in segments) { + equations.add( + segment.meanSpeedKmh, + segment.meanOutsideTempCelsius, + segment.percentPer100Km, + ) + } + val coefficients = equations.solve() + ?: return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) + 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) + } + + var squaredError = 0.0 + for (segment in segments) { + val prediction = rolling + + aero * segment.meanSpeedKmh * segment.meanSpeedKmh + + thermal * abs( + segment.meanOutsideTempCelsius - EnergyModel.COMFORT_TEMP_CELSIUS + ) + val error = segment.percentPer100Km - prediction + squaredError += error * error + } + val rmse = sqrt(squaredError / segments.size) + if (!rmse.isFinite() || rmse > maxResidualRmse) { + return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + + val model = SocConsumptionModel( + speedEvidence = evidence, + rollingPercentPer100Km = rolling, + aeroPercentPer100KmPerSpeedSquared = aero, + thermalPercentPer100KmPerDegree = thermal, + residualRmsePercentPer100Km = rmse, + segmentCount = segments.size, + envelope = EnergyModelEnvelope(minSpeed, maxSpeed, minTemp, maxTemp), + ) + return if (model.isValid()) { + SocConsumptionFitResult.Ready(model) + } else { + SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED) + } + } + + /** + * Walks one track, emitting a segment each time the charge has moved enough to divide by. + * + * Internal rather than private so the segmentation can be tested on its own: what this + * refuses to build a segment from is most of what makes the fit trustworthy. + */ + internal fun collect(samples: List, out: MutableList) { + var startSoc: Double? = null + var distanceKm = 0.0 + var hours = 0.0 + var tempSum = 0.0 + var tempCount = 0 + + fun reset(soc: Double?) { + startSoc = soc + distanceKm = 0.0 + hours = 0.0 + tempSum = 0.0 + tempCount = 0 + } + + for (index in samples.indices) { + val sample = samples[index] + val soc = sample.socPercent?.toDouble()?.takeIf { it.isFinite() } + if (index == 0) { + reset(soc) + continue + } + val previous = samples[index - 1] + val previousSpeed = previous.speedKmh?.toDouble() + val speed = sample.speedKmh?.toDouble() + val durationMs = sample.atMs - previous.atMs + if (previousSpeed == null || speed == null || + !previousSpeed.isFinite() || !speed.isFinite() || + previousSpeed < 0.0 || speed < 0.0 || + durationMs !in 1..MAX_SAMPLE_GAP_MS + ) { + // Distance across the hole is unknown, and a segment that spanned it would + // charge the missing kilometres to the ones on either side. + reset(soc) + continue + } + val intervalHours = durationMs / MILLIS_PER_HOUR + distanceKm += (previousSpeed + speed) / 2.0 * intervalHours + hours += intervalHours + sample.outsideTempCelsius?.toDouble()?.takeIf { it.isFinite() }?.let { + tempSum += it + tempCount++ + } + + val start = startSoc + if (soc == null) continue + if (start == null) { + reset(soc) + continue + } + // Charging, or a pack rebalancing upward: the segment is no longer a drive. + if (soc > start) { + reset(soc) + continue + } + val drop = start - soc + if (drop < minSocDropPercent) continue + emit(distanceKm, hours, tempSum, tempCount, drop)?.let(out::add) + reset(soc) + if (out.size >= maxSegments) return + } + } + + private fun emit( + distanceKm: Double, + hours: Double, + tempSum: Double, + tempCount: Int, + dropPercent: Double, + ): Segment? { + if (tempCount == 0 || hours <= 0.0 || distanceKm < MIN_SEGMENT_KM) return null + val meanSpeed = distanceKm / hours + val meanTemp = tempSum / tempCount + if (meanSpeed !in MIN_SPEED_KMH..MAX_SPEED_KMH) return null + if (meanTemp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS) return null + val segment = Segment(distanceKm, meanSpeed, meanTemp, dropPercent) + if (segment.percentPer100Km !in 0.0..MAX_CONSUMPTION_PERCENT_PER_100_KM) return null + return segment + } + + companion object { + /** Below this the drop is quantisation, not consumption. */ + const val MIN_SOC_DROP_PERCENT = 2.0 + + /** Three coefficients from a dozen points is already generous. */ + const val MIN_SEGMENTS = 12 + const val MAX_SEGMENTS = 5_000 + const val MAX_RESIDUAL_RMSE_PERCENT_PER_100_KM = 12.0 + const val MIN_SEGMENT_KM = 2.0 + + private const val MIN_SPEED_KMH = 10.0 + private const val MAX_SPEED_KMH = 180.0 + private const val MIN_TEMP_CELSIUS = -40.0 + private const val MAX_TEMP_CELSIUS = 60.0 + private const val MIN_SPEED_SPAN_KMH = 20.0 + private const val MIN_TEMPERATURE_SPAN_CELSIUS = 5.0 + + /** A tenth of the pack every ten kilometres is not a car, it is a broken record. */ + private const val MAX_CONSUMPTION_PERCENT_PER_100_KM = 100.0 + private const val MAX_SAMPLE_GAP_MS = 120_000L + private const val MILLIS_PER_HOUR = 3_600_000.0 + } +} diff --git a/EVHardware/lib/src/main/res/values-de/strings.xml b/EVHardware/lib/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..9bdb950 --- /dev/null +++ b/EVHardware/lib/src/main/res/values-de/strings.xml @@ -0,0 +1,223 @@ + + + + Kontext + Umgebung + Fahren + Komfort + Audio + Fahrerassistenz + Klima + Profil + System + °C + km/h + % + Bef. + Eco + Normal + Sport + Schnee + Individuell + Schwach + Mittel + Stark + Adaptiv + Aus + Ein-Pedal + Nur Warnung + Warnung + Bremsung + Niedrig + Standard + Hoch + Aus + Warnung (LDW) + Assistent (LDP) + Notfall-Spurhaltung (ELK) + Aus + Adaptiver Tempomat (ACC) + Stauassistent (TJA) + Aus + Manuell + Intelligent + Verriegelt + Aus + Zubehör + Läuft + Startet + Bluetooth-Telefon verbunden + Beliebiges Bluetooth-Gerät verbunden + Telefon an Bord (Bluetooth) + Bluetooth-Freisprechtelefon + Uhrzeit + Wochentag + Genaues Datum + Firmware-Generation + Außentemperatur + Zündungszustand + Gang in P + Geschwindigkeit + Fahrmodus + Rekuperation + Energiesparen + Sitzheizung links + Sitzheizung rechts + Lenkradheizung + Medienlautstärke + Bildschirmhelligkeit + Notbremsung (AEB) + AEB-Modus + AEB-Empfindlichkeit + ELK-Modus + ELK-Empfindlichkeit + ACC-/TJA-Modus + Geschwindigkeitsbegrenzer + Verkehrszeichenerkennung (TSR) + Geschwindigkeitsalarm + Tempolimit-Ton + ADAS-Tonwarnung + Klimaanlage ein + Klima AUTO ein + Umluft ein + Gebläsestufe + Solltemperatur + Ein Fenster ist offen + Profil anwenden + Profil abfragen + Fahrmodus + Rekuperation + Ein-Pedal-Fahren + Energiesparen + Sitzheizung links + Sitzheizung rechts + Lenkradheizung + Bildschirmhelligkeit + Medienlautstärke + Balance links / rechts + Überblendung vorn / hinten + Klang + Bose-Klangprofil + 3D-Effekt + Geschwindigkeitsabhängige Lautstärke + Notbremsung (AEB) + AEB-Modus + AEB-Empfindlichkeit + ELK-Modus + ELK-Empfindlichkeit + ACC-/TJA-Modus + Geschwindigkeitsbegrenzer + Verkehrszeichenerkennung (TSR) + Geschwindigkeitsalarm + Tempolimit-Ton + ADAS-Tonwarnung + Spurverlassenswarnung Ton + Spurverlassen Lenkradvibration + App starten + Benachrichtigung anzeigen + Nachricht vorlesen + Energie + m + In der Nähe eines Ortes + Physische Tasten + Batteriestand + Lädt + Ladegrenze + Klimaanlage an + Klimaanlage ein/aus + Solltemperatur + Klimakompressor + Klimaautomatik + Umluft + Gebläsestufe + Frontscheibenheizung + Heckscheibenheizung + Ladegrenze + Laden erlauben + Ladezeitplan + Ladefenster + Batterievorheizung + Radio einschalten + Radioband und Sender + Nächster Radiosender + Vorheriger Radiosender + Radio stummschalten + Radio Wiedergabe/Pause + Radio-Bildschirm öffnen + AM + FM + DAB + Geschlossen + Offen + z. B. 103.5 oder AM 1080 + Navigieren zu + Bestätigung anfordern + z. B. Fenster öffnen? + Anrufen + Kontakt anrufen + Fensteröffnung + Türen verriegelt + Alle Fenster + Türverriegelung + Warten + s + Ladezustand + Ladezeitplan aktiv + Ladebeginn um + Ladeende um + Batterievorheizung aktiv + Temperatur Beifahrer + ECON-Modus aktiv + Frontscheibenheizung aktiv + Heckscheibenheizung aktiv + Vordertür offen + Temperatur Beifahrer + ECON-Modus + Nicht angeschlossen + Laden (AC) + Laden (DC) + Angeschlossen, lädt nicht + Ladung abgeschlossen + Ladefehler + Medienwiedergabe läuft + Radio läuft + WLAN-Netz + z. B. Zuhause + Anruf läuft + Fahrtdauer + Zufallschance + Min + Regel aktivieren + Regel deaktivieren + Mediensteuerung + Wiedergabe / Pause + Nächster Titel + Vorheriger Titel + Bluetooth + WLAN + km + Restreichweite + Fensteröffnung Fahrer + Fensteröffnung Beifahrer + Fensteröffnung hinten links + Fensteröffnung hinten rechts + Fenster Fahrer + Fenster Beifahrer + Fenster hinten links + Fenster hinten rechts + Kilometerstand + Wetter + Klar + Bewölkt + Regen + Schnee + Gewitter + Nebel + Wind + Stabilitätskontrolle (ESC) + Müdigkeitswarnung + Empfindlichkeit der Müdigkeitswarnung + Medienlautstärke, schrittweise + Heute verbrauchte Daten + Diesen Monat verbrauchte Daten + MB + diff --git a/EVHardware/lib/src/main/res/values-es/strings.xml b/EVHardware/lib/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..030b1b6 --- /dev/null +++ b/EVHardware/lib/src/main/res/values-es/strings.xml @@ -0,0 +1,223 @@ + + + + Contexto + Entorno + Conducción + Confort + Audio + Asistencia a la conducción + Climatización + Perfil + Sistema + °C + km/h + % + cmd + Eco + Normal + Sport + Nieve + Personalizado + Bajo + Medio + Alto + Adaptativo + Desactivado + Un pedal + Solo alerta + Alerta + frenado + Baja + Estándar + Alta + Desactivado + Aviso (LDW) + Asistencia (LDP) + Mantenimiento de emergencia (ELK) + Desactivado + Control de crucero adaptativo (ACC) + Asistente de atascos (TJA) + Desactivado + Manual + Inteligente + Bloqueado + Apagado + Accesorios + En marcha + Arrancando + Teléfono Bluetooth conectado + Cualquier dispositivo Bluetooth conectado + Teléfono a bordo (Bluetooth) + Teléfono manos libres Bluetooth + Hora del día + Día de la semana + Fecha exacta + Generación de firmware + Temperatura exterior + Estado del contacto + Marcha en P + Velocidad + Modo de conducción + Regeneración + Ahorro de energía + Calefacción asiento izquierdo + Calefacción asiento derecho + Calefacción del volante + Volumen multimedia + Brillo de pantalla + Frenado de emergencia (AEB) + Modo AEB + Sensibilidad AEB + Modo ELK + Sensibilidad ELK + Modo ACC / TJA + Limitador de velocidad + Reconocimiento de señales (TSR) + Alarma de exceso de velocidad + Tono de límite de velocidad + Aviso sonoro ADAS + Aire acondicionado encendido + Clima AUTO encendido + Recirculación de aire activada + Velocidad del ventilador + Temperatura ajustada + Una ventanilla está abierta + Aplicar un perfil + Pedir un perfil + Modo de conducción + Regeneración + Conducción con un pedal + Ahorro de energía + Calefacción asiento izquierdo + Calefacción asiento derecho + Calefacción del volante + Brillo de pantalla + Volumen multimedia + Balance izquierda / derecha + Fundido delante / detrás + Tono + Perfil de sonido Bose + Efecto 3D + Volumen adaptativo a la velocidad + Frenado de emergencia (AEB) + Modo AEB + Sensibilidad AEB + Modo ELK + Sensibilidad ELK + Modo ACC / TJA + Limitador de velocidad + Reconocimiento de señales (TSR) + Alarma de exceso de velocidad + Tono de límite de velocidad + Aviso sonoro ADAS + Aviso sonoro de cambio de carril + Vibración del volante por cambio de carril + Abrir una app + Mostrar una notificación + Decir un mensaje + Energía + m + Cerca de un lugar + Botones físicos + Nivel de batería + Cargando + Límite de carga + Climatización activa + Climatización on/off + Temperatura de consigna + Aire acondicionado + Climatización AUTO + Recirculación de aire + Velocidad del ventilador + Desempañador del parabrisas + Desempañador de la luneta + Límite de carga + Permitir la carga + Carga programada + Franja de carga + Precalentamiento de la batería + Poner la radio + Banda y emisora de radio + Emisora de radio siguiente + Emisora de radio anterior + Silenciar la radio + Radio: reproducir/pausar + Abrir la pantalla de radio + AM + FM + DAB + Cerrada + Abierta + p. ej. 103.5 o AM 1080 + Navegar hasta + Pedir confirmación + p. ej.: ¿abrir las ventanillas? + Llamar + Llamar a un contacto + Apertura de ventanillas + Puertas bloqueadas + Todas las ventanillas + Bloqueo de puertas + Esperar + s + Estado de carga + Carga programada activa + La carga empieza a las + La carga termina a las + Precalentamiento de batería activo + Temperatura del acompañante + Modo ECON activo + Desempañador delantero activo + Desempañador trasero activo + Puerta delantera abierta + Temperatura del acompañante + Modo ECON + Desconectado + Cargando (CA) + Cargando (CC) + Conectado, sin cargar + Carga completa + Fallo de carga + Reproducción en curso + Radio encendida + Red Wi-Fi + p. ej. Casa + Llamada en curso + Duración del trayecto + Probabilidad aleatoria + min + Activar una regla + Desactivar una regla + Control multimedia + Reproducir / pausar + Pista siguiente + Pista anterior + Bluetooth + Wi-Fi + km + Autonomía restante + Apertura ventanilla del conductor + Apertura ventanilla del acompañante + Apertura ventanilla trasera izquierda + Apertura ventanilla trasera derecha + Ventanilla del conductor + Ventanilla del acompañante + Ventanilla trasera izquierda + Ventanilla trasera derecha + Cuentakilómetros + Tiempo + Despejado + Nublado + Lluvia + Nieve + Tormenta + Niebla + Viento + Control de estabilidad (ESC) + Alerta de somnolencia + Sensibilidad de la alerta de somnolencia + Volumen multimedia, por pasos + Datos consumidos hoy + Datos consumidos este mes + MB + diff --git a/EVHardware/lib/src/main/res/values-fr/strings.xml b/EVHardware/lib/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..8b1891f --- /dev/null +++ b/EVHardware/lib/src/main/res/values-fr/strings.xml @@ -0,0 +1,245 @@ + + + + Contexte + Environnement + Conduite + Confort + Audio + Aides à la conduite + Climatisation + Profil + Système + °C + km/h + % + cmd + Eco + Normal + Sport + Neige + Personnalisé + Faible + Moyenne + Forte + Adaptative + Désactivée + Une pédale + Alerte seule + Alerte + freinage + Faible + Standard + Élevée + Désactivé + Alerte (LDW) + Assistance (LDP) + Maintien actif (ELK) + Désactivé + Régulateur adaptatif (ACC) + Assistance embouteillage (TJA) + Désactivé + Manuel + Intelligent + Verrouillé + Éteint + Accessoires + Démarré + Démarrage + Téléphone Bluetooth connecté + Un appareil Bluetooth est connecté + Téléphone à bord (Bluetooth) + Téléphone mains-libres Bluetooth + Heure + Jour de la semaine + Date précise + Génération de firmware + Température extérieure + État d\'allumage + Levier en position P + Vitesse + Mode de conduite + Récupération + Économie d\'énergie + Siège chauffant gauche + Siège chauffant droit + Volant chauffant + Volume média + Luminosité écran + Freinage d\'urgence (AEB) + Mode AEB + Sensibilité AEB + Mode ELK + Sensibilité ELK + Mode ACC / TJA + Limiteur de vitesse + Lecture des panneaux (TSR) + Alerte survitesse + Bip de limitation + Alerte sonore ADAS + Climatisation active + Mode AUTO climatisation + Recyclage d\'air actif + Vitesse ventilation + Température de consigne + Une vitre est ouverte + Appliquer un profil + Demander un profil + Mode de conduite + Récupération + Conduite une pédale + Économie d\'énergie + Siège chauffant gauche + Siège chauffant droit + Volant chauffant + Luminosité écran + Volume média + Balance gauche / droite + Balance avant / arrière + Tonalité + Ambiance Bose + Effet 3D + Volume adaptatif à la vitesse + Freinage d\'urgence (AEB) + Mode AEB + Sensibilité AEB + Mode ELK + Sensibilité ELK + Mode ACC / TJA + Limiteur de vitesse + Lecture des panneaux (TSR) + Alerte survitesse + Bip de limitation + Alerte sonore ADAS + Alerte sonore de voie + Vibration volant en sortie de voie + Lancer une application + Afficher une notification + Dire un message + Énergie + m + À proximité d’un lieu + Boutons physiques + Appui court sur le bouton étoile gauche + Appui long sur le bouton étoile gauche + Appui court sur le bouton étoile droit + Appui long sur le bouton étoile droit + Appui court sur le bouton assistant + Appui long sur le bouton assistant + Appui court sur le bouton téléphoneAppui long sur le bouton téléphone + Appui court sur le bouton centralAppui long sur le bouton central + Appui court sur volume plusAppui long sur volume plus + Appui court sur volume moinsAppui long sur volume moins + Appui court sur piste suivanteAppui long sur piste suivante + Appui court sur piste précédenteAppui long sur piste précédente + Appui court sur sourdineAppui long sur sourdine + Appui court sur hautAppui long sur haut + Appui court sur basAppui long sur bas + Appui court sur OKAppui long sur OK + Appui court sur gaucheAppui long sur gauche + Appui court sur droiteAppui long sur droite + Appui court sur sourceAppui long sur source + Niveau de batterie + En charge + Limite de charge + Climatisation active + Climatisation on/off + Température de consigne + Air conditionné + Climatisation AUTO + Recyclage de l’air + Vitesse de ventilation + Dégivrage pare-brise + Dégivrage lunette arrière + Limite de charge + Autoriser la charge + Charge programmée + Plage de charge + Préchauffage de la batterie + Mettre la radio + Bande et station radio + Station radio suivante + Station radio précédente + Couper la radio + Radio : lecture/pause + Ouvrir l’écran radio + AM + FM + DAB + Fermée + Ouverte + ex. 103.5 ou AM 1080 + Naviguer vers + Demander confirmation + ex. : ouvrir les vitres ? + Appeler un webhook + Appeler + Envoyer un SMS + ex. : j’arrive + Appeler un contact + Ouverture des vitres + Portes verrouillées + Toutes les vitres + Verrouillage des portes + Attendre + s + État de charge + Charge programmée active + Début de charge à + Fin de charge à + Préchauffage batterie actif + Température passager + Mode ECON actif + Dégivrage pare-brise actif + Dégivrage lunette arrière actif + Porte avant ouverte + Température passager + Mode ECON + Débranché + Charge (AC) + Charge (DC) + Branché, pas de charge + Charge terminée + Défaut de charge + Lecture en cours + Radio allumée + Réseau Wi-Fi + ex. Maison + Appel en cours + Durée du trajet + Chance aléatoire + min + Activer une règle + Désactiver une règle + Contrôle média + Lecture / pause + Piste suivante + Piste précédente + Bluetooth + Wi-Fi + km + Autonomie restante + Ouverture vitre conducteur + Ouverture vitre passager + Ouverture vitre arrière gauche + Ouverture vitre arrière droite + Vitre conducteur + Vitre passager + Vitre arrière gauche + Vitre arrière droite + Compteur kilométrique + Météo + Ciel dégagé + Nuageux + Pluie + Neige + Orage + Brouillard + Vent + Contrôle de stabilité (ESC) + Alerte de somnolence + Sensibilité de l\'alerte de somnolence + Volume média, par crans + Données consommées aujourd\'hui + Données consommées ce mois-ci + Mo + diff --git a/EVHardware/lib/src/main/res/values-it/strings.xml b/EVHardware/lib/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..ba03518 --- /dev/null +++ b/EVHardware/lib/src/main/res/values-it/strings.xml @@ -0,0 +1,223 @@ + + + + Contesto + Ambiente + Guida + Comfort + Audio + Assistenza alla guida + Clima + Profilo + Sistema + °C + km/h + % + cmd + Eco + Normale + Sport + Neve + Personalizzato + Bassa + Media + Alta + Adattivo + Disattivato + Un pedale + Solo avviso + Avviso + frenata + Bassa + Standard + Alta + Disattivato + Avviso (LDW) + Assistenza (LDP) + Mantenimento d\'emergenza (ELK) + Disattivato + Cruise control adattivo (ACC) + Assistente ingorghi (TJA) + Disattivato + Manuale + Intelligente + Bloccato + Spento + Accessori + In funzione + Avviamento + Telefono Bluetooth connesso + Qualsiasi dispositivo Bluetooth connesso + Telefono a bordo (Bluetooth) + Telefono vivavoce Bluetooth + Ora del giorno + Giorno della settimana + Data esatta + Generazione firmware + Temperatura esterna + Stato del quadro + Marcia in P + Velocità + Modalità di guida + Rigenerazione + Risparmio energetico + Riscaldamento sedile sinistro + Riscaldamento sedile destro + Riscaldamento volante + Volume multimediale + Luminosità schermo + Frenata d\'emergenza (AEB) + Modalità AEB + Sensibilità AEB + Modalità ELK + Sensibilità ELK + Modalità ACC / TJA + Limitatore di velocità + Riconoscimento segnali (TSR) + Allarme velocità + Tono limite di velocità + Avviso sonoro ADAS + Aria condizionata accesa + Clima AUTO acceso + Ricircolo aria attivo + Velocità ventola + Temperatura impostata + Un finestrino è aperto + Applica un profilo + Chiedi un profilo + Modalità di guida + Rigenerazione + Guida a un pedale + Risparmio energetico + Riscaldamento sedile sinistro + Riscaldamento sedile destro + Riscaldamento volante + Luminosità schermo + Volume multimediale + Bilanciamento sinistra / destra + Dissolvenza anteriore / posteriore + Tono + Profilo audio Bose + Effetto 3D + Volume adattivo alla velocità + Frenata d\'emergenza (AEB) + Modalità AEB + Sensibilità AEB + Modalità ELK + Sensibilità ELK + Modalità ACC / TJA + Limitatore di velocità + Riconoscimento segnali (TSR) + Allarme velocità + Tono limite di velocità + Avviso sonoro ADAS + Avviso sonoro cambio corsia + Vibrazione volante cambio corsia + Avvia un\'app + Mostra una notifica + Pronuncia un messaggio + Energia + m + Vicino a un luogo + Pulsanti fisici + Livello batteria + In carica + Limite di carica + Climatizzatore acceso + Climatizzatore on/off + Temperatura impostata + Aria condizionata + Climatizzatore AUTO + Ricircolo dell’aria + Velocità ventola + Sbrinamento parabrezza + Sbrinamento lunotto + Limite di carica + Consenti la carica + Carica programmata + Finestra di carica + Preriscaldamento batteria + Accendi la radio + Banda e stazione radio + Stazione radio successiva + Stazione radio precedente + Silenzia la radio + Radio: riproduci/pausa + Apri la schermata radio + AM + FM + DAB + Chiuso + Aperto + es. 103.5 o AM 1080 + Naviga verso + Chiedere conferma + es. aprire i finestrini? + Chiama + Chiama un contatto + Apertura finestrini + Porte bloccate + Tutti i finestrini + Blocco porte + Attendi + s + Stato di ricarica + Ricarica programmata attiva + La ricarica inizia alle + La ricarica termina alle + Preriscaldamento batteria attivo + Temperatura passeggero + Modalità ECON attiva + Sbrinatore parabrezza attivo + Sbrinatore lunotto attivo + Porta anteriore aperta + Temperatura passeggero + Modalità ECON + Scollegato + In ricarica (CA) + In ricarica (CC) + Collegato, non in ricarica + Ricarica completata + Guasto di ricarica + Riproduzione in corso + Radio accesa + Rete Wi-Fi + es. Casa + Chiamata in corso + Durata del viaggio + Probabilità casuale + min + Attiva una regola + Disattiva una regola + Controllo multimediale + Riproduci / pausa + Brano successivo + Brano precedente + Bluetooth + Wi-Fi + km + Autonomia residua + Apertura finestrino guidatore + Apertura finestrino passeggero + Apertura finestrino posteriore sinistro + Apertura finestrino posteriore destro + Finestrino guidatore + Finestrino passeggero + Finestrino posteriore sinistro + Finestrino posteriore destro + Contachilometri + Meteo + Sereno + Nuvoloso + Pioggia + Neve + Temporale + Nebbia + Vento + Controllo di stabilità (ESC) + Allerta sonnolenza + Sensibilità dell\'allerta sonnolenza + Volume media, a passi + Dati consumati oggi + Dati consumati questo mese + MB + diff --git a/EVHardware/lib/src/main/res/values-pt/strings.xml b/EVHardware/lib/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..86444e2 --- /dev/null +++ b/EVHardware/lib/src/main/res/values-pt/strings.xml @@ -0,0 +1,223 @@ + + + + Contexto + Ambiente + Condução + Conforto + Áudio + Assistência à condução + Climatização + Perfil + Sistema + °C + km/h + % + cmd + Eco + Normal + Sport + Neve + Personalizado + Baixo + Médio + Alto + Adaptativo + Desativado + Um pedal + Apenas alerta + Alerta + travagem + Baixa + Padrão + Alta + Desativado + Aviso (LDW) + Assistência (LDP) + Manutenção de emergência (ELK) + Desativado + Piloto automático adaptativo (ACC) + Assistente de congestionamento (TJA) + Desativado + Manual + Inteligente + Bloqueado + Desligado + Acessórios + Em funcionamento + A arrancar + Telefone Bluetooth ligado + Qualquer dispositivo Bluetooth ligado + Telefone a bordo (Bluetooth) + Telefone mãos-livres Bluetooth + Hora do dia + Dia da semana + Data exata + Geração de firmware + Temperatura exterior + Estado da ignição + Mudança em P + Velocidade + Modo de condução + Regeneração + Poupança de energia + Aquecimento banco esquerdo + Aquecimento banco direito + Aquecimento do volante + Volume multimédia + Brilho do ecrã + Travagem de emergência (AEB) + Modo AEB + Sensibilidade AEB + Modo ELK + Sensibilidade ELK + Modo ACC / TJA + Limitador de velocidade + Reconhecimento de sinais (TSR) + Alarme de excesso de velocidade + Tom de limite de velocidade + Aviso sonoro ADAS + Ar condicionado ligado + Clima AUTO ligado + Recirculação de ar ativa + Velocidade da ventoinha + Temperatura definida + Uma janela está aberta + Aplicar um perfil + Pedir um perfil + Modo de condução + Regeneração + Condução com um pedal + Poupança de energia + Aquecimento banco esquerdo + Aquecimento banco direito + Aquecimento do volante + Brilho do ecrã + Volume multimédia + Balanço esquerda / direita + Fader frente / trás + Tom + Perfil de som Bose + Efeito 3D + Volume adaptado à velocidade + Travagem de emergência (AEB) + Modo AEB + Sensibilidade AEB + Modo ELK + Sensibilidade ELK + Modo ACC / TJA + Limitador de velocidade + Reconhecimento de sinais (TSR) + Alarme de excesso de velocidade + Tom de limite de velocidade + Aviso sonoro ADAS + Aviso sonoro de saída de faixa + Vibração do volante por saída de faixa + Abrir uma app + Mostrar uma notificação + Dizer uma mensagem + Energia + m + Perto de um local + Botões físicos + Nível da bateria + A carregar + Limite de carga + Climatização ligada + Climatização on/off + Temperatura definida + Ar condicionado + Climatização AUTO + Recirculação do ar + Velocidade da ventoinha + Desembaciador do para-brisas + Desembaciador do óculo traseiro + Limite de carga + Permitir o carregamento + Carregamento programado + Janela de carregamento + Pré-aquecimento da bateria + Ligar o rádio + Banda e estação de rádio + Estação de rádio seguinte + Estação de rádio anterior + Silenciar o rádio + Rádio: reproduzir/pausa + Abrir o ecrã do rádio + AM + FM + DAB + Fechado + Aberto + ex. 103.5 ou AM 1080 + Navegar para + Pedir confirmação + ex.: abrir os vidros? + Ligar + Ligar para um contacto + Abertura dos vidros + Portas trancadas + Todos os vidros + Trancamento das portas + Esperar + s + Estado de carregamento + Carregamento programado ativo + Carregamento começa às + Carregamento termina às + Pré-aquecimento da bateria ativo + Temperatura do passageiro + Modo ECON ativo + Desembaciador dianteiro ativo + Desembaciador traseiro ativo + Porta dianteira aberta + Temperatura do passageiro + Modo ECON + Desligado + A carregar (CA) + A carregar (CC) + Ligado, sem carregar + Carga concluída + Falha de carregamento + Reprodução em curso + Rádio ligado + Rede Wi-Fi + p. ex. Casa + Chamada em curso + Duração da viagem + Probabilidade aleatória + min + Ativar uma regra + Desativar uma regra + Controlo multimédia + Reproduzir / pausa + Faixa seguinte + Faixa anterior + Bluetooth + Wi-Fi + km + Autonomia restante + Abertura do vidro do condutor + Abertura do vidro do passageiro + Abertura do vidro traseiro esquerdo + Abertura do vidro traseiro direito + Vidro do condutor + Vidro do passageiro + Vidro traseiro esquerdo + Vidro traseiro direito + Conta-quilómetros + Meteorologia + Céu limpo + Nublado + Chuva + Neve + Trovoada + Nevoeiro + Vento + Controlo de estabilidade (ESC) + Alerta de sonolência + Sensibilidade do alerta de sonolência + Volume de multimédia, por passos + Dados consumidos hoje + Dados consumidos este mês + MB + diff --git a/EVHardware/lib/src/main/res/values/strings.xml b/EVHardware/lib/src/main/res/values/strings.xml new file mode 100644 index 0000000..838e38b --- /dev/null +++ b/EVHardware/lib/src/main/res/values/strings.xml @@ -0,0 +1,245 @@ + + + + Context + Environment + Driving + Comfort + Audio + Driver assistance + Climate + Profile + System + °C + km/h + % + cmd + Eco + Normal + Sport + Snow + Custom + Low + Medium + High + Adaptive + Off + One pedal + Alert only + Alert + braking + Low + Standard + High + Off + Warning (LDW) + Assist (LDP) + Emergency keeping (ELK) + Off + Adaptive cruise (ACC) + Traffic jam assist (TJA) + Off + Manual + Intelligent + Locked + Off + Accessory + Running + Starting + Bluetooth phone connected + Any Bluetooth device connected + Phone on board (Bluetooth) + Bluetooth hands-free phone + Time of day + Day of week + Exact date + Firmware generation + Outside temperature + Ignition state + Gear in P + Speed + Drive mode + Regeneration + Energy saving + Left seat heating + Right seat heating + Steering wheel heating + Media volume + Screen brightness + Emergency braking (AEB) + AEB mode + AEB sensitivity + ELK mode + ELK sensitivity + ACC / TJA mode + Speed limiter + Traffic sign recognition (TSR) + Overspeed alarm + Speed limit tone + ADAS sound warning + Air conditioning on + Climate AUTO on + Air recirculation on + Fan speed + Set temperature + A window is open + Apply a profile + Ask for a profile + Drive mode + Regeneration + One pedal driving + Energy saving + Left seat heating + Right seat heating + Steering wheel heating + Screen brightness + Media volume + Left / right balance + Front / rear fader + Tone + Bose sound profile + 3D effect + Speed-adaptive volume + Emergency braking (AEB) + AEB mode + AEB sensitivity + ELK mode + ELK sensitivity + ACC / TJA mode + Speed limiter + Traffic sign recognition (TSR) + Overspeed alarm + Speed limit tone + ADAS sound warning + Lane departure sound warning + Lane departure steering vibration + Launch an app + Show a notification + Speak a message + Energy + m + Near a place + Physical buttons + Left star button short press + Left star button long press + Right star button short press + Right star button long press + Assistant button short press + Assistant button long press + Phone button short pressPhone button long press + Center button short pressCenter button long press + Volume up short pressVolume up long press + Volume down short pressVolume down long press + Next track short pressNext track long press + Previous track short pressPrevious track long press + Mute button short pressMute button long press + Up button short pressUp button long press + Down button short pressDown button long press + OK button short pressOK button long press + Left button short pressLeft button long press + Right button short pressRight button long press + Source button short pressSource button long press + Battery level + Charging + Charge limit + Climate on + Climate on/off + Cabin temperature + Air conditioning + Climate AUTO + Air recirculation + Fan level + Windscreen defroster + Rear window defroster + Charge limit + Allow charging + Scheduled charging + Charging window + Battery pre-heating + Play the radio + Radio band and station + Next radio station + Previous radio station + Silence the radio + Radio play/pause + Open the radio screen + AM + FM + DAB + Closed + Open + e.g. 103.5 or AM 1080 + Navigate to + Ask for confirmation + e.g. Open the windows? + Call webhook + Call + Send a text message + e.g. On my way + Call a contact + Window opening + Doors locked + All windows + Door locks + Wait + s + Charging state + Scheduled charging on + Charging starts at + Charging stops at + Battery pre-heating on + Passenger temperature + ECON mode on + Windscreen defroster on + Rear window defroster on + Front door open + Passenger temperature + ECON mode + Unplugged + Charging (AC) + Charging (DC) + Plugged in, not charging + Charge complete + Charging fault + Media playing + Radio playing + Wi-Fi network + e.g. Home + Call in progress + Drive duration + Random chance + min + Enable a rule + Disable a rule + Media control + Play / pause + Next track + Previous track + Bluetooth + Wi-Fi + km + Remaining range + Driver window opening + Passenger window opening + Rear left window opening + Rear right window opening + Driver window + Passenger window + Rear left window + Rear right window + Odometer + Weather + Clear + Cloudy + Rain + Snow + Thunderstorm + Fog + Wind + Stability control (ESC) + Drowsiness warning + Drowsiness warning sensitivity + Media volume, step + Data used today + Data used this month + MB + diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/CarPropertyEvidenceTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/CarPropertyEvidenceTest.kt new file mode 100644 index 0000000..51e5200 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/CarPropertyEvidenceTest.kt @@ -0,0 +1,168 @@ +package com.evsuite.hardware + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * A property the firmware declares and never publishes answers with a zero that looks exactly + * like a measurement. The status is the only thing that tells the two apart, so what counts as + * evidence is pinned here rather than left to the reflection that cannot be unit-tested. + */ +class CarPropertyEvidenceTest { + + @Test + fun `an available value is the measurement`() { + assertEquals(21.5f, CarPropertyEvidence.accept(21.5f, CarPropertyEvidence.STATUS_AVAILABLE)) + } + + @Test + fun `zero is a measurement when the vehicle stands behind it`() { + assertEquals(0f, CarPropertyEvidence.accept(0f, CarPropertyEvidence.STATUS_AVAILABLE)) + } + + @Test + fun `a declared but unpublished property is not a zero reading`() { + assertNull(CarPropertyEvidence.accept(0f, CarPropertyEvidence.STATUS_UNAVAILABLE)) + } + + @Test + fun `a faulty signal is not a reading either`() { + assertNull(CarPropertyEvidence.accept(42f, CarPropertyEvidence.STATUS_ERROR)) + } + + @Test + fun `an unknown status is refused rather than guessed`() { + assertNull(CarPropertyEvidence.accept(42f, 7)) + } + + @Test + fun `a null value stays null whatever the status claims`() { + assertNull(CarPropertyEvidence.accept(null, CarPropertyEvidence.STATUS_AVAILABLE)) + } + + @Test + fun `every status is named so the diagnostics report reads`() { + assertEquals("available", CarPropertyEvidence.describe(CarPropertyEvidence.STATUS_AVAILABLE)) + assertEquals("unavailable", CarPropertyEvidence.describe(CarPropertyEvidence.STATUS_UNAVAILABLE)) + assertEquals("error", CarPropertyEvidence.describe(CarPropertyEvidence.STATUS_ERROR)) + assertEquals("status 7", CarPropertyEvidence.describe(7)) + } + + @Test + fun `battery power remains unvalidated until vehicle evidence names a generation`() { + FirmwareInfo.Gen.values().forEach { firmware -> + assertFalse( + firmware.name, + CarPropertyEvidence.isValidated( + CarPropertyEvidence.Signal.BATTERY_POWER_KW, + firmware, + ), + ) + assertNull(CarPropertyEvidence.batteryPowerEvidence(firmware)) + } + } + + @Test + fun `battery temperature remains unvalidated until vehicle evidence names a generation`() { + FirmwareInfo.Gen.values().forEach { firmware -> + assertFalse( + firmware.name, + CarPropertyEvidence.isValidated( + CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS, + firmware, + ), + ) + } + } +} + +/** + * One unreadable property sampled every second filled the whole log buffer with the same line + * and pushed every other entry out of it, so the throttle is part of the diagnostics being + * usable at all. + */ +class ReadFailureLogTest { + + @Before + fun reset() = ReadFailureLog.reset() + + @Test + fun `the same failure is worth one line`() { + assertTrue(ReadFailureLog.isNew("0x11600207", "unavailable")) + assertFalse(ReadFailureLog.isNew("0x11600207", "unavailable")) + assertFalse(ReadFailureLog.isNew("0x11600207", "unavailable")) + } + + @Test + fun `a failure that changes its story is worth another`() { + assertTrue(ReadFailureLog.isNew("0x11600207", "unavailable")) + assertTrue(ReadFailureLog.isNew("0x11600207", "PropertyNotAvailableException")) + assertFalse(ReadFailureLog.isNew("0x11600207", "PropertyNotAvailableException")) + } + + @Test + fun `properties are throttled apart`() { + assertTrue(ReadFailureLog.isNew("0x11600207", "unavailable")) + assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable")) + } + + @Test + fun `a property that recovers reports its next failure`() { + assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable")) + ReadFailureLog.clear("0x1160030e") + assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable")) + } + + @Test + fun `a new vehicle session starts from silence`() { + assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable")) + ReadFailureLog.reset() + assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable")) + } +} + +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 `absent is not the same claim as validated`() { + // The catalogue says the vehicle does not publish it, which is precisely why it can + // never be validated. Both must be false-for-validated and true-for-absent at once. + assertFalse( + CarPropertyEvidence.isValidated( + CarPropertyEvidence.Signal.BATTERY_POWER_KW, + FirmwareInfo.Gen.SWI68, + ) + ) + assertTrue( + CarPropertyEvidence.isNeverPublished( + CarPropertyEvidence.Signal.BATTERY_POWER_KW, + FirmwareInfo.Gen.SWI68, + ) + ) + } + + @Test fun `a generation nobody captured claims nothing either way`() { + listOf(FirmwareInfo.Gen.SWI69, FirmwareInfo.Gen.SWI131, FirmwareInfo.Gen.SWI133) + .forEach { generation -> + CarPropertyEvidence.Signal.entries.forEach { signal -> + assertFalse( + "$signal on $generation was never captured", + CarPropertyEvidence.isNeverPublished(signal, generation), + ) + assertFalse(CarPropertyEvidence.isValidated(signal, generation)) + } + } + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/FirmwareSupportTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/FirmwareSupportTest.kt new file mode 100644 index 0000000..4bca781 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/FirmwareSupportTest.kt @@ -0,0 +1,148 @@ +package com.evsuite.hardware + +import com.evsuite.hardware.catalog.ConditionType +import com.evsuite.hardware.catalog.ActionType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Guards the firmware annotations and regenerates the matrix. + * + * The annotations are the single source of truth: if a vehicle entry loses its + * `@SupportedOn`, the runtime filter would silently show it on every firmware, and the + * README matrix would go stale. These tests catch both. + */ +class FirmwareSupportTest { + + /** Catalogue entries that are firmware-independent — no annotation expected. */ + private val firmwareIndependentConditions = setOf( + ConditionType.BT_DEVICE_CONNECTED, + ConditionType.ANY_BT_CONNECTED, + ConditionType.BT_DEVICE_ONBOARD, + ConditionType.BT_DEVICE_HANDSFREE, + ConditionType.TIME_OF_DAY, + ConditionType.DAY_OF_WEEK, + ConditionType.DATE, + ConditionType.FIRMWARE_GEN, + ConditionType.LOCATION_WITHIN, + ConditionType.PHYSICAL_BUTTON, + // Lus dans Android, pas dans la voiture : rien à router par génération. + ConditionType.MEDIA_PLAYING, + ConditionType.WIFI_SSID, + ConditionType.IN_CALL, + ConditionType.DRIVE_DURATION, + ConditionType.RANDOM_CHANCE, + // Services des applications du poste (adaptateur de navigation, météo) : ce ne sont + // pas des services du SDK véhicule, rien ne les route par génération. Ils répondent + // ou non, comme le SMS. + ConditionType.ODOMETER, + ConditionType.WEATHER_NOW, + // La connexion du poste, comptée par Android : elle dépend d'une permission, pas + // d'une génération de firmware. + ConditionType.DATA_USED_TODAY, + ConditionType.DATA_USED_MONTH + ) + private val firmwareIndependentActions = setOf( + ActionType.LAUNCH_APP, + ActionType.SHOW_NOTIFICATION, + ActionType.SPEAK_TEXT, + ActionType.NAVIGATE_TO, + ActionType.WEBHOOK, + ActionType.ASK_CONFIRM, + ActionType.DELAY, + // Le SMS part par le profil Bluetooth MAP, pas par un service SAIC : rien à router + // par génération de firmware. Sa disponibilité se lit au bind, pas dans la matrice. + ActionType.SEND_SMS, + // Règles, touche média et radios du poste : rien de tout cela ne passe par la voiture. + ActionType.ENABLE_RULE, + ActionType.DISABLE_RULE, + ActionType.MEDIA_CONTROL, + ActionType.SET_BLUETOOTH, + ActionType.SET_WIFI + ) + + @Test + fun `every vehicle condition declares firmware support`() { + ConditionType.entries + .filterNot { it in firmwareIndependentConditions || it.eventDriven } + .forEach { + assertTrue("${it.name} is missing @SupportedOn", FirmwareSupport.gensOf(it) != null) + } + } + + @Test + fun `every vehicle action declares firmware support`() { + ActionType.entries + .filterNot { it in firmwareIndependentActions } + .forEach { + assertTrue("${it.name} is missing @SupportedOn", FirmwareSupport.gensOf(it) != null) + } + } + + @Test + fun `context entries stay firmware-independent`() { + firmwareIndependentConditions.forEach { + assertNull("${it.name} should not be firmware-specific", FirmwareSupport.gensOf(it)) + } + ConditionType.entries.filter { it.eventDriven }.forEach { + assertNull("${it.name} should not be firmware-specific", FirmwareSupport.gensOf(it)) + } + firmwareIndependentActions.forEach { + assertNull("${it.name} should not be firmware-specific", FirmwareSupport.gensOf(it)) + } + } + + @Test + fun `seat heating is limited to the generations that have it`() { + // EVProfile FirmwareInfo.hasHeatFeatures(): SWI133, SWI68, SWI165 only. + assertEquals( + setOf(FirmwareGen.SWI133, FirmwareGen.SWI68, FirmwareGen.SWI165), + FirmwareSupport.gensOf(ConditionType.STEERING_HEAT) + ) + } + + @Test + fun `ACC TJA is unavailable on SWI133`() { + // SWI133 uses getMixedIntelligentDrive, not the getAccTjaState path. + assertFalse(FirmwareSupport.isSupported(ConditionType.ACC_TJA_MODE, FirmwareGen.SWI133)) + assertTrue(FirmwareSupport.isSupported(ConditionType.ACC_TJA_MODE, FirmwareGen.SWI68)) + } + + @Test + fun `unknown firmware hides nothing`() { + // No bridge / no reported firmware: filtering on a guess would be worse than + // offering an entry that later refuses. + ConditionType.entries.forEach { + assertTrue("${it.name} hidden with unknown firmware", FirmwareSupport.isSupported(it, null)) + } + } + + @Test + fun `snapshot firmware strings parse to enum`() { + assertEquals(FirmwareGen.SWI68, FirmwareSupport.parse("SWI68")) + assertEquals(FirmwareGen.SWI133, FirmwareSupport.parse("swi133")) + assertNull(FirmwareSupport.parse("SWI999")) + assertNull(FirmwareSupport.parse(null)) + } + + /** + * Regenerates the matrix and fails if the committed copy is stale. Regenerate with: + * `./gradlew testDebugUnitTest` then commit `docs/firmware-matrix.md`. + */ + @Test + fun `firmware matrix is up to date`() { + val generated = FirmwareMatrix.render() + val file = File("../docs/firmware-matrix.md") + if (!file.exists() || file.readText().trim() != generated.trim()) { + file.parentFile?.mkdirs() + file.writeText(generated) + } + // Re-read: after the write the file must match, so a stale commit is the only + // way this differs — and the write above fixes it locally for the next run. + assertEquals(generated.trim(), file.readText().trim()) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/FirmwareWritePolicyTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/FirmwareWritePolicyTest.kt new file mode 100644 index 0000000..6a0814e --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/FirmwareWritePolicyTest.kt @@ -0,0 +1,23 @@ +package com.evsuite.hardware + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FirmwareWritePolicyTest { + + @Test + fun `known generations are recognized`() { + FirmwareInfo.Gen.values() + .filterNot { it == FirmwareInfo.Gen.UNKNOWN } + .forEach { generation -> + assertEquals(generation, FirmwareInfo.generationOf("${generation.name}-build")) + } + } + + @Test + fun `missing and unrecognized firmware stay unknown`() { + assertEquals(FirmwareInfo.Gen.UNKNOWN, FirmwareInfo.generationOf(null)) + assertEquals(FirmwareInfo.Gen.UNKNOWN, FirmwareInfo.generationOf("")) + assertEquals(FirmwareInfo.Gen.UNKNOWN, FirmwareInfo.generationOf("SWI999-test")) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/GlassEvidenceTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/GlassEvidenceTest.kt new file mode 100644 index 0000000..d09c6a5 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/GlassEvidenceTest.kt @@ -0,0 +1,57 @@ +package com.evsuite.hardware + +import com.evsuite.hardware.catalog.ActionType +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Ce que la preuve relevée sur une voiture change — et ce qu'elle ne change pas. + * + * L'enjeu : `writeProven` parle du projet, `effectProven` parle de la voiture. Confondre les + * deux rendrait soit les actions vitres définitivement inaccessibles, soit accessibles sur des + * voitures où rien n'a été constaté. + */ +class GlassEvidenceTest { + + private val generation get() = FirmwareInfo.getGeneration().name + + @After + fun tearDown() = GlassEvidence.setForTest(null) + + @Test + fun `sans preuve, effectProven suit writeProven`() { + GlassEvidence.setForTest(null) + ActionType.entries.forEach { + assertTrue("${it.name}", it.writeProven == it.effectProven) + } + } + + @Test + fun `la preuve debloque les actions vitres, et elles seules`() { + GlassEvidence.setForTest(GlassEvidence.Proof(5, 2, generation, 0L)) + + GlassEvidence.ACTIONS.forEach { + assertFalse("${it.name} reste non prouvée dans le catalogue", it.writeProven) + assertTrue("${it.name} doit être débloquée par la preuve", it.effectProven) + } + // Une preuve sur les vitres ne dit rien d'une autre action : le catalogue reste seul + // juge partout ailleurs. + ActionType.entries.filterNot { it in GlassEvidence.ACTIONS }.forEach { + assertTrue("${it.name}", it.writeProven == it.effectProven) + } + } + + @Test + fun `une preuve d'un autre firmware ne vaut rien`() { + // Les commandes sont propres au SDK de la génération : ce qui ouvre sur l'une n'a + // aucune raison d'ouvrir sur l'autre, et une mise à jour ne doit pas laisser derrière + // elle une autorisation qui n'a plus de fondement. + GlassEvidence.setForTest(GlassEvidence.Proof(5, 2, "SOME_OTHER_GEN", 0L)) + + GlassEvidence.ACTIONS.forEach { + assertFalse("${it.name} ne doit pas hériter d'une preuve étrangère", it.effectProven) + } + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/GlassProbeTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/GlassProbeTest.kt new file mode 100644 index 0000000..35673a2 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/GlassProbeTest.kt @@ -0,0 +1,134 @@ +package com.evsuite.hardware + +import com.evsuite.hardware.saic.SaicVehicleControl.Window +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Ce que la sonde doit établir sans voiture : l'enchaînement des deux passes et les refus. + * + * La vitre simulée reproduit le comportement qui a rendu la sonde nécessaire : le service + * accepte n'importe quelle commande et n'en applique que deux. Une passe unique ne peut donc + * pas voir les deux sens — c'est exactement ce que ces tests vérifient. + */ +class GlassProbeTest { + + /** Vitre où [openCommand] ouvre en grand et [closeCommand] referme ; le reste est inerte. */ + private class FakeGlass( + private val openCommand: Int, + private val closeCommand: Int, + var percent: Int = 0, + ) : GlassProbe.Glass { + val sent = mutableListOf() + var readable = true + override fun position(window: Window): Int? = percent.takeIf { readable } + override fun send(window: Window, command: Int): Boolean { + sent += command + when (command) { + openCommand -> percent = 100 + closeCommand -> percent = 0 + } + return true + } + } + + private fun probe( + glass: GlassProbe.Glass, + stopped: () -> Boolean = { true }, + ignitionRun: () -> Boolean = { true }, + ) = GlassProbe.run( + window = Window.DRIVER, + glass = glass, + stopped = stopped, + ignitionRun = ignitionRun, + sleep = {}, + ) + + @Test + fun `les deux sens sont trouves depuis une vitre fermee`() { + // Depuis 0 %, la commande qui ferme ne bouge rien : sans seconde passe elle passerait + // pour inerte, et l'action « fermer » resterait à jamais non prouvée. + val glass = FakeGlass(openCommand = 5, closeCommand = 2) + val result = probe(glass) + + assertEquals(5, result.openCommand) + assertEquals(2, result.closeCommand) + assertTrue(result.proven) + assertNull(result.refusal) + // Repartie de 0 %, la vitre doit y revenir : une sonde qui laisse la vitre ouverte + // est un défaut, pas un résultat. + assertTrue(result.restored) + assertEquals(0, glass.percent) + } + + @Test + fun `une commande deja classee n'est pas renvoyee`() { + val glass = FakeGlass(openCommand = 0, closeCommand = 1) + val result = probe(glass) + + assertEquals(0, result.openCommand) + assertEquals(1, result.closeCommand) + // 0 ouvre, 1 referme, les deux sens sont connus : la sonde s'arrête là, plus une + // remise en place. Rien au-delà de la commande 1 ne doit avoir été envoyé. + assertEquals(listOf(0, 1), glass.sent.take(2)) + assertTrue(glass.sent.none { it > 1 }) + } + + @Test + fun `une vitre inerte ne prouve rien`() { + val glass = FakeGlass(openCommand = 99, closeCommand = 98) + val result = probe(glass) + + assertNull(result.openCommand) + assertNull(result.closeCommand) + assertEquals(GlassProbe.Refusal.NOTHING_MOVED, result.refusal) + assertFalse(result.proven) + // Les huit commandes, deux fois : la seconde passe ne peut être sautée sur la foi de + // la première, qui ne voit pas les fermetures. + assertEquals(16, glass.sent.size) + } + + @Test + fun `un seul sens trouve n'est pas une preuve`() { + val glass = FakeGlass(openCommand = 3, closeCommand = 99) + val result = probe(glass) + + assertEquals(3, result.openCommand) + assertNull(result.closeCommand) + assertEquals(GlassProbe.Refusal.ONE_DIRECTION_ONLY, result.refusal) + // Rien ne peut refermer la vitre : il faut le dire, pas laisser croire au contraire. + assertFalse(result.restored) + } + + @Test + fun `la voiture qui repart interrompt la sonde`() { + val glass = FakeGlass(openCommand = 5, closeCommand = 2) + var checks = 0 + // Arrêtée pour la lecture initiale et la première commande, puis en mouvement. + val result = probe(glass, stopped = { checks++ < 2 }) + + assertEquals(GlassProbe.Refusal.NOT_STOPPED, result.refusal) + assertEquals(1, glass.sent.size) + } + + @Test + fun `contact coupe, aucune commande n'est envoyee`() { + val glass = FakeGlass(openCommand = 5, closeCommand = 2) + val result = probe(glass, ignitionRun = { false }) + + assertEquals(GlassProbe.Refusal.IGNITION_NOT_RUN, result.refusal) + assertTrue(glass.sent.isEmpty()) + } + + @Test + fun `une position illisible arrete tout`() { + val glass = FakeGlass(openCommand = 5, closeCommand = 2).apply { readable = false } + val result = probe(glass) + + assertEquals(GlassProbe.Refusal.UNREADABLE, result.refusal) + assertTrue(glass.sent.isEmpty()) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/PhysicalButtonEventDecoderTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/PhysicalButtonEventDecoderTest.kt new file mode 100644 index 0000000..eef193f --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/PhysicalButtonEventDecoderTest.kt @@ -0,0 +1,89 @@ +package com.evsuite.hardware + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +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, + 87 to PhysicalButtonEventDecoder.Button.MEDIA_NEXT, + 88 to PhysicalButtonEventDecoder.Button.MEDIA_PREVIOUS, + 110 to PhysicalButtonEventDecoder.Button.SOURCE, + 164 to PhysicalButtonEventDecoder.Button.MUTE, + 17 to PhysicalButtonEventDecoder.Button.STAR_LEFT, + 18 to PhysicalButtonEventDecoder.Button.STAR_RIGHT, + 286 to PhysicalButtonEventDecoder.Button.STAR_RIGHT, + 287 to PhysicalButtonEventDecoder.Button.ASSISTANT + ) + expected.forEach { (code, button) -> + val decoder = PhysicalButtonEventDecoder() + decoder.accept(code, true, false) + assertEquals(button, decoder.accept(code, false, false)?.button) + } + } + @Test fun `two releases inside the window make one double`() { + val decoder = PhysicalButtonEventDecoder() + decoder.accept(17, true, false, atMillis = 0) + assertEquals(PhysicalButtonEventDecoder.Press.SHORT, decoder.accept(17, false, false, atMillis = 10)?.press) + decoder.accept(17, true, false, atMillis = 100) + assertEquals(PhysicalButtonEventDecoder.Press.DOUBLE, decoder.accept(17, false, false, atMillis = 200)?.press) + } + + @Test fun `a second press after the window is another single`() { + val decoder = PhysicalButtonEventDecoder() + decoder.accept(17, true, false, atMillis = 0) + decoder.accept(17, false, false, atMillis = 0) + decoder.accept(17, true, false, atMillis = 1_000) + assertEquals(PhysicalButtonEventDecoder.Press.SHORT, decoder.accept(17, false, false, atMillis = 1_000)?.press) + } + + @Test fun `three presses give one double and then a single`() { + // Sinon un troisième appui rendrait un second DOUBLE, et la règle partirait deux fois. + val decoder = PhysicalButtonEventDecoder() + val presses = listOf(0L, 100L, 200L).map { + decoder.accept(17, true, false, atMillis = it) + decoder.accept(17, false, false, atMillis = it)?.press + } + assertEquals( + listOf( + PhysicalButtonEventDecoder.Press.SHORT, + PhysicalButtonEventDecoder.Press.DOUBLE, + PhysicalButtonEventDecoder.Press.SHORT + ), + presses + ) + } + + @Test fun `a long press does not pair with the release that follows`() { + val decoder = PhysicalButtonEventDecoder() + decoder.accept(17, true, false, atMillis = 0) + decoder.accept(17, false, false, atMillis = 0) // SHORT + decoder.accept(17, true, false, atMillis = 100) + decoder.accept(17, true, true, atMillis = 100) // LONG + decoder.accept(17, false, false, atMillis = 150) // release, suppressed + decoder.accept(17, true, false, atMillis = 200) + assertEquals( + PhysicalButtonEventDecoder.Press.SHORT, + decoder.accept(17, false, false, atMillis = 250)?.press + ) + } + + @Test fun `long press fires once and suppresses release`() { + val decoder = PhysicalButtonEventDecoder() + assertNull(decoder.accept(17, true, false)) + assertEquals(PhysicalButtonEventDecoder.Press.LONG, decoder.accept(17, true, true)?.press) + assertNull(decoder.accept(17, true, true)) + assertNull(decoder.accept(17, false, false)) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/StateChangeLogTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/StateChangeLogTest.kt new file mode 100644 index 0000000..34c68a4 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/StateChangeLogTest.kt @@ -0,0 +1,52 @@ +package com.evsuite.hardware + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StateChangeLogTest { + + @Test fun `the first value always passes`() { + assertTrue(StateChangeLog().accept(2)) + } + + @Test fun `a repeat is suppressed`() { + val gate = StateChangeLog() + assertTrue(gate.accept(2)) + assertFalse(gate.accept(2)) + assertFalse(gate.accept(2)) + } + + @Test fun `a transition passes and the repeats after it do not`() { + val gate = StateChangeLog() + gate.accept(2) + assertTrue(gate.accept(4)) + assertFalse(gate.accept(4)) + assertTrue(gate.accept(2)) + } + + @Test fun `the ignition flood collapses to its transitions`() { + // 10 Hz of "RUN/READY" either side of one real change, as the vehicle actually reports it. + val gate = StateChangeLog() + val stream = List(100) { 2 } + List(100) { 0 } + assertEquals(2, stream.count { gate.accept(it) }) + } + + @Test fun `a first null still counts as a change`() { + val gate = StateChangeLog() + assertTrue(gate.accept(null)) + assertFalse(gate.accept(null)) + assertTrue(gate.accept(1)) + } + + @Test fun `reset makes the next value log again`() { + val gate = StateChangeLog() + gate.accept(2) + assertFalse(gate.accept(2)) + gate.reset() + assertTrue(gate.accept(2)) + } + + private fun assertEquals(expected: Int, actual: Int) = + org.junit.Assert.assertEquals(expected.toLong(), actual.toLong()) +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleSpeedEvidenceTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleSpeedEvidenceTest.kt new file mode 100644 index 0000000..a0f4842 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleSpeedEvidenceTest.kt @@ -0,0 +1,45 @@ +package com.evsuite.hardware + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class VehicleSpeedEvidenceTest { + + @Test fun `the two conversions are distinct versions`() { + assertEquals(1, VehicleSpeedEvidence.MPS_TIMES_3_6_V1) + assertEquals(2, VehicleSpeedEvidence.KMH_DIRECT_V2) + assertEquals(VehicleSpeedEvidence.KMH_DIRECT_V2, VehicleSpeedEvidence.CURRENT) + } + + @Test fun `a distance from the old conversion never matches the current one`() { + val old = VehicleSpeedEvidence(FirmwareInfo.Gen.SWI68, VehicleSpeedEvidence.MPS_TIMES_3_6_V1) + assertFalse(old.matchesCurrent(FirmwareInfo.Gen.SWI68)) + } + + @Test fun `evidence naming an unknown firmware is refused`() { + assertThrows(IllegalArgumentException::class.java) { + VehicleSpeedEvidence(FirmwareInfo.Gen.UNKNOWN, VehicleSpeedEvidence.CURRENT) + } + } + + @Test fun `a conversion version must be positive`() { + assertThrows(IllegalArgumentException::class.java) { + VehicleSpeedEvidence(FirmwareInfo.Gen.SWI68, 0) + } + } + + @Test fun `an unknown firmware makes no claim rather than a wrong one`() { + // No vehicle in a JVM test, so the generation is UNKNOWN and current() must stay silent. + assertNull(VehicleSpeedEvidence.current()) + } + + @Test fun `evidence for a different firmware does not match this one`() { + val other = VehicleSpeedEvidence(FirmwareInfo.Gen.SWI69, VehicleSpeedEvidence.CURRENT) + assertFalse(other.matchesCurrent(FirmwareInfo.Gen.SWI68)) + assertTrue(other.matchesCurrent(FirmwareInfo.Gen.SWI69)) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleSpeedScaleTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleSpeedScaleTest.kt new file mode 100644 index 0000000..2bc00d6 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleSpeedScaleTest.kt @@ -0,0 +1,46 @@ +package com.evsuite.hardware + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class VehicleSpeedScaleTest { + + @Test fun `SWI68 reports km per hour and is not converted again`() { + assertTrue(VehicleSpeedScale.reportsKmh(FirmwareInfo.Gen.SWI68)) + assertEquals(34.4f, VehicleSpeedScale.toKmh(34.4f, FirmwareInfo.Gen.SWI68), 0.001f) + } + + @Test fun `an unproven generation keeps the specified conversion`() { + FirmwareInfo.Gen.entries + .filterNot { it == FirmwareInfo.Gen.SWI68 } + .forEach { generation -> + assertFalse("$generation is not proven", VehicleSpeedScale.reportsKmh(generation)) + assertEquals( + "$generation must keep the AAOS conversion", + 36.0f, + VehicleSpeedScale.toKmh(10.0f, generation), + 0.001f, + ) + } + } + + @Test fun `reverse is a speed, not a negative one`() { + assertEquals(12.0f, VehicleSpeedScale.toKmh(-12.0f, FirmwareInfo.Gen.SWI68), 0.001f) + assertEquals(36.0f, VehicleSpeedScale.toKmh(-10.0f, FirmwareInfo.Gen.SWI69), 0.001f) + } + + @Test fun `the drive that proved it now integrates to the route it followed`() { + // The recorded trip: 245836 ms at a steady 34.4 km/h reads as 2.35 km, not 8.46 km. + val hours = 245_836L / 3_600_000.0 + val kmh = VehicleSpeedScale.toKmh(34.4f, FirmwareInfo.Gen.SWI68) + assertEquals(2.35, kmh * hours, 0.02) + } + + @Test fun `standstill stays standstill in every generation`() { + FirmwareInfo.Gen.entries.forEach { + assertEquals(0.0f, VehicleSpeedScale.toKmh(0.0f, it), 0.0f) + } + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleWriteGateTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleWriteGateTest.kt new file mode 100644 index 0000000..61cc963 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/VehicleWriteGateTest.kt @@ -0,0 +1,34 @@ +package com.evsuite.hardware + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The gate decides whether a rule may change a car that is moving, so its rule is spelled out + * here rather than trusted to reading. + */ +class VehicleWriteGateTest { + + @Test + fun `standstill is the default and the only speed allowed at zero`() { + assertEquals(VehicleWriteGate.Decision.ALLOWED, VehicleWriteGate.decide(0f)) + assertEquals(VehicleWriteGate.Decision.REFUSED_MOVING, VehicleWriteGate.decide(1f)) + assertEquals(VehicleWriteGate.Decision.REFUSED_MOVING, VehicleWriteGate.decide(90f)) + } + + @Test + fun `every positive speed is refused`() { + assertEquals(VehicleWriteGate.Decision.REFUSED_MOVING, VehicleWriteGate.decide(0.01f)) + assertEquals(VehicleWriteGate.Decision.REFUSED_MOVING, VehicleWriteGate.decide(20f)) + } + + @Test + fun `an unreadable speed fails closed`() { + assertEquals(VehicleWriteGate.Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(null)) + assertEquals(VehicleWriteGate.Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(-1f)) + assertEquals( + VehicleWriteGate.Decision.REFUSED_UNKNOWN_SPEED, + VehicleWriteGate.decide(Float.NaN) + ) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/VsmGlassTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/VsmGlassTest.kt new file mode 100644 index 0000000..84e8f5d --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/VsmGlassTest.kt @@ -0,0 +1,79 @@ +package com.evsuite.hardware + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class VsmGlassTest { + + @Test fun `hold stops pulsing and releases glass when gate closes`() { + var now = 0L + var checks = 0 + val writes = mutableListOf>() + + val result = VsmGlass.runHold( + targets = listOf(0, 1), + command = VsmGlass.Command.UP, + durationMs = 1_000L, + nowMs = { now }, + allowed = { ++checks == 1 }, + write = { area, command -> writes += area to command; true }, + sleep = { now += it }, + ) + + assertFalse(result) + assertEquals(2, checks) + assertEquals( + listOf( + 0 to VsmGlass.Command.UP, + 1 to VsmGlass.Command.UP, + 0 to VsmGlass.Command.STOP, + 1 to VsmGlass.Command.STOP, + ), + writes, + ) + } + + @Test fun `hold releases glass when first pulse is refused`() { + val writes = mutableListOf>() + + val result = VsmGlass.runHold( + targets = listOf(2), + command = VsmGlass.Command.UP, + durationMs = 1_000L, + nowMs = { 0L }, + allowed = { false }, + write = { area, command -> writes += area to command; true }, + sleep = {}, + ) + + assertFalse(result) + assertEquals(listOf(2 to VsmGlass.Command.STOP), writes) + } + + @Test fun `hold completes while gate remains open`() { + var now = 0L + val writes = mutableListOf>() + + val result = VsmGlass.runHold( + targets = listOf(3), + command = VsmGlass.Command.UP, + durationMs = VsmGlass.PULSE_MS * 2, + nowMs = { now }, + allowed = { true }, + write = { area, command -> writes += area to command; true }, + sleep = { now += it }, + ) + + assertTrue(result) + assertEquals( + listOf( + 3 to VsmGlass.Command.UP, + 3 to VsmGlass.Command.UP, + 3 to VsmGlass.Command.STOP, + ), + writes, + ) + } +} 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 new file mode 100644 index 0000000..20cff1b --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/CatalogConsistencyTest.kt @@ -0,0 +1,386 @@ +package com.evsuite.hardware.catalog + +import com.evsuite.hardware.catalog.ActionType +import com.evsuite.hardware.catalog.ConditionType +import com.evsuite.hardware.catalog.ValueKind +import com.evsuite.hardware.saic.SaicRadio +import com.evsuite.hardware.saic.SaicVehicleControl +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Garde-fous sur le catalogue. Les erreurs visées ne cassent pas la compilation : elles + * produisent une entrée d'interface qui ne fait rien, ou pire, une écriture véhicule qui + * contourne le verrou de vitesse. + */ +class CatalogConsistencyTest { + + @Suppress("DEPRECATION") + @Test + fun `call is one public action accepting numbers or contacts`() { + val publicActions = ActionType.byGroup().values.flatten() + + assertEquals(ValueKind.CONTACT, ActionType.CALL_NUMBER.spec.kind) + assertTrue(ActionType.CALL_NUMBER in publicActions) + assertFalse(ActionType.CALL_CONTACT in publicActions) + } + + @Test + fun `la destination et le point utilisent des controles distincts`() { + // Un point a un rayon, une destination n'en a pas : deux ValueKind, sinon l'éditeur + // demanderait un rayon pour une adresse. + assertEquals(ValueKind.DESTINATION, ActionType.NAVIGATE_TO.spec.kind) + assertEquals(ValueKind.LOCATION, ConditionType.LOCATION_WITHIN.spec.kind) + assertEquals(0, ActionType.NAVIGATE_TO.spec.max) + } + + @Test + fun `le sms porte un destinataire et un message`() { + // Le destinataire est un numéro (ou un contact du répertoire PBAP) et le message est + // du texte libre : un seul contrôle ne pourrait pas porter les deux. + assertEquals(ValueKind.SMS, ActionType.SEND_SMS.spec.kind) + assertTrue(ActionType.SEND_SMS.spec.hintRes != 0) + assertFalse("le SMS n'écrit rien dans le véhicule", ActionType.SEND_SMS.gated) + } + + @Test + fun `la demande de confirmation porte sa question et son delai`() { + // Le texte EST la question posée au conducteur ; un exemple est indispensable, la + // formulation par défaut d'un champ vide n'apprend rien. Le nombre est l'attente en + // secondes, bornée pour qu'une question reste lisible sans bloquer le cycle. + assertEquals(ValueKind.CONFIRM, ActionType.ASK_CONFIRM.spec.kind) + assertTrue(ActionType.ASK_CONFIRM.spec.hintRes != 0) + assertTrue(ActionType.ASK_CONFIRM.spec.min > 0) + assertTrue(ActionType.ASK_CONFIRM.spec.max > ActionType.ASK_CONFIRM.spec.min) + // Le défaut des règles enregistrées sans délai doit rester dans les bornes offertes. + assertTrue(ActionType.ASK_CONFIRM_DEFAULT_SECONDS in + ActionType.ASK_CONFIRM.spec.min..ActionType.ASK_CONFIRM.spec.max) + assertFalse("la question ne touche pas le véhicule", ActionType.ASK_CONFIRM.gated) + } + + @Test + fun `toute action vehicule cible une action du pont`() { + // bridgeAction=null est réservé aux actions locales (lancer une app, notifier). + // Une action véhicule sans cible serait silencieusement ignorée à l'exécution. + val localOnly = setOf( + ActionType.LAUNCH_APP, ActionType.SHOW_NOTIFICATION, ActionType.SPEAK_TEXT, + ActionType.NAVIGATE_TO, ActionType.WEBHOOK, ActionType.ASK_CONFIRM, + ActionType.DELAY, ActionType.SEND_SMS, + ActionType.ENABLE_RULE, ActionType.DISABLE_RULE, ActionType.MEDIA_CONTROL, + ActionType.SET_BLUETOOTH, ActionType.SET_WIFI + ) + + ActionType.entries.filterNot { it in localOnly }.forEach { type -> + assertNotNull("${type.name} n'a pas de bridgeAction", type.bridgeAction) + } + } + + @Test + fun `les actions locales ne passent pas par le pont`() { + listOf( + ActionType.LAUNCH_APP, ActionType.SHOW_NOTIFICATION, ActionType.SPEAK_TEXT, + ActionType.NAVIGATE_TO, ActionType.WEBHOOK, ActionType.ASK_CONFIRM, + ActionType.DELAY, ActionType.SEND_SMS, + ActionType.ENABLE_RULE, ActionType.DISABLE_RULE, ActionType.MEDIA_CONTROL, + ActionType.SET_BLUETOOTH, ActionType.SET_WIFI + ).forEach { type -> + assertTrue( + "${type.name} ne touche pas le véhicule et ne doit pas avoir de bridgeAction", + type.bridgeAction == null + ) + } + } + + @Test + fun `les ecritures de comportement routier sont marquees gated`() { + // Le marquage ne remplace PAS le verrou côté EVProfile, qui reste l'autorité. + // Il conditionne l'avertissement affiché à l'utilisateur au moment du choix : + // une action gated non marquée se présenterait comme applicable en roulant. + val mustBeGated = setOf( + ActionType.APPLY_PROFILE, + ActionType.SET_DRIVE_MODE, ActionType.SET_REGEN_LEVEL, ActionType.SET_ONE_PEDAL, + ActionType.SET_ENERGY_SAVING, + ActionType.SET_AEB_ENABLED, ActionType.SET_AEB_MODE, ActionType.SET_AEB_SENSITIVITY, + 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 + ) + + mustBeGated.forEach { type -> + assertTrue("${type.name} modifie le comportement routier et doit être gated", type.gated) + } + } + + @Test + fun `couper le vehicule n est pas automatisable`() { + // Une règle ne doit jamais pouvoir éteindre la voiture : c'est irréversible pour + // le conducteur, et ça reste réservé à un geste humain dans EVProfile. + assertFalse( + "aucune action du catalogue ne doit couper le véhicule", + ActionType.entries.any { + it.bridgeAction?.contains("POWER_OFF", ignoreCase = true) == true + } + ) + } + + @Test + fun `toute condition vehicule lit une cle de l instantane`() { + // Les conditions calculées localement (heure, jour, Bluetooth) n'ont pas de clé ; + // toutes les autres en ont besoin, sinon elles seraient toujours indisponibles. + val localOnly = setOf( + ConditionType.BT_DEVICE_CONNECTED, + ConditionType.ANY_BT_CONNECTED, + ConditionType.BT_DEVICE_ONBOARD, + ConditionType.BT_DEVICE_HANDSFREE, + ConditionType.TIME_OF_DAY, + ConditionType.DAY_OF_WEEK, + ConditionType.DATE, + ConditionType.LOCATION_WITHIN, + // Tiré au sort à l'évaluation : il n'y a rien à lire. + ConditionType.RANDOM_CHANCE + ) + + ConditionType.entries.filterNot { it in localOnly }.forEach { type -> + assertNotNull("${type.name} n'a pas de clé d'instantané", type.snapshotKey) + } + } + + @Test + fun `every catalogue entry carries a string label`() { + // A zero labelRes would crash getString at display time on a device, invisible + // to these JVM tests — so assert the id is set rather than resolve it. + ConditionType.entries.forEach { assertTrue("${it.name} has no label", it.labelRes != 0) } + ActionType.entries.forEach { assertTrue("${it.name} has no label", it.labelRes != 0) } + } + + @Test + fun `charging state and the charging flag answer different questions`() { + // The boolean cannot name "plugged in but not charging", which is the state a rule + // warning a departing driver is about — so the two must not share a snapshot key. + assertEquals(ValueKind.ENUM, ConditionType.CHARGING_STATUS.spec.kind) + assertFalse( + "the state and the flag must stay separate readings", + ConditionType.CHARGING_STATUS.snapshotKey == ConditionType.CHARGING.snapshotKey + ) + } + + @Test + fun `only the flowing states count as charging`() { + // `status != 0` was the old derivation, and it made a finished charge, a stopped one + // and a fault all read as "charging". Whatever else the set gains, those three stay out. + listOf( + VehicleEnums.CHARGING_UNPLUGGED, + VehicleEnums.CHARGING_DONE, + VehicleEnums.CHARGING_FAULT, + VehicleEnums.CHARGING_PLUGGED_IDLE + ).forEach { + assertFalse("$it must not read as charging", it in VehicleEnums.CHARGING_ACTIVE_STATES) + } + assertTrue(VehicleEnums.CHARGING_AC in VehicleEnums.CHARGING_ACTIVE_STATES) + assertTrue(VehicleEnums.CHARGING_DC in VehicleEnums.CHARGING_ACTIVE_STATES) + // Every named state must be offered in the picker, or a rule cannot select it. + val offered = VehicleEnums.CHARGING_STATUSES.map { it.value }.toSet() + assertTrue(VehicleEnums.CHARGING_ACTIVE_STATES.all { it in offered }) + } + + @Test + fun `the charge window ends are clock times`() { + // Minutes since midnight behind a 0…1439 slider is unanswerable at the wheel; the + // window's two ends are clock times and get the time control. + listOf(ConditionType.CHARGE_WINDOW_START, ConditionType.CHARGE_WINDOW_STOP).forEach { + assertEquals(ValueKind.TIME, it.spec.kind) + assertTrue("${it.name} must offer before/after", it.comparable) + } + // The write moves both ends at once — a half-set window is one nobody intended. + assertEquals(ValueKind.TIME_RANGE, ActionType.SET_CHARGE_WINDOW.spec.kind) + } + + @Test + fun `the two cabin zones are separate entries`() { + // Adding a zone argument to the driver's target would have made every rule saved + // before it carry a zone it never chose. + assertFalse( + ActionType.SET_CABIN_TEMP.currentKey == ActionType.SET_PASSENGER_TEMP.currentKey + ) + assertEquals(SnapshotKeys.KEY_PASSENGER_TEMP, ConditionType.PASSENGER_TEMP.snapshotKey) + assertEquals( + ActionType.SET_PASSENGER_TEMP.spec.min, ConditionType.PASSENGER_TEMP.spec.min + ) + assertEquals( + ActionType.SET_PASSENGER_TEMP.spec.max, ConditionType.PASSENGER_TEMP.spec.max + ) + } + + @Test + fun `a settable climate switch is readable back`() { + // The editor opens a control on the value the car reports. A switch the catalogue can + // set but never read leaves the rule author guessing at the present state. + mapOf( + ActionType.SET_ECON to ConditionType.ECON_MODE, + ActionType.SET_FRONT_DEFROST to ConditionType.FRONT_DEFROST, + ActionType.SET_REAR_DEFROST to ConditionType.REAR_DEFROST, + ActionType.SET_PASSENGER_TEMP to ConditionType.PASSENGER_TEMP + ).forEach { (action, condition) -> + assertEquals( + "${action.name} must open on what ${condition.name} reads", + condition.snapshotKey, action.currentKey + ) + } + } + + @Test + fun `the media commands are the platform key codes`() { + // The runner passes them straight to the media key dispatch: a value invented here + // would send a different key, and the mistake would look like an app bug. + assertEquals(android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, MediaCommand.PLAY_PAUSE) + assertEquals(android.view.KeyEvent.KEYCODE_MEDIA_NEXT, MediaCommand.NEXT) + assertEquals(android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS, MediaCommand.PREVIOUS) + assertEquals(MediaCommand.OPTIONS.size, MediaCommand.OPTIONS.map { it.value }.toSet().size) + } + + @Test + fun `rule chaining names a rule and touches nothing else`() { + // The pair has to stay symmetric: an editor offering "enable" with a rule chooser and + // "disable" with a text field would store two different things in the same field. + listOf(ActionType.ENABLE_RULE, ActionType.DISABLE_RULE).forEach { + assertEquals(ValueKind.RULE, it.spec.kind) + assertFalse("${it.name} writes nothing to the vehicle", it.gated) + } + } + + @Test + fun `each window is readable and settable, and the group read stays`() { + // The all-windows action is not replaced by the four: closing the glass is one + // gesture, and splitting it would let three succeed and one be forgotten. + assertNotNull(ActionType.SET_WINDOWS.bridgeAction) + assertEquals(SnapshotKeys.KEY_WINDOW_PERCENT, ConditionType.WINDOW_POSITION.snapshotKey) + + // One action per window, one condition per window: the write is a command and the + // read is a position, so they are not two ends of the same scale — see the glass test. + listOf( + ActionType.SET_WINDOW_DRIVER, ActionType.SET_WINDOW_PASSENGER, + ActionType.SET_WINDOW_REAR_LEFT, ActionType.SET_WINDOW_REAR_RIGHT + ).forEach { assertNotNull("${it.name} must reach the bridge", it.bridgeAction) } + listOf( + ConditionType.WINDOW_DRIVER, ConditionType.WINDOW_PASSENGER, + ConditionType.WINDOW_REAR_LEFT, ConditionType.WINDOW_REAR_RIGHT + ).forEach { + assertEquals("${it.name} reads a position in percent", 100, it.spec.max) + } + + // Four windows, four distinct keys — a copy-paste that reused one would make two + // windows report each other's position. + val keys = listOf( + ConditionType.WINDOW_DRIVER, ConditionType.WINDOW_PASSENGER, + ConditionType.WINDOW_REAR_LEFT, ConditionType.WINDOW_REAR_RIGHT + ).map { it.snapshotKey } + assertEquals(4, keys.toSet().size) + assertFalse(SnapshotKeys.KEY_WINDOW_PERCENT in keys) + } + + @Test + fun `the head-unit app services are read-only and unrouted`() { + // They belong to the head unit's own apps, not to the vehicle SDK, so there is no + // generation to route them by and nothing to write back. + listOf(ConditionType.ODOMETER, ConditionType.WEATHER_NOW).forEach { + assertNotNull("${it.name} must read a snapshot key", it.snapshotKey) + } + // A list, not free text: the provider's phrase is the one value nobody can type, so + // the rule stores a state the catalogue owns and the reader does the classifying. + assertEquals(ValueKind.ENUM, ConditionType.WEATHER_NOW.spec.kind) + assertEquals(WeatherConditions.OPTIONS, ConditionType.WEATHER_NOW.spec.options) + assertTrue("a rule asks for more or fewer kilometres", ConditionType.ODOMETER.comparable) + } + + @Test + fun `every glass write is gated, and asks for a state rather than a raw command`() { + // The service takes a command in 0..7 on the way in and answers a percentage on the + // way out, so neither a raw command nor a position belongs in a saved rule. A command + // number is worse than useless in the editor — it is what let a rule ask for "7", + // which the service accepts and drops — and a percentage would offer a hundred and one + // values of which ninety-nine the glass cannot reach. Open and closed are the two + // states that exist. + val glass = listOf( + ActionType.SET_WINDOWS, ActionType.SET_WINDOW_DRIVER, ActionType.SET_WINDOW_PASSENGER, + ActionType.SET_WINDOW_REAR_LEFT, ActionType.SET_WINDOW_REAR_RIGHT + ) + glass.forEach { + assertTrue("${it.name} must take the standstill gate", it.gated) + assertEquals("${it.name} asks for a state", ValueKind.ENUM, it.spec.kind) + assertEquals( + "${it.name} offers exactly open and closed", + VehicleEnums.WINDOW_COMMANDS, it.spec.options + ) + // No current value to open the editor on: the reading is a position and the + // control is a state, so seeding one with the other would be a false start. + assertNull("${it.name} has no position to preselect", it.currentKey) + // The catalogue still cannot claim the write moves anything. GlassEvidence lifts + // this per car, once a probe has watched a command move the glass. + assertFalse("${it.name} effect is not established", it.writeProven) + } + // 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()) + } + + @Test + fun `the window states carry no car-specific command number`() { + // Which raw command opens and which closes is a property of the car, established by + // GlassProbe and stored in GlassEvidence — never of the catalogue. A rule exported + // from one car and imported on another must not carry the first car's command codes. + assertEquals(0, VehicleEnums.WINDOW_CLOSE) + assertEquals(1, VehicleEnums.WINDOW_OPEN) + assertEquals(2, VehicleEnums.WINDOW_COMMANDS.size) + assertEquals( + setOf(VehicleEnums.WINDOW_CLOSE, VehicleEnums.WINDOW_OPEN), + VehicleEnums.WINDOW_COMMANDS.map { it.value }.toSet() + ) + } + + @Test + fun `the radio bands are the vendor RadioType values, and include DAB`() { + // Passed straight to tune() as its band argument: a value invented here tunes the + // wrong band, or none. + val byValue = VehicleEnums.RADIO_BANDS.associateBy { it.value } + assertTrue("AM missing", SaicRadio.BAND_AM in byValue) + assertTrue("FM missing", SaicRadio.BAND_FM in byValue) + assertTrue("DAB missing — it is the band the tune action cannot reach", SaicRadio.BAND_DAB in byValue) + assertEquals(3, VehicleEnums.RADIO_BANDS.size) + } + + @Test + fun `les enumerations ont des options`() { + // Une ENUM sans option produit une liste déroulante vide : l'utilisateur ne peut + // rien choisir et la règle reste inutilisable. + ConditionType.entries + .filter { it.spec.kind == ValueKind.ENUM && it != ConditionType.FIRMWARE_GEN } + .forEach { assertTrue("${it.name} : ENUM sans option", it.spec.options.isNotEmpty()) } + + // RADIO dessine aussi une liste déroulante — celle des bandes — donc la même règle + // s'applique : sans option, l'action ne peut nommer aucune bande. + ActionType.entries + .filter { it.spec.kind == ValueKind.ENUM || it.spec.kind == ValueKind.RADIO } + .forEach { assertTrue("${it.name} : ENUM sans option", it.spec.options.isNotEmpty()) } + } + + @Test + fun `les bornes numeriques sont coherentes`() { + val specs = ConditionType.entries.map { it.name to it.spec } + + ActionType.entries.map { it.name to it.spec } + + specs.filter { it.second.kind == ValueKind.NUMBER }.forEach { (name, spec) -> + if (spec.max >= 0) { + assertTrue("$name : max doit dépasser min", spec.max > spec.min) + } else { + // max = -1 : borne résolue à l'exécution. Le repli doit rester utilisable + // même si le véhicule ne répond pas. + assertTrue("$name : fallbackMax doit dépasser min", spec.fallbackMax > spec.min) + } + } + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/CatalogueDocTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/CatalogueDocTest.kt new file mode 100644 index 0000000..f14c321 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/CatalogueDocTest.kt @@ -0,0 +1,184 @@ +package com.evsuite.hardware.catalog + +import com.evsuite.hardware.FirmwareGen +import com.evsuite.hardware.FirmwareSupport +import com.evsuite.hardware.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Writes `docs/catalogue.md`: every condition and every action the apps actually carry. + * + * The list is generated for the same reason the firmware matrix is. A hand-written catalogue + * drifts the first time an entry is added — and it drifted: the published one still promised + * an "AM or FM" tune action months after DAB was reachable, and named none of the twenty + * entries added after it was written. Rendering it from the enums makes that impossible. + * + * Labels are resource ids, which the JVM has no resource table for, so the names come back + * through `R.string` by reflection and the English text out of the `values/` file. That is + * the same text the car shows on its default locale, which is what a reader of the docs is + * trying to match against their screen. + */ +class CatalogueDocTest { + + /** Column order, and the wording, of the firmware column. */ + private val columns = listOf( + FirmwareGen.SWI133, FirmwareGen.SWI68, FirmwareGen.SWI165, + FirmwareGen.SWI69, FirmwareGen.SWI131, FirmwareGen.SWI132 + ) + + /** Resource id → resource name, from the generated `R.string` class. */ + private val resourceNames: Map = R.string::class.java.fields + .filter { it.type == Int::class.javaPrimitiveType } + .associate { (it.get(null) as Int) to it.name } + + /** Resource name → English text, from the file the library ships as its default locale. */ + private val strings: Map = File("src/main/res/values/strings.xml") + .readText() + .let { Regex("""]*>(.*?)""", RegexOption.DOT_MATCHES_ALL).findAll(it) } + .associate { it.groupValues[1] to it.groupValues[2].unescape() } + + private fun String.unescape(): String = replace("&", "&") + .replace("<", "<").replace(">", ">") + .replace("…", "…").replace("\\'", "'") + .replace(Regex("\\s+"), " ").trim() + + private fun label(res: Int): String = strings[resourceNames[res]] ?: "?" + + /** What the user is asked for, in words rather than in the enum's own vocabulary. */ + private fun value(spec: ValueSpec): String { + val unit = spec.unitRes.takeIf { it != 0 }?.let { " " + label(it) }.orEmpty() + return when (spec.kind) { + ValueKind.NONE -> "—" + ValueKind.BOOL -> "on / off" + ValueKind.NUMBER -> if (spec.max >= 0) "${spec.min}…${spec.max}$unit" else "0…max$unit" + // FIRMWARE_GEN is the one list the catalogue does not hold: the editor fills it + // from the generations the library knows, so there is nothing to print here. + ValueKind.ENUM -> spec.options.joinToString(", ") { label(it.labelRes) } + .ifEmpty { "a list the app fills in" } + ValueKind.RADIO -> + "band (" + spec.options.joinToString(", ") { label(it.labelRes) } + "), frequency, play" + ValueKind.TIME -> "a time of day" + ValueKind.TIME_RANGE -> "a start and an end time" + ValueKind.DAYS -> "weekdays" + ValueKind.DATE -> "a calendar date" + ValueKind.BT_DEVICE -> "a paired phone" + ValueKind.PROFILE -> "an EVProfile profile" + ValueKind.RULE -> "one of your rules" + ValueKind.APP -> "an installed app" + ValueKind.CONTACT -> "a number or a contact" + ValueKind.SMS -> "a number or a contact, and a message" + ValueKind.TEXT -> "free text" + ValueKind.CONFIRM -> "a question, and how long to wait" + ValueKind.DESTINATION -> "an address or coordinates" + ValueKind.LOCATION -> "a point and a radius" + unit.trim().let { if (it.isEmpty()) "" else " ($it)" } + ValueKind.WEBHOOK -> "a URL, and an optional body" + ValueKind.PHYSICAL_BUTTON -> "a button, short or long press" + } + } + + /** The generations an entry is annotated for; "any" when it depends on no firmware. */ + private fun firmware(gens: Set?): String = when (gens) { + null -> "any" + else -> columns.filter { it in gens }.joinToString(", ") { it.name }.ifEmpty { "none" } + } + + private fun render(): String = buildString { + appendLine("") + appendLine() + appendLine("# The EVTasker catalogue") + appendLine() + appendLine( + "Every condition a rule can test and every action it can run, straight out of " + + "`ConditionType` and `ActionType`. *Firmware* is what the entry is annotated " + + "for; \"any\" means it depends on no vehicle firmware at all, not that it needs " + + "nothing — a notification still needs the permission, a call still needs a " + + "paired phone. The car's own Diagnostic screen stays the only answer for the " + + "car in front of you." + ) + appendLine() + appendLine("## Conditions") + ConditionGroup.entries.forEach { group -> + val entries = ConditionType.entries.filter { it.group == group } + if (entries.isEmpty()) return@forEach + appendLine() + appendLine("### ${label(group.labelRes)}") + appendLine() + appendLine("| Condition | Entry | Tested against | Firmware |") + appendLine("|---|---|---|---|") + entries.forEach { + val compare = if (it.comparable) " (=, <, >)" else "" + appendLine( + "| ${label(it.labelRes)} | `${it.name}` | ${value(it.spec)}$compare | " + + "${firmware(FirmwareSupport.gensOf(it))} |" + ) + } + } + appendLine() + appendLine( + "A reading the car does not answer makes the rule **not evaluable**: it does not " + + "fire, and it does not count as false." + ) + appendLine() + appendLine("## Actions") + ActionGroup.entries.forEach { group -> + val entries = ActionType.entries.filter { it.group == group } + if (entries.isEmpty()) return@forEach + appendLine() + appendLine("### ${label(group.labelRes)}") + appendLine() + appendLine("| Action | Entry | Asks for | When stopped only | Firmware |") + appendLine("|---|---|---|:--:|---|") + entries.forEach { + val unproven = if (it.writeProven) "" else " ⚠️" + appendLine( + "| ${label(it.labelRes)}$unproven | `${it.name}` | ${value(it.spec)} | " + + "${if (it.gated) "yes" else "—"} | ${firmware(FirmwareSupport.gensOf(it))} |" + ) + } + } + appendLine() + appendLine( + "⚠️ — the firmware carries the property, but nothing establishes that writing it " + + "does anything (`ActionType.writeProven`). Those actions are hidden from the " + + "editor and refused by the executor until one is observed to work." + ) + appendLine() + appendLine( + "The per-generation grid is in [firmware-matrix.md](firmware-matrix.md), generated " + + "from the same annotations." + ) + } + + /** + * Rewrites the catalogue from the enums — the same contract the firmware matrix has, so + * one test run repairs both. + * + * It **repairs** rather than fails: a stale copy is written back and the assertion then + * passes, which is why running the tests is what keeps the file honest and why the file + * has to be committed with the change that moved it. The assertion is there for the write + * itself — a read-only checkout, or a path that moved, must not pass silently. + */ + @Test + fun `the catalogue document is up to date`() { + val generated = render() + val file = File("../docs/catalogue.md") + if (!file.exists() || file.readText().trim() != generated.trim()) { + file.parentFile?.mkdirs() + file.writeText(generated) + } + assertEquals(generated.trim(), file.readText().trim()) + } + + @Test + fun `every catalogue entry has a label the document can print`() { + // A missing string would render as "?" and publish a nameless row. The resource + // exists at build time either way, so only this catches it. + (ConditionType.entries.map { it.name to it.labelRes } + + ActionType.entries.map { it.name to it.labelRes }).forEach { (name, res) -> + assertTrue("$name has no English label", label(res) != "?" && label(res).isNotBlank()) + } + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/WeatherConditionsTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/WeatherConditionsTest.kt new file mode 100644 index 0000000..98f8b44 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/catalog/WeatherConditionsTest.kt @@ -0,0 +1,62 @@ +package com.evsuite.hardware.catalog + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The classifier is what makes the weather condition writable, so what it must get right is + * the phrase the head unit actually answers — in whichever of the shipped languages the car + * is set to, and with the qualifiers the provider likes to add. + */ +class WeatherConditionsTest { + + @Test + fun `a phrase is read whatever the language the head unit answers in`() { + listOf( + "Light rain" to WeatherConditions.RAIN, + "Pluie faible" to WeatherConditions.RAIN, + "Leichter Regen" to WeatherConditions.RAIN, + "Lluvia débil" to WeatherConditions.RAIN, + "Pioggia leggera" to WeatherConditions.RAIN, + "Chuva fraca" to WeatherConditions.RAIN, + "Sunny" to WeatherConditions.CLEAR, + "Ensoleillé" to WeatherConditions.CLEAR, + "Bewölkt" to WeatherConditions.CLOUDY, + "Ciel couvert" to WeatherConditions.CLOUDY, + "Brouillard" to WeatherConditions.FOG, + "Nebbia" to WeatherConditions.FOG, + "Vent fort" to WeatherConditions.WIND, + "Neige" to WeatherConditions.SNOW + ).forEach { (phrase, expected) -> + assertEquals(phrase, expected, WeatherConditions.classify(phrase)) + } + } + + @Test + fun `a phrase naming two things is read as the one a rule is about`() { + // Order is the design: the first match wins, so the specific weather has to be tried + // before the shower or the sky it comes with. + assertEquals(WeatherConditions.STORM, WeatherConditions.classify("Thunderstorm with rain")) + assertEquals(WeatherConditions.SNOW, WeatherConditions.classify("Snow showers")) + assertEquals(WeatherConditions.SNOW, WeatherConditions.classify("Schneeschauer")) + assertEquals(WeatherConditions.RAIN, WeatherConditions.classify("Rain and cloudy")) + } + + @Test + fun `an unrecognised phrase is not classified`() { + // Never CLEAR by default: an unknown phrase turned into fine weather is the one + // wrong answer, because it reads as a positive statement about the sky. + assertNull(WeatherConditions.classify("")) + assertNull(WeatherConditions.classify(" ")) + assertNull(WeatherConditions.classify("Unavailable")) + } + + @Test + fun `every state the picker offers can come out of the classifier`() { + val reachable = WeatherConditions.OPTIONS.map { it.value }.toSet() + assertEquals(reachable, listOf( + "Clear", "Cloudy", "Rain", "Snow", "Thunderstorm", "Fog", "Windy" + ).mapNotNull { WeatherConditions.classify(it) }.toSet()) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/diag/CrashLoggerTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/diag/CrashLoggerTest.kt new file mode 100644 index 0000000..5478757 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/diag/CrashLoggerTest.kt @@ -0,0 +1,106 @@ +package com.evsuite.hardware.diag + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.evsuite.hardware.AppLogger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * An oversized crash report must lose its TAIL, not its head: the exception and its stack + * trace sit at the top of the file, and they are the only lines that explain the crash. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class CrashLoggerTest { + + private val maxBytes = 64 * 1024 + + private fun context(): Context = ApplicationProvider.getApplicationContext() + + @Test + fun `an oversized report keeps the exception and the stack trace`() { + val context = context() + val boom = IllegalStateException("BOOM unique marker") + // The volume comes from the AppLogger section, which is at the END of the report: + // that is what must be sacrificed, not the exception preceding it. + AppLogger.clear() + repeat(100) { AppLogger.i("NOISE", "x".repeat(2_000)) } + + CrashLogger.write(context, "EVTest", Thread.currentThread(), boom) + val written = CrashLogger.read(context) ?: error("no report written") + + assertTrue("the header was truncated", written.contains("EVTest crash report")) + assertTrue("the exception was truncated", written.contains("BOOM unique marker")) + assertTrue("the stack trace was truncated", written.contains("CrashLoggerTest")) + assertTrue("the truncation is not reported", written.contains("report truncated")) + } + + @Test + fun `the cause chain is spelled out`() { + val context = context() + val root = IllegalArgumentException("ROOT unique marker") + AppLogger.clear() + + CrashLogger.write(context, "EVTest", Thread.currentThread(), RuntimeException("wrapper", root)) + val written = CrashLogger.read(context) ?: error("no report written") + + assertTrue(written.contains("-- Caused by --")) + assertTrue(written.contains("ROOT unique marker")) + } + + @Test + fun `read and clear track whether a report exists`() { + val context = context() + AppLogger.clear() + CrashLogger.write(context, "EVTest", Thread.currentThread(), IllegalStateException("boom")) + assertTrue(CrashLogger.hasReport(context)) + + CrashLogger.clear(context) + assertFalse(CrashLogger.hasReport(context)) + assertEquals(null, CrashLogger.read(context)) + } + + @Test + fun `a normally sized report is left untouched`() { + val content = "small report" + assertEquals(content, String(CrashLogger.truncate(content), Charsets.UTF_8)) + } + + @Test + fun `truncation respects the byte ceiling`() { + // Accented characters are two bytes each in UTF-8. Counting characters instead + // would write almost twice the announced ceiling. + val content = "é".repeat(maxBytes) + val truncated = CrashLogger.truncate(content) + assertTrue("ceiling exceeded: ${truncated.size}", truncated.size <= maxBytes) + } + + @Test + fun `truncation keeps the beginning and not the end`() { + val content = "HEAD_MARKER" + "x".repeat(maxBytes * 2) + "TAIL_MARKER" + val truncated = String(CrashLogger.truncate(content), Charsets.UTF_8) + assertTrue(truncated.startsWith("HEAD_MARKER")) + assertFalse("the tail should have been cut", truncated.contains("TAIL_MARKER")) + } + + @Test + fun `truncation never cuts a multi-byte character in half`() { + // Two and three bytes per character. Where the cut lands relative to a character + // boundary depends on the marker's own length, so a single width can happen to + // land cleanly and never exercise the backup loop; two widths cannot both. + for (glyph in listOf("é", "€")) { + val truncated = CrashLogger.truncate(glyph.repeat(maxBytes)) + assertFalse( + "character cut in half for $glyph", + String(truncated, Charsets.UTF_8).contains('�') + ) + assertTrue("ceiling exceeded for $glyph: ${truncated.size}", truncated.size <= maxBytes) + } + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/diag/PrivateBinTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/diag/PrivateBinTest.kt new file mode 100644 index 0000000..02ad9b0 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/diag/PrivateBinTest.kt @@ -0,0 +1,180 @@ +package com.evsuite.hardware.diag + +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.security.SecureRandom +import java.util.Base64 +import java.util.zip.Inflater +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * The paste format is the contract with a server this project does not own, and the failure + * mode is silent: a wrong `adata`, a zlib header where raw DEFLATE was expected, or a + * reordered field all upload cleanly and produce a paste nobody can open. So the wire body + * is decrypted here with primitives spelled out independently of [PrivateBin] — if the two + * ever drift, this fails instead of the person holding the link. + */ +class PrivateBinTest { + + private val config = PrivateBin.Config( + baseUrl = "https://paste.example.org/", + password = "correct horse", + expire = "1hour", + formatter = "markdown", + ) + + /** Deterministic bytes, so the test knows the key it must reproduce the fragment from. */ + private fun fixedRandom(): SecureRandom = object : SecureRandom() { + private val source = java.util.Random(1234) + override fun nextBytes(bytes: ByteArray) = source.nextBytes(bytes) + } + + private fun keysOf(): Triple { + val random = fixedRandom() + val key = ByteArray(32).also(random::nextBytes) + val iv = ByteArray(16).also(random::nextBytes) + val salt = ByteArray(8).also(random::nextBytes) + return Triple(key, iv, salt) + } + + @Test + fun `the body sent is PrivateBin v2 markdown expiring in one hour`() { + var sent = "" + PrivateBin.paste("hello", config, fixedRandom()) { _, body -> + sent = body + """{"status":0,"id":"abc","url":"/?abc"}""" + } + + val json = JsonParser.parseString(sent).asJsonObject + assertEquals(2, json.get("v").asInt) + assertEquals("1hour", json.getAsJsonObject("meta").get("expire").asString) + + val adata = json.getAsJsonArray("adata") + val spec = adata[0].asJsonArray + assertEquals(100_000, spec[2].asInt) + assertEquals(256, spec[3].asInt) + assertEquals(128, spec[4].asInt) + assertEquals("aes", spec[5].asString) + assertEquals("gcm", spec[6].asString) + assertEquals("zlib", spec[7].asString) + assertEquals("markdown", adata[1].asString) + } + + @Test + fun `the rendered link carries the key as a base58 fragment`() { + val outcome = PrivateBin.paste("hello", config, fixedRandom()) { _, _ -> + """{"status":0,"id":"abc","url":"/?abc"}""" + } + + val (key, _, _) = keysOf() + assertEquals( + PrivateBin.Outcome.Ok("https://paste.example.org/?abc#" + base58(key)), + outcome + ) + } + + @Test + fun `the text decrypts with the fragment key and the password`() { + var sent = "" + PrivateBin.paste("# title\n\nbody", config, fixedRandom()) { _, body -> + sent = body + """{"status":0,"id":"abc","url":"/?abc"}""" + } + + val json = JsonParser.parseString(sent).asJsonObject + val (key, iv, salt) = keysOf() + + // The AAD is the exact JSON text of adata as it was sent — not a re-serialisation. + // That is where compatibility with the reader's browser is won or lost. + val aad = sent.substringAfter("\"adata\":").substringBefore(",\"ct\":") + val derived = pbkdf2(key + config.password.toByteArray(), salt) + val plain = Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.DECRYPT_MODE, SecretKeySpec(derived, "AES"), GCMParameterSpec(128, iv)) + updateAAD(aad.toByteArray()) + doFinal(Base64.getDecoder().decode(json.get("ct").asString)) + } + + val payload = JsonParser.parseString(String(rawInflate(plain))).asJsonObject + assertEquals("# title\n\nbody", payload.get("paste").asString) + } + + @Test + fun `a server refusal reports its reason`() { + val outcome = PrivateBin.paste("hello", config, fixedRandom()) { _, _ -> + """{"status":1,"message":"paste too large"}""" + } + assertEquals(PrivateBin.Outcome.Failed("paste too large"), outcome) + } + + @Test + fun `a network failure does not surface as an exception`() { + val outcome = PrivateBin.paste("hello", config, fixedRandom()) { _, _ -> + throw java.net.UnknownHostException("paste.example.org") + } + assertTrue(outcome is PrivateBin.Outcome.Failed) + } + + /** + * Live check against the real instance. Skipped unless `EV_LIVE_PASTE=1`, because CI + * has no business uploading anything — run it by hand after touching the format. + */ + @Test + fun `a real upload to paste chapril org is accepted`() { + assumeTrue(System.getenv("EV_LIVE_PASTE") == "1") + val outcome = PrivateBin.paste( + "# EVHardware\n\nlive format check\n", + PrivateBin.Config("https://paste.chapril.org/", "evtaskerR0ck\$", "1hour", "markdown") + ) + assertTrue("upload refused: $outcome", outcome is PrivateBin.Outcome.Ok) + println("live paste: " + (outcome as PrivateBin.Outcome.Ok).url) + } + + // ------------------------------------------------------------------------- + + private fun pbkdf2(secret: ByteArray, salt: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret, "HmacSHA256")) } + var u = mac.doFinal(salt + byteArrayOf(0, 0, 0, 1)) + val acc = u.copyOf() + repeat(99_999) { + u = mac.doFinal(u) + for (i in acc.indices) acc[i] = (acc[i].toInt() xor u[i].toInt()).toByte() + } + return acc + } + + private fun rawInflate(data: ByteArray): ByteArray { + val inflater = Inflater(true) + return try { + inflater.setInput(data) + val out = ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + while (!inflater.finished()) out.write(buffer, 0, inflater.inflate(buffer)) + out.toByteArray() + } finally { + inflater.end() + } + } + + private fun base58(data: ByteArray): String { + val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + var value = java.math.BigInteger(1, data) + val out = StringBuilder() + while (value.signum() > 0) { + val (q, r) = value.divideAndRemainder(java.math.BigInteger.valueOf(58)) + out.append(alphabet[r.toInt()]) + value = q + } + for (byte in data) { + if (byte.toInt() != 0) break + out.append(alphabet[0]) + } + return out.reverse().toString() + } +} 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 new file mode 100644 index 0000000..56eece0 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/probe/RuntimeInterfaceProbeTest.kt @@ -0,0 +1,94 @@ +package com.evsuite.hardware.probe + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * What the survey has to get right off the car: the three verdicts stay distinct, and the + * harvested transaction table has no collisions. + * + * And the one thing that must never be true: no transaction the survey calls may be one that + * changes something. The power interface carries a shutdown request two codes away from a + * getter, and a survey is run on a car with a driver in it. + * + * None of this can be checked on the vehicle, because on the vehicle a wrong answer looks like a + * finding. A silent binder reported as ANSWERED would put an empty row in the survey that reads + * like "this interface has nothing to say", and two option getters sharing a code would put a + * value under the wrong name — both would survive the drive and mislead whoever reads the + * capture afterwards. + */ +class RuntimeInterfaceProbeTest { + + @Test + fun `no binder is absent, not denied`() { + assertEquals( + BindVerdict.ABSENT, + RuntimeInterfaceProbe.verdictFor(reached = false, answers = listOf(null)), + ) + } + + @Test + fun `a binder that answers nothing is denied, not answered empty`() { + assertEquals( + BindVerdict.DENIED, + RuntimeInterfaceProbe.verdictFor(reached = true, answers = listOf(null, null, null)), + ) + } + + @Test + fun `one answer among many is enough to call it alive`() { + assertEquals( + BindVerdict.ANSWERED, + RuntimeInterfaceProbe.verdictFor(reached = true, answers = listOf(null, "0", null)), + ) + } + + @Test + fun `a reached binder with nothing asked of it is denied`() { + assertEquals( + BindVerdict.DENIED, + RuntimeInterfaceProbe.verdictFor(reached = true, answers = emptyList()), + ) + } + + @Test + fun `the survey never names a transaction that changes something`() { + val calls = RuntimeInterfaceProbe.POWER_CALLS + val writes = calls.filterValues { it.code in RuntimeInterfaceProbe.POWER_WRITES } + assertEquals(emptyMap(), writes) + } + + @Test + fun `the property survey names no setter`() { + val writes = RuntimeInterfaceProbe.VEHICLE_PROPERTY_CALLS + .filterValues { it.code in RuntimeInterfaceProbe.PROPERTY_WRITES } + assertEquals(emptyMap(), writes) + } + + @Test + fun `the charging survey never starts, stops or limits a charge`() { + val writes = RuntimeInterfaceProbe.CHARGING_CALLS + .filterValues { it.code in RuntimeInterfaceProbe.CHARGING_WRITES } + assertEquals(emptyMap(), writes) + } + + @Test + fun `every property read carries the id it is asking for`() { + val idless = RuntimeInterfaceProbe.VEHICLE_PROPERTY_CALLS.filterValues { it.args.size != 1 } + assertEquals(emptyMap(), idless) + } + + @Test + fun `vehicle speed is read as a float`() { + assertEquals( + RuntimeInterfaceProbe.ReadAs.FLOAT, + RuntimeInterfaceProbe.EVS_CALLS.getValue("getVehicleSpeed").readAs, + ) + } + + @Test + fun `each config getter has its own transaction code`() { + val calls = RuntimeInterfaceProbe.CONFIG_CALLS + assertEquals(calls.size, calls.values.map { it.code }.toSet().size) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/EvRoutPoiParcelTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/EvRoutPoiParcelTest.kt new file mode 100644 index 0000000..57a30f9 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/EvRoutPoiParcelTest.kt @@ -0,0 +1,99 @@ +package com.evsuite.hardware.saic + +import android.os.Parcel +import android.os.Parcelable +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The one thing about [SaicNavGuidance.startNavFromEvRoute] that can be proven without a car. + * + * The adapter reads the argument with `Parcel.createTypedArrayList(EVRoutPoiInfo.CREATOR)`, and + * this module writes it by hand — it does not carry the vendor's bean. A layout that is wrong by + * one int is not a compile error and not a crash here: it is an adapter that reads a length off + * the wrong offset and hands the navigation app a route to nowhere. + * + * So the layout is compared against what Android's own `writeTypedList` produces for a bean with + * the same three fields in the same order, which is exactly what `EVRoutPoiInfo.writeToParcel` + * is. If a platform release ever changes that framing, this test says so rather than a drive. + */ +@RunWith(RobolectricTestRunner::class) +// The head unit runs API 28; 34 is what this module's other Robolectric test pins and what +// the sandbox has. Parcel framing has not moved between them, which is the point of the test. +@Config(sdk = [34]) +class EvRoutPoiParcelTest { + + /** `EVRoutPoiInfo`'s own serialisation: latitude, longitude, name. Nothing else is in it. */ + private class Stand(val poi: NavigationHandoff.Poi) : Parcelable { + override fun describeContents() = 0 + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeDouble(poi.latitude) + dest.writeDouble(poi.longitude) + dest.writeString(poi.name) + } + } + + private fun handWritten(pois: List): ByteArray = + bytes { parcel -> with(SaicNavGuidance) { parcel.writeEvRoutPoiList(pois) } } + + private fun framework(pois: List): ByteArray = + bytes { parcel -> parcel.writeTypedList(pois.map(::Stand)) } + + private fun bytes(write: (Parcel) -> Unit): ByteArray { + val parcel = Parcel.obtain() + return try { + write(parcel) + parcel.marshall() + } finally { + parcel.recycle() + } + } + + @Test fun `one destination is framed the way the adapter reads it`() { + val pois = listOf(NavigationHandoff.Poi(43.5583, 1.5333, "Auzielle")) + assertArrayEquals(framework(pois), handWritten(pois)) + } + + @Test fun `a pathway of several stops keeps its order and its framing`() { + val pois = listOf( + NavigationHandoff.Poi(43.343, 3.215, "Ionity Beziers Est"), + NavigationHandoff.Poi(44.128, 4.081, "Ales"), + ) + assertArrayEquals(framework(pois), handWritten(pois)) + } + + @Test fun `an empty pathway is a count of zero, not an absent argument`() { + // The adapter reads two lists whatever happens; a route with no waypoint still has to + // write the first one, or the destination is read off the wrong offset. + assertArrayEquals(framework(emptyList()), handWritten(emptyList())) + } + + @Test fun `an accented name survives the framing`() { + val pois = listOf(NavigationHandoff.Poi(43.6, 3.9, "Alès Centre")) + assertArrayEquals(framework(pois), handWritten(pois)) + } + + /** + * `goTo` writes three plain values, and the only way to get them wrong is the order. Read + * back exactly as `IGeneralService.Stub` reads them: `readString`, then latitude, then + * longitude. A latitude and a longitude both fit in a double, so nothing else would catch it. + */ + @Test fun `goTo names the place first, then latitude, then longitude`() { + val parcel = Parcel.obtain() + try { + with(SaicNavGuidance) { + parcel.writeGoTo(NavigationHandoff.Poi(43.5583, 1.5333, "Auzielle")) + } + parcel.setDataPosition(0) + assertEquals("Auzielle", parcel.readString()) + assertEquals(43.5583, parcel.readDouble(), 0.0) + assertEquals(1.5333, parcel.readDouble(), 0.0) + } finally { + parcel.recycle() + } + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavGuidanceReducerTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavGuidanceReducerTest.kt new file mode 100644 index 0000000..ba7a8ef --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavGuidanceReducerTest.kt @@ -0,0 +1,154 @@ +package com.evsuite.hardware.saic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class NavGuidanceReducerTest { + + private fun fold(code: Int, first: Int? = null, second: Int? = null, text: String? = null) = + NavGuidanceReducer.fold(NavGuidance.EMPTY, code, first, second, text, atElapsedMs = 1_000L) + + @Test fun `remaining distance and time land in their own fields`() { + val distance = fold(NavGuidanceReducer.TX_REMAINING_DISTANCE_CHANGE, first = 143) + assertEquals(143, distance.remainingDistanceRaw) + assertNull("distance must not be mistaken for a duration", distance.remainingMinutes) + + val minutes = fold(NavGuidanceReducer.TX_REMAINING_TIMES_CHANGE, first = 96) + assertEquals(96, minutes.remainingMinutes) + assertNull("duration must not be mistaken for a distance", minutes.remainingDistanceRaw) + } + + @Test fun `guide infos keep the turn distance apart from the trip distance`() { + val state = fold( + NavGuidanceReducer.TX_GUIDE_INFOS_CHANGE, + first = 7, second = 300, text = "D906", + ) + assertEquals(7, state.nextTurnIcon) + assertEquals(300, state.nextTurnDistanceRaw) + assertEquals("D906", state.direction) + // The whole point of the separation: 300 m to a turn is not 300 of anything to Alès. + assertNull(state.remainingDistanceRaw) + } + + @Test fun `an unknown transaction changes nothing at all`() { + val before = NavGuidance.EMPTY.copy(remainingDistanceRaw = 143, events = 4) + val after = NavGuidanceReducer.fold(before, code = 99, first = 1, atElapsedMs = 2_000L) + assertSame(before, after) + } + + @Test fun `folding accumulates a trip without losing earlier fields`() { + var state = NavGuidance.EMPTY + state = NavGuidanceReducer.fold( + state, NavGuidanceReducer.TX_GUIDE_STATUS_CHANGE, first = 1, atElapsedMs = 10L, + ) + state = NavGuidanceReducer.fold( + state, NavGuidanceReducer.TX_REMAINING_DISTANCE_CHANGE, first = 143, atElapsedMs = 20L, + ) + state = NavGuidanceReducer.fold( + state, NavGuidanceReducer.TX_REMAINING_TIMES_CHANGE, first = 96, atElapsedMs = 30L, + ) + state = NavGuidanceReducer.fold( + state, NavGuidanceReducer.TX_ROAD_INFO_CHANGE, text = "A61", atElapsedMs = 40L, + ) + assertEquals(1, state.guideStatus) + assertEquals(143, state.remainingDistanceRaw) + assertEquals(96, state.remainingMinutes) + assertEquals("A61", state.road) + assertEquals(4, state.events) + assertEquals(40L, state.updatedAtElapsedMs) + } + + @Test fun `nothing seen yet is null everywhere rather than zero`() { + assertNull(NavGuidance.EMPTY.remainingDistanceRaw) + assertNull(NavGuidance.EMPTY.remainingMinutes) + assertNull(NavGuidance.EMPTY.guideStatus) + assertNull(NavGuidance.EMPTY.updatedAtElapsedMs) + assertEquals(0, NavGuidance.EMPTY.events) + } + + @Test fun `the known transaction set matches the decoded callbacks`() { + assertEquals(setOf(1, 2, 3, 12, 13), NavGuidanceReducer.KNOWN_TRANSACTIONS) + } +} + +class TransactionCensusTest { + + @Test fun `every code is counted, decoded or not`() { + val census = TransactionCensus() + census.record(NavGuidanceReducer.TX_REMAINING_DISTANCE_CHANGE) + census.record(NavGuidanceReducer.TX_REMAINING_DISTANCE_CHANGE) + census.record(41) + assertEquals(mapOf(13 to 2, 41 to 1), census.snapshot()) + assertEquals(0, census.beyondCeiling) + } + + @Test fun `a code past the ceiling is separated rather than dropped`() { + val census = TransactionCensus(ceiling = 16) + census.record(99) + census.record(-1) + assertEquals(emptyMap(), census.snapshot()) + assertEquals(2, census.beyondCeiling) + } + + @Test fun `a shifted map shows as traffic on undecoded codes`() { + // What an inserted method one revision earlier would look like: everything this build + // decodes goes quiet, and the codes one above it carry the traffic instead. + val census = TransactionCensus() + listOf(2, 3, 4, 13, 14).forEach(census::record) + val seen = census.snapshot().keys + val decoded = NavGuidanceReducer.KNOWN_TRANSACTIONS + assertEquals(setOf(4, 14), seen - decoded) + } + + @Test fun `clearing forgets the previous capture`() { + val census = TransactionCensus() + census.record(13, payloadBytes = 48) + census.record(999) + census.clear() + assertEquals(emptyMap(), census.snapshot()) + assertEquals(emptyMap(), census.payloadBytes()) + assertEquals(0, census.beyondCeiling) + } + + /** + * The 2026-09-07 capture read a remaining distance of zero while guidance was running, and + * nothing in it could say whether the car meant zero or this build read the wrong shape. The + * size does say: a parcel of one int is the descriptor plus four bytes. + */ + @Test fun `the last parcel size is kept per code, and an unmeasured code has none`() { + val census = TransactionCensus() + census.record(13, payloadBytes = 48) + census.record(13, payloadBytes = 96) + census.record(11, payloadBytes = 0) + census.record(12) + assertEquals(mapOf(11 to 0, 13 to 96), census.payloadBytes()) + assertEquals(mapOf(11 to 1, 12 to 1, 13 to 2), census.snapshot()) + } +} + +class NavGuidanceDistanceUnitTest { + + /** The 2026-09-04 capture: 9788 remaining, 25 minutes, "Rue de la Fontaine". */ + private val seen = NavGuidance(remainingDistanceRaw = 9788, remainingMinutes = 25) + + @Test fun `a metres generation reports kilometres`() { + val km = seen.remainingDistanceKm(com.evsuite.hardware.FirmwareInfo.Gen.SWI68)!! + assertEquals(9.788, km, 1e-9) + // The reading that makes it metres: 9.788 km in 25 minutes is a plausible town route. + assertEquals(23.5, km / (25.0 / 60.0), 0.1) + } + + @Test fun `an unlisted generation claims no kilometres at all`() { + com.evsuite.hardware.FirmwareInfo.Gen.entries + .filterNot { it == com.evsuite.hardware.FirmwareInfo.Gen.SWI68 } + .forEach { assertNull("$it", seen.remainingDistanceKm(it)) } + } + + @Test fun `no raw distance means no kilometres`() { + assertNull( + NavGuidance.EMPTY.remainingDistanceKm(com.evsuite.hardware.FirmwareInfo.Gen.SWI68) + ) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavLegChainTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavLegChainTest.kt new file mode 100644 index 0000000..86f03c2 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavLegChainTest.kt @@ -0,0 +1,131 @@ +package com.evsuite.hardware.saic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The whole of [NavLegChain]'s behaviour, on the JVM, because the alternative is a drive. + * + * Every case here is a sequence of answers to one question — "is the map guiding" — and what the + * chain does with them. That is all the class knows, so that is all a test needs to give it. + */ +class NavLegChainTest { + + private val stop = NavigationHandoff.Poi(43.343, 3.215, "Ionity Beziers Est") + private val destination = NavigationHandoff.Poi(43.5583, 1.5333, "Auzielle") + + private fun chain() = NavLegChain(listOf(stop, destination)) + + /** Drives a leg to its end: guidance running, then the settle window of silence. */ + private fun NavLegChain.arrive(): NavLegChain.Step { + tick(guiding = true) + repeat(NavLegChain.SETTLE_TICKS - 1) { + assertEquals(NavLegChain.Step.Wait, tick(guiding = false)) + } + return tick(guiding = false) + } + + @Test fun `the second leg goes out when guidance for the first one ends`() { + val chain = chain() + assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true)) + assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true)) + repeat(NavLegChain.SETTLE_TICKS - 1) { + assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false)) + } + assertEquals( + NavLegChain.Step.Send(destination, number = 2, count = 2), + chain.tick(guiding = false), + ) + } + + /** + * The reading that must not move the car. On 2026-09-09 the adapter's remaining distance went + * to nothing seven minutes into a route it was still guiding; a single false from its sibling + * flag would otherwise send the car onward from a charger it is still driving to. + */ + @Test fun `one lone false reading is not an arrival`() { + val chain = chain() + chain.tick(guiding = true) + repeat(NavLegChain.SETTLE_TICKS - 1) { + assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false)) + } + assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true)) + // The window starts again from nothing, so the blip cost the leg no ground at all. + repeat(NavLegChain.SETTLE_TICKS - 1) { + assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false)) + } + assertEquals( + NavLegChain.Step.Send(destination, number = 2, count = 2), + chain.tick(guiding = false), + ) + } + + @Test fun `nothing goes out while the first leg is still being driven`() { + val chain = chain() + repeat(50) { assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true)) } + } + + @Test fun `a leg that never starts is abandoned rather than chained onto`() { + val chain = chain() + repeat(NavLegChain.GIVE_UP_TICKS - 1) { + assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false)) + } + assertEquals(NavLegChain.Step.Done(arrived = false), chain.tick(guiding = false)) + } + + @Test fun `guidance appearing late still counts, and the grace restarts with it`() { + val chain = chain() + repeat(NavLegChain.GIVE_UP_TICKS - 1) { chain.tick(guiding = false) } + assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true)) + assertEquals( + NavLegChain.Step.Send(destination, number = 2, count = 2), + chain.arrive(), + ) + } + + @Test fun `the leg handed over gets its own grace before it is given up on`() { + val chain = chain() + chain.arrive() + // The car was just sent somewhere; the map has not come up yet. That silence is not a + // refusal until the grace runs out again, and it must run out again in full. + repeat(NavLegChain.GIVE_UP_TICKS - 1) { + assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false)) + } + assertEquals(NavLegChain.Step.Done(arrived = false), chain.tick(guiding = false)) + } + + @Test fun `the last leg ending ends the chain, and every later tick says so`() { + val chain = chain() + chain.arrive() + assertEquals(NavLegChain.Step.Done(arrived = true), chain.arrive()) + assertEquals(NavLegChain.Step.Done(arrived = true), chain.tick(guiding = true)) + } + + @Test fun `three legs are driven one after the other, in order`() { + val second = NavigationHandoff.Poi(44.128, 4.081, "Ales") + val chain = NavLegChain(listOf(stop, second, destination)) + assertEquals(NavLegChain.Step.Send(second, number = 2, count = 3), chain.arrive()) + assertEquals(NavLegChain.Step.Send(destination, number = 3, count = 3), chain.arrive()) + assertEquals(NavLegChain.Step.Done(arrived = true), chain.arrive()) + } + + @Test fun `a trip with one point has nothing to chain`() { + val chain = NavLegChain(listOf(destination)) + assertTrue(chain.tick(guiding = true) is NavLegChain.Step.Done) + } + + @Test fun `an empty chain is over before it starts`() { + assertEquals( + NavLegChain.Step.Done(arrived = true), + NavLegChain(emptyList()).tick(guiding = false), + ) + } + + @Test fun `the legs are copied, so the caller's list cannot change the trip`() { + val legs = mutableListOf(stop, destination) + val chain = NavLegChain(legs) + legs.clear() + assertEquals(NavLegChain.Step.Send(destination, number = 2, count = 2), chain.arrive()) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavigationHandoffTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavigationHandoffTest.kt new file mode 100644 index 0000000..15d3401 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/NavigationHandoffTest.kt @@ -0,0 +1,109 @@ +package com.evsuite.hardware.saic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.Locale + +class NavigationHandoffTest { + + @Test fun `a destination becomes a geo uri both readings agree on`() { + assertEquals( + "geo:43.558300,1.533300?q=43.558300,1.533300", + NavigationHandoff.geoUri(43.5583, 1.5333), + ) + } + + @Test fun `a label travels in the q parameter`() { + assertEquals( + "geo:43.343000,3.215000?q=43.343000,3.215000(Ionity%20Beziers%20Est)", + NavigationHandoff.geoUri(43.343, 3.215, "Ionity Beziers Est"), + ) + } + + @Test fun `a french locale does not turn the decimal point into a field separator`() { + val previous = Locale.getDefault() + Locale.setDefault(Locale.FRANCE) + try { + assertEquals( + "geo:43.558300,1.533300?q=43.558300,1.533300", + NavigationHandoff.geoUri(43.5583, 1.5333), + ) + } finally { + Locale.setDefault(previous) + } + } + + @Test fun `a label cannot end itself or add a parameter`() { + assertEquals( + "geo:43.343000,3.215000?q=43.343000,3.215000(Total%20Energies%20A9)", + NavigationHandoff.geoUri(43.343, 3.215, "Total (Energies) &A9"), + ) + } + + @Test fun `an accented place name keeps its accents`() { + // Carried literally rather than percent-encoded. Android's Uri is lenient about this and + // a French place name stripped of its accents is a different name on a map. + assertEquals( + "geo:43.343000,3.215000?q=43.343000,3.215000(Béziers)", + NavigationHandoff.geoUri(43.343, 3.215, "Béziers"), + ) + } + + @Test fun `a label of nothing but punctuation is no label at all`() { + assertEquals( + "geo:43.343000,3.215000?q=43.343000,3.215000", + NavigationHandoff.geoUri(43.343, 3.215, "()&"), + ) + } + + @Test fun `coordinates that are not a place on earth are refused`() { + // The common swap: a French longitude read as a latitude stays in range, but a latitude + // read as a longitude does not always, and neither does anything from a parse failure. + assertNull(NavigationHandoff.geoUri(191.0, 3.0)) + assertNull(NavigationHandoff.geoUri(43.0, 210.0)) + assertNull(NavigationHandoff.geoUri(Double.NaN, 3.0)) + assertNull(NavigationHandoff.geoUri(43.0, Double.POSITIVE_INFINITY)) + } + + @Test fun `the poles and the meridian are places`() { + assertEquals("geo:-90.000000,-180.000000?q=-90.000000,-180.000000", NavigationHandoff.geoUri(-90.0, -180.0)) + assertEquals("geo:0.000000,0.000000?q=0.000000,0.000000", NavigationHandoff.geoUri(0.0, 0.0)) + } + + @Test fun `a point keeps its name as the driver reads it, not as a uri would`() { + // The URI form replaces the parenthesis and escapes the space; the adapter form is a + // parcel field and must not, or the map shows a name nobody wrote. + assertEquals( + NavigationHandoff.Poi(43.343, 3.215, "Ionity Beziers Est (A75)"), + NavigationHandoff.poi(43.343, 3.215, "Ionity Beziers Est (A75)"), + ) + } + + @Test fun `a name that is only whitespace falls back to the point itself`() { + assertEquals("43.343000,3.215000", NavigationHandoff.poi(43.343, 3.215, " ")?.name) + assertEquals("43.343000,3.215000", NavigationHandoff.poi(43.343, 3.215)?.name) + } + + @Test fun `control characters and doubled spaces cannot travel in a name`() { + assertEquals("A B", NavigationHandoff.poi(43.0, 3.0, "A\u0000 \n B")?.name) + } + + @Test fun `a point that is not on Earth is refused, exactly as the uri form refuses it`() { + assertNull(NavigationHandoff.poi(91.0, 3.0, "north of the pole")) + assertNull(NavigationHandoff.poi(43.0, 181.0, "past the date line")) + assertNull(NavigationHandoff.poi(Double.NaN, 3.0, "nowhere")) + } + + @Test fun `a name longer than a label is cut before it becomes a payload`() { + val long = "x".repeat(200) + assertEquals(48, NavigationHandoff.poi(43.0, 3.0, long)?.name?.length) + } + + @Test fun `the pathway is capped rather than refused`() { + val stops = List(20) { NavigationHandoff.Poi(43.0, 3.0, "stop $it") } + assertEquals(NavigationHandoff.MAX_PATHWAY_POINTS, NavigationHandoff.pathway(stops).size) + // The cap keeps the first stops: a plan is in the order it is driven. + assertEquals("stop 0", NavigationHandoff.pathway(stops).first().name) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/RadioFrequencyTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/RadioFrequencyTest.kt new file mode 100644 index 0000000..af9a2d6 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/RadioFrequencyTest.kt @@ -0,0 +1,58 @@ +package com.evsuite.hardware.saic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * A driver types a station, not a transaction argument. What the parser must never do is + * turn text it did not understand into a frequency anyway. + */ +class RadioFrequencyTest { + + private fun fm(khz: Int) = RadioFrequency.Station(SaicRadio.BAND_FM, khz) + private fun am(khz: Int) = RadioFrequency.Station(SaicRadio.BAND_AM, khz) + + @Test + fun `a bare number lands on the only band it can belong to`() { + assertEquals(fm(103_500), RadioFrequency.parse("103.5")) + assertEquals(fm(87_500), RadioFrequency.parse("87.5")) + assertEquals(fm(108_000), RadioFrequency.parse("108")) + assertEquals(am(1080), RadioFrequency.parse("1080")) + assertEquals(am(522), RadioFrequency.parse("522")) + } + + @Test + fun `the band may be written out, before or after`() { + assertEquals(fm(103_500), RadioFrequency.parse("FM 103.5")) + assertEquals(fm(103_500), RadioFrequency.parse("103.5 fm")) + assertEquals(am(1080), RadioFrequency.parse("1080 AM")) + } + + @Test + fun `a decimal comma is a decimal point`() { + // Five of the six locales this app ships in write it that way. + assertEquals(fm(103_500), RadioFrequency.parse("103,5")) + } + + @Test + fun `kilohertz written out is understood too`() { + assertEquals(fm(87_500), RadioFrequency.parse("87500")) + } + + @Test + fun `text naming no station is refused rather than approximated`() { + assertNull(RadioFrequency.parse("")) + assertNull(RadioFrequency.parse("Radio 4")) + assertNull(RadioFrequency.parse("250")) + assertNull(RadioFrequency.parse("109.1")) + } + + @Test + fun `a band that contradicts its own frequency is refused`() { + // The driver said two things. Silently keeping one of them is how a rule ends up on + // a station nobody chose. + assertNull(RadioFrequency.parse("AM 103.5")) + assertNull(RadioFrequency.parse("FM 1080")) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/RadioToggleTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/RadioToggleTest.kt new file mode 100644 index 0000000..e35261f --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/RadioToggleTest.kt @@ -0,0 +1,224 @@ +package com.evsuite.hardware.saic + +import com.evsuite.hardware.FirmwareGen +import com.evsuite.hardware.FirmwareSupport +import com.evsuite.hardware.catalog.ActionType +import com.evsuite.hardware.catalog.ConditionType +import com.evsuite.hardware.catalog.SnapshotKeys +import com.evsuite.hardware.catalog.ValueKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The radio family added by CR-020, checked where it can be checked without a car. + * + * [SaicRadio.playPause] itself binds a vendor service, so its direction logic is + * re-implemented here over the same three inputs rather than mocked: what the test defends + * is the decision table — an unreadable state sends nothing — and that table is small enough + * to state twice and compare. The binder round trip is what the on-vehicle run covers. + */ +class RadioToggleTest { + + /** [SaicRadio.playPause]'s decision, over a state that may be unreadable. */ + private fun toggle(playing: Boolean?, accepted: Boolean): SaicRadio.ToggleResult { + if (playing == null) return SaicRadio.ToggleResult.STATE_UNKNOWN + if (!accepted) return SaicRadio.ToggleResult.REFUSED + return if (playing) SaicRadio.ToggleResult.PAUSED else SaicRadio.ToggleResult.PLAYED + } + + @Test + fun `an unreadable state sends nothing in either direction`() { + // The whole point of the action: a rule fires unattended, so a guessed direction is + // a car left silent or left playing with nothing in the history saying it was a guess. + assertEquals(SaicRadio.ToggleResult.STATE_UNKNOWN, toggle(playing = null, accepted = true)) + assertEquals(SaicRadio.ToggleResult.STATE_UNKNOWN, toggle(playing = null, accepted = false)) + } + + @Test + fun `the toggle follows the state the tuner reports`() { + assertEquals(SaicRadio.ToggleResult.PAUSED, toggle(playing = true, accepted = true)) + assertEquals(SaicRadio.ToggleResult.PLAYED, toggle(playing = false, accepted = true)) + } + + @Test + fun `a refused call is not reported as an unknown state`() { + // Two different things to tell the driver: the service refused, or it was never asked. + assertEquals(SaicRadio.ToggleResult.REFUSED, toggle(playing = true, accepted = false)) + assertEquals(SaicRadio.ToggleResult.REFUSED, toggle(playing = false, accepted = false)) + } + + @Test + fun `the band constants are the vendor RadioType values`() { + // tune() passes them straight through; a value invented here tunes the wrong band. + assertEquals(1, SaicRadio.BAND_AM) + assertEquals(2, SaicRadio.BAND_FM) + assertEquals(4, SaicRadio.BAND_DAB) + } + + @Test + fun `every radio action reaches the bridge and declares its firmware`() { + RADIO_ACTIONS.forEach { + assertNotNull("${it.name} has no bridgeAction", it.bridgeAction) + assertTrue("${it.name} has no label", it.labelRes != 0) + } + } + + @Test + fun `only opening a screen is gated, and the audio actions stay outside the gate`() { + // Audio-only radio commands follow the physical media controls: refusing "next + // station" above 0 km/h would make the action pointless. Opening a full-screen app in + // front of a moving driver is the one that is not audio, and it takes the gate — and + // adding it must not have pulled the rest of the family in with it. + assertTrue("opening the radio screen must be standstill-only", ActionType.OPEN_RADIO_SCREEN.gated) + (RADIO_ACTIONS - ActionType.OPEN_RADIO_SCREEN).forEach { + assertFalse("${it.name} is audio-only and must not take the gate", it.gated) + } + } + + @Test + fun `the radio family carries no value to configure, except the tuner setting`() { + // A picker offering a control for an action that takes no argument asks the user for + // something it will then ignore. + (RADIO_ACTIONS - ActionType.TUNE_RADIO).forEach { + assertEquals("${it.name} takes no argument", ValueKind.NONE, it.spec.kind) + } + } + + @Test + fun `band, frequency and playback are one entry`() { + // The merge is the point of the action: a band alone could not carry a station, a + // frequency alone could not reach DAB, and a driver saying "FM 103.5, and play it" + // had to write two rows. Splitting them again would put back both halves of the gap. + assertEquals(ValueKind.RADIO, ActionType.TUNE_RADIO.spec.kind) + assertEquals( + "the band picker must offer the vendor RadioType values", + listOf(SaicRadio.BAND_AM, SaicRadio.BAND_FM, SaicRadio.BAND_DAB), + ActionType.TUNE_RADIO.spec.options.map { it.value } + ) + assertNull( + "there is no separate band action left to pick", + ActionType.entries.firstOrNull { it.name == "SELECT_RADIO_BAND" } + ) + } + + @Test + fun `a rule can ask whether the tuner is playing, which the media condition cannot answer`() { + // The whole reason the condition exists. AudioManager.isMusicActive — what + // MEDIA_PLAYING reads — is false while the radio plays, its stream not being the music + // one, so a car with the radio on reads as silent there. Two conditions, two keys, two + // sources: collapsing them would put that false answer behind a radio-shaped label. + assertEquals(ValueKind.BOOL, ConditionType.RADIO_PLAYING.spec.kind) + assertEquals(SnapshotKeys.KEY_RADIO_PLAYING, ConditionType.RADIO_PLAYING.snapshotKey) + assertNotEquals( + ConditionType.MEDIA_PLAYING.snapshotKey, + ConditionType.RADIO_PLAYING.snapshotKey + ) + // A vendor-service read, so it is routed per generation — unlike the platform one, + // which answers on every firmware because Android answers it. + assertEquals( + setOf(FirmwareGen.SWI68, FirmwareGen.SWI165), + FirmwareSupport.gensOf(ConditionType.RADIO_PLAYING) + ) + assertNull(FirmwareSupport.gensOf(ConditionType.MEDIA_PLAYING)) + } + + @Test + fun `the picked band decides the frequency, and a disagreement is refused`() { + // The two say the same thing, so the number needs no unit and no band prefix. + assertEquals( + RadioFrequency.Station(SaicRadio.BAND_FM, 103_500), + RadioFrequency.parse("103.5", SaicRadio.BAND_FM) + ) + // "1080" alone is AM; asked for on FM it names no station, and inventing one is how a + // rule lands somewhere nobody chose. + assertNull(RadioFrequency.parse("1080", SaicRadio.BAND_FM)) + assertNull(RadioFrequency.parse("AM 103.5", SaicRadio.BAND_FM)) + // DAB names no frequency at all: the band alone is the whole instruction. + assertNull(RadioFrequency.parse("103.5", SaicRadio.BAND_DAB)) + // No band picked is what every rule saved before the merge carries: read the text. + assertEquals( + RadioFrequency.Station(SaicRadio.BAND_AM, 1080), + RadioFrequency.parse("1080", null) + ) + } + + /** [SaicRadio.selectBand]'s decision, over a current band that may be unreadable. */ + private fun band(current: Int?, requested: Int, tuneAccepted: Boolean): SaicRadio.BandResult { + if (!SaicRadio.isKnownBand(requested)) return SaicRadio.BandResult.UNSUPPORTED_BAND + if (current == null) return SaicRadio.BandResult.STATE_UNKNOWN + if (current == requested) return SaicRadio.BandResult.ALREADY_ON_BAND + return if (tuneAccepted) SaicRadio.BandResult.SWITCHED else SaicRadio.BandResult.REFUSED + } + + @Test + fun `a band switch sends nothing when the tuner will not say where it is`() { + // Same rule as the play/pause toggle: without the current band there is no way to + // know whether a tune is a switch or a station change nobody asked for. + assertEquals( + SaicRadio.BandResult.STATE_UNKNOWN, + band(current = null, requested = SaicRadio.BAND_DAB, tuneAccepted = true) + ) + } + + @Test + fun `asking for the band already playing changes no station`() { + // The naive implementation tunes anyway and moves the driver off the station they + // were listening to, for an action that was supposed to be a no-op. + assertEquals( + SaicRadio.BandResult.ALREADY_ON_BAND, + band(current = SaicRadio.BAND_FM, requested = SaicRadio.BAND_FM, tuneAccepted = true) + ) + } + + @Test + fun `DAB is reachable by band where it is not reachable by frequency`() { + // The point of the action. RadioFrequency.parse names no DAB station — a DAB service + // is an ensemble and service id — but the band itself is one tune() argument away. + assertEquals( + SaicRadio.BandResult.SWITCHED, + band(current = SaicRadio.BAND_FM, requested = SaicRadio.BAND_DAB, tuneAccepted = true) + ) + assertNull("no typed text names a DAB station", RadioFrequency.parse("11D")) + } + + @Test + fun `an unknown band is refused before anything is sent`() { + assertEquals( + SaicRadio.BandResult.UNSUPPORTED_BAND, + band(current = SaicRadio.BAND_FM, requested = 9, tuneAccepted = true) + ) + } + + @Test + fun `a refused tune is not reported as a switch`() { + assertEquals( + SaicRadio.BandResult.REFUSED, + band(current = SaicRadio.BAND_AM, requested = SaicRadio.BAND_FM, tuneAccepted = false) + ) + } + + @Test + fun `the DAB band floor is a real Band III block frequency`() { + // Block 5A. It is only ever a starting point — a switch that lands there steps to the + // first station the tuner lists — but it must be inside Band III or the tune is + // meaningless. + assertTrue(SaicRadio.DAB_BAND_III_MIN_KHZ in 174_000..240_000) + } + + private companion object { + val RADIO_ACTIONS = setOf( + ActionType.PLAY_RADIO, + ActionType.PAUSE_RADIO, + ActionType.RADIO_PLAY_PAUSE, + ActionType.TUNE_RADIO, + ActionType.RADIO_NEXT_STATION, + ActionType.RADIO_PREV_STATION, + ActionType.OPEN_RADIO_SCREEN + ) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/SaicChargingHistoryTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/SaicChargingHistoryTest.kt new file mode 100644 index 0000000..a2254da --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/saic/SaicChargingHistoryTest.kt @@ -0,0 +1,28 @@ +package com.evsuite.hardware.saic + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The consumption history arrives as the JSON array the charging service persists, so what is + * tested here is that format rather than this app's idea of it. + */ +class SaicChargingHistoryTest { + + @Test fun `the car's own array is read oldest first`() { + assertEquals( + listOf(15.2f, 16.0f, 14.8f), + SaicCharging.parseHistory("[15.2,16.0,14.8]"), + ) + } + + @Test fun `a car that has never counted answers nothing, not zero`() { + assertEquals(emptyList(), SaicCharging.parseHistory("")) + assertEquals(emptyList(), SaicCharging.parseHistory(null)) + assertEquals(emptyList(), SaicCharging.parseHistory("[]")) + } + + @Test fun `a reading the service never wrote as a number is dropped, not guessed`() { + assertEquals(listOf(15.2f, 14.8f), SaicCharging.parseHistory("[15.2,null,14.8]")) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimatorTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimatorTest.kt new file mode 100644 index 0000000..507375a --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimatorTest.kt @@ -0,0 +1,148 @@ +package com.evsuite.hardware.telemetry + +import com.evsuite.hardware.FirmwareInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AdaptiveRangeEstimatorTest { + private val estimator = AdaptiveRangeEstimator() + + @Test fun `cold start is unavailable because consumption history is insufficient`() { + val estimate = estimator.estimate(snapshot(energyKwh = 40f), null, emptyList()) + + assertNull(estimate.value) + assertEquals(Provenance.UNAVAILABLE, estimate.provenance) + assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, estimate.reason) + } + + @Test fun `steady consumption matches the analytic range`() { + val estimate = estimator.estimate( + snapshot(energyKwh = 40f), + trip(20.0, endedAtMs = 3L), + listOf(trip(20.0, 2L), trip(20.0, 1L)), + ) + + assertEquals(Provenance.ESTIMATED, estimate.provenance) + assertEquals(200.0, estimate.value!!, 1e-9) + // Trips that happened to agree do not make the model certain: the band falls back to + // its floor rather than to zero, which would read as a measurement. + assertEquals(20.0, estimate.uncertainty!!, 1e-9) + } + + @Test fun `an estimate never claims a band narrower than the model's floor`() { + val estimate = estimator.estimate( + snapshot(energyKwh = 40f), null, + listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)), + ) + + assertEquals( + estimate.value!! * AdaptiveRangeEstimator.MIN_RELATIVE_UNCERTAINTY, + estimate.uncertainty!!, + 1e-9, + ) + assertTrue(estimate.uncertainty!! > 0.0) + } + + @Test fun `a momentarily unreadable power signal does not blank a settled estimate`() { + val history = listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)) + val withPower = snapshot(energyKwh = 40f) + val withoutPower = withPower.copy(batteryPowerKw = null) + + assertEquals(Provenance.ESTIMATED, estimator.estimate(withPower, null, history).provenance) + // The capability was established a sample ago and the car has not lost it since; the + // estimate depends on stored trips and pack charge, neither of which moved. + val next = estimator.estimate(withoutPower, null, history) + assertEquals(Provenance.ESTIMATED, next.provenance) + assertEquals(200.0, next.value!!, 1e-9) + } + + @Test fun `volatile consumption widens the band without moving the estimate`() { + val steady = estimator.estimate( + snapshot(energyKwh = 40f), null, + listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)), + ) + val volatile = estimator.estimate( + snapshot(energyKwh = 40f), null, + listOf(trip(10.0, 3L), trip(20.0, 2L), trip(30.0, 1L)), + ) + + assertEquals(steady.value!!, volatile.value!!, 1e-9) + assertTrue(volatile.uncertainty!! > steady.uncertainty!!) + assertEquals(100.0, volatile.uncertainty!!, 1e-9) + } + + @Test fun `soc and capacity provide usable energy when direct energy is absent`() { + val estimate = estimator.estimate( + snapshot(energyKwh = null, soc = 50f, capacityKwh = 64f), null, + listOf(trip(16.0, 3L), trip(16.0, 2L), trip(16.0, 1L)), + ) + + assertEquals(200.0, estimate.value!!, 1e-9) + } + + @Test fun `firmware without a current battery power signal never estimates`() { + val estimate = estimator.estimate( + snapshot(energyKwh = 40f, powerKw = null), null, + listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)), + UnavailableReason.UNSUPPORTED_FIRMWARE, + ) + + assertNull(estimate.value) + assertEquals(UnavailableReason.UNSUPPORTED_FIRMWARE, estimate.reason) + } + + @Test fun `only the bounded newest history window affects the fit`() { + val estimate = AdaptiveRangeEstimator(maxConsumptionSamples = 3).estimate( + snapshot(energyKwh = 40f), null, + listOf( + trip(20.0, 30L), trip(20.0, 20L), trip(20.0, 10L), + trip(100.0, 1L), + ), + ) + + assertEquals(200.0, estimate.value!!, 1e-9) + assertEquals(20.0, estimate.uncertainty!!, 1e-9) + } + + private fun trip(consumption: Double, endedAtMs: Long): EnergyTripSummary { + val distanceKm = 10.0 + return EnergyTripSummary( + startedAtMs = 0L, + endedAtMs = endedAtMs, + durationMs = 1_000L, + distanceKm = distanceKm, + startSocPercent = null, + endSocPercent = null, + consumedKwh = consumption * distanceKm / 100.0, + regeneratedKwh = 0.0, + distanceAvailable = true, + ) + } + + private fun snapshot( + energyKwh: Float?, + soc: Float? = null, + capacityKwh: Float? = null, + powerKw: Float? = 10f, + ) = EnergySnapshot( + timestampMs = 0L, + firmware = FirmwareInfo.Gen.SWI68, + socPercent = soc, + rangeKm = null, + speedKmh = 50f, + batteryPowerKw = powerKw, + outsideTempCelsius = null, + cabinTempCelsius = null, + batteryTempCelsius = null, + batteryEnergyKwh = energyKwh, + batteryCapacityKwh = capacityKwh, + odometerKm = null, + chargePortConnected = null, + chargingStatus = null, + parked = null, + climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null), + tirePressures = TirePressureSnapshot(null, null, null, null), + ) +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ArrivalSocForecastTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ArrivalSocForecastTest.kt new file mode 100644 index 0000000..3474eb5 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ArrivalSocForecastTest.kt @@ -0,0 +1,144 @@ +package com.evsuite.hardware.telemetry + +import com.evsuite.hardware.FirmwareInfo +import com.evsuite.hardware.VehicleSpeedEvidence +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 SocRateEstimatorTest { + + /** The 2026-09-04 bundle: 56,1 % of charge and the car's own 278 km of range. */ + @Test fun `the vehicle's own range gives a rate from the first reading`() { + val rate = SocRateEstimator.fromVehicleRange(56.1, 278.0)!! + assertEquals(0.2018, rate.percentPerKm, 1e-4) + assertEquals(SocRate.Source.VEHICLE_RANGE, rate.source) + assertEquals(rate.percentPerKm * 0.20, rate.uncertaintyPercentPerKm, 1e-9) + } + + @Test fun `an absent or impossible reading yields no rate rather than a zero one`() { + assertNull(SocRateEstimator.fromVehicleRange(null, 278.0)) + assertNull(SocRateEstimator.fromVehicleRange(56.1, null)) + assertNull(SocRateEstimator.fromVehicleRange(56.1, 0.0)) + assertNull(SocRateEstimator.fromVehicleRange(-1.0, 278.0)) + assertNull(SocRateEstimator.fromVehicleRange(56.1, Double.NaN)) + } + + private fun trip(km: Double, spentSoc: Double, vouched: Boolean = true) = EnergyTripSummary( + startedAtMs = 0L, + endedAtMs = 1_000L, + durationMs = 1_000L, + distanceKm = km, + startSocPercent = 80f, + endSocPercent = (80.0 - spentSoc).toFloat(), + consumedKwh = null, + regeneratedKwh = null, + distanceAvailable = true, + speedEvidence = VehicleSpeedEvidence( + GENERATION, + if (vouched) VehicleSpeedEvidence.CURRENT else VehicleSpeedEvidence.MPS_TIMES_3_6_V1, + ), + ) + + private companion object { + /** The generation the vehicle evidence was gathered on. */ + val GENERATION = FirmwareInfo.Gen.SWI68 + } + + @Test fun `fewer than three usable trips is not a band`() { + assertNull(SocRateEstimator.fromTrips(listOf(trip(10.0, 2.0), trip(10.0, 2.1)), GENERATION)) + } + + @Test fun `a distance the current conversion did not produce is never averaged in`() { + // Three trips, all recorded under the 3.6x conversion: the rate would come out 3.6 + // times too optimistic, so no rate is offered at all. + val stale = List(3) { trip(36.0, 2.0, vouched = false) } + assertNull(SocRateEstimator.fromTrips(stale, GENERATION)) + } + + @Test fun `a trip whose charge rose is not a trip about consumption`() { + val charging = EnergyTripSummary( + startedAtMs = 0L, endedAtMs = 1L, durationMs = 1L, distanceKm = 10.0, + startSocPercent = 40f, endSocPercent = 80f, + consumedKwh = null, regeneratedKwh = null, distanceAvailable = true, + ) + assertNull(SocRateEstimator.fromTrips(listOf(charging, charging, charging), GENERATION)) + } + + @Test fun `trip history is preferred over the vehicle's range when there is enough of it`() { + val trips = List(4) { trip(10.0, 2.0) } + val best = SocRateEstimator.best(56.1, 278.0, trips, GENERATION)!! + assertEquals(SocRate.Source.TRIP_HISTORY, best.source) + assertEquals(0.2, best.percentPerKm, 1e-9) + assertEquals(4, best.sampleCount) + } + + @Test fun `identical trips still carry a floor on their band`() { + val best = SocRateEstimator.fromTrips(List(4) { trip(10.0, 2.0) }, GENERATION)!! + assertEquals(0.2 * 0.10, best.uncertaintyPercentPerKm, 1e-9) + } + + @Test fun `with too little history the vehicle's own range is used instead`() { + val best = SocRateEstimator.best(56.1, 278.0, listOf(trip(10.0, 2.0)), GENERATION)!! + assertEquals(SocRate.Source.VEHICLE_RANGE, best.source) + } +} + +class ArrivalSocForecastTest { + + private val vehicleRate = SocRateEstimator.fromVehicleRange(56.1, 278.0)!! + + @Test fun `the captured route forecasts an arrival with a band`() { + // The 2026-09-04 guidance capture: 9788 m remaining at 56,1 %. + val arrival = ArrivalSocForecast.of(56.1, 9.788, vehicleRate) + assertEquals(Provenance.ESTIMATED, arrival.provenance) + assertEquals(54.12, arrival.value!!, 0.01) + assertNotNull("an arrival figure is never a bare number", arrival.uncertainty) + } + + @Test fun `the band widens with distance`() { + val near = ArrivalSocForecast.of(56.1, 10.0, vehicleRate).uncertainty!! + val far = ArrivalSocForecast.of(56.1, 50.0, vehicleRate).uncertainty!! + assertTrue("a longer route is a less certain one", far > near) + assertEquals(5.0, far / near, 1e-9) + } + + @Test fun `a band wider than the ceiling is refused rather than shown`() { + val arrival = ArrivalSocForecast.of(56.1, 500.0, vehicleRate) + assertEquals(Provenance.UNAVAILABLE, arrival.provenance) + assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, arrival.reason) + assertNull(arrival.value) + } + + @Test fun `a destination beyond the charge reports a negative arrival, not a zero`() { + // 56,1 % at 0,2018 %/km reaches about 278 km; 300 km does not fit. + val trips = SocRate(0.2018, 0.002, SocRate.Source.TRIP_HISTORY, 5) + val arrival = ArrivalSocForecast.of(56.1, 300.0, trips) + assertTrue("running out has to be sayable", arrival.value!! < 0.0) + } + + @Test fun `no rate means no forecast, with a reason`() { + val arrival = ArrivalSocForecast.of(56.1, 9.788, null) + assertEquals(Provenance.UNAVAILABLE, arrival.provenance) + assertEquals(UnavailableReason.MODEL_NOT_TRAINED, arrival.reason) + } + + @Test fun `an absent charge or distance is unavailable`() { + assertEquals( + Provenance.UNAVAILABLE, + ArrivalSocForecast.of(null, 9.788, vehicleRate).provenance, + ) + assertEquals( + Provenance.UNAVAILABLE, + ArrivalSocForecast.of(56.1, null, vehicleRate).provenance, + ) + } + + @Test fun `range at this rate matches the vehicle's own when the rate came from it`() { + val range = ArrivalSocForecast.rangeAtRateKm(56.1, vehicleRate) + assertEquals(278.0, range.value!!, 0.1) + assertTrue("the quoted band never overstates range", range.uncertainty!! > 0.0) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryCapacityConfigTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryCapacityConfigTest.kt new file mode 100644 index 0000000..98b62d1 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryCapacityConfigTest.kt @@ -0,0 +1,64 @@ +package com.evsuite.hardware.telemetry + +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 BatteryCapacityConfigTest { + + /** + * The owner's MG4 Long Range: 64 kWh gross and 61,7 kWh usable per EVKX's specification + * sheet, at the 95 % state of health the dealer reported. + */ + private val mg4 = BatteryCapacityConfig.of(61.7, 95.0)!! + + @Test fun `state of health scales the usable pack`() { + assertEquals(58.615, mg4.effectiveUsableKwh, 1e-6) + } + + @Test fun `energy at a state of charge is an estimate with a band`() { + val energy = mg4.energyAtSocKwh(56.1) + assertEquals(Provenance.ESTIMATED, energy.provenance) + assertEquals(32.883, energy.value!!, 1e-3) + assertNotNull("a declared capacity is never drawn as a reading", energy.uncertainty) + assertEquals(energy.value!! * 0.05, energy.uncertainty!!, 1e-6) + } + + @Test fun `a full pack is the effective capacity and an empty one is zero`() { + assertEquals(58.615, mg4.energyAtSocKwh(100.0).value!!, 1e-6) + assertEquals(0.0, mg4.energyAtSocKwh(0.0).value!!, 1e-9) + } + + @Test fun `an absent or impossible state of charge is unavailable, not zero`() { + listOf(null, -1.0, 101.0, Double.NaN).forEach { + val energy = mg4.energyAtSocKwh(it) + assertEquals("soc=$it", Provenance.UNAVAILABLE, energy.provenance) + assertNull(energy.value) + } + } + + @Test fun `energy converts back to state of charge with the same relative band`() { + val soc = mg4.socPercentForEnergy(58.615) + assertEquals(100.0, soc.value!!, 1e-6) + assertEquals(5.0, soc.uncertainty!!, 1e-6) + } + + @Test fun `figures outside the plausible range are refused rather than clamped`() { + assertNull(BatteryCapacityConfig.of(0.0, 95.0)) + assertNull(BatteryCapacityConfig.of(500.0, 95.0)) + assertNull(BatteryCapacityConfig.of(61.7, 0.0)) + assertNull(BatteryCapacityConfig.of(61.7, 140.0)) + assertNull(BatteryCapacityConfig.of(null, 95.0)) + assertNull(BatteryCapacityConfig.of(61.7, null)) + } + + @Test fun `the nameplate figure would overstate a full pack`() { + // Entering 64 kWh instead of the usable 61.7 inflates every forecast in the optimistic + // direction — which is the mistake this field's wording exists to prevent. + val nameplate = BatteryCapacityConfig.of(64.0, 95.0)!! + assertTrue(nameplate.effectiveUsableKwh > mg4.effectiveUsableKwh) + assertEquals(2.185, nameplate.effectiveUsableKwh - mg4.effectiveUsableKwh, 1e-3) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryExposureTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryExposureTest.kt new file mode 100644 index 0000000..259dcb9 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryExposureTest.kt @@ -0,0 +1,109 @@ +package com.evsuite.hardware.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BatteryExposureTest { + + @Test fun `a night ending at eighty five counts every one of its hours as high`() { + val report = BatteryExposure().analyse( + listOf(entry(0L, 90f), entry(10 * HOUR, 85f)) + )!! + + assertEquals(10.0, report.hoursAboveHighSoc, 1e-6) + assertEquals(0.0, report.hoursBelowLowSoc, 1e-6) + assertEquals(10.0, report.observedHours, 1e-6) + } + + @Test fun `a segment that crosses the threshold is split where it crosses`() { + // 90 % to 70 % over ten hours: half of the fall is above 80 %, so half the hours are. + val report = BatteryExposure().analyse( + listOf(entry(0L, 90f), entry(10 * HOUR, 70f)) + )!! + + assertEquals(5.0, report.hoursAboveHighSoc, 1e-6) + assertEquals(80.0, report.meanSocPercent, 1e-6) + } + + @Test fun `the charge spent is counted in equivalent full cycles`() { + val report = BatteryExposure().analyse( + listOf( + entry(0L, 100f, odometerKm = 10_000f), + entry(DAY, 20f, odometerKm = 10_400f), + entry(DAY + 4 * HOUR, 100f, odometerKm = 10_400f), + entry(2 * DAY, 20f, odometerKm = 10_800f), + ) + )!! + + assertEquals(1.6, report.equivalentFullCycles, 1e-6) + assertEquals(800.0 / 1.6, report.kmPerEquivalentCycle!!, 1e-6) + } + + @Test fun `a rise is one session however many entries it wrote`() { + val report = BatteryExposure().analyse( + listOf( + entry(0L, 20f), + entry(HOUR, 45f), + entry(2 * HOUR, 70f), + entry(3 * HOUR, 80f), + entry(DAY, 30f), + ) + )!! + + val session = report.sessions.single() + assertEquals(20.0, session.startSocPercent, 1e-6) + assertEquals(80.0, session.endSocPercent, 1e-6) + assertEquals(60.0, session.gainedPercent, 1e-6) + assertEquals(20.0, session.meanPercentPerHour, 1e-6) + assertEquals(25.0, session.peakPercentPerHour, 1e-6) + } + + @Test fun `the rate is what tells a wall box from a fast charger`() { + val slow = BatteryExposure().analyse( + listOf(entry(0L, 30f), entry(8 * HOUR, 90f), entry(DAY, 40f)) + )!!.sessions.single() + val fast = BatteryExposure().analyse( + listOf(entry(0L, 30f), entry(HOUR / 2, 80f), entry(DAY, 40f)) + )!!.sessions.single() + + assertTrue(slow.meanPercentPerHour < 10.0) + assertTrue(fast.meanPercentPerHour > 50.0) + } + + @Test fun `outside temperature is carried, and stays outside temperature`() { + val report = BatteryExposure().analyse( + listOf(entry(0L, 30f, temp = 2f), entry(2 * HOUR, 70f, temp = 4f)) + )!! + + assertEquals(4.0, report.sessions.single().meanOutsideTempCelsius!!, 1e-6) + } + + @Test fun `a ledger with nothing to compare is not a habit`() { + assertNull(BatteryExposure().analyse(listOf(entry(0L, 50f)))) + assertNull(BatteryExposure().analyse(emptyList())) + } + + @Test fun `hours below the low threshold are counted too`() { + val report = BatteryExposure().analyse( + listOf(entry(0L, 10f), entry(5 * HOUR, 5f)) + )!! + + assertEquals(5.0, report.hoursBelowLowSoc, 1e-6) + assertEquals(0.0, report.hoursAboveHighSoc, 1e-6) + } + + private fun entry(atMs: Long, soc: Float, odometerKm: Float? = null, temp: Float? = null) = + BatteryLedgerEntry( + atMs = atMs, + socPercent = soc, + odometerKm = odometerKm, + outsideTempCelsius = temp, + ) + + private companion object { + const val HOUR = 3_600_000L + const val DAY = 86_400_000L + } +} 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 new file mode 100644 index 0000000..ec5f6fb --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/BatteryLedgerTest.kt @@ -0,0 +1,159 @@ +package com.evsuite.hardware.telemetry + +import com.evsuite.hardware.FirmwareInfo +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 +import java.io.File +import java.nio.file.Files + +class BatteryLedgerTest { + + @Test fun `entries round trip oldest first`() { + val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME)) + assertTrue(store.append(entry(atMs = 2_000L, soc = 70f))) + assertTrue(store.append(entry(atMs = 1_000L, soc = 80f))) + + assertEquals(listOf(1_000L, 2_000L), store.read().map { it.atMs }) + } + + @Test fun `an unreadable file is quarantined and never read as an empty ledger twice`() { + val directory = tempDirectory() + val target = File(directory, BatteryLedgerStore.FILE_NAME) + target.writeText("{\"schemaVersion\":1,\"entries\":[{\"atMs\":1,") + + val store = BatteryLedgerStore(target) + assertTrue(store.read().isEmpty()) + assertFalse(target.exists()) + assertEquals(1, directory.listFiles()!!.count { it.name.contains("quarantine") }) + } + + @Test fun `a file from a schema this build does not know is not guessed at`() { + val target = File(tempDirectory(), BatteryLedgerStore.FILE_NAME) + target.writeText("{\"schemaVersion\":99,\"entries\":[{\"atMs\":1,\"socPercent\":50}]}") + + assertTrue(BatteryLedgerStore(target).read().isEmpty()) + } + + @Test fun `the newest entries survive the bound`() { + val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME), maxEntries = 3) + repeat(5) { store.append(entry(atMs = it * 1_000L, soc = 90f - it)) } + + assertEquals(listOf(2_000L, 3_000L, 4_000L), store.read().map { it.atMs }) + } + + @Test fun `a sample with no charge reading earns nothing`() { + val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME)) + val recorder = BatteryLedgerRecorder(store) + + assertFalse(recorder.observe(snapshot(atMs = 0L, soc = null))) + assertTrue(store.read().isEmpty()) + } + + @Test fun `an entry is earned by a point of charge, not by a sample`() { + val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME)) + val recorder = BatteryLedgerRecorder(store) + + assertTrue(recorder.observe(snapshot(atMs = 0L, soc = 80f))) + assertFalse(recorder.observe(snapshot(atMs = 1_000L, soc = 79.5f))) + assertTrue(recorder.observe(snapshot(atMs = 2_000L, soc = 79f))) + + assertEquals(listOf(80f, 79f), store.read().map { it.socPercent }) + } + + @Test fun `a charging state change earns an entry even at the same charge`() { + val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME)) + val recorder = BatteryLedgerRecorder(store) + + recorder.observe(snapshot(atMs = 0L, soc = 50f, chargingStatus = 0)) + assertTrue(recorder.observe(snapshot(atMs = 1_000L, soc = 50f, chargingStatus = 1))) + } + + @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) + + recorder.observe(snapshot(atMs = 0L, soc = 80f, powerKw = 36f, speedKmh = 72f)) + // 36 kW and 72 km/h held for an hour, sampled every second, would be 36 kWh and 72 km; + // one second of it is a thousandth of that. + recorder.observe(snapshot(atMs = 1_000L, soc = 80f, powerKw = 36f, speedKmh = 72f)) + // A gap longer than five seconds is time the app did not watch: nothing is added. + recorder.observe(snapshot(atMs = 60_000L, soc = 79f, powerKw = 36f, speedKmh = 72f)) + + val last = store.read().last() + assertEquals(0.01, last.packEnergyKwh!!, 1e-9) + assertEquals(0.02, last.integratedDistanceKm!!, 1e-9) + } + + @Test fun `a restart continues the counters instead of starting again at zero`() { + val target = File(tempDirectory(), BatteryLedgerStore.FILE_NAME) + val store = BatteryLedgerStore(target) + val first = BatteryLedgerRecorder(store) + first.observe(snapshot(atMs = 0L, soc = 80f, powerKw = 36f, speedKmh = 72f)) + first.observe(snapshot(atMs = 1_000L, soc = 79f, powerKw = 36f, speedKmh = 72f)) + val before = store.read().last().packEnergyKwh!! + + val afterRestart = BatteryLedgerRecorder(BatteryLedgerStore(target)) + afterRestart.observe(snapshot(atMs = 10_000L, soc = 78f, powerKw = 36f, speedKmh = 72f)) + afterRestart.observe(snapshot(atMs = 11_000L, soc = 77f, powerKw = 36f, speedKmh = 72f)) + + assertTrue(store.read().last().packEnergyKwh!! > before) + assertEquals(before + 0.01, store.read().last().packEnergyKwh!!, 1e-9) + } + + @Test fun `the vehicle's own counters are carried through when the car keeps any`() { + val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME)) + BatteryLedgerRecorder(store).observe( + snapshot(atMs = 0L, soc = 80f, consumedKwh = 4.5f, regeneratedKwh = 0.5f) + ) + + val entry = store.read().single() + assertEquals(4.5f, entry.vehicleConsumedKwh!!, 0f) + assertEquals(0.5f, entry.vehicleRegeneratedKwh!!, 0f) + } + + @Test fun `a reader never sees an integral the app never integrated`() { + val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME)) + BatteryLedgerRecorder(store).observe(snapshot(atMs = 0L, soc = 80f, powerKw = null)) + + assertNull(store.read().single().packEnergyKwh) + assertNotNull(store.read().single().socPercent) + } + + private fun entry(atMs: Long, soc: Float) = BatteryLedgerEntry(atMs = atMs, socPercent = soc) + + private fun snapshot( + atMs: Long, + soc: Float?, + powerKw: Float? = null, + speedKmh: Float? = null, + chargingStatus: Int? = null, + consumedKwh: Float? = null, + regeneratedKwh: Float? = null, + ) = EnergySnapshot( + timestampMs = atMs, + firmware = FirmwareInfo.Gen.SWI68, + socPercent = soc, + rangeKm = null, + speedKmh = speedKmh, + batteryPowerKw = powerKw, + outsideTempCelsius = 18f, + cabinTempCelsius = null, + batteryTempCelsius = null, + batteryEnergyKwh = null, + batteryCapacityKwh = null, + odometerKm = 20_000f, + chargePortConnected = null, + chargingStatus = chargingStatus, + vehicleConsumedKwh = consumedKwh, + vehicleRegeneratedKwh = regeneratedKwh, + parked = true, + climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null), + tirePressures = TirePressureSnapshot(null, null, null, null), + ) + + private fun tempDirectory(): File = Files.createTempDirectory("ledger").toFile() +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/CalibrationDriftTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/CalibrationDriftTest.kt new file mode 100644 index 0000000..3a15480 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/CalibrationDriftTest.kt @@ -0,0 +1,123 @@ +package com.evsuite.hardware.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CalibrationDriftTest { + + @Test fun `a charge to full last week settles it`() { + val entries = listOf( + entry(day(0), 98f), + // The odometer moves, because the charge was spent driving: a step is only a step + // where the car went nowhere. + entry(day(3), 60f, odometerKm = 10_200f), + ) + val report = CalibrationDrift().analyse(entries, nowMs = day(7))!! + + assertEquals(CalibrationVerdict.SETTLED, report.verdict) + assertEquals(day(0), report.lastFullChargeAtMs) + assertEquals(7.0, report.daysSinceFullCharge!!, 1e-9) + assertEquals(0.38, report.equivalentCyclesSinceFullCharge, 1e-6) + } + + @Test fun `ninety days without a full charge asks for one`() { + val entries = listOf( + entry(day(0), 98f), + entry(day(50), 60f, odometerKm = 10_300f), + entry(day(95), 55f, odometerKm = 10_400f), + ) + val report = CalibrationDrift().analyse(entries, nowMs = day(100))!! + + assertEquals(CalibrationVerdict.RE_ANCHOR_SUGGESTED, report.verdict) + assertEquals(100.0, report.daysSinceFullCharge!!, 1e-9) + } + + @Test fun `a history that never saw a full charge is not silently settled`() { + val entries = listOf(entry(day(0), 80f), entry(day(1), 60f, odometerKm = 10_100f)) + + assertEquals( + CalibrationVerdict.RE_ANCHOR_SUGGESTED, + CalibrationDrift().analyse(entries, nowMs = day(2))!!.verdict, + ) + } + + @Test fun `between the two it watches`() { + val entries = listOf(entry(day(0), 98f), entry(day(40), 70f)) + + assertEquals( + CalibrationVerdict.WATCH, + CalibrationDrift().analyse(entries, nowMs = day(45))!!.verdict, + ) + } + + @Test fun `a parked car that lost four points overnight is one unexplained step`() { + val entries = listOf( + entry(day(0), 98f), + entry(day(1), 70f, odometerKm = 10_100f), + entry(day(1) + 8 * HOUR, 66f, odometerKm = 10_100f), + ) + val report = CalibrationDrift().analyse(entries, nowMs = day(2))!! + + assertEquals(1, report.steps.size) + assertEquals(4.0, report.steps.single().dropPercent, 1e-6) + assertTrue(report.steps.single().parked) + assertEquals(CalibrationVerdict.RE_ANCHOR_SUGGESTED, report.verdict) + } + + @Test fun `a fortnight of standing still is self-discharge, not a step`() { + val entries = listOf( + entry(day(0), 98f, odometerKm = 10_000f), + entry(day(14), 85f, odometerKm = 10_000f), + ) + assertTrue(CalibrationDrift().analyse(entries, nowMs = day(15))!!.steps.isEmpty()) + } + + @Test fun `a drop with the odometer moving is a drive, whatever the app sampled`() { + val entries = listOf( + entry(day(0), 98f, odometerKm = 10_000f), + entry(day(0) + HOUR, 80f, odometerKm = 10_090f), + ) + assertTrue(CalibrationDrift().analyse(entries, nowMs = day(1))!!.steps.isEmpty()) + } + + @Test fun `without an odometer at both ends nothing is called a step`() { + val entries = listOf( + entry(day(0), 98f, odometerKm = null), + entry(day(0) + HOUR, 90f, odometerKm = null), + ) + assertTrue(CalibrationDrift().analyse(entries, nowMs = day(1))!!.steps.isEmpty()) + } + + @Test fun `the estimate's band is carried as the spread, in points`() { + val estimate = StateOfHealthEstimate( + usableCapacityKwh = Provenanced.estimated(60.0, 1.0), + stateOfHealthPercent = Provenanced.estimated(97.2, 1.6), + source = SohEnergySource.VEHICLE_COUNTERS, + windowCount = 6, + coldWindowsExcluded = 0, + spanDays = 30.0, + spanKm = 1_800.0, + trendPercentPoints = null, + ) + val report = CalibrationDrift() + .analyse(listOf(entry(day(0), 98f), entry(day(1), 60f)), day(2), estimate)!! + + assertEquals(3.2, report.estimateSpreadPercentPoints!!, 1e-9) + } + + @Test fun `one entry is not a history`() { + assertNull(CalibrationDrift().analyse(listOf(entry(day(0), 90f)), nowMs = day(1))) + } + + private fun entry(atMs: Long, soc: Float, odometerKm: Float? = 10_000f) = + BatteryLedgerEntry(atMs = atMs, socPercent = soc, odometerKm = odometerKm, parked = true) + + private fun day(index: Int): Long = index * DAY + + private companion object { + const val DAY = 86_400_000L + const val HOUR = 3_600_000L + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ChargeStopPlanTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ChargeStopPlanTest.kt new file mode 100644 index 0000000..fde8667 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ChargeStopPlanTest.kt @@ -0,0 +1,217 @@ +package com.evsuite.hardware.telemetry + +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 ChargeStopPlanTest { + + private fun rate(percentPerKm: Double, uncertainty: Double) = SocRate( + percentPerKm = percentPerKm, + uncertaintyPercentPerKm = uncertainty, + source = SocRate.Source.TRIP_HISTORY, + sampleCount = 8, + ) + + @Test + fun `a short route on a full pack needs no stop`() { + val plan = ChargeStopPlan.of(90.0, 100.0, rate(0.4, 0.04)) + assertTrue(plan is ChargeStopPlan.Plan.NoStop) + plan as ChargeStopPlan.Plan.NoStop + assertEquals(50.0, plan.arrivalPercent, 0.001) + assertEquals(40.0, plan.marginPercent, 0.001) + } + + @Test + fun `the reserve is what makes a marginal trip need a stop`() { + // 100 km at 0.4 %/km spends 40 %; from 45 % that arrives at 5, under a 10 % reserve. + val withoutReserve = ChargeStopPlan.of(45.0, 100.0, rate(0.4, 0.02), reservePercent = 0.0) + val withReserve = ChargeStopPlan.of(45.0, 100.0, rate(0.4, 0.02)) + assertTrue(withoutReserve is ChargeStopPlan.Plan.NoStop) + assertTrue(withReserve is ChargeStopPlan.Plan.Stop) + } + + @Test + fun `the stop distance is pessimistic, not the midpoint`() { + // 35 % spendable at 0.4 +- 0.04 %/km: the midpoint reaches 87,5 km, the top of the + // band only 79,5. The driver is told the shorter one. + val plan = ChargeStopPlan.of(45.0, 200.0, rate(0.4, 0.04)) + assertTrue(plan is ChargeStopPlan.Plan.Stop) + plan as ChargeStopPlan.Plan.Stop + assertEquals(79.5, plan.afterKm, 0.5) + assertTrue("pessimistic", plan.afterKm < 35.0 / 0.4) + assertEquals(200.0 - plan.afterKm, plan.shortfallKm, 0.001) + } + + @Test + fun `a long route with a loose rate is refused rather than shown wide`() { + val plan = ChargeStopPlan.of(80.0, 600.0, rate(0.4, 0.05)) + assertEquals( + ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.BAND_TOO_WIDE), + plan, + ) + } + + @Test + fun `the same route with a tight rate is planned`() { + val plan = ChargeStopPlan.of(80.0, 600.0, rate(0.4, 0.01)) + assertTrue(plan is ChargeStopPlan.Plan.Stop) + } + + @Test + fun `missing inputs each name themselves`() { + assertEquals( + ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.NO_CHARGE), + ChargeStopPlan.of(null, 100.0, rate(0.4, 0.01)), + ) + assertEquals( + ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.NO_ROUTE), + ChargeStopPlan.of(80.0, null, rate(0.4, 0.01)), + ) + assertEquals( + ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.NO_RATE), + ChargeStopPlan.of(80.0, 100.0, null), + ) + } + + @Test + fun `a flat route plans exactly as it did before grade existed`() { + val flat = RouteGrade.of(0.0, 0.0, BatteryCapacityConfig(61.7, 100.0)) + assertEquals( + ChargeStopPlan.of(90.0, 100.0, rate(0.4, 0.04)), + ChargeStopPlan.of(90.0, 100.0, rate(0.4, 0.04), grade = flat), + ) + } + + @Test + fun `a col between here and there is charge that has to be spent`() { + val pack = BatteryCapacityConfig(61.7, 100.0) + val plain = ChargeStopPlan.of(90.0, 200.0, rate(0.3, 0.02)) as ChargeStopPlan.Plan.NoStop + val overACol = ChargeStopPlan.of( + 90.0, 200.0, rate(0.3, 0.02), + grade = RouteGrade.of(1200.0, 400.0, pack), + ) as ChargeStopPlan.Plan.NoStop + + assertTrue("the climb costs charge", overACol.arrivalPercent < plain.arrivalPercent) + assertTrue("and it is less certain", overACol.bandPercent > plain.bandPercent) + + // Downhill the other way, and the same route returns some of it. + val backDown = ChargeStopPlan.of( + 90.0, 200.0, rate(0.3, 0.02), + grade = RouteGrade.of(400.0, 1200.0, pack), + ) as ChargeStopPlan.Plan.NoStop + assertTrue(backDown.arrivalPercent > plain.arrivalPercent) + assertTrue( + "the descent gives back less than the climb took", + backDown.arrivalPercent - plain.arrivalPercent < + plain.arrivalPercent - overACol.arrivalPercent, + ) + } + + @Test + fun `a climb brings the stop forward rather than leaving it where it was`() { + val pack = BatteryCapacityConfig(61.7, 100.0) + val plain = ChargeStopPlan.of(45.0, 300.0, rate(0.4, 0.02)) as ChargeStopPlan.Plan.Stop + val overACol = ChargeStopPlan.of( + 45.0, 300.0, rate(0.4, 0.02), + grade = RouteGrade.of(1200.0, 0.0, pack), + ) as ChargeStopPlan.Plan.Stop + assertTrue(overACol.afterKm < plain.afterKm) + } + + @Test + fun `a car already below its reserve is told to stop immediately`() { + val plan = ChargeStopPlan.of(8.0, 50.0, rate(0.4, 0.01)) + assertTrue(plan is ChargeStopPlan.Plan.Stop) + plan as ChargeStopPlan.Plan.Stop + assertEquals(0.0, plan.afterKm, 0.001) + } + + // --- chain ---------------------------------------------------------------------------- + + @Test + fun `Auzielle to Paris is two stops where one plan refused the whole trip`() { + // 680 km at 0,28 +- 0,028 %/km: the single-stop plan measures its band over all of it, + // 2 x 0,028 x 680 = 38 %, and refuses. Each leg is a third of that road. + val whole = ChargeStopPlan.of(80.0, 680.0, rate(0.28, 0.028)) + assertTrue(whole is ChargeStopPlan.Plan.Refused) + assertEquals( + ChargeStopPlan.Reason.BAND_TOO_WIDE, + (whole as ChargeStopPlan.Plan.Refused).reason, + ) + + val chain = ChargeStopPlan.chain(80.0, 680.0, rate(0.28, 0.028)) + assertNull("the chain reaches Paris", chain.refusal) + assertTrue("and stops twice on the way", chain.stops.size >= 2) + assertNotNull(chain.arrival) + // Every stop is somewhere on the road, in the order they are driven. + var previous = 0.0 + for (stop in chain.stops) { + assertTrue(stop.endKm > previous) + assertTrue(stop.endKm < 680.0) + previous = stop.endKm + } + // A stop leaves with the declared charge, not with a full pack. + assertEquals( + ChargeStopPlan.DEFAULT_DEPARTURE_PERCENT, + chain.legs[1].startPercent, + 0.001, + ) + } + + @Test + fun `a route one charge covers is the plan of already made`() { + val single = ChargeStopPlan.of(90.0, 200.0, rate(0.3, 0.02)) as ChargeStopPlan.Plan.NoStop + val chain = ChargeStopPlan.chain(90.0, 200.0, rate(0.3, 0.02)) + assertEquals(1, chain.legs.size) + assertEquals(single, chain.arrival) + assertEquals(0.0, chain.legs[0].startKm, 0.001) + assertEquals(200.0, chain.legs[0].endKm, 0.001) + } + + @Test + fun `a last leg too uncertain to vouch for refuses itself and not the legs before it`() { + // 414 km at 0,3 +- 0,06 %/km — the fifth the vehicle's own range figure is worth. The + // leg to the stop is 194 km and its band holds; the 220 km left after it are reachable + // on the charge the stop leaves with, and arriving there is a 26 % wide claim. + val chain = ChargeStopPlan.chain(80.0, 414.0, rate(0.3, 0.06)) + assertEquals(ChargeStopPlan.Reason.BAND_TOO_WIDE, chain.refusal) + assertEquals("the stop before it still reads", 1, chain.stops.size) + assertTrue(chain.stops.all { it.plan is ChargeStopPlan.Plan.Stop }) + } + + @Test + fun `a departure charge that buys no distance is refused rather than looped over`() { + // Leaving a stop on the reserve itself: the next leg reaches nowhere, for ever. + val chain = ChargeStopPlan.chain( + 80.0, 900.0, rate(0.3, 0.01), reservePercent = 10.0, departurePercent = 10.0, + ) + assertEquals(ChargeStopPlan.Reason.NO_PROGRESS, chain.refusal) + assertEquals("the first leg was still driveable", 1, chain.stops.size) + } + + @Test + fun `a trip needing more legs than the cap is refused as one`() { + val chain = ChargeStopPlan.chain(80.0, 4000.0, rate(0.3, 0.01)) + assertEquals(ChargeStopPlan.Reason.TOO_MANY_LEGS, chain.refusal) + assertEquals(ChargeStopPlan.MAX_LEGS, chain.stops.size) + } + + @Test + fun `the refusals about the trip are answered by a chain of one leg`() { + assertEquals( + ChargeStopPlan.Reason.NO_CHARGE, + ChargeStopPlan.chain(null, 100.0, rate(0.3, 0.01)).refusal, + ) + assertEquals( + ChargeStopPlan.Reason.NO_ROUTE, + ChargeStopPlan.chain(80.0, null, rate(0.3, 0.01)).refusal, + ) + assertEquals( + ChargeStopPlan.Reason.NO_RATE, + ChargeStopPlan.chain(80.0, 100.0, null).refusal, + ) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ChargingSessionMeterTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ChargingSessionMeterTest.kt new file mode 100644 index 0000000..d545138 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ChargingSessionMeterTest.kt @@ -0,0 +1,34 @@ +package com.evsuite.hardware.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ChargingSessionMeterTest { + @Test fun integratesTrapezoidsAndRoundsForPayloads() { + val meter = ChargingSessionMeter() + assertEquals(0.0, meter.sample(0L, true, 10f)!!, 0.0) + assertEquals(0.25, meter.sample(60_000L, true, 20f)!!, 0.0) + } + + @Test fun closedOrNeverOpenedSessionIsOmitted() { + val meter = ChargingSessionMeter() + assertNull(meter.sample(0L, null, null)) + meter.sample(1L, true, 5f) + assertNull(meter.sample(2L, false, 0f)) + } + + @Test fun missingRateAndLongGapNeverInventEnergy() { + val meter = ChargingSessionMeter(maxSampleGapMs = 1_000L) + meter.sample(0L, true, 10f) + assertEquals(0.0, meter.sample(500L, true, null)!!, 0.0) + assertEquals(0.0, meter.sample(2_000L, true, 10f)!!, 0.0) + assertEquals(0.0, meter.sample(4_000L, true, 10f)!!, 0.0) + } + + @Test fun negativeChargeRateCannotReduceSessionTotal() { + val meter = ChargingSessionMeter() + meter.sample(0L, true, 10f) + assertEquals(0.08, meter.sample(60_000L, true, -5f)!!, 0.0) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ConsumptionCalculatorTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ConsumptionCalculatorTest.kt new file mode 100644 index 0000000..a4f59dd --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ConsumptionCalculatorTest.kt @@ -0,0 +1,111 @@ +package com.evsuite.hardware.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import kotlin.math.exp + +class ConsumptionCalculatorTest { + @Test fun `zero and low speed are unavailable rather than zero or infinity`() { + val zero = ConsumptionCalculator.instantaneous(12f, 0f) + val low = ConsumptionCalculator.instantaneous(12f, 4.9f) + + assertNull(zero.value) + assertEquals(Provenance.UNAVAILABLE, zero.provenance) + assertEquals(UnavailableReason.SPEED_TOO_LOW, zero.reason) + assertNull(low.value) + assertEquals(UnavailableReason.SPEED_TOO_LOW, low.reason) + } + + @Test fun `a missing input propagates the stated reason`() { + val missingPower = ConsumptionCalculator.instantaneous(null, 80f) + val missingSpeed = ConsumptionCalculator.instantaneous( + 18f, + null, + UnavailableReason.UNSUPPORTED_FIRMWARE, + ) + + assertEquals(UnavailableReason.SIGNAL_ABSENT, missingPower.reason) + assertEquals(UnavailableReason.UNSUPPORTED_FIRMWARE, missingSpeed.reason) + } + + @Test fun `regeneration remains a negative instantaneous consumption`() { + val value = ConsumptionCalculator.instantaneous(-18f, 90f) + + assertEquals(Provenance.DERIVED, value.provenance) + assertEquals(-20.0, value.value!!, 1e-9) + } + + @Test fun `dashboard smoothing resets when consumption changes to regeneration`() { + val calculator = ConsumptionCalculator() + calculator.add(0L, 18f, 90f) + + val regeneration = calculator.add(1_000L, -18f, 90f) + + assertEquals(-20.0, regeneration.rawInstantaneous.value!!, 1e-9) + assertEquals(-20.0, regeneration.smoothedInstantaneous.value!!, 1e-9) + } + + @Test fun `dashboard value uses a five second exponential moving average`() { + val calculator = ConsumptionCalculator() + calculator.add(0L, 18f, 90f) + + val second = calculator.add(5_000L, 36f, 90f) + val expected = 20.0 + (1.0 - exp(-1.0)) * (40.0 - 20.0) + + assertEquals(40.0, second.rawInstantaneous.value!!, 1e-9) + assertEquals(expected, second.smoothedInstantaneous.value!!, 1e-9) + } + + @Test fun `rolling average matches trip integration for the same samples`() { + val calculator = ConsumptionCalculator() + val accumulator = EnergyTripAccumulator(0L, 80f) + val samples = listOf( + snapshot(0L, 120f, 18f), + snapshot(5_000L, 120f, 18f), + ) + + var reading: ConsumptionCalculator.Reading? = null + samples.forEach { + accumulator.add(it) + reading = calculator.add(it) + } + + assertEquals( + accumulator.snapshot(5_000L).averageConsumptionKwhPer100Km!!, + reading!!.rollingAverage.value!!, + 1e-9, + ) + } + + @Test fun `rolling average drops samples outside its bounded window`() { + val calculator = ConsumptionCalculator(rollingWindowMs = 2_000L) + calculator.add(0L, 40f, 100f) + calculator.add(1_000L, 40f, 100f) + calculator.add(4_000L, 20f, 200f) + + val recent = calculator.add(6_000L, 20f, 200f) + + assertEquals(10.0, recent.rollingAverage.value!!, 1e-9) + } + + private fun snapshot(atMs: Long, speedKmh: Float, powerKw: Float) = EnergySnapshot( + timestampMs = atMs, + firmware = com.evsuite.hardware.FirmwareInfo.Gen.SWI68, + socPercent = 80f, + rangeKm = null, + speedKmh = speedKmh, + batteryPowerKw = powerKw, + outsideTempCelsius = null, + cabinTempCelsius = null, + batteryTempCelsius = null, + batteryEnergyKwh = null, + batteryCapacityKwh = null, + odometerKm = null, + chargePortConnected = null, + chargingStatus = null, + parked = null, + climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null), + tirePressures = TirePressureSnapshot(null, null, null, null), + ) +} 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 new file mode 100644 index 0000000..fa5f612 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/EnergyTelemetryTest.kt @@ -0,0 +1,182 @@ +package com.evsuite.hardware.telemetry + +import com.evsuite.hardware.BatteryPowerEvidence +import com.evsuite.hardware.FirmwareInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files + +class EnergyTelemetryTest { + @Test fun `trip carries the exact evidence used for power integration`() { + val evidence = BatteryPowerEvidence( + FirmwareInfo.Gen.SWI68, + BatteryPowerEvidence.OUTPUT_POSITIVE_MW_V1, + ) + val trip = EnergyTripAccumulator(0L, 80f, batteryPowerEvidence = evidence) + trip.add(snapshot(0L, 50f, 10f)) + trip.add(snapshot(1_000L, 50f, 10f)) + + assertEquals(evidence, trip.snapshot(1_000L).batteryPowerEvidence) + } + + @Test fun `reader keeps unknowns null and rejects invalid physical values`() { + val source = FakeSignals(soc = 120f, range = -1f, power = 18f) + val snapshot = EnergyTelemetryReader(source).read(42L) + assertEquals(42L, snapshot.timestampMs) + assertNull(snapshot.socPercent) + assertNull(snapshot.rangeKm) + assertEquals(18f, snapshot.batteryPowerKw) + } + + @Test fun `candidate probes exist only in the explicit evidence read`() { + val source = FakeSignals(soc = 80f, range = 200f, power = 18f) + source.probes = listOf( + TelemetryEvidenceProbe( + TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH, + 61_700.0, + ) + ) + val reader = EnergyTelemetryReader(source) + + val normal = reader.read(41L) + val evidence = reader.readEvidence(42L) + + assertEquals(41L, normal.timestampMs) + assertEquals(42L, evidence.snapshot.timestampMs) + assertEquals(61_700.0, evidence.probes.single().value!!, 0.0) + } + + @Test fun `non finite candidate values fail closed`() { + val source = FakeSignals(soc = null, range = null, power = null) + source.probes = listOf(TelemetryEvidenceProbe("candidate.invalid", Double.NaN)) + + assertNull(EnergyTelemetryReader(source).readEvidence(42L).probes.single().value) + } + + @Test fun `trip integrates distance consumption and regeneration`() { + val trip = EnergyTripAccumulator(0L, 80f) + trip.add(snapshot(0L, 60f, 18f)) + trip.add(snapshot(1_000L, 60f, 18f)) + trip.add(snapshot(2_000L, 60f, -6f)) + trip.add(snapshot(3_000L, 60f, -6f)) + val result = trip.snapshot(3_000L) + assertEquals(0.05, result.distanceKm, 0.000001) + assertTrue(result.distanceAvailable == true) + assertEquals(0.05, result.recordedDistanceKm!!, 0.000001) + assertEquals(0.006666, result.consumedKwh!!, 0.000001) + assertEquals(0.001666, result.regeneratedKwh!!, 0.000001) + } + + @Test fun `trip keeps distance unknown without a usable speed interval`() { + val trip = EnergyTripAccumulator(0L, 80f) + trip.add(snapshot(0L, null, 18f)) + trip.add(snapshot(1_000L, null, 18f)) + + val result = trip.snapshot(1_000L) + + assertFalse(result.distanceAvailable == true) + assertNull(result.recordedDistanceKm) + assertNull(result.averageConsumptionKwhPer100Km) + } + + @Test fun `a suspended sampler never inflates trip duration`() { + val trip = EnergyTripAccumulator(0L, 80f) + trip.add(snapshot(0L, 60f, 18f)) + trip.add(snapshot(1_000L, 60f, 18f)) + trip.add(snapshot(600_000L, 60f, 18f)) // ten minutes with the dashboard hidden + val result = trip.snapshot(600_000L) + assertEquals(1_000L, result.durationMs) + assertEquals(600_000L, result.endedAtMs) + assertEquals(0.016666, result.distanceKm, 0.000001) + } + + @Test fun `history is bounded newest first and leaves no temp file`() { + val directory = Files.createTempDirectory("energy-history").toFile() + val target = directory.resolve("trips.json") + val store = EnergyTripHistoryStore(target, maxTrips = 2) + assertEquals(true, store.append(summary(1L))) + assertEquals(true, store.append(summary(2L))) + assertEquals(true, store.append(summary(3L))) + assertEquals(listOf(3L, 2L), store.readSummaries().map { it.startedAtMs }) + assertEquals(emptyList(), directory.list()?.filter { it.endsWith(".tmp") }) + } + + private fun snapshot(at: Long, speed: Float?, power: Float) = EnergySnapshot( + timestampMs = at, firmware = FirmwareInfo.Gen.SWI68, socPercent = 80f, + rangeKm = null, speedKmh = speed, batteryPowerKw = power, + outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null, + batteryEnergyKwh = null, batteryCapacityKwh = null, odometerKm = null, + chargePortConnected = null, chargingStatus = null, parked = null, + climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null), + tirePressures = TirePressureSnapshot(null, null, null, null), + ) + + private fun summary(start: Long) = EnergyTripSummary( + start, start + 1, 1, 0.0, null, null, null, null + ) + + private class FakeSignals( + private val soc: Float?, + private val range: Float?, + private val power: Float?, + ) : EnergySignalSource { + var probes: List = emptyList() + override fun firmware() = FirmwareInfo.Gen.SWI68 + override fun socPercent() = soc + override fun rangeKm() = range + override fun speedKmh(): Float? = null + override fun batteryPowerKw() = power + override fun outsideTempCelsius(): Float? = null + override fun cabinTempCelsius(): Float? = null + override fun batteryTempCelsius(): Float? = null + override fun batteryEnergyKwh(): Float? = null + override fun batteryCapacityKwh(): Float? = null + override fun odometerKm(): Float? = null + override fun chargePortConnected(): Boolean? = null + override fun chargingStatus(): Int? = null + override fun parked(): Boolean? = null + override fun climate() = + ClimateSnapshot(null, null, null, null, null, null, null, null, null) + override fun tirePressures() = TirePressureSnapshot(null, null, null, null) + override fun evidenceProbes() = probes + } +} + +class TripDistanceEvidenceTest { + + private fun summary(evidence: com.evsuite.hardware.VehicleSpeedEvidence?) = EnergyTripSummary( + startedAtMs = 0L, + endedAtMs = 1_000L, + durationMs = 1_000L, + distanceKm = 8.46, + startSocPercent = 60f, + endSocPercent = 59f, + consumedKwh = null, + regeneratedKwh = null, + distanceAvailable = true, + speedEvidence = evidence, + ) + + @Test fun `a legacy distance stays reported but is not modellable`() { + val trip = summary(evidence = null) + assertEquals(8.46, trip.recordedDistanceKm!!, 1e-9) + assertNull( + "a model must not train on an unversioned distance", + trip.modellableDistanceKm(com.evsuite.hardware.FirmwareInfo.Gen.SWI68), + ) + } + + @Test fun `a distance from the 3_6x conversion is not modellable`() { + val trip = summary( + com.evsuite.hardware.VehicleSpeedEvidence( + com.evsuite.hardware.FirmwareInfo.Gen.SWI68, + com.evsuite.hardware.VehicleSpeedEvidence.MPS_TIMES_3_6_V1, + ) + ) + assertEquals(8.46, trip.recordedDistanceKm!!, 1e-9) + assertNull(trip.modellableDistanceKm(com.evsuite.hardware.FirmwareInfo.Gen.SWI68)) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/PlanDriftTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/PlanDriftTest.kt new file mode 100644 index 0000000..d47f5e4 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/PlanDriftTest.kt @@ -0,0 +1,156 @@ +package com.evsuite.hardware.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlanDriftTest { + + /** 300 km at 0,20 %/km ± 0,02: leaves at 90 %, plans to arrive at 30 %, reserve 10 %. */ + private val plan = PlanDrift.Followed( + legKm = 300.0, + socAtDeparturePercent = 90.0, + plannedRatePercentPerKm = 0.20, + plannedUncertaintyPercentPerKm = 0.02, + reservePercent = 10.0, + ) + + /** Charge left after [km] at [ratePerKm], as the gauge would read it. */ + private fun socAfter(km: Double, ratePerKm: Double) = + plan.socAtDeparturePercent - km * ratePerKm + + @Test + fun `a drive spending what the plan assumed is holding`() { + val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.20)) + val holding = verdict as PlanDrift.Verdict.Holding + assertEquals(0.20, holding.reading.observedPercentPerKm, 1e-9) + assertEquals(200.0, holding.reading.remainingKm, 1e-9) + assertEquals(30.0, holding.reading.arrivalPercent, 1e-9) + } + + @Test + fun `spending inside the plan's own band raises nothing`() { + // 0,215 %/km against a plan of 0,20 ± 0,02, with the measurement's own band on top: + // the two overlap, so there is no drift to report however the arrival lands. + val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.215)) + assertTrue(verdict.toString(), verdict is PlanDrift.Verdict.Holding) + } + + @Test + fun `spending well past the plan, and short of the reserve, drifts`() { + // 0,30 %/km: half again what the plan assumed, so the arrival lands on empty. + val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.30)) + val short = verdict as PlanDrift.Verdict.Short + assertEquals(0.30, short.reading.observedPercentPerKm, 1e-9) + assertEquals(0.0, short.reading.arrivalPercent, 1e-9) + assertTrue(short.shortfallPercent > 0.0) + // The shortfall is measured at the pessimistic edge, so it is wider than 10 − 0. + assertEquals( + plan.reservePercent - (0.0 - short.reading.bandPercent), + short.shortfallPercent, + 1e-9, + ) + } + + /** + * The gauge steps a tenth, not a whole percent, and that is the difference between a companion + * that speaks here and one that does not. At 0,26 %/km the arrival lands at 12 % against a + * 10 % reserve; the measurement's own band on that arrival is 0,72 points, so the reserve is + * still clear and there is nothing to say. A whole-percent gauge would have made the band 2,5 + * points, crossed the reserve on quantisation alone, and raised a line the drive never earned. + */ + @Test + fun `an arrival two points above the reserve is not short at a tenth-percent gauge`() { + val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.26)) + val holding = verdict as PlanDrift.Verdict.Holding + assertEquals(12.0, holding.reading.arrivalPercent, 1e-9) + assertTrue( + holding.reading.bandPercent.toString(), + holding.reading.bandPercent < 1.0, + ) + } + + @Test + fun `spending past the plan but still arriving well clear of the reserve is not reported`() { + // Same 0,26 %/km, but only 60 km of leg left to spend it on: the arrival still clears + // the reserve, and a companion that speaks here is one nobody reads when it matters. + val near = plan.copy(legKm = 100.0) + val verdict = PlanDrift.check(near, drivenKm = 40.0, socNowPercent = socAfter(40.0, 0.26)) + assertTrue(verdict.toString(), verdict is PlanDrift.Verdict.Holding) + } + + @Test + fun `the car's own remaining distance wins over the leg's arithmetic`() { + val verdict = PlanDrift.check( + plan, + drivenKm = 100.0, + socNowPercent = socAfter(100.0, 0.20), + remainingKm = 240.0, + ) + val holding = verdict as PlanDrift.Verdict.Holding + assertEquals(240.0, holding.reading.remainingKm, 1e-9) + assertEquals(70.0 - 240.0 * 0.20, holding.reading.arrivalPercent, 1e-9) + } + + @Test + fun `too little road behind the car says so rather than measuring`() { + val verdict = PlanDrift.check(plan, drivenKm = 5.0, socNowPercent = 88.0) + assertEquals( + PlanDrift.Verdict.Unavailable(PlanDrift.Reason.TOO_SOON), + verdict, + ) + } + + @Test + fun `a charge en route is refused, a col's worth of regeneration is not`() { + // 97 % after leaving at 90 %: seven points back is more than a descent gives. + assertEquals( + PlanDrift.Verdict.Unavailable(PlanDrift.Reason.CHARGED_EN_ROUTE), + PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = 97.0), + ) + // 94 % after 100 km: four points above departure, which a long descent can give back. + val recovered = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = 94.0) + val holding = recovered as PlanDrift.Verdict.Holding + assertEquals(0.0, holding.reading.observedPercentPerKm, 1e-9) + } + + @Test + fun `no plan, no charge, no distance and an arrival each say which`() { + assertEquals( + PlanDrift.Verdict.Unavailable(PlanDrift.Reason.NO_PLAN), + PlanDrift.check(null, 100.0, 60.0), + ) + assertEquals( + PlanDrift.Verdict.Unavailable(PlanDrift.Reason.NO_CHARGE), + PlanDrift.check(plan, 100.0, null), + ) + assertEquals( + PlanDrift.Verdict.Unavailable(PlanDrift.Reason.NO_DISTANCE), + PlanDrift.check(plan, null, 60.0), + ) + assertEquals( + PlanDrift.Verdict.Unavailable(PlanDrift.Reason.ARRIVED), + PlanDrift.check(plan, 320.0, 20.0), + ) + } + + @Test + fun `the measurement's band shrinks as the road behind it grows`() { + val early = PlanDrift.check(plan, 20.0, socAfter(20.0, 0.20)) as PlanDrift.Verdict.Holding + val late = PlanDrift.check(plan, 200.0, socAfter(200.0, 0.20)) as PlanDrift.Verdict.Holding + // Same rate, same route: the only thing that changed is how much evidence there is. + assertTrue( + "${early.reading.bandPercent} vs ${late.reading.bandPercent}", + early.reading.bandPercent > late.reading.bandPercent, + ) + } + + @Test + fun `the rate the plan was made with is the one with the climb already in it`() { + // What the app hands over: the planner's own fold, not a second copy of it. + val rate = SocRate(0.20, 0.02, SocRate.Source.TRIP_HISTORY, sampleCount = 8) + val effective = ChargeStopPlan.effectiveRate(rate, 300.0, RouteGrade.Cost(6.0, 1.5)) + assertEquals(0.22, effective.percentPerKm, 1e-9) + assertEquals(0.025, effective.uncertaintyPercentPerKm, 1e-9) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ProvenanceTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ProvenanceTest.kt new file mode 100644 index 0000000..b4aea63 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/ProvenanceTest.kt @@ -0,0 +1,89 @@ +package com.evsuite.hardware.telemetry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProvenanceTest { + + @Test fun `a missing reading is unavailable with a reason, not a zero`() { + val value = Provenanced.measured(null) + assertEquals(Provenance.UNAVAILABLE, value.provenance) + assertEquals(UnavailableReason.SIGNAL_ABSENT, value.reason) + assertNull(value.value) + } + + @Test fun `an unsupported firmware says so rather than reporting an absent signal`() { + val value = Provenanced.measured(null, UnavailableReason.UNSUPPORTED_FIRMWARE) + assertEquals(UnavailableReason.UNSUPPORTED_FIRMWARE, value.reason) + } + + @Test(expected = IllegalArgumentException::class) + fun `an estimate cannot be built without a band`() { + Provenanced(42.0, Provenance.ESTIMATED) + } + + @Test(expected = IllegalArgumentException::class) + fun `a band must be finite and non-negative`() { + Provenanced.estimated(42.0, -1.0) + } + + @Test(expected = IllegalArgumentException::class) + fun `a measurement carries no band — the vehicle publishes no error bars`() { + Provenanced(42.0, Provenance.MEASURED, uncertainty = 1.0) + } + + @Test(expected = IllegalArgumentException::class) + fun `an unavailable value carries no number`() { + Provenanced(42.0, Provenance.UNAVAILABLE, reason = UnavailableReason.SIGNAL_ABSENT) + } + + @Test fun `an estimate exposes its band edges`() { + val value = Provenanced.estimated(14.2, 1.6) + assertEquals(12.6, value.bandLow!!, 1e-9) + assertEquals(15.8, value.bandHigh!!, 1e-9) + } + + @Test fun `a derivation over an unavailable input is unavailable, never partly computed`() { + val power = Provenanced.measured(18.0) + val speed = Provenanced.measured(null) + val consumption = Provenanced.derive(power, speed) { p, v -> p * 100.0 / v } + assertEquals(Provenance.UNAVAILABLE, consumption.provenance) + assertEquals(UnavailableReason.SIGNAL_ABSENT, consumption.reason) + } + + @Test fun `a derivation carries the first missing input's reason`() { + val power = Provenanced.measured(null, UnavailableReason.UNSUPPORTED_FIRMWARE) + val speed = Provenanced.measured(90.0) + val consumption = Provenanced.derive(power, speed) { p, v -> p * 100.0 / v } + assertEquals(UnavailableReason.UNSUPPORTED_FIRMWARE, consumption.reason) + } + + @Test fun `a derivation over available inputs is derived, not measured`() { + val power = Provenanced.measured(18.0) + val speed = Provenanced.measured(90.0) + val consumption = Provenanced.derive(power, speed) { p, v -> p * 100.0 / v } + assertEquals(Provenance.DERIVED, consumption.provenance) + assertEquals(20.0, consumption.value!!, 1e-9) + assertNull(consumption.uncertainty) + } + + @Test fun `a transform that declines to produce a value yields unavailable`() { + val speed = Provenanced.measured(0.0) + val consumption = Provenanced.derive(speed) { v -> if (v < 5.0) null else 100.0 / v } + assertEquals(Provenance.UNAVAILABLE, consumption.provenance) + assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, consumption.reason) + } + + @Test(expected = IllegalArgumentException::class) + fun `deriving from an estimate is refused rather than silently dropping its band`() { + val range = Provenanced.estimated(280.0, 25.0) + Provenanced.derive(range) { it / 2.0 } + } + + @Test fun `availability is readable without unwrapping the value`() { + assertTrue(Provenanced.measured(1.0).isAvailable) + assertTrue(!Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED).isAvailable) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/RouteGradeTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/RouteGradeTest.kt new file mode 100644 index 0000000..ba002ce --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/RouteGradeTest.kt @@ -0,0 +1,64 @@ +package com.evsuite.hardware.telemetry + +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 RouteGradeTest { + + private val pack = BatteryCapacityConfig(usableCapacityKwhWhenNew = 61.7, stateOfHealthPercent = 100.0) + + @Test + fun `a kilometre of climb costs what the physics says, near enough to check by hand`() { + // 1750 kg over 1000 m is 17,2 MJ = 4,77 kWh at the wheels, 5,61 kWh from the pack at + // 85 %, which is 9,1 % of 61,7 kWh. + val cost = RouteGrade.of(1000.0, 0.0, pack) + assertNotNull(cost) + assertEquals(9.1, cost!!.percent, 0.2) + assertTrue("the band carries the constants", cost.uncertaintyPercent > cost.percent * 0.4) + } + + @Test + fun `a flat route costs nothing, and says so as a number rather than as silence`() { + val cost = RouteGrade.of(0.0, 0.0, pack) + assertEquals(0.0, cost!!.percent, 1e-9) + } + + @Test + fun `a descent returns charge, but never as much as the same climb spent`() { + val down = RouteGrade.of(0.0, 1000.0, pack)!! + val up = RouteGrade.of(1000.0, 0.0, pack)!! + assertTrue("a descent returns charge", down.percent < 0.0) + assertTrue("regeneration is not free", -down.percent < up.percent) + } + + @Test + fun `a round trip over a col is not a free lunch`() { + val roundTrip = RouteGrade.of(1000.0, 1000.0, pack)!! + val climbOnly = RouteGrade.of(1000.0, 0.0, pack)!! + assertTrue("a col costs something both ways", roundTrip.percent > 0.0) + assertTrue("but less than climbing twice", roundTrip.percent < climbOnly.percent) + // Two thirds of a kWh per hundred metres of col, there and back. If this ever comes out + // at zero the efficiencies have been made equal and the arithmetic has stopped being + // physics. + assertEquals(4.4, roundTrip.percent, 0.3) + } + + @Test + fun `no profile is null, and null is not a flat road`() { + assertNull(RouteGrade.of(null, 0.0, pack)) + assertNull(RouteGrade.of(0.0, null, pack)) + assertNull(RouteGrade.of(100.0, 100.0, null)) + assertNull(RouteGrade.of(Double.NaN, 0.0, pack)) + assertNull(RouteGrade.of(-10.0, 0.0, pack)) + } + + @Test + fun `a smaller pack means the same col costs a larger share of it`() { + val small = RouteGrade.of(1000.0, 0.0, BatteryCapacityConfig(40.0, 100.0))!! + val large = RouteGrade.of(1000.0, 0.0, pack)!! + assertTrue(small.percent > large.percent) + } +} diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/StateOfHealthTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/StateOfHealthTest.kt new file mode 100644 index 0000000..97a6319 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/StateOfHealthTest.kt @@ -0,0 +1,226 @@ +package com.evsuite.hardware.telemetry + +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 StateOfHealthTest { + + @Test fun `six discharges on the car's own counters recover the capacity`() { + val result = StateOfHealthEstimator() + .estimate(counterLedger(windows = 6, capacityKwh = 60.0), CAPACITY_WHEN_NEW) + + val ready = result as StateOfHealthResult.Ready + assertEquals(60.0, ready.estimate.usableCapacityKwh.value!!, 0.2) + assertEquals( + 60.0 * 100.0 / CAPACITY_WHEN_NEW, + ready.estimate.stateOfHealthPercent.value!!, + 0.5, + ) + assertEquals(SohEnergySource.VEHICLE_COUNTERS, ready.estimate.source) + assertEquals(6, ready.estimate.windowCount) + assertEquals(Provenance.ESTIMATED, ready.estimate.stateOfHealthPercent.provenance) + assertNotNull(ready.estimate.stateOfHealthPercent.uncertainty) + } + + @Test fun `five discharges refuse rather than estimate`() { + val result = StateOfHealthEstimator() + .estimate(counterLedger(windows = 5, capacityKwh = 60.0), CAPACITY_WHEN_NEW) + + val unavailable = result as StateOfHealthResult.Unavailable + assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, unavailable.reason) + assertEquals(5, unavailable.windowsSeen) + } + + @Test fun `the band is never tighter than the counter's own whole kilowatt-hour`() { + val ready = StateOfHealthEstimator() + .estimate(counterLedger(windows = 8, capacityKwh = 60.0), CAPACITY_WHEN_NEW) + as StateOfHealthResult.Ready + + // Identical windows have no spread at all, so a floor is all that is left — and for the + // car's own counter the coarser of the two is its whole-kWh step, not the charge gauge. + val expected = StateOfHealthEstimator.COUNTER_STEP_KWH * 100.0 / 60.0 + assertEquals(expected, ready.estimate.usableCapacityKwh.uncertainty!!, 1e-6) + assertTrue(expected > 60.0 * StateOfHealthEstimator.SOC_STEP_PERCENT / 60.0) + } + + @Test fun `an integral has no step of its own, so the gauge is its floor`() { + val ready = StateOfHealthEstimator() + .estimate(packPairLedger(windows = 8, capacityKwh = 58.0), CAPACITY_WHEN_NEW) + as StateOfHealthResult.Ready + + val expected = 58.0 * StateOfHealthEstimator.SOC_STEP_PERCENT / 60.0 + assertEquals(expected, ready.estimate.usableCapacityKwh.uncertainty!!, 1e-3) + } + + @Test fun `a window under the minimum drop contributes nothing`() { + val entries = listOf( + entry(day(0), 60f, consumed = 0f), + entry(day(1), 40f, consumed = 12.0f), + ) + assertTrue(StateOfHealthEstimator().windows(entries).isEmpty()) + } + + @Test fun `a charge splits the window it falls in`() { + val entries = listOf( + entry(day(0), 95f, consumed = 0f), + entry(day(1), 35f, consumed = 36f), + entry(day(1) + HOUR, 90f, consumed = 0f), + entry(day(2), 30f, consumed = 36f), + ) + val windows = StateOfHealthEstimator().windows(entries) + assertEquals(2, windows.size) + assertTrue(windows.all { it.socDropPercent == 60.0 }) + } + + @Test fun `a counter that went backwards is a charge the gauge denied, and is refused`() { + val entries = listOf( + entry(day(0), 95f, consumed = 30f), + entry(day(1), 35f, consumed = 4f), + ) + assertTrue(StateOfHealthEstimator().windows(entries).isEmpty()) + } + + @Test fun `the car's counters outrank this app's integral even when the integral has more windows`() { + val entries = ArrayList() + entries += packPairLedger(windows = 9, capacityKwh = 52.0) + entries += counterLedger(windows = 6, capacityKwh = 60.0, startDay = 40) + + val ready = StateOfHealthEstimator().estimate(entries, CAPACITY_WHEN_NEW) + as StateOfHealthResult.Ready + assertEquals(SohEnergySource.VEHICLE_COUNTERS, ready.estimate.source) + assertEquals(60.0, ready.estimate.usableCapacityKwh.value!!, 0.2) + } + + @Test fun `the pack pair answers when the car keeps no counters`() { + val ready = StateOfHealthEstimator() + .estimate(packPairLedger(windows = 6, capacityKwh = 58.0), CAPACITY_WHEN_NEW) + as StateOfHealthResult.Ready + + assertEquals(SohEnergySource.PACK_PAIR_INTEGRAL, ready.estimate.source) + assertEquals(58.0, ready.estimate.usableCapacityKwh.value!!, 0.2) + } + + @Test fun `an integral that covered less ground than the odometer is refused`() { + val entries = listOf( + packEntry(day(0), 95f, packKwh = 0.0, odometerKm = 10_000f, integratedKm = 0.0), + // The car drove 300 km; the app was awake for 200 of them, so a third of the + // energy is missing and the capacity would come out a third too small. + packEntry(day(1), 35f, packKwh = 34.8, odometerKm = 10_300f, integratedKm = 200.0), + ) + assertTrue(StateOfHealthEstimator().windows(entries).isEmpty()) + } + + @Test fun `a cold window is excluded and counted`() { + val warm = counterLedger(windows = 6, capacityKwh = 60.0) + val cold = counterLedger(windows = 2, capacityKwh = 48.0, startDay = 40, tempCelsius = -4f) + + val ready = StateOfHealthEstimator().estimate(warm + cold, CAPACITY_WHEN_NEW) + as StateOfHealthResult.Ready + assertEquals(6, ready.estimate.windowCount) + assertEquals(2, ready.estimate.coldWindowsExcluded) + assertEquals(60.0, ready.estimate.usableCapacityKwh.value!!, 0.2) + } + + @Test fun `a trend needs a history long enough to have one`() { + val short = StateOfHealthEstimator() + .estimate(counterLedger(windows = 6, capacityKwh = 60.0), CAPACITY_WHEN_NEW) + as StateOfHealthResult.Ready + assertNull(short.estimate.trendPercentPoints) + + val fading = ArrayList() + fading += counterLedger(windows = 6, capacityKwh = 60.0, startDay = 0) + fading += counterLedger(windows = 6, capacityKwh = 57.0, startDay = 120) + val long = StateOfHealthEstimator().estimate(fading, CAPACITY_WHEN_NEW) + as StateOfHealthResult.Ready + // Three kilowatt-hours lost out of a 61,7 kWh pack is a little under five points. + assertEquals(-4.9, long.estimate.trendPercentPoints!!, 0.6) + } + + @Test fun `a capacity nobody declared is refused rather than divided by`() { + val result = StateOfHealthEstimator() + .estimate(counterLedger(windows = 6, capacityKwh = 60.0), 0.0) + assertTrue(result is StateOfHealthResult.Unavailable) + } + + /** One window per cycle: full to 35 %, then a charge back up, exactly as a week looks. */ + private fun counterLedger( + windows: Int, + capacityKwh: Double, + startDay: Int = 0, + tempCelsius: Float = 18f, + ): List { + val entries = ArrayList() + repeat(windows) { index -> + val start = day(startDay + index * 2) + val drop = 60.0 + entries += entry(start, 95f, consumed = 0f, temp = tempCelsius) + entries += entry( + start + DAY, + 35f, + consumed = (capacityKwh * drop / 100.0).toFloat(), + temp = tempCelsius, + ) + } + return entries + } + + private fun packPairLedger( + windows: Int, + capacityKwh: Double, + startDay: Int = 0, + ): List { + val entries = ArrayList() + var cumulativeKwh = 0.0 + var cumulativeKm = 0.0 + repeat(windows) { index -> + val start = day(startDay + index * 2) + entries += packEntry(start, 95f, cumulativeKwh, 10_000f + cumulativeKm.toFloat(), cumulativeKm) + cumulativeKwh += capacityKwh * 0.60 + cumulativeKm += 300.0 + entries += packEntry( + start + DAY, 35f, cumulativeKwh, 10_000f + cumulativeKm.toFloat(), cumulativeKm, + ) + } + return entries + } + + private fun entry( + atMs: Long, + soc: Float, + consumed: Float, + regenerated: Float = 0f, + temp: Float = 18f, + ) = BatteryLedgerEntry( + atMs = atMs, + socPercent = soc, + outsideTempCelsius = temp, + vehicleConsumedKwh = consumed, + vehicleRegeneratedKwh = regenerated, + ) + + private fun packEntry( + atMs: Long, + soc: Float, + packKwh: Double, + odometerKm: Float, + integratedKm: Double, + ) = BatteryLedgerEntry( + atMs = atMs, + socPercent = soc, + odometerKm = odometerKm, + outsideTempCelsius = 18f, + packEnergyKwh = packKwh, + integratedDistanceKm = integratedKm, + ) + + private fun day(index: Int): Long = index * DAY + + private companion object { + const val DAY = 86_400_000L + const val HOUR = 3_600_000L + const val CAPACITY_WHEN_NEW = 61.7 + } +} 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 new file mode 100644 index 0000000..8348961 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/TelemetryEvidenceTest.kt @@ -0,0 +1,187 @@ +package com.evsuite.hardware.telemetry + +import com.evsuite.hardware.FirmwareInfo +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 TelemetryEvidenceTest { + + @Test fun `a signal the vehicle never answers has nothing, not zero`() { + val recorder = TelemetryEvidenceRecorder() + repeat(10) { recorder.record(snapshot(at = it * 1_000L, power = null)) } + + val power = recorder.evidence().single { + it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW + } + assertEquals(0, power.samples) + assertEquals(10, power.nulls) + assertNull(power.min) + assertNull(power.max) + assertNull(power.mean) + assertTrue(!power.available) + } + + @Test fun `counts, range and mean come from the samples that existed`() { + val recorder = TelemetryEvidenceRecorder() + listOf(10f, null, 20f, 30f).forEachIndexed { index, value -> + recorder.record(snapshot(at = index * 1_000L, power = value)) + } + + val power = recorder.evidence().single { + it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW + } + assertEquals(3, power.samples) + assertEquals(1, power.nulls) + assertEquals(10.0, power.min!!, 1e-9) + assertEquals(30.0, power.max!!, 1e-9) + assertEquals(20.0, power.mean!!, 1e-9) + } + + @Test fun `the sign split is what proves a power convention`() { + val recorder = TelemetryEvidenceRecorder() + listOf(42f, -28f, 0f, 15f).forEachIndexed { index, value -> + recorder.record(snapshot(at = index * 1_000L, power = value)) + } + + val power = recorder.evidence().single { + it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW + } + assertEquals(2, power.positive) + assertEquals(1, power.negative) + assertEquals(1, power.zero) + } + + @Test fun `a flag carries no sign split`() { + val recorder = TelemetryEvidenceRecorder() + recorder.record(snapshot(at = 0L, power = null, parked = true)) + + val parked = recorder.evidence().single { it.signal == TelemetryEvidenceRecorder.PARKED } + assertEquals(SignalKind.BOOLEAN, parked.kind) + assertNull(parked.positive) + assertNull(parked.negative) + assertEquals(1, parked.samples) + } + + @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. + repeat(30) { tick -> + val value = (tick / 5) * 10f + recorder.record(snapshot(at = tick * 1_000L, power = value)) + } + + val power = recorder.evidence().single { + it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW + } + assertEquals(5, power.changes) + assertEquals(5_000L, power.updatePeriodMedianMs) + assertEquals(5_000L, power.updatePeriodMinMs) + assertEquals(5_000L, power.updatePeriodMaxMs) + } + + @Test fun `a constant signal has no update period rather than a zero one`() { + val recorder = TelemetryEvidenceRecorder() + repeat(10) { recorder.record(snapshot(at = it * 1_000L, power = 12f)) } + + val power = recorder.evidence().single { + it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW + } + assertEquals(0, power.changes) + assertNull(power.updatePeriodMedianMs) + } + + @Test fun `an irregular sampler still reports the periods it observed`() { + val recorder = TelemetryEvidenceRecorder() + listOf(0L to 1f, 1_000L to 2f, 9_000L to 3f, 11_000L to 4f).forEach { (at, value) -> + recorder.record(snapshot(at = at, power = value)) + } + + val power = recorder.evidence().single { + it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW + } + assertEquals(3, power.changes) + assertEquals(1_000L, power.updatePeriodMinMs) + assertEquals(8_000L, power.updatePeriodMaxMs) + assertEquals(2_000L, power.updatePeriodMedianMs) + } + + @Test fun `the capture round-trips through its schema`() { + val recorder = TelemetryEvidenceRecorder() + repeat(4) { recorder.record(snapshot(at = it * 1_000L, power = it.toFloat())) } + + val capture = recorder.capture() + val restored = TelemetryEvidenceFormat.fromJson(TelemetryEvidenceFormat.toJson(capture)) + assertNotNull(restored) + assertEquals(capture.snapshots, restored!!.snapshots) + assertEquals(capture.firmware, restored.firmware) + assertEquals(capture.signals.size, restored.signals.size) + val power = restored.signals.single { + it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW + } + assertEquals(4, power.samples) + assertEquals(3.0, power.max!!, 1e-9) + } + + @Test fun `an absent statistic stays absent through the schema, never a zero`() { + val recorder = TelemetryEvidenceRecorder() + recorder.record(snapshot(at = 0L, power = null)) + + val json = TelemetryEvidenceFormat.toJson(recorder.capture()) + val restored = TelemetryEvidenceFormat.fromJson(json)!! + val power = restored.signals.single { + it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW + } + assertNull(power.min) + assertNull(power.mean) + } + + @Test fun `a capture written by a different schema version is refused`() { + val json = TelemetryEvidenceFormat.toJson( + EvidenceCapture( + schemaVersion = 99, firmware = "SWI68", startedAtMs = 0L, endedAtMs = 1L, + snapshots = 1, signals = emptyList(), + ) + ) + assertNull(TelemetryEvidenceFormat.fromJson(json)) + } + + @Test fun `a probed candidate property is recorded beside the snapshot signals`() { + val recorder = TelemetryEvidenceRecorder() + val name = TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH + recorder.record(TelemetryEvidenceSample( + snapshot(at = 0L, power = 1f), + listOf(TelemetryEvidenceProbe(name, 61_700.0)), + )) + recorder.record(TelemetryEvidenceSample( + snapshot(at = 1_000L, power = 2f), + listOf(TelemetryEvidenceProbe(name, null)), + )) + + val candidate = recorder.evidence().single { it.signal == name } + assertEquals(1, candidate.samples) + assertEquals(1, candidate.nulls) + assertEquals(2, recorder.capture().snapshots) + } + + @Test fun `the markdown rendering names every signal and dashes what is missing`() { + val recorder = TelemetryEvidenceRecorder() + recorder.record(snapshot(at = 0L, power = null)) + + val markdown = TelemetryEvidenceFormat.toMarkdown(recorder.capture()) + assertTrue(markdown.contains("`${TelemetryEvidenceRecorder.BATTERY_POWER_KW}`")) + assertTrue(markdown.contains("| — | — | — |")) + } + + private fun snapshot(at: Long, power: Float?, parked: Boolean? = null) = EnergySnapshot( + timestampMs = at, firmware = FirmwareInfo.Gen.SWI68, socPercent = null, + rangeKm = null, speedKmh = null, batteryPowerKw = power, + outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null, + batteryEnergyKwh = null, batteryCapacityKwh = null, odometerKm = null, + chargePortConnected = null, chargingStatus = null, parked = parked, + climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null), + tirePressures = TirePressureSnapshot(null, null, null, null), + ) +} 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 new file mode 100644 index 0000000..a5afb88 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/TripDetectorTest.kt @@ -0,0 +1,183 @@ +package com.evsuite.hardware.telemetry + +import com.evsuite.hardware.FirmwareInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TripDetectorTest { + @Test fun `sustained motion starts once after the debounce window`() { + val detector = TripDetector() + + assertEquals(TripDetector.State.ARMED, detector.add(snapshot(0L, 10f)).state) + assertNull(detector.add(snapshot(4_999L, 10f)).event) + val started = detector.add(snapshot(5_000L, 10f)) + + assertEquals(TripDetector.State.RECORDING, started.state) + assertEquals(TripDetector.Event.START, started.event) + assertNull(detector.add(snapshot(6_000L, 10f)).event) + } + + @Test fun `a ninety second traffic stop does not split a trip`() { + val detector = recordingDetector() + + assertEquals(TripDetector.State.ENDING, detector.add(snapshot(6_000L, 0f, parked = false)).state) + val trafficStop = sustainStop(detector, 7_000L, 96_000L, parked = false) + assertNull(trafficStop.event) + val movingAgain = detector.add(snapshot(97_000L, 20f, parked = false)) + + assertEquals(TripDetector.State.RECORDING, movingAgain.state) + assertNull(movingAgain.event) + } + + @Test fun `a genuine confirmed end closes the trip`() { + val detector = recordingDetector() + detector.add(snapshot(6_000L, 0f, parked = false)) + + val stopped = sustainStop(detector, 7_000L, 126_000L, parked = true) + + assertEquals(TripDetector.State.IDLE, stopped.state) + assertEquals(TripDetector.Event.STOP, stopped.event) + } + + @Test fun `standstill alone closes when confirmation signals do not exist`() { + val detector = recordingDetector() + detector.add(snapshot(6_000L, 0f)) + + val stopped = sustainStop(detector, 7_000L, 126_000L) + + assertEquals(TripDetector.Event.STOP, stopped.event) + } + + @Test fun `available confirmation must become true before ending`() { + val detector = recordingDetector() + detector.add(snapshot(6_000L, 0f, parked = false, chargePortConnected = false)) + + val unconfirmed = sustainStop( + detector, + 7_000L, + 126_000L, + parked = false, + chargePortConnected = false, + ) + val charging = detector.add( + snapshot(127_000L, 0f, parked = false, chargePortConnected = true) + ) + + assertEquals(TripDetector.State.ENDING, unconfirmed.state) + assertNull(unconfirmed.event) + assertEquals(TripDetector.Event.STOP, charging.event) + } + + @Test fun `null speed mid trip discards stop evidence without ending`() { + val detector = recordingDetector() + detector.add(snapshot(6_000L, 0f, parked = true)) + detector.add(snapshot(100_000L, null, parked = true)) + + val afterOldDeadline = detector.add(snapshot(130_000L, 0f, parked = true)) + + assertEquals(TripDetector.State.ENDING, afterOldDeadline.state) + assertNull(afterOldDeadline.event) + } + + @Test fun `forward telemetry gap discards stop evidence without ending`() { + val detector = recordingDetector() + detector.add(snapshot(6_000L, 0f, parked = true)) + + val afterGap = detector.add(snapshot(126_000L, 0f, parked = true)) + + assertEquals(TripDetector.State.ENDING, afterGap.state) + assertNull(afterGap.event) + } + + @Test fun `forward telemetry gap discards start evidence`() { + val detector = TripDetector() + detector.add(snapshot(0L, 10f)) + + val afterGap = detector.add(snapshot(60_000L, 10f)) + + assertEquals(TripDetector.State.ARMED, afterGap.state) + assertNull(afterGap.event) + } + + @Test fun `bounce around start threshold never flaps into recording`() { + val detector = TripDetector() + + detector.add(snapshot(0L, 5.1f)) + assertEquals(TripDetector.State.IDLE, detector.add(snapshot(2_000L, 4.9f)).state) + detector.add(snapshot(3_000L, 5.1f)) + assertEquals(TripDetector.State.IDLE, detector.add(snapshot(7_999L, 4.9f)).state) + } + + @Test fun `missing speed firmware cannot auto start or auto stop`() { + val detector = TripDetector() + + repeat(10) { index -> + val result = detector.add(snapshot(index * 10_000L, null, parked = true)) + assertEquals(TripDetector.State.IDLE, result.state) + assertNull(result.event) + } + detector.markRecording() + val running = detector.add(snapshot(200_000L, null, parked = true)) + assertEquals(TripDetector.State.RECORDING, running.state) + assertNull(running.event) + } + + @Test fun `moving sample never ends even when parked is reported`() { + val detector = recordingDetector() + detector.add(snapshot(6_000L, 0f, parked = true)) + + val moving = detector.add(snapshot(200_000L, 2f, parked = true)) + + assertEquals(TripDetector.State.RECORDING, moving.state) + assertNull(moving.event) + } + + private fun recordingDetector() = TripDetector().also { detector -> + detector.add(snapshot(0L, 10f)) + assertEquals(TripDetector.Event.START, detector.add(snapshot(5_000L, 10f)).event) + } + + private fun sustainStop( + detector: TripDetector, + fromMs: Long, + throughMs: Long, + parked: Boolean? = null, + chargePortConnected: Boolean? = null, + ): TripDetector.Result { + var result = TripDetector.Result(detector.state) + var timestampMs = fromMs + while (timestampMs <= throughMs) { + result = detector.add( + snapshot(timestampMs, 0f, parked, chargePortConnected) + ) + timestampMs += 1_000L + } + return result + } + + private fun snapshot( + atMs: Long, + speedKmh: Float?, + parked: Boolean? = null, + chargePortConnected: Boolean? = null, + ) = EnergySnapshot( + timestampMs = atMs, + firmware = FirmwareInfo.Gen.SWI68, + socPercent = 80f, + rangeKm = null, + speedKmh = speedKmh, + batteryPowerKw = 10f, + outsideTempCelsius = null, + cabinTempCelsius = null, + batteryTempCelsius = null, + batteryEnergyKwh = null, + batteryCapacityKwh = null, + odometerKm = null, + chargePortConnected = chargePortConnected, + chargingStatus = null, + parked = parked, + climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null), + tirePressures = TirePressureSnapshot(null, null, null, null), + ) +} 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 new file mode 100644 index 0000000..d429724 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/TripHistoryStoreTest.kt @@ -0,0 +1,240 @@ +package com.evsuite.hardware.telemetry + +import com.evsuite.hardware.BatteryPowerEvidence +import com.evsuite.hardware.FirmwareInfo +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class TripHistoryStoreTest { + + @Test fun `a v1 file opens without losing a trip`() { + val directory = tempDirectory() + val target = File(directory, "trips.json") + // Exactly what the previous build wrote: a bare array of summaries, no envelope. + target.writeText(Gson().toJson(listOf(summary(2L), summary(1L)))) + + val store = EnergyTripHistoryStore(target) + assertEquals(listOf(2L, 1L), store.readSummaries().map { it.startedAtMs }) + assertTrue(store.read().all { it.samples == null }) + assertTrue(store.readSummaries().all { it.recordedDistanceKm == 1.0 }) + } + + @Test fun `distance availability expands v2 without breaking older records`() { + val directory = tempDirectory() + val target = File(directory, "trips.json") + val oldShape = Gson().toJson(summary(1L)) + target.writeText("{\"schemaVersion\":2,\"trips\":[{\"summary\":$oldShape}]}") + + val store = EnergyTripHistoryStore(target) + assertEquals(1.0, store.readSummaries().single().recordedDistanceKm!!, 0.0) + + assertTrue(store.append(summary(2L).copy(distanceAvailable = false))) + assertNull(store.readSummaries().first().recordedDistanceKm) + assertTrue(target.readText().contains("\"distanceAvailable\":false")) + } + + @Test fun `a v1 file is rewritten as v2 on the next append`() { + val directory = tempDirectory() + val target = File(directory, "trips.json") + target.writeText(Gson().toJson(listOf(summary(1L)))) + + val store = EnergyTripHistoryStore(target) + assertTrue(store.append(summary(2L), listOf(sample(0L)))) + assertTrue(target.readText().contains("\"schemaVersion\":2")) + assertEquals(listOf(2L, 1L), store.readSummaries().map { it.startedAtMs }) + } + + @Test fun `a truncated file is quarantined, not deleted, and does not throw`() { + val directory = tempDirectory() + val target = File(directory, "trips.json") + target.writeText("{\"schemaVersion\":2,\"trips\":[{\"summ") + + val store = EnergyTripHistoryStore(target) + assertEquals(emptyList(), store.read()) + val quarantined = directory.listFiles()?.filter { it.name.contains("quarantine") } + assertEquals(1, quarantined?.size) + assertTrue(quarantined!!.single().readText().startsWith("{")) + } + + @Test fun `a file from a newer schema is quarantined rather than misread`() { + val directory = tempDirectory() + val target = File(directory, "trips.json") + target.writeText("{\"schemaVersion\":99,\"trips\":[]}") + + val store = EnergyTripHistoryStore(target) + assertEquals(emptyList(), store.read()) + assertEquals(1, directory.listFiles()?.count { it.name.contains("quarantine") }) + } + + @Test fun `a null that was never measured comes back null, not zero`() { + val directory = tempDirectory() + val store = EnergyTripHistoryStore(File(directory, "trips.json")) + val bare = TripSample( + atMs = 10L, speedKmh = 42f, batteryPowerKw = null, socPercent = null, + outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null, + climatePowerOn = null, climateAcOn = null, climateFanLevel = null, + ) + assertTrue(store.append(summary(1L), listOf(bare))) + + val restored = store.read().single().samples!!.single() + assertEquals(42f, restored.speedKmh) + assertNull(restored.batteryPowerKw) + assertNull(restored.batteryTempCelsius) + assertNull(restored.climateAcOn) + assertNull(restored.climateFanLevel) + } + + @Test fun `battery power evidence survives a history round trip`() { + val directory = tempDirectory() + val store = EnergyTripHistoryStore(File(directory, "trips.json")) + val evidence = BatteryPowerEvidence( + firmware = FirmwareInfo.Gen.SWI68, + conversionVersion = BatteryPowerEvidence.OUTPUT_POSITIVE_MW_V1, + ) + + assertTrue(store.append(summary(1L).copy(batteryPowerEvidence = evidence))) + + assertEquals(evidence, store.readSummaries().single().batteryPowerEvidence) + } + + @Test fun `tracks are evicted before trips are`() { + val directory = tempDirectory() + // Small enough that two full tracks cannot both fit, large enough for one and for + // every summary. One sample serialises to roughly 200 bytes. + val store = EnergyTripHistoryStore(File(directory, "trips.json"), maxBytes = 3_000) + val track = (0 until 10).map { sample(it * 5_000L) } + + assertTrue(store.append(summary(1L), track)) + assertTrue(store.append(summary(2L), track)) + assertTrue(store.append(summary(3L), track)) + + val stored = store.read() + assertEquals(listOf(3L, 2L, 1L), stored.map { it.summary.startedAtMs }) + // The newest trip keeps its track; the oldest lost theirs first. + assertNotNull(stored.first().samples) + assertNull(stored.last().samples) + } + + @Test fun `deleting one trip preserves the other summaries and tracks`() { + val directory = tempDirectory() + val store = EnergyTripHistoryStore(File(directory, "trips.json")) + assertTrue(store.append(summary(1L), listOf(sample(1L)))) + assertTrue(store.append(summary(2L), listOf(sample(2L)))) + assertTrue(store.append(summary(3L), listOf(sample(3L)))) + + assertTrue(store.deleteTrip(2L)) + + val remaining = store.read() + assertEquals(listOf(3L, 1L), remaining.map { it.summary.startedAtMs }) + assertEquals(listOf(3L, 1L), remaining.map { it.samples!!.single().atMs }) + } + + @Test fun `deleting an unknown trip leaves the file unchanged`() { + val directory = tempDirectory() + val target = File(directory, "trips.json") + val store = EnergyTripHistoryStore(target) + assertTrue(store.append(summary(1L))) + val before = target.readText() + + assertTrue(!store.deleteTrip(99L)) + assertEquals(before, target.readText()) + } + + @Test fun `clearing writes a valid empty v2 history`() { + val directory = tempDirectory() + val target = File(directory, "trips.json") + val store = EnergyTripHistoryStore(target) + assertTrue(store.append(summary(1L), listOf(sample(1L)))) + + assertTrue(store.clear()) + + assertEquals(emptyList(), store.read()) + assertTrue(target.readText().contains("\"schemaVersion\":2")) + } + + @Test fun `the file stays under its byte bound across a month of trips`() { + val directory = tempDirectory() + val target = File(directory, "trips.json") + val store = EnergyTripHistoryStore(target, maxBytes = 64 * 1024) + val track = (0 until 500).map { sample(it * 5_000L) } + + repeat(30) { day -> assertTrue(store.append(summary(day.toLong()), track)) } + + assertTrue("history grew to ${target.length()} bytes", target.length() <= 64 * 1024) + assertEquals(30, store.read().size) + } + + @Test fun `the default history retains two hundred summaries newest first`() { + val directory = tempDirectory() + val store = EnergyTripHistoryStore(File(directory, "trips.json")) + + repeat(201) { trip -> assertTrue(store.append(summary(trip.toLong()))) } + + val starts = store.readSummaries().map { it.startedAtMs } + assertEquals(200, starts.size) + assertEquals(200L, starts.first()) + assertEquals(1L, starts.last()) + } + + @Test fun `a four hour trip is decimated rather than truncated`() { + val track = TripSampleTrack(startIntervalMs = 5_000L, maxSamples = 64) + // Four hours at 1 Hz: 14 400 reads, of which the track would keep 2 880 at 5 s. + repeat(14_400) { second -> track.add(snapshot(at = second * 1_000L)) } + + val samples = track.samples() + assertTrue("kept ${samples.size}", samples.size <= 64) + // The last hour is still represented: decimation halves everywhere, it does not cut a tail. + assertTrue(samples.last().atMs > 3 * 3_600_000L) + assertEquals(0L, samples.first().atMs) + } + + @Test fun `the track keeps one sample per interval, not one per read`() { + val track = TripSampleTrack(startIntervalMs = 5_000L, maxSamples = 4_096) + repeat(60) { second -> track.add(snapshot(at = second * 1_000L)) } + + // 0, 5, 10 … 55 seconds: twelve samples for sixty reads. + assertEquals(12, track.samples().size) + } + + @Test fun `a recorded trip carries the track it was integrated from`() { + EnergyTripSession.stop(0L) + EnergyTripSession.start(snapshot(at = 0L)) + repeat(20) { second -> EnergyTripSession.add(snapshot(at = second * 1_000L)) } + + val recorded = EnergyTripSession.stop(20_000L) + assertNotNull(recorded) + assertTrue(recorded!!.samples.isNotEmpty()) + assertEquals(0L, recorded.summary.startedAtMs) + } + + private fun tempDirectory(): File = + Files.createTempDirectory("trip-history").toFile() + + private fun summary(start: Long) = EnergyTripSummary( + startedAtMs = start, endedAtMs = start + 1_000L, durationMs = 1_000L, + distanceKm = 1.0, startSocPercent = 80f, endSocPercent = 79f, + consumedKwh = 0.2, regeneratedKwh = 0.05, + ) + + private fun sample(at: Long) = TripSample( + atMs = at, speedKmh = 90f, batteryPowerKw = 18f, socPercent = 70f, + outsideTempCelsius = 12f, cabinTempCelsius = 21f, batteryTempCelsius = 24f, + climatePowerOn = true, climateAcOn = false, climateFanLevel = 3, + ) + + private fun snapshot(at: Long) = EnergySnapshot( + timestampMs = at, firmware = FirmwareInfo.Gen.SWI68, socPercent = 70f, + rangeKm = null, speedKmh = 90f, batteryPowerKw = 18f, + outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null, + batteryEnergyKwh = null, batteryCapacityKwh = null, odometerKm = null, + chargePortConnected = null, chargingStatus = null, parked = null, + climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null), + tirePressures = TirePressureSnapshot(null, null, null, null), + ) +} 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 new file mode 100644 index 0000000..b1c773d --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/model/EnergyAttributionTest.kt @@ -0,0 +1,106 @@ +package com.evsuite.hardware.telemetry.model + +import com.evsuite.hardware.BatteryPowerEvidence +import com.evsuite.hardware.FirmwareInfo +import com.evsuite.hardware.telemetry.EnergyTripSummary +import com.evsuite.hardware.telemetry.StoredTrip +import com.evsuite.hardware.telemetry.TripSample +import com.evsuite.hardware.telemetry.UnavailableReason +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EnergyAttributionTest { + private val evidence = BatteryPowerEvidence( + FirmwareInfo.Gen.SWI68, + BatteryPowerEvidence.OUTPUT_POSITIVE_MW_V1, + ) + private val model = EnergyModel( + evidence = evidence, + rollingKwhPer100Km = 10.0, + aeroKwhPer100KmPerSpeedSquared = 0.0, + thermalKwhPer100KmPerDegree = 0.0, + residualRmseKwhPer100Km = 0.1, + sampleCount = 120, + envelope = EnergyModelEnvelope(90.0, 110.0, 5.0, 15.0), + ) + + @Test fun `known traction and active auxiliary load reconcile inside the band`() { + val result = ready(calculate(powerKw = 12f, climateOn = true, consumedKwh = 12.0)) + val active = result.residuals.single() + + assertEquals(10.0, result.modelledTraction.valueKwh, 1e-6) + assertEquals(2.0, active.estimate.valueKwh, 1e-6) + assertEquals(ResidualContext.CLIMATE_ACTIVE, active.context) + assertEquals(ResidualFinding.DISTINGUISHABLE, active.finding) + assertTrue(2.0 in active.estimate.bandLowKwh..active.estimate.bandHighKwh) + assertEquals(11.5, result.netBatteryEnergyKwh.value!!, 1e-9) + assertEquals(0.0, result.reconciliationErrorKwh, 1e-9) + } + + @Test fun `residual inside model noise is not distinguishable from zero`() { + val result = ready(calculate(powerKw = 10.2f, climateOn = true, consumedKwh = 10.2)) + + assertEquals( + ResidualFinding.NOT_DISTINGUISHABLE_FROM_ZERO, + result.residuals.single().finding, + ) + } + + @Test fun `negative residual is surfaced as model error and never clamped`() { + val result = ready(calculate(powerKw = 9f, climateOn = false, consumedKwh = 9.0)) + val inactive = result.residuals.single() + + assertEquals(ResidualContext.CLIMATE_INACTIVE, inactive.context) + assertEquals(ResidualFinding.NEGATIVE_MODEL_ERROR, inactive.finding) + assertEquals(-1.0, inactive.estimate.valueKwh, 1e-6) + assertEquals(0.0, result.reconciliationErrorKwh, 1e-9) + } + + @Test fun `missing model evidence stays unavailable`() { + val result = EnergyAttributionCalculator.calculate(trip(12f, true, 12.0), null) + + assertEquals( + UnavailableReason.MODEL_NOT_TRAINED, + (result as EnergyAttributionResult.Unavailable).reason, + ) + } + + private fun calculate(powerKw: Float, climateOn: Boolean, consumedKwh: Double) = + EnergyAttributionCalculator.calculate(trip(powerKw, climateOn, consumedKwh), model) + + private fun trip(powerKw: Float, climateOn: Boolean, consumedKwh: Double): StoredTrip { + val samples = (0..60).map { minute -> + TripSample( + atMs = minute * 60_000L, + speedKmh = 100f, + batteryPowerKw = powerKw, + socPercent = 80f, + outsideTempCelsius = 10f, + cabinTempCelsius = 20f, + batteryTempCelsius = null, + climatePowerOn = climateOn, + climateAcOn = climateOn, + climateFanLevel = if (climateOn) 4 else 0, + ) + } + return StoredTrip( + summary = EnergyTripSummary( + startedAtMs = 0L, + endedAtMs = 3_600_000L, + durationMs = 3_600_000L, + distanceKm = 100.0, + startSocPercent = 80f, + endSocPercent = 60f, + consumedKwh = consumedKwh, + regeneratedKwh = 0.5, + distanceAvailable = true, + batteryPowerEvidence = evidence, + ), + samples = samples, + ) + } + + private fun ready(result: EnergyAttributionResult): EnergyAttribution = + (result as EnergyAttributionResult.Ready).attribution +} 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 new file mode 100644 index 0000000..b4a069b --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/model/EnergyModelTest.kt @@ -0,0 +1,168 @@ +package com.evsuite.hardware.telemetry.model + +import com.evsuite.hardware.BatteryPowerEvidence +import com.evsuite.hardware.FirmwareInfo +import com.evsuite.hardware.telemetry.EnergyTripSummary +import com.evsuite.hardware.telemetry.Provenance +import com.evsuite.hardware.telemetry.StoredTrip +import com.evsuite.hardware.telemetry.TripSample +import com.evsuite.hardware.telemetry.UnavailableReason +import java.nio.file.Files +import kotlin.system.measureTimeMillis +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EnergyModelTest { + private val evidence = BatteryPowerEvidence( + FirmwareInfo.Gen.SWI68, + BatteryPowerEvidence.OUTPUT_POSITIVE_MW_V1, + ) + private val trainer = EnergyModelTrainer() + + @Test fun `synthetic data recovers known coefficients`() { + val model = ready(trainer.fit(listOf(syntheticTrip()), evidence)) + + assertEquals(10.0, model.rollingKwhPer100Km, 1e-6) + assertEquals(0.0005, model.aeroKwhPer100KmPerSpeedSquared, 1e-9) + assertEquals(0.10, model.thermalKwhPer100KmPerDegree, 1e-6) + assertEquals(Provenance.ESTIMATED, model.predict(80.0, 10.0).provenance) + } + + @Test fun `sparse data and a firmware with no power interpretation stay unavailable`() { + val sparse = syntheticTrip().copy(samples = syntheticTrip().samples?.take(10)) + + assertEquals( + UnavailableReason.INSUFFICIENT_SAMPLES, + (trainer.fit(listOf(sparse), evidence) as EnergyModelTrainingResult.Unavailable).reason, + ) + // A generation whose battery power has neither a validated conversion nor a pack pair to + // derive one from: nothing to train on, and it says so rather than fitting a guess. + assertEquals( + UnavailableReason.UNVALIDATED_FIRMWARE, + (trainer.train(listOf(syntheticTrip()), FirmwareInfo.Gen.SWI69) + as EnergyModelTrainingResult.Unavailable).reason, + ) + } + + @Test fun `SWI68 trains on the pack pair rather than refusing`() { + // The car this project runs on has no validated power conversion and will not get one + // from a desk. Refusing to train left the speed comparison permanently empty; the model + // is stamped with the derived interpretation instead, so a validated one supersedes it. + val trip = storedTrip( + syntheticTrip().samples, + BatteryPowerEvidence(FirmwareInfo.Gen.SWI68, BatteryPowerEvidence.PACK_PAIR_DERIVED_V2), + ) + val model = ready(trainer.train(listOf(trip), FirmwareInfo.Gen.SWI68)) + + assertEquals(BatteryPowerEvidence.PACK_PAIR_DERIVED_V2, model.evidence.conversionVersion) + assertEquals(Provenance.ESTIMATED, model.predict(80.0, 10.0).provenance) + } + + @Test fun `prediction band covers held-out truth and extrapolation is refused`() { + val model = ready(trainer.fit(listOf(syntheticTrip()), evidence)) + val prediction = model.predict(85.0, 12.0) + val truth = consumption(85.0, 12.0) + + assertTrue(truth in prediction.bandLow!!..prediction.bandHigh!!) + val outside = model.predict(model.envelope.maxSpeedKmh + 1.0, 12.0) + assertEquals(Provenance.UNAVAILABLE, outside.provenance) + assertEquals(UnavailableReason.MODEL_NOT_TRAINED, outside.reason) + } + + @Test fun `model persistence round trips one bounded atomic file`() { + val directory = Files.createTempDirectory("energy-model").toFile() + try { + val target = directory.resolve("model.json") + val store = EnergyModelStore(target) + val model = ready(trainer.fit(listOf(syntheticTrip()), evidence)) + + assertTrue(store.write(model)) + assertEquals(model, store.read()) + assertTrue(target.length() in 1..EnergyModelStore.MAX_MODEL_BYTES.toLong()) + assertEquals(1, directory.listFiles().orEmpty().size) + + target.writeBytes(ByteArray(EnergyModelStore.MAX_MODEL_BYTES + 1)) + assertEquals(null, store.read()) + } finally { + directory.deleteRecursively() + } + } + + @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) + lateinit var result: EnergyModelTrainingResult + + val elapsedMs = measureTimeMillis { result = trainer.fit(listOf(trip), evidence) } + + assertNotNull((result as EnergyModelTrainingResult.Ready).model) + assertEquals( + EnergyModelTrainer.MAX_TRAINING_SAMPLES, + (result as EnergyModelTrainingResult.Ready).model.sampleCount, + ) + assertTrue("fit took ${elapsedMs}ms", elapsedMs < HOST_TRAINING_BUDGET_MS) + } + + private fun syntheticTrip(): StoredTrip { + 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)) + } + } + } + } + return storedTrip(samples) + } + + private fun storedTrip( + samples: List?, + evidence: BatteryPowerEvidence = this.evidence, + ) = StoredTrip( + summary = EnergyTripSummary( + startedAtMs = 1L, + endedAtMs = 2L, + durationMs = 1L, + distanceKm = 1.0, + startSocPercent = 80f, + endSocPercent = 79f, + consumedKwh = 1.0, + regeneratedKwh = 0.0, + distanceAvailable = true, + batteryPowerEvidence = evidence, + ), + samples = samples, + ) + + private fun sample(speedKmh: Double, tempCelsius: Double): TripSample { + val powerKw = consumption(speedKmh, tempCelsius) * speedKmh / 100.0 + return TripSample( + atMs = speedKmh.toLong(), + speedKmh = speedKmh.toFloat(), + batteryPowerKw = powerKw.toFloat(), + socPercent = 80f, + outsideTempCelsius = tempCelsius.toFloat(), + cabinTempCelsius = null, + batteryTempCelsius = null, + climatePowerOn = null, + climateAcOn = null, + climateFanLevel = null, + ) + } + + private fun consumption(speedKmh: Double, tempCelsius: Double): Double = + 10.0 + 0.0005 * speedKmh * speedKmh + + 0.10 * kotlin.math.abs(tempCelsius - EnergyModel.COMFORT_TEMP_CELSIUS) + + private fun ready(result: EnergyModelTrainingResult): EnergyModel = + (result as EnergyModelTrainingResult.Ready).model + + companion object { + private const val HOST_TRAINING_BUDGET_MS = 2_000L + } +} 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 new file mode 100644 index 0000000..9f2d2f9 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/telemetry/model/SocConsumptionModelTest.kt @@ -0,0 +1,224 @@ +package com.evsuite.hardware.telemetry.model + +import com.evsuite.hardware.FirmwareInfo +import com.evsuite.hardware.VehicleSpeedEvidence +import com.evsuite.hardware.telemetry.EnergyTripSummary +import com.evsuite.hardware.telemetry.StoredTrip +import com.evsuite.hardware.telemetry.TripSample +import com.evsuite.hardware.telemetry.UnavailableReason +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 SocConsumptionModelTest { + + private val generation = FirmwareInfo.Gen.SWI68 + private val current = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT) + private val stale = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.MPS_TIMES_3_6_V1) + + /** + * A drive at one speed and one temperature, consuming [percentPer100Km] of charge. + * + * Sampled every 5 s like the real track, and long enough to cross the 2 % step several + * times, which is what produces more than one training point. + */ + private fun drive( + speedKmh: Double, + tempCelsius: Double, + percentPer100Km: Double, + distanceKm: Double = 60.0, + startSoc: Double = 90.0, + startAtMs: Long = 0L, + intervalMs: Long = 5_000L, + ): List { + val hours = distanceKm / speedKmh + val steps = (hours * 3_600_000.0 / intervalMs).toInt() + return (0..steps).map { index -> + val travelled = distanceKm * index / steps + TripSample( + atMs = startAtMs + index * intervalMs, + speedKmh = speedKmh.toFloat(), + batteryPowerKw = null, + socPercent = (startSoc - travelled * percentPer100Km / 100.0).toFloat(), + outsideTempCelsius = tempCelsius.toFloat(), + cabinTempCelsius = null, + batteryTempCelsius = null, + climatePowerOn = null, + climateAcOn = null, + climateFanLevel = null, + ) + } + } + + private fun trip( + samples: List, + evidence: VehicleSpeedEvidence? = current, + ) = StoredTrip( + summary = EnergyTripSummary( + startedAtMs = samples.first().atMs, + endedAtMs = samples.last().atMs, + durationMs = samples.last().atMs - samples.first().atMs, + distanceKm = 60.0, + startSocPercent = samples.first().socPercent, + endSocPercent = samples.last().socPercent, + consumedKwh = null, + regeneratedKwh = null, + distanceAvailable = true, + batteryPowerEvidence = null, + speedEvidence = evidence, + ), + samples = samples, + ) + + /** Four drives, two speeds and two temperatures — the smallest history that determines a fit. */ + private fun history() = listOf( + trip(drive(90.0, 20.0, 16.0, startAtMs = 0L)), + trip(drive(130.0, 20.0, 26.0, startAtMs = 100_000_000L)), + trip(drive(90.0, 0.0, 21.0, startAtMs = 200_000_000L)), + trip(drive(130.0, 0.0, 31.0, startAtMs = 300_000_000L)), + ) + + @Test + fun `a car that never publishes battery power still gets a consumption model`() { + val result = SocConsumptionFitter().fit(history(), generation) + assertTrue("$result", result is SocConsumptionFitResult.Ready) + val model = (result as SocConsumptionFitResult.Ready).model + + val at90 = model.predict(90.0, 20.0) + val at130 = model.predict(130.0, 20.0) + assertNotNull(at90.value) + assertNotNull(at130.value) + // The whole point of the fit: going faster costs more, and by roughly what was driven. + assertTrue("130 costs more than 90", at130.value!! > at90.value!!) + assertEquals(16.0, at90.value!!, 1.5) + assertEquals(26.0, at130.value!!, 1.5) + assertNotNull("every prediction carries a band", at130.uncertainty) + } + + @Test + fun `no capacity is asked for anywhere, because none is used`() { + // The unit is percent of charge. If this ever needs a pack size to answer, the + // specification sheet has crept back into the model. + val model = (SocConsumptionFitter().fit(history(), generation) + as SocConsumptionFitResult.Ready).model + assertEquals(current, model.speedEvidence) + assertTrue(model.predict(110.0, 20.0).value!! > 0.0) + } + + @Test + fun `a distance recorded by a conversion no longer believed contributes nothing`() { + val result = SocConsumptionFitter().fit( + history().map { trip(it.samples!!, evidence = stale) }, + generation, + ) + assertEquals( + SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES), + result, + ) + } + + @Test + fun `one speed is not a speed dependence`() { + val result = SocConsumptionFitter().fit( + listOf( + trip(drive(90.0, 20.0, 16.0, startAtMs = 0L)), + trip(drive(90.0, 0.0, 21.0, startAtMs = 100_000_000L)), + ), + generation, + ) + assertEquals( + SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES), + result, + ) + } + + @Test + fun `one temperature fits two coefficients and refuses the cold it never saw`() { + val result = SocConsumptionFitter().fit( + listOf( + trip(drive(90.0, 20.0, 16.0, distanceKm = 120.0, startAtMs = 0L)), + trip(drive(130.0, 20.0, 26.0, distanceKm = 120.0, startAtMs = 100_000_000L)), + ), + generation, + ) + val model = (result as SocConsumptionFitResult.Ready).model + assertEquals(0.0, model.thermalPercentPer100KmPerDegree, 1e-12) + assertNotNull(model.predict(110.0, 20.0).value) + // -5 °C is outside everything this fit has seen, and it says so rather than guessing. + 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 + // recorded. Bridged, the charge they cost would land on the kilometres either side. + val first = drive(110.0, 20.0, 20.0, distanceKm = 30.0, startAtMs = 0L) + val second = drive( + 110.0, 20.0, 20.0, + distanceKm = 30.0, + startSoc = 80.0, + startAtMs = first.last().atMs + 600_000L, + ) + val fitter = SocConsumptionFitter() + val segments = ArrayList() + fitter.collect(first + second, segments) + assertTrue("segments exist on both sides", segments.size >= 2) + // 20 %/100 km on both sides; a bridged segment would report a far larger number. + assertTrue(segments.all { it.percentPer100Km < 25.0 }) + } + + @Test + fun `charging is not driving`() { + val charging = (0..40).map { index -> + TripSample( + atMs = index * 5_000L, + speedKmh = 0f, + batteryPowerKw = null, + socPercent = (40.0 + index).toFloat(), + outsideTempCelsius = 20f, + cabinTempCelsius = null, + batteryTempCelsius = null, + climatePowerOn = null, + climateAcOn = null, + climateFanLevel = null, + ) + } + val segments = ArrayList() + SocConsumptionFitter().collect(charging, segments) + assertTrue(segments.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 + // things. Two validation bundles reported that word without saying which. + val fitter = SocConsumptionFitter() + + // Segments enough, one speed band. The answer is "drive a motorway leg". + val oneSpeed = listOf( + trip(drive(90.0, 20.0, 16.0, startAtMs = 0L)), + trip(drive(90.0, 0.0, 21.0, startAtMs = 100_000_000L)), + ) + assertEquals( + SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES), + fitter.fit(oneSpeed, generation), + ) + val narrow = fitter.describe(oneSpeed, generation) + assertTrue(narrow, narrow.contains("used=2/2")) + assertTrue(narrow, narrow.contains("span=0/20 km/h")) + + // Every trip skipped for a conversion no longer believed: no segments at all, and + // nothing about speed to say. The answer is "these kilometres do not count". + val staleHistory = oneSpeed.map { trip(it.samples!!, evidence = stale) } + assertEquals( + SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES), + fitter.fit(staleHistory, generation), + ) + val skipped = fitter.describe(staleHistory, generation) + assertTrue(skipped, skipped.contains("used=0/2")) + assertTrue(skipped, skipped.contains("segments=0/")) + assertTrue(skipped, !skipped.contains("span=")) + } +} diff --git a/EVHardware/mise.toml b/EVHardware/mise.toml new file mode 100644 index 0000000..d6321b5 --- /dev/null +++ b/EVHardware/mise.toml @@ -0,0 +1,24 @@ +# Dev environment for EVHardware. JDK 17 (AGP 8.5.2). The Android SDK is shared with the +# consuming apps (EVProfile installs it via its own `mise run bootstrap`). + +[tools] +java = "17" + +[env] +ANDROID_HOME = "{{env.HOME}}/Android/Sdk" +ANDROID_SDK_ROOT = "{{env.HOME}}/Android/Sdk" + +[tasks.test] +description = "JVM unit tests (also regenerates docs/firmware-matrix.md)" +run = './gradlew -Dorg.gradle.java.home=$JAVA_HOME :lib:testDebugUnitTest' + +[tasks.build] +description = "Assemble the debug AAR" +run = './gradlew -Dorg.gradle.java.home=$JAVA_HOME :lib:assembleDebug' + +[tasks.check] +description = "What CI runs" +depends = ["test"] + +[tasks.clean] +run = './gradlew -Dorg.gradle.java.home=$JAVA_HOME clean' diff --git a/EVHardware/settings.gradle.kts b/EVHardware/settings.gradle.kts new file mode 100644 index 0000000..f305125 --- /dev/null +++ b/EVHardware/settings.gradle.kts @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +// Standalone build (own CI). When consumed by an app, the app's settings.gradle includes +// EVHardware/lib directly as a subproject via the git submodule — this file is ignored +// in that case. +rootProject.name = "EVHardware" +include(":lib") diff --git a/FIRMWARE.md b/FIRMWARE.md new file mode 100644 index 0000000..cce5ddc --- /dev/null +++ b/FIRMWARE.md @@ -0,0 +1,46 @@ +# Supported firmware notes + +This document records project compatibility constraints and behaviour confirmed during +development. It must not be treated as a vendor specification. + +## Identity + +| | | +|---|---| +| Tested generation | `SWI68-29958-1300R69` | +| Android | 9 (Pie) — **API 28** | +| Chipset | MediaTek **MT2712** | +| Device | `mt2712_saic_eh32` (eh32 = MG4) | +| Emulator display profile | **1920×1080 @ 160 dpi** | + +## Signing caveat + +Certificate equality alone does not prove an APK came from this project. The nightly OTA +therefore also requires https and an exact-host GitHub allowlist. The same caveat applies +to EVProfile's OTA. + +## Vendor property IDs + +The vendor VHAL is `vendor/bin/hw/android.hardware.automotive.vehicle@2.0-service` +(ARM aarch64), backed by `vendor/lib64/android.hardware.automotive.YFvehicle@2.0.so` +("YF" supplier implementation). The property IDs (`0x2160F404` SOC, +`0x2140F41C` range, …) are **compiled into that binary**, not stored in a +readable config. The project therefore treats these IDs as implementation details with +firmware-specific compatibility limits. + +These IDs are therefore confirmed for **SWI68 (R69) only**. MG4 ships other +generations (SWI69/131/132/133/165) and the VHAL binary lists many SAIC +platforms (`eh32`, `as33`, `ec32`, `ip42`, …); the IDs may differ there. + +Only SOC and range still ride these vendor IDs. Speed, outside temperature and +park state are read through `EVHardware`, which branches per generation +internally and so covers all six firmwares — `CarPropertyAdapter` carries just +the SWI68 EV cluster plus the standard-AAOS charge / cabin-temp reads. EVHardware +has no EV-battery abstraction for any generation, which is why SOC/range cannot yet +follow the same path. See `AGENTS.md` for the full per-signal split. + +## Emulator fidelity + +The `mise` emulator tasks use an API 33 automotive AVD for the car service and an API 28 +AVD at **1920×1080 @ 160 dpi** for the screen. Neither exposes the SAIC vendor properties, +so vehicle-specific behaviour still requires on-vehicle validation. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ab7a2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Leon Kernan (original ABRP_Uploader) +Copyright (c) 2026 EVABRPUploader contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b97edd5 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +# Licence + +This project is released under the **MIT Licence** — see [`LICENSE`](LICENSE). + +## Attribution + +EVABRPUploader is a fork of Leon Kernan's `ABRP_Uploader`, and a meaningful part of +the original work survives in it. The copyright line names both him and this fork's +contributors, and MIT requires that notice to travel with any copy or substantial portion +of the software. Keep it. + +The upstream repository carried no licence file of its own. Applying MIT here is the +project owner's decision; if Leon Kernan ever objects, the right response is to honour +that rather than argue about it. + +## No warranty + +MIT disclaims all warranty and liability, and this software runs on a **vehicle** — see +[`DISCLAIMER.md`](DISCLAIMER.md) for what that means concretely. diff --git a/README.md b/README.md new file mode 100644 index 0000000..847deae --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +# EVABRPUploader + +

EVABRPUploader

+ +[![Tests](https://github.com/malys/EVABRPUploader/actions/workflows/tests.yml/badge.svg)](https://github.com/malys/EVABRPUploader/actions/workflows/tests.yml) +[![Security](https://github.com/malys/EVABRPUploader/actions/workflows/security.yml/badge.svg)](https://github.com/malys/EVABRPUploader/actions/workflows/security.yml) +[![Unstable](https://github.com/malys/EVABRPUploader/actions/workflows/unstable.yml/badge.svg)](https://github.com/malys/EVABRPUploader/actions/workflows/unstable.yml) +[![Release](https://img.shields.io/github/v/release/malys/EVABRPUploader?include_prereleases&sort=semver)](https://github.com/malys/EVABRPUploader/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +Sends live telemetry from an **MG4 (SAIC eh32)** to +[A Better Route Planner](https://abetterrouteplanner.com) — battery level, speed, range, +temperature, charging state and position — straight from the car's own APIs. + +No OBD dongle. No Home Assistant. No phone in the loop. The app runs on the car's head +unit and talks to ABRP directly. + +> ⚠️ **No warranty, no liability.** This software is provided "as is" and runs on a +> **vehicle**. Installing it is your decision and your risk — see +> [`DISCLAIMER.md`](DISCLAIMER.md). Not affiliated with SAIC, MG Motor or Iternio. +> MG and MG4 are third-party marks used only to identify compatibility; no official origin +> or approval is claimed. + +> **Fork notice.** This is a fork of Leon Kernan's `ABRP_Uploader`, substantially +> reworked. **Read [`LICENSE.md`](LICENSE.md) before redistributing anything** — the +> upstream project carries no licence, which limits what may legally be published. + +--- + +## Contents + +- [Screenshots](#screenshots) +- [Overview](#overview) +- [Install](#install) +- [Configuration](#configuration) +- [Building](#building) +- [Project layout](#project-layout) +- [Project documents](#project-documents) +- [Security](#security) +- [Contributing](#contributing) +- [Legal](#legal) +- [Credits](#credits) + +## Screenshots +

+ EVABRPUploader screenshot 1 + EVABRPUploader screenshot 2 +

+ +--- + +## Overview +| Signal | Source | Firmwares | +|---|---|---| +| State of charge | `EVHardware` vendor charging service, with a SWI68 property fallback | all supported (fallback SWI68 only) | +| Range | `EVHardware` vendor charging service, standard AAOS property, then SWI68 fallback | all supported | +| Speed, parked state | `EVHardware` shared energy snapshot | all supported | +| Outside temperature | The head unit's weather broadcast, falling back to its map-service query; the vehicle sensor first where one exists | all supported (the vehicle sensor is absent on MG4) | +| Charging, DC fast charging | Charge port state + charge rate heuristic | where the VHAL implements them | +| Charged energy this session | `EVHardware` charging-session integration | where the charge rate is readable | +| State of energy, pack capacity, pack temperature | `EVHardware` typed standard-AAOS reads | where the VHAL implements them | +| Odometer, tyre pressures | Standard AAOS properties (privileged) | platform-signed installs only | +| Cabin temperature | `EVHardware` typed HVAC read | where the VHAL implements it | +| Climate setpoint | `EVHardware` shared energy snapshot | all supported | +| Position, elevation, heading | GPS | — | + +All vehicle telemetry goes through **EVHardware**, which detects the generation +(SWI68/69/131/132/133/165), owns property ids and units, and applies the shared nullable +fallback order. `CarPropertyAdapter` remains only as transport for the debug-only VHAL probe +and is never instantiated by the production service. See [`FIRMWARE.md`](FIRMWARE.md). + +Odometer and tyre pressures sit behind privileged AAOS permissions: a platform-signed +install sends them, any other install simply omits them. + +A property the car will not give up is **omitted** from the payload, never sent as zero — +so ABRP is never told the battery is empty because a read failed. Position is held to the +same rule in time as well as in value: a GPS fix older than `max(5 min, 3 x your upload +interval)` is dropped rather than resent, so a stale fix cannot report the car back at the +place it set off from. + +**The app never writes to the car.** It only reads. Any write path would be a bug; see +[`SECURITY.md`](SECURITY.md). + +## Install +The MG4 head unit hides Settings and APK install. The known route in: +The MG4 head unit has no visible way to open Settings or install an APK. The known route +in (via the on-screen keyboard) is: + +1. Open any app with a text field and tap it to raise the on-screen keyboard — e.g. the + Amazon Music app's email/login field. +2. **Long-press** the comma `,` (or the `@`) key on the keyboard. +3. Tap **"Language settings"**. +4. Tap the **search** icon in the top bar and type **`backup`**. It opens an empty page — + now press the **back** arrow, and you land in Android's Settings panel. +5. Enable **Developer options**, and turn on **"Install unknown apps"** (unknown sources). +6. In Settings, search **`storage`** — you now have access to internal storage and the USB + key. Navigate to the APK and tap it to install. + +> ⚠️ You are enabling developer options and sideloading on a car. Do this **parked**, and +> only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md). + +Two channels. Pick one — they install side by side. + +| Channel | Auto-update | Use it if | +|---|---|---| +| **Stable** | No. Contains no updater at all. | You want the car to run what you put on it | +| **Unstable** | Yes, from GitHub pre-releases | You are testing and want fixes as they land | + +Grab the APK from [Releases](https://github.com/malys/EVABRPUploader/releases). Stable builds are the tagged ones; unstable builds +are marked pre-release. + +### Getting the APK onto the car + +The MG4 has no visible file manager. To reach it: + +1. Open **Bluetooth Settings** and select the car name. +2. Long-press the **comma** key until the keyboard settings appear. +3. **Android Keyboard Settings → Languages**. +4. Search for `file` in the search box at the top. +5. That gives you the Files app. + +From there, open your USB stick and tap the APK. + +### First run + +1. Open the app. +2. By default, the app prefills the open-source telemetry API key published by the + [SAIC Python MQTT Gateway](https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway#abrp-api-integration). + You can replace it with your own key. Get a token from your ABRP account. +3. Paste the token, press **Test** (read-only — it does not send telemetry), then **Save**. +4. Turn the switch on. The service restarts with the car from then on. + +Typing a long API key and token on the car's on-screen keyboard is painful. Instead you can +put them in a text file and tap **Import file** — see [Config file](#config-file) below. + +## Configuration +| Setting | Default | Notes | +|---|---|---| +| Upload frequency | 60 s | 15 / 30 / 60 / 120 / 300 s. Applies while driving | +| More often at low battery | On | Tightens to 15 s below the threshold | +| Low battery threshold | 20 % | Editable, 1–99 | +| Service on/off | Off | Also the "stop uploading" control | + +Parked and unplugged, the app drops to **one upload every 15 minutes** on its own, +whatever the setting. A state change — parked, plugged in, unplugged — always uploads +immediately. + +### Config file + +To avoid typing on the head unit, put your settings in a plain text file and tap **Import file** +on the **ABRP** tab. The app fills in whatever the file contains. Nothing is sent — press +**Test**, then **Save** as usual. + +The head unit has no file picker, so the file is not chosen by hand: copy it from a PC into + +``` +Android/data/com.evsuite.abrp/files/ +``` + +on the USB stick (or on the head unit's internal storage), then tap **Import file**. The app +scans that folder on every mounted volume and takes the first file that parses — the name does +not matter. If nothing is found it shows the exact path to copy to. On a phone or tablet, where +a document picker exists, the button falls back to letting you pick any file. + +The file is a `key = value` list, one per line. Blank lines and lines starting with `#` are +ignored, keys are case-insensitive, and any key you leave out keeps its current value: + +``` +# ABRP Uploader config +api_key = your-abrp-api-key +token = your-abrp-user-token + +# optional — the cadence controls, same values as the UI +interval_sec = 60 # snapped to 15 / 30 / 60 / 120 / 300 +boost_low_soc = true +low_soc_percent = 20 # clamped to 1–99 +``` + +Only `api_key` and `token` are usually needed. The file is read once on import and not kept — +your credentials live only in the app's encrypted store afterwards. + +### Power + +The app holds **no wake lock** and schedules **no alarms**, so it cannot wake a sleeping +head unit; it costs nothing while the car is off. While the car is on, both the upload tick +and the GPS subscription cost power, and the GPS interval follows your configured +frequency. + +At defaults that is roughly **59 uploads/hour driving** and **3/hour parked**. + +## Building +Requires JDK 17 and the Android SDK. With [mise](https://mise.jdev): + +```bash +mise install # JDK +mise run bootstrap # Android SDK + local.properties +mise run check # permission gate + lint + tests +mise run build # debug APK +``` + +Without mise: set `JAVA_HOME` to a JDK 17, write `sdk.dir` into `local.properties`, then +`./gradlew assembleStableDebug`. + +Release builds are signed from environment variables. These sign the **APK** and have +nothing to do with your ABRP API key, which you type into the app itself: + +| Variable | What it is | +|---|---| +| `SIGNING_KEYSTORE` | Path to the keystore file (CI decodes `SIGNING_KEYSTORE_BASE64` into one) | +| `SIGNING_STORE_PASSWORD` | Opens the keystore container | +| `SIGNING_KEY_ALIAS` | Which key inside it to sign with (default `platform`) | +| `SIGNING_KEY_PASSWORD` | Opens that particular key — a keystore protects each key separately | + +**Never commit a keystore or a password.** Put them in `mise.local.toml`, which is +gitignored, or in GitHub Actions secrets. + +## Project layout +``` +app/src/main upload service, ABRP payload and UI +EVHardware shared vehicle reads, energy snapshot and integration +app/src/stable no-op update hook — the stable channel cannot self-update +app/src/unstable audited updater policy; runtime trigger currently suspended +app/src/debug VHAL probe tools, absent from every release build +app/src/test JVM unit tests +``` + +## Project documents +| Document | What it covers | +|---|---| +| [DESIGN.md](DESIGN.md) | The EVSuite design system — colour, type, touch targets, icons | +| [AGENTS.md](AGENTS.md) | Context for AI agents working in this repository | +| [CONTRIBUTING.md](CONTRIBUTING.md) | How to build, test and submit a change | +| [SECURITY.md](SECURITY.md) | Threat model and vulnerability disclosure | +| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer — read before installing | +| [CHANGELOG.md](CHANGELOG.md) | Release history | +| [LICENSE.md](LICENSE.md) | Licence text | + +## Security +See [SECURITY.md](SECURITY.md) for the threat model and how to report a vulnerability +privately. + +## Contributing +See [`CONTRIBUTING.md`](CONTRIBUTING.md). Short version: this code runs on a moving +vehicle, so changes need tests and a clear account of what you verified on a car and what +you did not. + +## Legal +- [`DISCLAIMER.md`](DISCLAIMER.md) — no warranty, no liability, not affiliated with the + carmaker or with ABRP. +- [`LICENSE.md`](LICENSE.md) — unresolved licence status inherited from the fork source. + Read it before redistributing. +- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability privately. + +## Credits +- Original app: **Leon Kernan** — the car-API approach and the first working uploader. +- ABRP telemetry API: [Iternio](https://documenter.getpostman.com/view/7396339/SWTK5a8w). +- Sibling project: **EVProfile**, whose security and CI patterns this repo reuses. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e9e788c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# Security policy + +This app runs on a car. It reads vehicle data and holds credentials for a third-party +service, so security reports are welcome and taken seriously. + +## Reporting a vulnerability + +Please **do not** open a public issue for a vulnerability. Use GitHub's +[private vulnerability reporting](../../security/advisories/new) instead. + +Include what you were able to do, on which firmware generation, and whether the vehicle +was moving. A proof of concept helps; a working exploit is not required. + +## What is in scope + +- Anything that lets another app on the head unit read the stored ABRP credentials. +- Anything that gets attacker-controlled code installed through the unstable updater. +- Anything that makes the app write to the vehicle. **It should never write at all** — this + app is read-only towards the car — so any write path is a bug by definition. +- Telemetry that leaks credentials into logs, URLs or crash reports. + +## What is not in scope + +- Requiring physical access to an unlocked head unit with developer mode enabled. +- The stable channel not auto-updating. That is deliberate; see `LICENSE.md` and the + README's channel table. +- Vulnerabilities in the OEM firmware itself. Report those to SAIC. + +## Design decisions you should know about + +- Credentials live in `EncryptedSharedPreferences` and are sent in a POST body, never a + URL. `allowBackup` is off and both preference files are excluded from backup rules. +- The unstable updater accepts an APK only over https, only from an exact-match GitHub host + allowlist, and only when its signing certificate matches the running app's. All three + checks fail closed. The stable channel does not contain the updater at all. +- The VHAL probe helpers, which enumerate the whole vehicle abstraction layer, exist only + in debug builds. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..43b482d --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,145 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace 'com.evsuite.abrp' + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + // Signature de l'APK — rien à voir avec la clé d'API ABRP de l'utilisateur. + // Ces valeurs servent UNIQUEMENT au build : elles prouvent que l'APK vient bien de + // nous et n'entrent jamais dans l'application. La clé d'API et le token ABRP, eux, + // sont saisis dans l'app et stockés chiffrés sur l'appareil (voir SecurePrefs). + // + // Un keystore contient plusieurs clés : SIGNING_STORE_PASSWORD ouvre le conteneur, + // SIGNING_KEY_ALIAS choisit la clé, SIGNING_KEY_PASSWORD ouvre cette clé-là. + // Lues depuis l'environnement (CI) ou gradle.properties local — JAMAIS commitées. + def keystorePath = System.getenv("SIGNING_KEYSTORE") ?: project.findProperty("signing.keystore") + signingConfigs { + if (keystorePath != null && file(keystorePath).exists()) { + platform { + storeFile file(keystorePath) + storePassword System.getenv("SIGNING_STORE_PASSWORD") ?: project.findProperty("signing.store.password") + keyAlias System.getenv("SIGNING_KEY_ALIAS") ?: project.findProperty("signing.key.alias") ?: "platform" + keyPassword System.getenv("SIGNING_KEY_PASSWORD") ?: project.findProperty("signing.key.password") + } + } + } + + defaultConfig { + applicationId "com.evsuite.abrp" + minSdk 27 + targetSdk 36 + versionCode 19 + versionName "2.2.2" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + // null quand aucun keystore n'est configuré : le build reste possible sur une + // machine sans secrets (APK non signé), au lieu d'échouer. + def platformSigning = signingConfigs.findByName("platform") + + // Deux canaux de distribution : + // - stable : diffusé en release taguée, AUCUN auto-update. L'utilisateur installe + // lui-même ; rien dans l'app ne va chercher un APK. + // - unstable : pré-releases publiées à chaque push sur master, avec OTA pour que les testeurs + // soient à jour sans manipulation. Installable à côté du stable. + flavorDimensions += "channel" + productFlavors { + stable { + dimension "channel" + buildConfigField "boolean", "OTA_ENABLED", "false" + } + unstable { + dimension "channel" + applicationIdSuffix ".unstable" + // Android uses versionCode, not versionName, to order package updates. Give + // every rolling CI build a strictly increasing code so Package Installer does + // not treat a newer unstable APK as the same package version. + versionCode defaultConfig.versionCode * 100000 + + (project.findProperty('unstableBuild') ?: '0').toString().toInteger() + // Le numéro de build fait partie du COEUR numérique de la version + // ("1.0.42-unstable"), pas du suffixe : l'updater compare des segments + // numériques, donc deux builds unstable doivent différer avant le premier tiret. + // -PunstableBuild= est passé par la CI ; 0 en local. + versionName "${defaultConfig.versionName}.${project.findProperty('unstableBuild') ?: '0'}" + versionNameSuffix "-unstable" + buildConfigField "boolean", "OTA_ENABLED", "true" + } + } + + buildTypes { + debug { + if (platformSigning != null) signingConfig platformSigning + } + release { + // [T-918] R8 enabled. CarPropertyAdapter reaches the car purely by + // reflection, so proguard-rules.pro must keep those targets; a release build + // needs an on-vehicle check of the car reads before it ships. + minifyEnabled true + if (platformSigning != null) signingConfig platformSigning + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + // BuildConfig.DEBUG gates the debug-only logging (T-912). + buildFeatures { + buildConfig true + } + + lint { + abortOnError true + checkReleaseBuilds true + warningsAsErrors false + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } +} + +// Version publiée par la CI pour composer le nom de l'asset APK : ce nom DOIT être +// comparable par l'updater ("EVABRPUploader-unstable-1.0.42.apk"). Le tag de la +// pré-release est la constante "unstable", écrasée à chaque build. +tasks.register('printUnstableVersion') { + doLast { + println "${android.defaultConfig.versionName}.${project.findProperty('unstableBuild') ?: '0'}" + } +} + +// Nomme les APK par canal pour que les artefacts de release soient lisibles. +androidComponents { + onVariants(selector().all()) { variant -> + variant.outputs.each { output -> + if (variant.buildType == "release") { + // Le canal unstable garde un nom de fichier stable : l'updater identifie + // la version par le tag de la pré-release, jamais par le nom d'APK. + output.outputFileName.set(variant.flavorName == "unstable" + ? "EVABRPUploader-unstable.apk" + : "EVABRPUploader-${variant.flavorName}-${output.versionName.get()}.apk") + } + } + } +} + +dependencies { + implementation project(':evhardware') + implementation libs.appcompat + implementation libs.material + implementation libs.activity + implementation libs.constraintlayout + implementation libs.viewpager2 + implementation libs.security.crypto + implementation 'com.google.zxing:core:3.5.3' + // android.jar's org.json is a stub in unit tests; the real implementation lets the + // telemetry payload tests parse what the app actually builds. + testImplementation libs.junit + testImplementation libs.json + androidTestImplementation libs.ext.junit + androidTestImplementation libs.espresso.core +} diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 0000000..56e4f46 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..75f1b99 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# EVABRPUploader — R8 keep rules. +# +# CarPropertyAdapter reaches the car through reflection only (android.car is not on the +# compile classpath), so R8 cannot see those uses. Anything it reflects on must be kept +# by name or the release build fails silently at runtime, on a vehicle. + +# Reflected via Class.forName in CarPropertyAdapter. +-keep class android.car.** { *; } + +# The adapter itself: it defines the dynamic-proxy callback interfaces the framework +# invokes back into, which R8 cannot trace. +-keep class com.evsuite.abrp.CarPropertyAdapter { *; } +-keep interface com.evsuite.abrp.CarPropertyAdapter$* { *; } + +# Components named from the manifest. +-keep class com.evsuite.abrp.AbrpUploadService { *; } +-keep class com.evsuite.abrp.BootReceiver { *; } + +# EncryptedSharedPreferences (androidx.security) pulls Tink in via reflection. +-keep class com.google.crypto.tink.** { *; } +-dontwarn com.google.crypto.tink.** + +# Readable crash stack traces from a release build. +-keepattributes SourceFile, LineNumberTable +-renamesourcefileattribute SourceFile diff --git a/app/src/debug/java/com/evsuite/abrp/VhalProbe.java b/app/src/debug/java/com/evsuite/abrp/VhalProbe.java new file mode 100644 index 0000000..d280348 --- /dev/null +++ b/app/src/debug/java/com/evsuite/abrp/VhalProbe.java @@ -0,0 +1,118 @@ +package com.evsuite.abrp; + +import android.util.Log; + +/** + * VHAL discovery helpers — DEBUG BUILDS ONLY. + * + * These enumerate and log the entire vehicle abstraction layer. That is exactly the + * surface a release APK on a customer vehicle should not carry, so the class lives in + * src/debug and simply does not exist in a release build. It was previously part of + * CarPropertyAdapter and shipped to production. + * + * Neither method is called by the service; they are attached from a debug session when + * identifying which vendor property ID maps to a given sensor. + */ +final class VhalProbe { + + private static final String TAG = "VhalProbe"; + + private VhalProbe() { } + + /** + * Reads every vendor Float property visible in the car_service dump and logs non-zero values. + * Use this to identify which vendor property ID maps to a given sensor. + */ + static void probeVendorFloatProperties(CarPropertyAdapter adapter) { + // Full Float-type property ID list from `dumpsys car_service` on this vehicle. + int[] globalIds = { + // Standard AAOS + 291504647, 291504901, 291518465, 291518466, 291518467, 291518468, + // Vendor 0x2160xxxx global + 559945057, 559945059, 559945060, 559945125, 559945126, + 559971102, 559971105, + 559980820, 559980821, 559980822, 559980824, 559980825, 559980826, 559980827, + 559980833, 559980834, 559980835, 559980837, 559980838, 559980839, 559980840, + 559980844, 559980845, 559980846, 559980847, 559980848, + 559980850, 559980851, 559980852, 559980853, 559980854, + 559980857, 559980858, 559980859, 559980860, 559980861, + 559980864, 559980865, 559980866, 559980867, 559980868, + 559980872, 559980873, 559980875, 559980876, 559980877, 559980878, + 559980880, 559980881, 559980882, 559980883, 559980891, + 559980933, 559980951, 559980952, 559980953, 559980954, 559980955, + 559980961, 559980962, 559980963, 559980964, 559980968, + 559980987, 559980988, 559980989, 559980990, 559980991, 559980992, 559980993, 559980994, + 559981004, 559981005, 559981006, 559981007, + 559987029, + 559990304, 559990308, + 560002052, 560002053, 560002054, 560002055, 560002058, + 560002075, 560002077, 560002081, + 560002108, 560002109, 560002114, + }; + // HVAC / climate properties use zone area ID 117 + int[] hvacIds = { + 358622475, 358622476, 358622481, 358622494, 358622495, + 358622496, 358622497, 358622506, 358622518, + }; + + Log.d(TAG, "probeVendorFloatProperties: scanning " + (globalIds.length + hvacIds.length) + " IDs"); + for (int id : globalIds) { + Float val = adapter.getFloatProperty(id, 0); + if (val != null && val != 0f) Log.i(TAG, String.format(" GLOBAL 0x%08X = %.4f", id, val)); + } + for (int id : hvacIds) { + Float val = adapter.getFloatProperty(id, 117); + if (val != null && val != 0f) Log.i(TAG, String.format(" HVAC[117] 0x%08X = %.4f", id, val)); + Float val0 = adapter.getFloatProperty(id, 0); + if (val0 != null && val0 != 0f) Log.i(TAG, String.format(" HVAC[0] 0x%08X = %.4f", id, val0)); + } + Log.d(TAG, "probeVendorFloatProperties: done"); + } + + /** + * Enumerates every supported property and logs its current value. Used for + * discovery: match logged values against the dashboard to identify which + * vendor property ID maps to a given sensor (battery %, range, etc). + */ + static void logAllProperties(CarPropertyAdapter adapter) { + if (adapter.cpm == null) { Log.w(TAG, "logAllProperties: not connected"); return; } + Log.d(TAG, "logAllProperties: starting"); + try { + java.lang.reflect.Method listMethod = adapter.cpmClass.getMethod("getPropertyList"); + java.util.List configs = (java.util.List) listMethod.invoke(adapter.cpm); + Log.d(TAG, "getPropertyList returned: " + (configs == null ? "null" : configs.size() + " entries")); + if (configs == null || configs.isEmpty()) return; + Class configClass = Class.forName("android.car.hardware.CarPropertyConfig", true, adapter.cpmClass.getClassLoader()); + java.lang.reflect.Method getPropId = configClass.getMethod("getPropertyId"); + java.lang.reflect.Method getPropType = configClass.getMethod("getPropertyType"); + java.lang.reflect.Method getAreaIds = configClass.getMethod("getAreaIds"); + for (Object cfg : configs) { + int propId = (int) getPropId.invoke(cfg); + Class type = (Class) getPropType.invoke(cfg); + int[] areas = (int[]) getAreaIds.invoke(cfg); + int area = (areas != null && areas.length > 0) ? areas[0] : 0; + try { + if (type == Float.class) { + Log.i(TAG, String.format("FLOAT 0x%08X area=%d = %s", propId, area, + adapter.getFloatProperty(propId, area))); + } else if (type == Integer.class) { + Log.i(TAG, String.format("INT 0x%08X area=%d = %s", propId, area, + adapter.getIntProperty(propId, area))); + } else if (type == Boolean.class) { + Log.i(TAG, String.format("BOOL 0x%08X area=%d = %s", propId, area, + adapter.getBooleanProperty(propId, area))); + } else { + Log.i(TAG, String.format("OTHER 0x%08X area=%d type=%s", propId, area, type)); + } + } catch (Throwable t) { + Log.w(TAG, String.format("read 0x%08X area=%d failed: %s", + propId, area, t.getClass().getSimpleName())); + } + } + Log.d(TAG, "logAllProperties: done"); + } catch (Exception e) { + Log.e(TAG, "logAllProperties failed", e); + } + } + +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4155835 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/evsuite/abrp/AbrpApi.java b/app/src/main/java/com/evsuite/abrp/AbrpApi.java new file mode 100644 index 0000000..76f535e --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/AbrpApi.java @@ -0,0 +1,123 @@ +package com.evsuite.abrp; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * ABRP telemetry endpoint. + * + * Credentials go in the POST body, never in the query string: a URL travels + * through proxies, CDN access logs and crash reports, a body does not. + */ +final class AbrpApi { + + /** No query string: credentials travel in the body, so the URL is a constant. */ + static final String API_URL = "https://api.iternio.com/1/tlm/send"; + + /** + * Read-only endpoint used to validate credentials. The connection test must never + * touch /tlm/send: posting a placeholder sample tells ABRP the car really is in that + * state and corrupts the user's live route plan. + */ + static final String VERIFY_URL = "https://api.iternio.com/1/tlm/get_next_charge"; + private static final int TIMEOUT_MS = 8_000; + + private AbrpApi() { } + + /** HTTP status plus response body. */ + static final class Response { + final int code; + final String body; + + Response(int code, String body) { + this.code = code; + this.body = body; + } + } + + /** + * Validates credentials without writing anything. Read-only by construction: it is a + * GET against {@link #VERIFY_URL}, so no telemetry is recorded whatever the outcome. + * + * Tension with T-912 (no credential in a URL): this endpoint only accepts the token as + * a query parameter. The api_key is moved to the Authorization header, which is the + * part we can keep out of access logs; the token cannot be, short of dropping the + * read-only check and going back to writing fake telemetry. Sending one token in one + * URL when the user presses Test beats corrupting their route plan on every press. + */ + static Response verifyCredentials(String apiKey, String token) throws IOException { + HttpURLConnection conn = null; + try { + String url = VERIFY_URL + + "?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name()); + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Authorization", "APIKEY " + apiKey); + conn.setConnectTimeout(TIMEOUT_MS); + conn.setReadTimeout(TIMEOUT_MS); + int code = conn.getResponseCode(); + return new Response(code, readBody(conn, code)); + } finally { + if (conn != null) conn.disconnect(); + } + } + + /** Posts one telemetry sample. Throws on network failure. */ + static Response send(String apiKey, String token, String tlmJson) throws IOException { + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL(API_URL).openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(TIMEOUT_MS); + conn.setReadTimeout(TIMEOUT_MS); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + + byte[] payload = formBody(apiKey, token, tlmJson).getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(payload.length); + try (OutputStream out = conn.getOutputStream()) { + out.write(payload); + } + + int code = conn.getResponseCode(); + return new Response(code, readBody(conn, code)); + } finally { + if (conn != null) conn.disconnect(); + } + } + + /** Form-encoded request body carrying the credentials and the telemetry sample. */ + static String formBody(String apiKey, String token, String tlmJson) throws IOException { + return "api_key=" + URLEncoder.encode(apiKey, StandardCharsets.UTF_8.name()) + + "&token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name()) + + "&tlm=" + URLEncoder.encode(tlmJson, StandardCharsets.UTF_8.name()); + } + + private static String readBody(HttpURLConnection conn, int code) { + // getErrorStream() returns null on some non-200 responses; passing null to + // InputStreamReader throws. Checked rather than left to the catch-all below. + java.io.InputStream stream; + try { + stream = code == 200 ? conn.getInputStream() : conn.getErrorStream(); + } catch (Exception e) { + return ""; + } + if (stream == null) return ""; + + try (BufferedReader br = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } catch (Exception e) { + return ""; + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java new file mode 100644 index 0000000..27364ea --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -0,0 +1,762 @@ +package com.evsuite.abrp; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.SystemClock; +import android.util.Log; + +import android.Manifest; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.ServiceCompat; +import androidx.core.content.ContextCompat; + +import com.evsuite.hardware.saic.SaicWeather; +import com.evsuite.hardware.telemetry.EnergySnapshot; +import com.evsuite.hardware.telemetry.EnergyTelemetryReader; +import com.evsuite.hardware.telemetry.ChargingSessionMeter; +import com.evsuite.hardware.telemetry.TirePressureSnapshot; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Foreground service that polls vehicle telemetry every 15 s and pushes it to ABRP. + * + * Reliability design: + * - Uses ScheduledExecutorService (not Handler chains) so exceptions inside one + * upload cycle cannot break the schedule. Even on a thrown RuntimeException + * the next tick still fires. + * - Uploads unconditionally: if vehicle telemetry is unavailable we still emit + * GPS + timestamp so ABRP at least sees the vehicle online. + * - EVHardware owns the vehicle connection, firmware routing and nullable reads. + * - All IPC + HTTP happens on the scheduler thread, never the main thread. + */ +public class AbrpUploadService extends Service { + + public static final String ACTION_STOP = "com.evsuite.abrp.STOP"; + + /** + * Live in-process signal. The "service_running" preference cannot be trusted on its + * own: a force-kill (adb install -r, low-memory kill) skips onDestroy and leaves it + * stale-true forever. This field dies with the process, so it is only ever true while + * the service really is up. + */ + private static volatile boolean running = false; + + public static boolean isRunning() { return running; } + + /** + * Upload history, shared with the UI. Static because the activity reads it while the + * service owns it; it lives and dies with the process, like {@link #running}. + */ + private static final UploadLog uploadLog = new UploadLog(); + + public static UploadLog log() { return uploadLog; } + + public static UploadLog.State state() { return uploadLog.state(running); } + + /** Re-read after the user changes the cadence, without restarting the service. */ + public static void reloadSettings() { settingsDirty = true; } + + private static volatile boolean settingsDirty = false; + + private static final String TAG = "AbrpUploadService"; + private static final String CHANNEL_ID = "abrp_uploader"; + private static final int NOTIF_ID = 1; + /** Scheduler tick. Whether a tick uploads is decided by {@link UploadCadence}. */ + private static final long UPLOAD_INTERVAL_SEC = 15; + /** + * Warm-up before the first upload. Was 45 s with no stated reason; the car adapter + * connects asynchronously and telemetry now omits whatever it cannot read (T-913), so + * an early first sample is useful rather than misleading. + */ + private static final long FIRST_UPLOAD_DELAY_SEC = 20; + private static final long LOCATION_RETRY_INTERVAL_SEC = 30; + /** + * Floor for how old a GPS fix may be and still describe where the car is now. + * + * A fix has no expiry of its own, so the previous behaviour was to keep reporting the + * last one forever: the position saved when the car was parked at home was uploaded, + * stamped with the current time, for the whole first part of the next drive. ABRP then + * plans from a place the car left minutes ago. The subscription asks for a fix every + * upload interval, so anything older than several intervals means the receiver has + * stopped producing (garage, tunnel) and position is genuinely unknown. + */ + private static final long MIN_LOCATION_MAX_AGE_MS = 5 * 60 * 1000L; + /** + * How long a weather reading stands in for the car's own thermometer. + * + * The query can reach the network from inside the head unit and blocks the upload thread + * while it does, so it is not something to do every tick. Outside air does not move fast + * enough for ten minutes to matter to a consumption model. + */ + private static final long WEATHER_TTL_MS = 10 * 60 * 1000L; + /** + * How long a broadcast temperature keeps standing in for the car's thermometer. + * + * The weather app broadcasts when it refreshes itself, not on a cadence this app can ask + * for, so a reading has to outlive the moment it arrived or it would be useless between + * refreshes. Two hours is long enough to bridge them and short enough that the value has + * not stopped describing the day. + */ + private static final long WEATHER_MAX_AGE_MS = 2 * 60 * 60 * 1000L; + private static final String KEY_WEATHER_TEMP = "weather_temp_c"; + private static final String KEY_WEATHER_PLACE = "weather_place"; + private static final String KEY_WEATHER_MS = "weather_received_ms"; + + // Set from the main thread (LocationListener uses Main looper), read from + // the scheduler thread inside doUpload. volatile is sufficient since Location + // is effectively immutable for our purposes. + private volatile Location lastLocation; + + private EnergyTelemetryReader energyReader; + /** + * Keeps the unit on the phone hotspot without a trip into Settings. Attempted on the + * upload tick because that is exactly when being offline costs something, and it is + * rate-limited internally so a tick every few seconds does not hammer the supplicant. + */ + private WifiAutoConnect wifi; + private LocationManager locationManager; + private ScheduledExecutorService scheduler; + private SharedPreferences prefs; + /** Credentials only — encrypted at rest, see {@link SecurePrefs}. */ + private SharedPreferences securePrefs; + private Handler mainHandler; + + private volatile long lastSuccessfulUploadMs = 0L; + /** Previous tick's state, to detect a transition worth reporting immediately. */ + private volatile Boolean lastParked = null; + private volatile Boolean lastCharging = null; + private volatile long lastUploadAttemptMs = 0L; + /** Charge detection state. Touched only from the scheduler thread, inside doUpload. */ + private final ChargingSignal chargingSignal = new ChargingSignal(); + private final ChargingSessionMeter chargeMeter = new ChargingSessionMeter(); + private volatile UploadSettings settings = UploadSettings.defaults(); + /** False while no GPS subscription is delivering — drives the watchdog re-arm. */ + /** What the foreground notification currently says, so re-asserting it does not blank it. */ + private volatile String lastStatus = "Starting…"; + private volatile boolean locationUpdatesActive = false; + private volatile long lastLocationRequestMs = 0L; + /** Last head-unit weather reading and when it was taken — see {@link #WEATHER_TTL_MS}. */ + private volatile SaicWeather.Reading lastWeather = null; + private volatile long lastWeatherMs = 0L; + /** The head unit's own weather broadcast — the source its status bar uses. */ + private final HeadUnitWeather headUnitWeather = new HeadUnitWeather(); + private final BroadcastReceiver weatherReceiver = new BroadcastReceiver() { + @Override public void onReceive(Context context, Intent intent) { + headUnitWeather.accept(intent); + saveWeather(); + } + }; + private volatile boolean weatherReceiverRegistered = false; + /** How long the last map-service query took, or -1 when it was never made. */ + private volatile long lastQueryMs = -1L; + + // ---------- Lifecycle ---------- + + @Override + public void onCreate() { + super.onCreate(); + prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE); + securePrefs = SecurePrefs.get(this); + settings = UploadSettings.from(prefs); + uploadLog.clear(); + mainHandler = new Handler(Looper.getMainLooper()); + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "abrp-upload"); + t.setDaemon(false); + return t; + }); + + createNotificationChannel(); + startInForeground(); + running = true; + prefs.edit().putBoolean("service_running", true).apply(); + + // One shared, firmware-aware EVHardware snapshot owns every vehicle signal. + energyReader = new EnergyTelemetryReader(getApplicationContext()); + wifi = new WifiAutoConnect(getApplicationContext()); + // The head unit's own weather service, bound for one reason: ENV_OUTSIDE_TEMPERATURE + // is not implemented on this vehicle, so the car cannot say how warm it is outside + // and something else has to. Idempotent, asynchronous, and absent on a car that does + // not have the map stack — in which case ext_temp simply stays unreported. + SaicWeather.INSTANCE.connect(getApplicationContext()); + registerWeatherReceiver(); + + requestLocationUpdates(); + + // Fire the first upload after a short warm-up, then every UPLOAD_INTERVAL_SEC. + // scheduleWithFixedDelay ensures we always get at least UPLOAD_INTERVAL_SEC + // between cycles even if a previous upload was slow. + scheduler.scheduleWithFixedDelay( + this::safeUploadCycle, + FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS); + + Log.i(TAG, "Service started, upload scheduler armed"); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && ACTION_STOP.equals(intent.getAction())) { + prefs.edit().putBoolean("service_enabled", false).apply(); + stopSelf(); + return START_NOT_STICKY; + } + // Re-asserted on every start: a position grant that arrives after the service is up + // changes which foreground types it may hold, and the activity starts it again as + // soon as the user answers. Without this the grant only took effect at the next boot. + startInForeground(); + return START_STICKY; + } + + /** + * Enters the foreground holding only the service types this app currently has the + * permissions for. + * + * The service declares {@code foregroundServiceType="location"}, and since API 34 the + * platform validates every declared type against what is held at the moment + * startForeground runs — the plain two-argument call claims them all. On a car where + * position had not been granted yet (a fresh install, or the boot receiver starting the + * service before the app was ever opened) that threw a SecurityException and the uploader + * died before its first tick. Without the permission the service still runs and still + * uploads; it simply has no position to send, exactly as before. + */ + private void startInForeground() { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) { + // Service types do not exist before API 29: nothing is declared and nothing is + // checked, so the plain call is the only correct one there. + startForeground(NOTIF_ID, buildNotification(lastStatus)); + return; + } + int types = hasLocationPermission() + ? ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + : ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE; + ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(lastStatus), types); + } + + private boolean hasLocationPermission() { + return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } + + @Override + public IBinder onBind(Intent intent) { return null; } + + @Override + public void onDestroy() { + Log.i(TAG, "Service stopping"); + if (scheduler != null) scheduler.shutdownNow(); + if (locationManager != null) { + try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {} + } + if (weatherReceiverRegistered) { + try { unregisterReceiver(weatherReceiver); } catch (Exception ignored) {} + weatherReceiverRegistered = false; + } + if (mainHandler != null) mainHandler.removeCallbacksAndMessages(null); + running = false; + prefs.edit().putBoolean("service_running", false).apply(); + super.onDestroy(); + } + + // ---------- Upload cycle (runs on scheduler thread) ---------- + + private void safeUploadCycle() { + try { + if (settingsDirty) { + settingsDirty = false; + settings = UploadSettings.from(prefs); + // The GPS subscription is a power cost of its own; keep it in step with + // how often we actually send. + mainHandler.post(this::requestLocationUpdates); + Log.i(TAG, "Settings reloaded: interval=" + settings.intervalSec + "s" + + " boostLowSoc=" + settings.boostLowSoc + + " lowSoc=" + settings.lowSocPercent + "%"); + } + // Same watchdog for GPS: requestLocationUpdates used to run once in onCreate, + // so a provider that was off at start-up — or dropped later — meant + // position-less telemetry for the rest of the session. + if (!locationUpdatesActive + && System.currentTimeMillis() - lastLocationRequestMs + > LOCATION_RETRY_INTERVAL_SEC * 1000) { + Log.w(TAG, "Location updates inactive, re-arming"); + mainHandler.post(this::requestLocationUpdates); + } + // Before the upload, not after: an upload that fails for want of a network is + // the thing this is here to prevent. Cheap and rate-limited when already on. + if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) { + wifi.ensureConnected(false); + } + doUpload(); + } catch (Throwable t) { + // Absolutely never let an exception escape — would not break the + // schedule with scheduleWithFixedDelay's executor, but we want a + // clean log line anyway. + Log.e(TAG, "upload cycle threw", t); + } + } + + private void doUpload() { + String token = securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim(); + String apiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim(); + + if (token.isEmpty()) { + updateNotification("No ABRP token — open app to configure"); + return; + } + if (apiKey.isEmpty()) { + updateNotification("No API key — open app to configure"); + return; + } + + // Read whatever the car will give us. Every getter returns null when the read + // fails, and a null field is omitted from the payload — never sent as 0. + long sampleMs = System.currentTimeMillis(); + EnergySnapshot vehicle = energyReader.read(sampleMs); + boolean carUp = vehicle.getHasVehicleData(); + Float speedKmh = vehicle.getSpeedKmh(); + Integer soc = vehicle.getSocPercent() == null + ? null : Math.round(vehicle.getSocPercent()); + Integer rangeKm = vehicle.getRangeKm() == null + ? null : Math.round(vehicle.getRangeKm()); + Float extTemp = vehicle.getOutsideTempCelsius(); + Float powerKw = vehicle.getBatteryPowerKw(); + Float chargeRateKw = powerKw == null ? null : -powerKw; + Boolean portConnected = vehicle.getChargePortConnected(); + Boolean parked = vehicle.getParked(); + + // The charge port property is standard AAOS and this VHAL need not implement it. + // Trusting it alone meant a plugged-in car that reads "unplugged" was treated as + // parked-and-idle and throttled to one sample every 15 minutes for the whole + // charge — see ChargingSignal for the fallbacks. + Boolean charging = chargingSignal.evaluate( + sampleMs, portConnected, chargeRateKw, parked, soc); + + // DCFC heuristic: charging at AC speeds (3-22 kW) is type-2; above ~25 kW + // it can only be DC fast. Undecidable if either input is missing. + Boolean dcfc = (charging == null || chargeRateKw == null) + ? null : (charging && chargeRateKw > 25f); + + Float cabinTemp = vehicle.getCabinTempCelsius(); + Float battTemp = vehicle.getBatteryTempCelsius(); + Float capacityKwh = vehicle.getBatteryCapacityKwh(); + Float soeKwh = vehicle.getBatteryEnergyKwh(); + Float odometerKm = vehicle.getOdometerKm(); + Float hvacSetpoint = vehicle.getClimate().getDriverTargetCelsius(); + TirePressureSnapshot tires = vehicle.getTirePressures(); + + // Sampled before the cadence check below: the meter integrates the charge rate + // over time, so it has to see every tick, not only the ones that get uploaded. + Double kwhCharged = chargeMeter.sample( + sampleMs, charging, chargeRateKw); + + // Thin out uploads while the car is parked and not charging. Evaluated here, once + // parked/charging are known, so a transition is never delayed. + boolean stateChanged = !java.util.Objects.equals(parked, lastParked) + || !java.util.Objects.equals(charging, lastCharging); + lastParked = parked; + lastCharging = charging; + + // Cadence is measured on ATTEMPTS, not successes: keying it on the last success + // would retry every tick while offline, which is exactly when saving power matters. + if (!UploadCadence.shouldUpload(System.currentTimeMillis(), lastUploadAttemptMs, + parked, charging, soc, stateChanged, settings)) { + return; + } + lastUploadAttemptMs = System.currentTimeMillis(); + + Location loc = freshLocation(); + + // ext_temp was reaching ABRP as 0 degC, and omitting the unreadable value only turned + // that into ABRP's own default 0. The car is the problem: this VHAL does not implement + // ENV_OUTSIDE_TEMPERATURE, so there is no vehicle reading to correct. The head unit's + // weather service answers for a position, and ambient air at the car is what ext_temp + // means — a better figure than a bumper sensor in the sun would give anyway. + String tempSource = "car"; + SaicWeather.Reading sky = null; + if (!TelemetryPayload.isPlausibleTemp(extTemp)) { + // The broadcast first. It is what fills the head unit's own status bar, so on this + // car it is the source known to be producing a number, where the map service's + // query never even bound. + // The broadcast arrives when the weather app feels like it, so a reading that + // never came is retried here rather than only at start-up. + if (headUnitWeather.temperatureC() == null) probeStickyWeather(); + Float broadcast = weatherIsFresh() ? headUnitWeather.temperatureC() : null; + if (TelemetryPayload.isPlausibleTemp(broadcast)) { + extTemp = broadcast; + tempSource = "bcast:" + headUnitWeather.diagnostic(); + } else { + sky = weatherAt(loc); + if (sky != null && sky.getTemperatureCelsius() != null) { + extTemp = sky.getTemperatureCelsius().floatValue(); + tempSource = "map:" + sky.getCity(); + } else { + // Neither answered. Which one failed, and how, is the difference between + // "the broadcast never arrives" and "it arrives in a shape this cannot + // read" — and nothing on the ABRP side tells them apart. + tempSource = "none bcast=" + headUnitWeather.diagnostic() + + " map=" + (SaicWeather.INSTANCE.isAvailable() ? "bound" : "unbound") + + (lastQueryMs < 0 ? "" : "/" + lastQueryMs + "ms"); + } + } + } + + TelemetryPayload tlm = new TelemetryPayload(System.currentTimeMillis() / 1000); + tlm.soc = soc; + tlm.speedKmh = speedKmh; + tlm.rangeKm = rangeKm; + tlm.extTemp = extTemp; + tlm.powerKw = powerKw; + tlm.charging = charging; + tlm.dcfc = dcfc; + tlm.parked = parked; + tlm.cabinTemp = cabinTemp; + tlm.battTempC = battTemp; + tlm.capacityKwh = capacityKwh; + tlm.soeKwh = soeKwh; + tlm.kwhCharged = kwhCharged; + tlm.odometerKm = odometerKm; + tlm.hvacSetpointC = hvacSetpoint; + tlm.tirePressureFlKpa = tires.getFrontLeftKpa(); + tlm.tirePressureFrKpa = tires.getFrontRightKpa(); + tlm.tirePressureRlKpa = tires.getRearLeftKpa(); + tlm.tirePressureRrKpa = tires.getRearRightKpa(); + if (loc != null) { + tlm.lat = loc.getLatitude(); + tlm.lon = loc.getLongitude(); + tlm.elevation = loc.hasAltitude() ? loc.getAltitude() : null; + tlm.heading = loc.hasBearing() ? loc.getBearing() : null; + } + + String tlmJson = tlm.build(); + String summary = TelemetryPayload.summarize(tlmJson) + " [temp " + tempSource + "]"; + if (loc != null) { + summary += " (fix " + locationAgeMs(loc) / 1000 + "s old" + + " +/-" + (loc.hasAccuracy() ? Math.round(loc.getAccuracy()) + "m" : "?") + + " via " + loc.getProvider(); + // The weather service names the place it answered for. ABRP shows an address it + // reverse-geocodes from the same coordinates, so the two disagreeing says the + // coordinates are wrong, and the two agreeing says ABRP is showing something old. + if (sky != null && !sky.getCity().isEmpty()) summary += ", near " + sky.getCity(); + summary += ")"; + } + // What actually went on the wire. Kept on the log entry as well as in logcat: the + // head unit this runs on has no adb attached, so the in-app log is the only place + // the driver can see it. + Log.i(TAG, "TLM " + summary); + + sendToAbrp(apiKey, token, tlmJson, summary, soc, speedKmh, carUp); + } + + /** + * Listens for the head unit's weather updates. + * + * Exported on purpose: the broadcast comes from another application, and a receiver + * declared not-exported would never hear it. The flag is required from API 33 and does + * not exist before it — the car this runs on is API 28 — so the registration goes + * through ContextCompat, which picks the right overload for the platform underneath. + */ + private void registerWeatherReceiver() { + restoreWeather(); + try { + androidx.core.content.ContextCompat.registerReceiver( + this, weatherReceiver, new IntentFilter(HeadUnitWeather.ACTION), + androidx.core.content.ContextCompat.RECEIVER_EXPORTED); + weatherReceiverRegistered = true; + // A null receiver asks the platform for the last broadcast it is holding without + // subscribing to anything. If the weather app sends its update sticky, this is the + // reading straight away rather than after however long until its next refresh — + // and the app is started to look at the log, which is exactly the wrong moment to + // begin waiting. + probeStickyWeather(); + } catch (Exception e) { + // A car whose weather app broadcasts nothing is a car without ext_temp, not a + // reason to lose the upload service. + Log.w(TAG, "Weather broadcast unavailable: " + e.getMessage()); + } + } + + /** Asks for the last held broadcast, if the platform is holding one. Harmless when not. */ + private void probeStickyWeather() { + try { + headUnitWeather.accept( + registerReceiver(null, new IntentFilter(HeadUnitWeather.ACTION))); + saveWeather(); + } catch (Exception e) { + Log.w(TAG, "Sticky weather probe failed: " + e.getMessage()); + } + } + + /** + * Keeps the last reading across a restart. + * + * The broadcast arrives when the weather app refreshes, so a service that forgets on every + * start would spend most of its life with no temperature at all — and the app is restarted + * every time the driver opens it. + */ + private void saveWeather() { + Float celsius = headUnitWeather.temperatureC(); + if (celsius == null) return; + prefs.edit() + .putFloat(KEY_WEATHER_TEMP, celsius) + .putString(KEY_WEATHER_PLACE, headUnitWeather.place()) + .putLong(KEY_WEATHER_MS, headUnitWeather.receivedMs()) + .apply(); + } + + private void restoreWeather() { + long whenMs = prefs.getLong(KEY_WEATHER_MS, 0L); + if (whenMs <= 0L || System.currentTimeMillis() - whenMs > WEATHER_MAX_AGE_MS) return; + headUnitWeather.restore(prefs.getFloat(KEY_WEATHER_TEMP, 0f), + prefs.getString(KEY_WEATHER_PLACE, ""), whenMs); + } + + /** True while the stored reading still describes the day — see {@link #WEATHER_MAX_AGE_MS}. */ + private boolean weatherIsFresh() { + long whenMs = headUnitWeather.receivedMs(); + return whenMs > 0L && System.currentTimeMillis() - whenMs <= WEATHER_MAX_AGE_MS; + } + + /** + * Current conditions where the car is, or null when nothing can answer. + * + * Cached for {@link #WEATHER_TTL_MS}: the query blocks the upload thread on what may be a + * network round-trip inside the head unit, and it is asked on every upload the car's own + * thermometer cannot answer — which, on this vehicle, is all of them. A stale reading is + * kept when a refresh fails, since ten-minute-old air is a better answer than none. + */ + private SaicWeather.Reading weatherAt(Location loc) { + if (loc == null) return null; + long now = System.currentTimeMillis(); + SaicWeather.Reading cached = lastWeather; + if (cached != null && now - lastWeatherMs < WEATHER_TTL_MS) return cached; + if (!SaicWeather.INSTANCE.isAvailable()) { lastQueryMs = -1L; return cached; } + // How long the call took is the only thing that separates its failure modes from out + // here: the query is bounded by a two-second wait inside the library, so a null that + // comes back at once was refused or answered undecodably, and a null that comes back + // at the bound is a service that did not answer in time. The library reports neither. + long startedMs = System.currentTimeMillis(); + SaicWeather.Reading reading = SaicWeather.INSTANCE.currentAt( + loc.getLatitude(), loc.getLongitude(), weatherLanguage()); + lastQueryMs = System.currentTimeMillis() - startedMs; + if (reading == null) return cached; + lastWeather = reading; + lastWeatherMs = now; + return reading; + } + + /** The provider answers in the language it is asked in; the driver's is the right one. */ + private static String weatherLanguage() { + java.util.Locale locale = java.util.Locale.getDefault(); + String language = locale.getLanguage().toLowerCase(java.util.Locale.US); + String country = locale.getCountry().toLowerCase(java.util.Locale.US); + return country.isEmpty() ? language : language + "-" + country; + } + + private void sendToAbrp(String apiKey, String token, String tlmJson, String summary, + Integer soc, Float speedKmh, boolean carUp) { + try { + AbrpApi.Response response = AbrpApi.send(apiKey, token, tlmJson); + int code = response.code; + + // The response body can echo request details — debug builds only. + if (BuildConfig.DEBUG) Log.d(TAG, "ABRP [" + code + "]: " + response.body); + + uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), code, + code == 200, code == 200 ? "OK" : ("HTTP " + code), summary)); + + if (code == 200) { + lastSuccessfulUploadMs = System.currentTimeMillis(); + String time = android.text.format.DateFormat + .format("HH:mm:ss", lastSuccessfulUploadMs).toString(); + prefs.edit() + .putString("last_upload_time", time) + .putString("last_upload_status", "OK") + .apply(); + // Only claim a value in the notification if we actually read one. + String detail = (soc != null && speedKmh != null) + ? ("SOC " + soc + "% · " + Math.round(speedKmh) + " km/h · " + time) + : (carUp ? ("Car data partial · " + time) : ("GPS only · " + time)); + updateNotification(detail); + } else { + prefs.edit().putString("last_upload_status", "HTTP " + code).apply(); + int failures = uploadLog.consecutiveFailures(); + updateNotification(failures >= UploadLog.FAILURES_FOR_ERROR + ? ("Upload failing (" + failures + "x) — HTTP " + code) + : ("Upload error: HTTP " + code)); + } + } catch (java.net.UnknownHostException e) { + // httpStatus 0: the request never reached a server. + uploadLog.record(new UploadLog.Entry( + System.currentTimeMillis(), 0, false, "No internet", summary)); + prefs.edit().putString("last_upload_status", "No internet").apply(); + updateNotification("Offline — will retry"); + } catch (Exception e) { + Log.e(TAG, "Upload failed", e); + uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, false, + e.getClass().getSimpleName(), summary)); + prefs.edit().putString("last_upload_status", + e.getClass().getSimpleName() + ": " + e.getMessage()).apply(); + } + } + + // ---------- Location ---------- + + private final LocationListener locationListener = new LocationListener() { + @Override public void onLocationChanged(Location location) { + lastLocation = location; + locationUpdatesActive = true; + } + @Override public void onStatusChanged(String p, int s, Bundle e) {} + @Override public void onProviderEnabled(String p) { + Log.i(TAG, "GPS provider enabled — re-arming location updates"); + // The subscription made while the provider was off delivers nothing; ask again. + requestLocationUpdates(); + } + @Override public void onProviderDisabled(String p) { + Log.w(TAG, "GPS provider disabled — telemetry will omit position"); + locationUpdatesActive = false; + // A stale fix is worse than none: ABRP would think the car is parked there. + lastLocation = null; + } + }; + + /** + * The last GPS fix if it still describes where the car is, otherwise null so the + * payload omits position entirely. Omitting it leaves ABRP on its own last known + * point; sending a stale one actively moves the car back to where it used to be. + */ + private Location freshLocation() { + Location loc = lastLocation; + if (loc == null) return null; + long ageMs = locationAgeMs(loc); + long maxAgeMs = maxLocationAgeMs(); + if (ageMs > maxAgeMs) { + Log.w(TAG, "Dropping stale GPS fix: " + ageMs / 1000 + "s old (max " + + maxAgeMs / 1000 + "s) — position omitted"); + return null; + } + return loc; + } + + /** + * Age of a fix, measured on the monotonic clock. Location.getTime() is wall-clock and + * the head unit adjusts its own clock from GPS, which can make a fresh fix look like + * it came from the future — or from hours ago — the moment that correction lands. + */ + private static long locationAgeMs(Location loc) { + long fixNanos = loc.getElapsedRealtimeNanos(); + // A provider that does not stamp the monotonic clock leaves this at zero, which would + // date every fix to the last boot and drop all of them — turning a staleness guard + // into a position blackout. An unstamped fix is one whose age cannot be known, and + // guessing "ancient" there is the worse of the two guesses. + if (fixNanos <= 0L) return 0L; + long ageMs = (SystemClock.elapsedRealtimeNanos() - fixNanos) / 1_000_000L; + // A fix from the future is a clock that moved, not a fix worth dropping. + return Math.max(ageMs, 0L); + } + + /** Several subscription intervals, so a single missed fix never blanks the position. */ + private long maxLocationAgeMs() { + return Math.max(MIN_LOCATION_MAX_AGE_MS, 3 * settings.intervalMs()); + } + + /** + * Subscribes to GPS updates. Safe to call repeatedly: the previous subscription is + * removed first, so the watchdog can re-arm without stacking listeners. + */ + private void requestLocationUpdates() { + try { + locationManager = (LocationManager) getSystemService(LOCATION_SERVICE); + try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {} + locationUpdatesActive = false; + + if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + // Match the upload cadence: a 10 s GPS subscription burned power for + // fixes that were thrown away between uploads. + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, settings.intervalMs(), 0f, + locationListener, Looper.getMainLooper()); + locationUpdatesActive = true; + lastLocationRequestMs = System.currentTimeMillis(); + // Seed from the last known fix so the first upload is not position-less — + // but only if it is recent. This is where the car's overnight parking spot + // used to come from: cold GPS at start-up delivers nothing for the first + // minutes, and the fix from the previous drive filled the gap. + Location last = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (last != null && locationAgeMs(last) <= maxLocationAgeMs()) { + lastLocation = last; + } else if (last != null) { + Log.i(TAG, "Ignoring last known fix: " + locationAgeMs(last) / 1000 + + "s old, waiting for a live one"); + } + } else { + Log.w(TAG, "GPS provider disabled — will retry"); + lastLocationRequestMs = System.currentTimeMillis(); + } + } catch (SecurityException e) { + Log.w(TAG, "Location permission not granted: " + e.getMessage()); + lastLocationRequestMs = System.currentTimeMillis(); + } catch (Exception e) { + Log.w(TAG, "Location unavailable: " + e.getMessage()); + lastLocationRequestMs = System.currentTimeMillis(); + } + } + + // ---------- Notification ---------- + + private void createNotificationChannel() { + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, + getString(R.string.notif_channel_name), + NotificationManager.IMPORTANCE_LOW); + ch.setDescription(getString(R.string.notif_channel_desc)); + getSystemService(NotificationManager.class).createNotificationChannel(ch); + } + + private Notification buildNotification(String status) { + PendingIntent openApp = PendingIntent.getActivity(this, 0, + new Intent(this, MainActivity.class), + PendingIntent.FLAG_IMMUTABLE); + + Intent stopIntent = new Intent(this, AbrpUploadService.class); + stopIntent.setAction(ACTION_STOP); + PendingIntent stopPi = PendingIntent.getService(this, 1, + stopIntent, PendingIntent.FLAG_IMMUTABLE); + + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(getString(R.string.notif_title)) + .setContentText(status) + .setContentIntent(openApp) + .addAction(android.R.drawable.ic_delete, getString(R.string.notif_action_stop), stopPi) + .setOngoing(true) + .build(); + } + + private void updateNotification(String status) { + lastStatus = status; + getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status)); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/BootReceiver.java b/app/src/main/java/com/evsuite/abrp/BootReceiver.java new file mode 100644 index 0000000..f884b01 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/BootReceiver.java @@ -0,0 +1,69 @@ +package com.evsuite.abrp; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.util.Log; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Restarts uploading when the head unit powers up. + * + * BOOT_COMPLETED alone was not enough. An MG4's head unit rarely cold-boots: switching the + * car on usually resumes it, and the resume is announced with QUICKBOOT_POWERON instead. + * Users reported uploading never starting with the car, and this is why — the app was + * waiting for a broadcast their car does not always send. The other EVSuite apps already + * listen to the whole family; this one now does too. + * + * MY_PACKAGE_REPLACED is in the list for a different reason: an update stops the service, + * and nothing else would bring it back until the next power cycle. + */ +public class BootReceiver extends BroadcastReceiver { + + private static final String TAG = "EVABRP.Boot"; + + private static final Set START_ACTIONS = new HashSet<>(Arrays.asList( + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + "android.intent.action.LOCKED_BOOT_COMPLETED", + "android.intent.action.QUICKBOOT_POWERON", + "com.htc.intent.action.QUICKBOOT_POWERON" + )); + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action == null || !START_ACTIONS.contains(action)) return; + + SharedPreferences prefs = context.getSharedPreferences("abrp_prefs", Context.MODE_PRIVATE); + boolean enabled = prefs.getBoolean("service_enabled", false); + boolean autostart = prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART); + + // Credentials come from SecurePrefs, not from the plaintext file. This receiver used + // to read "token" out of abrp_prefs — where it has not lived since the credentials + // were encrypted, because the migration deletes the plaintext copy. The check + // therefore always failed, and uploading never started by itself on any car. + SharedPreferences secure = SecurePrefs.get(context); + boolean haveCreds = !secure.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty() + && !secure.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty(); + + if (!enabled || !autostart || !haveCreds) { + Log.i(TAG, action + " ignored (enabled=" + enabled + + " autostart=" + autostart + " credentials=" + haveCreds + ")"); + return; + } + + Log.i(TAG, action + " — starting upload service"); + try { + context.startForegroundService(new Intent(context, AbrpUploadService.class)); + } catch (Exception e) { + // A start refused this early is not worth crashing the boot broadcast over: the + // next power cycle, or opening the app, starts it just the same. + Log.w(TAG, "startForegroundService failed: " + e.getMessage()); + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/CarPropertyAdapter.java b/app/src/main/java/com/evsuite/abrp/CarPropertyAdapter.java new file mode 100644 index 0000000..a984cff --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/CarPropertyAdapter.java @@ -0,0 +1,289 @@ +package com.evsuite.abrp; + +import android.content.ComponentName; +import android.content.Context; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.util.Log; + +import java.lang.reflect.Method; + +/** + * Connects to Android Automotive's CarPropertyManager via reflection. + * + * Targets AAOS 9 (API 28) where android.car is a platform library not in the + * compile-time SDK — direct imports are unavailable, so all access is via reflection. + * + * Debug discovery transport for {@code VhalProbe}. Production telemetry goes exclusively + * through EVHardware; this class deliberately owns no vehicle property ids. + */ +public class CarPropertyAdapter { + + private static final String TAG = "CarPropertyAdapter"; + + public interface Listener { + void onConnected(); + void onDisconnected(); + } + + private final Listener listener; + private Context appContext; + // Written on the binder/main thread, read from the abrp-upload scheduler thread. + // Without volatile the scheduler can observe a stale null or a half-connected adapter. + private volatile Object car; // android.car.Car + volatile Object cpm; // android.car.hardware.property.CarPropertyManager (package-private: VhalProbe, debug builds only) + private Class carClass; + Class cpmClass; + + // Reflected getters resolved once at connect time. getMethod() on every read meant + // 8 lookups every 15 s, each allocating a Method and its parameter array. + private volatile Method getIntMethod; + private volatile Method getFloatMethod; + private volatile Method getBooleanMethod; + + /** + * Incremented on every disconnect. A polling runnable captures the value it started + * with and stops as soon as it no longer matches, so a reconnect cannot leave an + * orphan polling loop running alongside the new one. + */ + private volatile int connectionGeneration = 0; + + public CarPropertyAdapter(Listener listener) { + this.listener = listener; + } + + public void connect(Context context) { + appContext = context.getApplicationContext(); + + try { + carClass = Class.forName("android.car.Car"); + cpmClass = Class.forName("android.car.hardware.property.CarPropertyManager"); + } catch (ClassNotFoundException e) { + Log.e(TAG, "android.car not available — not running on AAOS", e); + return; + } + + ServiceConnection sc = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + try { + String svcName = (String) carClass.getField("PROPERTY_SERVICE").get(null); + Method getManager = carClass.getMethod("getCarManager", String.class); + Object manager = getManager.invoke(car, svcName); + if (manager != null) { + getIntMethod = cpmClass.getMethod("getIntProperty", int.class, int.class); + getFloatMethod = cpmClass.getMethod("getFloatProperty", int.class, int.class); + getBooleanMethod = cpmClass.getMethod("getBooleanProperty", int.class, int.class); + // Publish last: isConnected() must never be true before the + // cached methods are in place. + cpm = manager; + Log.i(TAG, "CarPropertyManager ready"); + listener.onConnected(); + } else { + Log.e(TAG, "getCarManager returned null for: " + svcName); + } + } catch (Exception e) { + Log.e(TAG, "Failed to get CarPropertyManager", e); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + connectionGeneration++; + cpm = null; + car = null; + Log.w(TAG, "Car service disconnected"); + listener.onDisconnected(); + } + }; + + try { + Method createCar = carClass.getMethod("createCar", Context.class, ServiceConnection.class); + car = createCar.invoke(null, appContext, sc); + carClass.getMethod("connect").invoke(car); + Log.d(TAG, "Connecting to Car service..."); + } catch (Exception e) { + Log.e(TAG, "Failed to create/connect Car", e); + } + } + + public void disconnect() { + // Bump first: any in-flight polling runnable must stop even if the calls below throw. + connectionGeneration++; + cpm = null; + if (car == null) return; + try { + carClass.getMethod("disconnect").invoke(car); + } catch (Exception e) { + Log.e(TAG, "disconnect error", e); + } + car = null; + } + + public boolean isConnected() { + return cpm != null; + } + + public interface PropertyCallback { + void onValue(int propId, int areaId, Object value); + } + + /** + * Polls a float property on the given handler at the specified interval. + * Stops automatically when disconnect() is called. + * This is the preferred mechanism on AAOS 9 where CarPropertyValue is not + * accessible via any standard classloader path, making Proxy-based listeners fail. + */ + public void startFloatPolling(int propId, int areaId, long intervalMs, + android.os.Handler handler, PropertyCallback cb) { + if (cpm == null) { Log.w(TAG, "startFloatPolling: not connected"); return; } + final int generation = connectionGeneration; + Runnable[] r = {null}; + r[0] = () -> { + // Checking cpm alone was not enough: a reconnect makes it non-null again and + // the orphan loop resumed forever next to the new one. + if (cpm == null || connectionGeneration != generation) return; + Float val = getFloatProperty(propId, areaId); + if (val != null) cb.onValue(propId, areaId, val); + handler.postDelayed(r[0], intervalMs); + }; + handler.post(r[0]); + Log.d(TAG, String.format("started polling 0x%08X every %dms", propId, intervalMs)); + } + + /** + * Subscribes to updates for any car property (float, int, bool — type-agnostic). + * The callback receives the raw {@link Object} value; cast to the expected type. + * + * AAOS 9 quirks handled here: + * - Method is named registerListener (not registerCallback as in later versions). + * - The event callback interface and CarPropertyValue class are discovered from + * the register method's signature — on this SAIC build CarPropertyValue lives + * at android.car.hardware.CarPropertyValue (NOT android.car.hardware.property.*). + * - The Proxy is defined with this class's loader so the framework can verify it. + * + * @param rateHz for CONTINUOUS properties, the requested rate in Hz. + * For ON_CHANGE properties (e.g. gear), this is ignored — pass 0f. + */ + public void registerPropertyCallback(int propId, int areaId, float rateHz, PropertyCallback cb) { + if (cpm == null) { Log.w(TAG, "registerPropertyCallback: not connected"); return; } + try { + // Find a 3-arg register* method on CarPropertyManager: (Listener, propId, rateHz). + // Match by exact name: getMethods() has no defined order, so "the first + // 3-arg register* method" could pick a different overload between runs. + java.lang.reflect.Method registerMethod = null; + for (java.lang.reflect.Method m : cpmClass.getMethods()) { + String n = m.getName(); + if (("registerListener".equals(n) || "registerCallback".equals(n)) + && m.getParameterCount() == 3) { + Log.d(TAG, "register candidate: " + m.toGenericString()); + registerMethod = m; + break; + } + } + if (registerMethod == null) { + Log.e(TAG, "no register method found on CarPropertyManager"); + return; + } + + Class cbInterface = registerMethod.getParameterTypes()[0]; + Log.d(TAG, "callback interface: " + cbInterface.getName()); + + // Discover CarPropertyValue directly from the interface's onChangeEvent + // parameter type. This sidesteps hardcoding a package path that differs + // between AAOS builds. + Class cpvClass = null; + for (java.lang.reflect.Method m : cbInterface.getMethods()) { + Log.d(TAG, " interface method: " + m.toGenericString()); + if ("onChangeEvent".equals(m.getName()) && m.getParameterCount() == 1) { + cpvClass = m.getParameterTypes()[0]; + } + } + if (cpvClass == null) { + Log.e(TAG, "no onChangeEvent(CarPropertyValue) method on " + cbInterface.getName()); + return; + } + Log.d(TAG, "CarPropertyValue resolved to: " + cpvClass.getName()); + + java.lang.reflect.Method getPropId = cpvClass.getMethod("getPropertyId"); + java.lang.reflect.Method getAreaId = cpvClass.getMethod("getAreaId"); + java.lang.reflect.Method getValue = cpvClass.getMethod("getValue"); + + // Use the app classloader (delegates to boot for android.car.* classes) + // so the generated Proxy class can actually be defined. + ClassLoader proxyLoader = CarPropertyAdapter.class.getClassLoader(); + + Object proxy = java.lang.reflect.Proxy.newProxyInstance( + proxyLoader, + new Class[]{cbInterface}, + (proxyObj, method, args) -> { + String mname = method.getName(); + if ("onChangeEvent".equals(mname) && args != null && args.length == 1) { + int pId = (int) getPropId.invoke(args[0]); + int aId = (int) getAreaId.invoke(args[0]); + Object val = getValue.invoke(args[0]); + cb.onValue(pId, aId, val); + } else if ("onErrorEvent".equals(mname)) { + Log.e(TAG, "onErrorEvent propId=" + args[0] + " area=" + args[1]); + } else if ("equals".equals(mname)) { + return proxyObj == args[0]; + } else if ("hashCode".equals(mname)) { + return System.identityHashCode(proxyObj); + } else if ("toString".equals(mname)) { + return "CarPropertyProxy@" + Integer.toHexString(System.identityHashCode(proxyObj)); + } + return null; + }); + + registerMethod.invoke(cpm, proxy, propId, rateHz); + Log.i(TAG, String.format("registered callback for 0x%08X area=%d rate=%.1fHz", + propId, areaId, rateHz)); + } catch (Exception e) { + Log.e(TAG, "registerPropertyCallback failed", e); + } + } + + // Getters return null when the property cannot be read. They used to return 0, + // which is indistinguishable from a genuine zero: one SecurityException on the SOC + // property was enough to tell ABRP the battery was empty, every 15 seconds. + // Callers must omit a null field from the payload rather than substituting a value. + + /** Area 0 covers global/non-zoned properties. Null when unreadable. */ + public Integer getIntProperty(int propId, int areaId) { + Object manager = cpm; + Method method = getIntMethod; + if (manager == null || method == null) return null; + try { + return (Integer) method.invoke(manager, propId, areaId); + } catch (Exception e) { + Log.e(TAG, "getIntProperty(" + Integer.toHexString(propId) + ") failed", e); + return null; + } + } + + /** Null when unreadable. */ + public Float getFloatProperty(int propId, int areaId) { + Object manager = cpm; + Method method = getFloatMethod; + if (manager == null || method == null) return null; + try { + return (Float) method.invoke(manager, propId, areaId); + } catch (Exception e) { + Log.e(TAG, "getFloatProperty(" + Integer.toHexString(propId) + ") failed", e); + return null; + } + } + + /** Null when unreadable. */ + public Boolean getBooleanProperty(int propId, int areaId) { + Object manager = cpm; + Method method = getBooleanMethod; + if (manager == null || method == null) return null; + try { + return (Boolean) method.invoke(manager, propId, areaId); + } catch (Exception e) { + Log.e(TAG, "getBooleanProperty(" + Integer.toHexString(propId) + ") failed", e); + return null; + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ChargingSignal.java b/app/src/main/java/com/evsuite/abrp/ChargingSignal.java new file mode 100644 index 0000000..feacd49 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ChargingSignal.java @@ -0,0 +1,77 @@ +package com.evsuite.abrp; + +/** + * Works out whether the car is charging from whichever signals the VHAL actually answers. + * + * {@code EV_CHARGE_PORT_CONNECTED} is a standard AAOS property and this vehicle's VHAL is + * not obliged to implement it. When it answers {@code false} on a plugged-in car, the + * cadence policy sees "parked and not charging" and backs off to + * {@link UploadCadence#PARKED_INTERVAL_MS} for the whole session — which is exactly when + * ABRP needs a tight SOC curve to size a charging stop. Two fallbacks close that hole: + * + * 1. the instantaneous charge rate, which is positive only while the pack takes energy; + * 2. a rising SOC on a parked car, which nothing but charging can produce. + * + * The SOC fallback is latched, because SOC only moves a whole percent every few minutes + * and a signal that flapped between ticks would flap {@code is_charging} in the payload + * too. The latch expires so a car left parked after a charge falls back to the idle + * cadence instead of polling all night. + * + * Stateful and NOT thread-safe: only the {@code abrp-upload} scheduler thread calls it. + */ +final class ChargingSignal { + + /** Above this the pack is taking energy; below it the reading is noise around zero. */ + static final float CHARGING_POWER_KW = 0.3f; + + /** + * How long a SOC rise keeps counting as "charging". Long enough to cover the slowest + * realistic case — a 2 kW granny lead needs ~19 min per percent on a 64 kWh pack — + * and short enough that a finished charge returns to the idle cadence promptly. + */ + static final long SOC_RISE_LATCH_MS = 1_800_000L; + + private Integer lastSocPercent; + /** When SOC last rose while parked; 0 when there is no live rise to trust. */ + private long socRoseAtMs; + + /** + * @param nowMs current time + * @param portConnected charge port property, null when unreadable + * @param chargeRateKw charge rate in kW as the VHAL signs it (+ve while charging), + * null when unreadable + * @param parked null when the gear could not be read + * @param socPercent null when the battery level could not be read + * @return TRUE / FALSE when the state can be established, null when nothing could be + * read — null must keep counting as "unknown", never as "not charging" + */ + Boolean evaluate(long nowMs, Boolean portConnected, Float chargeRateKw, + Boolean parked, Integer socPercent) { + trackSoc(nowMs, parked, socPercent); + + if (Boolean.TRUE.equals(portConnected)) return Boolean.TRUE; + if (chargeRateKw != null && chargeRateKw > CHARGING_POWER_KW) return Boolean.TRUE; + if (Boolean.TRUE.equals(parked) + && socRoseAtMs > 0L && nowMs - socRoseAtMs < SOC_RISE_LATCH_MS) { + return Boolean.TRUE; + } + // Nothing overrode the port: pass its reading through, including its null. + return portConnected; + } + + private void trackSoc(long nowMs, Boolean parked, Integer socPercent) { + if (!Boolean.TRUE.equals(parked)) { + // Regen while driving also raises SOC. Keep the latch clear so parking right + // after a downhill run does not read as charging. + socRoseAtMs = 0L; + lastSocPercent = socPercent; + return; + } + if (socPercent == null) return; // keep the previous reading to compare against + if (lastSocPercent != null) { + if (socPercent > lastSocPercent) socRoseAtMs = nowMs; + else if (socPercent < lastSocPercent) socRoseAtMs = 0L; // draining, not charging + } + lastSocPercent = socPercent; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ConfigImport.java b/app/src/main/java/com/evsuite/abrp/ConfigImport.java new file mode 100644 index 0000000..860e4e6 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ConfigImport.java @@ -0,0 +1,119 @@ +package com.evsuite.abrp; + +/** + * Parses an ABRP configuration text file so a user can set the app up without typing on the + * car's on-screen keyboard — the api_key and token in particular are long and error-prone to + * enter by hand. The file is a plain {@code key = value} list, one entry per line: + * + *
+ *   # ABRP Uploader config
+ *   api_key = 1234abcd-...
+ *   token   = 5678efgh-...
+ *   interval_sec = 60
+ *   boost_low_soc = true
+ *   low_soc_percent = 20
+ * 
+ * + * Only api_key and token are commonly needed; the cadence keys are optional. Blank lines and + * lines starting with {@code #} are ignored, keys are case-insensitive, and surrounding + * whitespace is trimmed. Every field is nullable: the caller applies only what the file set, + * leaving anything absent untouched. + */ +final class ConfigImport { + + final String apiKey; + final String token; + final Integer intervalSec; + final Boolean boostLowSoc; + final Integer lowSocPercent; + + private ConfigImport(String apiKey, String token, Integer intervalSec, + Boolean boostLowSoc, Integer lowSocPercent) { + this.apiKey = apiKey; + this.token = token; + this.intervalSec = intervalSec; + this.boostLowSoc = boostLowSoc; + this.lowSocPercent = lowSocPercent; + } + + /** True when the file yielded nothing usable — an empty or malformed file, not a config. */ + boolean isEmpty() { + return apiKey == null && token == null && intervalSec == null + && boostLowSoc == null && lowSocPercent == null; + } + + static ConfigImport parse(String text) { + String apiKey = null, token = null; + Integer intervalSec = null, lowSocPercent = null; + Boolean boostLowSoc = null; + + for (String line : text.split("\n", -1)) { + line = line.trim(); + if (line.isEmpty() || line.charAt(0) == '#') continue; + + int sep = line.indexOf('='); + if (sep <= 0) continue; + + String key = line.substring(0, sep).trim().toLowerCase(java.util.Locale.US); + String value = line.substring(sep + 1).trim(); + if (value.isEmpty()) continue; + + switch (key) { + case "api_key": + case "apikey": + apiKey = value; + break; + case "token": + case "user_token": + token = value; + break; + case "interval_sec": + case "upload_interval_sec": + intervalSec = nearestInterval(value); + break; + case "boost_low_soc": + boostLowSoc = parseBool(value); + break; + case "low_soc_percent": + lowSocPercent = clampPercent(value); + break; + default: + // Unknown keys are ignored rather than rejected, so a file can carry + // comments or future keys without breaking an older build. + break; + } + } + return new ConfigImport(apiKey, token, intervalSec, boostLowSoc, lowSocPercent); + } + + /** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */ + private static Integer nearestInterval(String value) { + int requested; + try { + requested = Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + int best = UploadSettings.INTERVAL_CHOICES_SEC[0]; + for (int choice : UploadSettings.INTERVAL_CHOICES_SEC) { + if (Math.abs(choice - requested) < Math.abs(best - requested)) best = choice; + } + return best; + } + + /** Same 1–99 clamp the manual field enforces, so an import cannot set an unusable value. */ + private static Integer clampPercent(String value) { + try { + return Math.max(1, Math.min(99, Integer.parseInt(value))); + } catch (NumberFormatException e) { + return null; + } + } + + private static Boolean parseBool(String value) { + value = value.toLowerCase(java.util.Locale.US); + if (value.equals("true") || value.equals("on") || value.equals("1")) return Boolean.TRUE; + if (value.equals("false") || value.equals("off") || value.equals("0")) return Boolean.FALSE; + return null; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/HeadUnitWeather.java b/app/src/main/java/com/evsuite/abrp/HeadUnitWeather.java new file mode 100644 index 0000000..3db460a --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/HeadUnitWeather.java @@ -0,0 +1,239 @@ +package com.evsuite.abrp; + +import android.content.Intent; +import android.os.Bundle; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Outside temperature from the head unit's own weather app. + * + * The car does not implement ENV_OUTSIDE_TEMPERATURE, so there is no vehicle sensor to read, + * and the vendor map service that answers a weather query by position never bound on this + * firmware — {@code ext_temp} was simply absent from every upload. What demonstrably works is + * the head unit's status bar, which shows a temperature all the time. It gets it from a + * broadcast, and so does this. + * + * **Everything about the payload here is unverified.** The action and the extra name come from + * reading the head unit, not from a published interface, and the shape inside is read by + * searching rather than by field offsets: any key whose name mentions a temperature, at any + * depth. That is deliberate. A parser written against a guessed layout fails silently and + * looks exactly like the bug it was meant to fix, whereas a search either finds a number or + * reports what it was given — see {@link #diagnostic()}, which reaches the app's own log. + * + * Stays in this app rather than in EVHardware for the same reason: the shared library carries + * vendor knowledge that has been confirmed on a car. Once a driver's log shows this reading a + * real broadcast, it belongs there instead, next to SaicWeather. + */ +final class HeadUnitWeather { + + /** The head unit's weather app announces each update with this. */ + static final String ACTION = "com.saicmotor.weather"; + + /** Enough of an unreadable payload to recognise its shape, short enough for a log line. */ + private static final int RAW_SAMPLE_CHARS = 200; + + /** How deep the search descends. A weather payload is nested, not deeply nested. */ + private static final int MAX_DEPTH = 6; + + private volatile Float temperatureC; + /** The place the head unit named — what ABRP's address is checked against. */ + private volatile String place = ""; + /** Why the last broadcast produced nothing, for the log. Null once one produced something. */ + private volatile String problem = "no broadcast yet"; + private volatile long receivedMs = 0L; + + /** Outside temperature in °C, or null while no broadcast has carried one. */ + Float temperatureC() { + return temperatureC; + } + + String place() { + return place; + } + + long receivedMs() { + return receivedMs; + } + + /** + * One short token for the upload log: the place a reading came from, or the reason there + * is none. "no broadcast yet" and "no temperature in {...}" call for different fixes and + * are indistinguishable from the ABRP side. + */ + String diagnostic() { + if (temperatureC == null) return problem; + String age = (System.currentTimeMillis() - receivedMs) / 60000 + "min"; + return (place.isEmpty() ? "ok" : place) + "/" + age; + } + + /** Reads whatever the head unit just broadcast. Never throws: this runs in a receiver. */ + void accept(Intent intent) { + if (intent == null) { + // Only reached from the sticky probe, which answers null when the platform is + // holding no such broadcast. Says nothing about whether one will ever be sent. + if (temperatureC == null) problem = "none held, none sent yet"; + return; + } + acceptPayload(payloadOf(intent)); + } + + /** Restores the last reading across a restart — see the service's prefs. */ + void restore(float celsius, String named, long whenMs) { + temperatureC = celsius; + place = named == null ? "" : named; + receivedMs = whenMs; + problem = null; + } + + /** + * The half of {@link #accept} that has no Android in it, so the search below is testable + * against the payloads it might be handed rather than only against the one it expects. + */ + void acceptPayload(String payload) { + try { + if (payload == null) { + problem = "broadcast carried no JSON"; + return; + } + JSONObject json = new JSONObject(payload); + Float celsius = toCelsius(findNumber(json, "temp", 0), findUnit(json, 0)); + if (celsius == null) { + problem = "no temperature in " + truncate(payload); + return; + } + temperatureC = celsius; + String named = findText(json, 0); + place = named == null ? "" : named; + receivedMs = System.currentTimeMillis(); + problem = null; + } catch (Exception e) { + // A payload that is not the JSON this expects is a finding, not a crash. + problem = e.getClass().getSimpleName() + " reading broadcast"; + } + } + + /** + * The JSON the broadcast carries. Named extras first, then any string extra that looks + * like an object — the extra's name is as unverified as the rest of the payload, and a + * renamed key is not a reason to give up on a broadcast that is otherwise readable. + */ + private static String payloadOf(Intent intent) { + Bundle extras = intent.getExtras(); + if (extras == null) return null; + String named = asJson(extras.get("weather")); + if (named != null) return named; + for (String key : extras.keySet()) { + String candidate = asJson(extras.get(key)); + if (candidate != null) return candidate; + } + return null; + } + + private static String asJson(Object value) { + if (!(value instanceof String)) return null; + String text = ((String) value).trim(); + return text.startsWith("{") ? text : null; + } + + /** + * The first number under a key whose name mentions [needle], at any depth. + * + * A search rather than a path: the payload's own field names are the only thing here that + * is self-describing, and they survive a layout change that a field order would not. + */ + private static Double findNumber(Object node, String needle, int depth) { + if (depth > MAX_DEPTH) return null; + if (node instanceof JSONObject) { + JSONObject object = (JSONObject) node; + for (java.util.Iterator keys = object.keys(); keys.hasNext(); ) { + String key = keys.next(); + Object value = object.opt(key); + if (key.toLowerCase(java.util.Locale.US).contains(needle)) { + Double number = asNumber(value); + if (number != null) return number; + } + Double nested = findNumber(value, needle, depth + 1); + if (nested != null) return nested; + } + } else if (node instanceof JSONArray) { + JSONArray array = (JSONArray) node; + for (int i = 0; i < array.length(); i++) { + Double nested = findNumber(array.opt(i), needle, depth + 1); + if (nested != null) return nested; + } + } + return null; + } + + /** Numbers arrive as numbers or as strings depending on the producer; both count. */ + private static Double asNumber(Object value) { + if (value instanceof Number) return ((Number) value).doubleValue(); + if (value instanceof String) { + try { + return Double.valueOf(((String) value).trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + /** The unit the payload names, if it names one at all. */ + private static String findUnit(Object node, int depth) { + if (depth > MAX_DEPTH) return null; + if (node instanceof JSONObject) { + JSONObject object = (JSONObject) node; + for (java.util.Iterator keys = object.keys(); keys.hasNext(); ) { + String key = keys.next(); + Object value = object.opt(key); + if (key.toLowerCase(java.util.Locale.US).contains("unit") + && value instanceof String) { + return (String) value; + } + String nested = findUnit(value, depth + 1); + if (nested != null) return nested; + } + } + return null; + } + + /** The place the reading is for — whatever the payload calls it. */ + private static String findText(Object node, int depth) { + if (depth > MAX_DEPTH || !(node instanceof JSONObject)) return null; + JSONObject object = (JSONObject) node; + for (java.util.Iterator keys = object.keys(); keys.hasNext(); ) { + String key = keys.next(); + String lower = key.toLowerCase(java.util.Locale.US); + Object value = object.opt(key); + if ((lower.contains("address") || lower.contains("city") || lower.contains("poi")) + && value instanceof String && !((String) value).trim().isEmpty()) { + return ((String) value).trim(); + } + String nested = findText(value, depth + 1); + if (nested != null) return nested; + } + return null; + } + + /** + * Celsius, or null when the payload named a unit this does not know. + * + * An unnamed unit is taken as Celsius: the head unit displays °C on this car, and the + * value is sanity-checked by the caller anyway. A named Fahrenheit is converted, because + * passing 81 °F through as 81 °C is worse than reporting nothing. + */ + private static Float toCelsius(Double value, String unit) { + if (value == null) return null; + String name = unit == null ? "" : unit.trim().toUpperCase(java.util.Locale.US); + if (name.contains("F")) return (float) ((value - 32.0) * 5.0 / 9.0); + return value.floatValue(); + } + + private static String truncate(String payload) { + return payload.length() <= RAW_SAMPLE_CHARS + ? payload + : payload.substring(0, RAW_SAMPLE_CHARS) + "…"; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java new file mode 100644 index 0000000..22bb3eb --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -0,0 +1,965 @@ +package com.evsuite.abrp; + +import android.Manifest; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.switchmaterial.SwitchMaterial; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; +import com.evsuite.hardware.EVHardware; +import com.evsuite.hardware.FirmwareInfo; +import com.evsuite.hardware.telemetry.EnergySnapshot; +import com.evsuite.hardware.telemetry.EnergyTelemetryReader; + + +public class MainActivity extends AppCompatActivity { + + private static final int LOCATION_PERMISSION_REQUEST = 100; + /** Shared with {@link AbrpUploadService}, which reads it on every tick. */ + static final String WIFI_AUTO_KEY = "wifi_auto_connect"; + private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/EVABRP"; + + /** Top-bar tabs, in page order. Parallel to {@link #panes}. */ + private static final int[] TAB_IDS = + { R.id.tabAbrp, R.id.tabService, R.id.tabVehicle, R.id.tabWifi, R.id.tabLog }; + + // Status colours come from the palette, not from the Material swatches: #4CAF50 and + // #F44336 sit around 4:1 on this background, which disappears behind a sunlit + // reflection. The palette entries are the lightened variants (7:1 and above). + private static final int COLOR_OK = 0xFF8FE6A6; + private static final int COLOR_ERROR = 0xFFFF9A9A; + private static final int COLOR_PENDING = 0xFFC6CFD8; + + private TextInputLayout apiKeyLayout; + private TextInputEditText apiKeyInput; + private TextInputLayout tokenLayout; + private TextInputEditText tokenInput; + private SwitchMaterial serviceSwitch; + private SwitchMaterial autostartSwitch; + private TextView statusText; + private Button testButton; + private View connectionStatusRow; + private View connectionIndicator; + private TextView connectionStatusText; + private Spinner intervalSpinner; + private SwitchMaterial boostSwitch; + private TextInputEditText lowSocInput; + private TextInputLayout lowSocLayout; + private TextView callLogText; + private View abrpPane; + private View servicePane; + private View logPane; + private View vehiclePane; + private View wifiPane; + + /** The five pages, in the order the top bar lists them. Parallel to {@link #TAB_IDS}. */ + private View[] panes; + + /** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */ + private WifiAutoConnect wifi; + private TextView wifiSsid, wifiState, wifiSavedList; + + /** + * Value cells of the Vehicle page, looked up once and refreshed on the UI tick. + * + * Read straight from {@link EnergyTelemetryReader} rather than from the uploader: the + * page shows what the car reports, whether or not the upload service is running, and a + * signal the vehicle does not publish must show a dash rather than a zero. + */ + private EnergyTelemetryReader vehicleReader; + private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer, + vehCharging, vehBattTemp, vehExtTemp, vehCabinTemp, vehHvac, + vehCapacity, vehSoe, + vehTireFl, vehTireFr, vehTireRl, vehTireRr; + + /** + * Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout + * params ViewPager2 requires of its items — inflating detached leaves them null, and + * the pager throws rather than guessing. + */ + private View inflatePane(int layoutRes) { + View pane = getLayoutInflater().inflate(layoutRes, null, false); + pane.setLayoutParams(new android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT)); + return pane; + } + + /** + * Wires the top bar to the pager: every tab is a page, and every page is a tab. + * + * Each page has its own view type, so the pager asks for it once and keeps it: these + * three views are the activity's own, held in {@link #panes}, not rows to be recycled. + */ + private void setUpPager() { + ViewPager2 pager = findViewById(R.id.content); + pager.setAdapter(new RecyclerView.Adapter() { + @Override public int getItemCount() { return panes.length; } + @Override public int getItemViewType(int position) { return position; } + + @NonNull @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull android.view.ViewGroup parent, + int viewType) { + return new RecyclerView.ViewHolder(panes[viewType]) {}; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + // Nothing to bind: the pages are built once in onCreate and keep their own + // state, exactly as they did when they were siblings in the layout. + } + }); + // All four stay alive. They are cheap, and the service switch, the call log and + // the vehicle readings are refreshed by the tick whether or not their page is on screen. + pager.setOffscreenPageLimit(4); + pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override public void onPageSelected(int position) { markCurrentPage(position); } + }); + + for (int i = 0; i < TAB_IDS.length; i++) { + final int index = i; + // Animated, so the button does the same thing the swipe does: the direction of + // travel is what tells the driver where they are in the row. + findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, true)); + } + markCurrentPage(pager.getCurrentItem()); + } + + /** + * Marks the top-bar tab of the page on screen. + * + * Only isSelected is set: fill, text, icon and stroke come from the + * res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected + * also serves TalkBack, which announces the current destination. + */ + private void markCurrentPage(int position) { + for (int i = 0; i < TAB_IDS.length; i++) { + findViewById(TAB_IDS[i]).setSelected(i == position); + } + } + + /** Refreshes state + call log while the screen is visible. */ + private final android.os.Handler uiHandler = new android.os.Handler(android.os.Looper.getMainLooper()); + private final Runnable uiRefresh = new Runnable() { + @Override public void run() { + refreshStatus(); + refreshCallLog(); + refreshVehicle(); + refreshWifi(); + uiHandler.postDelayed(this, 2_000L); + } + }; + + private SharedPreferences prefs; + /** Credentials only — encrypted at rest, see {@link SecurePrefs}. */ + private SharedPreferences securePrefs; + + /** + * Picks an ABRP config text file. Only reachable on devices that ship a document picker — + * the MG4 head unit does not, which is why {@link #startConfigImport()} scans the + * app-specific folders first and only falls back to this. + */ + private final ActivityResultLauncher configPicker = + registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> { + if (uri != null) importConfig(uri); + }); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE); + securePrefs = SecurePrefs.get(this); + + // The three pages are inflated here, once, and handed to the pager as fixed, + // non-recycled items. That is what lets every widget below be looked up now and + // held for the life of the activity, the way it was when the panes were siblings + // in activity_main.xml — a recycled page would invalidate these references as + // soon as the user swiped away from it. + abrpPane = inflatePane(R.layout.pane_abrp); + servicePane = inflatePane(R.layout.pane_service); + vehiclePane = inflatePane(R.layout.pane_vehicle); + wifiPane = inflatePane(R.layout.pane_wifi); + logPane = inflatePane(R.layout.pane_log); + panes = new View[] { abrpPane, servicePane, vehiclePane, wifiPane, logPane }; + + bindVehiclePane(); + bindWifiPane(); + + apiKeyLayout = abrpPane.findViewById(R.id.api_key_layout); + apiKeyInput = abrpPane.findViewById(R.id.api_key_input); + tokenLayout = abrpPane.findViewById(R.id.token_layout); + tokenInput = abrpPane.findViewById(R.id.token_input); + serviceSwitch = servicePane.findViewById(R.id.service_switch); + autostartSwitch = servicePane.findViewById(R.id.autostart_switch); + statusText = servicePane.findViewById(R.id.status_text); + testButton = abrpPane.findViewById(R.id.test_button); + connectionStatusRow = abrpPane.findViewById(R.id.connection_status_row); + connectionIndicator = abrpPane.findViewById(R.id.connection_indicator); + connectionStatusText = abrpPane.findViewById(R.id.connection_status_text); + intervalSpinner = servicePane.findViewById(R.id.interval_spinner); + boostSwitch = servicePane.findViewById(R.id.boost_switch); + lowSocInput = servicePane.findViewById(R.id.low_soc_input); + lowSocLayout = servicePane.findViewById(R.id.low_soc_layout); + callLogText = logPane.findViewById(R.id.call_log_text); + + setUpPager(); + findViewById(R.id.about_button).setOnClickListener(v -> showAbout()); + + bindCadenceControls(); + + // Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is + // a no-op and does not even contain the updater. + UpdateHook.checkInBackground(this); + + apiKeyInput.setText(savedApiKeyOrDefault()); + tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, "")); + serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false)); + + autostartSwitch.setChecked( + prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART)); + autostartSwitch.setOnCheckedChangeListener((btn, checked) -> + prefs.edit().putBoolean(UploadSettings.KEY_AUTOSTART, checked).apply()); + + // If the user wants the service running, kick it on every activity launch. + // startForegroundService is idempotent — if the service is already up this + // is a no-op aside from delivering a new intent. We don't trust + // service_running as a sole indicator because it can be stale-true if a + // previous process was force-killed (e.g. by `adb install -r`) without + // onDestroy running. + boolean enabled = prefs.getBoolean("service_enabled", false); + boolean haveCreds = !securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty() + && !securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty(); + if (enabled && haveCreds) { + // Asked for first, and deliberately: the service declares the `location` + // foreground type, and since API 34 starting it without the permission behind + // that type is a SecurityException in its own onCreate. Started this way round, + // the very first launch after an install could never bring the uploader up. + requestLocationPermissionIfNeeded(); + startForegroundService(new Intent(this, AbrpUploadService.class)); + } + + abrpPane.findViewById(R.id.save_button).setOnClickListener(v -> saveCredentials()); + testButton.setOnClickListener(v -> testConnection()); + abrpPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport()); + wireProbeIfDebug(); + + serviceSwitch.setOnCheckedChangeListener((btn, checked) -> { + if (checked) { + String apiKey = textOf(apiKeyInput); + String token = textOf(tokenInput); + if (apiKey.isEmpty() || token.isEmpty()) { + serviceSwitch.setChecked(false); + if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required)); + if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required)); + return; + } + apiKeyLayout.setError(null); + tokenLayout.setError(null); + prefs.edit().putBoolean("service_enabled", true).apply(); + startForegroundService(new Intent(this, AbrpUploadService.class)); + requestLocationPermissionIfNeeded(); + } else { + prefs.edit().putBoolean("service_enabled", false).apply(); + stopService(new Intent(this, AbrpUploadService.class)); + } + refreshStatus(); + }); + } + + private void showAbout() { + String version; + try { + version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName; + } catch (PackageManager.NameNotFoundException e) { + version = getString(R.string.about_version_unknown); + } + View content = getLayoutInflater().inflate(R.layout.dialog_about, null); + content.findViewById(R.id.about_version) + .setText(getString(R.string.about_version, version)); + ImageView qr = content.findViewById(R.id.about_qr_code); + android.graphics.Bitmap bitmap = QrCode.generate(REPOSITORY_URL, 416); + if (bitmap != null) qr.setImageBitmap(bitmap); + content.findViewById(R.id.about_repository).setOnClickListener(v -> + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(REPOSITORY_URL)))); + androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setView(content) + .create(); + content.findViewById(R.id.about_close).setOnClickListener(v -> dialog.dismiss()); + dialog.show(); + if (dialog.getWindow() != null) { + dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + } + } + + @Override + protected void onResume() { + super.onResume(); + apiKeyInput.setText(savedApiKeyOrDefault()); + tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, "")); + serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false)); + autostartSwitch.setChecked( + prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART)); + // Poll state and log only while the screen is up; onPause cancels it. + uiHandler.post(uiRefresh); + } + + // ---------- Credentials ---------- + + /** + * The SAIC open-source gateway publishes this shared ABRP key for compatible clients. + * A user-supplied key saved in encrypted preferences always takes precedence. + */ + private String savedApiKeyOrDefault() { + String savedApiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, ""); + return savedApiKey == null || savedApiKey.trim().isEmpty() + ? getString(R.string.default_abrp_api_key) + : savedApiKey; + } + + private void saveCredentials() { + String apiKey = textOf(apiKeyInput); + String token = textOf(tokenInput); + boolean valid = true; + + if (apiKey.isEmpty()) { + apiKeyLayout.setError(getString(R.string.api_key_required)); + valid = false; + } else { + apiKeyLayout.setError(null); + } + if (token.isEmpty()) { + tokenLayout.setError(getString(R.string.token_required)); + valid = false; + } else { + tokenLayout.setError(null); + } + if (!valid) return; + + securePrefs.edit() + .putString(SecurePrefs.KEY_API_KEY, apiKey) + .putString(SecurePrefs.KEY_TOKEN, token) + .apply(); + + if (serviceSwitch.isChecked()) { + startForegroundService(new Intent(this, AbrpUploadService.class)); + } + refreshStatus(); + } + + // ---------- Config import ---------- + + /** + * The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported + * on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns + * this app's own folder on internal storage and on every mounted volume including a USB + * stick, and those need no storage permission at any API level. The picker stays as the + * fallback for phones and tablets, where the user may keep the file anywhere. + */ + private void startConfigImport() { + java.io.File found = findConfigFile(); + if (found != null) { + importConfig(found); + return; + } + try { + // Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides + // the very file the user is trying to pick. They select it by name instead. + configPicker.launch(new String[]{"*/*"}); + } catch (android.content.ActivityNotFoundException e) { + setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint())); + } + } + + /** + * First readable file in an app-specific folder that parses as a config. Names are not + * filtered: the user copies one file to the folder, whatever they called it. + */ + private java.io.File findConfigFile() { + for (java.io.File dir : getExternalFilesDirs(null)) { + if (dir == null) continue; + java.io.File[] files = dir.listFiles(); + if (files == null) continue; + for (java.io.File f : files) { + if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024 + && !ConfigImport.parse(readText(f)).isEmpty()) { + return f; + } + } + } + return null; + } + + /** File text, or "" when unreadable — callers treat that as "not a config". */ + private String readText(java.io.File file) { + try (java.io.InputStream in = new java.io.FileInputStream(file)) { + return readCapped(in); + } catch (Exception e) { + return ""; + } + } + + /** + * Cap the read so a wrong file (a huge binary picked by mistake) can't be slurped whole + * into memory before we find out it isn't a config. + */ + private String readCapped(java.io.InputStream in) throws java.io.IOException { + java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int n; + while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n); + return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name()); + } + + /** Where the user should drop the file, shown when nothing was found. */ + private String configDirHint() { + java.io.File[] dirs = getExternalFilesDirs(null); + return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : ""; + } + + private void importConfig(java.io.File file) { + String text = readText(file); + if (text.isEmpty()) { + setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read)); + return; + } + applyConfig(text); + } + + private void importConfig(android.net.Uri uri) { + String text; + try (java.io.InputStream in = getContentResolver().openInputStream(uri)) { + if (in == null) throw new java.io.IOException("no stream"); + text = readCapped(in); + } catch (Exception e) { + setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read)); + return; + } + applyConfig(text); + } + + /** + * Applies whatever the config text sets. Credentials go straight to the encrypted store; + * cadence keys go to the plain prefs the same way the manual controls write them. Absent + * keys are left untouched, so a file carrying only the credentials does not wipe a cadence + * the user already tuned. + */ + private void applyConfig(String text) { + ConfigImport config = ConfigImport.parse(text); + if (config.isEmpty()) { + setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty)); + return; + } + + if (config.apiKey != null) { + securePrefs.edit().putString(SecurePrefs.KEY_API_KEY, config.apiKey).apply(); + } + if (config.token != null) { + securePrefs.edit().putString(SecurePrefs.KEY_TOKEN, config.token).apply(); + } + SharedPreferences.Editor edit = prefs.edit(); + if (config.intervalSec != null) { + edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec); + } + if (config.boostLowSoc != null) { + edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc); + } + if (config.lowSocPercent != null) { + edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent); + } + edit.apply(); + + // Re-read every control from prefs so the screen shows what was imported. + apiKeyInput.setText(savedApiKeyOrDefault()); + tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, "")); + apiKeyLayout.setError(null); + tokenLayout.setError(null); + bindCadenceControls(); + AbrpUploadService.reloadSettings(); + + if (serviceSwitch.isChecked()) { + startForegroundService(new Intent(this, AbrpUploadService.class)); + } + setConnectionStatus(COLOR_OK, getString(R.string.import_ok)); + refreshStatus(); + } + + // ---------- Connection test ---------- + + private void testConnection() { + String apiKey = textOf(apiKeyInput); + String token = textOf(tokenInput); + + if (apiKey.isEmpty() || token.isEmpty()) { + if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required)); + if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required)); + return; + } + + setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing)); + testButton.setEnabled(false); + + new Thread(() -> { + String result = pingAbrp(apiKey, token); + runOnUiThread(() -> { + testButton.setEnabled(true); + if (result == null) { + setConnectionStatus(COLOR_OK, getString(R.string.conn_ok)); + } else { + setConnectionStatus(COLOR_ERROR, result); + } + }); + }).start(); + } + + /** + * Sends a minimal test request to the ABRP API. + * Returns null on success, or a short error string on failure. + */ + private String pingAbrp(String apiKey, String token) { + try { + // Read-only check. The previous version POSTed {"utc":...,"soc":0} to the LIVE + // /tlm/send endpoint, i.e. told ABRP the car was at 0% every time the user + // pressed Test, wrecking the route plan it had computed. + AbrpApi.Response response = AbrpApi.verifyCredentials(apiKey, token); + + if (response.code == 200) return null; + if (response.code == 401) return getString(R.string.conn_err_auth); + return getString(R.string.conn_err_http, response.code); + + } catch (java.net.UnknownHostException e) { + return getString(R.string.conn_err_no_internet); + } catch (java.net.SocketTimeoutException e) { + return getString(R.string.conn_err_timeout); + } catch (Exception e) { + return e.getMessage(); + } + } + + @Override + protected void onPause() { + super.onPause(); + // Stop polling off-screen: this activity has no reason to spend cycles then. + uiHandler.removeCallbacks(uiRefresh); + } + + // ---------- Cadence configuration ---------- + + private void bindCadenceControls() { + UploadSettings current = UploadSettings.from(prefs); + + String[] labels = new String[UploadSettings.INTERVAL_CHOICES_SEC.length]; + int selected = 0; + for (int i = 0; i < UploadSettings.INTERVAL_CHOICES_SEC.length; i++) { + int sec = UploadSettings.INTERVAL_CHOICES_SEC[i]; + labels[i] = sec >= 60 + ? getResources().getQuantityString(R.plurals.interval_minutes, sec / 60, sec / 60) + : getResources().getQuantityString(R.plurals.interval_seconds, sec, sec); + if (sec == current.intervalSec) selected = i; + } + ArrayAdapter adapter = new ArrayAdapter<>( + this, android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + intervalSpinner.setAdapter(adapter); + intervalSpinner.setSelection(selected); + intervalSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC, + UploadSettings.INTERVAL_CHOICES_SEC[position]).apply(); + // Takes effect on the next cycle — no service restart needed. + AbrpUploadService.reloadSettings(); + } + @Override public void onNothingSelected(AdapterView parent) { } + }); + + boostSwitch.setChecked(current.boostLowSoc); + lowSocLayout.setEnabled(current.boostLowSoc); + lowSocInput.setEnabled(current.boostLowSoc); + boostSwitch.setOnCheckedChangeListener((button, checked) -> { + prefs.edit().putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, checked).apply(); + lowSocLayout.setEnabled(checked); + lowSocInput.setEnabled(checked); + AbrpUploadService.reloadSettings(); + }); + + lowSocInput.setText(String.valueOf(current.lowSocPercent)); + lowSocInput.setOnFocusChangeListener((view, hasFocus) -> { + if (!hasFocus) saveLowSocThreshold(); + }); + } + + /** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */ + private void saveLowSocThreshold() { + CharSequence raw = lowSocInput.getText(); + int value; + try { + value = Integer.parseInt(raw == null ? "" : raw.toString().trim()); + } catch (NumberFormatException e) { + value = UploadSettings.DEFAULT_LOW_SOC_PERCENT; + } + value = Math.max(1, Math.min(99, value)); + lowSocInput.setText(String.valueOf(value)); + prefs.edit().putInt(UploadSettings.KEY_LOW_SOC_PERCENT, value).apply(); + AbrpUploadService.reloadSettings(); + } + + // ---------- Wi-Fi page ---------- + + private void bindWifiPane() { + wifi = new WifiAutoConnect(getApplicationContext()); + wifiSsid = wifiPane.findViewById(R.id.wifi_ssid); + wifiState = wifiPane.findViewById(R.id.wifi_state); + wifiSavedList = wifiPane.findViewById(R.id.wifi_saved_list); + + SwitchMaterial auto = wifiPane.findViewById(R.id.wifi_auto_switch); + auto.setChecked(prefs.getBoolean(WIFI_AUTO_KEY, true)); + auto.setOnCheckedChangeListener((btn, checked) -> + prefs.edit().putBoolean(WIFI_AUTO_KEY, checked).apply()); + + wifiPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> { + // force: the driver is asking now, so the rate limit does not apply. + WifiAutoConnect.Result r = wifi.ensureConnected(true); + wifiState.setText(r.message); + refreshWifi(); + }); + } + + private void refreshWifi() { + if (wifi == null) return; + String ssid = wifi.currentSsid(); + wifiSsid.setText(ssid == null ? getString(R.string.wifi_not_connected) : ssid); + if (ssid != null) { + wifiState.setText("Bağlı ♥"); + } else if (!wifi.isWifiEnabled()) { + wifiState.setText("Wi-Fi kapalı"); + } + // Left alone otherwise: a message the button just set says more about what is + // happening than "not connected" would. + + java.util.List saved = wifi.savedNetworkNames(); + wifiSavedList.setText(saved.isEmpty() + ? getString(R.string.wifi_saved_empty) + : android.text.TextUtils.join("\n", saved)); + } + + // ---------- Vehicle page ---------- + + /** Looks up the value cells once; the labels are static and never touched from code. */ + private void bindVehiclePane() { + vehSoc = vehiclePane.findViewById(R.id.vehicle_soc); + vehRange = vehiclePane.findViewById(R.id.vehicle_range); + vehPower = vehiclePane.findViewById(R.id.vehicle_power); + vehSpeed = vehiclePane.findViewById(R.id.vehicle_speed); + vehOdometer = vehiclePane.findViewById(R.id.vehicle_odometer); + vehCharging = vehiclePane.findViewById(R.id.vehicle_charging); + vehBattTemp = vehiclePane.findViewById(R.id.vehicle_batt_temp); + vehExtTemp = vehiclePane.findViewById(R.id.vehicle_ext_temp); + vehCabinTemp = vehiclePane.findViewById(R.id.vehicle_cabin_temp); + vehHvac = vehiclePane.findViewById(R.id.vehicle_hvac); + vehCapacity = vehiclePane.findViewById(R.id.vehicle_capacity); + vehSoe = vehiclePane.findViewById(R.id.vehicle_soe); + vehTireFl = vehiclePane.findViewById(R.id.vehicle_tire_fl); + vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr); + vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl); + vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr); + } + + /** + * Reads one snapshot and writes it to the page. + * + * The reader is built lazily and kept: constructing it binds the vendor services, which + * is not work to repeat every two seconds. A read that throws leaves the cells as they + * were rather than blanking the page — a transient miss is not news worth showing. + */ + private void refreshVehicle() { + try { + if (vehicleReader == null) { + vehicleReader = new EnergyTelemetryReader(getApplicationContext()); + } + // The timestamp is passed explicitly: its Kotlin default is not visible from Java. + EnergySnapshot s = vehicleReader.read(System.currentTimeMillis()); + + vehSoc.setText(fmt(s.getSocPercent(), "%.0f %%")); + vehRange.setText(fmt(s.getRangeKm(), "%.0f km")); + vehPower.setText(fmt(s.getBatteryPowerKw(), "%.1f kW")); + vehSpeed.setText(fmt(s.getSpeedKmh(), "%.0f km/h")); + vehOdometer.setText(fmt(s.getOdometerKm(), "%.0f km")); + vehBattTemp.setText(fmt(s.getBatteryTempCelsius(), "%.0f °C")); + vehExtTemp.setText(fmt(s.getOutsideTempCelsius(), "%.0f °C")); + vehCabinTemp.setText(fmt(s.getCabinTempCelsius(), "%.0f °C")); + vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C")); + vehCapacity.setText(fmt(s.getBatteryCapacityKwh(), "%.1f kWh")); + vehSoe.setText(fmt(s.getBatteryEnergyKwh(), "%.1f kWh")); + + vehCharging.setText(chargingLabel(s)); + + vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa")); + vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa")); + vehTireRl.setText(fmt(s.getTirePressures().getRearLeftKpa(), "%.0f kPa")); + vehTireRr.setText(fmt(s.getTirePressures().getRearRightKpa(), "%.0f kPa")); + } catch (Throwable ignored) { + // Leave the last good reading on screen. + } + } + + /** + * The charging row: the session state when the car reports one, the port otherwise. + * + * The codes are the vehicle's own — see EVHardware.PROP_VENDOR_CHARGE_STATUS. An + * unrecognised code is shown as itself rather than guessed at. + */ + private static String chargingLabel(EnergySnapshot s) { + Integer status = s.getChargingStatus(); + if (status != null) { + switch (status) { + case 1: return "AC şarj ♥"; + case 10: return "DC hızlı şarj ♥"; + case 5: return "Bağlanıyor…"; + case 7: return "Kabloda, şarj yok"; + case 8: return "Durduruldu"; + case 0: return "Şarj yok"; + default: return "Durum " + status; + } + } + Boolean plugged = s.getChargePortConnected(); + if (plugged != null) return plugged ? "Bağlı ♥" : "Bağlı değil"; + return "—"; + } + + /** A dash for a signal the car does not publish — never a zero. See pane_vehicle.xml. */ + private static String fmt(Float value, String pattern) { + if (value == null || value.isNaN() || value.isInfinite()) return "—"; + return String.format(java.util.Locale.US, pattern, value); + } + + // ---------- Call log ---------- + + private void refreshCallLog() { + java.util.List entries = AbrpUploadService.log().recent(); + if (entries.isEmpty()) { + callLogText.setText(R.string.call_log_empty); + return; + } + java.text.SimpleDateFormat format = + new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US); + StringBuilder sb = new StringBuilder(); + for (UploadLog.Entry entry : entries) { + if (sb.length() > 0) sb.append('\n'); + sb.append(entry.success ? "OK " : "ERR ") + .append(format.format(new java.util.Date(entry.timestampMs))) + .append(" ") + // httpStatus 0 means the request never reached a server. + .append(entry.httpStatus > 0 ? String.valueOf(entry.httpStatus) : "---") + .append(" ") + .append(entry.detail); + // What the payload held, under the attempt it belongs to. This is the only view + // of it the driver has: the car runs no adb, so "the field is 0 on ABRP" and + // "the app never sent the field" look identical from the outside without it. + if (entry.summary != null && !entry.summary.isEmpty()) { + sb.append("\n ").append(entry.summary); + } + } + callLogText.setText(sb.toString()); + } + + // ---------- VHAL probe ---------- + + /** + * Wires the "VHAL Probe" button that dumps the vehicle's own telemetry property + * surface into the in-app log. The head unit runs no adb, so the screen's existing + * log view is the only channel a diagnostic can reach the driver through. + * + * Present in every build, not just debug: identifying the SWI69 property ids needs to + * happen on a production APK on the car, and EVHardware's probe is read-only, so it is + * safe to ship. It deliberately holds no device credentials. Keep it until the + * per-generation property map for SWI69 is confirmed, then gate it on debug. + */ + private void wireProbeIfDebug() { + View probeButton = abrpPane.findViewById(R.id.probe_button); + if (probeButton == null) return; + probeButton.setVisibility(View.VISIBLE); + probeButton.setOnClickListener(v -> runProbe()); + } + + private void runProbe() { + // Binder reads are slow enough to be worth keeping off the UI thread; the result + // is recorded through the upload log, which the screen already renders. + new Thread(() -> { + try { + EVHardware.INSTANCE.init(getApplicationContext()); + java.util.List reports = EVHardware.INSTANCE.probeTelemetryProperties(); + String firmware = String.valueOf(FirmwareInfo.INSTANCE.getGeneration()); + StringBuilder sb = new StringBuilder("VHAL probe on ").append(firmware).append('\n'); + for (EVHardware.PropertyReport r : reports) sb.append(" ").append(r).append('\n'); + sb.append("TOTAL ").append(reports.size()).append(" properties probed"); + runOnUiThread(() -> AbrpUploadService.log().record( + new UploadLog.Entry(System.currentTimeMillis(), 200, true, "VHAL Probe", sb.toString()))); + } catch (Throwable t) { + String msg = "Probe failed: " + (t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName()); + runOnUiThread(() -> AbrpUploadService.log().record( + new UploadLog.Entry(System.currentTimeMillis(), 0, false, "VHAL Probe", msg))); + } + }).start(); + } + + private void setConnectionStatus(int color, String message) { + connectionStatusRow.setVisibility(View.VISIBLE); + connectionIndicator.getBackground().setTint(color); + connectionStatusText.setText(message); + connectionStatusText.setTextColor(color); + } + + // ---------- Service status ---------- + + private void refreshStatus() { + // Live signal, not the preference: "service_running" stays stale-true after a + // force-kill because onDestroy never ran. + UploadLog.State state = AbrpUploadService.state(); + + switch (state) { + case ERROR: + // Derived from the log, not from the last status alone: a single failed + // upload is normal (tunnel, dead spot), three in a row is a problem. + statusText.setText(getString(R.string.state_error, + AbrpUploadService.log().consecutiveFailures())); + statusText.setTextColor(COLOR_ERROR); + break; + case RUNNING: + String lastTime = prefs.getString("last_upload_time", null); + statusText.setText(lastTime != null + ? getString(R.string.status_last_upload, lastTime, + getString(R.string.status_ok)) + : getString(R.string.state_running)); + statusText.setTextColor(COLOR_OK); + break; + case STARTING: + statusText.setText(R.string.state_starting); + statusText.setTextColor(COLOR_OK); + break; + case STOPPED: + default: + statusText.setText(R.string.state_stopped); + statusText.setTextColor(COLOR_PENDING); + break; + } + } + + // ---------- Helpers ---------- + + private String textOf(TextInputEditText field) { + CharSequence text = field.getText(); + return text != null ? text.toString().trim() : ""; + } + + /** + * Permissions that need a runtime grant (vs being granted at install time + * via the platform signature). CAR_SPEED and CAR_ENERGY are dangerous-level + * AAOS permissions — without these, the corresponding property reads throw + * SecurityException and we have no SOC / speed data. + */ + private static final String[] RUNTIME_PERMISSIONS = { + Manifest.permission.ACCESS_FINE_LOCATION, + "android.car.permission.CAR_SPEED", + "android.car.permission.CAR_ENERGY", + "android.car.permission.CAR_ENERGY_PORTS", + // Outside temperature and cabin temperature: declared and read since 2.1.0, but + // never asked for, so both came back unreadable and were dropped from every + // payload on a car that would have answered them. + "android.car.permission.CAR_EXTERIOR_ENVIRONMENT", + "android.car.permission.CONTROL_CAR_CLIMATE", + }; + + /** + * Asked for alongside the telemetry permissions from API 33, and separately because a + * denial costs something different: the foreground notification is the only status this + * service has, and without this it is dropped silently while the service runs on. + */ + private static String[] runtimePermissions() { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + return RUNTIME_PERMISSIONS; + } + String[] all = java.util.Arrays.copyOf(RUNTIME_PERMISSIONS, RUNTIME_PERMISSIONS.length + 1); + all[RUNTIME_PERMISSIONS.length] = Manifest.permission.POST_NOTIFICATIONS; + return all; + } + + private void requestLocationPermissionIfNeeded() { + java.util.List missing = new java.util.ArrayList<>(); + for (String p : runtimePermissions()) { + if (ContextCompat.checkSelfPermission(this, p) + != PackageManager.PERMISSION_GRANTED) { + missing.add(p); + } + } + if (!missing.isEmpty()) { + ActivityCompat.requestPermissions(this, + missing.toArray(new String[0]), + LOCATION_PERMISSION_REQUEST); + } + } + + /** + * A denied permission used to be silent: the service kept running and simply uploaded + * nothing useful, with the UI claiming everything was fine. Say what was denied and + * what it costs. + */ + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode != LOCATION_PERMISSION_REQUEST) return; + + boolean locationDenied = false; + boolean carDataDenied = false; + for (int i = 0; i < permissions.length && i < grantResults.length; i++) { + if (grantResults[i] == PackageManager.PERMISSION_GRANTED) continue; + if (Manifest.permission.ACCESS_FINE_LOCATION.equals(permissions[i])) { + locationDenied = true; + } else { + carDataDenied = true; + } + } + + // A grant that arrives after the service is already up changes which foreground + // types it may hold, and which properties it may read. Starting it again is what + // applies both without waiting for the next boot. + if (prefs.getBoolean("service_enabled", false)) { + startForegroundService(new Intent(this, AbrpUploadService.class)); + } + + if (locationDenied && carDataDenied) { + setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_all)); + } else if (locationDenied) { + setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_location)); + } else if (carDataDenied) { + setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_car)); + } + refreshStatus(); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/QrCode.java b/app/src/main/java/com/evsuite/abrp/QrCode.java new file mode 100644 index 0000000..a0f128c --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/QrCode.java @@ -0,0 +1,36 @@ +package com.evsuite.abrp; + +import android.graphics.Bitmap; +import android.graphics.Color; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.MultiFormatWriter; +import com.google.zxing.common.BitMatrix; + +import java.util.EnumMap; +import java.util.Map; + +/** Generates the repository QR locally; no network capability is involved. */ +final class QrCode { + private QrCode() {} + + static Bitmap generate(String content, int sizePx) { + try { + Map hints = new EnumMap<>(EncodeHintType.class); + hints.put(EncodeHintType.MARGIN, 1); + hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); + BitMatrix matrix = new MultiFormatWriter().encode( + content, BarcodeFormat.QR_CODE, sizePx, sizePx, hints); + Bitmap bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.RGB_565); + for (int x = 0; x < sizePx; x++) { + for (int y = 0; y < sizePx; y++) { + bitmap.setPixel(x, y, matrix.get(x, y) ? Color.BLACK : Color.WHITE); + } + } + return bitmap; + } catch (Exception ignored) { + return null; + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/SecurePrefs.java b/app/src/main/java/com/evsuite/abrp/SecurePrefs.java new file mode 100644 index 0000000..18a65e6 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/SecurePrefs.java @@ -0,0 +1,70 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKey; + +/** + * Storage for the ABRP credentials (api_key, token). + * + * They used to sit in plaintext in the regular SharedPreferences file, which + * `adb backup` hands out. They now live in an AndroidKeyStore-backed encrypted + * file, and the plaintext copy is wiped on first access. + */ +final class SecurePrefs { + + private static final String TAG = "SecurePrefs"; + private static final String FILE_NAME = "abrp_secure_prefs"; + private static final String PLAIN_NAME = "abrp_prefs"; + + static final String KEY_API_KEY = "api_key"; + static final String KEY_TOKEN = "token"; + + private static SharedPreferences instance; + + private SecurePrefs() { } + + /** + * Encrypted preferences, or the plaintext file if the crypto stack is + * unavailable. Falling back keeps the app usable on a device with a broken + * keystore instead of crashing it in a vehicle. + */ + static synchronized SharedPreferences get(Context context) { + if (instance != null) return instance; + Context app = context.getApplicationContext(); + try { + MasterKey masterKey = new MasterKey.Builder(app) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(); + instance = EncryptedSharedPreferences.create( + app, + FILE_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM); + migratePlaintext(app, instance); + } catch (Exception e) { + Log.e(TAG, "Encrypted preferences unavailable, falling back to plaintext", e); + instance = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE); + } + return instance; + } + + /** Moves credentials written by an older version, then deletes the plaintext copy. */ + private static void migratePlaintext(Context context, SharedPreferences secure) { + SharedPreferences plain = context.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE); + String apiKey = plain.getString(KEY_API_KEY, null); + String token = plain.getString(KEY_TOKEN, null); + if (apiKey == null && token == null) return; + + SharedPreferences.Editor secureEdit = secure.edit(); + if (apiKey != null) secureEdit.putString(KEY_API_KEY, apiKey); + if (token != null) secureEdit.putString(KEY_TOKEN, token); + secureEdit.commit(); + + plain.edit().remove(KEY_API_KEY).remove(KEY_TOKEN).commit(); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/TelemetryPayload.java b/app/src/main/java/com/evsuite/abrp/TelemetryPayload.java new file mode 100644 index 0000000..3200dea --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/TelemetryPayload.java @@ -0,0 +1,205 @@ +package com.evsuite.abrp; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Builds the ABRP telemetry JSON payload. + * + * Kept free of Android types so it can be unit-tested on the JVM: the caller + * pulls the values out of the car adapter and the Location, this class only + * decides what ends up in the payload. + * + * Every vehicle field is nullable and a null field is OMITTED. A property that + * cannot be read is not a zero: sending soc:0 because a getter threw tells ABRP + * the battery is empty and wrecks the user's route plan. + * + * Fields are set by name rather than passed positionally. The payload now carries + * 24 optional values and a positional build(...) call had become a wall of nulls + * that no reader could check against the ABRP field list. + */ +final class TelemetryPayload { + + /** Only value that is always present. Epoch SECONDS, not milliseconds. */ + private final long utc; + + // --- High priority (ABRP: "many features will only be usable with enough data") --- + Integer soc; // % + Float speedKmh; // km/h + Float powerKw; // kW, ABRP sign: positive leaving the battery + Boolean charging; + Boolean dcfc; + Boolean parked; + Double lat; // ° + Double lon; // ° + + // --- Lower priority --- + Integer rangeKm; // km, vehicle's own estimate + Float extTemp; // °C, outside + Float cabinTemp; // °C + Float battTempC; // °C, traction battery + Float capacityKwh; // kWh, usable pack capacity + Float soeKwh; // kWh, energy currently in the pack + Double kwhCharged; // kWh, taken by the pack during the current charge session + Float odometerKm; // km + Float hvacSetpointC; // °C, climate setpoint + Float tirePressureFlKpa; + Float tirePressureFrKpa; + Float tirePressureRlKpa; + Float tirePressureRrKpa; + Double elevation; // m + Float heading; // ° + + TelemetryPayload(long utc) { + this.utc = utc; + } + + /** Build the {@code tlm} JSON object. Only utc is always present. */ + String build() { + JSONObject tlm = new JSONObject(); + try { + tlm.put("utc", utc); + + putIfPresent(tlm, "soc", soc); + putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(speedKmh)); + putIfPresent(tlm, "est_battery_range", rangeKm); + // Outside temp gets the same plausibility guard as the other temperatures: + // a VHAL that does not implement ENV_OUTSIDE_TEMPERATURE answers 0.0, and + // ABRP treats "0 °C outside" as a real reading that costs range in its plan. + // A genuine 0 °C is lost with it, which is the cheaper of the two errors. + if (isPlausibleTemp(extTemp)) { + tlm.put("ext_temp", Math.round(extTemp)); + } + if (powerKw != null) tlm.put("power", round2(powerKw)); + putIfPresent(tlm, "is_charging", boolToInt(charging)); + putIfPresent(tlm, "is_dcfc", boolToInt(dcfc)); + putIfPresent(tlm, "is_parked", boolToInt(parked)); + + // Only send cabin_temp if we got a plausible reading — 0.0 likely + // means the property isn't supported on this VHAL. + if (isPlausibleTemp(cabinTemp)) { + tlm.put("cabin_temp", Math.round(cabinTemp)); + } + // Same guard for the pack: a VHAL that does not implement the property + // answers 0.0, and "battery at 0 °C" is a reading ABRP acts on. + if (isPlausibleTemp(battTempC)) { + tlm.put("batt_temp", Math.round(battTempC)); + } + // hvac_setpoint is a user setting, so 0 °C is not a real value either — + // no cabin climate control is ever set to freezing. + if (isPlausibleTemp(hvacSetpointC)) { + tlm.put("hvac_setpoint", Math.round(hvacSetpointC)); + } + + // A pack cannot hold 0 kWh and a car cannot have driven 0 km by the time + // it runs this app: both zeros mean "property not implemented". + if (capacityKwh != null && capacityKwh > 0f) { + tlm.put("capacity", round2(capacityKwh)); + } + if (odometerKm != null && odometerKm > 0f) { + tlm.put("odometer", Math.round(odometerKm)); + } + // Same reasoning as capacity: a car that is running this app has some energy + // in its pack, so 0 kWh is the VHAL saying it does not implement the property. + if (soeKwh != null && soeKwh > 0f) { + tlm.put("soe", round2(soeKwh)); + } + // 0.0 IS meaningful here — it is a charge session that has just begun — so + // This one is guarded on null only. EVHardware's charging-session meter sends + // null between sessions. + if (kwhCharged != null) { + tlm.put("kwh_charged", kwhCharged.doubleValue()); + } + + putIfPresent(tlm, "tire_pressure_fl", kpa(tirePressureFlKpa)); + putIfPresent(tlm, "tire_pressure_fr", kpa(tirePressureFrKpa)); + putIfPresent(tlm, "tire_pressure_rl", kpa(tirePressureRlKpa)); + putIfPresent(tlm, "tire_pressure_rr", kpa(tirePressureRrKpa)); + + if (lat != null && lon != null) { + tlm.put("lat", lat.doubleValue()); + tlm.put("lon", lon.doubleValue()); + putIfPresent(tlm, "elevation", elevation == null ? null : Math.round(elevation)); + putIfPresent(tlm, "heading", heading == null ? null : Math.round(heading)); + } + } catch (JSONException e) { + // JSONObject.put only throws on NaN/Infinity values, which the guards above + // already exclude. Returning just the timestamp keeps the upload alive. + return "{\"utc\":" + utc + "}"; + } + return tlm.toString(); + } + + /** + * True for a temperature that a sensor could actually have measured. Excludes exactly + * 0.0, which is what an unimplemented VHAL property returns — see cabin_temp above. + * + * Package-private because the service asks the same question before deciding whether to + * look for an outside temperature somewhere other than the car. + */ + static boolean isPlausibleTemp(Float celsius) { + return celsius != null && celsius > -50f && celsius < 80f && celsius != 0f; + } + + /** + * Rounded kPa, or null when the reading is outside anything a road tyre reports. + * A flat tyre still reads well above zero, so 0 means the wheel has no sensor. + */ + private static Integer kpa(Float pressure) { + if (pressure == null || pressure < 50f || pressure > 500f) return null; + return Math.round(pressure); + } + + private static void putIfPresent(JSONObject json, String key, Object value) + throws JSONException { + if (value != null) json.put(key, value); + } + + private static Integer boolToInt(Boolean value) { + return value == null ? null : (value ? 1 : 0); + } + + /** ABRP expects kW with 2 decimals; avoids a locale-dependent String.format. */ + private static double round2(float value) { + return Math.round(value * 100.0) / 100.0; + } + + /** + * Every field {@link #build()} can emit, in the order the summary lists them. + * Used only to work out what was left out — see {@link #summarize(String)}. + */ + private static final String[] ALL_FIELDS = { + "utc", "soc", "speed", "power", "est_battery_range", "ext_temp", "cabin_temp", + "batt_temp", "hvac_setpoint", "capacity", "soe", "kwh_charged", "odometer", + "is_charging", "is_dcfc", "is_parked", "lat", "lon", "elevation", "heading", + "tire_pressure_fl", "tire_pressure_fr", "tire_pressure_rl", "tire_pressure_rr", + }; + + /** + * One-line, human-readable digest of an upload: what was sent, and what was left out. + * + * Derived from the JSON that actually goes on the wire rather than from the fields + * above, so it cannot drift away from the payload it claims to describe — which is + * the whole point of having it when a value on the ABRP side looks wrong. + */ + static String summarize(String tlmJson) { + JSONObject tlm; + try { + tlm = new JSONObject(tlmJson); + } catch (JSONException e) { + return "unparseable payload: " + tlmJson; + } + StringBuilder sent = new StringBuilder(); + StringBuilder omitted = new StringBuilder(); + for (String field : ALL_FIELDS) { + if (tlm.has(field)) { + if (sent.length() > 0) sent.append(' '); + sent.append(field).append('=').append(tlm.opt(field)); + } else { + if (omitted.length() > 0) omitted.append(','); + omitted.append(field); + } + } + return sent + (omitted.length() == 0 ? "" : " | omitted: " + omitted); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/UploadCadence.java b/app/src/main/java/com/evsuite/abrp/UploadCadence.java new file mode 100644 index 0000000..4cf5737 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/UploadCadence.java @@ -0,0 +1,66 @@ +package com.evsuite.abrp; + +/** + * Decides whether a given scheduler tick should actually upload. + * + * The service ticks at a fixed short rate; this class thins the uploads out. A car parked + * overnight and not charging does not need a sample every minute — that is wasted data and + * power — but nothing may be lost while driving or charging, where ABRP needs the + * resolution to keep a route plan accurate. + * + * Free of Android types so the policy can be unit-tested directly. + */ +final class UploadCadence { + + /** + * Parked and not charging: one keep-alive sample every 15 minutes. The car is not + * moving and its SOC is not changing, so anything more is spent for nothing. + */ + static final long PARKED_INTERVAL_MS = 900_000L; + + private UploadCadence() { } + + /** + * @param nowMs current time + * @param lastUploadMs when the last upload was attempted, 0 if never + * @param parked null when the gear could not be read + * @param charging null when the charge port state could not be read + * @param socPercent null when the battery level could not be read + * @param stateChanged true if parked/charging differ from the previous tick + * @param settings user-configured cadence + */ + static boolean shouldUpload(long nowMs, long lastUploadMs, + Boolean parked, Boolean charging, Integer socPercent, + boolean stateChanged, UploadSettings settings) { + // Never uploaded yet: send immediately so ABRP sees the car come online. + if (lastUploadMs <= 0L) return true; + + // A transition (just parked, plugged in, unplugged) is exactly what ABRP must + // learn straight away — never make it wait for the slow interval. + if (stateChanged) return true; + + return nowMs - lastUploadMs >= intervalFor(parked, charging, socPercent, settings); + } + + /** + * Interval that applies in the given state. + * + * Unknown state counts as active: if the gear or charge port could not be read we do + * not know the car is idle, and under-reporting a moving car is worse than a few + * extra requests from a stationary one. + */ + static long intervalFor(Boolean parked, Boolean charging, Integer socPercent, + UploadSettings settings) { + boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging); + if (idle) return PARKED_INTERVAL_MS; + + // Low battery: the driver is picking a charge stop and ABRP needs a tighter SOC + // curve to get the arrival estimate right. Only while actually in use — a car + // parked at 5 % is still parked and gains nothing from fast polling. + if (settings.boostLowSoc && socPercent != null + && socPercent <= settings.lowSocPercent) { + return Math.min(settings.intervalMs(), UploadSettings.BOOSTED_INTERVAL_SEC * 1000L); + } + return settings.intervalMs(); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/UploadLog.java b/app/src/main/java/com/evsuite/abrp/UploadLog.java new file mode 100644 index 0000000..f95b053 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/UploadLog.java @@ -0,0 +1,122 @@ +package com.evsuite.abrp; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +/** + * Rolling record of the last {@link #MAX_ENTRIES} upload attempts, and the service health + * derived from them. + * + * Process-local on purpose: this is a diagnostic view, not persisted state. Written from + * the scheduler thread and read from the UI thread, so every access is synchronized. + */ +final class UploadLog { + + static final int MAX_ENTRIES = 20; + + /** Consecutive failures that flip the service into the error state. */ + static final int FAILURES_FOR_ERROR = 3; + + enum State { + /** Service is off. */ + STOPPED, + /** Running, no upload attempted yet. */ + STARTING, + /** Running and the recent uploads are going through. */ + RUNNING, + /** Running but the last {@link #FAILURES_FOR_ERROR} attempts failed. */ + ERROR + } + + /** One upload attempt. */ + static final class Entry { + final long timestampMs; + /** HTTP status, or 0 when the request never got a response (offline, timeout). */ + final int httpStatus; + final boolean success; + /** Short human-readable reason, e.g. "OK", "No internet", "HTTP 401". */ + final String detail; + /** + * What the payload actually contained — see TelemetryPayload.summarize(). Null for + * an attempt recorded before a payload existed. + * + * This is the whole point of the log for anything other than "is it uploading": + * a value that looks wrong on the ABRP side is either a field this app never sent, + * or one it sent with the wrong number in it, and nothing else distinguishes them. + */ + final String summary; + + Entry(long timestampMs, int httpStatus, boolean success, String detail) { + this(timestampMs, httpStatus, success, detail, null); + } + + Entry(long timestampMs, int httpStatus, boolean success, String detail, String summary) { + this.timestampMs = timestampMs; + this.httpStatus = httpStatus; + this.success = success; + this.detail = detail; + this.summary = summary; + } + } + + private final Deque entries = new ArrayDeque<>(MAX_ENTRIES); + private final Object lock = new Object(); + + void record(Entry entry) { + synchronized (lock) { + while (entries.size() >= MAX_ENTRIES) entries.removeFirst(); + entries.addLast(entry); + } + } + + void clear() { + synchronized (lock) { entries.clear(); } + } + + /** Most recent first — that is the order the UI shows them in. */ + List recent() { + synchronized (lock) { + List copy = new ArrayList<>(entries); + java.util.Collections.reverse(copy); + return copy; + } + } + + Entry latest() { + synchronized (lock) { + return entries.isEmpty() ? null : entries.peekLast(); + } + } + + /** + * Number of failures at the end of the log. Reset by any success, so a single + * recovered upload clears the error state. + */ + int consecutiveFailures() { + synchronized (lock) { + int count = 0; + java.util.Iterator it = entries.descendingIterator(); + while (it.hasNext()) { + if (it.next().success) break; + count++; + } + return count; + } + } + + /** + * Service state. + * + * Needs [running] from the service itself: an empty log means "not started yet" while + * running, but means nothing at all when stopped. + */ + State state(boolean running) { + if (!running) return State.STOPPED; + synchronized (lock) { + if (entries.isEmpty()) return State.STARTING; + } + return consecutiveFailures() >= FAILURES_FOR_ERROR ? State.ERROR : State.RUNNING; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/UploadSettings.java b/app/src/main/java/com/evsuite/abrp/UploadSettings.java new file mode 100644 index 0000000..9634d7a --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/UploadSettings.java @@ -0,0 +1,69 @@ +package com.evsuite.abrp; + +import android.content.SharedPreferences; + +/** + * User-configurable upload policy. + * + * Defaults are deliberately conservative. The head unit is awake whenever the car is on, + * and both the upload tick and the GPS subscription cost power the whole time, so the + * default cadence is the slowest one that still keeps an ABRP route plan usable. + */ +final class UploadSettings { + + static final String KEY_INTERVAL_SEC = "upload_interval_sec"; + static final String KEY_BOOST_LOW_SOC = "boost_low_soc"; + static final String KEY_LOW_SOC_PERCENT = "low_soc_percent"; + static final String KEY_AUTOSTART = "autostart"; + + /** + * On by default: an ABRP plan is only useful if it is fed from the moment the drive + * starts, and nobody opens this app before setting off. Kept as a switch because a + * service that starts itself must also be switchable off. + */ + static final boolean DEFAULT_AUTOSTART = true; + + /** + * Default cadence while driving. 60 s is roughly 1.5 km between points at motorway + * speed, which ABRP can still track; going much slower makes the live plan lag + * reality. Parked and idle backs off far further on its own — see UploadCadence. + */ + static final int DEFAULT_INTERVAL_SEC = 60; + + /** Offered in the UI. Kept short so the list stays a single glance on a car screen. */ + static final int[] INTERVAL_CHOICES_SEC = { 15, 30, 60, 120, 300 }; + + static final boolean DEFAULT_BOOST_LOW_SOC = true; + + /** Below this, range planning gets tight and ABRP needs a finer SOC curve. */ + static final int DEFAULT_LOW_SOC_PERCENT = 20; + + /** Cadence used while SOC is low and the boost is enabled. */ + static final int BOOSTED_INTERVAL_SEC = 15; + + final int intervalSec; + final boolean boostLowSoc; + final int lowSocPercent; + + UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent) { + this.intervalSec = intervalSec; + this.boostLowSoc = boostLowSoc; + this.lowSocPercent = lowSocPercent; + } + + static UploadSettings from(SharedPreferences prefs) { + return new UploadSettings( + prefs.getInt(KEY_INTERVAL_SEC, DEFAULT_INTERVAL_SEC), + prefs.getBoolean(KEY_BOOST_LOW_SOC, DEFAULT_BOOST_LOW_SOC), + prefs.getInt(KEY_LOW_SOC_PERCENT, DEFAULT_LOW_SOC_PERCENT)); + } + + static UploadSettings defaults() { + return new UploadSettings( + DEFAULT_INTERVAL_SEC, DEFAULT_BOOST_LOW_SOC, DEFAULT_LOW_SOC_PERCENT); + } + + long intervalMs() { + return intervalSec * 1000L; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java b/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java new file mode 100644 index 0000000..9dd3c04 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/WifiAutoConnect.java @@ -0,0 +1,158 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.net.wifi.WifiConfiguration; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.util.Log; + +import java.util.List; + +/** + * Brings Wi-Fi up and joins a network the head unit already knows. + * + * The point is the phone hotspot: the car has the credentials saved, but the driver still + * has to walk into Settings and tap the network every time. This does that tap. + * + * Why this works here and would not on a newer phone. {@code getConfiguredNetworks} + * and {@code enableNetwork} were deprecated in API 29 and, from that release, return an + * empty list and refuse the call for ordinary apps — Android handed network choice to the + * user through a system picker. This head unit runs Android 9 (API 28), where both are + * still honoured, and the build is platform-signed, so it is the same UID family the + * Settings app itself uses. Neither fact is portable: on a Pixel this class would find + * nothing and change nothing. It is deliberately written to fail quietly if so. + * + * Nothing here creates or edits a network. It only enables one the unit was already + * configured with, which is why no password ever passes through this app. + */ +final class WifiAutoConnect { + + private static final String TAG = "EVABRP.Wifi"; + + /** Re-checking faster than this achieves nothing: a join takes seconds to settle. */ + private static final long MIN_ATTEMPT_INTERVAL_MS = 30_000L; + + private final WifiManager wifi; + private long lastAttemptMs; + + WifiAutoConnect(Context context) { + this.wifi = (WifiManager) context.getApplicationContext() + .getSystemService(Context.WIFI_SERVICE); + } + + /** The SSID the unit is associated with, or null when it is not on Wi-Fi. */ + String currentSsid() { + try { + if (wifi == null || !wifi.isWifiEnabled()) return null; + WifiInfo info = wifi.getConnectionInfo(); + if (info == null) return null; + String ssid = info.getSSID(); + if (ssid == null) return null; + // The framework quotes the SSID and reports this placeholder when it will not + // say — usually because location is off. Neither is a network name. + ssid = ssid.replace("\"", "").trim(); + if (ssid.isEmpty() || "".equals(ssid) || "0x".equals(ssid)) return null; + return ssid; + } catch (Throwable t) { + return null; + } + } + + boolean isWifiEnabled() { + try { + return wifi != null && wifi.isWifiEnabled(); + } catch (Throwable t) { + return false; + } + } + + /** Saved network names, for the UI to show what it can reach. Empty if the OS refuses. */ + List savedNetworkNames() { + java.util.List names = new java.util.ArrayList<>(); + try { + if (wifi == null) return names; + List saved = wifi.getConfiguredNetworks(); + if (saved == null) return names; + for (WifiConfiguration c : saved) { + if (c == null || c.SSID == null) continue; + String name = c.SSID.replace("\"", "").trim(); + if (!name.isEmpty() && !names.contains(name)) names.add(name); + } + } catch (Throwable t) { + Log.w(TAG, "getConfiguredNetworks refused: " + t.getMessage()); + } + return names; + } + + /** + * One attempt to get onto a saved network, rate-limited. + * + * @param force ignore the rate limit — for the button, which a driver only presses + * because the automatic attempt has not worked yet. + * @return what happened, for the log and the UI. Never null. + */ + Result ensureConnected(boolean force) { + if (wifi == null) return Result.UNSUPPORTED; + + String ssid = currentSsid(); + if (ssid != null) return Result.alreadyOn(ssid); + + long now = System.currentTimeMillis(); + if (!force && now - lastAttemptMs < MIN_ATTEMPT_INTERVAL_MS) return Result.WAITING; + lastAttemptMs = now; + + try { + if (!wifi.isWifiEnabled()) { + // Also gone for ordinary apps from API 29, and also still honoured here. + if (!wifi.setWifiEnabled(true)) return Result.CANNOT_ENABLE; + // The radio needs a moment before it will associate; the next tick retries. + return Result.ENABLING; + } + + List saved = wifi.getConfiguredNetworks(); + if (saved == null || saved.isEmpty()) return Result.NO_SAVED_NETWORKS; + + // A scan would let us prefer a network that is actually in range, but the results + // are themselves throttled and location-gated. Asking the supplicant to pick from + // what it knows is both simpler and what the Settings screen effectively does. + wifi.reconnect(); + for (WifiConfiguration c : saved) { + if (c == null) continue; + // Second argument true: disable the others for this attempt, so the + // supplicant commits to this one rather than drifting between them. + if (wifi.enableNetwork(c.networkId, true)) { + String name = c.SSID == null ? "?" : c.SSID.replace("\"", "").trim(); + Log.i(TAG, "enableNetwork accepted for " + name); + return Result.attempting(name); + } + } + return Result.REFUSED; + } catch (Throwable t) { + Log.w(TAG, "connect attempt failed: " + t.getMessage()); + return Result.REFUSED; + } + } + + /** Outcome of one attempt. {@link #connected} is the only one that means we are online. */ + static final class Result { + final boolean connected; + final String ssid; + final String message; + + private Result(boolean connected, String ssid, String message) { + this.connected = connected; + this.ssid = ssid; + this.message = message; + } + + static Result alreadyOn(String ssid) { return new Result(true, ssid, "Bağlı: " + ssid); } + static Result attempting(String ssid) { return new Result(false, ssid, "Bağlanılıyor: " + ssid); } + + static final Result UNSUPPORTED = new Result(false, null, "Wi-Fi yönetilemiyor"); + static final Result WAITING = new Result(false, null, "Bekleniyor…"); + static final Result ENABLING = new Result(false, null, "Wi-Fi açılıyor…"); + static final Result CANNOT_ENABLE = new Result(false, null, "Wi-Fi açılamadı"); + static final Result NO_SAVED_NETWORKS = new Result(false, null, "Kayıtlı ağ yok"); + static final Result REFUSED = new Result(false, null, "Sistem izin vermedi"); + } +} diff --git a/app/src/main/res/color/nav_tab_background.xml b/app/src/main/res/color/nav_tab_background.xml new file mode 100644 index 0000000..7cc78d1 --- /dev/null +++ b/app/src/main/res/color/nav_tab_background.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/color/nav_tab_foreground.xml b/app/src/main/res/color/nav_tab_foreground.xml new file mode 100644 index 0000000..5b5d26f --- /dev/null +++ b/app/src/main/res/color/nav_tab_foreground.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/color/nav_tab_stroke.xml b/app/src/main/res/color/nav_tab_stroke.xml new file mode 100644 index 0000000..62b03a6 --- /dev/null +++ b/app/src/main/res/color/nav_tab_stroke.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/about_logo_tile.xml b/app/src/main/res/drawable/about_logo_tile.xml new file mode 100644 index 0000000..bdb6b1f --- /dev/null +++ b/app/src/main/res/drawable/about_logo_tile.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_topbar.xml b/app/src/main/res/drawable/bg_topbar.xml new file mode 100644 index 0000000..1d5b3ba --- /dev/null +++ b/app/src/main/res/drawable/bg_topbar.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/circle_indicator.xml b/app/src/main/res/drawable/circle_indicator.xml new file mode 100644 index 0000000..5ce9760 --- /dev/null +++ b/app/src/main/res/drawable/circle_indicator.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c6f98bc --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..b824a91 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tab_abrp.xml b/app/src/main/res/drawable/ic_tab_abrp.xml new file mode 100644 index 0000000..255588d --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_abrp.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_tab_log.xml b/app/src/main/res/drawable/ic_tab_log.xml new file mode 100644 index 0000000..67ce1ca --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_log.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_tab_service.xml b/app/src/main/res/drawable/ic_tab_service.xml new file mode 100644 index 0000000..40bd181 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_service.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_tab_vehicle.xml b/app/src/main/res/drawable/ic_tab_vehicle.xml new file mode 100644 index 0000000..bfd4063 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_vehicle.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_tab_wifi.xml b/app/src/main/res/drawable/ic_tab_wifi.xml new file mode 100644 index 0000000..30b440f --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_wifi.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..98b0c16 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_about.xml b/app/src/main/res/layout/dialog_about.xml new file mode 100644 index 0000000..84ed020 --- /dev/null +++ b/app/src/main/res/layout/dialog_about.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_abrp.xml b/app/src/main/res/layout/pane_abrp.xml new file mode 100644 index 0000000..2835442 --- /dev/null +++ b/app/src/main/res/layout/pane_abrp.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +