Skip to content

Commit 2f422b5

Browse files
malysclaude
andcommitted
fix(charging): keep uploading while the car is charging
Charge detection trusted EV_CHARGE_PORT_CONNECTED alone. That property is standard AAOS and this VHAL need not implement it, so a plugged-in car could read "unplugged" -- which UploadCadence sees as parked and idle and throttles to one sample every 15 minutes for the whole session. ABRP then never gets the SOC curve it needs to size a charging stop, which is what the app is for. The T-919 safeguard ("unknown counts as active") only covered null. It did not cover a property answering an honest-looking false, and the stateChanged escape hatch depends on that same property, so it never fired either. ChargingSignal keeps the port reading authoritative when it says "connected", and adds two fallbacks: the instantaneous charge rate, which is positive only while the pack absorbs energy, and a rising SOC on a parked car, which nothing but charging produces. The SOC fallback is latched for 30 min so is_charging does not flap between ticks, cleared by a falling SOC or by leaving park so regen on a downhill run is not mistaken for a charge, and bounded so a car left parked after a charge falls back to the idle cadence instead of polling all night. Also fixes the power sign, inherited from upstream: ABRP signs power positive when it leaves the battery, the VHAL charge rate signs it the other way, and it was forwarded unchanged -- so ABRP saw an 80 kW fast charge as an 80 kW discharge. The parked back-off is unchanged for a genuinely idle car, and the extra polling now only happens while the DC-DC is feeding the 12 V from the pack. 67 tests green, lint passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7f44fc7 commit 2f422b5

4 files changed

Lines changed: 231 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ All notable changes to this project are documented here. Format follows
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Fixed
10+
11+
- Telemetry no longer stops while the car is charging. Charge detection relied solely on
12+
the standard AAOS charge-port property, which this VHAL need not implement: a plugged-in
13+
car reading "unplugged" was treated as parked and idle and throttled to one sample every
14+
15 minutes, so ABRP never saw the SOC curve it needs to size a charging stop. The charge
15+
rate and, failing that, a rising SOC on a parked car are now used as fallbacks.
16+
- `power` is now signed ABRP's way — positive leaving the battery, negative charging. The
17+
vehicle's charge rate uses the opposite convention and was being forwarded unchanged, so
18+
ABRP saw a fast-charging car as discharging.
19+
720
## [2.0.0] - 2026-08-15
821

922
### ⚠️ Breaking — existing users must install once more

