Skip to content

Commit 3df0198

Browse files
malysclaude
andcommitted
fix(logging): log vehicle states on change, not on every event
Three vehicle bundles show the diagnostic log flooded: the ignition condition callback fires about ten times a second and the gear is read once a second, and both were logged unconditionally. Over 371 seconds of uptime one bundle counted 2763 log events against a 400-line retained window — under a minute of history, almost all of it the same line, with whatever went wrong earlier already evicted. That is the log a driver sends back after a drive, so it has to hold the drive. It also spends the head unit's CPU and log bandwidth continuously to report that nothing changed. StateChangeLog lets a state through only when it differs from the last one. The transitions are what a diagnostic reads; the repeats between them add nothing the next transition's timestamp does not already give. Only logging is gated. Every callback is still dispatched — suppressing a dispatch would change behaviour, suppressing a log line changes only noise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 00159b0 commit 3df0198

4 files changed

Lines changed: 113 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ All notable changes to this project are documented here. Format follows
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- **Repeated vehicle states no longer flood the diagnostic log.** The ignition condition
12+
arrives about ten times a second and the gear is read once a second; both were logged every
13+
time, so a 400-line retained log held under a minute of history and anything older had already
14+
been evicted from the bundle a driver sends back. `StateChangeLog` lets a state through only
15+
when it changed, keeping the transitions a diagnostic actually reads and dropping the repeats
16+
between them. Only logging is gated — every callback is still dispatched.
17+
918
## [1.14.0] - 2026-09-04
1019

1120
### Changed

‎lib/src/main/java/com/evsuite/hardware/EVHardware.kt‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,6 +1805,10 @@ object EVHardware {
18051805
* while the service behind it is bound and answering. A null here blocks power-off and
18061806
* costs the write gate its standstill fallback, so a second source is worth having.
18071807
*/
1808+
/** Keeps the repeated gear and ignition states out of the bounded diagnostic log. */
1809+
private val sGearLog = StateChangeLog<Int>()
1810+
private val sIgnitionLog = StateChangeLog<Int>()
1811+
18081812
fun isVehicleInPark(): Boolean? {
18091813
val gen = FirmwareInfo.getGeneration()
18101814
val gear = when {
@@ -1818,7 +1822,10 @@ object EVHardware {
18181822
}.takeIf { it >= 0 }
18191823
?: com.evsuite.hardware.saic.SaicVehicleCondition.gearOrNull()
18201824
?: return null
1821-
AppLogger.i(TAG, "isVehicleInPark — gen=$gen gear=$gear (P=$GEAR_PARK_VALUE)")
1825+
// Read once a second by the telemetry sampler; only gear changes are worth a line.
1826+
if (sGearLog.accept(gear)) {
1827+
AppLogger.i(TAG, "isVehicleInPark — gen=$gen gear=$gear (P=$GEAR_PARK_VALUE)")
1828+
}
18221829
return gear == GEAR_PARK_VALUE
18231830
}
18241831

@@ -3511,7 +3518,12 @@ object EVHardware {
35113518
try {
35123519
val ignition = bean.javaClass.getMethod("getVehicleIgnition").invoke(bean) as? Int
35133520
if (ignition != null) {
3514-
AppLogger.i(TAG, " Katman5 event: ignition=$ignition (${carIgnitionName(ignition)})")
3521+
// The callback repeats at about 10 Hz whether or not anything
3522+
// moved. Only the transitions are logged; every event is still
3523+
// dispatched, because suppressing one would change behaviour.
3524+
if (sIgnitionLog.accept(ignition)) {
3525+
AppLogger.i(TAG, " Katman5 event: ignition=$ignition (${carIgnitionName(ignition)})")
3526+
}
35153527
dispatchVehicleConditionIgnition(ignition)
35163528
}
35173529
} catch (e: Exception) {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.evsuite.hardware
2+
3+
import java.util.concurrent.atomic.AtomicReference
4+
5+
/**
6+
* Lets a repeated state through only when it actually changed.
7+
*
8+
* Two vehicle callbacks report a state rather than an event: the ignition condition arrives
9+
* about ten times a second and the gear is read once a second, and both were logged every
10+
* time. On a 400-line retained diagnostic log that is under a minute of history — the bundle
11+
* a driver sends back is then almost entirely one repeated line, and whatever went wrong
12+
* before that minute has already been evicted. It also spends the head unit's CPU and log
13+
* bandwidth continuously to say nothing new.
14+
*
15+
* A state worth logging is a state that changed. The transitions are what a diagnostic reads
16+
* anyway; the repeats between them carry no information the timestamp of the next transition
17+
* does not already give.
18+
*
19+
* Only logging is gated. Callers must still dispatch every callback they receive — dropping a
20+
* dispatch would change behaviour, dropping a log line changes only noise.
21+
*/
22+
class StateChangeLog<T> {
23+
24+
private val last = AtomicReference<Any?>(NOTHING)
25+
26+
/**
27+
* @return true when [value] differs from the previous accepted one, or nothing was seen yet.
28+
*/
29+
fun accept(value: T): Boolean = last.getAndSet(value) != value
30+
31+
/** Forgets what was seen, so the next value logs as if it were the first. */
32+
fun reset() = last.set(NOTHING)
33+
34+
private companion object {
35+
/** Distinct from null, so a first value of null still counts as a change. */
36+
val NOTHING = Any()
37+
}
38+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.evsuite.hardware
2+
3+
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
class StateChangeLogTest {
8+
9+
@Test fun `the first value always passes`() {
10+
assertTrue(StateChangeLog<Int>().accept(2))
11+
}
12+
13+
@Test fun `a repeat is suppressed`() {
14+
val gate = StateChangeLog<Int>()
15+
assertTrue(gate.accept(2))
16+
assertFalse(gate.accept(2))
17+
assertFalse(gate.accept(2))
18+
}
19+
20+
@Test fun `a transition passes and the repeats after it do not`() {
21+
val gate = StateChangeLog<Int>()
22+
gate.accept(2)
23+
assertTrue(gate.accept(4))
24+
assertFalse(gate.accept(4))
25+
assertTrue(gate.accept(2))
26+
}
27+
28+
@Test fun `the ignition flood collapses to its transitions`() {
29+
// 10 Hz of "RUN/READY" either side of one real change, as the vehicle actually reports it.
30+
val gate = StateChangeLog<Int>()
31+
val stream = List(100) { 2 } + List(100) { 0 }
32+
assertEquals(2, stream.count { gate.accept(it) })
33+
}
34+
35+
@Test fun `a first null still counts as a change`() {
36+
val gate = StateChangeLog<Int?>()
37+
assertTrue(gate.accept(null))
38+
assertFalse(gate.accept(null))
39+
assertTrue(gate.accept(1))
40+
}
41+
42+
@Test fun `reset makes the next value log again`() {
43+
val gate = StateChangeLog<Int>()
44+
gate.accept(2)
45+
assertFalse(gate.accept(2))
46+
gate.reset()
47+
assertTrue(gate.accept(2))
48+
}
49+
50+
private fun assertEquals(expected: Int, actual: Int) =
51+
org.junit.Assert.assertEquals(expected.toLong(), actual.toLong())
52+
}

0 commit comments

Comments
 (0)