From 40906ec4a7e9b2a88bb8571b9072cea075657cb2 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 18 Oct 2026 13:26:02 +0200 Subject: [PATCH] App 2.13.0: tyre pressures in bar; 5-minute average power MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tyre pressures show in bar by default; Settings → Display offers kPa and psi as well. Display only: the car reports kPa and the server keeps storing kPa. A "Power, 5-min average" card under Power: the time-weighted mean battery power over the last five minutes, standing included, from the same samples as the 5-minute range. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- SPECS.md | 3 ++ app/build.gradle | 4 +- .../java/com/evsuite/abrp/MainActivity.java | 37 ++++++++++++++--- .../java/com/evsuite/abrp/PressureUnit.java | 40 +++++++++++++++++++ .../java/com/evsuite/abrp/RangeEstimator.java | 23 +++++++++++ .../main/res/layout-w1000dp/pane_settings.xml | 2 + .../main/res/layout-w1000dp/pane_vehicle.xml | 6 ++- app/src/main/res/layout/pane_settings.xml | 2 + app/src/main/res/layout/pane_vehicle.xml | 5 +++ app/src/main/res/values/strings.xml | 4 ++ .../com/evsuite/abrp/PressureUnitTest.java | 29 ++++++++++++++ .../com/evsuite/abrp/RangeEstimatorTest.java | 12 ++++++ 12 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/PressureUnit.java create mode 100644 app/src/test/java/com/evsuite/abrp/PressureUnitTest.java diff --git a/SPECS.md b/SPECS.md index 32ba765..4866cf4 100644 --- a/SPECS.md +++ b/SPECS.md @@ -113,6 +113,9 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.13.0: tyre pressures in bar by default, kPa or psi selectable (Settings → + Display; display only, the car and the server keep kPa). "Power, 5-min average" card under + Power: time-weighted mean battery power, standing included. - 2026-10-18 — App 2.12.1: no scroll bars (the 2-second refresh kept them from ever fading); "Energy since start" tile (net kWh of the trip, recuperation counted back) left of "Usage since start". diff --git a/app/build.gradle b/app/build.gradle index 15b2507..e82f366 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 76 - versionName "2.12.1" + versionCode 77 + versionName "2.13.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 883b6fc..f9eb258 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -93,7 +93,7 @@ public class MainActivity extends AppCompatActivity { vehTireFl, vehTireFr, vehTireRl, vehTireRr, vehRangeTrip, vehRangeTripNote, vehRangeAverage, vehRangeAverageNote, vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote, - vehEnergyTrip, vehEnergyTripNote; + vehEnergyTrip, vehEnergyTripNote, vehPowerAverage, vehPowerAverageNote; /** Usable pack capacity for the range estimates; the car does not publish it. */ static final String USABLE_KWH_KEY = "usable_kwh"; @@ -977,6 +977,7 @@ public class MainActivity extends AppCompatActivity { voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs)); voiceKeySwitch.setOnCheckedChangeListener((btn, checked) -> prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply()); + bindTireUnit(); fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch); fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs)); fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> { @@ -1057,6 +1058,26 @@ public class MainActivity extends AppCompatActivity { }, "abrp-test").start(); } + /** Tyre pressure unit: display only, read on every refresh of the Overview. */ + private void bindTireUnit() { + android.widget.Spinner spinner = settingsPane.findViewById(R.id.tire_unit_spinner); + PressureUnit[] units = PressureUnit.values(); + String[] labels = new String[units.length]; + for (int i = 0; i < units.length; i++) labels[i] = units[i].label; + android.widget.ArrayAdapter adapter = new android.widget.ArrayAdapter<>( + this, R.layout.spinner_value, labels); + adapter.setDropDownViewResource(R.layout.spinner_dropdown); + spinner.setAdapter(adapter); + spinner.setSelection(PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null)).ordinal()); + spinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(android.widget.AdapterView parent, View view, int position, long id) { + prefs.edit().putString(PressureUnit.PREF_KEY, units[position].name()).apply(); + } + @Override public void onNothingSelected(android.widget.AdapterView parent) { } + }); + } + /** The service reads these settings on every tick, so a change applies without a restart. */ private void bindCadenceControls() { UploadSettings current = UploadSettings.from(prefs); @@ -1190,6 +1211,8 @@ public class MainActivity extends AppCompatActivity { vehUsageAverageNote = vehiclePane.findViewById(R.id.vehicle_usage_average_note); vehEnergyTrip = vehiclePane.findViewById(R.id.vehicle_energy_trip); vehEnergyTripNote = vehiclePane.findViewById(R.id.vehicle_energy_trip_note); + vehPowerAverage = vehiclePane.findViewById(R.id.vehicle_power_average); + vehPowerAverageNote = vehiclePane.findViewById(R.id.vehicle_power_average_note); } private double usableKwh() { @@ -1207,6 +1230,9 @@ public class MainActivity extends AppCompatActivity { estimator.add(monotonicMs, s.getBatteryPowerKw(), s.getSpeedKmh()); Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh()); RangeEstimator.Estimate trip = estimator.sinceStart(remaining); + Double power = estimator.averagePowerKw(monotonicMs); + vehPowerAverage.setText(power == null ? "—" : getString(R.string.power_kw, power)); + vehPowerAverageNote.setText(power == null ? R.string.power_waiting : R.string.usage_last_5_min); Double energy = estimator.energySinceStart(); vehEnergyTrip.setText(energy == null ? "—" : getString(R.string.energy_kwh, energy)); vehEnergyTripNote.setText(energy == null ? R.string.energy_trip_waiting : R.string.energy_trip_note); @@ -1264,10 +1290,11 @@ public class MainActivity extends AppCompatActivity { vehCharging.setText(chargingLabel(s)); refreshRangeEstimates(s); - vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa")); - vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa")); - vehTireRl.setText(fmt(s.getTirePressures().getRearLeftKpa(), "%.0f kPa")); - vehTireRr.setText(fmt(s.getTirePressures().getRearRightKpa(), "%.0f kPa")); + PressureUnit unit = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null)); + vehTireFl.setText(unit.format(s.getTirePressures().getFrontLeftKpa())); + vehTireFr.setText(unit.format(s.getTirePressures().getFrontRightKpa())); + vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa())); + vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa())); } catch (Throwable ignored) { // Leave the last good reading on screen. } diff --git a/app/src/main/java/com/evsuite/abrp/PressureUnit.java b/app/src/main/java/com/evsuite/abrp/PressureUnit.java new file mode 100644 index 0000000..51fd5ea --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/PressureUnit.java @@ -0,0 +1,40 @@ +package com.evsuite.abrp; + +import java.util.Locale; + +/** + * The unit tyre pressures are shown in. The car reports kPa; this only changes the display. + * Android-free, unit-tested. + */ +enum PressureUnit { + BAR("bar", "%.1f bar", 0.01), + KPA("kPa", "%.0f kPa", 1.0), + PSI("psi", "%.1f psi", 0.145038); + + static final String PREF_KEY = "tire_pressure_unit"; + static final PressureUnit DEFAULT = BAR; + + final String label; + private final String pattern; + private final double perKpa; + + PressureUnit(String label, String pattern, double perKpa) { + this.label = label; + this.pattern = pattern; + this.perKpa = perKpa; + } + + /** A reading in this unit, or a dash for none. */ + String format(Float kpa) { + if (kpa == null || kpa.isNaN() || kpa.isInfinite()) return "—"; + return String.format(Locale.US, pattern, kpa * perKpa); + } + + /** The stored choice; anything unknown falls back to bar. */ + static PressureUnit fromPref(String name) { + for (PressureUnit u : values()) { + if (u.name().equals(name)) return u; + } + return DEFAULT; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java index 8d55ea3..b982760 100644 --- a/app/src/main/java/com/evsuite/abrp/RangeEstimator.java +++ b/app/src/main/java/com/evsuite/abrp/RangeEstimator.java @@ -154,6 +154,29 @@ final class RangeEstimator { return new Estimate(rangeKm, kwhPerKm * 100, distanceKm); } + /** + * Mean battery power over the last five minutes, time-weighted, standing included; null with + * less than a minute of samples. Positive is drawn from the pack, as the Power tile shows it. + */ + synchronized Double averagePowerKw(long nowMs) { + double energyKwh = 0; + long covered = 0; + Sample previous = null; + for (Sample s : samples) { + if (s.timeMs < nowMs - WINDOW_MS) continue; + if (previous != null) { + long dt = s.timeMs - previous.timeMs; + if (dt > 0 && dt <= MAX_GAP_MS) { + energyKwh += (previous.powerKw + s.powerKw) / 2 * (dt / 3_600_000.0); + covered += dt; + } + } + previous = s; + } + if (covered < MIN_WINDOW_MS) return null; + return energyKwh / (covered / 3_600_000.0); + } + synchronized void clear() { samples.clear(); tripEnergyKwh = 0; diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 0732e99..8dfb182 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -8,6 +8,8 @@ + + diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml index c5d1457..44a9930 100644 --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml @@ -47,7 +47,11 @@ - + + + + + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index 4d17a49..534f54b 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -7,6 +7,8 @@ + + diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml index c75c93c..28d6816 100644 --- a/app/src/main/res/layout/pane_vehicle.xml +++ b/app/src/main/res/layout/pane_vehicle.xml @@ -40,6 +40,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8133840..e6e7459 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,10 @@ Range at 5-min average Usage since start Energy since start + Power, 5-min average + %1$.1f kW + needs a minute of data + Tyre pressure unit %1$.1f kWh net, after recuperation once the car is on diff --git a/app/src/test/java/com/evsuite/abrp/PressureUnitTest.java b/app/src/test/java/com/evsuite/abrp/PressureUnitTest.java new file mode 100644 index 0000000..b7d8485 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/PressureUnitTest.java @@ -0,0 +1,29 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** Tyre pressure display units — pure JVM. */ +public class PressureUnitTest { + + @Test + public void kpaFromTheCarInEachUnit() { + assertEquals("2.5 bar", PressureUnit.BAR.format(250f)); + assertEquals("250 kPa", PressureUnit.KPA.format(250f)); + assertEquals("36.3 psi", PressureUnit.PSI.format(250f)); + } + + @Test + public void anUnreadPressureStaysADash() { + assertEquals("—", PressureUnit.BAR.format(null)); + assertEquals("—", PressureUnit.PSI.format(Float.NaN)); + } + + @Test + public void barUnlessSomethingElseWasChosen() { + assertEquals(PressureUnit.BAR, PressureUnit.fromPref(null)); + assertEquals(PressureUnit.BAR, PressureUnit.fromPref("nonsense")); + assertEquals(PressureUnit.PSI, PressureUnit.fromPref("PSI")); + } +} diff --git a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java index b50d822..b5f4a62 100644 --- a/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java +++ b/app/src/test/java/com/evsuite/abrp/RangeEstimatorTest.java @@ -128,4 +128,16 @@ public class RangeEstimatorTest { for (long t = 615_000; t <= 735_000; t += 15_000) r.add(t, -20f, 60f); // downhill assertTrue(r.energySinceStart() < 5.0); // recuperation counts back } + + @Test + public void averagePowerOverTheLastFiveMinutes() { + RangeEstimator r = new RangeEstimator(); + r.add(0, 20f, 50f); + assertNull(r.averagePowerKw(0)); // too little yet + for (long t = 15_000; t <= 300_000; t += 15_000) r.add(t, 20f, 50f); + assertEquals(20.0, r.averagePowerKw(300_000), 1e-6); + // Standing with the climate on counts: half the window at 2 kW. + for (long t = 315_000; t <= 450_000; t += 15_000) r.add(t, 2f, 0f); + assertEquals(11.0, r.averagePowerKw(450_000), 1.0); + } }