‎app/src/main/java/com/evsuite/abrp/AbrpUploadService.java‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ public class AbrpUploadService extends Service {
102102
private volatile Boolean lastParked = null;
103103
private volatile Boolean lastCharging = null;
104104
private volatile long lastUploadAttemptMs = 0L;
105+
/** Charge detection state. Touched only from the scheduler thread, inside doUpload. */
106+
private final ChargingSignal chargingSignal = new ChargingSignal();
105107
private volatile UploadSettings settings = UploadSettings.defaults();
106108
/** False while no GPS subscription is delivering — drives the watchdog re-arm. */
107109
private volatile boolean locationUpdatesActive = false;
@@ -279,24 +281,35 @@ private void doUpload() {
279281
// every generation. The old local vendor ID (0x15602511) was SWI68-only.
280282
Float extTemp = EVHardware.INSTANCE.getOutsideTempCelsius();
281283

282-
// Charge rate comes in mW (signed: +ve charging, -ve driving). ABRP wants kW.
283-
Float chargeRate = carUp ? carAdapter.getFloatProperty(
284+
// Charge rate comes in mW, signed the VHAL's way: +ve charging, -ve driving.
285+
Float chargeRateRaw = carUp ? carAdapter.getFloatProperty(
284286
CarPropertyAdapter.PROP_EV_INSTANTANEOUS_CHARGE_RATE,
285287
CarPropertyAdapter.PROP_AREA_GLOBAL) : null;
286-
Float powerKw = chargeRate == null ? null : chargeRate / 1_000_000f;
288+
Float chargeRateKw = chargeRateRaw == null ? null : chargeRateRaw / 1_000_000f;
287289

288-
Boolean charging = carUp ? carAdapter.getBooleanProperty(
290+
Boolean portConnected = carUp ? carAdapter.getBooleanProperty(
289291
CarPropertyAdapter.PROP_EV_CHARGE_PORT_CONNECTED,
290292
CarPropertyAdapter.PROP_AREA_GLOBAL) : null;
291293

292294
// Park state via EVHardware: it resolves gear per generation (VPM / CarStateClient
293295
// / VehicleConditionManager depending on firmware). Null means unknown, not "moving".
294296
Boolean parked = EVHardware.INSTANCE.isVehicleInPark();
295297

298+
// The charge port property is standard AAOS and this VHAL need not implement it.
299+
// Trusting it alone meant a plugged-in car that reads "unplugged" was treated as
300+
// parked-and-idle and throttled to one sample every 15 minutes for the whole
301+
// charge — see ChargingSignal for the fallbacks.
302+
Boolean charging = chargingSignal.evaluate(
303+
System.currentTimeMillis(), portConnected, chargeRateKw, parked, soc);
304+
305+
// ABRP signs power the other way round from the VHAL: positive is power leaving
306+
// the battery (driving), negative is power going into it (charging).
307+
Float powerKw = chargeRateKw == null ? null : -chargeRateKw;
308+
296309
// DCFC heuristic: charging at AC speeds (3-22 kW) is type-2; above ~25 kW
297310
// it can only be DC fast. Undecidable if either input is missing.
298-
Boolean dcfc = (charging == null || powerKw == null)
299-
? null : (charging && powerKw > 25f);
311+
Boolean dcfc = (charging == null || chargeRateKw == null)
312+
? null : (charging && chargeRateKw > 25f);
300313

301314
// Cabin temperature — try the standard HVAC property, may fail on this VHAL.
302315
Float cabinTemp = carUp ? carAdapter.getFloatProperty(
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.evsuite.abrp;
2+
3+
/**
4+
* Works out whether the car is charging from whichever signals the VHAL actually answers.
5+
*
6+
* {@code EV_CHARGE_PORT_CONNECTED} is a standard AAOS property and this vehicle's VHAL is
7+
* not obliged to implement it. When it answers {@code false} on a plugged-in car, the
8+
* cadence policy sees "parked and not charging" and backs off to
9+
* {@link UploadCadence#PARKED_INTERVAL_MS} for the whole session — which is exactly when
10+
* ABRP needs a tight SOC curve to size a charging stop. Two fallbacks close that hole:
11+
*
12+
* 1. the instantaneous charge rate, which is positive only while the pack takes energy;
13+
* 2. a rising SOC on a parked car, which nothing but charging can produce.
14+
*
15+
* The SOC fallback is latched, because SOC only moves a whole percent every few minutes
16+
* and a signal that flapped between ticks would flap {@code is_charging} in the payload
17+
* too. The latch expires so a car left parked after a charge falls back to the idle
18+
* cadence instead of polling all night.
19+
*
20+
* Stateful and NOT thread-safe: only the {@code abrp-upload} scheduler thread calls it.
21+
*/
22+
final class ChargingSignal {
23+
24+
/** Above this the pack is taking energy; below it the reading is noise around zero. */
25+
static final float CHARGING_POWER_KW = 0.3f;
26+
27+
/**
28+
* How long a SOC rise keeps counting as "charging". Long enough to cover the slowest
29+
* realistic case — a 2 kW granny lead needs ~19 min per percent on a 64 kWh pack —
30+
* and short enough that a finished charge returns to the idle cadence promptly.
31+
*/
32+
static final long SOC_RISE_LATCH_MS = 1_800_000L;
33+
34+
private Integer lastSocPercent;
35+
/** When SOC last rose while parked; 0 when there is no live rise to trust. */
36+
private long socRoseAtMs;
37+
38+
/**
39+
* @param nowMs current time
40+
* @param portConnected charge port property, null when unreadable
41+
* @param chargeRateKw charge rate in kW as the VHAL signs it (+ve while charging),
42+
* null when unreadable
43+
* @param parked null when the gear could not be read
44+
* @param socPercent null when the battery level could not be read
45+
* @return TRUE / FALSE when the state can be established, null when nothing could be
46+
* read — null must keep counting as "unknown", never as "not charging"
47+
*/
48+
Boolean evaluate(long nowMs, Boolean portConnected, Float chargeRateKw,
49+
Boolean parked, Integer socPercent) {
50+
trackSoc(nowMs, parked, socPercent);
51+
52+
if (Boolean.TRUE.equals(portConnected)) return Boolean.TRUE;
53+
if (chargeRateKw != null && chargeRateKw > CHARGING_POWER_KW) return Boolean.TRUE;
54+
if (Boolean.TRUE.equals(parked)
55+
&& socRoseAtMs > 0L && nowMs - socRoseAtMs < SOC_RISE_LATCH_MS) {
56+
return Boolean.TRUE;
57+
}
58+
// Nothing overrode the port: pass its reading through, including its null.
59+
return portConnected;
60+
}
61+
62+
private void trackSoc(long nowMs, Boolean parked, Integer socPercent) {
63+
if (!Boolean.TRUE.equals(parked)) {
64+
// Regen while driving also raises SOC. Keep the latch clear so parking right
65+
// after a downhill run does not read as charging.
66+
socRoseAtMs = 0L;
67+
lastSocPercent = socPercent;
68+
return;
69+
}
70+
if (socPercent == null) return; // keep the previous reading to compare against
71+
if (lastSocPercent != null) {
72+
if (socPercent > lastSocPercent) socRoseAtMs = nowMs;
73+
else if (socPercent < lastSocPercent) socRoseAtMs = 0L; // draining, not charging
74+
}
75+
lastSocPercent = socPercent;
76+
}
77+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.evsuite.abrp;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertFalse;
5+
import static org.junit.Assert.assertNull;
6+
import static org.junit.Assert.assertTrue;
7+
8+
import org.junit.Test;
9+
10+
/**
11+
* Charge detection. The reported regression was a charging car being read as
12+
* "parked and not charging", which hands it to {@link UploadCadence#PARKED_INTERVAL_MS}
13+
* and costs ABRP the SOC curve it needs to size a charging stop.
14+
*/
15+
public class ChargingSignalTest {
16+
17+
private static final long T0 = 1_700_000_000_000L;
18+
19+
private final ChargingSignal signal = new ChargingSignal();
20+
21+
// ---- Charge port property, when the VHAL answers it ----
22+
23+
@Test
24+
public void aConnectedPortIsCharging() {
25+
assertTrue(signal.evaluate(T0, true, null, true, 50));
26+
}
27+
28+
@Test
29+
public void anUnpluggedParkedCarIsNotCharging() {
30+
assertFalse(signal.evaluate(T0, false, null, true, 50));
31+
}
32+
33+
@Test
34+
public void anUnreadablePortStaysUnknown() {
35+
// Unknown must not collapse to "not charging": UploadCadence treats null as active.
36+
assertNull(signal.evaluate(T0, null, null, true, 50));
37+
}
38+
39+
// ---- Fallback 1: charge rate ----
40+
41+
@Test
42+
public void aPositiveChargeRateOverridesAPortThatReadsUnplugged() {
43+
assertTrue(signal.evaluate(T0, false, 7.4f, true, 50));
44+
}
45+
46+
@Test
47+
public void aNegativeChargeRateIsDriving() {
48+
assertFalse(signal.evaluate(T0, false, -14.5f, false, 50));
49+
}
50+
51+
@Test
52+
public void noiseAroundZeroIsNotCharging() {
53+
assertFalse(signal.evaluate(T0, false, 0.1f, true, 50));
54+
}
55+
56+
// ---- Fallback 2: rising SOC on a parked car ----
57+
58+
@Test
59+
public void aRisingSocOnAParkedCarIsCharging() {
60+
// Neither charge property readable — the only evidence left is the battery filling.
61+
assertNull(signal.evaluate(T0, null, null, true, 50));
62+
assertTrue(signal.evaluate(T0 + 300_000, null, null, true, 51));
63+
}
64+
65+
@Test
66+
public void theSocRiseIsLatchedAcrossFlatTicks() {
67+
// SOC moves a whole percent every few minutes; without the latch is_charging
68+
// would flap between every pair of ticks.
69+
signal.evaluate(T0, null, null, true, 50);
70+
signal.evaluate(T0 + 300_000, null, null, true, 51);
71+
assertTrue(signal.evaluate(T0 + 315_000, null, null, true, 51));
72+
assertTrue(signal.evaluate(T0 + 1_500_000, null, null, true, 51));
73+
}
74+
75+
@Test
76+
public void theLatchExpiresSoAParkedCarGoesBackToIdle() {
77+
signal.evaluate(T0, null, null, true, 50);
78+
signal.evaluate(T0 + 300_000, null, null, true, 51);
79+
assertNull(signal.evaluate(T0 + 300_000 + ChargingSignal.SOC_RISE_LATCH_MS,
80+
null, null, true, 51));
81+
}
82+
83+
@Test
84+
public void aFallingSocDropsTheLatchImmediately() {
85+
signal.evaluate(T0, null, null, true, 50);
86+
assertTrue(signal.evaluate(T0 + 300_000, null, null, true, 51));
87+
assertNull(signal.evaluate(T0 + 600_000, null, null, true, 50));
88+
}
89+
90+
@Test
91+
public void regenWhileDrivingDoesNotLatch() {
92+
// SOC climbing on a downhill run, then the car parks: not a charging session.
93+
signal.evaluate(T0, false, null, false, 50);
94+
signal.evaluate(T0 + 60_000, false, null, false, 52);
95+
assertFalse(signal.evaluate(T0 + 120_000, false, null, true, 52));
96+
}
97+
98+
@Test
99+
public void anUnreadableSocKeepsThePreviousReadingToCompareAgainst() {
100+
signal.evaluate(T0, null, null, true, 50);
101+
signal.evaluate(T0 + 15_000, null, null, true, null); // one failed read
102+
assertTrue(signal.evaluate(T0 + 30_000, null, null, true, 51));
103+
}
104+
105+
// ---- The regression itself ----
106+
107+
@Test
108+
public void aChargingCarIsNeverHandedToTheParkedCadence() {
109+
// Charging at home with a VHAL that does not implement the charge port property:
110+
// before the fix this was parked + FALSE, i.e. one upload every 15 minutes.
111+
ChargingSignal s = new ChargingSignal();
112+
UploadSettings settings = UploadSettings.defaults();
113+
114+
Boolean charging = s.evaluate(T0, false, null, true, 50);
115+
assertEquals(UploadCadence.PARKED_INTERVAL_MS,
116+
UploadCadence.intervalFor(true, charging, 50, settings));
117+
118+
charging = s.evaluate(T0 + 300_000, false, null, true, 51);
119+
assertEquals(settings.intervalMs(),
120+
UploadCadence.intervalFor(true, charging, 51, settings));
121+
}
122+
}

0 commit comments

Comments
 (0)