Skip to content

Commit 2b394eb

Browse files
malysclaude
andcommitted
fix(glass): the probe reads the ignition on the scale it answers on
Two faults, one feature. The probe refused every run with "ignition not in RUN" on a car whose ignition was on: it compared getCurrentIgnitionState(), which answers on the AAOS IgnitionState scale, against CarIgnitionItem.RUN. The scales do not line up — RUN is 2 for Katman5, where 2 is OFF — so contact mis read as 4 and was refused, while contact coupe read as 2 and was let through. setEsc compared the same two scales; it now refuses only a known non-RUN ignition, which is what its own documentation always claimed. Under that, the window actions could not have worked anyway. They wrote through the vendor hub's setDriveWindow, a setter no application on the head unit calls, whose eight accepted values nothing explains and whose dropped writes are indistinguishable from obeyed ones. VsmGlass reaches the same glass through the VSM already bound for ADAS, where the commands are established rather than guessed: STOP, UP and AUTO_UP. UP is a switch held down and not an order — the glass travels only while the command keeps arriving — which is why a single write looked exactly like a command that does nothing, and why hold() re-sends it before releasing it. Reads answer 0-255 rather than the hub's 0-100, and a motor with no position sensor answers off that scale and is reported as no reading rather than as a closed window. SaicVehicleControl now prefers the VSM for every glass read and write and keeps the hub as the fallback where the methods are absent. GlassProbe holds each command down instead of writing it once, which is the only way a momentary command shows up in a position read at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 49cfa34 commit 2b394eb

5 files changed

Lines changed: 279 additions & 20 deletions

File tree

‎CHANGELOG.md‎

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

77
## [Unreleased]
88

9+
## [1.9.0] - 2026-08-28
10+
11+
### Fixed
12+
13+
- **The glass probe refused to run on a car whose ignition was on.** It read
14+
`getCurrentIgnitionState()`, which answers on the AAOS `IgnitionState` scale, against
15+
`CarIgnitionItem.RUN` — and the two scales do not line up: RUN is 2 for Katman5, where 2 is
16+
OFF. Contact mis read as 4 and was refused; contact coupé read as 2 and was let through.
17+
`setEsc` compared the same two scales and now refuses only a *known* non-RUN ignition, as its
18+
own documentation always said it did.
19+
- **Window actions moved nothing.** They were written through the vendor hub's
20+
`setDriveWindow`, a setter no application on the head unit calls, whose accepted values
21+
nothing explains and whose dropped writes are indistinguishable from obeyed ones.
22+
23+
### Added
24+
25+
- **`VsmGlass` — the windows through the interface the car's own window switch uses.**
26+
`setVehicleWindowStatus(area, command)` and `getVehicleWindowValue(area)`, on the VSM already
27+
bound for ADAS. The commands are established rather than guessed: `STOP`, `UP` and `AUTO_UP`.
28+
`UP` is a switch held down and not an order — the glass travels only while the command keeps
29+
arriving, which is why a single write looked exactly like a command that does nothing, and
30+
why `hold()` re-sends it and then releases it. Reads answer 0–255, not the hub's 0–100, and a
31+
motor without a position sensor answers off that scale and is reported as no reading rather
32+
than as a closed window. VSM-based firmwares only; the hub stays the fallback.
33+
34+
### Changed
35+
36+
- **`SaicVehicleControl` prefers the VSM for every glass read and write**, falling back to the
37+
hub only where the VSM does not carry the methods. `GlassProbe` holds each command down for
38+
`HOLD_MS` instead of writing it once, which is the only way a momentary command shows up in a
39+
position read at all.
40+
941
## [1.8.0] - 2026-08-27
1042

