Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 55 additions & 36 deletions app/src/main/java/com/dzid26/teslable/ble/TeslaBleController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.dzid26.teslable.core.history.BatterySample
import com.dzid26.teslable.core.history.ConnectionEvent
import com.dzid26.teslable.core.protocol.AntiReplayWindow
import com.dzid26.teslable.core.protocol.InfotainmentPollPolicy
import com.dzid26.teslable.core.protocol.InfotainmentSessionGate
import com.dzid26.teslable.core.protocol.ShiftStateKind
import com.dzid26.teslable.core.protocol.TeslaCommands
import com.dzid26.teslable.core.protocol.TeslaCrypto
Expand Down Expand Up @@ -666,7 +667,6 @@ class TeslaBleController(
private val sessions = mutableMapOf<Domain, TeslaSession>()
private val pendingSessions = mutableMapOf<String, PendingSession>()
private val pendingCommands = mutableMapOf<String, PendingCommand>()
private var chargeAfterSession = false
private var wakeAfterSession = false
private var sessionRetryAttempts = 0
var reconnectAttempts = 0
Expand All @@ -685,6 +685,9 @@ class TeslaBleController(
/** Decides when an Infotainment read is due (ADR-0009). */
private val infotainmentPolicy = InfotainmentPollPolicy()

/** Whether a due read is waiting for the Infotainment handshake; never left set once it is over. */
private val infotainmentGate = InfotainmentSessionGate()

/** The shift state last written to the debug log, so only a change is logged again. */
private var lastShiftState: ShiftStateKind? = null

Expand Down Expand Up @@ -743,6 +746,8 @@ class TeslaBleController(
if (needed.isEmpty()) return
if (sessionRetryAttempts++ >= SESSION_MAX_ATTEMPTS) {
log("${name()}: session not established (${needed.joinToString { it.name }})")
infotainmentGate.onGaveUp()
dropPendingInfotainment()
return
}
sendSessionRequests(needed)
Expand Down Expand Up @@ -829,7 +834,7 @@ class TeslaBleController(
pendingSessions.clear()
pendingCommands.clear()
keySlotQueue = emptyList()
chargeAfterSession = false
infotainmentGate.onLinkDropped()
wakeAfterSession = false
pendingPairCheck = false
updateConnection(address) {
Expand Down Expand Up @@ -920,34 +925,44 @@ class TeslaBleController(
}
}

/** Infotainment needs an awake car, so it is only requested then. */
private fun neededSessions(): List<Domain> {
val awake =
_state.value.connections[address]
?.status
?.asleep == false
return SESSION_DOMAINS.filter { domain ->
private fun isAwake(): Boolean =
_state.value.connections[address]
?.status
?.asleep == false

/**
* VCSEC is always needed. Infotainment only while an awake car has a read waiting for it
* (a sleeping car cannot answer), never on connect.
*/
private fun neededSessions(): List<Domain> =
SESSION_DOMAINS.filter { domain ->
sessions.containsKey(domain).not() &&
(domain != Domain.DOMAIN_INFOTAINMENT || awake)
(domain != Domain.DOMAIN_INFOTAINMENT || infotainmentGate.mayRequestSession(isAwake()))
}

/** Forgets unanswered Infotainment requests, so none is mistaken for a handshake still in flight. */
private fun dropPendingInfotainment() {
pendingSessions.values.removeAll { it.domain == Domain.DOMAIN_INFOTAINMENT }
}

fun startSession() {
/** True when session requests went out. */
fun startSession(): Boolean {
if (!keyStore.hasKey(bleName)) {
log("${name()}: no pairing key yet")
return
return false
}
val vin = vin()
if (vin.length != Vehicle.VIN_LENGTH) {
log("${name()}: enter your VIN to establish a session")
return
return false
}
val needed = neededSessions()
if (needed.isEmpty()) return
if (needed.isEmpty()) return false
sessionRetryAttempts = 0
sendSessionRequests(needed)
handler.removeCallbacks(sessionRetry)
handler.postDelayed(sessionRetry, SESSION_RETRY_MS)
return true
}

private fun sendSessionRequests(domains: List<Domain>) {
Expand Down Expand Up @@ -1023,8 +1038,7 @@ class TeslaBleController(
if (SESSION_DOMAINS.all { sessions.containsKey(it) }) {
handler.removeCallbacks(sessionRetry)
}
if (pending.domain == Domain.DOMAIN_INFOTAINMENT && chargeAfterSession) {
chargeAfterSession = false
if (pending.domain == Domain.DOMAIN_INFOTAINMENT && infotainmentGate.onSessionEstablished()) {
requestChargeState()
}
if (pending.domain == Domain.DOMAIN_VEHICLE_SECURITY && wakeAfterSession) {
Expand Down Expand Up @@ -1078,23 +1092,23 @@ class TeslaBleController(
/** [userRequested]: the refresh button, sent at once and counted by the policy. */
fun requestChargeState(userRequested: Boolean = false) {
if (userRequested) infotainmentPolicy.onUserRequest(System.currentTimeMillis())
if (_state.value.connections[address]
?.status
?.asleep != false
) {
log("${name()}: car is asleep; wake it first")
return
}
if (sessions.containsKey(Domain.DOMAIN_INFOTAINMENT)) {
sendAuthenticated(
domain = Domain.DOMAIN_INFOTAINMENT,
payload = TeslaCommands.buildChargeStateRequest(),
kind = CommandKind.CHARGE,
)
} else {
chargeAfterSession = true
log("${name()}: requesting Infotainment session for SOC")
startSession()
when (infotainmentGate.onReadDue(isAwake(), sessions.containsKey(Domain.DOMAIN_INFOTAINMENT))) {
InfotainmentSessionGate.Action.SKIP -> log("${name()}: car is asleep; wake it first")

InfotainmentSessionGate.Action.SEND_READ ->
sendAuthenticated(
domain = Domain.DOMAIN_INFOTAINMENT,
payload = TeslaCommands.buildChargeStateRequest(),
kind = CommandKind.CHARGE,
)

// The retry loop is already running; the read goes out when the session arrives.
InfotainmentSessionGate.Action.WAIT_FOR_HANDSHAKE -> Unit

InfotainmentSessionGate.Action.START_HANDSHAKE -> {
log("${name()}: requesting Infotainment session for SOC")
if (!startSession()) infotainmentGate.onNotStartable()
}
}
}

Expand Down Expand Up @@ -1528,9 +1542,12 @@ class TeslaBleController(
// Track SOC for every awake car that can answer, not just the
// selected one; skip cars with no VIN (no session). The policy
// decides when a read is due so the car can fall asleep (ADR-0009).
val canRead =
!chargeAfterSession &&
(sessions.containsKey(Domain.DOMAIN_INFOTAINMENT) || vin().length == Vehicle.VIN_LENGTH)
// A read can be attempted when a session exists or can be started on demand.
val canRead = sessions.containsKey(Domain.DOMAIN_INFOTAINMENT) || vin().length == Vehicle.VIN_LENGTH
if (status.asleep) {
infotainmentGate.onAsleep()
dropPendingInfotainment()
}
if (infotainmentPolicy.onStatus(status, canRead, System.currentTimeMillis())) requestChargeState()
} else {
log("${name()}: RX ${message.size} bytes ${message.toHex()}")
Expand All @@ -1546,6 +1563,8 @@ class TeslaBleController(
transport = null
handler.removeCallbacks(poll)
handler.removeCallbacks(rssiPoll)
infotainmentGate.onLinkDropped()
pendingSessions.clear()
val hadData =
_state.value.connections[address]?.let {
it.sessions.isNotEmpty() || it.status != null || it.charge != null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-only
package com.dzid26.teslable.core.protocol

/**
* Tracks whether an Infotainment read is waiting for its session handshake, and when the handshake
* may be requested (ADR-0009).
*
* The Infotainment session is requested only on demand: when [InfotainmentPollPolicy] says a read is
* due and VCSEC says the car is awake. A sleeping car cannot answer, and the request might wake it.
* The wait ends in exactly one of two ways: the session is established ([onSessionEstablished], the
* read then goes out), or it is abandoned ([onGaveUp], [onAsleep], [onLinkDropped],
* [onNotStartable]). A wait that is never ended would make the caller think a handshake is still in
* flight and never start another one; a real car logged VCSEC status for a whole drive without a
* single read that way.
*
* Pure, so it ports to other platforms; one instance per vehicle link, not thread-safe.
*/
class InfotainmentSessionGate {
/** What to do about a read that is due. */
enum class Action {
/** A session exists: send the read now. */
SEND_READ,

/** No session and none requested yet: request it; the read follows on success. */
START_HANDSHAKE,

/** A request is already in flight: do not send another; the read follows on success. */
WAIT_FOR_HANDSHAKE,

/** The car is not awake: do nothing. */
SKIP,
}

/** A read is waiting for the handshake to finish; this also means a handshake is in flight. */
var readWaiting = false
private set

/** Decides what to do with a read that is due. [awake] is the latest VCSEC verdict. */
fun onReadDue(
awake: Boolean,
hasSession: Boolean,
): Action =
when {
!awake -> {
readWaiting = false
Action.SKIP
}

hasSession -> Action.SEND_READ

readWaiting -> Action.WAIT_FOR_HANDSHAKE

else -> {
readWaiting = true
Action.START_HANDSHAKE
}
}

/** Whether the Infotainment session may be requested now (retries included). */
fun mayRequestSession(awake: Boolean): Boolean = readWaiting && awake

/** The Infotainment session is established. True when a read was waiting and should go out now. */
fun onSessionEstablished(): Boolean {
val waiting = readWaiting
readWaiting = false
return waiting
}

/** The handshake retries ran out. */
fun onGaveUp() = clear()

/** VCSEC reports the car asleep: no handshake can finish. */
fun onAsleep() = clear()

/** The BLE link dropped or was closed: the handshake went down with it. */
fun onLinkDropped() = clear()

/** The handshake could not even be sent (no key, no valid VIN). */
fun onNotStartable() = clear()

private fun clear() {
readWaiting = false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: AGPL-3.0-only
package com.dzid26.teslable.core.protocol

import com.dzid26.teslable.core.protocol.InfotainmentSessionGate.Action
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class InfotainmentSessionGateTest {
private val gate = InfotainmentSessionGate()

@Test
fun `read with a session goes out without a handshake`() {
assertEquals(Action.SEND_READ, gate.onReadDue(awake = true, hasSession = true))
assertFalse(gate.readWaiting)
}

@Test
fun `first due read starts the handshake and later ones wait for it`() {
assertEquals(Action.START_HANDSHAKE, gate.onReadDue(awake = true, hasSession = false))
assertTrue(gate.readWaiting)
assertEquals(Action.WAIT_FOR_HANDSHAKE, gate.onReadDue(awake = true, hasSession = false))
assertEquals(Action.WAIT_FOR_HANDSHAKE, gate.onReadDue(awake = true, hasSession = false))
}

@Test
fun `established session releases the waiting read once`() {
gate.onReadDue(awake = true, hasSession = false)
assertTrue(gate.onSessionEstablished())
assertFalse(gate.readWaiting)
assertFalse(gate.onSessionEstablished())
}

@Test
fun `session is requested only while awake and a read waits`() {
assertFalse(gate.mayRequestSession(awake = true))
gate.onReadDue(awake = true, hasSession = false)
assertTrue(gate.mayRequestSession(awake = true))
assertFalse(gate.mayRequestSession(awake = false))
}

@Test
fun `a sleeping car never starts a handshake`() {
assertEquals(Action.SKIP, gate.onReadDue(awake = false, hasSession = false))
assertFalse(gate.readWaiting)
assertFalse(gate.mayRequestSession(awake = false))
}

@Test
fun `give-up unblocks the next due read`() {
gate.onReadDue(awake = true, hasSession = false)
gate.onGaveUp()
assertFalse(gate.readWaiting)
assertEquals(Action.START_HANDSHAKE, gate.onReadDue(awake = true, hasSession = false))
}

@Test
fun `car falling asleep unblocks the next due read after the wake`() {
gate.onReadDue(awake = true, hasSession = false)
gate.onAsleep()
assertFalse(gate.readWaiting)
assertEquals(Action.START_HANDSHAKE, gate.onReadDue(awake = true, hasSession = false))
}

@Test
fun `dropped link unblocks the next due read after the reconnect`() {
gate.onReadDue(awake = true, hasSession = false)
gate.onLinkDropped()
assertFalse(gate.readWaiting)
assertEquals(Action.START_HANDSHAKE, gate.onReadDue(awake = true, hasSession = false))
}

@Test
fun `unstartable handshake does not leave a read waiting`() {
gate.onReadDue(awake = true, hasSession = false)
gate.onNotStartable()
assertFalse(gate.readWaiting)
}

@Test
fun `waiting read is dropped when the car is found asleep at the next due read`() {
gate.onReadDue(awake = true, hasSession = false)
assertEquals(Action.SKIP, gate.onReadDue(awake = false, hasSession = false))
assertFalse(gate.readWaiting)
}
}
15 changes: 15 additions & 0 deletions docs/adr/0009-infotainment-poll-backoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ status, whether the charge+drive pair is due. One instance per vehicle link.
door change that happened while the link was down therefore starts the hold on
the first status after the reconnect.

The Infotainment session is requested on demand, by the read itself
(`InfotainmentSessionGate`, `core`), never on connect:

- A due read with no session starts the handshake and goes out when the
session is established; further due reads while it is in flight do not send
another request.
- Never while VCSEC says asleep: a sleeping car cannot answer, and the request
might wake it. On connect only the VCSEC session is requested.
- The wait for the handshake ends when the session is established, the retries
give up, the car is reported asleep, or the link drops. A wait that outlived
its handshake once blocked every read for a whole drive (found in the first
real-car log, `docs/research/real-car-log-2026-10-09.md`).
- The policy's "session ready" input means "a read can be attempted now": a
session exists or the VIN is valid so one can be started.

A read is due slightly early (1 s slack) so that jitter in the 10 s status
ticks does not turn a 10 s cadence into 20 s.

Expand Down
3 changes: 3 additions & 0 deletions docs/research/infotainment-polling-and-sleep.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ Sources were read on 2026-10-08; quotes are short extracts.

- Not yet measured on a real car. The real-car check in the ADR-0009 PR is the
first measurement: record the time from idle to asleep here.
- First real-car log (2026-10-09, old build, unstable link):
`real-car-log-2026-10-09.md`. A repeat with a stable link is planned to show
whether reads hold the car awake.

## Related

Expand Down
Loading
Loading