diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt index f1c1fe8..9e2c1c9 100644 --- a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt @@ -2115,7 +2115,14 @@ object EVHardware { private val sGearLog = StateChangeLog() private val sIgnitionLog = StateChangeLog() - fun isVehicleInPark(): Boolean? { + fun isVehicleInPark(): Boolean? = getGearRaw()?.let { it == GEAR_PARK_VALUE } + + /** + * The gear as the car's own code, null if unreadable. Only P = 1 is established (see + * [isVehicleInPark]); the other codes are passed on unnamed so the app can show them and + * the driver can match them to R, N and D. + */ + fun getGearRaw(): Int? { val gen = FirmwareInfo.getGeneration() val gear = when { gen == FirmwareInfo.Gen.SWI133 -> @@ -2132,7 +2139,7 @@ object EVHardware { if (sGearLog.accept(gear)) { AppLogger.i(TAG, "isVehicleInPark — gen=$gen gear=$gear (P=$GEAR_PARK_VALUE)") } - return gear == GEAR_PARK_VALUE + return gear } // ── Read gear SWI68/165 (poll direct) + A9 (CarStateClient) ─────────── diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergySnapshot.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergySnapshot.kt index 6ec277b..dc174f0 100644 --- a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergySnapshot.kt +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergySnapshot.kt @@ -48,6 +48,8 @@ data class EnergySnapshot( */ val driveModeRaw: Int? = null, val regenLevelRaw: Int? = null, + /** The gear's raw code, unmapped beyond P = 1; null where the car did not answer. */ + val gearRaw: Int? = null, ) { val hasVehicleData: Boolean get() = socPercent != null || rangeKm != null || speedKmh != null || @@ -56,7 +58,8 @@ data class EnergySnapshot( odometerKm != null || chargePortConnected != null || chargingStatus != null || vehicleConsumedKwh != null || vehicleRegeneratedKwh != null || parked != null || climate.hasData || tirePressures.hasData || - widestWindowPercent != null || driveModeRaw != null || regenLevelRaw != null + widestWindowPercent != null || driveModeRaw != null || regenLevelRaw != null || + gearRaw != null } data class TirePressureSnapshot( diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTelemetryReader.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTelemetryReader.kt index ca3fcad..66e14a8 100644 --- a/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTelemetryReader.kt +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTelemetryReader.kt @@ -27,6 +27,8 @@ interface EnergySignalSource { fun vehicleConsumedKwh(): Float? = null fun vehicleRegeneratedKwh(): Float? = null fun parked(): Boolean? + /** The gear's raw code; only 1 (P) is named. Null where unread. */ + fun gearRaw(): Int? = null fun batteryVoltageV(): Float? = null fun batteryCurrentA(): Float? = null /** Widest window opening in percent; null where the firmware has no read. */ @@ -74,6 +76,7 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource { override fun vehicleConsumedKwh() = SaicCharging.consumedKwhSinceCharge() override fun vehicleRegeneratedKwh() = SaicCharging.regeneratedKwhSinceCharge() override fun parked() = EVHardware.isVehicleInPark() + override fun gearRaw() = EVHardware.getGearRaw() override fun batteryVoltageV() = EVHardware.getVendorBatteryVoltageV() override fun batteryCurrentA() = EVHardware.getVendorBatteryCurrentA() override fun widestWindowPercent() = SaicVehicleControl.widestWindowPercent() @@ -154,6 +157,7 @@ class EnergyTelemetryReader(private val source: EnergySignalSource) { widestWindowPercent = source.widestWindowPercent()?.takeIf { it in 0..100 }, driveModeRaw = source.driveModeRaw(), regenLevelRaw = source.regenLevelRaw(), + gearRaw = source.gearRaw(), ) /** Reads CP-004 candidates only when an unstable evidence capture explicitly requests them. */ diff --git a/LICENSE.md b/LICENSE.md index f5147d0..e3073fe 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -30,7 +30,8 @@ It is a copy, not a submodule: [malys/EVHardware](https://github.com/malys/EVHar top. Those are Fatih's reads the car needs and upstream does not have — the vendor tyre-pressure ids `0x21401553`–`56`, the vendor mileage `0x21401566` for the odometer, the charging status from `0x2140F409`, SoC, range, power, pack voltage and current on SWI69 as on SWI68, and SWI69 -speed already in km/h — plus translated comments. To take upstream again, merge three ways with +speed already in km/h — plus translated comments and, from this fork, the gear's raw code +(`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`). To take upstream again, merge three ways with `e4e1158` as the base. The car app links EVHardware into its APK, so the **built app** may only be used and passed on diff --git a/SPECS.md b/SPECS.md index ed44d45..a22d611 100644 --- a/SPECS.md +++ b/SPECS.md @@ -131,6 +131,15 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-24 — App 2.22.1 (branch `dashboard-cockpit`): the gear badge on the Dashboard and + Cockpit no longer guesses. It showed "D" for anything not in P, so N and R read as D. The app + only knew "in P or not"; EVHardware now passes on the car's raw gear code + (`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`; `isVehicleInPark` is built on it). + `Gear.label`: 1 → "P" (the only established code), any other code → no letter, unread → "—". + To map R, N and D, the Logs tab lists every change of the code with time and speed + (`GearLog`, last 12, kept in preferences). Checked on the emulator with a throwaway build + cycling codes 1–4 (pill shows P on 1, nothing on 2–4; the Logs tab lists each change). Not + tried on the car; the codes for R, N and D are still to be read there. - 2026-10-24 — App 2.22.0 (branch `dashboard-cockpit`, from `dashboard-gauges`): a third Overview style, "Cockpit", designed without a reference: three segmented rings (`RingView`, Canvas) — battery (0…100 %, range inside, below 20 % in the warning colour), power with the speed and the diff --git a/app/build.gradle b/app/build.gradle index 9ce9509..40e08f0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 111 - versionName "2.22.0" + versionCode 112 + versionName "2.22.1" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/Gear.java b/app/src/main/java/com/evsuite/abrp/Gear.java new file mode 100644 index 0000000..9ab04ab --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/Gear.java @@ -0,0 +1,23 @@ +package com.evsuite.abrp; + +/** + * The gear letter for the dashboards, from the car's raw gear code. + * + * Only code 1 is known to be P (EVHardware, two head-unit apps and the reversing camera + * agree). The codes for R, N and D are not established yet, so any other code shows no letter + * rather than a guessed "D": the Logs tab lists the codes the car reports, for the driver to + * match them to the lever. Unread is "—". + */ +final class Gear { + + static final int PARK = 1; + + private Gear() {} + + /** "P", "" for a gear not yet named, "—" when unread. */ + static String label(Integer raw) { + if (raw == null) return "—"; + if (raw == PARK) return "P"; + return ""; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/GearLog.java b/app/src/main/java/com/evsuite/abrp/GearLog.java new file mode 100644 index 0000000..f2fd972 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/GearLog.java @@ -0,0 +1,63 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * Every change of the car's raw gear code with the time and speed, the last {@link #MAX_LINES}, + * shown on the Logs tab. It is how the codes for R, N and D get matched to the lever: shift + * through them and read which code each one gave. Kept in preferences so the list survives + * the app being closed in between. Written only when the code changes. + */ +final class GearLog { + + static final int MAX_LINES = 12; + private static final String PREFS = "gear_log"; + private static final String KEY = "lines"; + + /** The last code seen in this process; MIN_VALUE before the first reading. */ + private static int last = Integer.MIN_VALUE; + + private GearLog() {} + + /** Called with every reading; records a line when the code differs from the last one. */ + static void note(Context context, Integer raw, Float speedKmh) { + int code = raw == null ? -1 : raw; + synchronized (GearLog.class) { + if (context == null || code == last) return; + last = code; + String line = new SimpleDateFormat("dd.MM. HH:mm:ss", Locale.US).format(new Date()) + + " code " + (raw == null ? "unread" : String.valueOf(raw)) + + (raw != null && raw == Gear.PARK ? " (P)" : "") + + " at " + (speedKmh == null ? "—" : String.format(Locale.US, "%.0f km/h", speedKmh)); + SharedPreferences prefs = context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + List lines = UpdateLog.append(split(prefs.getString(KEY, null)), line, MAX_LINES); + prefs.edit().putString(KEY, String.join("\n", lines)).apply(); + } + } + + /** The code now, null when unread or not read yet. */ + static synchronized Integer current() { + return last < 0 ? null : last; + } + + /** Newest first. */ + static List lines(Context context) { + SharedPreferences prefs = context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + return split(prefs.getString(KEY, null)); + } + + private static List split(String stored) { + if (stored == null || stored.isEmpty()) return new ArrayList<>(); + return new ArrayList<>(Arrays.asList(stored.split("\n"))); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 787354a..a7bd38b 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -1519,8 +1519,7 @@ public class MainActivity extends AppCompatActivity { Float power = s.getBatteryPowerKw(); Float speed = s.getSpeedKmh(); - Boolean parked = s.getParked(); - cockpitPower.setValue(power, parked == null ? "—" : parked ? "P" : "D", + cockpitPower.setValue(power, Gear.label(s.getGearRaw()), speed == null ? null : String.format(us, "%.0f", speed), getString(R.string.dash_unit_kmh), power == null ? "—" : String.format(us, "%.1f kW", power), null); cockpitTrace.add(android.os.SystemClock.elapsedRealtime(), power); @@ -1554,8 +1553,7 @@ public class MainActivity extends AppCompatActivity { fmt(s.getRangeKm(), "%.0f km")); dashBatteryBar.setSoc(soc); dashBatteryText.setText(fmt(soc, "%.0f %%")); - Boolean parked = s.getParked(); - dashGear.setText(parked == null ? "—" : parked ? "P" : "D"); + dashGear.setText(Gear.label(s.getGearRaw())); dashClimate.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C")); } @@ -1577,6 +1575,7 @@ public class MainActivity extends AppCompatActivity { vehTireFr.setText(unit.format(s.getTirePressures().getFrontRightKpa())); vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa())); vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa())); + GearLog.note(this, s.getGearRaw(), s.getSpeedKmh()); showDashboard(s); showCockpit(s); } catch (Throwable ignored) { @@ -1901,6 +1900,14 @@ public class MainActivity extends AppCompatActivity { // Update steps first: after a failed install, they are what the driver came here for. java.util.List updates = UpdateLog.lines(this); StringBuilder sb = new StringBuilder(); + // The car's gear codes, for matching R, N and D to the lever (only P = 1 is known). + java.util.List gears = GearLog.lines(this); + if (!gears.isEmpty()) { + Integer now = GearLog.current(); + sb.append(getString(R.string.gear_log_title, now == null ? "—" : String.valueOf(now))); + for (String line : gears) sb.append('\n').append(line); + sb.append("\n\n"); + } if (!updates.isEmpty()) { sb.append(getString(R.string.update_log_title)); for (String line : updates) sb.append('\n').append(line); diff --git a/app/src/main/java/com/evsuite/abrp/RingView.java b/app/src/main/java/com/evsuite/abrp/RingView.java index 6d5ec2b..331384c 100644 --- a/app/src/main/java/com/evsuite/abrp/RingView.java +++ b/app/src/main/java/com/evsuite/abrp/RingView.java @@ -239,7 +239,7 @@ public final class RingView extends View { } // Inside: the label on top in a pill, the value, the unit, the captions. - if (top != null) { + if (top != null && !top.isEmpty()) { topPaint.setTextSize(r * (topPill ? 0.11f : 0.075f)); topPaint.setLetterSpacing(topPill ? 0f : 0.16f); float ty = cy - r * 0.40f; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07b3c08..16d26b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -187,6 +187,7 @@ Recent uploads No upload attempted yet. Updates, newest first: + Gear codes, newest first (now: %1$s; only 1 = P is known): Upload now Starting… Running — waiting for first upload diff --git a/app/src/test/java/com/evsuite/abrp/GearTest.java b/app/src/test/java/com/evsuite/abrp/GearTest.java new file mode 100644 index 0000000..60db355 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/GearTest.java @@ -0,0 +1,22 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** The gear letter — pure JVM. */ +public class GearTest { + + @Test + public void onlyParkIsNamed() { + assertEquals("P", Gear.label(1)); + assertEquals("", Gear.label(2)); + assertEquals("", Gear.label(3)); + assertEquals("", Gear.label(4)); + } + + @Test + public void unreadIsADash() { + assertEquals("—", Gear.label(null)); + } +}