1143
### Added

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,14 @@ object EVHardware {
15781578
internal fun a9ClassLoader(): ClassLoader? =
15791579
sVsm?.javaClass?.classLoader ?: sCarAdapterClass?.classLoader
15801580

1581+
/**
1582+
* The VSM singleton, for the primitives that live outside this file.
1583+
*
1584+
* [VsmGlass] is one of them: the glass belongs with the glass, not in the middle of the
1585+
* ADAS calls, and it needs the same instance [callVsm] uses rather than a second bind.
1586+
*/
1587+
internal fun vsmInstance(): Any? = sVsm
1588+
15811589
private fun callVsm(methodName: String, vararg args: Any?): Any? {
15821590
val vsm = sVsm ?: return null
15831591
return try {
@@ -2713,10 +2721,11 @@ object EVHardware {
27132721
fun setEsc(on: Boolean): Boolean {
27142722
if (!hasDrowsinessAndEsc()) return false
27152723

2724+
// [IgnitionState], not [CarIgnitionItem]: getCurrentIgnitionState() answers on the
2725+
// AAOS scale, where the two do not line up — RUN is 2 for Katman5 and OFF is 2 here.
2726+
// Unreadable is 0 or -1 and is deliberately not a refusal, per the KDoc above.
27162727
val ignition = getCurrentIgnitionState()
2717-
if (ignition == CarIgnitionItem.OFF || ignition == CarIgnitionItem.ACCESSORY ||
2718-
ignition == CarIgnitionItem.CRANK
2719-
) {
2728+
if (ignition > 0 && ignition != IgnitionState.ON) {
27202729
AppLogger.w(TAG, " ESC: vehicle not in RUN (ignition=$ignition) — no action, the " +
27212730
"reading cannot be trusted yet")
27222731
return false

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,10 @@ object GlassProbe {
9494
window: Window,
9595
glass: Glass = VehicleGlass,
9696
stopped: () -> Boolean = { VehicleWriteGate.decideNow() == VehicleWriteGate.Decision.ALLOWED },
97-
ignitionRun: () -> Boolean = { EVHardware.getCurrentIgnitionState() == EVHardware.CarIgnitionItem.RUN },
97+
// [IgnitionState], not [CarIgnitionItem]: getCurrentIgnitionState() answers on the AAOS
98+
// scale, and the two do not line up — RUN is 2 for Katman5, where 2 is OFF here. Read
99+
// against the wrong one, a car with the ignition on refused the probe as if it were off.
100+
ignitionRun: () -> Boolean = { EVHardware.getCurrentIgnitionState() == EVHardware.IgnitionState.ON },
98101
sleep: (Long) -> Unit = { Thread.sleep(it) },
99102
): Result {
100103
val attempts = mutableListOf<Attempt>()
@@ -172,11 +175,23 @@ object GlassProbe {
172175
return abs(settled - start) < MOVEMENT_PERCENT
173176
}
174177

178+
/**
179+
* How long each command is held down before the position is read back.
180+
*
181+
* Shorter than a close, because the sweep sends up to sixteen of them and the driver is
182+
* sitting through all of it — long enough for a window to travel further than
183+
* [MOVEMENT_PERCENT], which is all the probe has to see.
184+
*/
185+
const val HOLD_MS = 1_500L
186+
175187
/** The real car. */
176188
object VehicleGlass : Glass {
177189
override fun position(window: Window): Int? = SaicVehicleControl.windowPercent(window)
190+
// Held, not sent once: the commands that move this glass are switches rather than
191+
// orders, and a single write travels the window by less than the read can resolve.
192+
// Holding a command that *is* an order costs nothing — it simply arrives again.
178193
override fun send(window: Window, command: Int): Boolean =
179-
SaicVehicleControl.setWindow(window, command)
194+
SaicVehicleControl.holdWindow(window, command, HOLD_MS)
180195
}
181196

182197
private const val TAG = "EV_GLASS"
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package com.evsuite.hardware
2+
3+
import java.lang.reflect.Method
4+
5+
/**
6+
* The glass, through the VSM — the interface the car's own window switch goes through.
7+
*
8+
* EVHardware reached the windows through the vendor hub until now
9+
* ([com.evsuite.hardware.saic.SaicVehicleControl]), and that path is the whole reason the
10+
* glass actions shipped refused: its setter takes a value no application on the head unit
11+
* ever writes, so what the eight accepted values do was never more than a guess, and a value
12+
* the service drops looks exactly like one it obeys.
13+
*
14+
* The VSM carries the same glass under two methods whose meaning is settled by use:
15+
*
16+
* • `setVehicleWindowStatus(area, command)` — the commands in [Command]
17+
* • `getVehicleWindowValue(area)` — a position, 0 closed to [VALUE_MAX] open
18+
*
19+
* Areas are `0..3`: front left, front right, rear left, rear right.
20+
*
21+
* **Per generation.** VSM-based firmwares only. SWI68 and SWI165 reach the VSM through
22+
* `VehicleSettingManager`, SWI69, SWI131 and SWI132 through `CarVehicleSettingClient`, and
23+
* both expose these names. SWI133 is VPM-based and carries neither, so [isAvailable] answers
24+
* false there and the caller keeps the hub instead of writing into nothing.
25+
*/
26+
object VsmGlass {
27+
28+
/** The scale `getVehicleWindowValue` answers on: 0 fully closed, 255 fully open. */
29+
const val VALUE_MAX = 255
30+
31+
/** The largest command `setVehicleWindowStatus` carries. */
32+
const val COMMAND_MAX = 7
33+
34+
/** How often a held command is re-sent while the glass travels. */
35+
const val PULSE_MS = 120L
36+
37+
/** How long [hold] keeps a command down when the caller names no duration. */
38+
const val HOLD_MS = 5_000L
39+
40+
/**
41+
* The commands whose effect is established.
42+
*
43+
* [UP] is a switch held down, not an order: the window travels while the command keeps
44+
* arriving and stops the instant it does not — which is why sending it once and waiting
45+
* looked exactly like a command that does nothing at all. [AUTO_UP] is the one-shot close
46+
* the door switch performs, and only the trims whose motors carry a position sensor obey
47+
* it; [hold] works on both, so it is what a close falls back to.
48+
*
49+
* No opening command is named here. Nothing on the head unit sends one, so any value
50+
* written down would be a guess — [GlassProbe] finds them on the car instead.
51+
*/
52+
object Command {
53+
const val STOP = 0
54+
const val UP = 1
55+
const val AUTO_UP = 3
56+
}
57+
58+
private val INT = Int::class.javaPrimitiveType!!
59+
private val AREAS = 0..3
60+
61+
// Looked up once per VSM class rather than per call: a hold writes eight times a second,
62+
// and `getMethod` on every one of them is forty lookups for a single close.
63+
@Volatile private var owner: Class<*>? = null
64+
@Volatile private var setter: Method? = null
65+
@Volatile private var getter: Method? = null
66+
67+
/** The VSM with its two methods resolved, or null when there is nothing to call. */
68+
@Synchronized
69+
private fun vsm(): Any? {
70+
val vsm = EVHardware.vsmInstance() ?: return null
71+
if (owner !== vsm.javaClass) {
72+
owner = vsm.javaClass
73+
setter = method(vsm, "setVehicleWindowStatus", INT, INT)
74+
getter = method(vsm, "getVehicleWindowValue", INT)
75+
}
76+
return vsm
77+
}
78+
79+
private fun method(vsm: Any, name: String, vararg types: Class<*>): Method? = try {
80+
vsm.javaClass.getMethod(name, *types)
81+
} catch (e: Exception) {
82+
AppLogger.w(TAG, " VSM: $name absent — ${e.javaClass.simpleName}")
83+
null
84+
}
85+
86+
/** True when this firmware's VSM carries the glass setter. */
87+
val isAvailable: Boolean
88+
get() = FirmwareInfo.isVsmBased() && vsm() != null && setter != null
89+
90+
/**
91+
* One window's position, 0 closed to [VALUE_MAX] open, or null when there is no reading.
92+
*
93+
* Null covers two cases that must not be told apart by guessing: the method is absent, or
94+
* the motor has no position sensor and answers a fixed value off the scale. The standard
95+
* trim is the second one, and calling its sentinel "closed" would let a close skip a
96+
* window that is wide open.
97+
*/
98+
fun value(area: Int): Int? {
99+
val vsm = vsm() ?: return null
100+
val get = getter ?: return null
101+
if (area !in AREAS) return null
102+
return try {
103+
(get.invoke(vsm, area) as? Number)?.toInt()?.takeIf { it in 0..VALUE_MAX }
104+
} catch (e: Exception) {
105+
AppLogger.w(TAG, " VSM: getVehicleWindowValue($area) exc: ${e.message}")
106+
null
107+
}
108+
}
109+
110+
/** The same position in percent — the scale the catalogue and the rules speak in. */
111+
fun percent(area: Int): Int? = value(area)?.let { it * 100 / VALUE_MAX }
112+
113+
/** Sends [command] once to each of [areas]. One write, for the commands that are orders. */
114+
fun send(areas: List<Int>, command: Int): Boolean =
115+
gate(areas, command)?.let { targets -> targets.map { write(it, command) }.all { it } } ?: false
116+
117+
/**
118+
* Holds [command] down on [areas] for [durationMs], then releases it with [Command.STOP].
119+
*
120+
* This is what [Command.UP] needs: it moves the glass only while it keeps arriving, so a
121+
* single write travels the window by whatever it manages before the next frame — nothing
122+
* a position read can see. A command that is an order rather than a switch is unharmed by
123+
* being repeated, which is what lets [GlassProbe] sweep both kinds the same way.
124+
*
125+
* Blocking for [durationMs] — call it off the main thread. The standstill gate is checked
126+
* once, before the first write: a hold is one gesture, and abandoning it halfway would
127+
* leave the glass part-way up with nothing on the way to finish it.
128+
*/
129+
fun hold(
130+
areas: List<Int>,
131+
command: Int,
132+
durationMs: Long = HOLD_MS,
133+
sleep: (Long) -> Unit = { Thread.sleep(it) },
134+
): Boolean {
135+
val targets = gate(areas, command) ?: return false
136+
var ok = true
137+
val deadline = System.currentTimeMillis() + durationMs
138+
while (System.currentTimeMillis() < deadline) {
139+
targets.forEach { if (!write(it, command)) ok = false }
140+
sleep(PULSE_MS)
141+
}
142+
targets.forEach { write(it, Command.STOP) }
143+
return ok
144+
}
145+
146+
/** The areas worth writing to, or null when the call is refused before any write. */
147+
private fun gate(areas: List<Int>, command: Int): List<Int>? {
148+
if (command !in 0..COMMAND_MAX) return null
149+
val targets = areas.filter { it in AREAS }
150+
if (targets.isEmpty()) return null
151+
if (vsm() == null || setter == null) return null
152+
// [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable.
153+
if (!VehicleWriteGate.allow("VSM setVehicleWindowStatus")) return null
154+
return targets
155+
}
156+
157+
private fun write(area: Int, command: Int): Boolean {
158+
val vsm = EVHardware.vsmInstance() ?: return false
159+
val set = setter ?: return false
160+
return try {
161+
set.invoke(vsm, area, command)
162+
true
163+
} catch (e: Exception) {
164+
AppLogger.w(TAG, " VSM: setVehicleWindowStatus($area, $command) exc: ${e.message}")
165+
false
166+
}
167+
}
168+
169+
private const val TAG = "EV_GLASS"
170+
}

‎lib/src/main/java/com/evsuite/hardware/saic/SaicVehicle.kt‎

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.evsuite.hardware.saic
33
import android.content.Context
44
import android.os.IBinder
55
import com.evsuite.hardware.A9Climate
6+
import com.evsuite.hardware.GlassProbe
7+
import com.evsuite.hardware.VsmGlass
68

79
/**
810
* The vendor vehicle service the head unit's HVAC and charging screens talk to.
@@ -297,13 +299,12 @@ object SaicCharging {
297299
* same shape holds elsewhere in the service — the tailgate is `get = 100`, `set = 1` — so a
298300
* status range and a command range simply are not the same thing on this interface.
299301
*
300-
* **What the seven commands mean is not known.** No head-unit application writes this
301-
* property: the settings, HVAC, launcher, system UI and telematics APKs all carry the SDK
302-
* and none of them calls the setter, so the firmware offers no example to read the values
303-
* off. Until one is identified, the command is passed through as the caller gave it and
304-
* anything outside 0–7 is refused here rather than sent to be dropped — a refusal in the
305-
* history is the honest report, and the value that moves the glass is then one experiment
306-
* away rather than hidden behind a scale that cannot work.
302+
* **The hub is the fallback, not the path.** No head-unit application writes this property,
303+
* so the firmware offers no example to read the seven values off, and the command is passed
304+
* through as the caller gave it with anything outside 0–7 refused here rather than sent to
305+
* be dropped. The VSM carries the same glass under a setter the car's own window switch
306+
* goes through, where the commands *are* established — [VsmGlass]. Every read and write
307+
* below prefers it and falls back to the hub only on a firmware whose VSM does not have it.
307308
*
308309
* The electric tailgate is deliberately absent: the launcher defines OPEN and CLOSE as the
309310
* same value (1.0f), so it is a pulse whose direction depends on state this cannot read.
@@ -350,11 +351,16 @@ object SaicVehicleControl {
350351
* is the other half — a rule that vents the driver's window alone cannot be written with
351352
* an all-or-nothing call.
352353
*/
353-
enum class Window(internal val readCode: Int, internal val writeCode: Int) {
354-
DRIVER(TX_GET_DRIVER_WINDOW, TX_SET_DRIVER_WINDOW),
355-
PASSENGER(TX_GET_PASSENGER_WINDOW, TX_SET_PASSENGER_WINDOW),
356-
REAR_LEFT(TX_GET_LEFT_REAR_WINDOW, TX_SET_LEFT_REAR_WINDOW),
357-
REAR_RIGHT(TX_GET_RIGHT_REAR_WINDOW, TX_SET_RIGHT_REAR_WINDOW)
354+
enum class Window(
355+
internal val readCode: Int,
356+
internal val writeCode: Int,
357+
/** The same window's number on the VSM, which addresses glass by area — [VsmGlass]. */
358+
internal val area: Int,
359+
) {
360+
DRIVER(TX_GET_DRIVER_WINDOW, TX_SET_DRIVER_WINDOW, 0),
361+
PASSENGER(TX_GET_PASSENGER_WINDOW, TX_SET_PASSENGER_WINDOW, 1),
362+
REAR_LEFT(TX_GET_LEFT_REAR_WINDOW, TX_SET_LEFT_REAR_WINDOW, 2),
363+
REAR_RIGHT(TX_GET_RIGHT_REAR_WINDOW, TX_SET_RIGHT_REAR_WINDOW, 3)
358364
}
359365

360366
private val WINDOW_READS = Window.entries.map { it.readCode }
@@ -369,7 +375,8 @@ object SaicVehicleControl {
369375
* window open" is `> 0`, and a rule closing them cares about the worst case.
370376
*/
371377
fun widestWindowPercent(): Int? =
372-
WINDOW_READS.mapNotNull { code -> SaicAidl.callFloat(binder(), DESCRIPTOR, code) }
378+
if (VsmGlass.isAvailable) Window.entries.mapNotNull { VsmGlass.percent(it.area) }.maxOrNull()
379+
else WINDOW_READS.mapNotNull { code -> SaicAidl.callFloat(binder(), DESCRIPTOR, code) }
373380
.takeIf { it.isNotEmpty() }
374381
?.max()
375382
?.toInt()
@@ -379,18 +386,39 @@ object SaicVehicleControl {
379386
* One window's position in percent, 0 closed to 100 open. Null when it does not answer.
380387
*/
381388
fun windowPercent(window: Window): Int? =
382-
SaicAidl.callFloat(binder(), DESCRIPTOR, window.readCode)?.toInt()?.takeIf { it >= 0 }
389+
if (VsmGlass.isAvailable) VsmGlass.percent(window.area)
390+
else SaicAidl.callFloat(binder(), DESCRIPTOR, window.readCode)?.toInt()?.takeIf { it >= 0 }
383391

384392
/**
385393
* Sends one glass command to one window.
386394
*
387395
* [command] is a command in `0..`[WINDOW_COMMAND_MAX], **not** a position — see the class
388396
* KDoc. A value outside that range is refused here and returns false: the service would
389397
* accept the call and drop the value, which is indistinguishable from having moved.
398+
*
399+
* [VsmGlass.Command.UP] is held down rather than written once, because that is the only
400+
* way it moves anything — see [holdWindow]. The hub path has no such notion and cannot
401+
* acquire one: it is one write there whatever the value.
390402
*/
391403
fun setWindow(window: Window, command: Int): Boolean {
392404
if (command !in 0..WINDOW_COMMAND_MAX) return false
393-
return SaicAidl.callVoid(binder(), DESCRIPTOR, window.writeCode, command.toFloat())
405+
if (!VsmGlass.isAvailable)
406+
return SaicAidl.callVoid(binder(), DESCRIPTOR, window.writeCode, command.toFloat())
407+
return if (command == VsmGlass.Command.UP) VsmGlass.hold(listOf(window.area), command)
408+
else VsmGlass.send(listOf(window.area), command)
409+
}
410+
411+
/**
412+
* Holds [command] on one window for [durationMs], then releases it.
413+
*
414+
* What [GlassProbe] sweeps with, and the reason a close works on the trims whose motors
415+
* have no position sensor: those obey nothing but a switch held down. Falls back to a
416+
* single write on the hub, which is all that path offers.
417+
*/
418+
fun holdWindow(window: Window, command: Int, durationMs: Long = VsmGlass.HOLD_MS): Boolean {
419+
if (command !in 0..WINDOW_COMMAND_MAX) return false
420+
return if (VsmGlass.isAvailable) VsmGlass.hold(listOf(window.area), command, durationMs)
421+
else SaicAidl.callVoid(binder(), DESCRIPTOR, window.writeCode, command.toFloat())
394422
}
395423

396424
/** Each window individually, for the diagnostic — this is what confirms the scale. */
@@ -404,6 +432,11 @@ object SaicVehicleControl {
404432
*/
405433
fun setAllWindows(command: Int): Boolean {
406434
if (command !in 0..WINDOW_COMMAND_MAX) return false
435+
if (VsmGlass.isAvailable) {
436+
val areas = Window.entries.map { it.area }
437+
return if (command == VsmGlass.Command.UP) VsmGlass.hold(areas, command)
438+
else VsmGlass.send(areas, command)
439+
}
407440
val value = command.toFloat()
408441
// Every window attempted even if one refuses: three closed beats none closed, and
409442
// the caller still learns that it was not complete.

0 commit comments

Comments
 (0)