Fatih'sMG4 — ABRP telemetry for an MG4 EH32 on SWI69
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<MavenPublication>("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")
|
||||
}
|
||||
@@ -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<List<DrivingProfile>> does not decay to LinkedTreeMap.
|
||||
-keep class com.evsuite.hardware.model.** { *; }
|
||||
-keepclassmembers class com.evsuite.hardware.model.** { <fields>; <init>(...); }
|
||||
-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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Entry>(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<Entry>
|
||||
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<Entry>? = 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() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FirmwareInfo.Gen, BatteryPowerEvidence> =
|
||||
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<FirmwareInfo.Gen, BatteryPowerEvidence> = 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<FirmwareInfo.Gen> = 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<Signal, Set<FirmwareInfo.Gen>> = 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<String, String>()
|
||||
|
||||
/** 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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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("<!-- GENERATED by FirmwareMatrix.render(); do not edit by hand. -->")
|
||||
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<Pair<String, Set<FirmwareGen>?>>): 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 |")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ConditionType, Set<FirmwareGen>?>()
|
||||
private val actionCache = HashMap<ActionType, Set<FirmwareGen>?>()
|
||||
|
||||
/** Supported generations, or null when the entry is firmware-independent (works on all). */
|
||||
fun gensOf(type: ConditionType): Set<FirmwareGen>? =
|
||||
conditionCache.getOrPut(type) { read(ConditionType::class.java, type.name) }
|
||||
|
||||
fun gensOf(type: ActionType): Set<FirmwareGen>? =
|
||||
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<FirmwareGen>?, gen: FirmwareGen?): Boolean =
|
||||
gens == null || gen == null || gen in gens
|
||||
|
||||
private fun <T> read(enumClass: Class<T>, name: String): Set<FirmwareGen>? =
|
||||
enumClass.getField(name).getAnnotation(SupportedOn::class.java)?.gens?.toSet()
|
||||
}
|
||||
@@ -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<ActionType> = 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)
|
||||
@@ -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<Attempt>,
|
||||
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<Attempt>()
|
||||
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"
|
||||
}
|
||||
@@ -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<Int>, 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<String, Any> = mapOf(SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT to value)
|
||||
}
|
||||
private enum class State { DOWN, LONG_REPORTED }
|
||||
private val states = mutableMapOf<Button, State>()
|
||||
|
||||
/**
|
||||
* 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<Button, Long>()
|
||||
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<T> {
|
||||
|
||||
private val last = AtomicReference<Any?>(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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Int>, 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<Int>,
|
||||
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<Int>,
|
||||
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<Int>, command: Int): List<Int>? {
|
||||
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"
|
||||
}
|
||||
@@ -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<ActionGroup, List<ActionType>> =
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -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<ConditionGroup, List<ConditionType>> = entries.groupBy { it.group }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<EnumOption> = 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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<Pair<Int, List<String>>> = 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}+"), "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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<DrivingProfile> = emptyList()
|
||||
) {
|
||||
companion object {
|
||||
const val CURRENT_SCHEMA_VERSION = 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String, String?> = 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<InterfaceProbe> {
|
||||
// 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<InterfaceProbe> = 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<InterfaceProbe> = 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<String?>): 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<InterfaceProbe> = 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<Any> = 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<String, Call>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -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<Int, Int> =
|
||||
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<Int, Int> =
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NavigationHandoff.Poi>) {
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -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<Poi>): List<Poi> = 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 <T> 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 <T> transact(
|
||||
binder: IBinder?,
|
||||
descriptor: String,
|
||||
code: Int,
|
||||
args: Array<out Any?>,
|
||||
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 <T> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int, Int>()
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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<MediaController> = 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)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<NavigationHandoff.Poi> = emptyList(),
|
||||
): Boolean = SaicAidl.callWriting(binder(), DESCRIPTOR, TX_START_NAV_FROM_EV_ROUTE) { data ->
|
||||
data.writeEvRoutPoiList(pathway)
|
||||
data.writeEvRoutPoiList(listOf(destination))
|
||||
}
|
||||
|
||||
/**
|
||||
* A `List<EVRoutPoiInfo>` 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<NavigationHandoff.Poi>) {
|
||||
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<Int, Int> = 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<Int, Int> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<String, IBinder>()
|
||||
|
||||
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<Float> =
|
||||
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<Float> {
|
||||
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<Float?> =
|
||||
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 }
|
||||
}
|
||||
@@ -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 <T> 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 <T> 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
|
||||
)
|
||||
}
|
||||
+124
@@ -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<EnergyTripSummary>,
|
||||
missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT,
|
||||
): Provenanced<Double> {
|
||||
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<EnergyTripSummary>,
|
||||
): List<Double> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Double> {
|
||||
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<Double> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Double> {
|
||||
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<Double> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BatteryChargeSession>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<BatteryLedgerEntry>): 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<BatteryLedgerEntry>): List<BatteryChargeSession> {
|
||||
val result = ArrayList<BatteryChargeSession>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<BatteryLedgerEntry>,
|
||||
) {
|
||||
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<BatteryLedgerEntry> {
|
||||
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<BatteryLedgerEntry>? {
|
||||
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<BatteryLedgerEntry>? = null,
|
||||
)
|
||||
|
||||
/** Newest entries win: a health estimate is about the pack as it is now. */
|
||||
private fun bound(entries: List<BatteryLedgerEntry>): List<BatteryLedgerEntry> =
|
||||
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<BatteryLedgerEntry>): 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
|
||||
}
|
||||
}
|
||||
@@ -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<UnexplainedSocStep>,
|
||||
/** 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<BatteryLedgerEntry>,
|
||||
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<BatteryLedgerEntry>): List<UnexplainedSocStep> {
|
||||
val result = ArrayList<UnexplainedSocStep>()
|
||||
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<BatteryLedgerEntry>): 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
|
||||
}
|
||||
}
|
||||
@@ -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<Leg>) {
|
||||
|
||||
/** The legs that end at a charging stop — as many as the trip needs, and never more. */
|
||||
val stops: List<Leg> 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<Leg>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Double>,
|
||||
/** Exponential smoothing used only for the glanceable dashboard number. */
|
||||
val smoothedInstantaneous: Provenanced<Double>,
|
||||
/** Consumed energy divided by distance over the bounded recent window. */
|
||||
val rollingAverage: Provenanced<Double>,
|
||||
)
|
||||
|
||||
private data class Sample(
|
||||
val atMs: Long,
|
||||
val speedKmh: Double?,
|
||||
val batteryPowerKw: Double?,
|
||||
)
|
||||
|
||||
private val window = ArrayDeque<Sample>()
|
||||
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<Double>): Provenanced<Double> {
|
||||
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<Double> {
|
||||
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<Double> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<TelemetryEvidenceProbe> = 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() })
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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<TripSample>,
|
||||
)
|
||||
|
||||
/** 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<TripSample> = 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())
|
||||
}
|
||||
}
|
||||
+183
@@ -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<TripSample>? = 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<StoredTrip>,
|
||||
) {
|
||||
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<StoredTrip> {
|
||||
if (!target.exists()) return emptyList()
|
||||
val text = runCatching { target.readText() }.getOrNull() ?: return emptyList()
|
||||
return parse(text) ?: run {
|
||||
quarantine()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun readSummaries(): List<EnergyTripSummary> = read().map { it.summary }
|
||||
|
||||
fun append(summary: EnergyTripSummary, samples: List<TripSample> = 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<StoredTrip>? {
|
||||
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<StoredTrip>? = runCatching {
|
||||
val type = object : TypeToken<List<EnergyTripSummary>>() {}.type
|
||||
gson.fromJson<List<EnergyTripSummary>>(text, type)?.map { StoredTrip(it) }
|
||||
}.getOrNull()
|
||||
|
||||
private data class HistoryEnvelope(
|
||||
val schemaVersion: Int = 0,
|
||||
val trips: List<StoredTrip>? = 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<StoredTrip>): List<StoredTrip> {
|
||||
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>): 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T : Any>(
|
||||
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<Double, Double>? {
|
||||
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 <T : Any> measured(
|
||||
value: T?,
|
||||
absent: UnavailableReason = UnavailableReason.SIGNAL_ABSENT,
|
||||
): Provenanced<T> =
|
||||
if (value == null) unavailable(absent)
|
||||
else Provenanced(value, Provenance.MEASURED)
|
||||
|
||||
/** Arithmetic on measured inputs. Prefer the [derive] overloads, which propagate gaps. */
|
||||
fun <T : Any> derived(
|
||||
value: T?,
|
||||
absent: UnavailableReason = UnavailableReason.INSUFFICIENT_SAMPLES,
|
||||
): Provenanced<T> =
|
||||
if (value == null) unavailable(absent)
|
||||
else Provenanced(value, Provenance.DERIVED)
|
||||
|
||||
/** A model output. The band is not optional. */
|
||||
fun <T : Any> estimated(
|
||||
value: T?,
|
||||
uncertainty: Double,
|
||||
absent: UnavailableReason = UnavailableReason.MODEL_NOT_TRAINED,
|
||||
): Provenanced<T> =
|
||||
if (value == null) unavailable(absent)
|
||||
else Provenanced(value, Provenance.ESTIMATED, uncertainty = uncertainty)
|
||||
|
||||
fun <T : Any> unavailable(reason: UnavailableReason): Provenanced<T> =
|
||||
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 <A : Any, R : Any> derive(
|
||||
a: Provenanced<A>,
|
||||
transform: (A) -> R?,
|
||||
): Provenanced<R> {
|
||||
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 <A : Any, B : Any, R : Any> derive(
|
||||
a: Provenanced<A>,
|
||||
b: Provenanced<B>,
|
||||
transform: (A, B) -> R?,
|
||||
): Provenanced<R> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<EnergyTripSummary>,
|
||||
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<EnergyTripSummary>,
|
||||
generation: com.evsuite.hardware.FirmwareInfo.Gen =
|
||||
com.evsuite.hardware.FirmwareInfo.getGeneration(),
|
||||
): SocRate? = fromTrips(trips, generation) ?: fromVehicleRange(socPercent, rangeKm)
|
||||
}
|
||||
@@ -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<Double>,
|
||||
val stateOfHealthPercent: Provenanced<Double>,
|
||||
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<BatteryLedgerEntry>,
|
||||
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<BatteryLedgerEntry>): List<SohWindow> {
|
||||
val ordered = entries.filter { it.socPercent.isFinite() }.sortedBy { it.atMs }
|
||||
if (ordered.size < 2) return emptyList()
|
||||
val result = ArrayList<SohWindow>()
|
||||
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<SohWindow>,
|
||||
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>): Double = percentile(sorted, 0.5)
|
||||
|
||||
/** Nearest-rank, which needs no interpolation rule to argue about on six samples. */
|
||||
internal fun percentile(sorted: List<Double>, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TelemetryEvidenceProbe>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<SignalEvidence>,
|
||||
) {
|
||||
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<String, Accumulator>()
|
||||
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<SignalEvidence> = 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) } ?: "—"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<TripSample>()
|
||||
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<TripSample> = 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
|
||||
}
|
||||
}
|
||||
+226
@@ -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<Double>,
|
||||
val modelledTraction: AttributedEnergyEstimate,
|
||||
val residuals: List<ResidualAttribution>,
|
||||
val measuredRegenerationKwh: Provenanced<Double>,
|
||||
val netBatteryEnergyKwh: Provenanced<Double>,
|
||||
/** Measured total not covered by a modelled interval. Never hidden inside a residual. */
|
||||
val unmodelledDiscrepancyKwh: Provenanced<Double>,
|
||||
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
|
||||
}
|
||||
@@ -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<Double> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+139
@@ -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<StoredTrip>,
|
||||
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<StoredTrip>,
|
||||
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<StoredTrip>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+351
@@ -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<Double> {
|
||||
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<StoredTrip>, generation: FirmwareInfo.Gen): List<Segment> {
|
||||
val segments = ArrayList<Segment>()
|
||||
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<StoredTrip>,
|
||||
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<StoredTrip>,
|
||||
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<TripSample>, out: MutableList<Segment>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Catalogue labels — owned by the EVHardware library (the catalogue lives here). -->
|
||||
<resources>
|
||||
<string name="group_context">Kontext</string>
|
||||
<string name="group_environment">Umgebung</string>
|
||||
<string name="group_driving">Fahren</string>
|
||||
<string name="group_comfort">Komfort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Fahrerassistenz</string>
|
||||
<string name="group_climate">Klima</string>
|
||||
<string name="group_profile">Profil</string>
|
||||
<string name="group_system">System</string>
|
||||
<string name="unit_celsius">°C</string>
|
||||
<string name="unit_kmh">km/h</string>
|
||||
<string name="unit_percent">%</string>
|
||||
<string name="unit_command">Bef.</string>
|
||||
<string name="drive_eco">Eco</string>
|
||||
<string name="drive_normal">Normal</string>
|
||||
<string name="drive_sport">Sport</string>
|
||||
<string name="drive_snow">Schnee</string>
|
||||
<string name="drive_custom">Individuell</string>
|
||||
<string name="regen_low">Schwach</string>
|
||||
<string name="regen_medium">Mittel</string>
|
||||
<string name="regen_high">Stark</string>
|
||||
<string name="regen_adaptive">Adaptiv</string>
|
||||
<string name="regen_off">Aus</string>
|
||||
<string name="regen_one_pedal">Ein-Pedal</string>
|
||||
<string name="aeb_alert_only">Nur Warnung</string>
|
||||
<string name="aeb_alert_brake">Warnung + Bremsung</string>
|
||||
<string name="sensitivity_low">Niedrig</string>
|
||||
<string name="sensitivity_standard">Standard</string>
|
||||
<string name="sensitivity_high">Hoch</string>
|
||||
<string name="elk_off">Aus</string>
|
||||
<string name="elk_warn">Warnung (LDW)</string>
|
||||
<string name="elk_assist">Assistent (LDP)</string>
|
||||
<string name="elk_full">Notfall-Spurhaltung (ELK)</string>
|
||||
<string name="acc_off">Aus</string>
|
||||
<string name="acc_acc">Adaptiver Tempomat (ACC)</string>
|
||||
<string name="acc_tja">Stauassistent (TJA)</string>
|
||||
<string name="limiter_off">Aus</string>
|
||||
<string name="limiter_manual">Manuell</string>
|
||||
<string name="limiter_smart">Intelligent</string>
|
||||
<string name="ignition_lock">Verriegelt</string>
|
||||
<string name="ignition_off">Aus</string>
|
||||
<string name="ignition_acc">Zubehör</string>
|
||||
<string name="ignition_on">Läuft</string>
|
||||
<string name="ignition_start">Startet</string>
|
||||
<string name="cond_bt_device">Bluetooth-Telefon verbunden</string>
|
||||
<string name="cond_bt_any">Beliebiges Bluetooth-Gerät verbunden</string>
|
||||
<string name="cond_bt_onboard">Telefon an Bord (Bluetooth)</string>
|
||||
<string name="cond_bt_handsfree">Bluetooth-Freisprechtelefon</string>
|
||||
<string name="cond_time">Uhrzeit</string>
|
||||
<string name="cond_day">Wochentag</string>
|
||||
<string name="cond_date">Genaues Datum</string>
|
||||
<string name="cond_firmware">Firmware-Generation</string>
|
||||
<string name="cond_outside_temp">Außentemperatur</string>
|
||||
<string name="cond_ignition">Zündungszustand</string>
|
||||
<string name="cond_in_park">Gang in P</string>
|
||||
<string name="cond_speed">Geschwindigkeit</string>
|
||||
<string name="cond_drive_mode">Fahrmodus</string>
|
||||
<string name="cond_regen">Rekuperation</string>
|
||||
<string name="cond_energy_saving">Energiesparen</string>
|
||||
<string name="cond_seat_heat_l">Sitzheizung links</string>
|
||||
<string name="cond_seat_heat_r">Sitzheizung rechts</string>
|
||||
<string name="cond_steering_heat">Lenkradheizung</string>
|
||||
<string name="cond_media_volume">Medienlautstärke</string>
|
||||
<string name="cond_brightness">Bildschirmhelligkeit</string>
|
||||
<string name="cond_aeb">Notbremsung (AEB)</string>
|
||||
<string name="cond_aeb_mode">AEB-Modus</string>
|
||||
<string name="cond_aeb_sensitivity">AEB-Empfindlichkeit</string>
|
||||
<string name="cond_elk_mode">ELK-Modus</string>
|
||||
<string name="cond_elk_sensitivity">ELK-Empfindlichkeit</string>
|
||||
<string name="cond_acc_tja">ACC-/TJA-Modus</string>
|
||||
<string name="cond_limiter">Geschwindigkeitsbegrenzer</string>
|
||||
<string name="cond_tsr">Verkehrszeichenerkennung (TSR)</string>
|
||||
<string name="cond_overspeed">Geschwindigkeitsalarm</string>
|
||||
<string name="cond_speed_limit_tone">Tempolimit-Ton</string>
|
||||
<string name="cond_sound_warning">ADAS-Tonwarnung</string>
|
||||
<string name="cond_ac">Klimaanlage ein</string>
|
||||
<string name="cond_hvac_auto">Klima AUTO ein</string>
|
||||
<string name="cond_recirc">Umluft ein</string>
|
||||
<string name="cond_fan_speed">Gebläsestufe</string>
|
||||
<string name="cond_temperature_set">Solltemperatur</string>
|
||||
<string name="cond_window_open">Ein Fenster ist offen</string>
|
||||
<string name="act_apply_profile">Profil anwenden</string>
|
||||
<string name="act_show_profile_picker">Profil abfragen</string>
|
||||
<string name="act_drive_mode">Fahrmodus</string>
|
||||
<string name="act_regen">Rekuperation</string>
|
||||
<string name="act_one_pedal">Ein-Pedal-Fahren</string>
|
||||
<string name="act_energy_saving">Energiesparen</string>
|
||||
<string name="act_seat_heat_l">Sitzheizung links</string>
|
||||
<string name="act_seat_heat_r">Sitzheizung rechts</string>
|
||||
<string name="act_steering_heat">Lenkradheizung</string>
|
||||
<string name="act_brightness">Bildschirmhelligkeit</string>
|
||||
<string name="act_media_volume">Medienlautstärke</string>
|
||||
<string name="act_balance">Balance links / rechts</string>
|
||||
<string name="act_fader">Überblendung vorn / hinten</string>
|
||||
<string name="act_tone">Klang</string>
|
||||
<string name="act_bose">Bose-Klangprofil</string>
|
||||
<string name="act_3d">3D-Effekt</string>
|
||||
<string name="act_speed_volume">Geschwindigkeitsabhängige Lautstärke</string>
|
||||
<string name="act_aeb">Notbremsung (AEB)</string>
|
||||
<string name="act_aeb_mode">AEB-Modus</string>
|
||||
<string name="act_aeb_sensitivity">AEB-Empfindlichkeit</string>
|
||||
<string name="act_elk_mode">ELK-Modus</string>
|
||||
<string name="act_elk_sensitivity">ELK-Empfindlichkeit</string>
|
||||
<string name="act_acc_tja">ACC-/TJA-Modus</string>
|
||||
<string name="act_limiter">Geschwindigkeitsbegrenzer</string>
|
||||
<string name="act_tsr">Verkehrszeichenerkennung (TSR)</string>
|
||||
<string name="act_overspeed">Geschwindigkeitsalarm</string>
|
||||
<string name="act_speed_limit_tone">Tempolimit-Ton</string>
|
||||
<string name="act_sound_warning">ADAS-Tonwarnung</string>
|
||||
<string name="act_las_sound">Spurverlassenswarnung Ton</string>
|
||||
<string name="act_las_vibration">Spurverlassen Lenkradvibration</string>
|
||||
<string name="act_launch_app">App starten</string>
|
||||
<string name="act_notify">Benachrichtigung anzeigen</string>
|
||||
<string name="act_speak">Nachricht vorlesen</string>
|
||||
<string name="group_energy">Energie</string>
|
||||
<string name="unit_metre">m</string>
|
||||
<string name="cond_location">In der Nähe eines Ortes</string>
|
||||
<string name="cond_physical_button">Physische Tasten</string>
|
||||
<string name="cond_battery">Batteriestand</string>
|
||||
<string name="cond_charging">Lädt</string>
|
||||
<string name="cond_charge_limit">Ladegrenze</string>
|
||||
<string name="cond_climate_on">Klimaanlage an</string>
|
||||
<string name="act_climate_power">Klimaanlage ein/aus</string>
|
||||
<string name="act_cabin_temp">Solltemperatur</string>
|
||||
<string name="act_ac">Klimakompressor</string>
|
||||
<string name="act_climate_auto">Klimaautomatik</string>
|
||||
<string name="act_recirc">Umluft</string>
|
||||
<string name="act_fan_level">Gebläsestufe</string>
|
||||
<string name="act_front_defrost">Frontscheibenheizung</string>
|
||||
<string name="act_rear_defrost">Heckscheibenheizung</string>
|
||||
<string name="act_charge_limit">Ladegrenze</string>
|
||||
<string name="act_charging_enabled">Laden erlauben</string>
|
||||
<string name="act_charge_schedule">Ladezeitplan</string>
|
||||
<string name="act_charge_window">Ladefenster</string>
|
||||
<string name="act_battery_preheat">Batterievorheizung</string>
|
||||
<string name="act_play_radio">Radio einschalten</string>
|
||||
<string name="act_tune_radio">Radioband und Sender</string>
|
||||
<string name="act_radio_next_station">Nächster Radiosender</string>
|
||||
<string name="act_radio_prev_station">Vorheriger Radiosender</string>
|
||||
<string name="act_pause_radio">Radio stummschalten</string>
|
||||
<string name="act_radio_play_pause">Radio Wiedergabe/Pause</string>
|
||||
<string name="act_open_radio_screen">Radio-Bildschirm öffnen</string>
|
||||
<string name="radio_band_am">AM</string>
|
||||
<string name="radio_band_fm">FM</string>
|
||||
<string name="radio_band_dab">DAB</string>
|
||||
<string name="window_close">Geschlossen</string>
|
||||
<string name="window_open">Offen</string>
|
||||
<string name="value_radio_hint">z. B. 103.5 oder AM 1080</string>
|
||||
<string name="act_navigate">Navigieren zu</string>
|
||||
<string name="act_ask_confirm">Bestätigung anfordern</string>
|
||||
<string name="act_ask_confirm_hint">z. B. Fenster öffnen?</string>
|
||||
<string name="act_call">Anrufen</string>
|
||||
<string name="act_call_contact">Kontakt anrufen</string>
|
||||
<string name="cond_window_position">Fensteröffnung</string>
|
||||
<string name="cond_doors_locked">Türen verriegelt</string>
|
||||
<string name="act_windows">Alle Fenster</string>
|
||||
<string name="act_door_lock">Türverriegelung</string>
|
||||
<string name="act_delay">Warten</string>
|
||||
<string name="unit_second">s</string>
|
||||
<string name="cond_charging_status">Ladezustand</string>
|
||||
<string name="cond_charge_schedule">Ladezeitplan aktiv</string>
|
||||
<string name="cond_charge_window_start">Ladebeginn um</string>
|
||||
<string name="cond_charge_window_stop">Ladeende um</string>
|
||||
<string name="cond_battery_preheat">Batterievorheizung aktiv</string>
|
||||
<string name="cond_passenger_temp">Temperatur Beifahrer</string>
|
||||
<string name="cond_econ">ECON-Modus aktiv</string>
|
||||
<string name="cond_front_defrost">Frontscheibenheizung aktiv</string>
|
||||
<string name="cond_rear_defrost">Heckscheibenheizung aktiv</string>
|
||||
<string name="cond_front_door_open">Vordertür offen</string>
|
||||
<string name="act_passenger_temp">Temperatur Beifahrer</string>
|
||||
<string name="act_econ">ECON-Modus</string>
|
||||
<string name="charging_unplugged">Nicht angeschlossen</string>
|
||||
<string name="charging_ac">Laden (AC)</string>
|
||||
<string name="charging_dc">Laden (DC)</string>
|
||||
<string name="charging_plugged_idle">Angeschlossen, lädt nicht</string>
|
||||
<string name="charging_done">Ladung abgeschlossen</string>
|
||||
<string name="charging_fault">Ladefehler</string>
|
||||
<string name="cond_media_playing">Medienwiedergabe läuft</string>
|
||||
<string name="cond_radio_playing">Radio läuft</string>
|
||||
<string name="cond_wifi_ssid">WLAN-Netz</string>
|
||||
<string name="cond_wifi_ssid_hint">z. B. Zuhause</string>
|
||||
<string name="cond_in_call">Anruf läuft</string>
|
||||
<string name="cond_drive_duration">Fahrtdauer</string>
|
||||
<string name="cond_random_chance">Zufallschance</string>
|
||||
<string name="unit_minute">Min</string>
|
||||
<string name="act_enable_rule">Regel aktivieren</string>
|
||||
<string name="act_disable_rule">Regel deaktivieren</string>
|
||||
<string name="act_media_control">Mediensteuerung</string>
|
||||
<string name="media_play_pause">Wiedergabe / Pause</string>
|
||||
<string name="media_next">Nächster Titel</string>
|
||||
<string name="media_previous">Vorheriger Titel</string>
|
||||
<string name="act_set_bluetooth">Bluetooth</string>
|
||||
<string name="act_set_wifi">WLAN</string>
|
||||
<string name="unit_km">km</string>
|
||||
<string name="cond_range">Restreichweite</string>
|
||||
<string name="cond_window_driver">Fensteröffnung Fahrer</string>
|
||||
<string name="cond_window_passenger">Fensteröffnung Beifahrer</string>
|
||||
<string name="cond_window_rear_left">Fensteröffnung hinten links</string>
|
||||
<string name="cond_window_rear_right">Fensteröffnung hinten rechts</string>
|
||||
<string name="act_window_driver">Fenster Fahrer</string>
|
||||
<string name="act_window_passenger">Fenster Beifahrer</string>
|
||||
<string name="act_window_rear_left">Fenster hinten links</string>
|
||||
<string name="act_window_rear_right">Fenster hinten rechts</string>
|
||||
<string name="cond_odometer">Kilometerstand</string>
|
||||
<string name="cond_weather">Wetter</string>
|
||||
<string name="weather_clear">Klar</string>
|
||||
<string name="weather_cloudy">Bewölkt</string>
|
||||
<string name="weather_rain">Regen</string>
|
||||
<string name="weather_snow">Schnee</string>
|
||||
<string name="weather_storm">Gewitter</string>
|
||||
<string name="weather_fog">Nebel</string>
|
||||
<string name="weather_wind">Wind</string>
|
||||
<string name="act_esc">Stabilitätskontrolle (ESC)</string>
|
||||
<string name="act_drowsiness">Müdigkeitswarnung</string>
|
||||
<string name="act_drowsiness_sensitivity">Empfindlichkeit der Müdigkeitswarnung</string>
|
||||
<string name="act_adjust_media_volume">Medienlautstärke, schrittweise</string>
|
||||
<string name="cond_data_today">Heute verbrauchte Daten</string>
|
||||
<string name="cond_data_month">Diesen Monat verbrauchte Daten</string>
|
||||
<string name="unit_megabyte">MB</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,223 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Catalogue labels — owned by the EVHardware library (the catalogue lives here). -->
|
||||
<resources>
|
||||
<string name="group_context">Contexto</string>
|
||||
<string name="group_environment">Entorno</string>
|
||||
<string name="group_driving">Conducción</string>
|
||||
<string name="group_comfort">Confort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Asistencia a la conducción</string>
|
||||
<string name="group_climate">Climatización</string>
|
||||
<string name="group_profile">Perfil</string>
|
||||
<string name="group_system">Sistema</string>
|
||||
<string name="unit_celsius">°C</string>
|
||||
<string name="unit_kmh">km/h</string>
|
||||
<string name="unit_percent">%</string>
|
||||
<string name="unit_command">cmd</string>
|
||||
<string name="drive_eco">Eco</string>
|
||||
<string name="drive_normal">Normal</string>
|
||||
<string name="drive_sport">Sport</string>
|
||||
<string name="drive_snow">Nieve</string>
|
||||
<string name="drive_custom">Personalizado</string>
|
||||
<string name="regen_low">Bajo</string>
|
||||
<string name="regen_medium">Medio</string>
|
||||
<string name="regen_high">Alto</string>
|
||||
<string name="regen_adaptive">Adaptativo</string>
|
||||
<string name="regen_off">Desactivado</string>
|
||||
<string name="regen_one_pedal">Un pedal</string>
|
||||
<string name="aeb_alert_only">Solo alerta</string>
|
||||
<string name="aeb_alert_brake">Alerta + frenado</string>
|
||||
<string name="sensitivity_low">Baja</string>
|
||||
<string name="sensitivity_standard">Estándar</string>
|
||||
<string name="sensitivity_high">Alta</string>
|
||||
<string name="elk_off">Desactivado</string>
|
||||
<string name="elk_warn">Aviso (LDW)</string>
|
||||
<string name="elk_assist">Asistencia (LDP)</string>
|
||||
<string name="elk_full">Mantenimiento de emergencia (ELK)</string>
|
||||
<string name="acc_off">Desactivado</string>
|
||||
<string name="acc_acc">Control de crucero adaptativo (ACC)</string>
|
||||
<string name="acc_tja">Asistente de atascos (TJA)</string>
|
||||
<string name="limiter_off">Desactivado</string>
|
||||
<string name="limiter_manual">Manual</string>
|
||||
<string name="limiter_smart">Inteligente</string>
|
||||
<string name="ignition_lock">Bloqueado</string>
|
||||
<string name="ignition_off">Apagado</string>
|
||||
<string name="ignition_acc">Accesorios</string>
|
||||
<string name="ignition_on">En marcha</string>
|
||||
<string name="ignition_start">Arrancando</string>
|
||||
<string name="cond_bt_device">Teléfono Bluetooth conectado</string>
|
||||
<string name="cond_bt_any">Cualquier dispositivo Bluetooth conectado</string>
|
||||
<string name="cond_bt_onboard">Teléfono a bordo (Bluetooth)</string>
|
||||
<string name="cond_bt_handsfree">Teléfono manos libres Bluetooth</string>
|
||||
<string name="cond_time">Hora del día</string>
|
||||
<string name="cond_day">Día de la semana</string>
|
||||
<string name="cond_date">Fecha exacta</string>
|
||||
<string name="cond_firmware">Generación de firmware</string>
|
||||
<string name="cond_outside_temp">Temperatura exterior</string>
|
||||
<string name="cond_ignition">Estado del contacto</string>
|
||||
<string name="cond_in_park">Marcha en P</string>
|
||||
<string name="cond_speed">Velocidad</string>
|
||||
<string name="cond_drive_mode">Modo de conducción</string>
|
||||
<string name="cond_regen">Regeneración</string>
|
||||
<string name="cond_energy_saving">Ahorro de energía</string>
|
||||
<string name="cond_seat_heat_l">Calefacción asiento izquierdo</string>
|
||||
<string name="cond_seat_heat_r">Calefacción asiento derecho</string>
|
||||
<string name="cond_steering_heat">Calefacción del volante</string>
|
||||
<string name="cond_media_volume">Volumen multimedia</string>
|
||||
<string name="cond_brightness">Brillo de pantalla</string>
|
||||
<string name="cond_aeb">Frenado de emergencia (AEB)</string>
|
||||
<string name="cond_aeb_mode">Modo AEB</string>
|
||||
<string name="cond_aeb_sensitivity">Sensibilidad AEB</string>
|
||||
<string name="cond_elk_mode">Modo ELK</string>
|
||||
<string name="cond_elk_sensitivity">Sensibilidad ELK</string>
|
||||
<string name="cond_acc_tja">Modo ACC / TJA</string>
|
||||
<string name="cond_limiter">Limitador de velocidad</string>
|
||||
<string name="cond_tsr">Reconocimiento de señales (TSR)</string>
|
||||
<string name="cond_overspeed">Alarma de exceso de velocidad</string>
|
||||
<string name="cond_speed_limit_tone">Tono de límite de velocidad</string>
|
||||
<string name="cond_sound_warning">Aviso sonoro ADAS</string>
|
||||
<string name="cond_ac">Aire acondicionado encendido</string>
|
||||
<string name="cond_hvac_auto">Clima AUTO encendido</string>
|
||||
<string name="cond_recirc">Recirculación de aire activada</string>
|
||||
<string name="cond_fan_speed">Velocidad del ventilador</string>
|
||||
<string name="cond_temperature_set">Temperatura ajustada</string>
|
||||
<string name="cond_window_open">Una ventanilla está abierta</string>
|
||||
<string name="act_apply_profile">Aplicar un perfil</string>
|
||||
<string name="act_show_profile_picker">Pedir un perfil</string>
|
||||
<string name="act_drive_mode">Modo de conducción</string>
|
||||
<string name="act_regen">Regeneración</string>
|
||||
<string name="act_one_pedal">Conducción con un pedal</string>
|
||||
<string name="act_energy_saving">Ahorro de energía</string>
|
||||
<string name="act_seat_heat_l">Calefacción asiento izquierdo</string>
|
||||
<string name="act_seat_heat_r">Calefacción asiento derecho</string>
|
||||
<string name="act_steering_heat">Calefacción del volante</string>
|
||||
<string name="act_brightness">Brillo de pantalla</string>
|
||||
<string name="act_media_volume">Volumen multimedia</string>
|
||||
<string name="act_balance">Balance izquierda / derecha</string>
|
||||
<string name="act_fader">Fundido delante / detrás</string>
|
||||
<string name="act_tone">Tono</string>
|
||||
<string name="act_bose">Perfil de sonido Bose</string>
|
||||
<string name="act_3d">Efecto 3D</string>
|
||||
<string name="act_speed_volume">Volumen adaptativo a la velocidad</string>
|
||||
<string name="act_aeb">Frenado de emergencia (AEB)</string>
|
||||
<string name="act_aeb_mode">Modo AEB</string>
|
||||
<string name="act_aeb_sensitivity">Sensibilidad AEB</string>
|
||||
<string name="act_elk_mode">Modo ELK</string>
|
||||
<string name="act_elk_sensitivity">Sensibilidad ELK</string>
|
||||
<string name="act_acc_tja">Modo ACC / TJA</string>
|
||||
<string name="act_limiter">Limitador de velocidad</string>
|
||||
<string name="act_tsr">Reconocimiento de señales (TSR)</string>
|
||||
<string name="act_overspeed">Alarma de exceso de velocidad</string>
|
||||
<string name="act_speed_limit_tone">Tono de límite de velocidad</string>
|
||||
<string name="act_sound_warning">Aviso sonoro ADAS</string>
|
||||
<string name="act_las_sound">Aviso sonoro de cambio de carril</string>
|
||||
<string name="act_las_vibration">Vibración del volante por cambio de carril</string>
|
||||
<string name="act_launch_app">Abrir una app</string>
|
||||
<string name="act_notify">Mostrar una notificación</string>
|
||||
<string name="act_speak">Decir un mensaje</string>
|
||||
<string name="group_energy">Energía</string>
|
||||
<string name="unit_metre">m</string>
|
||||
<string name="cond_location">Cerca de un lugar</string>
|
||||
<string name="cond_physical_button">Botones físicos</string>
|
||||
<string name="cond_battery">Nivel de batería</string>
|
||||
<string name="cond_charging">Cargando</string>
|
||||
<string name="cond_charge_limit">Límite de carga</string>
|
||||
<string name="cond_climate_on">Climatización activa</string>
|
||||
<string name="act_climate_power">Climatización on/off</string>
|
||||
<string name="act_cabin_temp">Temperatura de consigna</string>
|
||||
<string name="act_ac">Aire acondicionado</string>
|
||||
<string name="act_climate_auto">Climatización AUTO</string>
|
||||
<string name="act_recirc">Recirculación de aire</string>
|
||||
<string name="act_fan_level">Velocidad del ventilador</string>
|
||||
<string name="act_front_defrost">Desempañador del parabrisas</string>
|
||||
<string name="act_rear_defrost">Desempañador de la luneta</string>
|
||||
<string name="act_charge_limit">Límite de carga</string>
|
||||
<string name="act_charging_enabled">Permitir la carga</string>
|
||||
<string name="act_charge_schedule">Carga programada</string>
|
||||
<string name="act_charge_window">Franja de carga</string>
|
||||
<string name="act_battery_preheat">Precalentamiento de la batería</string>
|
||||
<string name="act_play_radio">Poner la radio</string>
|
||||
<string name="act_tune_radio">Banda y emisora de radio</string>
|
||||
<string name="act_radio_next_station">Emisora de radio siguiente</string>
|
||||
<string name="act_radio_prev_station">Emisora de radio anterior</string>
|
||||
<string name="act_pause_radio">Silenciar la radio</string>
|
||||
<string name="act_radio_play_pause">Radio: reproducir/pausar</string>
|
||||
<string name="act_open_radio_screen">Abrir la pantalla de radio</string>
|
||||
<string name="radio_band_am">AM</string>
|
||||
<string name="radio_band_fm">FM</string>
|
||||
<string name="radio_band_dab">DAB</string>
|
||||
<string name="window_close">Cerrada</string>
|
||||
<string name="window_open">Abierta</string>
|
||||
<string name="value_radio_hint">p. ej. 103.5 o AM 1080</string>
|
||||
<string name="act_navigate">Navegar hasta</string>
|
||||
<string name="act_ask_confirm">Pedir confirmación</string>
|
||||
<string name="act_ask_confirm_hint">p. ej.: ¿abrir las ventanillas?</string>
|
||||
<string name="act_call">Llamar</string>
|
||||
<string name="act_call_contact">Llamar a un contacto</string>
|
||||
<string name="cond_window_position">Apertura de ventanillas</string>
|
||||
<string name="cond_doors_locked">Puertas bloqueadas</string>
|
||||
<string name="act_windows">Todas las ventanillas</string>
|
||||
<string name="act_door_lock">Bloqueo de puertas</string>
|
||||
<string name="act_delay">Esperar</string>
|
||||
<string name="unit_second">s</string>
|
||||
<string name="cond_charging_status">Estado de carga</string>
|
||||
<string name="cond_charge_schedule">Carga programada activa</string>
|
||||
<string name="cond_charge_window_start">La carga empieza a las</string>
|
||||
<string name="cond_charge_window_stop">La carga termina a las</string>
|
||||
<string name="cond_battery_preheat">Precalentamiento de batería activo</string>
|
||||
<string name="cond_passenger_temp">Temperatura del acompañante</string>
|
||||
<string name="cond_econ">Modo ECON activo</string>
|
||||
<string name="cond_front_defrost">Desempañador delantero activo</string>
|
||||
<string name="cond_rear_defrost">Desempañador trasero activo</string>
|
||||
<string name="cond_front_door_open">Puerta delantera abierta</string>
|
||||
<string name="act_passenger_temp">Temperatura del acompañante</string>
|
||||
<string name="act_econ">Modo ECON</string>
|
||||
<string name="charging_unplugged">Desconectado</string>
|
||||
<string name="charging_ac">Cargando (CA)</string>
|
||||
<string name="charging_dc">Cargando (CC)</string>
|
||||
<string name="charging_plugged_idle">Conectado, sin cargar</string>
|
||||
<string name="charging_done">Carga completa</string>
|
||||
<string name="charging_fault">Fallo de carga</string>
|
||||
<string name="cond_media_playing">Reproducción en curso</string>
|
||||
<string name="cond_radio_playing">Radio encendida</string>
|
||||
<string name="cond_wifi_ssid">Red Wi-Fi</string>
|
||||
<string name="cond_wifi_ssid_hint">p. ej. Casa</string>
|
||||
<string name="cond_in_call">Llamada en curso</string>
|
||||
<string name="cond_drive_duration">Duración del trayecto</string>
|
||||
<string name="cond_random_chance">Probabilidad aleatoria</string>
|
||||
<string name="unit_minute">min</string>
|
||||
<string name="act_enable_rule">Activar una regla</string>
|
||||
<string name="act_disable_rule">Desactivar una regla</string>
|
||||
<string name="act_media_control">Control multimedia</string>
|
||||
<string name="media_play_pause">Reproducir / pausar</string>
|
||||
<string name="media_next">Pista siguiente</string>
|
||||
<string name="media_previous">Pista anterior</string>
|
||||
<string name="act_set_bluetooth">Bluetooth</string>
|
||||
<string name="act_set_wifi">Wi-Fi</string>
|
||||
<string name="unit_km">km</string>
|
||||
<string name="cond_range">Autonomía restante</string>
|
||||
<string name="cond_window_driver">Apertura ventanilla del conductor</string>
|
||||
<string name="cond_window_passenger">Apertura ventanilla del acompañante</string>
|
||||
<string name="cond_window_rear_left">Apertura ventanilla trasera izquierda</string>
|
||||
<string name="cond_window_rear_right">Apertura ventanilla trasera derecha</string>
|
||||
<string name="act_window_driver">Ventanilla del conductor</string>
|
||||
<string name="act_window_passenger">Ventanilla del acompañante</string>
|
||||
<string name="act_window_rear_left">Ventanilla trasera izquierda</string>
|
||||
<string name="act_window_rear_right">Ventanilla trasera derecha</string>
|
||||
<string name="cond_odometer">Cuentakilómetros</string>
|
||||
<string name="cond_weather">Tiempo</string>
|
||||
<string name="weather_clear">Despejado</string>
|
||||
<string name="weather_cloudy">Nublado</string>
|
||||
<string name="weather_rain">Lluvia</string>
|
||||
<string name="weather_snow">Nieve</string>
|
||||
<string name="weather_storm">Tormenta</string>
|
||||
<string name="weather_fog">Niebla</string>
|
||||
<string name="weather_wind">Viento</string>
|
||||
<string name="act_esc">Control de estabilidad (ESC)</string>
|
||||
<string name="act_drowsiness">Alerta de somnolencia</string>
|
||||
<string name="act_drowsiness_sensitivity">Sensibilidad de la alerta de somnolencia</string>
|
||||
<string name="act_adjust_media_volume">Volumen multimedia, por pasos</string>
|
||||
<string name="cond_data_today">Datos consumidos hoy</string>
|
||||
<string name="cond_data_month">Datos consumidos este mes</string>
|
||||
<string name="unit_megabyte">MB</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,245 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Catalogue labels — owned by the EVHardware library (the catalogue lives here). -->
|
||||
<resources>
|
||||
<string name="group_context">Contexte</string>
|
||||
<string name="group_environment">Environnement</string>
|
||||
<string name="group_driving">Conduite</string>
|
||||
<string name="group_comfort">Confort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Aides à la conduite</string>
|
||||
<string name="group_climate">Climatisation</string>
|
||||
<string name="group_profile">Profil</string>
|
||||
<string name="group_system">Système</string>
|
||||
<string name="unit_celsius">°C</string>
|
||||
<string name="unit_kmh">km/h</string>
|
||||
<string name="unit_percent">%</string>
|
||||
<string name="unit_command">cmd</string>
|
||||
<string name="drive_eco">Eco</string>
|
||||
<string name="drive_normal">Normal</string>
|
||||
<string name="drive_sport">Sport</string>
|
||||
<string name="drive_snow">Neige</string>
|
||||
<string name="drive_custom">Personnalisé</string>
|
||||
<string name="regen_low">Faible</string>
|
||||
<string name="regen_medium">Moyenne</string>
|
||||
<string name="regen_high">Forte</string>
|
||||
<string name="regen_adaptive">Adaptative</string>
|
||||
<string name="regen_off">Désactivée</string>
|
||||
<string name="regen_one_pedal">Une pédale</string>
|
||||
<string name="aeb_alert_only">Alerte seule</string>
|
||||
<string name="aeb_alert_brake">Alerte + freinage</string>
|
||||
<string name="sensitivity_low">Faible</string>
|
||||
<string name="sensitivity_standard">Standard</string>
|
||||
<string name="sensitivity_high">Élevée</string>
|
||||
<string name="elk_off">Désactivé</string>
|
||||
<string name="elk_warn">Alerte (LDW)</string>
|
||||
<string name="elk_assist">Assistance (LDP)</string>
|
||||
<string name="elk_full">Maintien actif (ELK)</string>
|
||||
<string name="acc_off">Désactivé</string>
|
||||
<string name="acc_acc">Régulateur adaptatif (ACC)</string>
|
||||
<string name="acc_tja">Assistance embouteillage (TJA)</string>
|
||||
<string name="limiter_off">Désactivé</string>
|
||||
<string name="limiter_manual">Manuel</string>
|
||||
<string name="limiter_smart">Intelligent</string>
|
||||
<string name="ignition_lock">Verrouillé</string>
|
||||
<string name="ignition_off">Éteint</string>
|
||||
<string name="ignition_acc">Accessoires</string>
|
||||
<string name="ignition_on">Démarré</string>
|
||||
<string name="ignition_start">Démarrage</string>
|
||||
<string name="cond_bt_device">Téléphone Bluetooth connecté</string>
|
||||
<string name="cond_bt_any">Un appareil Bluetooth est connecté</string>
|
||||
<string name="cond_bt_onboard">Téléphone à bord (Bluetooth)</string>
|
||||
<string name="cond_bt_handsfree">Téléphone mains-libres Bluetooth</string>
|
||||
<string name="cond_time">Heure</string>
|
||||
<string name="cond_day">Jour de la semaine</string>
|
||||
<string name="cond_date">Date précise</string>
|
||||
<string name="cond_firmware">Génération de firmware</string>
|
||||
<string name="cond_outside_temp">Température extérieure</string>
|
||||
<string name="cond_ignition">État d\'allumage</string>
|
||||
<string name="cond_in_park">Levier en position P</string>
|
||||
<string name="cond_speed">Vitesse</string>
|
||||
<string name="cond_drive_mode">Mode de conduite</string>
|
||||
<string name="cond_regen">Récupération</string>
|
||||
<string name="cond_energy_saving">Économie d\'énergie</string>
|
||||
<string name="cond_seat_heat_l">Siège chauffant gauche</string>
|
||||
<string name="cond_seat_heat_r">Siège chauffant droit</string>
|
||||
<string name="cond_steering_heat">Volant chauffant</string>
|
||||
<string name="cond_media_volume">Volume média</string>
|
||||
<string name="cond_brightness">Luminosité écran</string>
|
||||
<string name="cond_aeb">Freinage d\'urgence (AEB)</string>
|
||||
<string name="cond_aeb_mode">Mode AEB</string>
|
||||
<string name="cond_aeb_sensitivity">Sensibilité AEB</string>
|
||||
<string name="cond_elk_mode">Mode ELK</string>
|
||||
<string name="cond_elk_sensitivity">Sensibilité ELK</string>
|
||||
<string name="cond_acc_tja">Mode ACC / TJA</string>
|
||||
<string name="cond_limiter">Limiteur de vitesse</string>
|
||||
<string name="cond_tsr">Lecture des panneaux (TSR)</string>
|
||||
<string name="cond_overspeed">Alerte survitesse</string>
|
||||
<string name="cond_speed_limit_tone">Bip de limitation</string>
|
||||
<string name="cond_sound_warning">Alerte sonore ADAS</string>
|
||||
<string name="cond_ac">Climatisation active</string>
|
||||
<string name="cond_hvac_auto">Mode AUTO climatisation</string>
|
||||
<string name="cond_recirc">Recyclage d\'air actif</string>
|
||||
<string name="cond_fan_speed">Vitesse ventilation</string>
|
||||
<string name="cond_temperature_set">Température de consigne</string>
|
||||
<string name="cond_window_open">Une vitre est ouverte</string>
|
||||
<string name="act_apply_profile">Appliquer un profil</string>
|
||||
<string name="act_show_profile_picker">Demander un profil</string>
|
||||
<string name="act_drive_mode">Mode de conduite</string>
|
||||
<string name="act_regen">Récupération</string>
|
||||
<string name="act_one_pedal">Conduite une pédale</string>
|
||||
<string name="act_energy_saving">Économie d\'énergie</string>
|
||||
<string name="act_seat_heat_l">Siège chauffant gauche</string>
|
||||
<string name="act_seat_heat_r">Siège chauffant droit</string>
|
||||
<string name="act_steering_heat">Volant chauffant</string>
|
||||
<string name="act_brightness">Luminosité écran</string>
|
||||
<string name="act_media_volume">Volume média</string>
|
||||
<string name="act_balance">Balance gauche / droite</string>
|
||||
<string name="act_fader">Balance avant / arrière</string>
|
||||
<string name="act_tone">Tonalité</string>
|
||||
<string name="act_bose">Ambiance Bose</string>
|
||||
<string name="act_3d">Effet 3D</string>
|
||||
<string name="act_speed_volume">Volume adaptatif à la vitesse</string>
|
||||
<string name="act_aeb">Freinage d\'urgence (AEB)</string>
|
||||
<string name="act_aeb_mode">Mode AEB</string>
|
||||
<string name="act_aeb_sensitivity">Sensibilité AEB</string>
|
||||
<string name="act_elk_mode">Mode ELK</string>
|
||||
<string name="act_elk_sensitivity">Sensibilité ELK</string>
|
||||
<string name="act_acc_tja">Mode ACC / TJA</string>
|
||||
<string name="act_limiter">Limiteur de vitesse</string>
|
||||
<string name="act_tsr">Lecture des panneaux (TSR)</string>
|
||||
<string name="act_overspeed">Alerte survitesse</string>
|
||||
<string name="act_speed_limit_tone">Bip de limitation</string>
|
||||
<string name="act_sound_warning">Alerte sonore ADAS</string>
|
||||
<string name="act_las_sound">Alerte sonore de voie</string>
|
||||
<string name="act_las_vibration">Vibration volant en sortie de voie</string>
|
||||
<string name="act_launch_app">Lancer une application</string>
|
||||
<string name="act_notify">Afficher une notification</string>
|
||||
<string name="act_speak">Dire un message</string>
|
||||
<string name="group_energy">Énergie</string>
|
||||
<string name="unit_metre">m</string>
|
||||
<string name="cond_location">À proximité d’un lieu</string>
|
||||
<string name="cond_physical_button">Boutons physiques</string>
|
||||
<string name="cond_star_left_short">Appui court sur le bouton étoile gauche</string>
|
||||
<string name="cond_star_left_long">Appui long sur le bouton étoile gauche</string>
|
||||
<string name="cond_star_right_short">Appui court sur le bouton étoile droit</string>
|
||||
<string name="cond_star_right_long">Appui long sur le bouton étoile droit</string>
|
||||
<string name="cond_assistant_short">Appui court sur le bouton assistant</string>
|
||||
<string name="cond_assistant_long">Appui long sur le bouton assistant</string>
|
||||
<string name="cond_phone_short">Appui court sur le bouton téléphone</string><string name="cond_phone_long">Appui long sur le bouton téléphone</string>
|
||||
<string name="cond_center_short">Appui court sur le bouton central</string><string name="cond_center_long">Appui long sur le bouton central</string>
|
||||
<string name="cond_volume_up_short">Appui court sur volume plus</string><string name="cond_volume_up_long">Appui long sur volume plus</string>
|
||||
<string name="cond_volume_down_short">Appui court sur volume moins</string><string name="cond_volume_down_long">Appui long sur volume moins</string>
|
||||
<string name="cond_media_next_short">Appui court sur piste suivante</string><string name="cond_media_next_long">Appui long sur piste suivante</string>
|
||||
<string name="cond_media_previous_short">Appui court sur piste précédente</string><string name="cond_media_previous_long">Appui long sur piste précédente</string>
|
||||
<string name="cond_mute_short">Appui court sur sourdine</string><string name="cond_mute_long">Appui long sur sourdine</string>
|
||||
<string name="cond_up_short">Appui court sur haut</string><string name="cond_up_long">Appui long sur haut</string>
|
||||
<string name="cond_down_short">Appui court sur bas</string><string name="cond_down_long">Appui long sur bas</string>
|
||||
<string name="cond_ok_short">Appui court sur OK</string><string name="cond_ok_long">Appui long sur OK</string>
|
||||
<string name="cond_left_short">Appui court sur gauche</string><string name="cond_left_long">Appui long sur gauche</string>
|
||||
<string name="cond_right_short">Appui court sur droite</string><string name="cond_right_long">Appui long sur droite</string>
|
||||
<string name="cond_source_short">Appui court sur source</string><string name="cond_source_long">Appui long sur source</string>
|
||||
<string name="cond_battery">Niveau de batterie</string>
|
||||
<string name="cond_charging">En charge</string>
|
||||
<string name="cond_charge_limit">Limite de charge</string>
|
||||
<string name="cond_climate_on">Climatisation active</string>
|
||||
<string name="act_climate_power">Climatisation on/off</string>
|
||||
<string name="act_cabin_temp">Température de consigne</string>
|
||||
<string name="act_ac">Air conditionné</string>
|
||||
<string name="act_climate_auto">Climatisation AUTO</string>
|
||||
<string name="act_recirc">Recyclage de l’air</string>
|
||||
<string name="act_fan_level">Vitesse de ventilation</string>
|
||||
<string name="act_front_defrost">Dégivrage pare-brise</string>
|
||||
<string name="act_rear_defrost">Dégivrage lunette arrière</string>
|
||||
<string name="act_charge_limit">Limite de charge</string>
|
||||
<string name="act_charging_enabled">Autoriser la charge</string>
|
||||
<string name="act_charge_schedule">Charge programmée</string>
|
||||
<string name="act_charge_window">Plage de charge</string>
|
||||
<string name="act_battery_preheat">Préchauffage de la batterie</string>
|
||||
<string name="act_play_radio">Mettre la radio</string>
|
||||
<string name="act_tune_radio">Bande et station radio</string>
|
||||
<string name="act_radio_next_station">Station radio suivante</string>
|
||||
<string name="act_radio_prev_station">Station radio précédente</string>
|
||||
<string name="act_pause_radio">Couper la radio</string>
|
||||
<string name="act_radio_play_pause">Radio : lecture/pause</string>
|
||||
<string name="act_open_radio_screen">Ouvrir l’écran radio</string>
|
||||
<string name="radio_band_am">AM</string>
|
||||
<string name="radio_band_fm">FM</string>
|
||||
<string name="radio_band_dab">DAB</string>
|
||||
<string name="window_close">Fermée</string>
|
||||
<string name="window_open">Ouverte</string>
|
||||
<string name="value_radio_hint">ex. 103.5 ou AM 1080</string>
|
||||
<string name="act_navigate">Naviguer vers</string>
|
||||
<string name="act_ask_confirm">Demander confirmation</string>
|
||||
<string name="act_ask_confirm_hint">ex. : ouvrir les vitres ?</string>
|
||||
<string name="act_webhook">Appeler un webhook</string>
|
||||
<string name="act_call">Appeler</string>
|
||||
<string name="act_send_sms">Envoyer un SMS</string>
|
||||
<string name="act_send_sms_hint">ex. : j’arrive</string>
|
||||
<string name="act_call_contact">Appeler un contact</string>
|
||||
<string name="cond_window_position">Ouverture des vitres</string>
|
||||
<string name="cond_doors_locked">Portes verrouillées</string>
|
||||
<string name="act_windows">Toutes les vitres</string>
|
||||
<string name="act_door_lock">Verrouillage des portes</string>
|
||||
<string name="act_delay">Attendre</string>
|
||||
<string name="unit_second">s</string>
|
||||
<string name="cond_charging_status">État de charge</string>
|
||||
<string name="cond_charge_schedule">Charge programmée active</string>
|
||||
<string name="cond_charge_window_start">Début de charge à</string>
|
||||
<string name="cond_charge_window_stop">Fin de charge à</string>
|
||||
<string name="cond_battery_preheat">Préchauffage batterie actif</string>
|
||||
<string name="cond_passenger_temp">Température passager</string>
|
||||
<string name="cond_econ">Mode ECON actif</string>
|
||||
<string name="cond_front_defrost">Dégivrage pare-brise actif</string>
|
||||
<string name="cond_rear_defrost">Dégivrage lunette arrière actif</string>
|
||||
<string name="cond_front_door_open">Porte avant ouverte</string>
|
||||
<string name="act_passenger_temp">Température passager</string>
|
||||
<string name="act_econ">Mode ECON</string>
|
||||
<string name="charging_unplugged">Débranché</string>
|
||||
<string name="charging_ac">Charge (AC)</string>
|
||||
<string name="charging_dc">Charge (DC)</string>
|
||||
<string name="charging_plugged_idle">Branché, pas de charge</string>
|
||||
<string name="charging_done">Charge terminée</string>
|
||||
<string name="charging_fault">Défaut de charge</string>
|
||||
<string name="cond_media_playing">Lecture en cours</string>
|
||||
<string name="cond_radio_playing">Radio allumée</string>
|
||||
<string name="cond_wifi_ssid">Réseau Wi-Fi</string>
|
||||
<string name="cond_wifi_ssid_hint">ex. Maison</string>
|
||||
<string name="cond_in_call">Appel en cours</string>
|
||||
<string name="cond_drive_duration">Durée du trajet</string>
|
||||
<string name="cond_random_chance">Chance aléatoire</string>
|
||||
<string name="unit_minute">min</string>
|
||||
<string name="act_enable_rule">Activer une règle</string>
|
||||
<string name="act_disable_rule">Désactiver une règle</string>
|
||||
<string name="act_media_control">Contrôle média</string>
|
||||
<string name="media_play_pause">Lecture / pause</string>
|
||||
<string name="media_next">Piste suivante</string>
|
||||
<string name="media_previous">Piste précédente</string>
|
||||
<string name="act_set_bluetooth">Bluetooth</string>
|
||||
<string name="act_set_wifi">Wi-Fi</string>
|
||||
<string name="unit_km">km</string>
|
||||
<string name="cond_range">Autonomie restante</string>
|
||||
<string name="cond_window_driver">Ouverture vitre conducteur</string>
|
||||
<string name="cond_window_passenger">Ouverture vitre passager</string>
|
||||
<string name="cond_window_rear_left">Ouverture vitre arrière gauche</string>
|
||||
<string name="cond_window_rear_right">Ouverture vitre arrière droite</string>
|
||||
<string name="act_window_driver">Vitre conducteur</string>
|
||||
<string name="act_window_passenger">Vitre passager</string>
|
||||
<string name="act_window_rear_left">Vitre arrière gauche</string>
|
||||
<string name="act_window_rear_right">Vitre arrière droite</string>
|
||||
<string name="cond_odometer">Compteur kilométrique</string>
|
||||
<string name="cond_weather">Météo</string>
|
||||
<string name="weather_clear">Ciel dégagé</string>
|
||||
<string name="weather_cloudy">Nuageux</string>
|
||||
<string name="weather_rain">Pluie</string>
|
||||
<string name="weather_snow">Neige</string>
|
||||
<string name="weather_storm">Orage</string>
|
||||
<string name="weather_fog">Brouillard</string>
|
||||
<string name="weather_wind">Vent</string>
|
||||
<string name="act_esc">Contrôle de stabilité (ESC)</string>
|
||||
<string name="act_drowsiness">Alerte de somnolence</string>
|
||||
<string name="act_drowsiness_sensitivity">Sensibilité de l\'alerte de somnolence</string>
|
||||
<string name="act_adjust_media_volume">Volume média, par crans</string>
|
||||
<string name="cond_data_today">Données consommées aujourd\'hui</string>
|
||||
<string name="cond_data_month">Données consommées ce mois-ci</string>
|
||||
<string name="unit_megabyte">Mo</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,223 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Catalogue labels — owned by the EVHardware library (the catalogue lives here). -->
|
||||
<resources>
|
||||
<string name="group_context">Contesto</string>
|
||||
<string name="group_environment">Ambiente</string>
|
||||
<string name="group_driving">Guida</string>
|
||||
<string name="group_comfort">Comfort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Assistenza alla guida</string>
|
||||
<string name="group_climate">Clima</string>
|
||||
<string name="group_profile">Profilo</string>
|
||||
<string name="group_system">Sistema</string>
|
||||
<string name="unit_celsius">°C</string>
|
||||
<string name="unit_kmh">km/h</string>
|
||||
<string name="unit_percent">%</string>
|
||||
<string name="unit_command">cmd</string>
|
||||
<string name="drive_eco">Eco</string>
|
||||
<string name="drive_normal">Normale</string>
|
||||
<string name="drive_sport">Sport</string>
|
||||
<string name="drive_snow">Neve</string>
|
||||
<string name="drive_custom">Personalizzato</string>
|
||||
<string name="regen_low">Bassa</string>
|
||||
<string name="regen_medium">Media</string>
|
||||
<string name="regen_high">Alta</string>
|
||||
<string name="regen_adaptive">Adattivo</string>
|
||||
<string name="regen_off">Disattivato</string>
|
||||
<string name="regen_one_pedal">Un pedale</string>
|
||||
<string name="aeb_alert_only">Solo avviso</string>
|
||||
<string name="aeb_alert_brake">Avviso + frenata</string>
|
||||
<string name="sensitivity_low">Bassa</string>
|
||||
<string name="sensitivity_standard">Standard</string>
|
||||
<string name="sensitivity_high">Alta</string>
|
||||
<string name="elk_off">Disattivato</string>
|
||||
<string name="elk_warn">Avviso (LDW)</string>
|
||||
<string name="elk_assist">Assistenza (LDP)</string>
|
||||
<string name="elk_full">Mantenimento d\'emergenza (ELK)</string>
|
||||
<string name="acc_off">Disattivato</string>
|
||||
<string name="acc_acc">Cruise control adattivo (ACC)</string>
|
||||
<string name="acc_tja">Assistente ingorghi (TJA)</string>
|
||||
<string name="limiter_off">Disattivato</string>
|
||||
<string name="limiter_manual">Manuale</string>
|
||||
<string name="limiter_smart">Intelligente</string>
|
||||
<string name="ignition_lock">Bloccato</string>
|
||||
<string name="ignition_off">Spento</string>
|
||||
<string name="ignition_acc">Accessori</string>
|
||||
<string name="ignition_on">In funzione</string>
|
||||
<string name="ignition_start">Avviamento</string>
|
||||
<string name="cond_bt_device">Telefono Bluetooth connesso</string>
|
||||
<string name="cond_bt_any">Qualsiasi dispositivo Bluetooth connesso</string>
|
||||
<string name="cond_bt_onboard">Telefono a bordo (Bluetooth)</string>
|
||||
<string name="cond_bt_handsfree">Telefono vivavoce Bluetooth</string>
|
||||
<string name="cond_time">Ora del giorno</string>
|
||||
<string name="cond_day">Giorno della settimana</string>
|
||||
<string name="cond_date">Data esatta</string>
|
||||
<string name="cond_firmware">Generazione firmware</string>
|
||||
<string name="cond_outside_temp">Temperatura esterna</string>
|
||||
<string name="cond_ignition">Stato del quadro</string>
|
||||
<string name="cond_in_park">Marcia in P</string>
|
||||
<string name="cond_speed">Velocità</string>
|
||||
<string name="cond_drive_mode">Modalità di guida</string>
|
||||
<string name="cond_regen">Rigenerazione</string>
|
||||
<string name="cond_energy_saving">Risparmio energetico</string>
|
||||
<string name="cond_seat_heat_l">Riscaldamento sedile sinistro</string>
|
||||
<string name="cond_seat_heat_r">Riscaldamento sedile destro</string>
|
||||
<string name="cond_steering_heat">Riscaldamento volante</string>
|
||||
<string name="cond_media_volume">Volume multimediale</string>
|
||||
<string name="cond_brightness">Luminosità schermo</string>
|
||||
<string name="cond_aeb">Frenata d\'emergenza (AEB)</string>
|
||||
<string name="cond_aeb_mode">Modalità AEB</string>
|
||||
<string name="cond_aeb_sensitivity">Sensibilità AEB</string>
|
||||
<string name="cond_elk_mode">Modalità ELK</string>
|
||||
<string name="cond_elk_sensitivity">Sensibilità ELK</string>
|
||||
<string name="cond_acc_tja">Modalità ACC / TJA</string>
|
||||
<string name="cond_limiter">Limitatore di velocità</string>
|
||||
<string name="cond_tsr">Riconoscimento segnali (TSR)</string>
|
||||
<string name="cond_overspeed">Allarme velocità</string>
|
||||
<string name="cond_speed_limit_tone">Tono limite di velocità</string>
|
||||
<string name="cond_sound_warning">Avviso sonoro ADAS</string>
|
||||
<string name="cond_ac">Aria condizionata accesa</string>
|
||||
<string name="cond_hvac_auto">Clima AUTO acceso</string>
|
||||
<string name="cond_recirc">Ricircolo aria attivo</string>
|
||||
<string name="cond_fan_speed">Velocità ventola</string>
|
||||
<string name="cond_temperature_set">Temperatura impostata</string>
|
||||
<string name="cond_window_open">Un finestrino è aperto</string>
|
||||
<string name="act_apply_profile">Applica un profilo</string>
|
||||
<string name="act_show_profile_picker">Chiedi un profilo</string>
|
||||
<string name="act_drive_mode">Modalità di guida</string>
|
||||
<string name="act_regen">Rigenerazione</string>
|
||||
<string name="act_one_pedal">Guida a un pedale</string>
|
||||
<string name="act_energy_saving">Risparmio energetico</string>
|
||||
<string name="act_seat_heat_l">Riscaldamento sedile sinistro</string>
|
||||
<string name="act_seat_heat_r">Riscaldamento sedile destro</string>
|
||||
<string name="act_steering_heat">Riscaldamento volante</string>
|
||||
<string name="act_brightness">Luminosità schermo</string>
|
||||
<string name="act_media_volume">Volume multimediale</string>
|
||||
<string name="act_balance">Bilanciamento sinistra / destra</string>
|
||||
<string name="act_fader">Dissolvenza anteriore / posteriore</string>
|
||||
<string name="act_tone">Tono</string>
|
||||
<string name="act_bose">Profilo audio Bose</string>
|
||||
<string name="act_3d">Effetto 3D</string>
|
||||
<string name="act_speed_volume">Volume adattivo alla velocità</string>
|
||||
<string name="act_aeb">Frenata d\'emergenza (AEB)</string>
|
||||
<string name="act_aeb_mode">Modalità AEB</string>
|
||||
<string name="act_aeb_sensitivity">Sensibilità AEB</string>
|
||||
<string name="act_elk_mode">Modalità ELK</string>
|
||||
<string name="act_elk_sensitivity">Sensibilità ELK</string>
|
||||
<string name="act_acc_tja">Modalità ACC / TJA</string>
|
||||
<string name="act_limiter">Limitatore di velocità</string>
|
||||
<string name="act_tsr">Riconoscimento segnali (TSR)</string>
|
||||
<string name="act_overspeed">Allarme velocità</string>
|
||||
<string name="act_speed_limit_tone">Tono limite di velocità</string>
|
||||
<string name="act_sound_warning">Avviso sonoro ADAS</string>
|
||||
<string name="act_las_sound">Avviso sonoro cambio corsia</string>
|
||||
<string name="act_las_vibration">Vibrazione volante cambio corsia</string>
|
||||
<string name="act_launch_app">Avvia un\'app</string>
|
||||
<string name="act_notify">Mostra una notifica</string>
|
||||
<string name="act_speak">Pronuncia un messaggio</string>
|
||||
<string name="group_energy">Energia</string>
|
||||
<string name="unit_metre">m</string>
|
||||
<string name="cond_location">Vicino a un luogo</string>
|
||||
<string name="cond_physical_button">Pulsanti fisici</string>
|
||||
<string name="cond_battery">Livello batteria</string>
|
||||
<string name="cond_charging">In carica</string>
|
||||
<string name="cond_charge_limit">Limite di carica</string>
|
||||
<string name="cond_climate_on">Climatizzatore acceso</string>
|
||||
<string name="act_climate_power">Climatizzatore on/off</string>
|
||||
<string name="act_cabin_temp">Temperatura impostata</string>
|
||||
<string name="act_ac">Aria condizionata</string>
|
||||
<string name="act_climate_auto">Climatizzatore AUTO</string>
|
||||
<string name="act_recirc">Ricircolo dell’aria</string>
|
||||
<string name="act_fan_level">Velocità ventola</string>
|
||||
<string name="act_front_defrost">Sbrinamento parabrezza</string>
|
||||
<string name="act_rear_defrost">Sbrinamento lunotto</string>
|
||||
<string name="act_charge_limit">Limite di carica</string>
|
||||
<string name="act_charging_enabled">Consenti la carica</string>
|
||||
<string name="act_charge_schedule">Carica programmata</string>
|
||||
<string name="act_charge_window">Finestra di carica</string>
|
||||
<string name="act_battery_preheat">Preriscaldamento batteria</string>
|
||||
<string name="act_play_radio">Accendi la radio</string>
|
||||
<string name="act_tune_radio">Banda e stazione radio</string>
|
||||
<string name="act_radio_next_station">Stazione radio successiva</string>
|
||||
<string name="act_radio_prev_station">Stazione radio precedente</string>
|
||||
<string name="act_pause_radio">Silenzia la radio</string>
|
||||
<string name="act_radio_play_pause">Radio: riproduci/pausa</string>
|
||||
<string name="act_open_radio_screen">Apri la schermata radio</string>
|
||||
<string name="radio_band_am">AM</string>
|
||||
<string name="radio_band_fm">FM</string>
|
||||
<string name="radio_band_dab">DAB</string>
|
||||
<string name="window_close">Chiuso</string>
|
||||
<string name="window_open">Aperto</string>
|
||||
<string name="value_radio_hint">es. 103.5 o AM 1080</string>
|
||||
<string name="act_navigate">Naviga verso</string>
|
||||
<string name="act_ask_confirm">Chiedere conferma</string>
|
||||
<string name="act_ask_confirm_hint">es. aprire i finestrini?</string>
|
||||
<string name="act_call">Chiama</string>
|
||||
<string name="act_call_contact">Chiama un contatto</string>
|
||||
<string name="cond_window_position">Apertura finestrini</string>
|
||||
<string name="cond_doors_locked">Porte bloccate</string>
|
||||
<string name="act_windows">Tutti i finestrini</string>
|
||||
<string name="act_door_lock">Blocco porte</string>
|
||||
<string name="act_delay">Attendi</string>
|
||||
<string name="unit_second">s</string>
|
||||
<string name="cond_charging_status">Stato di ricarica</string>
|
||||
<string name="cond_charge_schedule">Ricarica programmata attiva</string>
|
||||
<string name="cond_charge_window_start">La ricarica inizia alle</string>
|
||||
<string name="cond_charge_window_stop">La ricarica termina alle</string>
|
||||
<string name="cond_battery_preheat">Preriscaldamento batteria attivo</string>
|
||||
<string name="cond_passenger_temp">Temperatura passeggero</string>
|
||||
<string name="cond_econ">Modalità ECON attiva</string>
|
||||
<string name="cond_front_defrost">Sbrinatore parabrezza attivo</string>
|
||||
<string name="cond_rear_defrost">Sbrinatore lunotto attivo</string>
|
||||
<string name="cond_front_door_open">Porta anteriore aperta</string>
|
||||
<string name="act_passenger_temp">Temperatura passeggero</string>
|
||||
<string name="act_econ">Modalità ECON</string>
|
||||
<string name="charging_unplugged">Scollegato</string>
|
||||
<string name="charging_ac">In ricarica (CA)</string>
|
||||
<string name="charging_dc">In ricarica (CC)</string>
|
||||
<string name="charging_plugged_idle">Collegato, non in ricarica</string>
|
||||
<string name="charging_done">Ricarica completata</string>
|
||||
<string name="charging_fault">Guasto di ricarica</string>
|
||||
<string name="cond_media_playing">Riproduzione in corso</string>
|
||||
<string name="cond_radio_playing">Radio accesa</string>
|
||||
<string name="cond_wifi_ssid">Rete Wi-Fi</string>
|
||||
<string name="cond_wifi_ssid_hint">es. Casa</string>
|
||||
<string name="cond_in_call">Chiamata in corso</string>
|
||||
<string name="cond_drive_duration">Durata del viaggio</string>
|
||||
<string name="cond_random_chance">Probabilità casuale</string>
|
||||
<string name="unit_minute">min</string>
|
||||
<string name="act_enable_rule">Attiva una regola</string>
|
||||
<string name="act_disable_rule">Disattiva una regola</string>
|
||||
<string name="act_media_control">Controllo multimediale</string>
|
||||
<string name="media_play_pause">Riproduci / pausa</string>
|
||||
<string name="media_next">Brano successivo</string>
|
||||
<string name="media_previous">Brano precedente</string>
|
||||
<string name="act_set_bluetooth">Bluetooth</string>
|
||||
<string name="act_set_wifi">Wi-Fi</string>
|
||||
<string name="unit_km">km</string>
|
||||
<string name="cond_range">Autonomia residua</string>
|
||||
<string name="cond_window_driver">Apertura finestrino guidatore</string>
|
||||
<string name="cond_window_passenger">Apertura finestrino passeggero</string>
|
||||
<string name="cond_window_rear_left">Apertura finestrino posteriore sinistro</string>
|
||||
<string name="cond_window_rear_right">Apertura finestrino posteriore destro</string>
|
||||
<string name="act_window_driver">Finestrino guidatore</string>
|
||||
<string name="act_window_passenger">Finestrino passeggero</string>
|
||||
<string name="act_window_rear_left">Finestrino posteriore sinistro</string>
|
||||
<string name="act_window_rear_right">Finestrino posteriore destro</string>
|
||||
<string name="cond_odometer">Contachilometri</string>
|
||||
<string name="cond_weather">Meteo</string>
|
||||
<string name="weather_clear">Sereno</string>
|
||||
<string name="weather_cloudy">Nuvoloso</string>
|
||||
<string name="weather_rain">Pioggia</string>
|
||||
<string name="weather_snow">Neve</string>
|
||||
<string name="weather_storm">Temporale</string>
|
||||
<string name="weather_fog">Nebbia</string>
|
||||
<string name="weather_wind">Vento</string>
|
||||
<string name="act_esc">Controllo di stabilità (ESC)</string>
|
||||
<string name="act_drowsiness">Allerta sonnolenza</string>
|
||||
<string name="act_drowsiness_sensitivity">Sensibilità dell\'allerta sonnolenza</string>
|
||||
<string name="act_adjust_media_volume">Volume media, a passi</string>
|
||||
<string name="cond_data_today">Dati consumati oggi</string>
|
||||
<string name="cond_data_month">Dati consumati questo mese</string>
|
||||
<string name="unit_megabyte">MB</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,223 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Catalogue labels — owned by the EVHardware library (the catalogue lives here). -->
|
||||
<resources>
|
||||
<string name="group_context">Contexto</string>
|
||||
<string name="group_environment">Ambiente</string>
|
||||
<string name="group_driving">Condução</string>
|
||||
<string name="group_comfort">Conforto</string>
|
||||
<string name="group_audio">Áudio</string>
|
||||
<string name="group_adas">Assistência à condução</string>
|
||||
<string name="group_climate">Climatização</string>
|
||||
<string name="group_profile">Perfil</string>
|
||||
<string name="group_system">Sistema</string>
|
||||
<string name="unit_celsius">°C</string>
|
||||
<string name="unit_kmh">km/h</string>
|
||||
<string name="unit_percent">%</string>
|
||||
<string name="unit_command">cmd</string>
|
||||
<string name="drive_eco">Eco</string>
|
||||
<string name="drive_normal">Normal</string>
|
||||
<string name="drive_sport">Sport</string>
|
||||
<string name="drive_snow">Neve</string>
|
||||
<string name="drive_custom">Personalizado</string>
|
||||
<string name="regen_low">Baixo</string>
|
||||
<string name="regen_medium">Médio</string>
|
||||
<string name="regen_high">Alto</string>
|
||||
<string name="regen_adaptive">Adaptativo</string>
|
||||
<string name="regen_off">Desativado</string>
|
||||
<string name="regen_one_pedal">Um pedal</string>
|
||||
<string name="aeb_alert_only">Apenas alerta</string>
|
||||
<string name="aeb_alert_brake">Alerta + travagem</string>
|
||||
<string name="sensitivity_low">Baixa</string>
|
||||
<string name="sensitivity_standard">Padrão</string>
|
||||
<string name="sensitivity_high">Alta</string>
|
||||
<string name="elk_off">Desativado</string>
|
||||
<string name="elk_warn">Aviso (LDW)</string>
|
||||
<string name="elk_assist">Assistência (LDP)</string>
|
||||
<string name="elk_full">Manutenção de emergência (ELK)</string>
|
||||
<string name="acc_off">Desativado</string>
|
||||
<string name="acc_acc">Piloto automático adaptativo (ACC)</string>
|
||||
<string name="acc_tja">Assistente de congestionamento (TJA)</string>
|
||||
<string name="limiter_off">Desativado</string>
|
||||
<string name="limiter_manual">Manual</string>
|
||||
<string name="limiter_smart">Inteligente</string>
|
||||
<string name="ignition_lock">Bloqueado</string>
|
||||
<string name="ignition_off">Desligado</string>
|
||||
<string name="ignition_acc">Acessórios</string>
|
||||
<string name="ignition_on">Em funcionamento</string>
|
||||
<string name="ignition_start">A arrancar</string>
|
||||
<string name="cond_bt_device">Telefone Bluetooth ligado</string>
|
||||
<string name="cond_bt_any">Qualquer dispositivo Bluetooth ligado</string>
|
||||
<string name="cond_bt_onboard">Telefone a bordo (Bluetooth)</string>
|
||||
<string name="cond_bt_handsfree">Telefone mãos-livres Bluetooth</string>
|
||||
<string name="cond_time">Hora do dia</string>
|
||||
<string name="cond_day">Dia da semana</string>
|
||||
<string name="cond_date">Data exata</string>
|
||||
<string name="cond_firmware">Geração de firmware</string>
|
||||
<string name="cond_outside_temp">Temperatura exterior</string>
|
||||
<string name="cond_ignition">Estado da ignição</string>
|
||||
<string name="cond_in_park">Mudança em P</string>
|
||||
<string name="cond_speed">Velocidade</string>
|
||||
<string name="cond_drive_mode">Modo de condução</string>
|
||||
<string name="cond_regen">Regeneração</string>
|
||||
<string name="cond_energy_saving">Poupança de energia</string>
|
||||
<string name="cond_seat_heat_l">Aquecimento banco esquerdo</string>
|
||||
<string name="cond_seat_heat_r">Aquecimento banco direito</string>
|
||||
<string name="cond_steering_heat">Aquecimento do volante</string>
|
||||
<string name="cond_media_volume">Volume multimédia</string>
|
||||
<string name="cond_brightness">Brilho do ecrã</string>
|
||||
<string name="cond_aeb">Travagem de emergência (AEB)</string>
|
||||
<string name="cond_aeb_mode">Modo AEB</string>
|
||||
<string name="cond_aeb_sensitivity">Sensibilidade AEB</string>
|
||||
<string name="cond_elk_mode">Modo ELK</string>
|
||||
<string name="cond_elk_sensitivity">Sensibilidade ELK</string>
|
||||
<string name="cond_acc_tja">Modo ACC / TJA</string>
|
||||
<string name="cond_limiter">Limitador de velocidade</string>
|
||||
<string name="cond_tsr">Reconhecimento de sinais (TSR)</string>
|
||||
<string name="cond_overspeed">Alarme de excesso de velocidade</string>
|
||||
<string name="cond_speed_limit_tone">Tom de limite de velocidade</string>
|
||||
<string name="cond_sound_warning">Aviso sonoro ADAS</string>
|
||||
<string name="cond_ac">Ar condicionado ligado</string>
|
||||
<string name="cond_hvac_auto">Clima AUTO ligado</string>
|
||||
<string name="cond_recirc">Recirculação de ar ativa</string>
|
||||
<string name="cond_fan_speed">Velocidade da ventoinha</string>
|
||||
<string name="cond_temperature_set">Temperatura definida</string>
|
||||
<string name="cond_window_open">Uma janela está aberta</string>
|
||||
<string name="act_apply_profile">Aplicar um perfil</string>
|
||||
<string name="act_show_profile_picker">Pedir um perfil</string>
|
||||
<string name="act_drive_mode">Modo de condução</string>
|
||||
<string name="act_regen">Regeneração</string>
|
||||
<string name="act_one_pedal">Condução com um pedal</string>
|
||||
<string name="act_energy_saving">Poupança de energia</string>
|
||||
<string name="act_seat_heat_l">Aquecimento banco esquerdo</string>
|
||||
<string name="act_seat_heat_r">Aquecimento banco direito</string>
|
||||
<string name="act_steering_heat">Aquecimento do volante</string>
|
||||
<string name="act_brightness">Brilho do ecrã</string>
|
||||
<string name="act_media_volume">Volume multimédia</string>
|
||||
<string name="act_balance">Balanço esquerda / direita</string>
|
||||
<string name="act_fader">Fader frente / trás</string>
|
||||
<string name="act_tone">Tom</string>
|
||||
<string name="act_bose">Perfil de som Bose</string>
|
||||
<string name="act_3d">Efeito 3D</string>
|
||||
<string name="act_speed_volume">Volume adaptado à velocidade</string>
|
||||
<string name="act_aeb">Travagem de emergência (AEB)</string>
|
||||
<string name="act_aeb_mode">Modo AEB</string>
|
||||
<string name="act_aeb_sensitivity">Sensibilidade AEB</string>
|
||||
<string name="act_elk_mode">Modo ELK</string>
|
||||
<string name="act_elk_sensitivity">Sensibilidade ELK</string>
|
||||
<string name="act_acc_tja">Modo ACC / TJA</string>
|
||||
<string name="act_limiter">Limitador de velocidade</string>
|
||||
<string name="act_tsr">Reconhecimento de sinais (TSR)</string>
|
||||
<string name="act_overspeed">Alarme de excesso de velocidade</string>
|
||||
<string name="act_speed_limit_tone">Tom de limite de velocidade</string>
|
||||
<string name="act_sound_warning">Aviso sonoro ADAS</string>
|
||||
<string name="act_las_sound">Aviso sonoro de saída de faixa</string>
|
||||
<string name="act_las_vibration">Vibração do volante por saída de faixa</string>
|
||||
<string name="act_launch_app">Abrir uma app</string>
|
||||
<string name="act_notify">Mostrar uma notificação</string>
|
||||
<string name="act_speak">Dizer uma mensagem</string>
|
||||
<string name="group_energy">Energia</string>
|
||||
<string name="unit_metre">m</string>
|
||||
<string name="cond_location">Perto de um local</string>
|
||||
<string name="cond_physical_button">Botões físicos</string>
|
||||
<string name="cond_battery">Nível da bateria</string>
|
||||
<string name="cond_charging">A carregar</string>
|
||||
<string name="cond_charge_limit">Limite de carga</string>
|
||||
<string name="cond_climate_on">Climatização ligada</string>
|
||||
<string name="act_climate_power">Climatização on/off</string>
|
||||
<string name="act_cabin_temp">Temperatura definida</string>
|
||||
<string name="act_ac">Ar condicionado</string>
|
||||
<string name="act_climate_auto">Climatização AUTO</string>
|
||||
<string name="act_recirc">Recirculação do ar</string>
|
||||
<string name="act_fan_level">Velocidade da ventoinha</string>
|
||||
<string name="act_front_defrost">Desembaciador do para-brisas</string>
|
||||
<string name="act_rear_defrost">Desembaciador do óculo traseiro</string>
|
||||
<string name="act_charge_limit">Limite de carga</string>
|
||||
<string name="act_charging_enabled">Permitir o carregamento</string>
|
||||
<string name="act_charge_schedule">Carregamento programado</string>
|
||||
<string name="act_charge_window">Janela de carregamento</string>
|
||||
<string name="act_battery_preheat">Pré-aquecimento da bateria</string>
|
||||
<string name="act_play_radio">Ligar o rádio</string>
|
||||
<string name="act_tune_radio">Banda e estação de rádio</string>
|
||||
<string name="act_radio_next_station">Estação de rádio seguinte</string>
|
||||
<string name="act_radio_prev_station">Estação de rádio anterior</string>
|
||||
<string name="act_pause_radio">Silenciar o rádio</string>
|
||||
<string name="act_radio_play_pause">Rádio: reproduzir/pausa</string>
|
||||
<string name="act_open_radio_screen">Abrir o ecrã do rádio</string>
|
||||
<string name="radio_band_am">AM</string>
|
||||
<string name="radio_band_fm">FM</string>
|
||||
<string name="radio_band_dab">DAB</string>
|
||||
<string name="window_close">Fechado</string>
|
||||
<string name="window_open">Aberto</string>
|
||||
<string name="value_radio_hint">ex. 103.5 ou AM 1080</string>
|
||||
<string name="act_navigate">Navegar para</string>
|
||||
<string name="act_ask_confirm">Pedir confirmação</string>
|
||||
<string name="act_ask_confirm_hint">ex.: abrir os vidros?</string>
|
||||
<string name="act_call">Ligar</string>
|
||||
<string name="act_call_contact">Ligar para um contacto</string>
|
||||
<string name="cond_window_position">Abertura dos vidros</string>
|
||||
<string name="cond_doors_locked">Portas trancadas</string>
|
||||
<string name="act_windows">Todos os vidros</string>
|
||||
<string name="act_door_lock">Trancamento das portas</string>
|
||||
<string name="act_delay">Esperar</string>
|
||||
<string name="unit_second">s</string>
|
||||
<string name="cond_charging_status">Estado de carregamento</string>
|
||||
<string name="cond_charge_schedule">Carregamento programado ativo</string>
|
||||
<string name="cond_charge_window_start">Carregamento começa às</string>
|
||||
<string name="cond_charge_window_stop">Carregamento termina às</string>
|
||||
<string name="cond_battery_preheat">Pré-aquecimento da bateria ativo</string>
|
||||
<string name="cond_passenger_temp">Temperatura do passageiro</string>
|
||||
<string name="cond_econ">Modo ECON ativo</string>
|
||||
<string name="cond_front_defrost">Desembaciador dianteiro ativo</string>
|
||||
<string name="cond_rear_defrost">Desembaciador traseiro ativo</string>
|
||||
<string name="cond_front_door_open">Porta dianteira aberta</string>
|
||||
<string name="act_passenger_temp">Temperatura do passageiro</string>
|
||||
<string name="act_econ">Modo ECON</string>
|
||||
<string name="charging_unplugged">Desligado</string>
|
||||
<string name="charging_ac">A carregar (CA)</string>
|
||||
<string name="charging_dc">A carregar (CC)</string>
|
||||
<string name="charging_plugged_idle">Ligado, sem carregar</string>
|
||||
<string name="charging_done">Carga concluída</string>
|
||||
<string name="charging_fault">Falha de carregamento</string>
|
||||
<string name="cond_media_playing">Reprodução em curso</string>
|
||||
<string name="cond_radio_playing">Rádio ligado</string>
|
||||
<string name="cond_wifi_ssid">Rede Wi-Fi</string>
|
||||
<string name="cond_wifi_ssid_hint">p. ex. Casa</string>
|
||||
<string name="cond_in_call">Chamada em curso</string>
|
||||
<string name="cond_drive_duration">Duração da viagem</string>
|
||||
<string name="cond_random_chance">Probabilidade aleatória</string>
|
||||
<string name="unit_minute">min</string>
|
||||
<string name="act_enable_rule">Ativar uma regra</string>
|
||||
<string name="act_disable_rule">Desativar uma regra</string>
|
||||
<string name="act_media_control">Controlo multimédia</string>
|
||||
<string name="media_play_pause">Reproduzir / pausa</string>
|
||||
<string name="media_next">Faixa seguinte</string>
|
||||
<string name="media_previous">Faixa anterior</string>
|
||||
<string name="act_set_bluetooth">Bluetooth</string>
|
||||
<string name="act_set_wifi">Wi-Fi</string>
|
||||
<string name="unit_km">km</string>
|
||||
<string name="cond_range">Autonomia restante</string>
|
||||
<string name="cond_window_driver">Abertura do vidro do condutor</string>
|
||||
<string name="cond_window_passenger">Abertura do vidro do passageiro</string>
|
||||
<string name="cond_window_rear_left">Abertura do vidro traseiro esquerdo</string>
|
||||
<string name="cond_window_rear_right">Abertura do vidro traseiro direito</string>
|
||||
<string name="act_window_driver">Vidro do condutor</string>
|
||||
<string name="act_window_passenger">Vidro do passageiro</string>
|
||||
<string name="act_window_rear_left">Vidro traseiro esquerdo</string>
|
||||
<string name="act_window_rear_right">Vidro traseiro direito</string>
|
||||
<string name="cond_odometer">Conta-quilómetros</string>
|
||||
<string name="cond_weather">Meteorologia</string>
|
||||
<string name="weather_clear">Céu limpo</string>
|
||||
<string name="weather_cloudy">Nublado</string>
|
||||
<string name="weather_rain">Chuva</string>
|
||||
<string name="weather_snow">Neve</string>
|
||||
<string name="weather_storm">Trovoada</string>
|
||||
<string name="weather_fog">Nevoeiro</string>
|
||||
<string name="weather_wind">Vento</string>
|
||||
<string name="act_esc">Controlo de estabilidade (ESC)</string>
|
||||
<string name="act_drowsiness">Alerta de sonolência</string>
|
||||
<string name="act_drowsiness_sensitivity">Sensibilidade do alerta de sonolência</string>
|
||||
<string name="act_adjust_media_volume">Volume de multimédia, por passos</string>
|
||||
<string name="cond_data_today">Dados consumidos hoje</string>
|
||||
<string name="cond_data_month">Dados consumidos este mês</string>
|
||||
<string name="unit_megabyte">MB</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,245 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Catalogue labels — owned by the EVHardware library (the catalogue lives here). -->
|
||||
<resources>
|
||||
<string name="group_context">Context</string>
|
||||
<string name="group_environment">Environment</string>
|
||||
<string name="group_driving">Driving</string>
|
||||
<string name="group_comfort">Comfort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Driver assistance</string>
|
||||
<string name="group_climate">Climate</string>
|
||||
<string name="group_profile">Profile</string>
|
||||
<string name="group_system">System</string>
|
||||
<string name="unit_celsius">°C</string>
|
||||
<string name="unit_kmh">km/h</string>
|
||||
<string name="unit_percent">%</string>
|
||||
<string name="unit_command">cmd</string>
|
||||
<string name="drive_eco">Eco</string>
|
||||
<string name="drive_normal">Normal</string>
|
||||
<string name="drive_sport">Sport</string>
|
||||
<string name="drive_snow">Snow</string>
|
||||
<string name="drive_custom">Custom</string>
|
||||
<string name="regen_low">Low</string>
|
||||
<string name="regen_medium">Medium</string>
|
||||
<string name="regen_high">High</string>
|
||||
<string name="regen_adaptive">Adaptive</string>
|
||||
<string name="regen_off">Off</string>
|
||||
<string name="regen_one_pedal">One pedal</string>
|
||||
<string name="aeb_alert_only">Alert only</string>
|
||||
<string name="aeb_alert_brake">Alert + braking</string>
|
||||
<string name="sensitivity_low">Low</string>
|
||||
<string name="sensitivity_standard">Standard</string>
|
||||
<string name="sensitivity_high">High</string>
|
||||
<string name="elk_off">Off</string>
|
||||
<string name="elk_warn">Warning (LDW)</string>
|
||||
<string name="elk_assist">Assist (LDP)</string>
|
||||
<string name="elk_full">Emergency keeping (ELK)</string>
|
||||
<string name="acc_off">Off</string>
|
||||
<string name="acc_acc">Adaptive cruise (ACC)</string>
|
||||
<string name="acc_tja">Traffic jam assist (TJA)</string>
|
||||
<string name="limiter_off">Off</string>
|
||||
<string name="limiter_manual">Manual</string>
|
||||
<string name="limiter_smart">Intelligent</string>
|
||||
<string name="ignition_lock">Locked</string>
|
||||
<string name="ignition_off">Off</string>
|
||||
<string name="ignition_acc">Accessory</string>
|
||||
<string name="ignition_on">Running</string>
|
||||
<string name="ignition_start">Starting</string>
|
||||
<string name="cond_bt_device">Bluetooth phone connected</string>
|
||||
<string name="cond_bt_any">Any Bluetooth device connected</string>
|
||||
<string name="cond_bt_onboard">Phone on board (Bluetooth)</string>
|
||||
<string name="cond_bt_handsfree">Bluetooth hands-free phone</string>
|
||||
<string name="cond_time">Time of day</string>
|
||||
<string name="cond_day">Day of week</string>
|
||||
<string name="cond_date">Exact date</string>
|
||||
<string name="cond_firmware">Firmware generation</string>
|
||||
<string name="cond_outside_temp">Outside temperature</string>
|
||||
<string name="cond_ignition">Ignition state</string>
|
||||
<string name="cond_in_park">Gear in P</string>
|
||||
<string name="cond_speed">Speed</string>
|
||||
<string name="cond_drive_mode">Drive mode</string>
|
||||
<string name="cond_regen">Regeneration</string>
|
||||
<string name="cond_energy_saving">Energy saving</string>
|
||||
<string name="cond_seat_heat_l">Left seat heating</string>
|
||||
<string name="cond_seat_heat_r">Right seat heating</string>
|
||||
<string name="cond_steering_heat">Steering wheel heating</string>
|
||||
<string name="cond_media_volume">Media volume</string>
|
||||
<string name="cond_brightness">Screen brightness</string>
|
||||
<string name="cond_aeb">Emergency braking (AEB)</string>
|
||||
<string name="cond_aeb_mode">AEB mode</string>
|
||||
<string name="cond_aeb_sensitivity">AEB sensitivity</string>
|
||||
<string name="cond_elk_mode">ELK mode</string>
|
||||
<string name="cond_elk_sensitivity">ELK sensitivity</string>
|
||||
<string name="cond_acc_tja">ACC / TJA mode</string>
|
||||
<string name="cond_limiter">Speed limiter</string>
|
||||
<string name="cond_tsr">Traffic sign recognition (TSR)</string>
|
||||
<string name="cond_overspeed">Overspeed alarm</string>
|
||||
<string name="cond_speed_limit_tone">Speed limit tone</string>
|
||||
<string name="cond_sound_warning">ADAS sound warning</string>
|
||||
<string name="cond_ac">Air conditioning on</string>
|
||||
<string name="cond_hvac_auto">Climate AUTO on</string>
|
||||
<string name="cond_recirc">Air recirculation on</string>
|
||||
<string name="cond_fan_speed">Fan speed</string>
|
||||
<string name="cond_temperature_set">Set temperature</string>
|
||||
<string name="cond_window_open">A window is open</string>
|
||||
<string name="act_apply_profile">Apply a profile</string>
|
||||
<string name="act_show_profile_picker">Ask for a profile</string>
|
||||
<string name="act_drive_mode">Drive mode</string>
|
||||
<string name="act_regen">Regeneration</string>
|
||||
<string name="act_one_pedal">One pedal driving</string>
|
||||
<string name="act_energy_saving">Energy saving</string>
|
||||
<string name="act_seat_heat_l">Left seat heating</string>
|
||||
<string name="act_seat_heat_r">Right seat heating</string>
|
||||
<string name="act_steering_heat">Steering wheel heating</string>
|
||||
<string name="act_brightness">Screen brightness</string>
|
||||
<string name="act_media_volume">Media volume</string>
|
||||
<string name="act_balance">Left / right balance</string>
|
||||
<string name="act_fader">Front / rear fader</string>
|
||||
<string name="act_tone">Tone</string>
|
||||
<string name="act_bose">Bose sound profile</string>
|
||||
<string name="act_3d">3D effect</string>
|
||||
<string name="act_speed_volume">Speed-adaptive volume</string>
|
||||
<string name="act_aeb">Emergency braking (AEB)</string>
|
||||
<string name="act_aeb_mode">AEB mode</string>
|
||||
<string name="act_aeb_sensitivity">AEB sensitivity</string>
|
||||
<string name="act_elk_mode">ELK mode</string>
|
||||
<string name="act_elk_sensitivity">ELK sensitivity</string>
|
||||
<string name="act_acc_tja">ACC / TJA mode</string>
|
||||
<string name="act_limiter">Speed limiter</string>
|
||||
<string name="act_tsr">Traffic sign recognition (TSR)</string>
|
||||
<string name="act_overspeed">Overspeed alarm</string>
|
||||
<string name="act_speed_limit_tone">Speed limit tone</string>
|
||||
<string name="act_sound_warning">ADAS sound warning</string>
|
||||
<string name="act_las_sound">Lane departure sound warning</string>
|
||||
<string name="act_las_vibration">Lane departure steering vibration</string>
|
||||
<string name="act_launch_app">Launch an app</string>
|
||||
<string name="act_notify">Show a notification</string>
|
||||
<string name="act_speak">Speak a message</string>
|
||||
<string name="group_energy">Energy</string>
|
||||
<string name="unit_metre">m</string>
|
||||
<string name="cond_location">Near a place</string>
|
||||
<string name="cond_physical_button">Physical buttons</string>
|
||||
<string name="cond_star_left_short">Left star button short press</string>
|
||||
<string name="cond_star_left_long">Left star button long press</string>
|
||||
<string name="cond_star_right_short">Right star button short press</string>
|
||||
<string name="cond_star_right_long">Right star button long press</string>
|
||||
<string name="cond_assistant_short">Assistant button short press</string>
|
||||
<string name="cond_assistant_long">Assistant button long press</string>
|
||||
<string name="cond_phone_short">Phone button short press</string><string name="cond_phone_long">Phone button long press</string>
|
||||
<string name="cond_center_short">Center button short press</string><string name="cond_center_long">Center button long press</string>
|
||||
<string name="cond_volume_up_short">Volume up short press</string><string name="cond_volume_up_long">Volume up long press</string>
|
||||
<string name="cond_volume_down_short">Volume down short press</string><string name="cond_volume_down_long">Volume down long press</string>
|
||||
<string name="cond_media_next_short">Next track short press</string><string name="cond_media_next_long">Next track long press</string>
|
||||
<string name="cond_media_previous_short">Previous track short press</string><string name="cond_media_previous_long">Previous track long press</string>
|
||||
<string name="cond_mute_short">Mute button short press</string><string name="cond_mute_long">Mute button long press</string>
|
||||
<string name="cond_up_short">Up button short press</string><string name="cond_up_long">Up button long press</string>
|
||||
<string name="cond_down_short">Down button short press</string><string name="cond_down_long">Down button long press</string>
|
||||
<string name="cond_ok_short">OK button short press</string><string name="cond_ok_long">OK button long press</string>
|
||||
<string name="cond_left_short">Left button short press</string><string name="cond_left_long">Left button long press</string>
|
||||
<string name="cond_right_short">Right button short press</string><string name="cond_right_long">Right button long press</string>
|
||||
<string name="cond_source_short">Source button short press</string><string name="cond_source_long">Source button long press</string>
|
||||
<string name="cond_battery">Battery level</string>
|
||||
<string name="cond_charging">Charging</string>
|
||||
<string name="cond_charge_limit">Charge limit</string>
|
||||
<string name="cond_climate_on">Climate on</string>
|
||||
<string name="act_climate_power">Climate on/off</string>
|
||||
<string name="act_cabin_temp">Cabin temperature</string>
|
||||
<string name="act_ac">Air conditioning</string>
|
||||
<string name="act_climate_auto">Climate AUTO</string>
|
||||
<string name="act_recirc">Air recirculation</string>
|
||||
<string name="act_fan_level">Fan level</string>
|
||||
<string name="act_front_defrost">Windscreen defroster</string>
|
||||
<string name="act_rear_defrost">Rear window defroster</string>
|
||||
<string name="act_charge_limit">Charge limit</string>
|
||||
<string name="act_charging_enabled">Allow charging</string>
|
||||
<string name="act_charge_schedule">Scheduled charging</string>
|
||||
<string name="act_charge_window">Charging window</string>
|
||||
<string name="act_battery_preheat">Battery pre-heating</string>
|
||||
<string name="act_play_radio">Play the radio</string>
|
||||
<string name="act_tune_radio">Radio band and station</string>
|
||||
<string name="act_radio_next_station">Next radio station</string>
|
||||
<string name="act_radio_prev_station">Previous radio station</string>
|
||||
<string name="act_pause_radio">Silence the radio</string>
|
||||
<string name="act_radio_play_pause">Radio play/pause</string>
|
||||
<string name="act_open_radio_screen">Open the radio screen</string>
|
||||
<string name="radio_band_am">AM</string>
|
||||
<string name="radio_band_fm">FM</string>
|
||||
<string name="radio_band_dab">DAB</string>
|
||||
<string name="window_close">Closed</string>
|
||||
<string name="window_open">Open</string>
|
||||
<string name="value_radio_hint">e.g. 103.5 or AM 1080</string>
|
||||
<string name="act_navigate">Navigate to</string>
|
||||
<string name="act_ask_confirm">Ask for confirmation</string>
|
||||
<string name="act_ask_confirm_hint">e.g. Open the windows?</string>
|
||||
<string name="act_webhook">Call webhook</string>
|
||||
<string name="act_call">Call</string>
|
||||
<string name="act_send_sms">Send a text message</string>
|
||||
<string name="act_send_sms_hint">e.g. On my way</string>
|
||||
<string name="act_call_contact">Call a contact</string>
|
||||
<string name="cond_window_position">Window opening</string>
|
||||
<string name="cond_doors_locked">Doors locked</string>
|
||||
<string name="act_windows">All windows</string>
|
||||
<string name="act_door_lock">Door locks</string>
|
||||
<string name="act_delay">Wait</string>
|
||||
<string name="unit_second">s</string>
|
||||
<string name="cond_charging_status">Charging state</string>
|
||||
<string name="cond_charge_schedule">Scheduled charging on</string>
|
||||
<string name="cond_charge_window_start">Charging starts at</string>
|
||||
<string name="cond_charge_window_stop">Charging stops at</string>
|
||||
<string name="cond_battery_preheat">Battery pre-heating on</string>
|
||||
<string name="cond_passenger_temp">Passenger temperature</string>
|
||||
<string name="cond_econ">ECON mode on</string>
|
||||
<string name="cond_front_defrost">Windscreen defroster on</string>
|
||||
<string name="cond_rear_defrost">Rear window defroster on</string>
|
||||
<string name="cond_front_door_open">Front door open</string>
|
||||
<string name="act_passenger_temp">Passenger temperature</string>
|
||||
<string name="act_econ">ECON mode</string>
|
||||
<string name="charging_unplugged">Unplugged</string>
|
||||
<string name="charging_ac">Charging (AC)</string>
|
||||
<string name="charging_dc">Charging (DC)</string>
|
||||
<string name="charging_plugged_idle">Plugged in, not charging</string>
|
||||
<string name="charging_done">Charge complete</string>
|
||||
<string name="charging_fault">Charging fault</string>
|
||||
<string name="cond_media_playing">Media playing</string>
|
||||
<string name="cond_radio_playing">Radio playing</string>
|
||||
<string name="cond_wifi_ssid">Wi-Fi network</string>
|
||||
<string name="cond_wifi_ssid_hint">e.g. Home</string>
|
||||
<string name="cond_in_call">Call in progress</string>
|
||||
<string name="cond_drive_duration">Drive duration</string>
|
||||
<string name="cond_random_chance">Random chance</string>
|
||||
<string name="unit_minute">min</string>
|
||||
<string name="act_enable_rule">Enable a rule</string>
|
||||
<string name="act_disable_rule">Disable a rule</string>
|
||||
<string name="act_media_control">Media control</string>
|
||||
<string name="media_play_pause">Play / pause</string>
|
||||
<string name="media_next">Next track</string>
|
||||
<string name="media_previous">Previous track</string>
|
||||
<string name="act_set_bluetooth">Bluetooth</string>
|
||||
<string name="act_set_wifi">Wi-Fi</string>
|
||||
<string name="unit_km">km</string>
|
||||
<string name="cond_range">Remaining range</string>
|
||||
<string name="cond_window_driver">Driver window opening</string>
|
||||
<string name="cond_window_passenger">Passenger window opening</string>
|
||||
<string name="cond_window_rear_left">Rear left window opening</string>
|
||||
<string name="cond_window_rear_right">Rear right window opening</string>
|
||||
<string name="act_window_driver">Driver window</string>
|
||||
<string name="act_window_passenger">Passenger window</string>
|
||||
<string name="act_window_rear_left">Rear left window</string>
|
||||
<string name="act_window_rear_right">Rear right window</string>
|
||||
<string name="cond_odometer">Odometer</string>
|
||||
<string name="cond_weather">Weather</string>
|
||||
<string name="weather_clear">Clear</string>
|
||||
<string name="weather_cloudy">Cloudy</string>
|
||||
<string name="weather_rain">Rain</string>
|
||||
<string name="weather_snow">Snow</string>
|
||||
<string name="weather_storm">Thunderstorm</string>
|
||||
<string name="weather_fog">Fog</string>
|
||||
<string name="weather_wind">Wind</string>
|
||||
<string name="act_esc">Stability control (ESC)</string>
|
||||
<string name="act_drowsiness">Drowsiness warning</string>
|
||||
<string name="act_drowsiness_sensitivity">Drowsiness warning sensitivity</string>
|
||||
<string name="act_adjust_media_volume">Media volume, step</string>
|
||||
<string name="cond_data_today">Data used today</string>
|
||||
<string name="cond_data_month">Data used this month</string>
|
||||
<string name="unit_megabyte">MB</string>
|
||||
</resources>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int>()
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<Int>().accept(2))
|
||||
}
|
||||
|
||||
@Test fun `a repeat is suppressed`() {
|
||||
val gate = StateChangeLog<Int>()
|
||||
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<Int>()
|
||||
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<Int>()
|
||||
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<Int?>()
|
||||
assertTrue(gate.accept(null))
|
||||
assertFalse(gate.accept(null))
|
||||
assertTrue(gate.accept(1))
|
||||
}
|
||||
|
||||
@Test fun `reset makes the next value log again`() {
|
||||
val gate = StateChangeLog<Int>()
|
||||
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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Pair<Int, Int>>()
|
||||
|
||||
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<Pair<Int, Int>>()
|
||||
|
||||
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<Pair<Int, Int>>()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int, String> = 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<String, String> = File("src/main/res/values/strings.xml")
|
||||
.readText()
|
||||
.let { Regex("""<string name="([^"]+)"[^>]*>(.*?)</string>""", 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<FirmwareGen>?): String = when (gens) {
|
||||
null -> "any"
|
||||
else -> columns.filter { it in gens }.joinToString(", ") { it.name }.ifEmpty { "none" }
|
||||
}
|
||||
|
||||
private fun render(): String = buildString {
|
||||
appendLine("<!-- GENERATED by CatalogueDocTest; do not edit by hand. -->")
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ByteArray, ByteArray, ByteArray> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<String, RuntimeInterfaceProbe.Call>(), writes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the property survey names no setter`() {
|
||||
val writes = RuntimeInterfaceProbe.VEHICLE_PROPERTY_CALLS
|
||||
.filterValues { it.code in RuntimeInterfaceProbe.PROPERTY_WRITES }
|
||||
assertEquals(emptyMap<String, RuntimeInterfaceProbe.Call>(), 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<String, RuntimeInterfaceProbe.Call>(), 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<String, RuntimeInterfaceProbe.Call>(), 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)
|
||||
}
|
||||
}
|
||||
@@ -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<NavigationHandoff.Poi>): ByteArray =
|
||||
bytes { parcel -> with(SaicNavGuidance) { parcel.writeEvRoutPoiList(pois) } }
|
||||
|
||||
private fun framework(pois: List<NavigationHandoff.Poi>): 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int, Int>(), 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<Int, Int>(), census.snapshot())
|
||||
assertEquals(emptyMap<Int, Int>(), 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user