From 638c78045dfb2d461ae5af9d0f072b4d3c5d9c6d Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:25:43 +0200 Subject: [PATCH 01/17] Add the ERG bias learner df5d112 left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit df5d112 committed everything that USES ErgBias — the import in BridgeService, the ergBiasW argument to PowerCorrection, the correctCommanded path — but the class itself and its tests were still untracked, so that commit does not compile. No behaviour change on top of df5d112; this is the missing half of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R4wR6MG97CqNiC9shgQSvf --- .../trainerbridgeble/correction/ErgBias.kt | 90 +++++++++++++++++ .../correction/ErgBiasTest.kt | 98 +++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 app/src/main/java/com/enderthor/trainerbridgeble/correction/ErgBias.kt create mode 100644 app/src/test/java/com/enderthor/trainerbridgeble/correction/ErgBiasTest.kt diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/correction/ErgBias.kt b/app/src/main/java/com/enderthor/trainerbridgeble/correction/ErgBias.kt new file mode 100644 index 0000000..9a4b9f5 --- /dev/null +++ b/app/src/main/java/com/enderthor/trainerbridgeble/correction/ErgBias.kt @@ -0,0 +1,90 @@ +package com.enderthor.trainerbridgeble.correction + +import kotlin.math.roundToInt + +/** + * Learns how far the trainer settles ABOVE the raw target we command it. + * + * Measured over three sessions against a power meter: the Zycle holds ~8 W (raw) more than commanded. The + * app runs its own ERG loop on the power we report, sees itself over target, and walks its setpoint down a + * click at a time — which is what "the intensity goes down by itself, as if the minus button pressed + * itself" actually is. The app is not misbehaving and neither are we: it is correcting a real overshoot. + * + * We cannot make the trainer track better, so we command it LOWER by exactly what it overshoots and it + * lands where the app asked. Learned rather than configured: it is a property of this particular trainer + * (and its temperature, and its belt), so no one can be expected to type the number in. + * + * It cannot wind up. Commanding `target - bias` makes the trainer deliver `(target - bias) + overshoot`, + * so the sampled error stays at `overshoot` whatever the bias is — the EMA converges on the trainer's + * error, it does not chase its own output the way an error integrator would. + * + * Pure logic, no Android: the caller passes the clock (as [com.enderthor.trainerbridgeble.CorrectedFeed] + * does) so this is unit-testable. + */ +object ErgBias { + + /** Settling time before a sample counts: the trainer ramps to a new target over several seconds, and + * that ramp is not the steady-state error we are after. */ + private const val SETTLE_MS = 12_000L + /** Below this raw target the ERG floor may be holding the command above what the app asked, so + * "measured - commanded" no longer measures the trainer. (floorRaw is ~25 with the usual settings.) */ + private const val MIN_TARGET_W = 40 + /** ~100 samples to converge; at the trainer's 0.5 Hz that is roughly three minutes. Slow on purpose: + * the rider surging over target must not move it. */ + private const val ALPHA = 0.02 + private const val MAX_BIAS_W = 30 + /** One absurd sample (a dropout, a standing sprint) must not drag the average. */ + private const val MAX_SAMPLE_W = 60 + + private const val OP_SET_TARGET_POWER = 0x05 + + @Volatile private var bias = 0.0 + private var commandedRaw: Int? = null + private var commandedAtMs = 0L + + /** The learned bias, in raw watts, to subtract from the ERG command. */ + val watts: Int get() = bias.roundToInt() + + /** Restore what a previous session learned, so a ride starts calibrated instead of re-converging. Also + * retires any active command: this is a session boundary, and a command left over from the last one + * would read as long settled and be measured against power from a different ride. */ + @Synchronized fun seed(w: Int) { + bias = w.coerceIn(-MAX_BIAS_W, MAX_BIAS_W).toDouble() + forget() + } + + /** + * A control write on its way to the trainer — already inverse-corrected, i.e. exactly the raw watts the + * trainer is being told to hold. Anything that ends ERG (reset, stop, resistance or simulation mode) + * retires the active command; the rest (request control, start/resume) leave it alone. + */ + @Synchronized fun onControl(bytes: ByteArray, nowMs: Long) { + if (bytes.isEmpty()) return + when (bytes[0].toInt() and 0xFF) { + OP_SET_TARGET_POWER -> if (bytes.size >= 3) { + val raw = ((bytes[1].toInt() and 0xFF) or ((bytes[2].toInt() and 0xFF) shl 8)).toShort().toInt() + if (raw != commandedRaw) { commandedRaw = raw; commandedAtMs = nowMs } + } + 0x01, 0x04, 0x08, 0x11 -> forget() + } + } + + /** + * A raw power reading from the trainer. Returns the new bias when its whole-watt value changed (so the + * caller can persist it), null otherwise. + */ + @Synchronized fun onPower(rawWatts: Int, nowMs: Long): Int? { + val target = commandedRaw ?: return null + if (target < MIN_TARGET_W) return null + if (rawWatts <= 0) return null // not pedalling: says nothing about tracking + if (nowMs - commandedAtMs < SETTLE_MS) return null // still ramping + val before = watts + val sample = (rawWatts - target).coerceIn(-MAX_SAMPLE_W, MAX_SAMPLE_W) + bias = (bias + ALPHA * (sample - bias)).coerceIn(-MAX_BIAS_W.toDouble(), MAX_BIAS_W.toDouble()) + return watts.takeIf { it != before } + } + + /** The trainer dropped, or ERG ended: there is no active command to measure against any more. The + * learned bias survives — it belongs to the trainer, not to the session. */ + @Synchronized fun forget() { commandedRaw = null; commandedAtMs = 0L } +} diff --git a/app/src/test/java/com/enderthor/trainerbridgeble/correction/ErgBiasTest.kt b/app/src/test/java/com/enderthor/trainerbridgeble/correction/ErgBiasTest.kt new file mode 100644 index 0000000..2d8775a --- /dev/null +++ b/app/src/test/java/com/enderthor/trainerbridgeble/correction/ErgBiasTest.kt @@ -0,0 +1,98 @@ +package com.enderthor.trainerbridgeble.correction + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ErgBiasTest { + + /** ErgBias is a singleton — every test starts from a known state. */ + @Before fun reset() { ErgBias.seed(0); ErgBias.forget() } + + private fun setTargetPower(w: Int) = byteArrayOf(0x05, (w and 0xFF).toByte(), ((w shr 8) and 0xFF).toByte()) + + /** Feed `seconds` of a trainer that settles `overshoot` W above whatever it is commanded. */ + private fun ride(commandedRaw: Int, overshoot: Int, seconds: Int, startMs: Long = 0L): Long { + ErgBias.onControl(setTargetPower(commandedRaw), startMs) + var t = startMs + repeat(seconds) { t += 2_000; ErgBias.onPower(commandedRaw + overshoot, t) } + return t + } + + @Test fun learnsTheTrainersOvershoot() { + ride(commandedRaw = 150, overshoot = 8, seconds = 400) + assertEquals(8, ErgBias.watts) + } + + /** + * The property the whole design rests on: the bias converges on the trainer's error and STAYS there, + * because commanding lower does not change the error we sample. An error integrator would run away. + */ + @Test fun doesNotWindUpOnceApplied() { + val c = PowerCorrection(scale = 1.05, offset = 24.0, ergBiasW = 8) + val target = 200 + val commanded = c.invert(target) // already 8 W lower than the honest inverse + ride(commandedRaw = commanded, overshoot = 8, seconds = 600) + assertEquals(8, ErgBias.watts) // still 8 — not 16, not climbing + // ...and the rider gets what the app asked for: the trainer delivers commanded + 8. + assertEquals(target, c.correct(commanded + 8)) + } + + /** + * Regression: subtracting the bias BEFORE the "is the app asking for nothing" check widened that + * window by the bias, so a 30 W recovery target commanded 0 — flywheel free — instead of holding the + * ERG floor. The bias may push the command down, never past the floor and never off a cliff to zero. + */ + @Test fun theErgBiasNeverCostsTheFloor() { + val plain = PowerCorrection(scale = 1.08, offset = 25.0, invertFloorW = 50) + val biased = PowerCorrection(scale = 1.08, offset = 25.0, invertFloorW = 50, ergBiasW = 8) + for (target in 1..400) { + val p = plain.invert(target); val b = biased.invert(target) + if (p == 0) assertEquals("target $target: zero must stay zero", 0, b) + else assertTrue("target $target: floor lost ($p -> $b)", b >= 23) // floorRaw = (50-25)/1.08 + } + assertEquals(0, biased.invert(25)) // within the offset: still a real stop + assertEquals(23, biased.invert(30)) // recovery target: still holds the floor + assertEquals(154, biased.invert(200)) // well above it: the full bias applies + } + + @Test fun ignoresTheRampToANewTarget() { + ErgBias.onControl(setTargetPower(150), 0L) + repeat(5) { ErgBias.onPower(60, 2_000L * it) } // first 10 s: still spinning up, way under target + assertEquals(0, ErgBias.watts) + } + + @Test fun ignoresACoastingRiderAndLowTargets() { + ErgBias.onControl(setTargetPower(150), 0L) + repeat(100) { ErgBias.onPower(0, 20_000L + 2_000L * it) } // stopped pedalling + assertEquals(0, ErgBias.watts) + ride(commandedRaw = 20, overshoot = 15, seconds = 200) // below the ERG floor: means nothing + assertEquals(0, ErgBias.watts) + } + + @Test fun stopEndsTheMeasurement() { + ErgBias.onControl(setTargetPower(150), 0L) + ErgBias.onControl(byteArrayOf(0x08), 1_000L) // FTMS Stop/Pause + repeat(200) { ErgBias.onPower(300, 20_000L + 2_000L * it) } + assertEquals(0, ErgBias.watts) + } + + @Test fun oneAbsurdSampleBarelyMovesIt() { + ride(commandedRaw = 150, overshoot = 8, seconds = 400) + ErgBias.onPower(150 + 900, 2_000_000L) // a garbage frame / standing sprint + assertTrue("a single outlier must not swing the bias", ErgBias.watts in 8..10) + } + + @Test fun staysWithinItsClamp() { + ride(commandedRaw = 150, overshoot = 500, seconds = 2000) + assertEquals(30, ErgBias.watts) + } + + @Test fun seedSurvivesUntilNewEvidence() { + ErgBias.seed(9) + assertEquals(9, ErgBias.watts) + ErgBias.forget() + assertEquals(9, ErgBias.watts) // the trainer dropping does not unlearn the trainer + } +} From b02378108e01d7d200a15707d21007894d938603 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:40:01 +0200 Subject: [PATCH 02/17] Three review rounds, plus the diagnostics to tell whether they worked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A four-reviewer audit (three Opus passes on separate axes, one adversarial Codex pass) found 19 issues; ten are fixed here. Two further Codex rounds then reviewed the fixes themselves and broke twelve and three of them respectively, which is the same lesson as 000b6f3 — the fixes need the adversary more than the original code does. Proxy: - The Zycle level anchor survived a trainer dropout, so the first frame back was differenced against a level from before it and the whole gap reached the app as one rider button press. Re-anchor instead, without moving what the app sees. This is the "sometimes the proxy misbehaves" symptom. - Four paths left BLE advertising dead for the rest of the ride: an exhausted retry budget, a start watchdog that cleared its latch without retrying or reporting, a synchronous throw, and openGattServer returning null. All four now enter one backoff loop with no attempt cap; a held profile is replayed when the server finally opens. - nudgeResistance wrote 0x04 straight past the mirror, so ErgBias never saw the op that ends ERG and kept measuring against a dead command — saturating the bias and PERSISTING it, which started the next ride 30 W out. - The servo-step budget was armed by writes that never reached the trainer, and survived a dropout to eat the rider's next press. - elapsedRealtime for every timeout and freshness decision; wall clock only for log timestamps. CorrectedFeed already documented why. Energy (all in the idle state, which is the default one): - The wake lock followed the master switch, not the trainer link, blocking suspend for every hour the app sat armed with no trainer. It now follows the link, lingering past the reconnect delay so we never suspend with no scan up. - The cold scan ran at BALANCED (25% radio duty) indefinitely. A search before any connection is not the mid-ride reacquisition the comment defends. - The ERG bias was written to prefs on every whole-watt flip. Once a minute. Performance: - The Monitor re-rendered at the trainer's packet rate on the same looper the notify fan-out posts to; the 1 Hz poller already covered it. - Every reconnect blacked out data for 0.6-2 s because the whole read burst was queued ahead of the subscribes. The data characteristics go first; the cold-cache invariant gates on the queue draining, not on the first subscribe. From the review rounds on the fixes: a generation guard so a replaced source's callbacks cannot drive the live one (validated on main, or the check is check-then-act), a lock making the server/profile handover atomic, stopped guards so a late callback cannot re-advertise a closed server, and try/finally around the burst so nothing can hold the GATT queue shut. Diagnostics, because none of the above is observable otherwise: a session header with the build and the correction values, the level decision (raw, shown, and whether it was scored servo or rider), wake lock transitions, scan start with its mode, and a state line a minute so a quiet log is informative rather than ambiguous. Log cap raised to 48 MB: at 16 MB rotation was throwing away the start of the ride, which is the part you turned logging on to see. Not verified on hardware. No test covers any file touched here. --- .../trainerbridgeble/BridgeService.kt | 189 +++++++++++++++--- .../com/enderthor/trainerbridgeble/FileLog.kt | 5 +- .../trainerbridgeble/MonitorActivity.kt | 2 +- .../trainerbridgeble/ant/RawAntLink.kt | 9 +- .../trainerbridgeble/ble/MirrorServer.kt | 173 ++++++++++++++-- .../trainerbridgeble/ble/ZycleClient.kt | 75 +++++-- .../trainerbridgeble/correction/ErgBias.kt | 4 + 7 files changed, 385 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index 208fccb..433a206 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -59,8 +59,10 @@ class BridgeService : Service() { @Volatile var lastSpeedKmh: Double? = null; private set @Volatile var lastCadence: Int? = null; private set @Volatile var lastControl: String? = null; private set - @Volatile var lastSampleMs: Long = 0L; private set // wall-clock of the last trainer sample, for UI staleness - @Volatile var lastPowerMs: Long = 0L; private set // ...and of the last packet that actually CARRIED power + // elapsedRealtime, NOT wall-clock, for both — the reason CorrectedFeed already documents: the Karoo + // re-syncs its clock mid-ride, and a jump either blanks live data or hides a real dropout. + @Volatile var lastSampleMs: Long = 0L; private set // last trainer sample, for UI staleness + @Volatile var lastPowerMs: Long = 0L; private set // ...and the last packet that actually CARRIED power // FTMS says Instantaneous Speed is 0.01 km/h. Some trainers (the Zycle among them) report 0.1 km/h, // which silently makes speed AND the recorded distance ten times too small. Rather than hardcode either, @@ -87,10 +89,20 @@ class BridgeService : Service() { /** Power specifically — a packet can arrive without the power field, and a sticky last value must not be * reported as live to ANT, the Karoo recording, or the tiles. A short grace covers one dropped frame. */ - val powerFresh: Boolean get() = lastPowerMs != 0L && System.currentTimeMillis() - lastPowerMs <= POWER_STALE_MS + val powerFresh: Boolean get() = lastPowerMs != 0L && android.os.SystemClock.elapsedRealtime() - lastPowerMs <= POWER_STALE_MS private fun freshPowerOrNull(): Int? = if (powerFresh) lastCorrectedW else null @Volatile private var lastResistance: Int? = null @Volatile private var sawIndoorBikeData = false // NOT `lastRawW == null`: that is set by the fallback itself + @Volatile private var pendingErgBiasW: Int? = null // learned but not yet written to prefs (see learnErgBias) + @Volatile private var lastBiasPersistMs = 0L + /** Bumped by every start AND stop of the receive half. The source's callbacks capture the value they were + * created with and no-op once it moves: a GATT callback already past its own `gatt === g` check when a + * source switch lands would otherwise write this session's state (and take the wake lock) on behalf of a + * source that no longer exists. */ + @Volatile private var receiveGen = 0 + /** How many callbacks the generation guard rejected. Zero all ride means the races the guard exists for + * never happened; a climbing number is itself the finding. Reported by the periodic snapshot. */ + private val staleCallbacks = java.util.concurrent.atomic.AtomicInteger(0) @Volatile private var antEnabled = false @Volatile var antOk = false; private set @Volatile var antStatus: String = ""; private set @@ -126,7 +138,18 @@ class BridgeService : Service() { // rider, exactly like the bike's own, and the level move it causes SHOULD reach the app. // optimistic, and only if the write was at least QUEUED (no link / unknown char → don't move the // tile). A stack refusal after queueing still shows briefly; the trainer's own IBD corrects it. - if (client?.write(com.enderthor.trainerbridgeble.ble.GattUuids.FTMS_CONTROL_POINT, byteArrayOf(0x04, target.toByte()), true) == true) { + val bytes = byteArrayOf(0x04, target.toByte()) + if (client?.write(com.enderthor.trainerbridgeble.ble.GattUuids.FTMS_CONTROL_POINT, bytes, true) == true) { + // The mirror's toZycle lambda is where ErgBias sees control ops, and this path deliberately + // bypasses it — so tell the learner directly. 0x04 takes the trainer OUT of ERG, and without + // this its commandedRaw stays pinned to the app's last target: every later reading is then + // measured against a command no longer in force, saturating the bias and PERSISTING it. + // ponytail: `write() == true` means QUEUED, not accepted by the stack, so a 0x04 that dies in + // the queue still retires the command here. That only makes the learner stop learning until + // the next 0x05 — it cannot poison the bias, which is what this fix is for. Closing it needs + // the write path to report terminal completion back (the same plumbing an FTMS failure + // indication would need); do both together or neither. + ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) lastResistance = target lastControl = getString(R.string.control_resistance_target, target) FileLog.event("UI button → resistance target=$target") @@ -195,6 +218,19 @@ class BridgeService : Service() { // Before the try, not after: the catch below logs WHY startForeground failed, and FileLog silently // drops anything written before init. One File object is not what blows the 5 s window. FileLog.init(this); FileLog.enabled = Config(this).loggingEnabled + // Session header. Two builds are now in play and a log with no version is a log you cannot trust to + // be about the code you think it is; the correction values matter because every wattage below is + // relative to them. + Config(this).let { c -> + // via PackageManager rather than BuildConfig: AGP 8 does not generate that class unless + // buildFeatures.buildConfig is turned on, and one log line does not justify a build change. + val ver = runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() + FileLog.event("=== session start v$ver " + + "${android.os.Build.MODEL} api${android.os.Build.VERSION.SDK_INT} — " + + "scale=+${c.scaleAdjustPercent}% offset=${c.offsetW}W floor=${c.invertFloorW}W " + + "ergBias=${c.ergBiasW}W advName='${c.advertisedName}' sim=${c.simulate} ant=${c.antOutputEnabled}") + } + try { // connectedDevice only: dataSync would add a ~6h/24h cumulative FGS timeout on Android 14+ that // could kill the bridge mid-ride, and the BLE companion link doesn't need it. @@ -206,7 +242,10 @@ class BridgeService : Service() { status = getString(R.string.status_missing_bt_permission); listener?.invoke(); stopSelf(); return false } foreground = true - acquireWakeLock() + handler.removeCallbacks(snapshot); handler.post(snapshot) // stops itself once foreground goes false + // NOT here: the wake lock follows the TRAINER LINK, not the master switch (see the onState callback in + // startReceive). Held from here it blocked suspend for every hour the master was left on with no + // trainer in the room — which is most of the day, and the single biggest idle drain in the app. return true } @@ -265,29 +304,64 @@ class BridgeService : Service() { if (client != null || simSource != null) return // idempotent val config = Config(this) config.lastSeenAddress = ""; config.lastSeenName = "" // runtime state; a process kill leaves it stale + pendingErgBiasW = null; lastBiasPersistMs = 0L // 0 = the first learned value of the ride writes at once ErgBias.seed(config.ergBiasW) // start calibrated; there is no live command to measure against yet FileLog.event("receive start paired=${config.pairedAddress.ifEmpty { "any" }} sim=${config.simulate} ergBias=${config.ergBiasW}W") - val onProfile: (GattProfile) -> Unit = { profile -> lastProfile = profile; mirror?.build(profile) } + val gen = ++receiveGen + // Hopping to main is what makes the generation check MEAN anything: read on a binder thread it is + // check-then-act, and a source switch landing between the check and the body would let a replaced + // source drive the live one. startReceive/stopReceive both run on main, so validating there is + // genuinely serialised against them. Only for the callbacks that can LATCH something. + val onProfile: (GattProfile) -> Unit = { profile -> + // The worst of them: MirrorServer.build() is a one-shot latch, so a late profile from the source + // we just replaced wins it and the new source's real profile is then ignored for the session. + handler.post { if (gen == receiveGen) { lastProfile = profile; mirror?.build(profile) } } + } val onValue: (java.util.UUID, ByteArray) -> Unit = { uuid, value -> - cacheForUi(config, uuid, value) - lastValues[uuid] = value // the one-shot reads happen long before Broadcast is pressed - mirror?.onZycleValue(uuid, value) + // NOT posted: this is the 4 Hz relay path and a main-looper hop is exactly the latency R2 is + // about. A plain volatile compare is free, and the residue of check-then-act here is one stale + // sample relayed — nothing latches, unlike onProfile above. + if (gen == receiveGen) { + cacheForUi(config, uuid, value) + lastValues[uuid] = value // the one-shot reads happen long before Broadcast is pressed + mirror?.onZycleValue(uuid, value) + } else staleCallbacks.incrementAndGet() // counted, not logged: it would be per-packet } val onState: (Boolean) -> Unit = { connected -> + handler.post { if (gen == receiveGen) { // see onProfile: validated on main, so it is atomic zycleConnected = connected + // The wake lock lives HERE, not in goForeground(): there is data to keep the CPU awake for only + // while a trainer is actually feeding us. + // On the DROP it lingers instead of releasing at once. The reconnect is a postDelayed 2 s away, + // and postDelayed does not wake a suspended CPU — releasing immediately can leave us suspended + // with NO scan running, and then the trainer's advertising has nothing to arrive at. Once a scan + // is actually up the controller wakes the AP on a match, so the lock is only needed to bridge + // that gap. (Residual: if startScan itself keeps failing, its backoff windows are unscanned.) + if (connected) acquireWakeLock() else { + handler.removeCallbacks(releaseWakeLockLater) + handler.postDelayed(releaseWakeLockLater, WAKELOCK_LINGER_MS) + } // Only the DROP is immediate; going on the air waits for onSynced below. if (!connected) { zycleSynced = false; mirror?.setTrainerLinked(false); ErgBias.forget() } if (!connected) { config.lastSeenAddress = ""; config.lastSeenName = "" } // the config screen offers it only while live - status = if (connected) getString(R.string.status_trainer_connected) else getString(R.string.status_searching_trainer); listener?.invoke() } - val onSynced: () -> Unit = { zycleSynced = true; mirror?.setTrainerLinked(true) } + status = if (connected) getString(R.string.status_trainer_connected) else getString(R.string.status_searching_trainer); listener?.invoke() + } } + } + // Same treatment: a stale onSynced would put the mirror on the air with no trainer behind it, and + // setTrainerLinked is edge-triggered, so it would STAY there. + val onSynced: () -> Unit = { handler.post { if (gen == receiveGen) { zycleSynced = true; mirror?.setTrainerLinked(true) } } } val c: TrainerSource = if (config.simulate) SimSource(onProfile, onValue, onState, onSynced).also { simSource = it } else ZycleClient(this, config.pairedAddress, onProfile, onValue, onState, onSynced, - onAdv = { bp -> lastAdvBlueprint = bp; mirror?.setAdvBlueprint(bp) }, // clone the trainer's real advertising - onFound = { name, addr -> config.lastSeenName = name ?: ""; config.lastSeenAddress = addr }) + // Guarded too, or the replaced source's advertising blueprint and address get written over the + // live one's. Plain compare: neither latches anything, so the post is not worth the hop. + onAdv = { bp -> if (gen == receiveGen) { lastAdvBlueprint = bp; mirror?.setAdvBlueprint(bp) } }, + onFound = { name, addr -> if (gen == receiveGen) { config.lastSeenName = name ?: ""; config.lastSeenAddress = addr } }) currentSourceKey = sourceKey(config) c.start() client = c - // after start(): SimSource reports connected synchronously, so don't overwrite it with "searching" + // SimSource reports connected synchronously inside start(), but onState now hops to main (see the + // lambdas above), so zycleConnected is still false here and this reads "searching". The posted body + // runs right after and corrects it — a one-frame flash, not a wrong end state. status = getString(if (zycleConnected) R.string.status_trainer_connected else R.string.status_searching_trainer) updateNotification(); listener?.invoke() } @@ -295,10 +369,17 @@ class BridgeService : Service() { private fun stopReceive() { if (client == null && simSource == null) return FileLog.event("receive stop") - ErgBias.forget() + receiveGen++ // invalidate this source's callbacks BEFORE anything else reads or writes state + releaseWakeLock() // no source → nothing to stay awake for (the master switch keeps the FGS alive) mirror?.setTrainerLinked(false) // no source → nothing to advertise, whatever the call order Config(this).let { it.lastSeenAddress = ""; it.lastSeenName = "" } client?.stop(); client = null; simSource = null; lastProfile = null; lastAdvBlueprint = null; currentSourceKey = null + // Flush after client.stop() — but note stop() is NOT a callback barrier: a notification already past + // its `stopped` check can still publish a pending value after this runs, and that last whole-watt + // step is then lost. Immaterial (the EMA moves ~0.05 W a sample and is re-seeded next ride) and it is + // the SAFE direction: the dangerous half — a stale sample being persisted into the NEXT session — is + // closed by the receiveGen guard on onValue, which is what feeds learnErgBias. + persistErgBias(Config(this)); ErgBias.forget() lastValues.clear() CorrectedFeed.clear() speedUnit = 0.01; speedUnitLocked = false; prevDistM = 0; prevElapsedS = 0 @@ -408,7 +489,7 @@ class BridgeService : Service() { private fun cacheForUi(config: Config, uuid: java.util.UUID, value: ByteArray) { if (client == null && simSource == null) return // a late BLE callback after stopReceive() — no phantom if (uuid == com.enderthor.trainerbridgeble.ble.GattUuids.INDOOR_BIKE_DATA || - uuid == com.enderthor.trainerbridgeble.ble.GattUuids.CYCLING_POWER_MEASUREMENT) lastSampleMs = System.currentTimeMillis() + uuid == com.enderthor.trainerbridgeble.ble.GattUuids.CYCLING_POWER_MEASUREMENT) lastSampleMs = android.os.SystemClock.elapsedRealtime() when (uuid) { com.enderthor.trainerbridgeble.ble.GattUuids.INDOOR_BIKE_DATA -> { if (value.size < 2) return @@ -447,7 +528,7 @@ class BridgeService : Service() { // dropout would be transmitted as live. Speed/cadence still flow to the Karoo sensor. if (havePower) { sawIndoorBikeData = true // IBD really carries power — only now disable the CPM fallback - lastPowerMs = System.currentTimeMillis() // the clock powerFresh/freshPowerOrNull read + lastPowerMs = android.os.SystemClock.elapsedRealtime() // the clock powerFresh/freshPowerOrNull read } // ALWAYS report to ANT, with a null power once it has gone stale: gating the CALL froze // speed and cadence too and starved ANT on a trainer whose IBD carries no power field. @@ -457,16 +538,19 @@ class BridgeService : Service() { // A single truncated frame must not punch a hole in the recording, and a real dropout must // not be recorded as live watts: the grace window decides, not this one packet. CorrectedFeed.push(freshPowerOrNull(), lastSpeedKmh?.let { it / 3.6 }, lastCadence, android.os.SystemClock.elapsedRealtime()) - listener?.invoke() + // NOT listener?.invoke(): the Monitor already polls at 1 Hz, and driving it from here re-ran a + // full render (a fresh GradientDrawable + autosize on seven TextViews) at the trainer's ~4 Hz — + // on the same main looper the mirror's notify fan-out posts to. State CHANGES still notify + // immediately (connect/disconnect, control write, status, adv state); only the tiles wait. } com.enderthor.trainerbridgeble.ble.GattUuids.CYCLING_POWER_MEASUREMENT -> { if (!sawIndoorBikeData && value.size >= 4) { // fallback only if the trainer sends no IBD val raw = le16signed(value, 2); lastRawW = raw; lastCorrectedW = config.correction().correct(raw) learnErgBias(config, raw) - lastPowerMs = System.currentTimeMillis() + lastPowerMs = android.os.SystemClock.elapsedRealtime() antTx?.setLatest(PowerSample(freshPowerOrNull(), lastCadence, lastSpeedKmh?.let { it / 3.6 })) // ANT too, or FE-C stays blank CorrectedFeed.push(freshPowerOrNull(), lastSpeedKmh?.let { it / 3.6 }, lastCadence, android.os.SystemClock.elapsedRealtime()) - listener?.invoke() + // same as the IBD branch above: the 1 Hz poller owns the tiles } } } @@ -478,10 +562,27 @@ class BridgeService : Service() { // SimSource tracks the ERG target exactly, so it would teach a bias of ~0 and PERSIST it — running // the simulator for three minutes would quietly wipe the real trainer's calibration. if (config.simulate) return - ErgBias.onPower(raw, android.os.SystemClock.elapsedRealtime())?.let { - config.ergBiasW = it - FileLog.event("ERG bias learned: ${it}W (trainer settles above its command)") - } + val w = ErgBias.onPower(raw, android.os.SystemClock.elapsedRealtime()) ?: return + pendingErgBiasW = w + // Rate-limited: onPower is fed at 4 Hz, and a converged bias sitting near an integer boundary (the + // measured overshoot is ~8 W) flips across it over and over — each flip a full rewrite+fsync of the + // prefs XML, in flash. The stated goal ("start the next ride where this one finished") is met just as + // well at one-minute granularity, and stopReceive flushes whatever is still pending. + val now = android.os.SystemClock.elapsedRealtime() + // `!= 0L` matters: elapsedRealtime is measured from BOOT, so in the first minute of uptime — which on + // a Karoo that reboots daily and auto-starts this extension is a real moment — `now - 0` is under the + // interval and the FIRST learned value of the ride would be held back rather than written at once. + if (lastBiasPersistMs != 0L && now - lastBiasPersistMs < ERG_BIAS_PERSIST_MS) return + lastBiasPersistMs = now + persistErgBias(config) + } + + /** Write out the latest learned bias, if it moved since the last write. */ + private fun persistErgBias(config: Config) { + val w = pendingErgBiasW ?: return + pendingErgBiasW = null + config.ergBiasW = w + FileLog.event("ERG bias learned: ${w}W (trainer settles above its command)") } private fun le16(b: ByteArray, i: Int) = (b[i].toInt() and 0xFF) or ((b[i + 1].toInt() and 0xFF) shl 8) @@ -497,13 +598,46 @@ class BridgeService : Service() { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); stopSelf() } - private fun acquireWakeLock() { + // Synchronized since the trainer link drives these: onState fires from the GATT binder thread AND from + // the client's main-thread heartbeat, and two concurrent acquires would strand a lock nothing releases. + @Synchronized private fun acquireWakeLock() { + handler.removeCallbacks(releaseWakeLockLater) // a reconnect inside the linger window keeps the lock if (wakeLock?.isHeld == true) return wakeLock = (getSystemService(POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TrainerBridgeBLE:session").also { runCatching { it.acquire() } } + FileLog.event("wakelock ACQUIRED (trainer linked)") // the two lines that make E1 verifiable + } + + @Synchronized private fun releaseWakeLock() { + handler.removeCallbacks(releaseWakeLockLater) + val held = wakeLock?.isHeld == true + wakeLock?.let { if (it.isHeld) runCatching { it.release() } }; wakeLock = null + if (held) FileLog.event("wakelock RELEASED — the CPU may suspend from here") } - private fun releaseWakeLock() { wakeLock?.let { if (it.isHeld) runCatching { it.release() } }; wakeLock = null } + /** Deferred release after a trainer drop — see the onState callback in [startReceive]. Re-checks, so a + * reconnect inside the linger window keeps the lock. */ + private val releaseWakeLockLater = Runnable { if (!zycleConnected) releaseWakeLock() } + + /** + * One compact state line a minute. Without it a quiet stretch of log is ambiguous — nothing happened, or + * the bridge stalled? — and every "permanent death" bug in this project's history looked exactly like + * silence. It also carries the values you would otherwise have to reconstruct: whether ERG is live, what + * the learner has settled on, and how far the level we SHOW has drifted from the machine's. + */ + private val snapshot = object : Runnable { + override fun run() { + if (!foreground) return // master off / service dying: stop the loop, don't outlive it + if (FileLog.enabled) FileLog.event( + "state master=${Config(this@BridgeService).masterEnabled} recv=$receiving emit=$emitting " + + "trainer=${if (zycleSynced) "synced" else if (zycleConnected) "connected" else "-"} " + + "adv=$bleAdvOk apps=${mirror?.clientCount ?: 0} " + + "powerFresh=$powerFresh raw=$lastRawW corr=$lastCorrectedW cad=$lastCadence res=$resistance " + + "erg=${ErgBias.commanded} bias=${ErgBias.watts}W " + + "level=${mirror?.levelDebug ?: "-"} ant=${if (antEnabled) antOk else null} stale=${staleCallbacks.get()}") + handler.postDelayed(this, SNAPSHOT_MS) + } + } private fun createChannel() { val mgr = getSystemService(NotificationManager::class.java) @@ -533,6 +667,9 @@ class BridgeService : Service() { private const val BT_RESTART_SETTLE_MS = 2000L // let the BT stack settle before reopening the server private const val POWER_STALE_MS = 2000L // ~8 missed frames at 4 Hz: covers a hiccup, not a dropout private const val ANT_RESTART_DELAY_MS = 1500L // let the ANT service release the channel first + private const val ERG_BIAS_PERSIST_MS = 60_000L // at most one prefs write a minute; stopReceive flushes + private const val WAKELOCK_LINGER_MS = 6000L // hold past the reconnect delay, until a scan is up + private const val SNAPSHOT_MS = 60_000L // one state line a minute while the service is up const val ACTION_MASTER_ON = "com.enderthor.trainerbridgeble.MASTER_ON" const val ACTION_MASTER_OFF = "com.enderthor.trainerbridgeble.MASTER_OFF" const val ACTION_EMIT_START = "com.enderthor.trainerbridgeble.EMIT_START" diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt b/app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt index b0735d6..4c57fcd 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt @@ -40,7 +40,10 @@ object FileLog { } } - private const val MAX_BYTES = 16L * 1024 * 1024 // two files kept, so 32 MB total + // Every notification is logged unthrottled (~2 KB/s), so 16 MB filled in ~2 h and rotation threw away + // the START of the ride — which is exactly where the connect / sync / first-advertise sequence lives, + // the part you turned logging on to see. 48 MB holds a ~6 h ride in one file, 12 h across the two. + private const val MAX_BYTES = 48L * 1024 * 1024 // two files kept, so 96 MB worst case fun clear() { file?.let { f -> io.execute { runCatching { f.writeText("") } } } } diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/MonitorActivity.kt b/app/src/main/java/com/enderthor/trainerbridgeble/MonitorActivity.kt index 0e4a2b9..b1a610c 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/MonitorActivity.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/MonitorActivity.kt @@ -143,7 +143,7 @@ class MonitorActivity : Activity() { startBtn.isEnabled = canEmit; startBtn.alpha = if (canEmit) 1f else 0.4f // Fresh = a sample arrived within the last 3s. A brief (<3s) blip keeps showing the last value // (the mirror is re-emitting it too); a longer gap blanks the tiles to "—". - val fresh = s != null && s.lastSampleMs != 0L && System.currentTimeMillis() - s.lastSampleMs <= STALE_MS + val fresh = s != null && s.lastSampleMs != 0L && android.os.SystemClock.elapsedRealtime() - s.lastSampleMs <= STALE_MS // Banner shows the TRAINER (receive) link when master is on; "Off" when master is off. val (bText, bColor) = when { !master -> getString(R.string.monitor_off) to Palette.MUTED diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ant/RawAntLink.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ant/RawAntLink.kt index eecd8e0..399b3cd 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ant/RawAntLink.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ant/RawAntLink.kt @@ -84,7 +84,8 @@ class RawAntLink( * bidirectional slave to send acknowledged control data from its own thread; tolerates null. */ val currentChannel: AntChannel? get() = channel - /** Wall-clock of the last message from the current channel; 0 when none since (re)open. Drives the + /** elapsedRealtime (NOT wall-clock — a clock re-sync must not move a timeout) of the last message from + * the current channel; 0 when none since (re)open. Drives the * heartbeat watchdog: a SLAVE that acquires a master then goes silent while still "tracking" emits * neither RX_SEARCH_TIMEOUT nor onChannelDeath, so nothing else would recover it. Masters get a TX * event every period, so their heartbeat never expires. */ @@ -130,9 +131,9 @@ class RawAntLink( delay(HEARTBEAT_CHECK_MS) val last = lastMessageMs if (!stopped && channel != null && !opening.get() && last != 0L && - System.currentTimeMillis() - last > HEARTBEAT_TIMEOUT_MS + android.os.SystemClock.elapsedRealtime() - last > HEARTBEAT_TIMEOUT_MS ) { - Log.i(tag, "silent ${System.currentTimeMillis() - last}ms — recycling") + Log.i(tag, "silent ${android.os.SystemClock.elapsedRealtime() - last}ms — recycling") lastMessageMs = 0L val dead = channel; channel = null; releaseChannel(dead) scheduleReopen("heartbeat") @@ -161,7 +162,7 @@ class RawAntLink( ch.setChannelEventHandler(object : IAntChannelEventHandler { override fun onReceiveMessage(type: MessageFromAntType?, msg: AntMessageParcel?) { if (msg == null || type == null || ch !== channel) return - lastMessageMs = System.currentTimeMillis() + lastMessageMs = android.os.SystemClock.elapsedRealtime() // The slave's search window expiring closes the channel as a CHANNEL_EVENT // (never onChannelDeath). Consume it here and reopen, else the channel is dead // for the rest of the session. Any other CHANNEL_EVENT (e.g. master TX) and all diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt index 4c9aadd..728b205 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt @@ -64,10 +64,15 @@ class MirrorServer( private val ADV_RESTART_MS = 250L private val ADV_RETRY_MS = 1000L - private val ADV_MAX_RETRIES = 5 + private val ADV_RETRY_MAX_MS = 30_000L // backoff ceiling; there is no attempt cap (see scheduleAdvRetry) private val SERVICE_RETRY_MS = 300L private val SERVICE_MAX_RETRIES = 5 - private val ADV_START_TIMEOUT_MS = 3000L + // Generous on purpose: this must only ever fire for a callback that is genuinely LOST (adapter off, BT + // process died). Firing it for one that is merely slow starts a second attempt against the same shared + // callback object — see scheduleAdvRetry's note. + private val ADV_START_TIMEOUT_MS = 8000L + private val SERVER_RETRY_MS = 2000L + private val SERVER_RETRY_MAX_MS = 30_000L // Bluetooth off is a whole-ride failure, not a hiccup private val ADV_LATE_STOP_MS = 1500L /** How long after a control write the trainer's level is still settling on it. Observed on the 28-jul * ride: every servo-driven level step landed 0.19-2.6 s after the write that caused it. */ @@ -85,7 +90,8 @@ class MirrorServer( @Volatile private var advBlueprint: AdvBlueprint? = null // the trainer's real advertising, to clone @Volatile private var trainerLinked = false // advertise only while a trainer is actually feeding us @Volatile private var servicesReady = false // every mirrored service has been ADDED (built != added) - @Volatile private var advRetries = 0 + @Volatile private var advRetries = 0 // diagnostic count only; the retry policy is advRetryMs + @Volatile private var advRetryMs = ADV_RETRY_MS @Volatile private var dropNameFromAdv = false // set after DATA_TOO_LARGE: the packet won't fit the name @Volatile private var dropMfrFromAdv = false // shed the cloned manufacturer data first @Volatile private var dropBlueprintFromAdv = false // then the cloned UUIDs, falling back to the standard pair @@ -96,6 +102,14 @@ class MirrorServer( fun setTrainerLinked(linked: Boolean) { if (trainerLinked == linked) return trainerLinked = linked + // Losing the trainer invalidates the level anchor (see [reanchorLevel]) AND any servo step we were + // still owed: the write that bought it may never have reached the trainer, and if it did, the step it + // caused is on the far side of the outage where the re-anchor absorbs it anyway. Leaving it armed + // means the rider's first press after a fast reconnect is eaten instead — and this codebase's settled + // bias is that eating a real press is the worse failure (pinning the byte killed the buttons). + // Getting the link back also deserves a fresh advertise backoff. + if (!linked) { reanchorLevel = true; servoStepOwed = false; lastControlWriteMs = 0L } + else { advRetries = 0; advRetryMs = ADV_RETRY_MS } FileLog.event("mirror trainer link=$linked -> ${if (linked) "advertise" else "stop advertising"}") handler.post { if (linked) startAdvertising() else stopAdvertising() } } @@ -104,24 +118,59 @@ class MirrorServer( * advertise an identical packet. If we're already advertising, restart to apply it. */ fun setAdvBlueprint(bp: AdvBlueprint) { advBlueprint = bp - advRetries = 0 // a new blueprint deserves a fresh retry budget — but keep what we learned about size + // A new blueprint deserves a fresh retry budget — and that means the BACKOFF, not just the diagnostic + // count: at the 30 s ceiling, resetting only the counter changed nothing. Keep the size shedding. + advRetries = 0; advRetryMs = ADV_RETRY_MS FileLog.event("mirror adv blueprint: ${bp.serviceUuids.size} uuids, ${bp.manufacturerData.size} mfr") handler.post { if (advertising) restartAdvertising() } // serialise onto the advertising thread } fun start() { + stopped = false // Kill any advertising set left running by a PREVIOUS instance: its stop may have been dropped // because a start was still in flight, and its callback died with the object. lastAdvCallback?.takeIf { it !== advCallback }?.let { orphan -> FileLog.event("stopping an advertising set left by a previous mirror") runCatching { adapter.bluetoothLeAdvertiser?.stopAdvertising(orphan) } } + openServer() + } + + /** Open the GATT server, retrying while emit is on. A null here is usually the stack restarting, and + * returning on it left the whole emit half dead for the session with no path back — [build] had already + * been called by then, so even a later recovery would have had nothing to serve. Hence [pendingProfile]. */ + private fun openServer() { + if (stopped || server != null) return val srv = runCatching { mgr.openGattServer(context, serverCallback) }.getOrNull() - if (srv == null) { onStatus(context.getString(R.string.status_ble_server_failed)); onAdvState(false); return } - server = srv + if (srv == null) { + // Backoff, not a flat retry: the common cause (Bluetooth off, stack not coming back) lasts the + // whole ride, and at a fixed 2 s that is ~1800 attempts an hour, each one a log line — the same + // mistake RawAntLink.scheduleReopen already documents having made. + val wait = serverRetryMs + serverRetryMs = (wait * 2).coerceAtMost(SERVER_RETRY_MAX_MS) + FileLog.event("openGattServer returned null — retrying in ${wait}ms") + onStatus(context.getString(R.string.status_ble_server_failed)); onAdvState(false) + handler.removeCallbacks(serverRetryRunnable); handler.postDelayed(serverRetryRunnable, wait) + return + } + serverRetryMs = SERVER_RETRY_MS // open: a later failure starts its backoff from scratch + // Publishing the server and claiming the held profile must be ONE step, under the same lock build() + // uses. As two independent volatiles they interleave: build() reads server==null on a binder thread, + // we publish and find pendingProfile still null, then build() stores it — and nobody ever replays it, + // leaving an open GATT server with no services that can never advertise. + val replay = synchronized(serverLock) { server = srv; pendingProfile.also { pendingProfile = null } } renameAdapter() + replay?.let { FileLog.event("mirror server opened — building the profile we held"); build(it) } } + private val serverRetryRunnable = Runnable { openServer() } + /** Guards the server/pendingProfile handover only — never held across a GATT call. */ + private val serverLock = Any() + @Volatile private var serverRetryMs = SERVER_RETRY_MS + @Volatile private var stopped = false + /** A profile handed to [build] before the server existed, replayed once it does. */ + @Volatile private var pendingProfile: GattProfile? = null + /** Rename the adapter to our advertised name, persisting the ORIGINAL to prefs so a process kill (which * skips stop()) doesn't lose the user's real Bluetooth name — and so we never capture our own rename. */ private fun renameAdapter() { @@ -174,7 +223,10 @@ class MirrorServer( * services + characteristics (re-adding them would strand apps still subscribed to the old instances * and there is no clean live rebuild). */ fun build(profile: GattProfile) { - val srv = server ?: return + // No server yet (it is being retried): hold the profile rather than drop it, or a server that opens + // on the second attempt would have no services and would therefore never advertise. Under the same + // lock openServer() claims it with, so the read and the store cannot straddle the handover. + val srv = synchronized(serverLock) { server ?: run { pendingProfile = profile; null } } ?: return // Atomic gate: build() is called from both the GATT binder thread (onServicesDiscovered) and the main // thread (Start) — a plain check-then-set could let both through and double-add the services. if (!built.compareAndSet(false, true)) return @@ -247,6 +299,7 @@ class MirrorServer( fun stop() { // Stop the advertiser UNCONDITIONALLY: the `advertising` flag is transiently false mid-restart, so // trusting it here can leave the phone broadcasting with a closed GATT server. + stopped = true; pendingProfile = null advertising = false; servicesReady = false handler.removeCallbacksAndMessages(null) // pending adv starts / service retries must not outlive us runCatching { adapter.bluetoothLeAdvertiser?.stopAdvertising(advCallback) } @@ -262,7 +315,7 @@ class MirrorServer( restoreName() built.set(false); advBlueprint = null chars.clear(); cache.clear(); subscribers.clear(); clients.clear(); pendingServices.clear() - shownZycleLevel = null; lastRawZycleLevel = null; lastControlWriteMs = 0L; servoStepOwed = false + shownZycleLevel = null; lastRawZycleLevel = null; lastControlWriteMs = 0L; servoStepOwed = false; reanchorLevel = false } /** @@ -283,6 +336,16 @@ class MirrorServer( @Volatile private var lastRawZycleLevel: Int? = null // what the trainer last reported, to difference against @Volatile private var lastControlWriteMs = 0L // elapsedRealtime of the last control write we relayed @Volatile private var servoStepOwed = false // that write has a level step coming; it is not the rider's + /** The trainer link dropped, so the next level we see must be RE-ANCHORED rather than differenced. + * A drop does not stop the mirror (the GATT and the connected apps are deliberately kept), so without + * this the pair above straddles the outage and the first frame back is differenced against a level from + * before it — delivering the whole gap to the app in one step, as if the rider had made it. */ + @Volatile private var reanchorLevel = false + + /** For the service's periodic snapshot: how many apps are attached, and how far the level we report has + * drifted from the machine's (shown/raw — they diverge by every servo step we absorbed, by design). */ + val clientCount: Int get() = clients.size + val levelDebug: String get() = "${shownZycleLevel ?: "-"}/${lastRawZycleLevel ?: "-"}" /** A value arrived from the trainer: correct power, cache, and notify every subscribed client. */ fun onZycleValue(charUuid: UUID, value: ByteArray) { @@ -296,11 +359,31 @@ class MirrorServer( // one bridge must never hand one app two different numbers for the same instant. charUuid == GattUuids.ZYCLE_TELEMETRY -> { PowerRewrite.zycleLevel(value)?.let { raw -> + // First frame after a dropout: re-anchor onto whatever the trainer says now, so the gap it + // moved through while we were blind is NOT differenced into the app's view. Deliberately + // NOT by nulling shownZycleLevel — levelToShow's first-frame rule adopts the raw level, + // which is the same jump by another route. A genuine press made during the outage is lost; + // we could not have seen it. + // ponytail: the flag has no expiry, so a press landing between the link coming back and + // the first telemetry frame is absorbed into the anchor too. That window is one frame + // (~250 ms at 4 Hz); bounding it with a timer would cost state and re-open the far worse + // jump this exists to stop. Revisit only if a trainer is seen going quiet after reconnect. + val reanchored = reanchorLevel + if (reanchorLevel) { lastRawZycleLevel = raw; reanchorLevel = false } + val prevRaw = lastRawZycleLevel; val prevShown = shownZycleLevel // The budget expires: 57 of 169 writes moved no level at all, and an armed one left // lying around would eat the rider's next press minutes later. val owed = servoStepOwed && SystemClock.elapsedRealtime() - lastControlWriteMs < LEVEL_SETTLE_MS val next = PowerRewrite.levelToShow(shownZycleLevel, lastRawZycleLevel, raw, owed) shownZycleLevel = next.level; servoStepOwed = next.servoStepOwed; lastRawZycleLevel = raw + // THE line that makes the servo-vs-rider rule auditable. Every claim in this file's + // comments ("118 of 125", "111 of 169 writes → 1 step") came from reconstructing this by + // hand out of hex dumps; logged directly, a ride answers it by counting lines. Only when + // the level actually moved or we re-anchored — a few hundred lines a ride, not 4 Hz. + if (FileLog.enabled && (reanchored || raw != prevRaw)) + FileLog.event("level raw=$prevRaw->$raw shown=$prevShown->${next.level} " + + (if (reanchored) "REANCHOR" else if (owed) "SERVO(spent)" else "RIDER") + + " owedAfter=${next.servoStepOwed}") } PowerRewrite.correctZycleTelemetry(value, correction(), shownZycleLevel) } @@ -339,6 +422,9 @@ class MirrorServer( private val serverCallback = object : BluetoothGattServerCallback() { override fun onServiceAdded(status: Int, service: BluetoothGattService?) { + // A callback still in flight when stop() ran would otherwise find pendingServices empty, set + // servicesReady and post a start — putting us back on the air with a closed GATT server. + if (stopped || server == null) return if (status != BluetoothGatt.GATT_SUCCESS) { // don't poll it: retry the head rather than advertise a mirror missing a service FileLog.event("mirror addService FAILED status=$status for ${shortUuid(service?.uuid)} — retrying head") @@ -404,9 +490,10 @@ class MirrorServer( (if (preparedWrite) " PREPARED off=$offset" else "") + (if (!responseNeeded) " noResp" else "") if (uuid != null && value != null) { val out = if (GattUuids.carriesControl(uuid)) PowerRewrite.inverseTargetPower(value, correction()) else value - // Any control op can make the servo move the level; from here on that move is ours, not the - // rider's. Stamped before the relay, so the window covers the trip to the trainer too. - if (GattUuids.carriesControl(uuid)) { lastControlWriteMs = SystemClock.elapsedRealtime(); servoStepOwed = true } + // Any control op can make the servo move the level; from there on that move is ours, not the + // rider's. Read the clock HERE, before the relay, so the window still covers the trip to the + // trainer — but only commit it once we know the write was dispatched (below). + val stampAt = SystemClock.elapsedRealtime() // what the client asked for, unless the trainer's characteristic can't take a Write Command val withResponse = responseNeeded || (ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE == 0) @@ -415,6 +502,12 @@ class MirrorServer( // until onExecuteWrite. No FTMS/CPS characteristic exceeds one ATT payload, so this only // matters if some app starts using long writes — the log line above says when it happens. relayed = toZycle(uuid, out, withResponse) // relay to the trainer + // Only a write that was actually dispatched buys the servo a step. A dropped one (no link, + // characteristic not found — the window right after a reconnect, before discovery repopulates + // g.services) moves no level, and an armed budget would silently eat the rider's next real + // button press within LEVEL_SETTLE_MS. `relayed` still only means QUEUED, so a write that + // fails later on the wire arms it anyway — no worse than before, and one less lost press. + if (relayed && GattUuids.carriesControl(uuid)) { lastControlWriteMs = stampAt; servoStepOwed = true } if (!relayed) FileLog.event("app write $tag NOT RELAYED — answering failure") } else FileLog.event("app write $tag = ") // ATT response = "received", always. FTMS puts the OUTCOME in the control point indication. @@ -482,31 +575,32 @@ class MirrorServer( // ── advertising ────────────────────────────────────────────────────────────────────────────────── private val advCallback = object : android.bluetooth.le.AdvertiseCallback() { - override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { handler.removeCallbacks(advStartWatchdog); advStarting = false; advertising = true; advRetries = 0; onAdvState(true); onStatus(context.getString(R.string.status_advertising, advertisedName)); FileLog.event("advertising as $advertisedName") + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { handler.removeCallbacks(advStartWatchdog); advStarting = false; advertising = true; advRetries = 0; advRetryMs = ADV_RETRY_MS; onAdvState(true); onStatus(context.getString(R.string.status_advertising, advertisedName)); FileLog.event("advertising as $advertisedName") // a stop issued while this start was in flight is dropped by the stack — reconcile now if (!trainerLinked || server == null) { FileLog.event("advertising with no trainer — stopping"); stopAdvertising() } } override fun onStartFailure(errorCode: Int) { handler.removeCallbacks(advStartWatchdog); advStarting = false; advertising = false; onAdvState(false) onStatus(context.getString(R.string.status_advertise_failed, errorCode)) - FileLog.event("advertise failed $errorCode (retry ${advRetries + 1}/$ADV_MAX_RETRIES, name=${!dropNameFromAdv})") + FileLog.event("advertise failed $errorCode (attempt ${++advRetries}, name=${!dropNameFromAdv})") // 31-byte PDU: shed the cloned manufacturer data first (usually the culprit), the name only if - // that still isn't enough — apps find us BY the name, so it is the last thing to go. + // that still isn't enough — apps find us BY the name, so it is the last thing to go. Driven by + // the error code, not by the attempt counter, so it still walks its three steps in order. if (errorCode == ADVERTISE_FAILED_DATA_TOO_LARGE) when { !dropMfrFromAdv -> dropMfrFromAdv = true // the cloned manufacturer data usually is it !dropBlueprintFromAdv -> dropBlueprintFromAdv = true // then the cloned UUIDs (128-bit won't fit) else -> dropNameFromAdv = true // last resort: apps find us BY the name } - if (advRetries++ < ADV_MAX_RETRIES) { - handler.removeCallbacks(startAdvRunnable); handler.postDelayed(startAdvRunnable, ADV_RETRY_MS) - } + scheduleAdvRetry("failure $errorCode") } } private fun startAdvertising() { // no trainer → stay off the air (see setTrainerLinked); not built → we'd advertise an empty GATT and // an app that connects in that window caches it. onServiceAdded calls back here once services land. - if (advertising || advStarting || !trainerLinked || !servicesReady) return + // `stopped`/`server` too: a callback still in flight when stop() ran must not put us back on the air + // with a closed GATT server behind the advert. + if (stopped || server == null || advertising || advStarting || !trainerLinked || !servicesReady) return val advertiser = adapter.bluetoothLeAdvertiser ?: run { onStatus(context.getString(R.string.status_ble_adv_unsupported)); onAdvState(false); return } val settings = AdvertiseSettings.Builder() .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) @@ -526,8 +620,12 @@ class MirrorServer( } advStarting = true lastAdvCallback = advCallback // process-wide, so a later instance can still stop this set - if (runCatching { advertiser.startAdvertising(settings, builder.build(), advCallback) }.isFailure) advStarting = false - else { + if (runCatching { advertiser.startAdvertising(settings, builder.build(), advCallback) }.isFailure) { + // A synchronous throw answers with no callback at all, so this is the only place that can report + // it. Clearing the flag alone left health green and nothing scheduled. + advStarting = false; onAdvState(false) + scheduleAdvRetry("start threw") + } else { // An accepted start normally answers with exactly one callback — except when the adapter is // turned off or the BT process dies under it, which drops the callback silently. Without this // the flag latches and we never advertise again. @@ -548,11 +646,44 @@ class MirrorServer( /** Force a fresh advertise (Android silently stopped it when a central connected, but our flag didn't * know). Needed so more than one app can find us. */ private val startAdvRunnable = Runnable { startAdvertising() } + /** A start that is accepted and then never answers. Clearing the latch is not enough: nothing else + * retries, and `bleAdvOk` would stay at its optimistic true — a mirror off the air behind a green UI, + * which is strictly worse to diagnose than a visible failure. Do what onStartFailure does, minus the + * size-shedding (there is no error code to shed for). */ private val advStartWatchdog = Runnable { - if (advStarting) { FileLog.event("advertise start never answered — clearing in-flight flag"); advStarting = false } + if (advStarting) { + FileLog.event("advertise start never answered — clearing in-flight flag and retrying") + advStarting = false; advertising = false; onAdvState(false) + scheduleAdvRetry("start never answered") + } + } + + /** + * The one place that re-arms advertising. A capped ATTEMPT COUNT was the bug this replaces: five + * transient failures took the mirror off the air for the whole ride. A flat 1 s retry is the opposite + * mistake — a revoked BLUETOOTH_ADVERTISE or a stack that throws every time would retry ~3600 times an + * hour, each one a log line, across a 4-12 h ride. So: backoff, no cap. The budget resets on a real + * success and when the trainer link comes back. + * + * ponytail: every attempt shares ONE AdvertiseCallback, because that object is also the handle + * stopAdvertising needs. So if a start's callback arrives after [ADV_START_TIMEOUT_MS] the watchdog can + * have already started a second attempt, and the two callbacks are indistinguishable — a late success + * for A cancels B's watchdog, a late failure for B clears `advertising` while A is really on air. The + * timeout is set well past any plausible stack latency so this needs a genuinely lost callback AND a + * slow one; closing it properly means a fresh callback object per attempt plus tracking which one owns + * the live set. Backoff bounds the damage to a self-healing flap. + */ + private fun scheduleAdvRetry(why: String) { + if (stopped) return + val wait = advRetryMs + advRetryMs = (wait * 2).coerceAtMost(ADV_RETRY_MAX_MS) + FileLog.event("advertise retry in ${wait}ms: $why") + handler.removeCallbacks(startAdvRunnable); handler.postDelayed(startAdvRunnable, wait) } private fun restartAdvertising() { + // an app connecting/disconnecting is a fresh chance, not a continuation of old failures + advRetries = 0; advRetryMs = ADV_RETRY_MS stopAdvertising() // The stop is async and only frees this callback when it completes; starting in the same turn // answers ADVERTISE_FAILED_ALREADY_STARTED. Give it a turn — and collapse duplicate restarts diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt index 6653478..3e2d71c 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt @@ -51,9 +51,14 @@ class ZycleClient( private val connecting = AtomicBoolean(false) // CAS: scan results arrive on a binder thread pool @Volatile private var stopped = false @Volatile private var scanning = false - @Volatile private var lastMessageMs = 0L // wall-clock of the last notification, for the silent-link watchdog + // elapsedRealtime, not wall-clock: a mid-ride clock re-sync would otherwise either trip the watchdog on + // a healthy link or delay it past a real one, by the size of the correction. + @Volatile private var lastMessageMs = 0L // last notification, for the silent-link watchdog private val reconnectPending = AtomicBoolean(false) @Volatile private var retryMs = SCAN_RETRY_MS // scan backoff, reset on a good connection + // Have we ever had this trainer on the line in THIS session? Splits the two scans that look alike: + // a cold search (trainer not powered on — may run for hours) from a mid-ride reacquisition. + @Volatile private var everConnected = false private val opQueue = ConcurrentLinkedQueue<() -> Unit>() private val opBusy = AtomicBoolean(false) @@ -102,8 +107,8 @@ class ZycleClient( override fun run() { if (stopped) return val g = gatt - if (g != null && lastMessageMs != 0L && System.currentTimeMillis() - lastMessageMs > HEARTBEAT_TIMEOUT_MS) { - FileLog.event("Zycle watchdog: silent ${System.currentTimeMillis() - lastMessageMs}ms -> reconnect") + if (g != null && lastMessageMs != 0L && android.os.SystemClock.elapsedRealtime() - lastMessageMs > HEARTBEAT_TIMEOUT_MS) { + FileLog.event("Zycle watchdog: silent ${android.os.SystemClock.elapsedRealtime() - lastMessageMs}ms -> reconnect") gatt = null; lastMessageMs = 0L onState(false) opQueue.clear(); opBusy.set(false); syncOwed.set(false); burstEnqueued = false @@ -196,12 +201,21 @@ class ZycleClient( val filters = if (pairedAddress.isNotEmpty()) listOf(android.bluetooth.le.ScanFilter.Builder().setDeviceAddress(pairedAddress).build()) else GattUuids.scanFilters(0x1826) - // BALANCED, not LOW_POWER: this scan runs while we have no trainer, and with the screen off - // LOW_POWER's duty cycle can take minutes to reacquire mid-ride. + // BALANCED, not LOW_POWER, for a REACQUISITION: with the screen off LOW_POWER's duty cycle can take + // minutes to find the trainer again mid-ride. That argument is about a scan following a connection we + // already had. A cold search is the other case and is not the same: the trainer simply isn't powered + // on, nothing stops a successful scan, and BALANCED's 25% radio duty then runs for hours. Cost of + // telling them apart: the first acquisition of the day takes a few seconds longer. val settings = android.bluetooth.le.ScanSettings.Builder() - .setScanMode(android.bluetooth.le.ScanSettings.SCAN_MODE_BALANCED).build() + .setScanMode(if (everConnected) android.bluetooth.le.ScanSettings.SCAN_MODE_BALANCED + else android.bluetooth.le.ScanSettings.SCAN_MODE_LOW_POWER).build() // A revoked BLUETOOTH_SCAN throws here; swallowing it while `scanning` was already true left the // client permanently dead and silent. Set the flag ONLY once the scan really started. + // Log the START, not just failures: without it a log cannot tell "scanning and the trainer is off" + // from "never started scanning", and it is the only way to time how long a cold acquisition takes + // (the cost E2's LOW_POWER duty cycle trades against) or to prove a reacquisition ran at BALANCED. + FileLog.event("Zycle scan start mode=${if (everConnected) "BALANCED" else "LOW_POWER"} " + + "filter=${if (pairedAddress.isNotEmpty()) "addr" else "FTMS"}") val started = runCatching { scanner.startScan(filters, settings, scanCallback) } if (started.isFailure) { FileLog.event("Zycle scan start threw: ${started.exceptionOrNull()}"); retryScanLater(); return @@ -262,7 +276,8 @@ class ZycleClient( FileLog.event("Zycle connected status=$status") connecting.set(false) retryMs = SCAN_RETRY_MS // a good connection resets the backoff - lastMessageMs = System.currentTimeMillis() // start the silent-link window at connect + everConnected = true // ...and promotes every later scan to the reacquisition duty cycle + lastMessageMs = android.os.SystemClock.elapsedRealtime() // start the silent-link window at connect syncOwed.set(true); burstEnqueued = false onState(true) handler.post { runCatching { g.discoverServices() } } @@ -289,7 +304,7 @@ class ZycleClient( if (status != BluetoothGatt.GATT_SUCCESS) { Log.w(tag, "discover failed $status"); FileLog.event("Zycle discover FAILED status=$status"); return } - lastMessageMs = System.currentTimeMillis() // discovery counts as life, or the watchdog recycles us mid-subscribe + lastMessageMs = android.os.SystemClock.elapsedRealtime() // discovery counts as life, or the watchdog recycles us mid-subscribe val profile = buildProfile(g) FileLog.event("Zycle profile: " + profile.services.joinToString("; ") { s -> "${s.uuid}[" + s.chars.joinToString(",") { "${shortUuid(it.uuid)}(p=${it.properties})" } + "]" @@ -301,13 +316,29 @@ class ZycleClient( // burst can drain right here — and a flag set afterwards would arm an already-empty queue, // leaving the mirror permanently off the air. burstEnqueued = true - // READS FIRST: an app connecting to the mirror reads FTMS Feature almost immediately, and a cold - // cache there reads to it as "this machine has no capabilities" for the whole session. - for (svc in svcs) for (ch in svc.characteristics) - if (ch.properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) enqueueRead(g, ch) - for (svc in svcs) for (ch in svc.characteristics) - if (ch.properties and (BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) - enqueueSubscribe(g, ch) + // try/finally is load-bearing, not defensive habit: anything escaping between these two + // assignments would leave the queue held shut for the rest of the session — a silent link with + // no watchdog able to reopen it, which is the worst failure this file has. + burstBuilding = true // hold the queue until every op below is in it (see burstBuilding) + try { + // THE DATA STREAMS FIRST — ahead of the reads. Nothing notifies until the queue reaches the + // subscribes, and the read burst below is 20-40 ATT round trips (0.5-2 s over the air), so every + // reconnect punched that long a hole in what the apps, ANT and the Karoo recording received. + // This does NOT weaken the cold-cache invariant documented below: what gates the mirror going on + // the air is the WHOLE queue draining (onSynced), not the first subscribe. The two are re-issued + // by the general loop further down; a repeated CCCD write is idempotent and costs one op each. + for (svc in svcs) for (ch in svc.characteristics) + if ((ch.uuid == GattUuids.INDOOR_BIKE_DATA || ch.uuid == GattUuids.CYCLING_POWER_MEASUREMENT) && + ch.properties and (BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) + enqueueSubscribe(g, ch) + // READS NEXT: an app connecting to the mirror reads FTMS Feature almost immediately, and a cold + // cache there reads to it as "this machine has no capabilities" for the whole session. + for (svc in svcs) for (ch in svc.characteristics) + if (ch.properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) enqueueRead(g, ch) + for (svc in svcs) for (ch in svc.characteristics) + if (ch.properties and (BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) + enqueueSubscribe(g, ch) + } finally { burstBuilding = false } pump() // a profile with nothing readable/notifiable enqueues nothing: drain now, don't hang } @@ -316,7 +347,7 @@ class ZycleClient( // a failed CCCD write means that characteristic silently never notifies — never let it pass quietly if (status != BluetoothGatt.GATT_SUCCESS) FileLog.event("Zycle subscribe ${shortUuid(u)} FAILED status=$status") - lastMessageMs = System.currentTimeMillis() + lastMessageMs = android.os.SystemClock.elapsedRealtime() opDone() } @@ -331,12 +362,12 @@ class ZycleClient( FileLog.event("Zycle read ${shortUuid(ch.uuid)} = ${FileLog.hex(v)}") // identity/feature/ranges values onValue(ch.uuid, v) } else FileLog.event("Zycle read ${shortUuid(ch.uuid)} failed status=$status") - lastMessageMs = System.currentTimeMillis() + lastMessageMs = android.os.SystemClock.elapsedRealtime() opDone() } override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) { - lastMessageMs = System.currentTimeMillis() + lastMessageMs = android.os.SystemClock.elapsedRealtime() // only the write this callback is FOR: a late status used to be attributed to whatever write // happened to be in the slot, re-sending someone else's ERG target val w = inFlightWrite?.takeIf { it.uuid == ch.uuid } @@ -355,7 +386,7 @@ class ZycleClient( override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) { if (stopped) return // in-flight notification after stop() — not our data any more val value = @Suppress("DEPRECATION") (ch.value?.copyOf() ?: ByteArray(0)) - lastMessageMs = System.currentTimeMillis() // feed the silent-link watchdog + lastMessageMs = android.os.SystemClock.elapsedRealtime() // feed the silent-link watchdog logNotif(ch.uuid, value) // every notification, unthrottled onValue(ch.uuid, value) } @@ -408,8 +439,14 @@ class ZycleClient( // ── GATT op serialisation ──────────────────────────────────────────────────────────────────────── private fun enqueue(op: () -> Unit) { opQueue.add(op); pump() } + /** Set while the opening burst is still being ENQUEUED. `enqueue` pumps on every add, so without this the + * first op runs immediately — and an op the stack refuses completes synchronously, draining a queue whose + * remaining ops have not been added yet. `pump()` then sees it empty and fires onSynced, putting the + * mirror on the air with a cold read cache: the very thing `burstEnqueued` exists to prevent. */ + @Volatile private var burstBuilding = false @Volatile private var opWatchdog: Runnable? = null private fun pump() { + if (burstBuilding) return // nothing may run until the whole burst is queued — see burstBuilding if (opBusy.compareAndSet(false, true)) { val op = opQueue.poll() if (op == null) { diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/correction/ErgBias.kt b/app/src/main/java/com/enderthor/trainerbridgeble/correction/ErgBias.kt index 9a4b9f5..f554313 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/correction/ErgBias.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/correction/ErgBias.kt @@ -45,6 +45,10 @@ object ErgBias { /** The learned bias, in raw watts, to subtract from the ERG command. */ val watts: Int get() = bias.roundToInt() + /** The raw ERG target currently being measured against, or null if none is active. Diagnostics only — + * it is how a log answers "did the resistance button actually retire the command?". */ + val commanded: Int? get() = commandedRaw + /** Restore what a previous session learned, so a ride starts calibrated instead of re-converging. Also * retires any active command: this is a session boundary, and a command left over from the last one * would read as long settled and be measured against power from a different ride. */ From 5755543a19a94026e2d7edaa8a3555eca8e38127 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:56:19 +0200 Subject: [PATCH 03/17] Ownership tokens for every BLE handle, and the rounds that found what they broke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the volatile/generation guards with identity tokens, so validating a callback and mutating on its behalf can no longer be split by a source, session or attempt switch landing in between. ZycleClient: a timed-out GATT op retires the handle rather than advancing the queue behind an operation Android still holds. Unsticking in place cannot cancel that op, so its late callback completes the wrong one, cancels the wrong watchdog and pumps a third while the second is still on the controller; deferring the retirement to an Nth consecutive timeout fails for the same reason, since that callback also resets the counter. connect() now owns its attempt with a token that stop() clears, so a connectGatt() returning after stop() closes its own handle instead of installing an orphan that keeps the trainer for the whole ride. MirrorServer: one AdvertiseCallback per attempt, plus a process-wide registry of callbacks retired while their start was still unresolved. The callback IS the controller registration and a stop issued mid-start is dropped by the stack, so those are swept with backoff until their own delivery proves them gone. One whose result is already known is stopped once and released — adopting it would strand it in the registry for the life of the process. BridgeService: one token for the receive source, one for the emit instance. The emit token closes a control write that entered the old mirror and reached the replacement trainer. The write dispatches outside the monitor: writeCharacteristic is a Binder call that stopEmit() would otherwise wait on from the main thread. ErgBias persistence: the learner runs on every power sample and the deadline is evaluated independently, so a bias converging inside the interval is no longer held back until shutdown. Four review rounds, two of them external and adversarial. Three fixes were reverted after review falsified the premise each rested on, and the reasoning is kept as comments where they sat so they are not retried. Two limits stand: the advertising sweep cannot guarantee retirement, because stopAdvertising() never confirms removal and a start can stay in flight indefinitely; and none of this is device-validated. The riskiest sequence to try on the Karoo is a start accepted without a callback, its watchdog, a successful retry, then the first callback arriving very late or never, followed by stop/restart and Bluetooth off/on. --- .gitignore | 1 + .../trainerbridgeble/BridgeService.kt | 100 ++++--- .../trainerbridgeble/RuntimeHardening.kt | 117 ++++++++ .../trainerbridgeble/ble/MirrorServer.kt | 240 +++++++++++---- .../trainerbridgeble/ble/ZycleClient.kt | 278 +++++++++++------- .../trainerbridgeble/RuntimeHardeningTest.kt | 189 ++++++++++++ 6 files changed, 711 insertions(+), 214 deletions(-) create mode 100644 app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt create mode 100644 app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt diff --git a/.gitignore b/.gitignore index cf5dfae..f181cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ /app/build/ /app/release/ /graphify-out/ +/.local-notes/ diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index 433a206..3ab457b 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -93,13 +93,17 @@ class BridgeService : Service() { private fun freshPowerOrNull(): Int? = if (powerFresh) lastCorrectedW else null @Volatile private var lastResistance: Int? = null @Volatile private var sawIndoorBikeData = false // NOT `lastRawW == null`: that is set by the fallback itself - @Volatile private var pendingErgBiasW: Int? = null // learned but not yet written to prefs (see learnErgBias) - @Volatile private var lastBiasPersistMs = 0L - /** Bumped by every start AND stop of the receive half. The source's callbacks capture the value they were - * created with and no-op once it moves: a GATT callback already past its own `gatt === g` check when a - * source switch lands would otherwise write this session's state (and take the wake lock) on behalf of a - * source that no longer exists. */ - @Volatile private var receiveGen = 0 + private val pendingErgBias = ErgBiasPersistence(ERG_BIAS_PERSIST_MS, ErgBias::onPower) + /** Owns one receive source at a time. Callback validation and mutation share this monitor with source + * replacement, so teardown cannot overtake a callback that already passed its ownership check. */ + private val receiveOwner = IdentityOwner() + /** Owns one emit instance. MirrorServer.stop() is not a callback barrier, and the toZycle lambda reads + * `client` dynamically — so an app's control write that entered the OLD mirror could change resistance + * or the ERG target on the REPLACEMENT trainer. Validation, the ErgBias/lastControl mutations and the + * capture of the target source happen under this; the write itself is dispatched outside it, because + * writeCharacteristic is a Binder call into the Bluetooth process and stopEmit() waits on this monitor + * from the main thread. */ + private val emitOwner = IdentityOwner() /** How many callbacks the generation guard rejected. Zero all ride means the races the guard exists for * never happened; a climbing number is itself the finding. Reported by the periodic snapshot. */ private val staleCallbacks = java.util.concurrent.atomic.AtomicInteger(0) @@ -304,31 +308,29 @@ class BridgeService : Service() { if (client != null || simSource != null) return // idempotent val config = Config(this) config.lastSeenAddress = ""; config.lastSeenName = "" // runtime state; a process kill leaves it stale - pendingErgBiasW = null; lastBiasPersistMs = 0L // 0 = the first learned value of the ride writes at once + pendingErgBias.reset() ErgBias.seed(config.ergBiasW) // start calibrated; there is no live command to measure against yet FileLog.event("receive start paired=${config.pairedAddress.ifEmpty { "any" }} sim=${config.simulate} ergBias=${config.ergBiasW}W") - val gen = ++receiveGen - // Hopping to main is what makes the generation check MEAN anything: read on a binder thread it is - // check-then-act, and a source switch landing between the check and the body would let a replaced - // source drive the live one. startReceive/stopReceive both run on main, so validating there is - // genuinely serialised against them. Only for the callbacks that can LATCH something. + val owner = Any() + receiveOwner.replace(owner) + // Low-rate callbacks still hop to main; onValue stays on the BLE thread and uses receiveOwner's + // monitor to make validation + mutation atomic with stopReceive(). val onProfile: (GattProfile) -> Unit = { profile -> // The worst of them: MirrorServer.build() is a one-shot latch, so a late profile from the source // we just replaced wins it and the new source's real profile is then ignored for the session. - handler.post { if (gen == receiveGen) { lastProfile = profile; mirror?.build(profile) } } + handler.post { receiveOwner.runIfCurrent(owner) { lastProfile = profile; mirror?.build(profile) } } } val onValue: (java.util.UUID, ByteArray) -> Unit = { uuid, value -> - // NOT posted: this is the 4 Hz relay path and a main-looper hop is exactly the latency R2 is - // about. A plain volatile compare is free, and the residue of check-then-act here is one stale - // sample relayed — nothing latches, unlike onProfile above. - if (gen == receiveGen) { + // Keep the 4 Hz relay off the main looper, but make ownership check + every source mutation one + // critical section. stopReceive() clears the same owner before teardown. + if (!receiveOwner.runIfCurrent(owner) { cacheForUi(config, uuid, value) lastValues[uuid] = value // the one-shot reads happen long before Broadcast is pressed mirror?.onZycleValue(uuid, value) - } else staleCallbacks.incrementAndGet() // counted, not logged: it would be per-packet + }) staleCallbacks.incrementAndGet() // counted, not logged: it would be per-packet } val onState: (Boolean) -> Unit = { connected -> - handler.post { if (gen == receiveGen) { // see onProfile: validated on main, so it is atomic + handler.post { receiveOwner.runIfCurrent(owner) { zycleConnected = connected // The wake lock lives HERE, not in goForeground(): there is data to keep the CPU awake for only // while a trainer is actually feeding us. @@ -349,13 +351,13 @@ class BridgeService : Service() { } // Same treatment: a stale onSynced would put the mirror on the air with no trainer behind it, and // setTrainerLinked is edge-triggered, so it would STAY there. - val onSynced: () -> Unit = { handler.post { if (gen == receiveGen) { zycleSynced = true; mirror?.setTrainerLinked(true) } } } + val onSynced: () -> Unit = { handler.post { receiveOwner.runIfCurrent(owner) { zycleSynced = true; mirror?.setTrainerLinked(true) } } } val c: TrainerSource = if (config.simulate) SimSource(onProfile, onValue, onState, onSynced).also { simSource = it } else ZycleClient(this, config.pairedAddress, onProfile, onValue, onState, onSynced, // Guarded too, or the replaced source's advertising blueprint and address get written over the - // live one's. Plain compare: neither latches anything, so the post is not worth the hop. - onAdv = { bp -> if (gen == receiveGen) { lastAdvBlueprint = bp; mirror?.setAdvBlueprint(bp) } }, - onFound = { name, addr -> if (gen == receiveGen) { config.lastSeenName = name ?: ""; config.lastSeenAddress = addr } }) + // live one's. Neither needs a main-looper hop; the owner monitor provides the ordering. + onAdv = { bp -> receiveOwner.runIfCurrent(owner) { lastAdvBlueprint = bp; mirror?.setAdvBlueprint(bp) } }, + onFound = { name, addr -> receiveOwner.runIfCurrent(owner) { config.lastSeenName = name ?: ""; config.lastSeenAddress = addr } }) currentSourceKey = sourceKey(config) c.start() client = c @@ -369,7 +371,7 @@ class BridgeService : Service() { private fun stopReceive() { if (client == null && simSource == null) return FileLog.event("receive stop") - receiveGen++ // invalidate this source's callbacks BEFORE anything else reads or writes state + receiveOwner.clear() // waits for an admitted callback, then rejects every later one releaseWakeLock() // no source → nothing to stay awake for (the master switch keeps the FGS alive) mirror?.setTrainerLinked(false) // no source → nothing to advertise, whatever the call order Config(this).let { it.lastSeenAddress = ""; it.lastSeenName = "" } @@ -378,7 +380,7 @@ class BridgeService : Service() { // its `stopped` check can still publish a pending value after this runs, and that last whole-watt // step is then lost. Immaterial (the EMA moves ~0.05 W a sample and is re-seeded next ride) and it is // the SAFE direction: the dangerous half — a stale sample being persisted into the NEXT session — is - // closed by the receiveGen guard on onValue, which is what feeds learnErgBias. + // closed by the receiveOwner guard on onValue, which is what feeds learnErgBias. persistErgBias(Config(this)); ErgBias.forget() lastValues.clear() CorrectedFeed.clear() @@ -393,18 +395,29 @@ class BridgeService : Service() { if (mirror != null) return // idempotent val config = Config(this) FileLog.event("emit start scaleAdj=${config.scaleAdjustPercent}% offset=${config.offsetW}W") + val emitToken = Any() val m = MirrorServer( context = this, advertisedName = config.advertisedName, correction = { config.correction() }, toZycle = { uuid, bytes, withResponse -> - if (com.enderthor.trainerbridgeble.ble.GattUuids.carriesControl(uuid)) { - // `bytes` is already inverse-corrected: exactly the raw watts the trainer is told to hold, - // which is what the measured power has to be compared against. - ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) - lastControl = describeControl(bytes); listener?.invoke() + // Mutate and CAPTURE the source under the owner; dispatch outside it (see [emitOwner]). A + // callback from a stopped mirror captures nothing and relays nothing; one admitted before the + // clear still targets the source it was admitted for, never the replacement. + var target: com.enderthor.trainerbridgeble.ble.TrainerSource? = null + var moved = false + emitOwner.runIfCurrent(emitToken) { + if (com.enderthor.trainerbridgeble.ble.GattUuids.carriesControl(uuid)) { + // `bytes` is already inverse-corrected: exactly the raw watts the trainer is told to hold, + // which is what the measured power has to be compared against. + ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) + lastControl = describeControl(bytes); moved = true + } + target = client } - client?.write(uuid, bytes, withResponse) ?: false // false → the mirror answers the app with failure + val relayed = target?.write(uuid, bytes, withResponse) ?: false // false → the mirror answers failure + if (moved) listener?.invoke() // an arbitrary UI callback has no business inside the monitor + relayed }, onStatus = { s -> status = s; listener?.invoke() }, onAdvState = { ok -> bleAdvOk = ok; listener?.invoke() }, @@ -413,6 +426,7 @@ class BridgeService : Service() { addr.equals(c.lastSeenAddress, true) || (c.pairedAddress.isNotEmpty() && addr.equals(c.pairedAddress, true)) }, ) + emitOwner.replace(emitToken) // published before start(): the constructor raises no callbacks mirror = m m.start() m.setTrainerLinked(zycleSynced) // Start pressed with the trainer already connected AND read @@ -456,6 +470,9 @@ class BridgeService : Service() { } private fun stopEmit() { + // Before the early return: a mirror whose construction or start() failed still left callbacks able + // to run against this token. + emitOwner.clear() if (mirror == null) return FileLog.event("emit stop") mirror?.stop(); mirror = null @@ -562,25 +579,22 @@ class BridgeService : Service() { // SimSource tracks the ERG target exactly, so it would teach a bias of ~0 and PERSIST it — running // the simulator for three minutes would quietly wipe the real trainer's calibration. if (config.simulate) return - val w = ErgBias.onPower(raw, android.os.SystemClock.elapsedRealtime()) ?: return - pendingErgBiasW = w + val now = android.os.SystemClock.elapsedRealtime() + val due = pendingErgBias.onPower(raw, now) ?: return // Rate-limited: onPower is fed at 4 Hz, and a converged bias sitting near an integer boundary (the // measured overshoot is ~8 W) flips across it over and over — each flip a full rewrite+fsync of the // prefs XML, in flash. The stated goal ("start the next ride where this one finished") is met just as // well at one-minute granularity, and stopReceive flushes whatever is still pending. - val now = android.os.SystemClock.elapsedRealtime() - // `!= 0L` matters: elapsedRealtime is measured from BOOT, so in the first minute of uptime — which on - // a Karoo that reboots daily and auto-starts this extension is a real moment — `now - 0` is under the - // interval and the FIRST learned value of the ride would be held back rather than written at once. - if (lastBiasPersistMs != 0L && now - lastBiasPersistMs < ERG_BIAS_PERSIST_MS) return - lastBiasPersistMs = now - persistErgBias(config) + persistErgBias(config, due) } /** Write out the latest learned bias, if it moved since the last write. */ private fun persistErgBias(config: Config) { - val w = pendingErgBiasW ?: return - pendingErgBiasW = null + val w = pendingErgBias.drain() ?: return + persistErgBias(config, w) + } + + private fun persistErgBias(config: Config, w: Int) { config.ergBiasW = w FileLog.event("ERG bias learned: ${w}W (trainer settles above its command)") } diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt new file mode 100644 index 0000000..9375309 --- /dev/null +++ b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt @@ -0,0 +1,117 @@ +package com.enderthor.trainerbridgeble + +internal class IdentityOwner { + @Volatile private var value: T? = null + + val current: T? get() = value + + @Synchronized fun replace(next: T?): T? = value.also { value = next } + + @Synchronized fun clear(): T? = value.also { value = null } + + @Synchronized fun clearIfCurrent(candidate: T, action: () -> Unit): Boolean { + if (value !== candidate) return false + value = null + action() + return true + } + + @Synchronized fun runIfCurrent(candidate: T, action: () -> Unit): Boolean { + if (value !== candidate) return false + action() + return true + } +} + +internal class GattSessionCoordinator( + private val resetRuntime: () -> Unit, + private val disconnect: (T) -> Unit, + private val close: (T) -> Unit, + private val reconnect: () -> Unit, +) { + private val owner = IdentityOwner() + + val current: T? get() = owner.current + fun replace(next: T?) = owner.replace(next) + fun clear() = owner.clear() + fun clearIfCurrent(candidate: T, action: () -> Unit) = owner.clearIfCurrent(candidate, action) + fun runIfCurrent(candidate: T, action: () -> Unit) = owner.runIfCurrent(candidate, action) + + fun retireIfCurrent(candidate: T, onRetiring: () -> Unit = {}): Boolean { + if (!owner.clearIfCurrent(candidate) { onRetiring(); resetRuntime() }) return false + disconnect(candidate) + close(candidate) + reconnect() + return true + } + + /** + * A timed-out op MUST retire the handle, not just advance the queue. Unsticking in place cannot cancel + * the operation Android still has, so its late callback is indistinguishable from the callback for the + * op dispatched in its place: that one completes the wrong op, cancels the wrong watchdog, and pumps a + * third while the second is still on the controller. Deferring the retirement to an Nth CONSECUTIVE + * timeout does not work either — the same late callback resets any such counter, so the escalation + * never fires precisely when the handle is wedged. A reconnect is the cheaper failure. + */ + fun timeoutIfCurrent(candidate: T, stillPending: () -> Boolean, onRetiring: () -> Unit = {}): Boolean { + var timedOut = false + owner.runIfCurrent(candidate) { + if (stillPending()) timedOut = owner.clearIfCurrent(candidate) { onRetiring(); resetRuntime() } + } + if (!timedOut) return false + disconnect(candidate) + close(candidate) + reconnect() + return true + } +} + +internal class AdvertisingAttemptCoordinator { + private val owner = IdentityOwner() + + val current: T? get() = owner.current + fun runIfCurrent(candidate: T, action: () -> Unit) = owner.runIfCurrent(candidate, action) + fun clearIfCurrent(candidate: T, action: () -> Unit) = owner.clearIfCurrent(candidate, action) + fun clear() = owner.clear() + + @Synchronized fun begin(next: T, retire: (T) -> Unit) { + owner.clear()?.let(retire) + owner.replace(next) + } + + fun retireIfCurrent(candidate: T, retire: (T) -> Unit, after: () -> Unit): Boolean { + if (!owner.clearIfCurrent(candidate) {}) return false + retire(candidate) + after() + return true + } + + fun failIfCurrent( + candidate: T, + stop: (T) -> Unit, + markFailed: () -> Unit, + retry: () -> Unit, + ): Boolean = retireIfCurrent(candidate, stop) { markFailed(); retry() } +} + +internal class ErgBiasPersistence( + private val persistIntervalMs: Long, + private val learner: (rawWatts: Int, nowMs: Long) -> Int?, +) { + private var pending: Int? = null + private var lastPersistMs: Long? = null + + fun reset() { pending = null; lastPersistMs = null } + + fun onPower(rawWatts: Int, nowMs: Long): Int? { + val newWholeWatt = learner(rawWatts, nowMs) + if (newWholeWatt != null) pending = newWholeWatt + val value = pending ?: return null + if (lastPersistMs?.let { nowMs - it < persistIntervalMs } == true) return null + pending = null + lastPersistMs = nowMs + return value + } + + fun drain(): Int? = pending.also { pending = null } +} diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt index 728b205..4d7d4ff 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt @@ -10,7 +10,9 @@ import android.bluetooth.BluetoothGattServerCallback import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothManager import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseCallback import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser import android.content.Context import android.os.Build import android.os.Handler @@ -18,6 +20,7 @@ import android.os.Looper import android.os.ParcelUuid import android.os.SystemClock import android.util.Log +import com.enderthor.trainerbridgeble.AdvertisingAttemptCoordinator import com.enderthor.trainerbridgeble.FileLog import com.enderthor.trainerbridgeble.R import com.enderthor.trainerbridgeble.correction.PowerCorrection @@ -73,7 +76,10 @@ class MirrorServer( private val ADV_START_TIMEOUT_MS = 8000L private val SERVER_RETRY_MS = 2000L private val SERVER_RETRY_MAX_MS = 30_000L // Bluetooth off is a whole-ride failure, not a hiccup - private val ADV_LATE_STOP_MS = 1500L + /** First re-stop of an orphan; doubles to [ADV_SWEEP_MAX_MS] while any remain. Not a bound on when the + * stack answers a start — there is none — just the rate at which we keep asking. */ + private val ADV_SWEEP_MS = 1000L + private val ADV_SWEEP_MAX_MS = 30000L /** How long after a control write the trainer's level is still settling on it. Observed on the 28-jul * ride: every servo-driven level step landed 0.19-2.6 s after the write that caused it. */ private val LEVEL_SETTLE_MS = 3000L @@ -86,6 +92,7 @@ class MirrorServer( private var originalName: String? = null @Volatile private var advertising = false @Volatile private var advStarting = false // a start is in flight; `advertising` only flips in the callback + private val advAttempts = AdvertisingAttemptCoordinator() private val built = java.util.concurrent.atomic.AtomicBoolean(false) // build the mirrored GATT once; a trainer reconnect keeps it @Volatile private var advBlueprint: AdvBlueprint? = null // the trainer's real advertising, to clone @Volatile private var trainerLinked = false // advertise only while a trainer is actually feeding us @@ -129,9 +136,19 @@ class MirrorServer( stopped = false // Kill any advertising set left running by a PREVIOUS instance: its stop may have been dropped // because a start was still in flight, and its callback died with the object. - lastAdvCallback?.takeIf { it !== advCallback }?.let { orphan -> - FileLog.event("stopping an advertising set left by a previous mirror") - runCatching { adapter.bluetoothLeAdvertiser?.stopAdvertising(orphan) } + lastAdvCallback?.let { orphan -> + FileLog.event("stopping an advertising set left by a previous mirror (unresolved=$lastAdvUnresolved)") + // Only an unresolved start can have its stop dropped, so only that one needs the sweep. A + // resolved one is stopped once, reliably — adopting it would strand it in advOrphans forever. + if (lastAdvUnresolved) orphanAdvCallback(orphan) + else { + runCatching { adapter.bluetoothLeAdvertiser?.stopAdvertising(orphan) } + if (lastAdvCallback === orphan) lastAdvCallback = null + } + } + if (advOrphans.isNotEmpty()) { // orphans from an earlier instance keep being swept by this one + advSweepMs = ADV_SWEEP_MS + orphanHandler.removeCallbacks(advSweep); orphanHandler.postDelayed(advSweep, advSweepMs) } openServer() } @@ -299,17 +316,9 @@ class MirrorServer( fun stop() { // Stop the advertiser UNCONDITIONALLY: the `advertising` flag is transiently false mid-restart, so // trusting it here can leave the phone broadcasting with a closed GATT server. - stopped = true; pendingProfile = null - advertising = false; servicesReady = false + stopped = true; pendingProfile = null; servicesReady = false + stopAdvertising() handler.removeCallbacksAndMessages(null) // pending adv starts / service retries must not outlive us - runCatching { adapter.bluetoothLeAdvertiser?.stopAdvertising(advCallback) } - if (advStarting) { - // The stop we just issued is dropped when a start is still in flight. Try once more after it - // has had time to complete — on a Handler that stop() has NOT just drained. - val cb = advCallback; val adv = adapter.bluetoothLeAdvertiser - Handler(Looper.getMainLooper()).postDelayed({ runCatching { adv?.stopAdvertising(cb) } }, ADV_LATE_STOP_MS) - } - advStarting = false runCatching { server?.close() } server = null restoreName() @@ -574,25 +583,63 @@ class MirrorServer( } // ── advertising ────────────────────────────────────────────────────────────────────────────────── - private val advCallback = object : android.bluetooth.le.AdvertiseCallback() { - override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { handler.removeCallbacks(advStartWatchdog); advStarting = false; advertising = true; advRetries = 0; advRetryMs = ADV_RETRY_MS; onAdvState(true); onStatus(context.getString(R.string.status_advertising, advertisedName)); FileLog.event("advertising as $advertisedName") - // a stop issued while this start was in flight is dropped by the stack — reconcile now - if (!trainerLinked || server == null) { FileLog.event("advertising with no trainer — stopping"); stopAdvertising() } - } - override fun onStartFailure(errorCode: Int) { - handler.removeCallbacks(advStartWatchdog); advStarting = false; advertising = false; onAdvState(false) - onStatus(context.getString(R.string.status_advertise_failed, errorCode)) - FileLog.event("advertise failed $errorCode (attempt ${++advRetries}, name=${!dropNameFromAdv})") - // 31-byte PDU: shed the cloned manufacturer data first (usually the culprit), the name only if - // that still isn't enough — apps find us BY the name, so it is the last thing to go. Driven by - // the error code, not by the attempt counter, so it still walks its three steps in order. - if (errorCode == ADVERTISE_FAILED_DATA_TOO_LARGE) when { - !dropMfrFromAdv -> dropMfrFromAdv = true // the cloned manufacturer data usually is it - !dropBlueprintFromAdv -> dropBlueprintFromAdv = true // then the cloned UUIDs (128-bit won't fit) - else -> dropNameFromAdv = true // last resort: apps find us BY the name + private fun newAdvAttempt(advertiser: BluetoothLeAdvertiser): Pair { + lateinit var callback: AdvertiseCallback + lateinit var watchdog: Runnable + callback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { + handler.removeCallbacks(watchdog) + resolveAdvOrphan(this) // our one callback arrived: resolved, current or not + if (!advAttempts.runIfCurrent(this) { + advStarting = false; advertising = true; advRetries = 0; advRetryMs = ADV_RETRY_MS + onAdvState(true); onStatus(context.getString(R.string.status_advertising, advertisedName)) + FileLog.event("advertising as $advertisedName") + if (!trainerLinked || server == null) { + FileLog.event("advertising with no trainer — stopping") + stopAdvertising() + } + }) { + // Late success for a retired attempt: its registration is real, so stop it. Resolved + // above, so this stop can no longer be dropped by the stack. + runCatching { advertiser.stopAdvertising(this) } + } + } + + override fun onStartFailure(errorCode: Int) { + handler.removeCallbacks(watchdog) + resolveAdvOrphan(this) // no registration exists for a failed start, orphaned or not + advAttempts.failIfCurrent( + this, + stop = { runCatching { advertiser.stopAdvertising(it) } }, + markFailed = { + if (lastAdvCallback === this) lastAdvCallback = null + advStarting = false; advertising = false; onAdvState(false) + onStatus(context.getString(R.string.status_advertise_failed, errorCode)) + FileLog.event("advertise failed $errorCode (attempt ${++advRetries}, name=${!dropNameFromAdv})") + if (errorCode == ADVERTISE_FAILED_DATA_TOO_LARGE) when { + !dropMfrFromAdv -> dropMfrFromAdv = true + !dropBlueprintFromAdv -> dropBlueprintFromAdv = true + else -> dropNameFromAdv = true + } + }, + retry = { scheduleAdvRetry("failure $errorCode") }, + ) } - scheduleAdvRetry("failure $errorCode") } + watchdog = Runnable { + advAttempts.retireIfCurrent( + callback, + // Unresolved by definition — that is what the watchdog fires on. One stop is not enough: + // if the start is still in flight the stack drops it, so hand it to the orphan sweep. + retire = { orphanAdvCallback(it) }, + after = { + advStarting = false; advertising = false; onAdvState(false) + FileLog.event("advertise start never answered — retiring attempt and retrying") + scheduleAdvRetry("start never answered") + }, + ) + } + return callback to watchdog } private fun startAdvertising() { @@ -619,17 +666,32 @@ class MirrorServer( .addServiceUuid(ParcelUuid(GattUuids.uuid16(0x1826))) } advStarting = true - lastAdvCallback = advCallback // process-wide, so a later instance can still stop this set - if (runCatching { advertiser.startAdvertising(settings, builder.build(), advCallback) }.isFailure) { + // One callback is one controller registration. Retire any orphan before publishing the new owner. + val (callback, watchdog) = newAdvAttempt(advertiser) + // A previous owner still here means its start never resolved (a resolved one is retired by its own + // callback), so it is an orphan, not a plain stop. + advAttempts.begin(callback) { old -> orphanAdvCallback(old) } + lastAdvCallback = callback // process-wide, so a later instance can still stop this set + lastAdvUnresolved = true // ...and know whether that stop can be dropped by the stack + if (runCatching { advertiser.startAdvertising(settings, builder.build(), callback) }.isFailure) { // A synchronous throw answers with no callback at all, so this is the only place that can report // it. Clearing the flag alone left health green and nothing scheduled. - advStarting = false; onAdvState(false) - scheduleAdvRetry("start threw") + advAttempts.failIfCurrent( + callback, + // The start was never accepted, so there is no registration to chase: a plain stop, and + // deliberately NOT an orphan — an entry nothing can ever resolve would be swept forever. + stop = { runCatching { advertiser.stopAdvertising(it) } }, + markFailed = { + if (lastAdvCallback === callback) lastAdvCallback = null + advStarting = false; advertising = false; onAdvState(false) + }, + retry = { scheduleAdvRetry("start threw") }, + ) } else { // An accepted start normally answers with exactly one callback — except when the adapter is // turned off or the BT process dies under it, which drops the callback silently. Without this // the flag latches and we never advertise again. - handler.removeCallbacks(advStartWatchdog); handler.postDelayed(advStartWatchdog, ADV_START_TIMEOUT_MS) + handler.postDelayed(watchdog, ADV_START_TIMEOUT_MS) } } @@ -637,27 +699,65 @@ class MirrorServer( * onStartSuccess, so an early return here can leave a pending advert running with no trainer behind it. */ private fun stopAdvertising() { handler.removeCallbacks(startAdvRunnable) - advertising = false - // NOT advStarting: a stop issued while a start is in flight is dropped by the stack, so the start - // is still coming. Only its callback may clear the flag, or we let a second start through. - runCatching { adapter.bluetoothLeAdvertiser?.stopAdvertising(advCallback) } + val wasStarting = advStarting + // Clear waits for a callback already admitted by runIfCurrent; finalize flags only after it exits. + val callback = advAttempts.clear() + // advStarting IS cleared here now, unlike before. A stop issued while a start is in flight is dropped + // by the stack, so that start is still coming and a second one can get through — but it now builds a + // NEW callback, i.e. a separate registration, while this one is retired from the owner (its late + // callback is rejected) and handed to the orphan sweep, which keeps stopping it until its own + // callback arrives. So the extra registration is chased until it is provably gone, rather than + // stopped once on a delay that no Android contract actually bounds. + advertising = false; advStarting = false + callback ?: return + // Only an UNRESOLVED start needs the sweep: its stop can be dropped and nothing else holds the + // handle. A resolved attempt is stopped once, reliably, right here. + if (wasStarting) orphanAdvCallback(callback) + else { + if (lastAdvCallback === callback) lastAdvCallback = null + runCatching { adapter.bluetoothLeAdvertiser?.stopAdvertising(callback) } + } } - /** Force a fresh advertise (Android silently stopped it when a central connected, but our flag didn't - * know). Needed so more than one app can find us. */ - private val startAdvRunnable = Runnable { startAdvertising() } - /** A start that is accepted and then never answers. Clearing the latch is not enough: nothing else - * retries, and `bleAdvOk` would stay at its optimistic true — a mirror off the air behind a green UI, - * which is strictly worse to diagnose than a visible failure. Do what onStartFailure does, minus the - * size-shedding (there is no error code to shed for). */ - private val advStartWatchdog = Runnable { - if (advStarting) { - FileLog.event("advertise start never answered — clearing in-flight flag and retrying") - advStarting = false; advertising = false; onAdvState(false) - scheduleAdvRetry("start never answered") + /** Retire a callback whose start never resolved: stop it now, and keep stopping it until its own + * callback proves the registration is gone. [resolved] callbacks skip this — their result is known. */ + private fun orphanAdvCallback(cb: AdvertiseCallback) { + advOrphans.add(cb) + if (lastAdvCallback === cb) lastAdvCallback = null + runCatching { adapter.bluetoothLeAdvertiser?.stopAdvertising(cb) } + advSweepMs = ADV_SWEEP_MS + orphanHandler.removeCallbacks(advSweep); orphanHandler.postDelayed(advSweep, advSweepMs) + } + + /** An orphan's own callback finally arrived: that is the only proof the registration resolved. Also + * the one place that can mark the process-wide handle resolved, for a later instance's handoff. */ + private fun resolveAdvOrphan(cb: AdvertiseCallback) { + advOrphans.remove(cb) + if (lastAdvCallback === cb) lastAdvUnresolved = false + } + + private var advSweepMs = ADV_SWEEP_MS + private val advSweep = object : Runnable { + override fun run() { + val advertiser = adapter.bluetoothLeAdvertiser + if (advertiser == null) { // adapter off: every prior registration died with it + if (advOrphans.isNotEmpty()) FileLog.event("adapter off — dropping ${advOrphans.size} orphan advertising set(s)") + advOrphans.clear(); advSweepMs = ADV_SWEEP_MS + return + } + val current = advAttempts.current + // Snapshot under the lock, call the advertiser outside it. NEVER the current attempt. + val targets = synchronized(advOrphans) { advOrphans.toList() }.filter { it !== current } + for (cb in targets) runCatching { advertiser.stopAdvertising(cb) } + if (advOrphans.isEmpty()) { advSweepMs = ADV_SWEEP_MS; return } + advSweepMs = (advSweepMs * 2).coerceAtMost(ADV_SWEEP_MAX_MS) + orphanHandler.postDelayed(this, advSweepMs) } } + /** Force a fresh advertise (Android silently stopped it when a central connected, but our flag didn't + * know). Needed so more than one app can find us. */ + private val startAdvRunnable = Runnable { startAdvertising() } /** * The one place that re-arms advertising. A capped ATTEMPT COUNT was the bug this replaces: five * transient failures took the mirror off the air for the whole ride. A flat 1 s retry is the opposite @@ -665,13 +765,8 @@ class MirrorServer( * hour, each one a log line, across a 4-12 h ride. So: backoff, no cap. The budget resets on a real * success and when the trainer link comes back. * - * ponytail: every attempt shares ONE AdvertiseCallback, because that object is also the handle - * stopAdvertising needs. So if a start's callback arrives after [ADV_START_TIMEOUT_MS] the watchdog can - * have already started a second attempt, and the two callbacks are indistinguishable — a late success - * for A cancels B's watchdog, a late failure for B clears `advertising` while A is really on air. The - * timeout is set well past any plausible stack latency so this needs a genuinely lost callback AND a - * slow one; closing it properly means a fresh callback object per attempt plus tracking which one owns - * the live set. Backoff bounds the damage to a self-healing flap. + * Each attempt owns a distinct callback. Its watchdog retires that registration before scheduling the + * retry, so callbacks from an old attempt cannot cancel or mutate the current one. */ private fun scheduleAdvRetry(why: String) { if (stopped) return @@ -704,7 +799,34 @@ class MirrorServer( } private companion object { + /** + * Callbacks retired while their start was STILL UNRESOLVED — and only those. The callback IS the + * controller registration, and a stop issued while its start is in flight is dropped by the stack, + * so such an attempt can stay registered with nothing holding its handle: an advertiser slot burnt, + * or an obsolete mirror on the air, until Bluetooth restarts. Membership ends only when that + * attempt's OWN callback is finally delivered, because nothing else proves the registration is gone. + * + * Deliberately NOT every started attempt: a sweep must never stop the live one. An attempt whose + * result is already known (onStartFailure, a synchronous throw — the start was never accepted) is + * not an orphan either. + * + * Process-wide: an orphan has to outlive the MirrorServer that created it, which is the whole point. + * + * ponytail: stopAdvertising() does not confirm removal and a start can stay in flight past + * ADV_START_TIMEOUT_MS, so NO finite delay can guarantee the retirement lands. The sweep backs off + * to ADV_SWEEP_MAX_MS and keeps trying while the set is non-empty; a null advertiser (adapter off) + * is the one piece of evidence that every prior registration is definitively gone. + */ + private val advOrphans: MutableSet = + java.util.Collections.synchronizedSet(mutableSetOf()) + /** NOT the instance handler: stop() drains that one, and an orphan must outlive its mirror. */ + private val orphanHandler by lazy { Handler(Looper.getMainLooper()) } @Volatile private var lastAdvCallback: android.bluetooth.le.AdvertiseCallback? = null + /** Whether [lastAdvCallback]'s start is still UNRESOLVED. A bare reference cannot say: a callback + * that already got its onStartSuccess is registered but RESOLVED, so the next instance must stop it + * once — never adopt it as an orphan, which nothing could ever resolve and the sweep would chase + * every 30 s for the life of the process. */ + @Volatile private var lastAdvUnresolved = false const val KEY_ADV_NAMES = "advertisedNamesUsed" const val KEY_ORIG_NAME = "origBtName" } diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt index 3e2d71c..39e593b 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt @@ -16,6 +16,8 @@ import android.os.Handler import android.os.Looper import android.util.Log import com.enderthor.trainerbridgeble.FileLog +import com.enderthor.trainerbridgeble.GattSessionCoordinator +import com.enderthor.trainerbridgeble.IdentityOwner import java.util.UUID import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean @@ -47,7 +49,26 @@ class ZycleClient( private val handler = Handler(Looper.getMainLooper()) private val adapter by lazy { (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter } - @Volatile private var gatt: BluetoothGatt? = null + private val gattSessions = GattSessionCoordinator( + resetRuntime = { + lastMessageMs = 0L + onState(false) + opQueue.clear(); opBusy.set(false); syncOwed.set(false); burstEnqueued = false + connecting.set(false); inFlightWrite = null + }, + disconnect = { runCatching { it.disconnect() } }, + close = { runCatching { it.close() } }, + reconnect = { scheduleReconnect() }, + ) + private var gatt: BluetoothGatt? + get() = gattSessions.current + set(value) { gattSessions.replace(value) } + /** Owns the ONE connect attempt allowed to publish a handle. connectGatt() is a blocking Binder call and + * stop() can run to completion inside it, so a plain `stopped` check cannot gate the publication: the + * check would pass, stop() would find no GATT to close, and the attempt would then install an orphan + * that keeps the trainer to itself for the rest of the ride. Distinct from [connecting], which stops + * concurrent connectGatt() calls; this decides which attempt still OWNS the outcome. */ + private val connectAttempts = IdentityOwner() private val connecting = AtomicBoolean(false) // CAS: scan results arrive on a binder thread pool @Volatile private var stopped = false @Volatile private var scanning = false @@ -69,8 +90,10 @@ class ZycleClient( * dropped in the meantime: a stale delivery would put the mirror on the air with no trainer behind it, * and setTrainerLinked is edge-triggered, so it would STAY there. */ private fun fireSynced(g: BluetoothGatt) { - if (!syncOwed.compareAndSet(true, false)) return - handler.post { if (!stopped && gatt === g) onSynced() } + gattSessions.runIfCurrent(g) { + if (!syncOwed.compareAndSet(true, false)) return@runIfCurrent + handler.post { if (!stopped) gattSessions.runIfCurrent(g) { onSynced() } } + } } private val opToken = java.util.concurrent.atomic.AtomicInteger(0) // guards the per-op watchdog vs a stale timeout @@ -90,6 +113,10 @@ class ZycleClient( override fun stop() { stopped = true + // BEFORE anything reads `gatt`: an in-flight connectGatt() then fails its ownership check and closes + // its own handle. Both paths cross this monitor, and this clear precedes the `gatt` read below, so + // either the attempt published first (and the read closes it) or it never publishes at all. + connectAttempts.clear() connecting.set(false) inFlightWrite = null stopScan() @@ -108,12 +135,7 @@ class ZycleClient( if (stopped) return val g = gatt if (g != null && lastMessageMs != 0L && android.os.SystemClock.elapsedRealtime() - lastMessageMs > HEARTBEAT_TIMEOUT_MS) { - FileLog.event("Zycle watchdog: silent ${android.os.SystemClock.elapsedRealtime() - lastMessageMs}ms -> reconnect") - gatt = null; lastMessageMs = 0L - onState(false) - opQueue.clear(); opBusy.set(false); syncOwed.set(false); burstEnqueued = false - runCatching { g.disconnect() }; runCatching { g.close() } - scheduleReconnect() + recycleGatt(g, "silent ${android.os.SystemClock.elapsedRealtime() - lastMessageMs}ms") } handler.postDelayed(this, HEARTBEAT_CHECK_MS) } @@ -126,6 +148,12 @@ class ZycleClient( handler.postDelayed({ reconnectPending.set(false); startScan() }, RECONNECT_DELAY_MS) } + /** A timed-out Android GATT operation has no cancellation API. The only safe queue reset is therefore + * to retire the whole handle; callbacks already queued for it then fail the same identity gate. */ + private fun recycleGatt(g: BluetoothGatt, reason: String) { + gattSessions.retireIfCurrent(g) { FileLog.event("Zycle watchdog: $reason -> reconnect") } + } + /** Forward a write to the trainer's characteristic [charUuid] (control relay). Queued. */ override fun write(charUuid: UUID, bytes: ByteArray, withResponse: Boolean): Boolean = writeInternal(charUuid, bytes, withResponse, CONTROL_WRITE_RETRIES) @@ -246,19 +274,37 @@ class ZycleClient( if (stopped) { connecting.set(false); return } // stop() raced a scan result on a binder thread // TRANSPORT_LE explicitly: with TRANSPORT_AUTO a device that ever bonded as DUAL is attempted over // BR/EDR and fails with status=133 every time. + val attempt = Any() + connectAttempts.replace(attempt) + // Re-checked AFTER the token exists: stop() could have landed between the check above and this line, + // and it clears no token that had not been registered yet. From here on a stop is guaranteed to + // invalidate us. No monitor is held across connectGatt() — it can block for as long as the stack likes. + if (stopped) { connectAttempts.clearIfCurrent(attempt) { connecting.set(false) }; return } val g = runCatching { device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) }.getOrNull() - gatt = g if (g == null) { // registerClient failed (client-interface exhaustion / stack restart) - FileLog.event("Zycle connectGatt returned null — rescheduling") - connecting.set(false); scheduleReconnect(); return + // Only if we still own the attempt: `connecting` and the retry belong to whoever replaced us. + if (connectAttempts.clearIfCurrent(attempt) { connecting.set(false) }) { + FileLog.event("Zycle connectGatt returned null — rescheduling") + scheduleReconnect() + } + return + } + // A handle nobody owns: stop(), or a newer attempt, landed while connectGatt blocked. Close it and + // touch NOTHING else — clearing `connecting` here would release the latch the live attempt holds, + // letting a scan result start a third connection behind its back. + if (!connectAttempts.clearIfCurrent(attempt) { gatt = g }) { + FileLog.event("Zycle connectGatt returned for a retired attempt — closing it") + runCatching { g.disconnect() }; runCatching { g.close() } + return } // Bound to THIS handle: a timeout left over from a previous attempt used to tear down the next one. val timeout = Runnable { - if (!stopped && connecting.get() && gatt === g) { + if (!stopped && connecting.get() && gattSessions.clearIfCurrent(g) { FileLog.event("Zycle connect timeout ${CONNECT_TIMEOUT_MS}ms -> retry") - gatt = null; connecting.set(false) + connecting.set(false) + }) { runCatching { g.disconnect() }; runCatching { g.close() } scheduleReconnect() } @@ -272,83 +318,82 @@ class ZycleClient( private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { - if (gatt !== g) { runCatching { g.close() }; return } // orphaned handle — drop it - FileLog.event("Zycle connected status=$status") - connecting.set(false) - retryMs = SCAN_RETRY_MS // a good connection resets the backoff - everConnected = true // ...and promotes every later scan to the reacquisition duty cycle - lastMessageMs = android.os.SystemClock.elapsedRealtime() // start the silent-link window at connect - syncOwed.set(true); burstEnqueued = false - onState(true) - handler.post { runCatching { g.discoverServices() } } - // Floor under the mirror going on the air. Discovery can fail, be refused by the stack, or - // yield a profile with nothing to read; and a lost GATT callback costs OP_TIMEOUT_MS each. - // Waiting forever for a perfect sync is worse than advertising with a partial cache. - handler.postDelayed({ - if (syncOwed.get()) FileLog.event("Zycle sync fallback ${SYNC_FALLBACK_MS}ms -> advertising anyway") - fireSynced(g) - }, SYNC_FALLBACK_MS) + if (!gattSessions.runIfCurrent(g) { + FileLog.event("Zycle connected status=$status") + connecting.set(false) + retryMs = SCAN_RETRY_MS // a good connection resets the backoff + everConnected = true // ...and promotes every later scan to the reacquisition duty cycle + lastMessageMs = android.os.SystemClock.elapsedRealtime() // start the silent-link window at connect + syncOwed.set(true); burstEnqueued = false + onState(true) + handler.post { runCatching { g.discoverServices() } } + // Floor under the mirror going on the air. Discovery can fail, be refused by the stack, or + // yield a profile with nothing to read; and a lost GATT callback costs OP_TIMEOUT_MS each. + // Waiting forever for a perfect sync is worse than advertising with a partial cache. + handler.postDelayed({ + if (syncOwed.get()) FileLog.event("Zycle sync fallback ${SYNC_FALLBACK_MS}ms -> advertising anyway") + fireSynced(g) + }, SYNC_FALLBACK_MS) + }) runCatching { g.close() } // orphaned handle — drop it } else { FileLog.event("Zycle disconnected status=$status") runCatching { g.close() } - if (gatt !== g) return // a stale/superseded handle — don't touch the live connection's state - onState(false) - opQueue.clear(); opBusy.set(false); syncOwed.set(false); burstEnqueued = false - gatt = null; connecting.set(false); inFlightWrite = null; lastMessageMs = 0L - scheduleReconnect() + if (gattSessions.clearIfCurrent(g) { + onState(false) + opQueue.clear(); opBusy.set(false); syncOwed.set(false); burstEnqueued = false + connecting.set(false); inFlightWrite = null; lastMessageMs = 0L + }) scheduleReconnect() } } override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { - if (stopped || gatt !== g) return // stopped, or a callback from a handle we already replaced - if (status != BluetoothGatt.GATT_SUCCESS) { - Log.w(tag, "discover failed $status"); FileLog.event("Zycle discover FAILED status=$status"); return + if (stopped) return + gattSessions.runIfCurrent(g) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w(tag, "discover failed $status"); FileLog.event("Zycle discover FAILED status=$status") + return@runIfCurrent + } + lastMessageMs = android.os.SystemClock.elapsedRealtime() + val profile = buildProfile(g) + FileLog.event("Zycle profile: " + profile.services.joinToString("; ") { s -> + "${s.uuid}[" + s.chars.joinToString(",") { "${shortUuid(it.uuid)}(p=${it.properties})" } + "]" + }) + onProfile(profile) + // Subscribe to every notify/indicate char, and read every readable char once — all serialised. + val svcs = g.services.filterNot { GattUuids.isStackService(it.uuid) } + // BEFORE the loops: an op the stack refuses completes synchronously inside pump(), so the + // whole burst can drain right here. The mirror must wait for the FULL queue to drain, not + // merely for the first subscription, or its read cache is still cold when it advertises. + burstEnqueued = true + // try/finally is load-bearing: an exception while building leaves no permanent queue latch. + burstBuilding = true + try { + // DATA STREAMS FIRST. Reads can take 0.5-2 s over the air; putting them first punches that + // gap in every reconnect. The cold-cache invariant still gates advertising on full drain. + for (svc in svcs) for (ch in svc.characteristics) + if ((ch.uuid == GattUuids.INDOOR_BIKE_DATA || ch.uuid == GattUuids.CYCLING_POWER_MEASUREMENT) && + ch.properties and (BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) + enqueueSubscribe(g, ch) + // READS NEXT: apps read FTMS Feature immediately and cache an empty answer for the session. + for (svc in svcs) for (ch in svc.characteristics) + if (ch.properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) enqueueRead(g, ch) + for (svc in svcs) for (ch in svc.characteristics) + if (ch.properties and (BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) + enqueueSubscribe(g, ch) + } finally { burstBuilding = false } + // Also drains a profile with nothing readable/notifiable instead of hanging the sync latch. + pump() } - lastMessageMs = android.os.SystemClock.elapsedRealtime() // discovery counts as life, or the watchdog recycles us mid-subscribe - val profile = buildProfile(g) - FileLog.event("Zycle profile: " + profile.services.joinToString("; ") { s -> - "${s.uuid}[" + s.chars.joinToString(",") { "${shortUuid(it.uuid)}(p=${it.properties})" } + "]" - }) - onProfile(profile) - // Subscribe to every notify/indicate char, and read every readable char once — all serialised. - val svcs = g.services.filterNot { GattUuids.isStackService(it.uuid) } - // BEFORE the loops: an op the stack refuses completes synchronously inside pump(), so the whole - // burst can drain right here — and a flag set afterwards would arm an already-empty queue, - // leaving the mirror permanently off the air. - burstEnqueued = true - // try/finally is load-bearing, not defensive habit: anything escaping between these two - // assignments would leave the queue held shut for the rest of the session — a silent link with - // no watchdog able to reopen it, which is the worst failure this file has. - burstBuilding = true // hold the queue until every op below is in it (see burstBuilding) - try { - // THE DATA STREAMS FIRST — ahead of the reads. Nothing notifies until the queue reaches the - // subscribes, and the read burst below is 20-40 ATT round trips (0.5-2 s over the air), so every - // reconnect punched that long a hole in what the apps, ANT and the Karoo recording received. - // This does NOT weaken the cold-cache invariant documented below: what gates the mirror going on - // the air is the WHOLE queue draining (onSynced), not the first subscribe. The two are re-issued - // by the general loop further down; a repeated CCCD write is idempotent and costs one op each. - for (svc in svcs) for (ch in svc.characteristics) - if ((ch.uuid == GattUuids.INDOOR_BIKE_DATA || ch.uuid == GattUuids.CYCLING_POWER_MEASUREMENT) && - ch.properties and (BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) - enqueueSubscribe(g, ch) - // READS NEXT: an app connecting to the mirror reads FTMS Feature almost immediately, and a cold - // cache there reads to it as "this machine has no capabilities" for the whole session. - for (svc in svcs) for (ch in svc.characteristics) - if (ch.properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) enqueueRead(g, ch) - for (svc in svcs) for (ch in svc.characteristics) - if (ch.properties and (BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) - enqueueSubscribe(g, ch) - } finally { burstBuilding = false } - pump() // a profile with nothing readable/notifiable enqueues nothing: drain now, don't hang } override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { - val u = descriptor.characteristic.uuid - // a failed CCCD write means that characteristic silently never notifies — never let it pass quietly - if (status != BluetoothGatt.GATT_SUCCESS) - FileLog.event("Zycle subscribe ${shortUuid(u)} FAILED status=$status") - lastMessageMs = android.os.SystemClock.elapsedRealtime() - opDone() + gattSessions.runIfCurrent(g) { + val u = descriptor.characteristic.uuid + if (status != BluetoothGatt.GATT_SUCCESS) + FileLog.event("Zycle subscribe ${shortUuid(u)} FAILED status=$status") + lastMessageMs = android.os.SystemClock.elapsedRealtime() + opDone() + } } override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { @@ -357,38 +402,48 @@ class ZycleClient( @Deprecated("Deprecated in Java") override fun onCharacteristicRead(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) { - if (status == BluetoothGatt.GATT_SUCCESS) { - val v = @Suppress("DEPRECATION") (ch.value?.copyOf() ?: ByteArray(0)) - FileLog.event("Zycle read ${shortUuid(ch.uuid)} = ${FileLog.hex(v)}") // identity/feature/ranges values - onValue(ch.uuid, v) - } else FileLog.event("Zycle read ${shortUuid(ch.uuid)} failed status=$status") - lastMessageMs = android.os.SystemClock.elapsedRealtime() - opDone() + gattSessions.runIfCurrent(g) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val v = @Suppress("DEPRECATION") (ch.value?.copyOf() ?: ByteArray(0)) + FileLog.event("Zycle read ${shortUuid(ch.uuid)} = ${FileLog.hex(v)}") // identity/feature/ranges values + onValue(ch.uuid, v) + } else FileLog.event("Zycle read ${shortUuid(ch.uuid)} failed status=$status") + lastMessageMs = android.os.SystemClock.elapsedRealtime() + opDone() + } } override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) { - lastMessageMs = android.os.SystemClock.elapsedRealtime() - // only the write this callback is FOR: a late status used to be attributed to whatever write - // happened to be in the slot, re-sending someone else's ERG target - val w = inFlightWrite?.takeIf { it.uuid == ch.uuid } - if (w != null) inFlightWrite = null - if (status != BluetoothGatt.GATT_SUCCESS) { - // Retry a failed CONTROL write (the trainer occasionally NAKs with status 133), but only if a - // newer control write hasn't superseded it (w.seq == writeSeq) — never re-send a stale target. - val retry = w != null && w.retriesLeft > 0 && GattUuids.carriesControl(w.uuid) && w.seq == writeSeq.get() && !stopped - FileLog.event("Zycle write ${shortUuid(ch.uuid)} status=$status" + if (retry) " — retry ${w!!.retriesLeft}" else "") - if (retry) handler.postDelayed({ writeInternal(w!!.uuid, w.bytes, w.withResponse, w.retriesLeft - 1) }, CONTROL_RETRY_DELAY_MS) + gattSessions.runIfCurrent(g) { + lastMessageMs = android.os.SystemClock.elapsedRealtime() + // Attribute a status only to the write this callback names. A late callback must not retry + // whichever newer ERG target happens to occupy the slot. + val w = inFlightWrite?.takeIf { it.uuid == ch.uuid } + if (w != null) inFlightWrite = null + if (status != BluetoothGatt.GATT_SUCCESS) { + // Retry only a still-current control write; never resurrect a superseded target. + val retry = w != null && w.retriesLeft > 0 && GattUuids.carriesControl(w.uuid) && w.seq == writeSeq.get() && !stopped + FileLog.event("Zycle write ${shortUuid(ch.uuid)} status=$status" + if (retry) " — retry ${w!!.retriesLeft}" else "") + if (retry) handler.postDelayed({ writeInternal(w!!.uuid, w.bytes, w.withResponse, w.retriesLeft - 1) }, CONTROL_RETRY_DELAY_MS) + } + opDone() } - opDone() } @Deprecated("Deprecated in Java") override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) { if (stopped) return // in-flight notification after stop() — not our data any more - val value = @Suppress("DEPRECATION") (ch.value?.copyOf() ?: ByteArray(0)) - lastMessageMs = android.os.SystemClock.elapsedRealtime() // feed the silent-link watchdog - logNotif(ch.uuid, value) // every notification, unthrottled - onValue(ch.uuid, value) + // The whole relay stays INSIDE the session gate. receiveOwner identifies the source OBJECT, not + // the GATT handle, so a frame from a handle this client already retired still passes it — and in + // MirrorServer it would consume the reanchorLevel the disconnect just armed for the replacement, + // reporting an outage's level movement as a rider button press. Cheap to hold: the notify + // fan-out is posted to main by MirrorServer (see onZycleValue), never called under this monitor. + gattSessions.runIfCurrent(g) { + val value = @Suppress("DEPRECATION") (ch.value?.copyOf() ?: ByteArray(0)) + lastMessageMs = android.os.SystemClock.elapsedRealtime() + logNotif(ch.uuid, value) + onValue(ch.uuid, value) + } } } @@ -455,19 +510,18 @@ class ZycleClient( if (burstEnqueued) gatt?.let { fireSynced(it) } return } + val session = gatt + if (session == null) { opQueue.clear(); opBusy.set(false); return } // Per-op watchdog: a LOST GATT callback (flaky link) would otherwise latch opBusy forever and every // later control/ERG write would sit undispatched while power keeps streaming (invisible failure). - // opDone() cancels this on normal completion; the token guard covers the concurrent-fire edge. - // ponytail: a real callback arriving >OP_TIMEOUT_MS LATE (not lost — link already badly degraded) - // can still double-advance for an instant. Self-healing and strictly better than the old permanent - // wedge; fully closing it needs matching each callback to its op (fragile) — not worth it. + // opDone() cancels this on normal completion; token + session identity cover the concurrent edge. val token = opToken.incrementAndGet() val w = Runnable { - if (opBusy.get() && opToken.get() == token) { - FileLog.event("Zycle GATT op timeout ${OP_TIMEOUT_MS}ms -> unstick queue") - inFlightWrite = null - opDone() - } + gattSessions.timeoutIfCurrent( + session, + stillPending = { opBusy.get() && opToken.get() == token }, + onRetiring = { FileLog.event("Zycle watchdog: GATT op timeout ${OP_TIMEOUT_MS}ms -> reconnect") }, + ) } opWatchdog = w handler.postDelayed(w, OP_TIMEOUT_MS) diff --git a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt new file mode 100644 index 0000000..b2cbbb7 --- /dev/null +++ b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt @@ -0,0 +1,189 @@ +package com.enderthor.trainerbridgeble + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * COORDINATOR CONTRACT ONLY. These are pure-JVM tests of the ownership primitives, with production's own + * call shapes. They deliberately do NOT prove any Android BLE behaviour, and cannot: a really-blocked + * connectGatt(), a GATT-server callback after close(), writeCharacteristic() blocking Binder while main + * runs stopEmit(), an accepted advertising start whose stop is dropped and whose callback is lost, the + * number of registrations actually live in the controller, adapter OFF/ON or a Bluetooth process death, + * and the real Handler/main/Binder ordering on the device all remain device-validation territory. + * Adding Robolectric would not move that line — it reproduces none of the above — so it is not used. + * + * Three purely LOGICAL gaps remain too, and are not covered here either: + * 1. `ZycleClient.connect()`'s `stopped` re-check after registering its token — a field read inside a + * production method, not a coordinator contract. + * 2. `MirrorServer`'s advOrphans / lastAdvCallback cross-instance handoff, including the resolved-vs- + * unresolved distinction. It lives entirely in an Android-typed class; a permanently-stranded orphan + * got through this suite once already, so treat that path as device-validated only. + * 3. That the production toZycle lambda really dispatches write() OUTSIDE the emit monitor. Only the + * ownership rejection is checked below; the call shape is not. + */ +class RuntimeHardeningTest { + + // ── connect-attempt ownership (ZycleClient.connect / stop) ──────────────────────────────────── + /** An attempt invalidated while connectGatt() blocks must not publish its handle. NOT the `stopped` + * re-check between connect()'s first guard and replace() — that gap is closed by a plain field read in + * ZycleClient, which no coordinator-level test can reach (see the class KDoc). */ + @Test fun anInvalidatedConnectAttemptCannotPublishItsHandle() { + val attempts = IdentityOwner() + var published: String? = null + val attempt = Any() + + attempts.replace(attempt) // connect() registers... + attempts.clear() // ...stop() invalidates it while connectGatt blocks + assertFalse(attempts.clearIfCurrent(attempt) { published = "handle" }) + assertNull(published) + } + + /** A stale attempt returning after a newer one is live must close only its own handle: clearing the + * shared `connecting` latch there let a scan result start a third connection behind the live one. */ + @Test fun aStaleConnectAttemptTouchesNothingBelongingToItsReplacement() { + val attempts = IdentityOwner() + val stale = Any() + val live = Any() + var published: Any? = null + + attempts.replace(stale) + attempts.replace(live) // stop() + a later start() promoted a new attempt + assertFalse(attempts.clearIfCurrent(stale) { published = stale }) + assertNull(published) + // the live attempt still owns the outcome, so IT can still publish + assertTrue(attempts.clearIfCurrent(live) { published = live }) + assertEquals(live, published) + } + + // ── emit ownership (BridgeService.toZycle / stopEmit) ───────────────────────────────────────── + /** The point of the emit token: a write admitted by the OLD mirror must never capture the NEW source. */ + @Test fun aStaleMirrorWriteCannotCaptureTheReplacementSource() { + val emitOwner = IdentityOwner() + val oldToken = Any() + emitOwner.replace(oldToken) + + var captured: String? = null + var mutated = false + assertTrue(emitOwner.runIfCurrent(oldToken) { mutated = true; captured = "old source" }) + assertEquals("old source", captured) + // The dispatch is deliberately outside the section, so clear() must not wait on it. (That the + // PRODUCTION lambda dispatches outside is a call-shape this test cannot check — KDoc gap 3.) + assertNull(emitOwner.clear().let { null }) + + emitOwner.clear() // stopEmit() + emitOwner.replace(Any()) // startEmit() with a replacement source + captured = null; mutated = false + assertFalse(emitOwner.runIfCurrent(oldToken) { mutated = true; captured = "new source" }) + assertNull(captured) // never reached the replacement + assertFalse(mutated) // and left ErgBias/lastControl alone + } + + + @Test fun gattOperationTimeoutResetsClosesAndReconnectsWithoutPumping() { + val effects = mutableListOf() + val coordinator = GattSessionCoordinator( + resetRuntime = { effects += "reset" }, + disconnect = { effects += "disconnect" }, + close = { effects += "close" }, + reconnect = { effects += "reconnect" }, + ) + val session = Any() + coordinator.replace(session) + + assertTrue(coordinator.timeoutIfCurrent(session, stillPending = { true })) + assertEquals(listOf("reset", "disconnect", "close", "reconnect"), effects) + assertNull(coordinator.current) + } + + @Test fun staleGattTimeoutCannotRetireReplacementSession() { + var retirements = 0 + val coordinator = GattSessionCoordinator( + resetRuntime = { retirements++ }, disconnect = {}, close = {}, reconnect = {}, + ) + val stale = Any() + val replacement = Any() + coordinator.replace(stale) + coordinator.replace(replacement) + + assertFalse(coordinator.timeoutIfCurrent(stale, stillPending = { true })) + assertEquals(0, retirements) + assertTrue(coordinator.runIfCurrent(replacement) {}) + } + + @Test fun advertisingFailureStopsRegistrationBeforeStateAndRetryWhileStaleFailureIsNoOp() { + val attempts = AdvertisingAttemptCoordinator() + val firstCallback = Any() + val retryCallback = Any() + val effects = mutableListOf() + attempts.begin(firstCallback) { effects += "unexpected" } + + assertTrue(attempts.failIfCurrent( + firstCallback, + stop = { effects += "stop" }, + markFailed = { effects += "failed" }, + retry = { effects += "retry" }, + )) + attempts.begin(retryCallback) { effects += "retire-before-begin" } + assertEquals(listOf("stop", "failed", "retry"), effects) + assertFalse(attempts.failIfCurrent( + firstCallback, + stop = { effects += "stale stop" }, + markFailed = { effects += "stale failure" }, + retry = { effects += "stale retry" }, + )) + assertEquals(listOf("stop", "failed", "retry"), effects) + } + + @Test fun sourceTeardownCannotOvertakeAnAdmittedValueMutation() { + val owner = IdentityOwner() + val source = Any() + owner.replace(source) + val mutationEntered = CountDownLatch(1) + val releaseMutation = CountDownLatch(1) + val teardownEntered = CountDownLatch(1) + var state = "initial" + + val callback = thread { + owner.runIfCurrent(source) { + mutationEntered.countDown() + releaseMutation.await() + state = "old value" + } + } + assertTrue(mutationEntered.await(1, TimeUnit.SECONDS)) + val teardown = thread { + owner.clearIfCurrent(source) { + teardownEntered.countDown() + state = "stopped" + } + } + + assertFalse(teardownEntered.await(1, TimeUnit.SECONDS)) + releaseMutation.countDown() + callback.join(1_000) + teardown.join(1_000) + assertEquals("stopped", state) + assertFalse(owner.runIfCurrent(source) { state = "late value" }) + assertEquals("stopped", state) + } + + @Test fun ergPersistenceInvokesLearnerAndFlushesPendingAfterConvergence() { + var learnerCalls = 0 + val persistence = ErgBiasPersistence(persistIntervalMs = 60_000L) { _, _ -> + learnerCalls++ + when (learnerCalls) { 1 -> 7; 2 -> 8; else -> null } + } + + assertEquals(7, persistence.onPower(rawWatts = 157, nowMs = 10L)) + assertEquals(null, persistence.onPower(rawWatts = 158, nowMs = 1_000L)) + assertEquals(null, persistence.onPower(rawWatts = 158, nowMs = 30_000L)) + assertEquals(8, persistence.onPower(rawWatts = 158, nowMs = 60_010L)) + assertEquals(4, learnerCalls) + } +} From d364b94c0b677d8945c31297a4dc23cf447b2a68 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:05:12 +0200 Subject: [PATCH 04/17] Two from the PR review: a tautological assertion and a "vnull" log header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emit-ownership test asserted `emitOwner.clear().let { null }` is null, which is true by construction and can never fail — with a comment claiming it checked that clear() does not wait on the dispatch. It checked nothing. Replaced with the real property: clear() hands back exactly the token it retired. The claim about the production call shape already lives in the class KDoc's gap list, which is the honest place for something no JVM test here can reach. versionName is nullable and getPackageInfo can throw, so an unresolved version was logging as "vnull" in the session header. --- app/build.gradle.kts | 4 ++-- app/manifest.json | 6 +++--- .../java/com/enderthor/trainerbridgeble/BridgeService.kt | 3 ++- .../com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt | 7 ++----- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 99e26ab..959769d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.enderthor.trainerbridgeble" minSdk = 26 targetSdk = 34 - versionCode = 20260722 - versionName = "0.9.2" + versionCode = 202609011 + versionName = "0.9.3" } buildTypes { diff --git a/app/manifest.json b/app/manifest.json index e374c4e..ebabd75 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -3,10 +3,10 @@ "packageName": "com.enderthor.trainerbridgeble", "latestApkUrl": "https://github.com/lockevod/TrainerBridgeBLE/releases/latest/download/trainerbridge.apk", "iconUrl": "https://github.com/lockevod/TrainerBridgeBLE/releases/latest/download/trainerbridge.png", - "latestVersion": "0.9.1", - "latestVersionCode": 20260721, + "latestVersion": "0.9.3", + "latestVersionCode": 202609011, "developer": "Enderthor", "description": "Bridges your indoor trainer to your apps (Bestcycling/Garmin/Karoo), correcting the reported power on the fly and inverting the ERG target, and re-exposes it as a virtual power/cadence/speed sensor to the Karoo.", - "releaseNotes": "Config changes (simulation, pairing, ANT, advertised name) now apply on save, which can briefly drop connected apps \u2014 avoid saving mid-ride. Scan lists only trainers and power meters (retrying unfiltered if that finds nothing) and offers the trainer already in use. With nothing paired the bridge now connects to the first FTMS trainer it finds, so pair yours if another may be in range. Simulation reports the same capabilities as the real trainer. The bridge only advertises while a trainer is connected.", + "releaseNotes": "Improvements.Config changes (simulation, pairing, ANT, advertised name) now apply on save, which can briefly drop connected apps \u2014 avoid saving mid-ride. Scan lists only trainers and power meters (retrying unfiltered if that finds nothing) and offers the trainer already in use. With nothing paired the bridge now connects to the first FTMS trainer it finds, so pair yours if another may be in range. Simulation reports the same capabilities as the real trainer. The bridge only advertises while a trainer is connected.", "tags": ["health","performance"] } diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index 3ab457b..12f18ba 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -228,7 +228,8 @@ class BridgeService : Service() { Config(this).let { c -> // via PackageManager rather than BuildConfig: AGP 8 does not generate that class unless // buildFeatures.buildConfig is turned on, and one log line does not justify a build change. - val ver = runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() + // versionName is nullable and the lookup can throw: both used to read as "vnull" in the header. + val ver = runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?" FileLog.event("=== session start v$ver " + "${android.os.Build.MODEL} api${android.os.Build.VERSION.SDK_INT} — " + "scale=+${c.scaleAdjustPercent}% offset=${c.offsetW}W floor=${c.invertFloorW}W " + diff --git a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt index b2cbbb7..a8667cb 100644 --- a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt +++ b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt @@ -72,11 +72,8 @@ class RuntimeHardeningTest { var mutated = false assertTrue(emitOwner.runIfCurrent(oldToken) { mutated = true; captured = "old source" }) assertEquals("old source", captured) - // The dispatch is deliberately outside the section, so clear() must not wait on it. (That the - // PRODUCTION lambda dispatches outside is a call-shape this test cannot check — KDoc gap 3.) - assertNull(emitOwner.clear().let { null }) - - emitOwner.clear() // stopEmit() + // stopEmit()'s barrier hands back exactly the token it retired. + assertEquals(oldToken, emitOwner.clear()) emitOwner.replace(Any()) // startEmit() with a replacement source captured = null; mutated = false assertFalse(emitOwner.runIfCurrent(oldToken) { mutated = true; captured = "new source" }) From cd80b94293d8c7a9d17d2c11541f5bf436a92494 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:33:00 +0200 Subject: [PATCH 05/17] Complete trainer writes before updating control state --- .../trainerbridgeble/BridgeService.kt | 45 +++--- .../trainerbridgeble/RuntimeHardening.kt | 17 +++ .../trainerbridgeble/ble/SimSource.kt | 10 +- .../trainerbridgeble/ble/TrainerSource.kt | 7 +- .../trainerbridgeble/ble/ZycleClient.kt | 132 +++++++++++++++--- .../trainerbridgeble/RuntimeHardeningTest.kt | 14 ++ 6 files changed, 181 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index 12f18ba..45238ee 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -134,32 +134,37 @@ class BridgeService : Service() { fun buttonDown() = nudgeResistance(-5) private fun nudgeResistance(delta: Int) { val sim = simSource - if (sim != null) { if (delta > 0) sim.buttonUp() else sim.buttonDown() } + if (sim != null) { + if (delta > 0) sim.buttonUp() else sim.buttonDown() + listener?.invoke() + } else { val target = ((lastResistance ?: 0) + delta).coerceIn(0, 200) // 0..200 per the Zycle's 0x2AD6 range // ponytail: 0x04+level% Set Target Resistance, no Request Control first — shares the FTMS control point with the app // Deliberately NOT routed through the mirror, so it arms no servo-step budget: this button is the // rider, exactly like the bike's own, and the level move it causes SHOULD reach the app. - // optimistic, and only if the write was at least QUEUED (no link / unknown char → don't move the - // tile). A stack refusal after queueing still shows briefly; the trainer's own IBD corrects it. - val bytes = byteArrayOf(0x04, target.toByte()) - if (client?.write(com.enderthor.trainerbridgeble.ble.GattUuids.FTMS_CONTROL_POINT, bytes, true) == true) { - // The mirror's toZycle lambda is where ErgBias sees control ops, and this path deliberately - // bypasses it — so tell the learner directly. 0x04 takes the trainer OUT of ERG, and without - // this its commandedRaw stays pinned to the app's last target: every later reading is then - // measured against a command no longer in force, saturating the bias and PERSISTING it. - // ponytail: `write() == true` means QUEUED, not accepted by the stack, so a 0x04 that dies in - // the queue still retires the command here. That only makes the learner stop learning until - // the next 0x05 — it cannot poison the bias, which is what this fix is for. Closing it needs - // the write path to report terminal completion back (the same plumbing an FTMS failure - // indication would need); do both together or neither. - ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) - lastResistance = target - lastControl = getString(R.string.control_resistance_target, target) - FileLog.event("UI button → resistance target=$target") - } else FileLog.event("UI button → resistance target=$target NOT DISPATCHED") + val bytes = encodeTargetResistance(target) + val source = client + if (source == null) { + FileLog.event("UI button → resistance target=$target FAILED") + listener?.invoke() + } else source.write( + com.enderthor.trainerbridgeble.ble.GattUuids.FTMS_CONTROL_POINT, + bytes, + true, + ) { success -> + handler.post { + if (success) { + // This path bypasses the mirror, so retire ERG learning only after the trainer write. + ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) + lastResistance = target + lastControl = getString(R.string.control_resistance_target, target) + FileLog.event("UI button → resistance target=$target") + } else FileLog.event("UI button → resistance target=$target FAILED") + listener?.invoke() + } + } } - listener?.invoke() } /** The BLE stack does not survive a Bluetooth off/on (or a crash of com.android.bluetooth): the GATT diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt index 9375309..0590785 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt @@ -1,5 +1,22 @@ package com.enderthor.trainerbridgeble +internal class TrainerWriteTicket( + val sequence: Long, + private val onComplete: (Boolean) -> Unit, +) { + private val completed = java.util.concurrent.atomic.AtomicBoolean(false) + fun complete(success: Boolean): Boolean { + if (!completed.compareAndSet(false, true)) return false + onComplete(success) + return true + } +} + +internal fun encodeTargetResistance(target: Int): ByteArray { + val value = target.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()) + return byteArrayOf(0x04, (value and 0xFF).toByte(), ((value ushr 8) and 0xFF).toByte()) +} + internal class IdentityOwner { @Volatile private var value: T? = null diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/SimSource.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/SimSource.kt index 010007b..fc0bb6f 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/SimSource.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/SimSource.kt @@ -108,9 +108,14 @@ class SimSource( override fun stop() { handler.removeCallbacks(ticker); onState(false); FileLog.event("SIM trainer stopped") } - override fun write(charUuid: UUID, bytes: ByteArray, withResponse: Boolean): Boolean { + override fun write( + charUuid: UUID, + bytes: ByteArray, + withResponse: Boolean, + onComplete: (Boolean) -> Unit, + ): Boolean { FileLog.event("SIM write ${bytes.joinToString("") { "%02X".format(it) }}") - if (charUuid != CONTROL || bytes.isEmpty()) return true + if (charUuid != CONTROL || bytes.isEmpty()) { onComplete(true); return true } val op = bytes[0].toInt() and 0xFF when (op) { 0x05 -> if (bytes.size >= 3) ergTarget = (bytes[1].toInt() and 0xFF) or ((bytes[2].toInt() and 0xFF) shl 8) // Set Target Power @@ -124,6 +129,7 @@ class SimSource( // down 0x13): answer "op code not supported" (0x02) for those instead of a success an app would // then wait on — a slope-mode app would otherwise watch power ignore the grade forever. val result: Byte = if (op in IMPLEMENTED_OPS) 0x01 else 0x02 + onComplete(true) onValue(CONTROL, byteArrayOf(0x80.toByte(), (op and 0xFF).toByte(), result)) return true } diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/TrainerSource.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/TrainerSource.kt index 4d6927b..822aeca 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/TrainerSource.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/TrainerSource.kt @@ -9,5 +9,10 @@ interface TrainerSource { fun stop() /** @return false if the write could not be dispatched (no link, unknown characteristic) — the mirror * must NOT then answer the app with success. */ - fun write(charUuid: UUID, bytes: ByteArray, withResponse: Boolean): Boolean + fun write( + charUuid: UUID, + bytes: ByteArray, + withResponse: Boolean, + onComplete: (Boolean) -> Unit = {}, + ): Boolean } diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt index 39e593b..bbcf8c7 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt @@ -18,9 +18,12 @@ import android.util.Log import com.enderthor.trainerbridgeble.FileLog import com.enderthor.trainerbridgeble.GattSessionCoordinator import com.enderthor.trainerbridgeble.IdentityOwner +import com.enderthor.trainerbridgeble.TrainerWriteTicket import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong /** * BLE central to the trainer (Zycle). Scans filtered by FTMS/address, connects, discovers the FULL GATT, subscribes @@ -53,6 +56,7 @@ class ZycleClient( resetRuntime = { lastMessageMs = 0L onState(false) + completePendingWrites(false) opQueue.clear(); opBusy.set(false); syncOwed.set(false); burstEnqueued = false connecting.set(false); inFlightWrite = null }, @@ -97,9 +101,18 @@ class ZycleClient( } private val opToken = java.util.concurrent.atomic.AtomicInteger(0) // guards the per-op watchdog vs a stale timeout - private class WriteReq(val uuid: UUID, val bytes: ByteArray, val withResponse: Boolean, val retriesLeft: Int, val seq: Int) + private data class WriteReq( + val session: BluetoothGatt, + val characteristic: BluetoothGattCharacteristic, + val uuid: UUID, + val bytes: ByteArray, + val withResponse: Boolean, + val retriesLeft: Int, + val ticket: TrainerWriteTicket, + ) @Volatile private var inFlightWrite: WriteReq? = null // the write currently on the wire, for retry on failure - private val writeSeq = java.util.concurrent.atomic.AtomicInteger(0) // bumps per write; a retry is dropped if superseded + private val pendingWrites = ConcurrentHashMap.newKeySet() + private val writeSeq = AtomicLong(0) // bumps per write; a retry is dropped if superseded private val cccd: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") @@ -118,6 +131,7 @@ class ZycleClient( // either the attempt published first (and the read closes it) or it never publishes at all. connectAttempts.clear() connecting.set(false) + completePendingWrites(false) inFlightWrite = null stopScan() handler.removeCallbacksAndMessages(null) // heartbeat, rescan, connect/op watchdogs, write retries @@ -155,29 +169,101 @@ class ZycleClient( } /** Forward a write to the trainer's characteristic [charUuid] (control relay). Queued. */ - override fun write(charUuid: UUID, bytes: ByteArray, withResponse: Boolean): Boolean = - writeInternal(charUuid, bytes, withResponse, CONTROL_WRITE_RETRIES) - - private fun writeInternal(charUuid: UUID, bytes: ByteArray, withResponse: Boolean, retriesLeft: Int): Boolean { - val g = gatt ?: run { FileLog.event("Zycle write ${shortUuid(charUuid)} DROPPED — no trainer link"); return false } + override fun write( + charUuid: UUID, + bytes: ByteArray, + withResponse: Boolean, + onComplete: (Boolean) -> Unit, + ): Boolean { + val ticket = TrainerWriteTicket(writeSeq.incrementAndGet(), onComplete) + if (stopped) { + FileLog.event("Zycle write ${shortUuid(charUuid)} DROPPED — client stopped") + ticket.complete(false) + return false + } + val g = gatt ?: run { + FileLog.event("Zycle write ${shortUuid(charUuid)} DROPPED — no trainer link") + ticket.complete(false) + return false + } // g.services is repopulated by discovery while this runs on the GATT-server binder thread val ch = runCatching { g.services.firstNotNullOfOrNull { s -> s.getCharacteristic(charUuid) } }.getOrNull() - ?: run { FileLog.event("Zycle write ${shortUuid(charUuid)} DROPPED — characteristic not found"); return false } + ?: run { + FileLog.event("Zycle write ${shortUuid(charUuid)} DROPPED — characteristic not found") + ticket.complete(false) + return false + } FileLog.event("Zycle write ${shortUuid(charUuid)} = ${FileLog.hex(bytes)}") - val seq = writeSeq.incrementAndGet() - enqueue { + return enqueueWrite(WriteReq(g, ch, charUuid, bytes, withResponse, CONTROL_WRITE_RETRIES, ticket)) + } + + private fun enqueueWrite(request: WriteReq): Boolean { + var queued = false + gattSessions.runIfCurrent(request.session) { + pendingWrites.add(request.ticket) + opQueue.add { executeWrite(request) } + queued = true + } + if (!queued) { + completeWrite(request, false) + return false + } + pump() + return true + } + + private fun executeWrite(request: WriteReq) { + if (stopped || gattSessions.current !== request.session) { + completeWrite(request, false) + opDone() + return + } + runCatching { @Suppress("DEPRECATION") run { - inFlightWrite = WriteReq(charUuid, bytes, withResponse, retriesLeft, seq) - ch.writeType = if (withResponse) BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + inFlightWrite = request + request.characteristic.writeType = if (request.withResponse) BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT else BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - ch.value = bytes - if (g.writeCharacteristic(ch) != true) { - FileLog.event("Zycle write ${shortUuid(charUuid)} REFUSED by stack"); inFlightWrite = null; opDone() + request.characteristic.value = request.bytes + if (request.session.writeCharacteristic(request.characteristic) != true) { + FileLog.event("Zycle write ${shortUuid(request.uuid)} REFUSED by stack" + + if (canRetry(request)) " — retry ${request.retriesLeft}" else "") + inFlightWrite = null + if (canRetry(request)) scheduleWriteRetry(request) else completeWrite(request, false) + opDone() } } + }.onFailure { + FileLog.event("Zycle write ${shortUuid(request.uuid)} FAILED before dispatch") + inFlightWrite = null + completeWrite(request, false) + opDone() + } + } + + private fun canRetry(request: WriteReq): Boolean = + request.retriesLeft > 0 && GattUuids.carriesControl(request.uuid) + + private fun scheduleWriteRetry(request: WriteReq) { + handler.postDelayed({ + if (!stopped && writeSeq.get() == request.ticket.sequence && gattSessions.current === request.session) { + enqueueWrite(request.copy(retriesLeft = request.retriesLeft - 1)) + } else completeWrite(request, false) + }, CONTROL_RETRY_DELAY_MS) + } + + private fun completeWrite(request: WriteReq, success: Boolean) { + pendingWrites.remove(request.ticket) + runCatching { request.ticket.complete(success) } + .onFailure { FileLog.event("Zycle write ${shortUuid(request.uuid)} completion callback FAILED") } + } + + private fun completePendingWrites(success: Boolean) { + pendingWrites.forEach { ticket -> + pendingWrites.remove(ticket) + runCatching { ticket.complete(success) } + .onFailure { FileLog.event("Zycle write completion callback FAILED") } } - return true } // ── scan ──────────────────────────────────────────────────────────────────────────────────────── @@ -340,6 +426,7 @@ class ZycleClient( runCatching { g.close() } if (gattSessions.clearIfCurrent(g) { onState(false) + completePendingWrites(false) opQueue.clear(); opBusy.set(false); syncOwed.set(false); burstEnqueued = false connecting.set(false); inFlightWrite = null; lastMessageMs = 0L }) scheduleReconnect() @@ -418,13 +505,16 @@ class ZycleClient( lastMessageMs = android.os.SystemClock.elapsedRealtime() // Attribute a status only to the write this callback names. A late callback must not retry // whichever newer ERG target happens to occupy the slot. - val w = inFlightWrite?.takeIf { it.uuid == ch.uuid } + val w = inFlightWrite?.takeIf { it.session === g && it.uuid == ch.uuid } if (w != null) inFlightWrite = null - if (status != BluetoothGatt.GATT_SUCCESS) { - // Retry only a still-current control write; never resurrect a superseded target. - val retry = w != null && w.retriesLeft > 0 && GattUuids.carriesControl(w.uuid) && w.seq == writeSeq.get() && !stopped + if (w != null && status == BluetoothGatt.GATT_SUCCESS) { + completeWrite(w, true) + } else if (status != BluetoothGatt.GATT_SUCCESS) { + // Decide supersession when the delayed retry runs. A newer command must not relabel a + // write the Android stack already accepted while this callback was outstanding. + val retry = w != null && canRetry(w) FileLog.event("Zycle write ${shortUuid(ch.uuid)} status=$status" + if (retry) " — retry ${w!!.retriesLeft}" else "") - if (retry) handler.postDelayed({ writeInternal(w!!.uuid, w.bytes, w.withResponse, w.retriesLeft - 1) }, CONTROL_RETRY_DELAY_MS) + if (retry) scheduleWriteRetry(w!!) else if (w != null) completeWrite(w, false) } opDone() } diff --git a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt index a8667cb..6f41868 100644 --- a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt +++ b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt @@ -3,6 +3,7 @@ package com.enderthor.trainerbridgeble import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.concurrent.thread +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -29,6 +30,19 @@ import org.junit.Test */ class RuntimeHardeningTest { + @Test fun trainerWriteCompletesExactlyOnce() { + val results = mutableListOf() + val ticket = TrainerWriteTicket(7L) { results += it } + assertTrue(ticket.complete(true)) + assertFalse(ticket.complete(false)) + assertEquals(listOf(true), results) + } + + @Test fun targetResistanceUsesSigned16LittleEndian() { + assertArrayEquals(byteArrayOf(0x04, 0x24, 0x00), encodeTargetResistance(36)) + assertArrayEquals(byteArrayOf(0x04, 0x10, 0x00), encodeTargetResistance(16)) + } + // ── connect-attempt ownership (ZycleClient.connect / stop) ──────────────────────────────────── /** An attempt invalidated while connectGatt() blocks must not publish its handle. NOT the `stopped` * re-check between connect()'s first guard and replace() — that gap is closed by a plain field read in From ce4ea8761b2f7afe2206631b00d7b0e9a95fc063 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:41:48 +0200 Subject: [PATCH 06/17] Close trainer write teardown races --- .../com/enderthor/trainerbridgeble/BridgeService.kt | 1 + .../com/enderthor/trainerbridgeble/ble/ZycleClient.kt | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index 45238ee..a5305f0 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -154,6 +154,7 @@ class BridgeService : Service() { true, ) { success -> handler.post { + if (client !== source) return@post if (success) { // This path bypasses the mirror, so retire ERG learning only after the trainer write. ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt index bbcf8c7..783d3b2 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt @@ -126,18 +126,20 @@ class ZycleClient( override fun stop() { stopped = true - // BEFORE anything reads `gatt`: an in-flight connectGatt() then fails its ownership check and closes - // its own handle. Both paths cross this monitor, and this clear precedes the `gatt` read below, so + // BEFORE clearing the GATT session: an in-flight connectGatt() then fails its ownership check and closes + // its own handle. Both paths cross this monitor, and this clear precedes the session clear below, so // either the attempt published first (and the read closes it) or it never publishes at all. connectAttempts.clear() connecting.set(false) + // Session ownership is the admission barrier: enqueueWrite either registers its ticket before this + // clear returns, or observes no current session and completes false itself. + val session = gattSessions.clear() completePendingWrites(false) inFlightWrite = null stopScan() handler.removeCallbacksAndMessages(null) // heartbeat, rescan, connect/op watchdogs, write retries opQueue.clear(); opBusy.set(false) - gatt?.let { runCatching { it.disconnect() }; runCatching { it.close() } } - gatt = null + session?.let { runCatching { it.disconnect() }; runCatching { it.close() } } } /** ANT-learned: a GATT link can stay "connected" while notifications silently stop (no disconnect From 517dfc19f3363c4038aa524de73c1205c2e3a5a6 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:53:01 +0200 Subject: [PATCH 07/17] Give FTMS control to one connected client --- .../trainerbridgeble/BridgeService.kt | 36 ++++--- .../trainerbridgeble/RuntimeHardening.kt | 48 ++++++++++ .../trainerbridgeble/ble/MirrorServer.kt | 93 +++++++++++++------ .../trainerbridgeble/RuntimeHardeningTest.kt | 85 +++++++++++++++++ 4 files changed, 218 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index a5305f0..adb0606 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -407,24 +407,30 @@ class BridgeService : Service() { context = this, advertisedName = config.advertisedName, correction = { config.correction() }, - toZycle = { uuid, bytes, withResponse -> - // Mutate and CAPTURE the source under the owner; dispatch outside it (see [emitOwner]). A - // callback from a stopped mirror captures nothing and relays nothing; one admitted before the - // clear still targets the source it was admitted for, never the replacement. + toZycle = { uuid, bytes, withResponse, onComplete -> + // CAPTURE the source under the owner; dispatch outside it (see [emitOwner]). A callback from + // a stopped mirror captures nothing and relays nothing; one admitted before the clear still + // targets the source it was admitted for, never the replacement. var target: com.enderthor.trainerbridgeble.ble.TrainerSource? = null - var moved = false - emitOwner.runIfCurrent(emitToken) { - if (com.enderthor.trainerbridgeble.ble.GattUuids.carriesControl(uuid)) { - // `bytes` is already inverse-corrected: exactly the raw watts the trainer is told to hold, - // which is what the measured power has to be compared against. - ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) - lastControl = describeControl(bytes); moved = true + emitOwner.runIfCurrent(emitToken) { target = client } + val source = target + if (source == null) { + onComplete(false) + false + } else source.write(uuid, bytes, withResponse) { success -> + var moved = false + if (success) emitOwner.runIfCurrent(emitToken) { + if (com.enderthor.trainerbridgeble.ble.GattUuids.carriesControl(uuid)) { + // `bytes` is already inverse-corrected: exactly the raw watts the trainer is told + // to hold, which is what measured power has to be compared against. + ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) + lastControl = describeControl(bytes) + moved = true + } } - target = client + onComplete(success) + if (moved) listener?.invoke() } - val relayed = target?.write(uuid, bytes, withResponse) ?: false // false → the mirror answers failure - if (moved) listener?.invoke() // an arbitrary UI callback has no business inside the monitor - relayed }, onStatus = { s -> status = s; listener?.invoke() }, onAdvState = { ok -> bleAdvOk = ok; listener?.invoke() }, diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt index 0590785..6a8ee0f 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt @@ -17,6 +17,54 @@ internal fun encodeTargetResistance(target: Int): ByteArray { return byteArrayOf(0x04, (value and 0xFF).toByte(), ((value ushr 8) and 0xFF).toByte()) } +internal class FtmsControlCoordinator { + data class Procedure(val client: String, val opcode: Int) + + sealed interface Admission { + data class Admitted(val procedure: Procedure) : Admission + data class Rejected(val result: Int) : Admission + } + + private var owner: String? = null + private var pending: Procedure? = null + + @Synchronized fun admit(client: String, opcode: Int): Admission { + if (pending != null) return Admission.Rejected(OPERATION_FAILED) + if (if (opcode == REQUEST_CONTROL) owner != null && owner != client else owner != client) + return Admission.Rejected(CONTROL_NOT_PERMITTED) + return Procedure(client, opcode).let { pending = it; Admission.Admitted(it) } + } + + @Synchronized fun transportFailed(client: String, opcode: Int): String? { + if (pending != Procedure(client, opcode)) return null + pending = null + return client + } + + @Synchronized fun response(opcode: Int, result: Int): String? { + val procedure = pending?.takeIf { it.opcode == opcode } ?: return null + pending = null + if (opcode == REQUEST_CONTROL && result == SUCCESS) owner = procedure.client + return procedure.client + } + + @Synchronized fun disconnect(client: String) { + if (owner == client) owner = null + if (pending?.client == client) pending = null + } + + @Synchronized fun trainerDropped(): String? = owner.also { owner = null; pending = null } + + @Synchronized fun clear() { owner = null; pending = null } + + companion object { + const val SUCCESS = 0x01 + const val OPERATION_FAILED = 0x04 + const val CONTROL_NOT_PERMITTED = 0x05 + private const val REQUEST_CONTROL = 0x00 + } +} + internal class IdentityOwner { @Volatile private var value: T? = null diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt index 4d7d4ff..db7eabb 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt @@ -22,6 +22,7 @@ import android.os.SystemClock import android.util.Log import com.enderthor.trainerbridgeble.AdvertisingAttemptCoordinator import com.enderthor.trainerbridgeble.FileLog +import com.enderthor.trainerbridgeble.FtmsControlCoordinator import com.enderthor.trainerbridgeble.R import com.enderthor.trainerbridgeble.correction.PowerCorrection import java.util.ArrayDeque @@ -35,14 +36,14 @@ import java.util.concurrent.ConcurrentHashMap * ERG target inverse-corrected on control writes). Serves multiple centrals at once. * * @param correction supplies the LIVE correction (config may change mid-session). - * @param toZycle forwards an app write to the trainer's matching characteristic. + * @param toZycle forwards an app write to the trainer's matching characteristic and reports its terminal result. */ @SuppressLint("MissingPermission") class MirrorServer( private val context: Context, private val advertisedName: String, private val correction: () -> PowerCorrection, - private val toZycle: (charUuid: UUID, bytes: ByteArray, withResponse: Boolean) -> Boolean, + private val toZycle: (charUuid: UUID, bytes: ByteArray, withResponse: Boolean, onComplete: (Boolean) -> Unit) -> Boolean, private val onStatus: (String) -> Unit = {}, /** Health report to the UI: true once we're actually advertising; false if the server/advertising fails. */ private val onAdvState: (Boolean) -> Unit = {}, @@ -61,6 +62,7 @@ class MirrorServer( private val cache = ConcurrentHashMap() // last value (power corrected) private val subscribers = ConcurrentHashMap>() // char uuid → subscribed client addrs private val clients = ConcurrentHashMap() // connected centrals + private val ftmsControl = FtmsControlCoordinator() // touched from the GATT server binder thread, the client's binder thread and main — a plain ArrayDeque // can throw mid-poll when stop() clears it, and an exception on a binder callback kills the process private val pendingServices = java.util.concurrent.ConcurrentLinkedDeque() @@ -107,7 +109,11 @@ class MirrorServer( * starts advertising again the moment it drops — so advertising with no trainer behind us puts two * identical devices in the air and lets an app bind to a bridge that has no data to give it. */ fun setTrainerLinked(linked: Boolean) { - if (trainerLinked == linked) return + val controller = if (linked) null else ftmsControl.trainerDropped() + if (trainerLinked == linked) { + controller?.let { address -> handler.post { clients[address]?.let { server?.cancelConnection(it) } } } + return + } trainerLinked = linked // Losing the trainer invalidates the level anchor (see [reanchorLevel]) AND any servo step we were // still owed: the write that bought it may never have reached the trainer, and if it did, the step it @@ -118,7 +124,12 @@ class MirrorServer( if (!linked) { reanchorLevel = true; servoStepOwed = false; lastControlWriteMs = 0L } else { advRetries = 0; advRetryMs = ADV_RETRY_MS } FileLog.event("mirror trainer link=$linked -> ${if (linked) "advertise" else "stop advertising"}") - handler.post { if (linked) startAdvertising() else stopAdvertising() } + handler.post { + if (linked) startAdvertising() else { + stopAdvertising() + controller?.let { address -> clients[address]?.let { server?.cancelConnection(it) } } + } + } } /** Adopt the trainer's own advertised service UUIDs + manufacturer data (captured by the client) so we @@ -317,6 +328,7 @@ class MirrorServer( // Stop the advertiser UNCONDITIONALLY: the `advertising` flag is transiently false mid-restart, so // trusting it here can leave the phone broadcasting with a closed GATT server. stopped = true; pendingProfile = null; servicesReady = false + ftmsControl.clear() stopAdvertising() handler.removeCallbacksAndMessages(null) // pending adv starts / service retries must not outlive us runCatching { server?.close() } @@ -358,6 +370,13 @@ class MirrorServer( /** A value arrived from the trainer: correct power, cache, and notify every subscribed client. */ fun onZycleValue(charUuid: UUID, value: ByteArray) { + if (charUuid == GattUuids.FTMS_CONTROL_POINT && value.size >= 3 && + value[0].toInt() and 0xFF == 0x80) { + val opcode = value[1].toInt() and 0xFF + val result = value[2].toInt() and 0xFF + ftmsControl.response(opcode, result)?.let { notifyControlResult(it, value) } + return + } val out = when { charUuid == GattUuids.INDOOR_BIKE_DATA -> PowerRewrite.correctIndoorBikeData(value, correction()) charUuid == GattUuids.CYCLING_POWER_MEASUREMENT -> PowerRewrite.correctCyclingPower(value, correction()) @@ -429,6 +448,20 @@ class MirrorServer( } } + private fun notifyControlResult(client: String, value: ByteArray) { + val uuid = GattUuids.FTMS_CONTROL_POINT + val ch = chars[uuid] ?: return + val subs = subscribers[uuid] ?: return + if (!synchronized(subs) { subs.contains(client) }) return + handler.post { + val srv = server ?: return@post + val dev = clients[client] ?: return@post + val currentSubs = subscribers[uuid] ?: return@post + if (!synchronized(currentSubs) { currentSubs.contains(client) }) return@post + notify(srv, dev, ch, value, ch.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) + } + } + private val serverCallback = object : BluetoothGattServerCallback() { override fun onServiceAdded(status: Int, service: BluetoothGattService?) { // A callback still in flight when stop() ran would otherwise find pendingServices empty, set @@ -464,6 +497,7 @@ class MirrorServer( // central (e.g. the Garmin) can still discover us. handler.post { restartAdvertising() } } else { + ftmsControl.disconnect(device.address) clients.remove(device.address); subscribers.values.forEach { it.remove(device.address) } onStatus(context.getString(R.string.status_app_disconnected, clients.size)) FileLog.event("app disconnected ${device.address} status=$status (${clients.size} left)") @@ -499,10 +533,6 @@ class MirrorServer( (if (preparedWrite) " PREPARED off=$offset" else "") + (if (!responseNeeded) " noResp" else "") if (uuid != null && value != null) { val out = if (GattUuids.carriesControl(uuid)) PowerRewrite.inverseTargetPower(value, correction()) else value - // Any control op can make the servo move the level; from there on that move is ours, not the - // rider's. Read the clock HERE, before the relay, so the window still covers the trip to the - // trainer — but only commit it once we know the write was dispatched (below). - val stampAt = SystemClock.elapsedRealtime() // what the client asked for, unless the trainer's characteristic can't take a Write Command val withResponse = responseNeeded || (ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE == 0) @@ -510,33 +540,38 @@ class MirrorServer( // ponytail: a prepared (long) write is relayed fragment-by-fragment rather than buffered // until onExecuteWrite. No FTMS/CPS characteristic exceeds one ATT payload, so this only // matters if some app starts using long writes — the log line above says when it happens. - relayed = toZycle(uuid, out, withResponse) // relay to the trainer - // Only a write that was actually dispatched buys the servo a step. A dropped one (no link, - // characteristic not found — the window right after a reconnect, before discovery repopulates - // g.services) moves no level, and an armed budget would silently eat the rider's next real - // button press within LEVEL_SETTLE_MS. `relayed` still only means QUEUED, so a write that - // fails later on the wire arms it anyway — no worse than before, and one less lost press. - if (relayed && GattUuids.carriesControl(uuid)) { lastControlWriteMs = stampAt; servoStepOwed = true } + if (!preparedWrite && uuid == GattUuids.FTMS_CONTROL_POINT && device != null && value.isNotEmpty()) { + val opcode = value[0].toInt() and 0xFF + when (val admission = ftmsControl.admit(device.address, opcode)) { + is FtmsControlCoordinator.Admission.Rejected -> + notifyControlResult(device.address, byteArrayOf(0x80.toByte(), opcode.toByte(), admission.result.toByte())) + is FtmsControlCoordinator.Admission.Admitted -> { + val procedure = admission.procedure + relayed = toZycle(uuid, out, withResponse) { success -> + if (success) { + lastControlWriteMs = SystemClock.elapsedRealtime() + servoStepOwed = true + } else ftmsControl.transportFailed(procedure.client, procedure.opcode)?.let { + notifyControlResult(it, byteArrayOf( + 0x80.toByte(), procedure.opcode.toByte(), + FtmsControlCoordinator.OPERATION_FAILED.toByte(), + )) + } + } + } + } + } else relayed = toZycle(uuid, out, withResponse) { success -> + if (success && GattUuids.carriesControl(uuid)) { + lastControlWriteMs = SystemClock.elapsedRealtime() + servoStepOwed = true + } + } if (!relayed) FileLog.event("app write $tag NOT RELAYED — answering failure") } else FileLog.event("app write $tag = ") // ATT response = "received", always. FTMS puts the OUTCOME in the control point indication. if (responseNeeded) runCatching { server?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) } - // ...and if we could not hand it to the trainer, say so the way a trainer would: Response Code - // 0x80, , 0x04 Operation Failed. Without this the app waits forever for an indication. - if (!relayed && uuid != null && device != null && value != null && value.isNotEmpty() && - !preparedWrite && GattUuids.carriesControl(uuid)) { - // ONLY to the client that wrote, and NOT into the read cache: the response belongs to one - // FTMS procedure, and fanning it out tells the other app its own request failed. - val resp = byteArrayOf(0x80.toByte(), value[0], 0x04) - val cp = ch - // only if this client actually enabled the control point — never indicate unsolicited - if (subscribers[uuid]?.contains(device.address) == true) handler.post { - val srv = server ?: return@post - notify(srv, device, cp, resp, cp.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) - } - } } override fun onDescriptorWriteRequest(device: BluetoothDevice?, requestId: Int, descriptor: BluetoothGattDescriptor?, diff --git a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt index 6f41868..c7fd841 100644 --- a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt +++ b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt @@ -30,6 +30,91 @@ import org.junit.Test */ class RuntimeHardeningTest { + @Test fun firstSuccessfulRequestControlOwnsFtms() { + val coordinator = FtmsControlCoordinator() + + assertEquals( + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x00)), + coordinator.admit("A", 0x00), + ) + assertEquals("A", coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)) + assertEquals( + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x05)), + coordinator.admit("A", 0x05), + ) + } + + @Test fun secondClientCannotControlOrStealOwnership() { + val coordinator = FtmsControlCoordinator() + coordinator.admit("A", 0x00) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + + assertEquals( + FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.CONTROL_NOT_PERMITTED), + coordinator.admit("B", 0x05), + ) + assertEquals( + FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.CONTROL_NOT_PERMITTED), + coordinator.admit("B", 0x00), + ) + } + + @Test fun onlyOneProcedureCanBePending() { + val coordinator = FtmsControlCoordinator() + + coordinator.admit("A", 0x00) + assertEquals( + FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), + coordinator.admit("A", 0x00), + ) + assertNull(coordinator.transportFailed("B", 0x00)) + assertEquals("A", coordinator.transportFailed("A", 0x00)) + assertEquals( + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x00)), + coordinator.admit("A", 0x00), + ) + } + + @Test fun responseRoutesOnlyToMatchingOrigin() { + val coordinator = FtmsControlCoordinator() + coordinator.admit("A", 0x00) + + assertNull(coordinator.response(0x05, FtmsControlCoordinator.SUCCESS)) + assertEquals("A", coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)) + assertEquals( + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x05)), + coordinator.admit("A", 0x05), + ) + } + + @Test fun failedRequestControlDoesNotAcquireOwnership() { + val coordinator = FtmsControlCoordinator() + coordinator.admit("A", 0x00) + + assertEquals("A", coordinator.response(0x00, FtmsControlCoordinator.OPERATION_FAILED)) + assertEquals( + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("B", 0x00)), + coordinator.admit("B", 0x00), + ) + } + + @Test fun ownerDisconnectAndTrainerDropClearOwnership() { + val coordinator = FtmsControlCoordinator() + coordinator.admit("A", 0x00) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + + coordinator.admit("A", 0x05) + coordinator.disconnect("A") + coordinator.admit("B", 0x00) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + coordinator.admit("B", 0x05) + assertEquals("B", coordinator.trainerDropped()) + assertEquals( + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x00)), + coordinator.admit("A", 0x00), + ) + } + @Test fun trainerWriteCompletesExactlyOnce() { val results = mutableListOf() val ticket = TrainerWriteTicket(7L) { results += it } From 2de4e41ed4f03eb07e2ba40f69b4d81e87960aeb Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:06:18 +0200 Subject: [PATCH 08/17] Close FTMS ownership race gaps --- .../trainerbridgeble/BridgeService.kt | 20 ++- .../trainerbridgeble/RuntimeHardening.kt | 37 ++++-- .../trainerbridgeble/ble/MirrorServer.kt | 56 +++++++-- .../trainerbridgeble/RuntimeHardeningTest.kt | 114 +++++++++++++----- 4 files changed, 172 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index adb0606..91c1de6 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -140,12 +140,16 @@ class BridgeService : Service() { } else { val target = ((lastResistance ?: 0) + delta).coerceIn(0, 200) // 0..200 per the Zycle's 0x2AD6 range - // ponytail: 0x04+level% Set Target Resistance, no Request Control first — shares the FTMS control point with the app - // Deliberately NOT routed through the mirror, so it arms no servo-step budget: this button is the - // rider, exactly like the bike's own, and the level move it causes SHOULD reach the app. val bytes = encodeTargetResistance(target) + val localMirror = mirror + if (localMirror != null && !localMirror.admitLocalControl(0x04)) { + FileLog.event("UI button → resistance target=$target BLOCKED — FTMS control busy") + listener?.invoke() + return + } val source = client if (source == null) { + localMirror?.localControlTransportFailed(0x04) FileLog.event("UI button → resistance target=$target FAILED") listener?.invoke() } else source.write( @@ -153,6 +157,7 @@ class BridgeService : Service() { bytes, true, ) { success -> + if (!success) localMirror?.localControlTransportFailed(0x04) handler.post { if (client !== source) return@post if (success) { @@ -432,6 +437,15 @@ class BridgeService : Service() { if (moved) listener?.invoke() } }, + onTrainerRecycle = { + var current = false + emitOwner.runIfCurrent(emitToken) { current = true } + if (current && receiving) { + FileLog.event("FTMS origin disconnected mid-procedure — recycling trainer link") + stopReceive() + maybeStartReceive() + } + }, onStatus = { s -> status = s; listener?.invoke() }, onAdvState = { ok -> bleAdvOk = ok; listener?.invoke() }, isTrainer = { addr -> diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt index 6a8ee0f..86aa5de 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt @@ -18,44 +18,59 @@ internal fun encodeTargetResistance(target: Int): ByteArray { } internal class FtmsControlCoordinator { - data class Procedure(val client: String, val opcode: Int) + data class Client(val address: String, val generation: Long) + data class Procedure(val client: Client?, val opcode: Int) sealed interface Admission { data class Admitted(val procedure: Procedure) : Admission data class Rejected(val result: Int) : Admission } - private var owner: String? = null + private var owner: Client? = null private var pending: Procedure? = null + private var invalidSession = false - @Synchronized fun admit(client: String, opcode: Int): Admission { - if (pending != null) return Admission.Rejected(OPERATION_FAILED) + @Synchronized fun admit(client: Client, opcode: Int): Admission { + if (invalidSession || pending != null) return Admission.Rejected(OPERATION_FAILED) if (if (opcode == REQUEST_CONTROL) owner != null && owner != client else owner != client) return Admission.Rejected(CONTROL_NOT_PERMITTED) return Procedure(client, opcode).let { pending = it; Admission.Admitted(it) } } - @Synchronized fun transportFailed(client: String, opcode: Int): String? { + @Synchronized fun admitLocal(opcode: Int): Boolean { + if (invalidSession || owner != null || pending != null) return false + pending = Procedure(null, opcode) + return true + } + + @Synchronized fun transportFailed(client: Client?, opcode: Int): Client? { if (pending != Procedure(client, opcode)) return null pending = null return client } - @Synchronized fun response(opcode: Int, result: Int): String? { + @Synchronized fun response(opcode: Int, result: Int): Client? { + if (invalidSession) return null val procedure = pending?.takeIf { it.opcode == opcode } ?: return null pending = null - if (opcode == REQUEST_CONTROL && result == SUCCESS) owner = procedure.client + if (procedure.client != null && opcode == REQUEST_CONTROL && result == SUCCESS) owner = procedure.client return procedure.client } - @Synchronized fun disconnect(client: String) { + @Synchronized fun disconnect(client: Client): Boolean { + val lostPending = pending?.client == client if (owner == client) owner = null - if (pending?.client == client) pending = null + if (lostPending) { pending = null; invalidSession = true } + return lostPending + } + + @Synchronized fun trainerDropped(): Client? = owner.also { + owner = null; pending = null } - @Synchronized fun trainerDropped(): String? = owner.also { owner = null; pending = null } + @Synchronized fun trainerReady() { invalidSession = false } - @Synchronized fun clear() { owner = null; pending = null } + @Synchronized fun clear() { owner = null; pending = null; invalidSession = false } companion object { const val SUCCESS = 0x01 diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt index db7eabb..f65f054 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt @@ -44,6 +44,7 @@ class MirrorServer( private val advertisedName: String, private val correction: () -> PowerCorrection, private val toZycle: (charUuid: UUID, bytes: ByteArray, withResponse: Boolean, onComplete: (Boolean) -> Unit) -> Boolean, + private val onTrainerRecycle: () -> Unit = {}, private val onStatus: (String) -> Unit = {}, /** Health report to the UI: true once we're actually advertising; false if the server/advertising fails. */ private val onAdvState: (Boolean) -> Unit = {}, @@ -62,6 +63,8 @@ class MirrorServer( private val cache = ConcurrentHashMap() // last value (power corrected) private val subscribers = ConcurrentHashMap>() // char uuid → subscribed client addrs private val clients = ConcurrentHashMap() // connected centrals + private val clientKeys = ConcurrentHashMap() + private val clientGeneration = java.util.concurrent.atomic.AtomicLong() private val ftmsControl = FtmsControlCoordinator() // touched from the GATT server binder thread, the client's binder thread and main — a plain ArrayDeque // can throw mid-poll when stop() clears it, and an exception on a binder callback kills the process @@ -91,6 +94,8 @@ class MirrorServer( private val cccd: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") private val ATT_UNLIKELY_ERROR = 0x0E private val ATT_INVALID_OFFSET = 0x07 + private val ATT_REQUEST_NOT_SUPPORTED = 0x06 + private val ATT_INVALID_ATTRIBUTE_VALUE_LENGTH = 0x0D private var originalName: String? = null @Volatile private var advertising = false @Volatile private var advStarting = false // a start is in flight; `advertising` only flips in the callback @@ -109,9 +114,10 @@ class MirrorServer( * starts advertising again the moment it drops — so advertising with no trainer behind us puts two * identical devices in the air and lets an app bind to a bridge that has no data to give it. */ fun setTrainerLinked(linked: Boolean) { + if (linked) ftmsControl.trainerReady() val controller = if (linked) null else ftmsControl.trainerDropped() if (trainerLinked == linked) { - controller?.let { address -> handler.post { clients[address]?.let { server?.cancelConnection(it) } } } + controller?.let { handler.post { cancelClient(it) } } return } trainerLinked = linked @@ -127,7 +133,7 @@ class MirrorServer( handler.post { if (linked) startAdvertising() else { stopAdvertising() - controller?.let { address -> clients[address]?.let { server?.cancelConnection(it) } } + controller?.let { cancelClient(it) } } } } @@ -335,7 +341,7 @@ class MirrorServer( server = null restoreName() built.set(false); advBlueprint = null - chars.clear(); cache.clear(); subscribers.clear(); clients.clear(); pendingServices.clear() + chars.clear(); cache.clear(); subscribers.clear(); clients.clear(); clientKeys.clear(); pendingServices.clear() shownZycleLevel = null; lastRawZycleLevel = null; lastControlWriteMs = 0L; servoStepOwed = false; reanchorLevel = false } @@ -368,6 +374,9 @@ class MirrorServer( val clientCount: Int get() = clients.size val levelDebug: String get() = "${shownZycleLevel ?: "-"}/${lastRawZycleLevel ?: "-"}" + fun admitLocalControl(opcode: Int): Boolean = ftmsControl.admitLocal(opcode) + fun localControlTransportFailed(opcode: Int) { ftmsControl.transportFailed(null, opcode) } + /** A value arrived from the trainer: correct power, cache, and notify every subscribed client. */ fun onZycleValue(charUuid: UUID, value: ByteArray) { if (charUuid == GattUuids.FTMS_CONTROL_POINT && value.size >= 3 && @@ -448,20 +457,26 @@ class MirrorServer( } } - private fun notifyControlResult(client: String, value: ByteArray) { + private fun notifyControlResult(client: FtmsControlCoordinator.Client, value: ByteArray) { val uuid = GattUuids.FTMS_CONTROL_POINT val ch = chars[uuid] ?: return val subs = subscribers[uuid] ?: return - if (!synchronized(subs) { subs.contains(client) }) return + if (clientKeys[client.address] != client || !synchronized(subs) { subs.contains(client.address) }) return handler.post { val srv = server ?: return@post - val dev = clients[client] ?: return@post + if (clientKeys[client.address] != client) return@post + val dev = clients[client.address] ?: return@post val currentSubs = subscribers[uuid] ?: return@post - if (!synchronized(currentSubs) { currentSubs.contains(client) }) return@post + if (!synchronized(currentSubs) { currentSubs.contains(client.address) }) return@post notify(srv, dev, ch, value, ch.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) } } + private fun cancelClient(client: FtmsControlCoordinator.Client) { + if (clientKeys[client.address] != client) return + clients[client.address]?.let { server?.cancelConnection(it) } + } + private val serverCallback = object : BluetoothGattServerCallback() { override fun onServiceAdded(status: Int, service: BluetoothGattService?) { // A callback still in flight when stop() ran would otherwise find pendingServices empty, set @@ -491,16 +506,19 @@ class MirrorServer( return } if (newState == android.bluetooth.BluetoothProfile.STATE_CONNECTED) { - clients[device.address] = device; onStatus(context.getString(R.string.status_app_connected, clients.size)) + clientKeys[device.address] = FtmsControlCoordinator.Client(device.address, clientGeneration.incrementAndGet()) + clients[device.address] = device + onStatus(context.getString(R.string.status_app_connected, clients.size)) FileLog.event("app connected ${device.address} status=$status (${clients.size} total)") // Android stops connectable advertising once a central connects — restart it so a SECOND // central (e.g. the Garmin) can still discover us. handler.post { restartAdvertising() } } else { - ftmsControl.disconnect(device.address) + val lostPending = clientKeys.remove(device.address)?.let { ftmsControl.disconnect(it) } == true clients.remove(device.address); subscribers.values.forEach { it.remove(device.address) } onStatus(context.getString(R.string.status_app_disconnected, clients.size)) FileLog.event("app disconnected ${device.address} status=$status (${clients.size} left)") + if (lostPending) handler.post(onTrainerRecycle) handler.post { restartAdvertising() } // the controller stopped our advert when it connected } } @@ -528,6 +546,20 @@ class MirrorServer( override fun onCharacteristicWriteRequest(device: BluetoothDevice?, requestId: Int, ch: BluetoothGattCharacteristic?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) { val uuid = ch?.uuid + if (preparedWrite && uuid == GattUuids.FTMS_CONTROL_POINT) { + FileLog.event("app write ${shortUuid(uuid)} <- ${device?.address} PREPARED rejected") + if (responseNeeded) runCatching { + server?.sendResponse(device, requestId, ATT_REQUEST_NOT_SUPPORTED, offset, null) + } + return + } + if (uuid == GattUuids.FTMS_CONTROL_POINT && value?.isEmpty() == true) { + FileLog.event("app write ${shortUuid(uuid)} <- ${device?.address} empty rejected") + if (responseNeeded) runCatching { + server?.sendResponse(device, requestId, ATT_INVALID_ATTRIBUTE_VALUE_LENGTH, offset, null) + } + return + } var relayed = false val tag = "${shortUuid(uuid)} <- ${device?.address}" + (if (preparedWrite) " PREPARED off=$offset" else "") + (if (!responseNeeded) " noResp" else "") @@ -542,9 +574,11 @@ class MirrorServer( // matters if some app starts using long writes — the log line above says when it happens. if (!preparedWrite && uuid == GattUuids.FTMS_CONTROL_POINT && device != null && value.isNotEmpty()) { val opcode = value[0].toInt() and 0xFF - when (val admission = ftmsControl.admit(device.address, opcode)) { + val client = clientKeys[device.address] + when (val admission = client?.let { ftmsControl.admit(it, opcode) }) { + null -> Unit is FtmsControlCoordinator.Admission.Rejected -> - notifyControlResult(device.address, byteArrayOf(0x80.toByte(), opcode.toByte(), admission.result.toByte())) + notifyControlResult(client, byteArrayOf(0x80.toByte(), opcode.toByte(), admission.result.toByte())) is FtmsControlCoordinator.Admission.Admitted -> { val procedure = admission.procedure relayed = toZycle(uuid, out, withResponse) { success -> diff --git a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt index c7fd841..6fb1eed 100644 --- a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt +++ b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt @@ -29,89 +29,143 @@ import org.junit.Test * ownership rejection is checked below; the call shape is not. */ class RuntimeHardeningTest { + private val clientA = FtmsControlCoordinator.Client("A", 1L) + private val clientB = FtmsControlCoordinator.Client("B", 1L) + + @Test fun localProcedureBlocksExternalAndDrainsWithoutClientNotification() { + val coordinator = FtmsControlCoordinator() + + assertTrue(coordinator.admitLocal(0x04)) + assertEquals( + FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), + coordinator.admit(clientA, 0x00), + ) + assertNull(coordinator.response(0x05, FtmsControlCoordinator.SUCCESS)) + assertEquals( + FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), + coordinator.admit(clientA, 0x00), + ) + assertNull(coordinator.response(0x04, FtmsControlCoordinator.SUCCESS)) + assertEquals( + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x00)), + coordinator.admit(clientA, 0x00), + ) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + assertFalse(coordinator.admitLocal(0x04)) + } + + @Test fun disconnectReportsWhenPendingProcedureWasLost() { + val coordinator = FtmsControlCoordinator() + coordinator.admit(clientA, 0x00) + + assertFalse(coordinator.disconnect(clientB)) + assertTrue(coordinator.disconnect(clientA)) + assertEquals( + FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), + coordinator.admit(clientB, 0x00), + ) + coordinator.trainerDropped() + assertEquals( + FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), + coordinator.admit(clientB, 0x00), + ) + coordinator.trainerReady() + assertEquals( + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientB, 0x00)), + coordinator.admit(clientB, 0x00), + ) + } + + @Test fun reconnectWithSameAddressHasDifferentClientIdentity() { + assertFalse( + FtmsControlCoordinator.Client("A", 1L) == FtmsControlCoordinator.Client("A", 2L), + ) + } @Test fun firstSuccessfulRequestControlOwnsFtms() { val coordinator = FtmsControlCoordinator() assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x00)), - coordinator.admit("A", 0x00), + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x00)), + coordinator.admit(clientA, 0x00), ) - assertEquals("A", coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)) + assertEquals(clientA, coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)) assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x05)), - coordinator.admit("A", 0x05), + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x05)), + coordinator.admit(clientA, 0x05), ) } @Test fun secondClientCannotControlOrStealOwnership() { val coordinator = FtmsControlCoordinator() - coordinator.admit("A", 0x00) + coordinator.admit(clientA, 0x00) coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) assertEquals( FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.CONTROL_NOT_PERMITTED), - coordinator.admit("B", 0x05), + coordinator.admit(clientB, 0x05), ) assertEquals( FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.CONTROL_NOT_PERMITTED), - coordinator.admit("B", 0x00), + coordinator.admit(clientB, 0x00), ) } @Test fun onlyOneProcedureCanBePending() { val coordinator = FtmsControlCoordinator() - coordinator.admit("A", 0x00) + coordinator.admit(clientA, 0x00) assertEquals( FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), - coordinator.admit("A", 0x00), + coordinator.admit(clientA, 0x00), ) - assertNull(coordinator.transportFailed("B", 0x00)) - assertEquals("A", coordinator.transportFailed("A", 0x00)) + assertNull(coordinator.transportFailed(clientB, 0x00)) + assertEquals(clientA, coordinator.transportFailed(clientA, 0x00)) assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x00)), - coordinator.admit("A", 0x00), + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x00)), + coordinator.admit(clientA, 0x00), ) } @Test fun responseRoutesOnlyToMatchingOrigin() { val coordinator = FtmsControlCoordinator() - coordinator.admit("A", 0x00) + coordinator.admit(clientA, 0x00) assertNull(coordinator.response(0x05, FtmsControlCoordinator.SUCCESS)) - assertEquals("A", coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)) + assertEquals(clientA, coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)) assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x05)), - coordinator.admit("A", 0x05), + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x05)), + coordinator.admit(clientA, 0x05), ) } @Test fun failedRequestControlDoesNotAcquireOwnership() { val coordinator = FtmsControlCoordinator() - coordinator.admit("A", 0x00) + coordinator.admit(clientA, 0x00) - assertEquals("A", coordinator.response(0x00, FtmsControlCoordinator.OPERATION_FAILED)) + assertEquals(clientA, coordinator.response(0x00, FtmsControlCoordinator.OPERATION_FAILED)) assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("B", 0x00)), - coordinator.admit("B", 0x00), + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientB, 0x00)), + coordinator.admit(clientB, 0x00), ) } @Test fun ownerDisconnectAndTrainerDropClearOwnership() { val coordinator = FtmsControlCoordinator() - coordinator.admit("A", 0x00) + coordinator.admit(clientA, 0x00) coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) - coordinator.admit("A", 0x05) - coordinator.disconnect("A") - coordinator.admit("B", 0x00) + coordinator.admit(clientA, 0x05) + coordinator.disconnect(clientA) + coordinator.trainerDropped() + coordinator.trainerReady() + coordinator.admit(clientB, 0x00) coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) - coordinator.admit("B", 0x05) - assertEquals("B", coordinator.trainerDropped()) + coordinator.admit(clientB, 0x05) + assertEquals(clientB, coordinator.trainerDropped()) assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure("A", 0x00)), - coordinator.admit("A", 0x00), + FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x00)), + coordinator.admit(clientA, 0x00), ) } From 9d011fe621cd3421534cf1d065e1c62436a7468e Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:12:54 +0200 Subject: [PATCH 09/17] Release FTMS quarantine from replacement source --- .../enderthor/trainerbridgeble/BridgeService.kt | 15 ++++++++++++++- .../trainerbridgeble/ble/MirrorServer.kt | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index 91c1de6..7ff041f 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -104,6 +104,8 @@ class BridgeService : Service() { * writeCharacteristic is a Binder call into the Bluetooth process and stopEmit() waits on this monitor * from the main thread. */ private val emitOwner = IdentityOwner() + private var receiveGeneration = 0L + private var ftmsReleaseGeneration: Long? = null /** How many callbacks the generation guard rejected. Zero all ride means the races the guard exists for * never happened; a climbing number is itself the finding. Reported by the periodic snapshot. */ private val staleCallbacks = java.util.concurrent.atomic.AtomicInteger(0) @@ -324,6 +326,7 @@ class BridgeService : Service() { ErgBias.seed(config.ergBiasW) // start calibrated; there is no live command to measure against yet FileLog.event("receive start paired=${config.pairedAddress.ifEmpty { "any" }} sim=${config.simulate} ergBias=${config.ergBiasW}W") val owner = Any() + val sourceGeneration = ++receiveGeneration receiveOwner.replace(owner) // Low-rate callbacks still hop to main; onValue stays on the BLE thread and uses receiveOwner's // monitor to make validation + mutation atomic with stopReceive(). @@ -363,7 +366,15 @@ class BridgeService : Service() { } // Same treatment: a stale onSynced would put the mirror on the air with no trainer behind it, and // setTrainerLinked is edge-triggered, so it would STAY there. - val onSynced: () -> Unit = { handler.post { receiveOwner.runIfCurrent(owner) { zycleSynced = true; mirror?.setTrainerLinked(true) } } } + val onSynced: () -> Unit = { handler.post { receiveOwner.runIfCurrent(owner) { + val activeMirror = mirror + if (activeMirror != null && ftmsReleaseGeneration?.let { sourceGeneration >= it } == true) { + activeMirror.releaseFtmsQuarantine() + ftmsReleaseGeneration = null + } + zycleSynced = true + activeMirror?.setTrainerLinked(true) + } } } val c: TrainerSource = if (config.simulate) SimSource(onProfile, onValue, onState, onSynced).also { simSource = it } else ZycleClient(this, config.pairedAddress, onProfile, onValue, onState, onSynced, // Guarded too, or the replaced source's advertising blueprint and address get written over the @@ -442,6 +453,7 @@ class BridgeService : Service() { emitOwner.runIfCurrent(emitToken) { current = true } if (current && receiving) { FileLog.event("FTMS origin disconnected mid-procedure — recycling trainer link") + ftmsReleaseGeneration = receiveGeneration + 1 stopReceive() maybeStartReceive() } @@ -500,6 +512,7 @@ class BridgeService : Service() { // Before the early return: a mirror whose construction or start() failed still left callbacks able // to run against this token. emitOwner.clear() + ftmsReleaseGeneration = null if (mirror == null) return FileLog.event("emit stop") mirror?.stop(); mirror = null diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt index f65f054..cd42f94 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt @@ -114,7 +114,6 @@ class MirrorServer( * starts advertising again the moment it drops — so advertising with no trainer behind us puts two * identical devices in the air and lets an app bind to a bridge that has no data to give it. */ fun setTrainerLinked(linked: Boolean) { - if (linked) ftmsControl.trainerReady() val controller = if (linked) null else ftmsControl.trainerDropped() if (trainerLinked == linked) { controller?.let { handler.post { cancelClient(it) } } @@ -376,6 +375,7 @@ class MirrorServer( fun admitLocalControl(opcode: Int): Boolean = ftmsControl.admitLocal(opcode) fun localControlTransportFailed(opcode: Int) { ftmsControl.transportFailed(null, opcode) } + fun releaseFtmsQuarantine() { ftmsControl.trainerReady() } /** A value arrived from the trainer: correct power, cache, and notify every subscribed client. */ fun onZycleValue(charUuid: UUID, value: ByteArray) { From 46c23cb45aff8f7347681df8fe88f7eb0ceb9b67 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:30:39 +0200 Subject: [PATCH 10/17] Recover incomplete BLE bootstrap and mirror builds --- .../trainerbridgeble/RuntimeHardening.kt | 19 +++++ .../trainerbridgeble/ble/MirrorServer.kt | 65 ++++++++++++-- .../trainerbridgeble/ble/ZycleClient.kt | 84 +++++++++++++++---- .../trainerbridgeble/RuntimeHardeningTest.kt | 64 ++++++++++++++ 4 files changed, 210 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt index 86aa5de..be1b32a 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt @@ -17,6 +17,25 @@ internal fun encodeTargetResistance(target: Int): ByteArray { return byteArrayOf(0x04, (value and 0xFF).toByte(), ((value ushr 8) and 0xFF).toByte()) } +internal class FtmsBootstrapReadiness( + val controllable: Boolean, +) { + var featureRead = false + var controlPointSubscribed = false + var indoorBikeSubscribed = false + var cyclingPowerSubscribed = false + + val ready: Boolean get() = + (!controllable || featureRead && controlPointSubscribed) && + (indoorBikeSubscribed || cyclingPowerSubscribed) + + val missingRequirements: List get() = buildList { + if (controllable && !featureRead) add("FTMS Feature read") + if (controllable && !controlPointSubscribed) add("FTMS Control Point subscription") + if (!indoorBikeSubscribed && !cyclingPowerSubscribed) add("Indoor Bike or Cycling Power subscription") + } +} + internal class FtmsControlCoordinator { data class Client(val address: String, val generation: Long) data class Procedure(val client: Client?, val opcode: Int) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt index cd42f94..221fc74 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt @@ -23,6 +23,7 @@ import android.util.Log import com.enderthor.trainerbridgeble.AdvertisingAttemptCoordinator import com.enderthor.trainerbridgeble.FileLog import com.enderthor.trainerbridgeble.FtmsControlCoordinator +import com.enderthor.trainerbridgeble.IdentityOwner import com.enderthor.trainerbridgeble.R import com.enderthor.trainerbridgeble.correction.PowerCorrection import java.util.ArrayDeque @@ -75,6 +76,9 @@ class MirrorServer( private val ADV_RETRY_MAX_MS = 30_000L // backoff ceiling; there is no attempt cap (see scheduleAdvRetry) private val SERVICE_RETRY_MS = 300L private val SERVICE_MAX_RETRIES = 5 + private val SERVICE_ADD_TIMEOUT_MS = 8000L + private val SERVICE_REBUILD_MS = 2000L + private val SERVICE_REBUILD_MAX_MS = 30_000L // Generous on purpose: this must only ever fire for a callback that is genuinely LOST (adapter off, BT // process died). Firing it for one that is merely slow starts a second attempt against the same shared // callback object — see scheduleAdvRetry's note. @@ -90,6 +94,9 @@ class MirrorServer( private val LEVEL_SETTLE_MS = 3000L @Volatile private var serviceRetries = 0 private val serviceRetryRunnable = Runnable { if (server != null) addNextService() } + private val serviceAddOwner = IdentityOwner() + @Volatile private var serviceAddWatchdog: Runnable? = null + @Volatile private var serviceRebuildMs = SERVICE_REBUILD_MS private val ADVERTISE_FAILED_DATA_TOO_LARGE = 1 private val cccd: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") private val ATT_UNLIKELY_ERROR = 0x0E @@ -203,6 +210,7 @@ class MirrorServer( @Volatile private var stopped = false /** A profile handed to [build] before the server existed, replayed once it does. */ @Volatile private var pendingProfile: GattProfile? = null + @Volatile private var latestProfile: GattProfile? = null /** Rename the adapter to our advertised name, persisting the ORIGINAL to prefs so a process kill (which * skips stop()) doesn't lose the user's real Bluetooth name — and so we never capture our own rename. */ @@ -256,6 +264,7 @@ class MirrorServer( * services + characteristics (re-adding them would strand apps still subscribed to the old instances * and there is no clean live rebuild). */ fun build(profile: GattProfile) { + latestProfile = profile // No server yet (it is being retried): hold the profile rather than drop it, or a server that opens // on the second attempt would have no services and would therefore never advertise. Under the same // lock openServer() claims it with, so the read and the store cannot straddle the handover. @@ -316,12 +325,20 @@ class MirrorServer( /** PEEK, don't poll: the service stays at the head until its own onServiceAdded confirms it. Removing it * up front let a late success (the stack had queued the "refused" add after all) and the retry both * drive the chain — adding a service twice and letting `servicesReady` fire with an add still in flight. */ - private fun addNextService() { + @Synchronized private fun addNextService() { + if (serviceAddOwner.current != null) return val svc = pendingServices.peek() ?: return - if (runCatching { server?.addService(svc) }.getOrNull() != true) { + if (runCatching { server?.addService(svc) }.getOrNull() == true) { + serviceAddOwner.replace(svc) + val watchdog = Runnable { + if (serviceAddOwner.clearIfCurrent(svc) { serviceAddWatchdog = null }) + rebuildServer("addService callback timeout for ${shortUuid(svc.uuid)}") + } + serviceAddWatchdog = watchdog + handler.postDelayed(watchdog, SERVICE_ADD_TIMEOUT_MS) + } else { if (serviceRetries++ >= SERVICE_MAX_RETRIES) { - FileLog.event("mirror addService REFUSED for ${shortUuid(svc.uuid)} — giving up, releasing build latch") - built.set(false) // so the next discovery can rebuild instead of staying silent forever + rebuildServer("addService refused for ${shortUuid(svc.uuid)}") return } FileLog.event("mirror addService REFUSED for ${shortUuid(svc.uuid)} — retry ${serviceRetries}") @@ -329,10 +346,38 @@ class MirrorServer( } } + private fun rebuildServer(reason: String) { + if (stopped) return + val wait = serviceRebuildMs + serviceRebuildMs = (wait * 2).coerceAtMost(SERVICE_REBUILD_MAX_MS) + FileLog.event("mirror service build failed: $reason — reopening in ${wait}ms") + servicesReady = false + built.set(false) + serviceAddOwner.clear() + serviceAddWatchdog?.let { handler.removeCallbacks(it) } + serviceAddWatchdog = null + handler.removeCallbacks(serviceRetryRunnable) + pendingServices.clear() + chars.clear() + val failedServer = synchronized(serverLock) { + server.also { + server = null + pendingProfile = latestProfile + } + } + runCatching { failedServer?.close() } + onAdvState(false) + handler.removeCallbacks(serverRetryRunnable) + handler.postDelayed(serverRetryRunnable, wait) + } + fun stop() { // Stop the advertiser UNCONDITIONALLY: the `advertising` flag is transiently false mid-restart, so // trusting it here can leave the phone broadcasting with a closed GATT server. - stopped = true; pendingProfile = null; servicesReady = false + stopped = true; pendingProfile = null; latestProfile = null; servicesReady = false + serviceAddOwner.clear() + serviceAddWatchdog?.let { handler.removeCallbacks(it) } + serviceAddWatchdog = null ftmsControl.clear() stopAdvertising() handler.removeCallbacksAndMessages(null) // pending adv starts / service retries must not outlive us @@ -479,15 +524,20 @@ class MirrorServer( private val serverCallback = object : BluetoothGattServerCallback() { override fun onServiceAdded(status: Int, service: BluetoothGattService?) { + val added = service ?: return + if (!serviceAddOwner.clearIfCurrent(added) { + serviceAddWatchdog?.let { handler.removeCallbacks(it) } + serviceAddWatchdog = null + }) return // A callback still in flight when stop() ran would otherwise find pendingServices empty, set // servicesReady and post a start — putting us back on the air with a closed GATT server. if (stopped || server == null) return if (status != BluetoothGatt.GATT_SUCCESS) { // don't poll it: retry the head rather than advertise a mirror missing a service - FileLog.event("mirror addService FAILED status=$status for ${shortUuid(service?.uuid)} — retrying head") + FileLog.event("mirror addService FAILED status=$status for ${shortUuid(added.uuid)} — retrying head") if (serviceRetries++ < SERVICE_MAX_RETRIES) { handler.removeCallbacks(serviceRetryRunnable); handler.postDelayed(serviceRetryRunnable, SERVICE_RETRY_MS) - } else { FileLog.event("mirror giving up on ${shortUuid(service?.uuid)} — releasing build latch"); built.set(false) } + } else rebuildServer("addService callback status=$status for ${shortUuid(added.uuid)}") return } handler.removeCallbacks(serviceRetryRunnable) // a stale retry would add the NEXT service twice @@ -495,6 +545,7 @@ class MirrorServer( serviceRetries = 0 if (pendingServices.isEmpty()) { // the mirrored GATT is complete servicesReady = true + serviceRebuildMs = SERVICE_REBUILD_MS handler.post { startAdvertising() } } else addNextService() } diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt index 783d3b2..0be4f43 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt @@ -16,6 +16,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import com.enderthor.trainerbridgeble.FileLog +import com.enderthor.trainerbridgeble.FtmsBootstrapReadiness import com.enderthor.trainerbridgeble.GattSessionCoordinator import com.enderthor.trainerbridgeble.IdentityOwner import com.enderthor.trainerbridgeble.TrainerWriteTicket @@ -58,6 +59,7 @@ class ZycleClient( onState(false) completePendingWrites(false) opQueue.clear(); opBusy.set(false); syncOwed.set(false); burstEnqueued = false + bootstrapReadiness = null connecting.set(false); inFlightWrite = null }, disconnect = { runCatching { it.disconnect() } }, @@ -89,12 +91,14 @@ class ZycleClient( private val opBusy = AtomicBoolean(false) private val syncOwed = AtomicBoolean(false) // onSynced not yet delivered for THIS connection @Volatile private var burstEnqueued = false // the opening read/subscribe burst is in the queue + @Volatile private var bootstrapReadiness: FtmsBootstrapReadiness? = null /** Deliver [onSynced] at most once per connection, on the main thread, and never for a link that has * dropped in the meantime: a stale delivery would put the mirror on the air with no trainer behind it, * and setTrainerLinked is edge-triggered, so it would STAY there. */ private fun fireSynced(g: BluetoothGatt) { gattSessions.runIfCurrent(g) { + if (bootstrapReadiness?.ready != true) return@runIfCurrent if (!syncOwed.compareAndSet(true, false)) return@runIfCurrent handler.post { if (!stopped) gattSessions.runIfCurrent(g) { onSynced() } } } @@ -138,7 +142,7 @@ class ZycleClient( inFlightWrite = null stopScan() handler.removeCallbacksAndMessages(null) // heartbeat, rescan, connect/op watchdogs, write retries - opQueue.clear(); opBusy.set(false) + opQueue.clear(); opBusy.set(false); bootstrapReadiness = null session?.let { runCatching { it.disconnect() }; runCatching { it.close() } } } @@ -412,15 +416,19 @@ class ZycleClient( retryMs = SCAN_RETRY_MS // a good connection resets the backoff everConnected = true // ...and promotes every later scan to the reacquisition duty cycle lastMessageMs = android.os.SystemClock.elapsedRealtime() // start the silent-link window at connect - syncOwed.set(true); burstEnqueued = false + syncOwed.set(true); burstEnqueued = false; bootstrapReadiness = null onState(true) handler.post { runCatching { g.discoverServices() } } - // Floor under the mirror going on the air. Discovery can fail, be refused by the stack, or - // yield a profile with nothing to read; and a lost GATT callback costs OP_TIMEOUT_MS each. - // Waiting forever for a perfect sync is worse than advertising with a partial cache. + // A fallback may shorten a slow queue only after every required bootstrap result exists. + // Advertising a controllable profile without Feature/control or any power stream makes + // the client cache a broken trainer for the whole session. handler.postDelayed({ - if (syncOwed.get()) FileLog.event("Zycle sync fallback ${SYNC_FALLBACK_MS}ms -> advertising anyway") - fireSynced(g) + gattSessions.runIfCurrent(g) { + if (syncOwed.get() && bootstrapReadiness?.ready == true) { + FileLog.event("Zycle sync fallback ${SYNC_FALLBACK_MS}ms -> required bootstrap ready") + fireSynced(g) + } + } }, SYNC_FALLBACK_MS) }) runCatching { g.close() } // orphaned handle — drop it } else { @@ -440,10 +448,16 @@ class ZycleClient( gattSessions.runIfCurrent(g) { if (status != BluetoothGatt.GATT_SUCCESS) { Log.w(tag, "discover failed $status"); FileLog.event("Zycle discover FAILED status=$status") + recycleGatt(g, "service discovery failed status=$status") return@runIfCurrent } lastMessageMs = android.os.SystemClock.elapsedRealtime() val profile = buildProfile(g) + bootstrapReadiness = FtmsBootstrapReadiness( + controllable = profile.services.any { service -> + service.chars.any { it.uuid == GattUuids.FTMS_CONTROL_POINT } + }, + ) FileLog.event("Zycle profile: " + profile.services.joinToString("; ") { s -> "${s.uuid}[" + s.chars.joinToString(",") { "${shortUuid(it.uuid)}(p=${it.properties})" } + "]" }) @@ -467,7 +481,9 @@ class ZycleClient( for (svc in svcs) for (ch in svc.characteristics) if (ch.properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) enqueueRead(g, ch) for (svc in svcs) for (ch in svc.characteristics) - if (ch.properties and (BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) + if (ch.uuid != GattUuids.INDOOR_BIKE_DATA && + ch.uuid != GattUuids.CYCLING_POWER_MEASUREMENT && + ch.properties and (BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) enqueueSubscribe(g, ch) } finally { burstBuilding = false } // Also drains a profile with nothing readable/notifiable instead of hanging the sync latch. @@ -478,6 +494,7 @@ class ZycleClient( override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { gattSessions.runIfCurrent(g) { val u = descriptor.characteristic.uuid + if (descriptor.uuid == cccd) markBootstrapSubscription(u, status == BluetoothGatt.GATT_SUCCESS) if (status != BluetoothGatt.GATT_SUCCESS) FileLog.event("Zycle subscribe ${shortUuid(u)} FAILED status=$status") lastMessageMs = android.os.SystemClock.elapsedRealtime() @@ -497,6 +514,7 @@ class ZycleClient( FileLog.event("Zycle read ${shortUuid(ch.uuid)} = ${FileLog.hex(v)}") // identity/feature/ranges values onValue(ch.uuid, v) } else FileLog.event("Zycle read ${shortUuid(ch.uuid)} failed status=$status") + if (ch.uuid == FTMS_FEATURE) bootstrapReadiness?.featureRead = status == BluetoothGatt.GATT_SUCCESS lastMessageMs = android.os.SystemClock.elapsedRealtime() opDone() } @@ -561,7 +579,12 @@ class ZycleClient( private fun enqueueSubscribe(g: BluetoothGatt, ch: BluetoothGattCharacteristic, retry: Boolean = true): Unit = enqueue { g.setCharacteristicNotification(ch, true) val d = ch.getDescriptor(cccd) - if (d == null) { FileLog.event("Zycle subscribe ${shortUuid(ch.uuid)} — no CCCD"); opDone(); return@enqueue } + if (d == null) { + markBootstrapSubscription(ch.uuid, false) + FileLog.event("Zycle subscribe ${shortUuid(ch.uuid)} — no CCCD") + opDone() + return@enqueue + } FileLog.event("Zycle subscribe ${shortUuid(ch.uuid)}") @Suppress("DEPRECATION") run { @@ -569,8 +592,15 @@ class ZycleClient( BluetoothGattDescriptor.ENABLE_INDICATION_VALUE else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE if (g.writeDescriptor(d) != true) { FileLog.event("Zycle subscribe ${shortUuid(ch.uuid)} REFUSED by stack" + if (retry) " — requeueing" else " — giving up") - if (retry) handler.postDelayed({ if (!stopped && gatt === g) enqueueSubscribe(g, ch, retry = false) }, OP_REQUEUE_MS) - opDone() + if (retry) handler.postDelayed({ + gattSessions.runIfCurrent(g) { + enqueueSubscribe(g, ch, retry = false) + opDone() + } + }, OP_REQUEUE_MS) else { + markBootstrapSubscription(ch.uuid, false) + opDone() + } } } } @@ -578,8 +608,23 @@ class ZycleClient( private fun enqueueRead(g: BluetoothGatt, ch: BluetoothGattCharacteristic, retry: Boolean = true): Unit = enqueue { if (g.readCharacteristic(ch) != true) { FileLog.event("Zycle read ${shortUuid(ch.uuid)} REFUSED by stack" + if (retry) " — requeueing" else " — giving up") - if (retry) handler.postDelayed({ if (!stopped && gatt === g) enqueueRead(g, ch, retry = false) }, OP_REQUEUE_MS) - opDone() + if (retry) handler.postDelayed({ + gattSessions.runIfCurrent(g) { + enqueueRead(g, ch, retry = false) + opDone() + } + }, OP_REQUEUE_MS) else { + if (ch.uuid == FTMS_FEATURE) bootstrapReadiness?.featureRead = false + opDone() + } + } + } + + private fun markBootstrapSubscription(uuid: UUID, success: Boolean) { + when (uuid) { + GattUuids.FTMS_CONTROL_POINT -> bootstrapReadiness?.controlPointSubscribed = success + GattUuids.INDOOR_BIKE_DATA -> bootstrapReadiness?.indoorBikeSubscribed = success + GattUuids.CYCLING_POWER_MEASUREMENT -> bootstrapReadiness?.cyclingPowerSubscribed = success } } @@ -599,7 +644,15 @@ class ZycleClient( if (op == null) { opBusy.set(false) // Drained: the opening burst is done, so the mirror now has every readable value we can give it. - if (burstEnqueued) gatt?.let { fireSynced(it) } + if (burstEnqueued && syncOwed.get()) gatt?.let { session -> + val readiness = bootstrapReadiness + if (readiness?.ready == true) fireSynced(session) + else { + val missing = readiness?.missingRequirements?.joinToString() ?: "service discovery" + FileLog.event("Zycle bootstrap incomplete: $missing -> reconnect") + recycleGatt(session, "bootstrap missing $missing") + } + } return } val session = gatt @@ -633,6 +686,7 @@ class ZycleClient( const val CONTROL_WRITE_RETRIES = 2 // resend a control write that NAKs (status 133) up to twice const val CONTROL_RETRY_DELAY_MS = 250L const val OP_TIMEOUT_MS = 4000L // unstick the GATT queue if a callback is ever lost (flaky link) - const val SYNC_FALLBACK_MS = 6000L // go on the air with a partial cache rather than never + const val SYNC_FALLBACK_MS = 6000L // only shorten a slow queue after required bootstrap is ready + val FTMS_FEATURE: UUID = GattUuids.uuid16(0x2ACC) } } diff --git a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt index 6fb1eed..d60c1b9 100644 --- a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt +++ b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt @@ -215,6 +215,70 @@ class RuntimeHardeningTest { } // ── emit ownership (BridgeService.toZycle / stopEmit) ───────────────────────────────────────── + @Test fun controllableFtmsRequiresFeatureControlPointAndOnePowerStream() { + val readiness = FtmsBootstrapReadiness(controllable = true) + + assertFalse(readiness.ready) + assertEquals( + listOf( + "FTMS Feature read", + "FTMS Control Point subscription", + "Indoor Bike or Cycling Power subscription", + ), + readiness.missingRequirements, + ) + readiness.featureRead = true + assertFalse(readiness.ready) + assertEquals( + listOf("FTMS Control Point subscription", "Indoor Bike or Cycling Power subscription"), + readiness.missingRequirements, + ) + readiness.controlPointSubscribed = true + assertFalse(readiness.ready) + assertEquals(listOf("Indoor Bike or Cycling Power subscription"), readiness.missingRequirements) + readiness.indoorBikeSubscribed = true + assertTrue(readiness.ready) + assertTrue(readiness.missingRequirements.isEmpty()) + } + + @Test fun eitherIndoorBikeOrCyclingPowerSubscriptionSatisfiesPower() { + val indoorBike = FtmsBootstrapReadiness(controllable = true).apply { + featureRead = true + controlPointSubscribed = true + indoorBikeSubscribed = true + } + val cyclingPower = FtmsBootstrapReadiness(controllable = true).apply { + featureRead = true + controlPointSubscribed = true + cyclingPowerSubscribed = true + } + + assertTrue(indoorBike.ready) + assertTrue(cyclingPower.ready) + } + + @Test fun readOnlyProfileDoesNotRequireControlPoint() { + val readiness = FtmsBootstrapReadiness(controllable = false).apply { + indoorBikeSubscribed = true + } + + assertTrue(readiness.ready) + } + + @Test fun staleServiceAddCallbackCannotCompleteReplacementAttempt() { + val attempts = IdentityOwner() + val staleService = Any() + val replacementService = Any() + var completed: Any? = null + attempts.replace(staleService) + attempts.replace(replacementService) + + assertFalse(attempts.clearIfCurrent(staleService) { completed = staleService }) + assertNull(completed) + assertTrue(attempts.clearIfCurrent(replacementService) { completed = replacementService }) + assertEquals(replacementService, completed) + } + /** The point of the emit token: a write admitted by the OLD mirror must never capture the NEW source. */ @Test fun aStaleMirrorWriteCannotCaptureTheReplacementSource() { val emitOwner = IdentityOwner() From 8aa22f65ef341835ab09aeca403bee7dae6065c0 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:03:10 +0200 Subject: [PATCH 11/17] Keep the Karoo CPU awake for active bridge sessions --- .../trainerbridgeble/BridgeService.kt | 29 ++----------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index 7ff041f..e6d5ce6 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -260,10 +260,8 @@ class BridgeService : Service() { status = getString(R.string.status_missing_bt_permission); listener?.invoke(); stopSelf(); return false } foreground = true + acquireWakeLock() handler.removeCallbacks(snapshot); handler.post(snapshot) // stops itself once foreground goes false - // NOT here: the wake lock follows the TRAINER LINK, not the master switch (see the onState callback in - // startReceive). Held from here it blocked suspend for every hour the master was left on with no - // trainer in the room — which is most of the day, and the single biggest idle drain in the app. return true } @@ -347,17 +345,6 @@ class BridgeService : Service() { val onState: (Boolean) -> Unit = { connected -> handler.post { receiveOwner.runIfCurrent(owner) { zycleConnected = connected - // The wake lock lives HERE, not in goForeground(): there is data to keep the CPU awake for only - // while a trainer is actually feeding us. - // On the DROP it lingers instead of releasing at once. The reconnect is a postDelayed 2 s away, - // and postDelayed does not wake a suspended CPU — releasing immediately can leave us suspended - // with NO scan running, and then the trainer's advertising has nothing to arrive at. Once a scan - // is actually up the controller wakes the AP on a match, so the lock is only needed to bridge - // that gap. (Residual: if startScan itself keeps failing, its backoff windows are unscanned.) - if (connected) acquireWakeLock() else { - handler.removeCallbacks(releaseWakeLockLater) - handler.postDelayed(releaseWakeLockLater, WAKELOCK_LINGER_MS) - } // Only the DROP is immediate; going on the air waits for onSynced below. if (!connected) { zycleSynced = false; mirror?.setTrainerLinked(false); ErgBias.forget() } if (!connected) { config.lastSeenAddress = ""; config.lastSeenName = "" } // the config screen offers it only while live @@ -395,7 +382,6 @@ class BridgeService : Service() { if (client == null && simSource == null) return FileLog.event("receive stop") receiveOwner.clear() // waits for an admitted callback, then rejects every later one - releaseWakeLock() // no source → nothing to stay awake for (the master switch keeps the FGS alive) mirror?.setTrainerLinked(false) // no source → nothing to advertise, whatever the call order Config(this).let { it.lastSeenAddress = ""; it.lastSeenName = "" } client?.stop(); client = null; simSource = null; lastProfile = null; lastAdvBlueprint = null; currentSourceKey = null @@ -652,27 +638,19 @@ class BridgeService : Service() { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); stopSelf() } - // Synchronized since the trainer link drives these: onState fires from the GATT binder thread AND from - // the client's main-thread heartbeat, and two concurrent acquires would strand a lock nothing releases. @Synchronized private fun acquireWakeLock() { - handler.removeCallbacks(releaseWakeLockLater) // a reconnect inside the linger window keeps the lock if (wakeLock?.isHeld == true) return wakeLock = (getSystemService(POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TrainerBridgeBLE:session").also { runCatching { it.acquire() } } - FileLog.event("wakelock ACQUIRED (trainer linked)") // the two lines that make E1 verifiable + FileLog.event("wakelock ACQUIRED (master active)") } @Synchronized private fun releaseWakeLock() { - handler.removeCallbacks(releaseWakeLockLater) val held = wakeLock?.isHeld == true wakeLock?.let { if (it.isHeld) runCatching { it.release() } }; wakeLock = null - if (held) FileLog.event("wakelock RELEASED — the CPU may suspend from here") + if (held) FileLog.event("wakelock RELEASED (master inactive)") } - /** Deferred release after a trainer drop — see the onState callback in [startReceive]. Re-checks, so a - * reconnect inside the linger window keeps the lock. */ - private val releaseWakeLockLater = Runnable { if (!zycleConnected) releaseWakeLock() } - /** * One compact state line a minute. Without it a quiet stretch of log is ambiguous — nothing happened, or * the bridge stalled? — and every "permanent death" bug in this project's history looked exactly like @@ -722,7 +700,6 @@ class BridgeService : Service() { private const val POWER_STALE_MS = 2000L // ~8 missed frames at 4 Hz: covers a hiccup, not a dropout private const val ANT_RESTART_DELAY_MS = 1500L // let the ANT service release the channel first private const val ERG_BIAS_PERSIST_MS = 60_000L // at most one prefs write a minute; stopReceive flushes - private const val WAKELOCK_LINGER_MS = 6000L // hold past the reconnect delay, until a scan is up private const val SNAPSHOT_MS = 60_000L // one state line a minute while the service is up const val ACTION_MASTER_ON = "com.enderthor.trainerbridgeble.MASTER_ON" const val ACTION_MASTER_OFF = "com.enderthor.trainerbridgeble.MASTER_OFF" From a532bbc4561d74baa3639aeb28c1bc198639cb7a Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:08:34 +0200 Subject: [PATCH 12/17] Handle wake lock acquisition failures --- .../trainerbridgeble/BridgeService.kt | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index e6d5ce6..bce9d40 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -260,7 +260,11 @@ class BridgeService : Service() { status = getString(R.string.status_missing_bt_permission); listener?.invoke(); stopSelf(); return false } foreground = true - acquireWakeLock() + if (!acquireWakeLock()) { + foreground = false + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); stopSelf() + return false + } handler.removeCallbacks(snapshot); handler.post(snapshot) // stops itself once foreground goes false return true } @@ -638,17 +642,32 @@ class BridgeService : Service() { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); stopSelf() } - @Synchronized private fun acquireWakeLock() { - if (wakeLock?.isHeld == true) return - wakeLock = (getSystemService(POWER_SERVICE) as PowerManager) - .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TrainerBridgeBLE:session").also { runCatching { it.acquire() } } + @Synchronized private fun acquireWakeLock(): Boolean { + if (wakeLock?.isHeld == true) return true + val candidate = (getSystemService(POWER_SERVICE) as PowerManager) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TrainerBridgeBLE:session") + val failure = runCatching { candidate.acquire(); check(candidate.isHeld) { "lock not held" } }.exceptionOrNull() + if (failure != null) { + runCatching { if (candidate.isHeld) candidate.release() } + FileLog.event("wakelock ACQUIRE FAILED (master active): ${failure.message ?: failure.javaClass.simpleName}") + return false + } + wakeLock = candidate FileLog.event("wakelock ACQUIRED (master active)") + return true } @Synchronized private fun releaseWakeLock() { - val held = wakeLock?.isHeld == true - wakeLock?.let { if (it.isHeld) runCatching { it.release() } }; wakeLock = null - if (held) FileLog.event("wakelock RELEASED (master inactive)") + val lock = wakeLock ?: return + val failure = runCatching { if (lock.isHeld) lock.release() }.exceptionOrNull() + val heldAfter = runCatching { lock.isHeld } + if (heldAfter.getOrNull() == false) { + wakeLock = null + FileLog.event("wakelock RELEASED (master inactive)") + } else { + val reason = failure ?: heldAfter.exceptionOrNull() + FileLog.event("wakelock RELEASE FAILED (master inactive): ${reason?.message ?: "lock still held"}") + } } /** From 20d578dfac3d5a5deae2dcc571fb1995b9c9e04f Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:09:59 +0200 Subject: [PATCH 13/17] =?UTF-8?q?One=20owner=20for=20FTMS=20control,=20bou?= =?UTF-8?q?nded=20=E2=80=94=20and=20the=20three=20rounds=20that=20found=20?= =?UTF-8?q?what=20each=20fix=20broke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate that admits FTMS procedures had no liveness property. An unanswered or opcode-mismatched Control Point response left `pending` set forever, after which every request — Request Control included — was refused for the rest of the ride. Power kept streaming, so nothing looked broken; ERG just stopped obeying. FtmsControlCoordinator now owns the three things every decision needs together: the per-connection identities, the admitted procedure, and its command bytes. They were in three places under two locks, which is where the bugs lived: - Identity lookup and admission were separate steps, so a disconnect landing between them could promote a client that had already gone — and lock out its own reconnect with CONTROL_NOT_PERMITTED for the session. - The command payload sat in a side slot, so one procedure's response could commit another's target, and the local button carried no payload at all — meaning ErgBias never saw opcode 0x04 and never retired an armed ERG target when the rider took manual control. - A rebuild of the local GATT server cleared six pieces of state but not the one gating ERG. Closing a server raises no disconnect callbacks, so ownership survived onto a generation that could never come back. Transport is no longer treated as acceptance. An ATT write the trainer ACKs can still be refused by the procedure, so UI, servo filtering and ERG bias learning now commit only on an FTMS Response Code of SUCCESS, with the bytes that procedure actually carried. Learning from targets the machine rejected is how a bias gets persisted against a trainer that was never holding it. Each procedure has a 30 s deadline, armed after the trainer takes the write — not at admission, which also measured our own serialised op queue. Expiry quarantines and recycles the link, because an opcode-only response arriving late can no longer be matched to a request. Arming and cancelling are both identity checked: the local path crosses two main-loop hops, so a stale arm would otherwise strip a live procedure of its only timer. Also here: addService publishes its claim before the binder call rather than after, where an early callback was discarded as stale and cost an 8 s watchdog plus a full rebuild; a rebuild stops advertising instead of broadcasting over a closed server; a refused or asynchronously failed terminal indication releases the owner so the next requester can arbitrate; bootstrap honours setCharacteristicNotification and rejects an FTMS Feature too short to carry Target Setting Features. The log can now prove all of it: admitted/rejected/response/timeout with opcode, result and procedure id; every relayed write with its sequence and terminal outcome, so an ordering violation is visible rather than inferred; and wakelock state in the minute snapshot. Diagnostic logging is still off by default and is only read at foreground start. 78 unit tests. Three adversarial review rounds got us here, and each of the first two shipped fixes that introduced a fresh variant of the same lockout — so the tests that matter are mutation checked against the exact line they name. --- .../trainerbridgeble/BridgeService.kt | 59 +++- .../trainerbridgeble/RuntimeHardening.kt | 116 +++++-- .../trainerbridgeble/ble/MirrorServer.kt | 261 +++++++++++--- .../trainerbridgeble/ble/ZycleClient.kt | 50 ++- app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../trainerbridgeble/RuntimeHardeningTest.kt | 327 +++++++++++++----- 7 files changed, 630 insertions(+), 187 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index bce9d40..c8232b4 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -144,14 +144,16 @@ class BridgeService : Service() { val target = ((lastResistance ?: 0) + delta).coerceIn(0, 200) // 0..200 per the Zycle's 0x2AD6 range val bytes = encodeTargetResistance(target) val localMirror = mirror - if (localMirror != null && !localMirror.admitLocalControl(0x04)) { + val procedure = localMirror?.admitLocalControl(0x04, bytes) + if (localMirror != null && procedure == null) { + lastControl = getString(R.string.status_control_busy) FileLog.event("UI button → resistance target=$target BLOCKED — FTMS control busy") listener?.invoke() return } val source = client if (source == null) { - localMirror?.localControlTransportFailed(0x04) + procedure?.let { localMirror.localControlTransportFailed(it) } FileLog.event("UI button → resistance target=$target FAILED") listener?.invoke() } else source.write( @@ -159,15 +161,27 @@ class BridgeService : Service() { bytes, true, ) { success -> - if (!success) localMirror?.localControlTransportFailed(0x04) + // Arm/close the procedure HERE, on the write callback, not inside the UI post below: + // that post is a second main-loop hop, and an arm that lands after a newer procedure was + // admitted used to strip the newer one of its deadline. + if (success) procedure?.let { localMirror.localControlDispatched(it) } + else procedure?.let { localMirror.localControlTransportFailed(it) } handler.post { if (client !== source) return@post if (success) { - // This path bypasses the mirror, so retire ERG learning only after the trainer write. - ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) + // Transport only. With the mirror up the trainer's FTMS Response Code decides + // acceptance, and onControlAccepted commits it — including telling ErgBias to + // retire any armed ERG target. The response clock starts now, not at admission. lastResistance = target - lastControl = getString(R.string.control_resistance_target, target) - FileLog.event("UI button → resistance target=$target") + if (procedure == null) { + // No mirror: nothing will ever correlate a response, so the write is all we + // have. A trainer that rejects the procedure is indistinguishable from one + // that accepts it on this path. + ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) + lastControl = getString(R.string.control_resistance_target, target) + } + FileLog.event("UI button → resistance target=$target sent" + + if (procedure != null) " (awaiting trainer verdict #${procedure.id})" else "") } else FileLog.event("UI button → resistance target=$target FAILED") listener?.invoke() } @@ -262,6 +276,7 @@ class BridgeService : Service() { foreground = true if (!acquireWakeLock()) { foreground = false + status = getString(R.string.status_wakelock_failed); listener?.invoke() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); stopSelf() return false } @@ -424,20 +439,26 @@ class BridgeService : Service() { onComplete(false) false } else source.write(uuid, bytes, withResponse) { success -> - var moved = false - if (success) emitOwner.runIfCurrent(emitToken) { - if (com.enderthor.trainerbridgeble.ble.GattUuids.carriesControl(uuid)) { - // `bytes` is already inverse-corrected: exactly the raw watts the trainer is told - // to hold, which is what measured power has to be compared against. - ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) - lastControl = describeControl(bytes) - moved = true - } - } + // An accepted ATT write is NOT an accepted procedure: the trainer can still answer + // Control Not Permitted / Invalid Parameter. Committing here taught ERG bias from + // targets the machine refused. onControlAccepted below is the real commit point. onComplete(success) - if (moved) listener?.invoke() } }, + onControlAccepted = { bytes -> + // Delivered on the trainer's binder thread. The mutations must happen UNDER the monitor, + // not after a check-then-act: stopEmit() runs on main and a callback that merely passed + // the check could otherwise publish into an already torn-down session. + var moved = false + emitOwner.runIfCurrent(emitToken) { + // `bytes` is already inverse-corrected: exactly the raw watts the trainer was told to + // hold, which is what measured power has to be compared against. + ErgBias.onControl(bytes, android.os.SystemClock.elapsedRealtime()) + lastControl = describeControl(bytes) + moved = true + } + if (moved) listener?.invoke() + }, onTrainerRecycle = { var current = false emitOwner.runIfCurrent(emitToken) { current = true } @@ -682,7 +703,7 @@ class BridgeService : Service() { if (FileLog.enabled) FileLog.event( "state master=${Config(this@BridgeService).masterEnabled} recv=$receiving emit=$emitting " + "trainer=${if (zycleSynced) "synced" else if (zycleConnected) "connected" else "-"} " + - "adv=$bleAdvOk apps=${mirror?.clientCount ?: 0} " + + "adv=$bleAdvOk apps=${mirror?.clientCount ?: 0} wake=${wakeLock?.isHeld == true} " + "powerFresh=$powerFresh raw=$lastRawW corr=$lastCorrectedW cad=$lastCadence res=$resistance " + "erg=${ErgBias.commanded} bias=${ErgBias.watts}W " + "level=${mirror?.levelDebug ?: "-"} ant=${if (antEnabled) antOk else null} stale=${staleCallbacks.get()}") diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt index be1b32a..1d3c08f 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt @@ -36,60 +36,128 @@ internal class FtmsBootstrapReadiness( } } +/** Single owner of "who may drive the trainer". It also owns the per-connection identities and the + * in-flight procedure's payload, because every decision needs them together: looking a client up in one + * map and admitting it under a different lock let a disconnect land in between; keeping the command bytes + * in a side slot let one procedure's response commit another procedure's target. Identity, admission, + * payload and termination are all ONE synchronized transition here. */ internal class FtmsControlCoordinator { data class Client(val address: String, val generation: Long) - data class Procedure(val client: Client?, val opcode: Int) + /** `id` makes every admitted procedure unique: two same-opcode procedures are NOT interchangeable, so a + * late transport failure or an expired deadline for the first cannot terminate the second. */ + data class Procedure(val client: Client?, val opcode: Int, val id: Long) + /** A procedure that just ended, with the exact bytes it carried. Non-null even for a local procedure + * (whose `client` is legitimately null) so callers can tell "matched" from "already gone". */ + /** Deliberately NOT a data class: it carries a ByteArray, whose generated equals/hashCode would be + * identity-based and quietly wrong for anyone who later compares or keys on one. */ + class Terminated(val procedure: Procedure, val bytes: ByteArray?) { + val client: Client? get() = procedure.client + } sealed interface Admission { data class Admitted(val procedure: Procedure) : Admission - data class Rejected(val result: Int) : Admission + data class Rejected(val result: Int, val client: Client?) : Admission } private var owner: Client? = null private var pending: Procedure? = null + private var pendingBytes: ByteArray? = null private var invalidSession = false + private var generations = 0L + private var procedures = 0L + private val keys = HashMap() + + @Synchronized fun connected(address: String): Client = + Client(address, ++generations).also { keys[address] = it } + + @Synchronized fun identity(address: String): Client? = keys[address] + + /** True only while this exact procedure is the admitted one. An arm that lost a race to the main + * looper must ask before replacing the live deadline — otherwise it cancels a healthy procedure's + * only timer and installs one for a procedure that already ended. */ + @Synchronized fun isPending(procedure: Procedure): Boolean = pending == procedure + + /** True while this client holds control. A terminal result that was produced while the client was + * NOT the owner carries no authority to release ownership it may have acquired since. */ + @Synchronized fun owns(client: Client): Boolean = owner == client - @Synchronized fun admit(client: Client, opcode: Int): Admission { - if (invalidSession || pending != null) return Admission.Rejected(OPERATION_FAILED) + /** Key removal and ownership loss as ONE transition; returns the procedure that died with it, if any. */ + @Synchronized fun disconnected(address: String): Terminated? { + val client = keys.remove(address) ?: return null + if (owner == client) owner = null + val lost = pending?.takeIf { it.client == client } ?: return null + val terminated = Terminated(lost, pendingBytes) + pending = null; pendingBytes = null; invalidSession = true + return terminated + } + + /** Admit by ADDRESS so the identity lookup and the decision cannot straddle a disconnect. + * Returns null only when the address has no live connection — callers must fail closed, never + * mint an identity from a write, or a departing client can be resurrected and take ownership. */ + @Synchronized fun admit(address: String, opcode: Int, bytes: ByteArray?): Admission? { + val client = keys[address] ?: return null + if (invalidSession || pending != null) return Admission.Rejected(OPERATION_FAILED, client) if (if (opcode == REQUEST_CONTROL) owner != null && owner != client else owner != client) - return Admission.Rejected(CONTROL_NOT_PERMITTED) - return Procedure(client, opcode).let { pending = it; Admission.Admitted(it) } + return Admission.Rejected(CONTROL_NOT_PERMITTED, client) + return Procedure(client, opcode, ++procedures).let { + pending = it; pendingBytes = bytes; Admission.Admitted(it) + } } - @Synchronized fun admitLocal(opcode: Int): Boolean { - if (invalidSession || owner != null || pending != null) return false - pending = Procedure(null, opcode) - return true + @Synchronized fun admitLocal(opcode: Int, bytes: ByteArray?): Procedure? { + if (invalidSession || owner != null || pending != null) return null + return Procedure(null, opcode, ++procedures).also { pending = it; pendingBytes = bytes } } - @Synchronized fun transportFailed(client: Client?, opcode: Int): Client? { - if (pending != Procedure(client, opcode)) return null - pending = null - return client + /** Terminates exactly the procedure named — never a newer one that reused the opcode. */ + @Synchronized fun transportFailed(procedure: Procedure): Terminated? { + if (pending != procedure) return null + val terminated = Terminated(procedure, pendingBytes) + pending = null; pendingBytes = null + return terminated + } + + /** No FTMS response arrived in time. A late opcode-only response can no longer be correlated to a + * request, so the trainer session is quarantined exactly as it is for a controller that vanished + * mid-procedure. Returns non-null whenever it MATCHED, including a local procedure with no client — + * a null return means "not the pending procedure", and only that may skip the recovery. */ + @Synchronized fun timedOut(procedure: Procedure): Terminated? { + if (pending != procedure) return null + val terminated = Terminated(procedure, pendingBytes) + pending = null; pendingBytes = null; invalidSession = true + return terminated } - @Synchronized fun response(opcode: Int, result: Int): Client? { + /** Returns the procedure the response terminated together with the bytes it carried, so the caller + * commits the target that was actually acknowledged and cancels the right deadline. */ + @Synchronized fun response(opcode: Int, result: Int): Terminated? { if (invalidSession) return null val procedure = pending?.takeIf { it.opcode == opcode } ?: return null - pending = null + val terminated = Terminated(procedure, pendingBytes) + pending = null; pendingBytes = null if (procedure.client != null && opcode == REQUEST_CONTROL && result == SUCCESS) owner = procedure.client - return procedure.client + return terminated } - @Synchronized fun disconnect(client: Client): Boolean { - val lostPending = pending?.client == client - if (owner == client) owner = null - if (lostPending) { pending = null; invalidSession = true } - return lostPending + /** The owner could not be told the outcome; drop its claim so the next requester can arbitrate. */ + @Synchronized fun releaseOwner(client: Client): Boolean { + if (owner != client) return false + owner = null + return true } @Synchronized fun trainerDropped(): Client? = owner.also { - owner = null; pending = null + owner = null; pending = null; pendingBytes = null } @Synchronized fun trainerReady() { invalidSession = false } - @Synchronized fun clear() { owner = null; pending = null; invalidSession = false } + /** Full teardown, used by BOTH stop() and a local GATT server rebuild. Closing the server invalidates + * every ATT handle and delivers no disconnect callbacks, so keeping any of this would strand ownership + * on a generation that can never come back. */ + @Synchronized fun clear() { + owner = null; pending = null; pendingBytes = null; invalidSession = false; keys.clear() + } companion object { const val SUCCESS = 0x01 diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt index 221fc74..ce85515 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt @@ -46,6 +46,9 @@ class MirrorServer( private val correction: () -> PowerCorrection, private val toZycle: (charUuid: UUID, bytes: ByteArray, withResponse: Boolean, onComplete: (Boolean) -> Unit) -> Boolean, private val onTrainerRecycle: () -> Unit = {}, + /** The trainer ACCEPTED a control procedure (FTMS Response Code = SUCCESS) with these exact bytes. + * ERG bias learning and the UI must hang off this, not off the ATT write callback. */ + private val onControlAccepted: (ByteArray) -> Unit = {}, private val onStatus: (String) -> Unit = {}, /** Health report to the UI: true once we're actually advertising; false if the server/advertising fails. */ private val onAdvState: (Boolean) -> Unit = {}, @@ -64,13 +67,23 @@ class MirrorServer( private val cache = ConcurrentHashMap() // last value (power corrected) private val subscribers = ConcurrentHashMap>() // char uuid → subscribed client addrs private val clients = ConcurrentHashMap() // connected centrals - private val clientKeys = ConcurrentHashMap() - private val clientGeneration = java.util.concurrent.atomic.AtomicLong() private val ftmsControl = FtmsControlCoordinator() + /** Deadline for the admitted FTMS procedure. Without it one unanswered Control Point response wedges + * the gate and every later request — Request Control included — is refused for the rest of the ride. */ + /** The live procedure and its timer, kept together so a cancellation can prove it owns the deadline. + * Mostly main-looper, but rebuildServer() reaches it from a binder thread, hence @Volatile. */ + @Volatile private var procedureDeadline: Pair? = null + /** The client whose TERMINAL Control Point result is currently in flight, so a later failure report + * from onNotificationSent can be attributed. Measurement notifications never set this. */ + @Volatile private var terminalIndication: FtmsControlCoordinator.Client? = null // touched from the GATT server binder thread, the client's binder thread and main — a plain ArrayDeque // can throw mid-poll when stop() clears it, and an exception on a binder callback kills the process private val pendingServices = java.util.concurrent.ConcurrentLinkedDeque() + // Armed only AFTER the trainer write completes, so it measures the machine and not our own op queue. + // FTMS conformance ties the collector's wait to the ATT transaction timeout (~30 s); anything shorter + // recycles the trainer link on a healthy-but-slow machine, which costs the rider far more than waiting. + private val PROCEDURE_TIMEOUT_MS = 30_000L private val ADV_RESTART_MS = 250L private val ADV_RETRY_MS = 1000L private val ADV_RETRY_MAX_MS = 30_000L // backoff ceiling; there is no attempt cap (see scheduleAdvRetry) @@ -328,15 +341,21 @@ class MirrorServer( @Synchronized private fun addNextService() { if (serviceAddOwner.current != null) return val svc = pendingServices.peek() ?: return - if (runCatching { server?.addService(svc) }.getOrNull() == true) { - serviceAddOwner.replace(svc) - val watchdog = Runnable { - if (serviceAddOwner.clearIfCurrent(svc) { serviceAddWatchdog = null }) - rebuildServer("addService callback timeout for ${shortUuid(svc.uuid)}") - } - serviceAddWatchdog = watchdog - handler.postDelayed(watchdog, SERVICE_ADD_TIMEOUT_MS) - } else { + // Claim ownership BEFORE the binder call: onServiceAdded can arrive on a binder thread before a + // post-call assignment lands, and a callback that finds no owner is discarded as stale — costing an + // 8 s watchdog and a whole server rebuild for an add that actually succeeded. + val watchdog = Runnable { + if (serviceAddOwner.clearIfCurrent(svc) { serviceAddWatchdog = null }) + rebuildServer("addService callback timeout for ${shortUuid(svc.uuid)}") + } + serviceAddWatchdog = watchdog + handler.postDelayed(watchdog, SERVICE_ADD_TIMEOUT_MS) + serviceAddOwner.replace(svc) + val accepted = runCatching { server?.addService(svc) }.getOrNull() == true + // Retract the claim only if the callback has not already consumed it (it may have completed the + // add while the binder call was still returning). + if (!accepted && serviceAddOwner.clearIfCurrent(svc) { serviceAddWatchdog = null }) { + handler.removeCallbacks(watchdog) if (serviceRetries++ >= SERVICE_MAX_RETRIES) { rebuildServer("addService refused for ${shortUuid(svc.uuid)}") return @@ -359,6 +378,17 @@ class MirrorServer( handler.removeCallbacks(serviceRetryRunnable) pendingServices.clear() chars.clear() + // Closing the server invalidates every ATT handle and delivers NO disconnect callbacks, so any + // controller identity kept here would strand FTMS ownership on a generation that can never return: + // the app reconnects, gets a new generation, and is refused CONTROL_NOT_PERMITTED for the ride. + procedureDeadline?.let { handler.removeCallbacks(it.second) } + procedureDeadline = null + ftmsControl.clear() + terminalIndication = null + clients.clear(); subscribers.clear() + // ...and never keep broadcasting under the trainer's name with no server behind it: an app that + // connects during the rebuild window finds no services and caches a broken device for the session. + stopAdvertising() val failedServer = synchronized(serverLock) { server.also { server = null @@ -379,13 +409,16 @@ class MirrorServer( serviceAddWatchdog?.let { handler.removeCallbacks(it) } serviceAddWatchdog = null ftmsControl.clear() + terminalIndication = null + procedureDeadline?.let { handler.removeCallbacks(it.second) } + procedureDeadline = null stopAdvertising() handler.removeCallbacksAndMessages(null) // pending adv starts / service retries must not outlive us runCatching { server?.close() } server = null restoreName() built.set(false); advBlueprint = null - chars.clear(); cache.clear(); subscribers.clear(); clients.clear(); clientKeys.clear(); pendingServices.clear() + chars.clear(); cache.clear(); subscribers.clear(); clients.clear(); pendingServices.clear() shownZycleLevel = null; lastRawZycleLevel = null; lastControlWriteMs = 0L; servoStepOwed = false; reanchorLevel = false } @@ -418,9 +451,76 @@ class MirrorServer( val clientCount: Int get() = clients.size val levelDebug: String get() = "${shownZycleLevel ?: "-"}/${lastRawZycleLevel ?: "-"}" - fun admitLocalControl(opcode: Int): Boolean = ftmsControl.admitLocal(opcode) - fun localControlTransportFailed(opcode: Int) { ftmsControl.transportFailed(null, opcode) } - fun releaseFtmsQuarantine() { ftmsControl.trainerReady() } + /** Non-null once admitted: hand the same token back so a late failure cannot kill a newer procedure. + * The bytes travel WITH the procedure — a side slot let one procedure's response commit another's + * target, and left the local button with no payload at all. */ + internal fun admitLocalControl(opcode: Int, bytes: ByteArray): FtmsControlCoordinator.Procedure? = + ftmsControl.admitLocal(opcode, bytes)?.also { + FileLog.event("ftms ADMITTED local op=0x%02X #%d".format(opcode, it.id)) + } + internal fun localControlTransportFailed(procedure: FtmsControlCoordinator.Procedure) { + // Only the procedure that was actually still pending may cancel its deadline: a stale failure + // arriving after a newer procedure was admitted must not disarm the newer one's timer. + if (ftmsControl.transportFailed(procedure) != null) cancelProcedureDeadline(procedure) + } + /** The trainer took the write. Only now does the response clock start — arming at admission also + * measured our own serialised op queue, so a slow predecessor could expire a healthy procedure. */ + internal fun localControlDispatched(procedure: FtmsControlCoordinator.Procedure) = + armProcedureDeadline(procedure) + + fun releaseFtmsQuarantine() { + ftmsControl.trainerReady() + FileLog.event("ftms quarantine released") + } + + private fun armProcedureDeadline(procedure: FtmsControlCoordinator.Procedure) { + handler.post { + // An arm can reach main AFTER its own procedure ended and a newer one was armed — the local + // button path crosses two main-loop hops, so it loses that race routinely. Replacing the live + // deadline here would strip a healthy procedure of its only timer and leave it unbounded. + if (!ftmsControl.isPending(procedure)) return@post + procedureDeadline?.let { handler.removeCallbacks(it.second) } + val deadline = Runnable { + procedureDeadline = null + // A null return means "not the pending procedure" — the ONLY case that may skip recovery. + // A matched LOCAL procedure legitimately has no client, and skipping recovery for it left + // the session quarantined with nothing able to lift it for the rest of the ride. + val terminated = ftmsControl.timedOut(procedure) ?: return@Runnable + FileLog.event("ftms TIMEOUT op=0x%02X #%d — no trainer response in ${PROCEDURE_TIMEOUT_MS}ms" + .format(procedure.opcode, procedure.id)) + terminated.client?.let { + notifyControlResult(it, byteArrayOf( + 0x80.toByte(), procedure.opcode.toByte(), + FtmsControlCoordinator.OPERATION_FAILED.toByte())) + } + // a late opcode-only response can no longer be matched to a request: recycle the link + handler.post(onTrainerRecycle) + } + procedureDeadline = procedure to deadline + handler.postDelayed(deadline, PROCEDURE_TIMEOUT_MS) + } + } + + /** Cancels ONLY this procedure's deadline. Arming and cancelling are both posted from binder threads, + * so "cancel whatever is current" could disarm the timer of a procedure admitted in between. */ + private fun cancelProcedureDeadline(procedure: FtmsControlCoordinator.Procedure) { + handler.post { + val live = procedureDeadline ?: return@post + if (live.first.id != procedure.id) return@post + handler.removeCallbacks(live.second) + procedureDeadline = null + } + } + + /** The trainer's verdict. Only SUCCESS commits UI/servo/ERG state, and it commits the bytes THAT + * procedure carried — including the local button's, which must reach ErgBias so an armed ERG target + * is retired when the rider takes manual control. */ + private fun onControlOutcome(terminated: FtmsControlCoordinator.Terminated, accepted: Boolean) { + if (!accepted) return + lastControlWriteMs = SystemClock.elapsedRealtime() + servoStepOwed = true + terminated.bytes?.let(onControlAccepted) + } /** A value arrived from the trainer: correct power, cache, and notify every subscribed client. */ fun onZycleValue(charUuid: UUID, value: ByteArray) { @@ -428,7 +528,14 @@ class MirrorServer( value[0].toInt() and 0xFF == 0x80) { val opcode = value[1].toInt() and 0xFF val result = value[2].toInt() and 0xFF - ftmsControl.response(opcode, result)?.let { notifyControlResult(it, value) } + ftmsControl.response(opcode, result)?.let { terminated -> + cancelProcedureDeadline(terminated.procedure) + FileLog.event("ftms RESPONSE op=0x%02X result=0x%02X #%d -> %s".format(opcode, result, + terminated.procedure.id, + terminated.client?.let { "${it.address}#${it.generation}" } ?: "local")) + terminated.client?.let { notifyControlResult(it, value) } + onControlOutcome(terminated, result == FtmsControlCoordinator.SUCCESS) + } return } val out = when { @@ -488,8 +595,8 @@ class MirrorServer( } @Suppress("DEPRECATION") - private fun notify(srv: BluetoothGattServer, dev: BluetoothDevice, ch: BluetoothGattCharacteristic, value: ByteArray, indicate: Boolean) { - runCatching { + private fun notify(srv: BluetoothGattServer, dev: BluetoothDevice, ch: BluetoothGattCharacteristic, value: ByteArray, indicate: Boolean): Boolean { + return runCatching { val ok = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) srv.notifyCharacteristicChanged(dev, ch, indicate, value) == android.bluetooth.BluetoothStatusCodes.SUCCESS else { @@ -499,31 +606,55 @@ class MirrorServer( } // a refused notification is a silently dropped sample — "power froze in the app" if (!ok) FileLog.event("notify ${shortUuid(ch.uuid)} REFUSED (buffer full?) -> ${dev.address}") - } + ok + }.getOrDefault(false) } - private fun notifyControlResult(client: FtmsControlCoordinator.Client, value: ByteArray) { + /** EVERY path that fails to hand a TERMINAL result to its origin must release that origin's claim: + * a controller waiting for a response it will never see would otherwise keep ERG locked for everyone. + * A rejection is not terminal for an admitted procedure and carries no such authority — a stale one + * could otherwise revoke ownership the same client legitimately acquired in the meantime. */ + private fun undeliverable(client: FtmsControlCoordinator.Client, why: String, terminal: Boolean) { + if (!terminal || !ftmsControl.owns(client)) return + if (ftmsControl.releaseOwner(client)) + FileLog.event("ftms terminal result undeliverable ($why) -> ${client.address}#${client.generation} — owner released") + } + + private fun notifyControlResult( + client: FtmsControlCoordinator.Client, + value: ByteArray, + terminal: Boolean = true, + ) { val uuid = GattUuids.FTMS_CONTROL_POINT - val ch = chars[uuid] ?: return - val subs = subscribers[uuid] ?: return - if (clientKeys[client.address] != client || !synchronized(subs) { subs.contains(client.address) }) return + val ch = chars[uuid] ?: return undeliverable(client, "no local characteristic", terminal) + val subs = subscribers[uuid] ?: return undeliverable(client, "no subscriber set", terminal) + if (ftmsControl.identity(client.address) != client) return undeliverable(client, "stale generation", terminal) + if (!synchronized(subs) { subs.contains(client.address) }) return undeliverable(client, "not subscribed", terminal) handler.post { - val srv = server ?: return@post - if (clientKeys[client.address] != client) return@post - val dev = clients[client.address] ?: return@post - val currentSubs = subscribers[uuid] ?: return@post - if (!synchronized(currentSubs) { currentSubs.contains(client.address) }) return@post - notify(srv, dev, ch, value, ch.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) + val srv = server ?: return@post undeliverable(client, "server closed", terminal) + if (ftmsControl.identity(client.address) != client) return@post undeliverable(client, "stale generation", terminal) + val dev = clients[client.address] ?: return@post undeliverable(client, "device gone", terminal) + val currentSubs = subscribers[uuid] ?: return@post undeliverable(client, "no subscriber set", terminal) + if (!synchronized(currentSubs) { currentSubs.contains(client.address) }) + return@post undeliverable(client, "unsubscribed before send", terminal) + if (terminal) terminalIndication = client + if (!notify(srv, dev, ch, value, ch.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0)) { + terminalIndication = null + // The controller would wait forever for a response it will never get. Drop its claim and cut + // the link so it reconnects and re-arbitrates instead of sitting there believing it owns ERG. + undeliverable(client, "indication refused", terminal) + cancelClient(client) + } } } private fun cancelClient(client: FtmsControlCoordinator.Client) { - if (clientKeys[client.address] != client) return + if (ftmsControl.identity(client.address) != client) return clients[client.address]?.let { server?.cancelConnection(it) } } private val serverCallback = object : BluetoothGattServerCallback() { - override fun onServiceAdded(status: Int, service: BluetoothGattService?) { + @Synchronized override fun onServiceAdded(status: Int, service: BluetoothGattService?) { val added = service ?: return if (!serviceAddOwner.clearIfCurrent(added) { serviceAddWatchdog?.let { handler.removeCallbacks(it) } @@ -557,7 +688,7 @@ class MirrorServer( return } if (newState == android.bluetooth.BluetoothProfile.STATE_CONNECTED) { - clientKeys[device.address] = FtmsControlCoordinator.Client(device.address, clientGeneration.incrementAndGet()) + ftmsControl.connected(device.address) clients[device.address] = device onStatus(context.getString(R.string.status_app_connected, clients.size)) FileLog.event("app connected ${device.address} status=$status (${clients.size} total)") @@ -565,7 +696,9 @@ class MirrorServer( // central (e.g. the Garmin) can still discover us. handler.post { restartAdvertising() } } else { - val lostPending = clientKeys.remove(device.address)?.let { ftmsControl.disconnect(it) } == true + val lostPending = ftmsControl.disconnected(device.address)?.also { + cancelProcedureDeadline(it.procedure) + } != null clients.remove(device.address); subscribers.values.forEach { it.remove(device.address) } onStatus(context.getString(R.string.status_app_disconnected, clients.size)) FileLog.event("app disconnected ${device.address} status=$status (${clients.size} left)") @@ -625,22 +758,47 @@ class MirrorServer( // matters if some app starts using long writes — the log line above says when it happens. if (!preparedWrite && uuid == GattUuids.FTMS_CONTROL_POINT && device != null && value.isNotEmpty()) { val opcode = value[0].toInt() and 0xFF - val client = clientKeys[device.address] - when (val admission = client?.let { ftmsControl.admit(it, opcode) }) { - null -> Unit - is FtmsControlCoordinator.Admission.Rejected -> - notifyControlResult(client, byteArrayOf(0x80.toByte(), opcode.toByte(), admission.result.toByte())) + when (val admission = ftmsControl.admit(device.address, opcode, out)) { + // No live identity: fail CLOSED. Minting one here from `clients` — which is cleared + // a few instructions after the coordinator on disconnect and on rebuild — would let + // a departing app be resurrected and promoted to owner, which is the exact hole the + // by-address admission was written to close. + null -> FileLog.event("ftms REJECTED op=0x%02X — no live connection <- %s" + .format(opcode, device.address)) + is FtmsControlCoordinator.Admission.Rejected -> { + FileLog.event("ftms REJECTED op=0x%02X result=0x%02X <- %s" + .format(opcode, admission.result, device.address)) + admission.client?.let { + notifyControlResult(it, + byteArrayOf(0x80.toByte(), opcode.toByte(), admission.result.toByte()), + terminal = false) + } + } is FtmsControlCoordinator.Admission.Admitted -> { val procedure = admission.procedure + FileLog.event("ftms ADMITTED op=0x%02X #%d <- %s#%d" + .format(opcode, procedure.id, device.address, procedure.client?.generation ?: 0L)) relayed = toZycle(uuid, out, withResponse) { success -> - if (success) { - lastControlWriteMs = SystemClock.elapsedRealtime() - servoStepOwed = true - } else ftmsControl.transportFailed(procedure.client, procedure.opcode)?.let { - notifyControlResult(it, byteArrayOf( + // Transport success is NOT command success: FTMS puts acceptance in the + // Response Code, so nothing commits until onZycleValue sees it. The response + // clock starts HERE, once the machine actually has the write. + if (success) armProcedureDeadline(procedure) + else ftmsControl.transportFailed(procedure)?.let { + cancelProcedureDeadline(procedure) + it.client?.let { c -> + notifyControlResult(c, byteArrayOf( + 0x80.toByte(), procedure.opcode.toByte(), + FtmsControlCoordinator.OPERATION_FAILED.toByte(), + )) + } + } + } + if (!relayed) ftmsControl.transportFailed(procedure)?.let { + cancelProcedureDeadline(procedure) + it.client?.let { c -> + notifyControlResult(c, byteArrayOf( 0x80.toByte(), procedure.opcode.toByte(), - FtmsControlCoordinator.OPERATION_FAILED.toByte(), - )) + FtmsControlCoordinator.OPERATION_FAILED.toByte())) } } } @@ -682,7 +840,18 @@ class MirrorServer( /** Notification flow control: a failure here means our notifications stopped reaching the app. */ override fun onNotificationSent(device: BluetoothDevice?, status: Int) { - if (status != BluetoothGatt.GATT_SUCCESS) FileLog.event("notify FAILED status=$status -> ${device?.address}") + if (status == BluetoothGatt.GATT_SUCCESS) return + FileLog.event("notify FAILED status=$status -> ${device?.address}") + // notifyCharacteristicChanged() returning success only means the send was accepted for + // dispatch; THIS is where Android reports whether it actually landed. If the send in flight + // was a terminal Control Point result, its origin will wait forever for an answer it will + // never see, while every other app is refused because it still holds control. + val addr = device?.address ?: return + val pendingTerminal = terminalIndication ?: return + if (pendingTerminal.address != addr) return + terminalIndication = null + undeliverable(pendingTerminal, "onNotificationSent status=$status", terminal = true) + cancelClient(pendingTerminal) } override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) { diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt index 0be4f43..f249da0 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt @@ -253,13 +253,23 @@ class ZycleClient( private fun scheduleWriteRetry(request: WriteReq) { handler.postDelayed({ if (!stopped && writeSeq.get() == request.ticket.sequence && gattSessions.current === request.session) { + FileLog.event("Zycle write ${shortUuid(request.uuid)} #${request.ticket.sequence} RETRY") enqueueWrite(request.copy(retriesLeft = request.retriesLeft - 1)) - } else completeWrite(request, false) + } else { + // The ordering invariant lives or dies here: without this line a superseded retry is + // indistinguishable from a lost write in the log. + FileLog.event("Zycle write ${shortUuid(request.uuid)} #${request.ticket.sequence} " + + "SUPERSEDED (now #${writeSeq.get()})") + completeWrite(request, false) + } }, CONTROL_RETRY_DELAY_MS) } private fun completeWrite(request: WriteReq, success: Boolean) { pendingWrites.remove(request.ticket) + if (GattUuids.carriesControl(request.uuid)) + FileLog.event("Zycle write ${shortUuid(request.uuid)} #${request.ticket.sequence} " + + if (success) "COMPLETED" else "FAILED") runCatching { request.ticket.complete(success) } .onFailure { FileLog.event("Zycle write ${shortUuid(request.uuid)} completion callback FAILED") } } @@ -511,10 +521,21 @@ class ZycleClient( gattSessions.runIfCurrent(g) { if (status == BluetoothGatt.GATT_SUCCESS) { val v = @Suppress("DEPRECATION") (ch.value?.copyOf() ?: ByteArray(0)) - FileLog.event("Zycle read ${shortUuid(ch.uuid)} = ${FileLog.hex(v)}") // identity/feature/ranges values - onValue(ch.uuid, v) - } else FileLog.event("Zycle read ${shortUuid(ch.uuid)} failed status=$status") - if (ch.uuid == FTMS_FEATURE) bootstrapReadiness?.featureRead = status == BluetoothGatt.GATT_SUCCESS + // A GATT_SUCCESS read of a too-short value is not a Feature. Validate BEFORE + // publishing: onValue() seeds lastValues and any already-connected mirror client, + // which would then cache "no ERG, no automatic mode" for its whole connection. + // 0x2ACC is two mandatory 32-bit fields: Fitness Machine Features AND Target Setting + // Features. A 4-byte read has no Target Setting Features, so the client concludes + // "no ERG, no automatic mode" — the exact failure this gate exists to prevent. + val usable = ch.uuid != FTMS_FEATURE || v.size >= 8 + FileLog.event("Zycle read ${shortUuid(ch.uuid)} = ${FileLog.hex(v)}" + + if (!usable) " — REJECTED, too short for FTMS Feature" else "") + if (usable) onValue(ch.uuid, v) + if (ch.uuid == FTMS_FEATURE) bootstrapReadiness?.featureRead = usable + } else { + FileLog.event("Zycle read ${shortUuid(ch.uuid)} failed status=$status") + if (ch.uuid == FTMS_FEATURE) bootstrapReadiness?.featureRead = false + } lastMessageMs = android.os.SystemClock.elapsedRealtime() opDone() } @@ -577,7 +598,24 @@ class ZycleClient( ) private fun enqueueSubscribe(g: BluetoothGatt, ch: BluetoothGattCharacteristic, retry: Boolean = true): Unit = enqueue { - g.setCharacteristicNotification(ch, true) + // The CCCD write tells the TRAINER to send; this tells ANDROID to deliver. If only the former + // succeeds we advertise as controllable and no Control Point response ever reaches us. + if (!g.setCharacteristicNotification(ch, true)) { + // Requeue once, like the writeDescriptor refusal below: a transient false during re-discovery + // would otherwise fail readiness permanently and drop us into a reconnect loop. + FileLog.event("Zycle subscribe ${shortUuid(ch.uuid)} — local registration REFUSED" + + if (retry) " — requeueing" else " — giving up") + if (retry) handler.postDelayed({ + gattSessions.runIfCurrent(g) { + enqueueSubscribe(g, ch, retry = false) + opDone() + } + }, OP_REQUEUE_MS) else { + markBootstrapSubscription(ch.uuid, false) + opDone() + } + return@enqueue + } val d = ch.getDescriptor(cccd) if (d == null) { markBootstrapSubscription(ch.uuid, false) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 926a11c..d33a837 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -56,6 +56,8 @@ buscando trainer… iniciando… app conectada (%d) + una app controla el rodillo + no se pudo mantener la CPU activa app desconectada (%d) anunciando %s fallo al anunciar (%d) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51f7695..5a72383 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,6 +56,8 @@ searching trainer… starting… app connected (%d) + an app is controlling the trainer + could not keep the CPU awake app disconnected (%d) advertising %s advertise failed (%d) diff --git a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt index d60c1b9..cd3fdbc 100644 --- a/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt +++ b/app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt @@ -6,6 +6,7 @@ import kotlin.concurrent.thread import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -29,144 +30,281 @@ import org.junit.Test * ownership rejection is checked below; the call shape is not. */ class RuntimeHardeningTest { - private val clientA = FtmsControlCoordinator.Client("A", 1L) - private val clientB = FtmsControlCoordinator.Client("B", 1L) + private fun admitted(c: FtmsControlCoordinator.Admission?) = + (c as FtmsControlCoordinator.Admission.Admitted).procedure + + private fun rejected(result: Int, client: FtmsControlCoordinator.Client?) = + FtmsControlCoordinator.Admission.Rejected(result, client) + + private val erg = byteArrayOf(0x05, 0xF0.toByte(), 0x00) // Set Target Power 240 W + private val res = byteArrayOf(0x04, 0x24, 0x00) // Set Target Resistance 36 @Test fun localProcedureBlocksExternalAndDrainsWithoutClientNotification() { val coordinator = FtmsControlCoordinator() + val a = coordinator.connected("A") - assertTrue(coordinator.admitLocal(0x04)) - assertEquals( - FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), - coordinator.admit(clientA, 0x00), - ) + assertNotNull(coordinator.admitLocal(0x04, res)) + assertEquals(rejected(FtmsControlCoordinator.OPERATION_FAILED, a), coordinator.admit("A", 0x00, null)) assertNull(coordinator.response(0x05, FtmsControlCoordinator.SUCCESS)) - assertEquals( - FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), - coordinator.admit(clientA, 0x00), - ) - assertNull(coordinator.response(0x04, FtmsControlCoordinator.SUCCESS)) - assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x00)), - coordinator.admit(clientA, 0x00), - ) + assertEquals(rejected(FtmsControlCoordinator.OPERATION_FAILED, a), coordinator.admit("A", 0x00, null)) + assertNull(coordinator.response(0x04, FtmsControlCoordinator.SUCCESS)?.client) + assertNotNull(admitted(coordinator.admit("A", 0x00, null))) coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) - assertFalse(coordinator.admitLocal(0x04)) + assertNull(coordinator.admitLocal(0x04, res)) } - @Test fun disconnectReportsWhenPendingProcedureWasLost() { + /** The C1 fix, and the bug the first attempt at it introduced: a LOCAL procedure has no client, so a + * `Client?` return could not distinguish "timed out" from "not the pending one". Skipping recovery on + * that null left the session quarantined with nothing able to lift it for the rest of the ride. */ + @Test fun aTimedOutLocalProcedureStillReportsItsTerminationSoRecoveryRuns() { val coordinator = FtmsControlCoordinator() - coordinator.admit(clientA, 0x00) + val local = coordinator.admitLocal(0x04, res)!! + assertNull(local.client) - assertFalse(coordinator.disconnect(clientB)) - assertTrue(coordinator.disconnect(clientA)) - assertEquals( - FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), - coordinator.admit(clientB, 0x00), - ) + val terminated = coordinator.timedOut(local) + assertNotNull(terminated) // matched, even with no client to notify + assertNull(terminated!!.client) + assertEquals(local, terminated.procedure) + } + + @Test fun anUnansweredProcedureIsReleasedByItsDeadlineAndQuarantinesTheLink() { + val coordinator = FtmsControlCoordinator() + coordinator.connected("A") + coordinator.admit("A", 0x00, null) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) // A owns control + val stuck = admitted(coordinator.admit("A", 0x04, res)) // trainer never answers + + assertEquals(FtmsControlCoordinator.OPERATION_FAILED, + (coordinator.admit("A", 0x04, res) as FtmsControlCoordinator.Admission.Rejected).result) + assertEquals("A", coordinator.timedOut(stuck)?.client?.address) + // quarantined until the trainer link is recycled — not silently reopened + assertEquals(FtmsControlCoordinator.OPERATION_FAILED, + (coordinator.admit("A", 0x04, res) as FtmsControlCoordinator.Admission.Rejected).result) + coordinator.trainerReady() + assertNotNull(admitted(coordinator.admit("A", 0x04, res))) + } + + @Test fun anExpiredDeadlineCannotTerminateTheProcedureThatReplacedIt() { + val coordinator = FtmsControlCoordinator() + coordinator.connected("A") + coordinator.admit("A", 0x00, null) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + val first = admitted(coordinator.admit("A", 0x04, res)) + coordinator.response(0x04, FtmsControlCoordinator.SUCCESS) + val second = admitted(coordinator.admit("A", 0x04, res)) + + assertNull(coordinator.timedOut(first)) // stale: must not quarantine or cancel + assertNull(coordinator.transportFailed(first)) // ...and must not disarm the newer deadline + assertEquals("A", coordinator.transportFailed(second)?.client?.address) + } + + /** The payload must ride WITH the procedure. As a side slot, one procedure's response committed + * another's target — teaching ERG bias a number the machine was never holding. */ + @Test fun aResponseCommitsTheBytesOfTheProcedureItTerminated() { + val coordinator = FtmsControlCoordinator() + coordinator.connected("A") + coordinator.admit("A", 0x00, null) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + + val ergProcedure = admitted(coordinator.admit("A", 0x05, erg)) + assertArrayEquals(erg, coordinator.transportFailed(ergProcedure)?.bytes) // failed: bytes came back + // ...and the slot is empty afterwards, so a later local success cannot commit them + val local = coordinator.admitLocal(0x04, res) + assertNull(local) // A still owns control coordinator.trainerDropped() - assertEquals( - FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), - coordinator.admit(clientB, 0x00), - ) + val afterDrop = coordinator.admitLocal(0x04, res)!! + assertArrayEquals(res, coordinator.response(0x04, FtmsControlCoordinator.SUCCESS)?.bytes) + assertEquals(0x04, afterDrop.opcode) + } + + /** The local button carries 0x04, which is ErgBias's signal to retire an armed ERG target. Routing it + * through the mirror without a payload silently stopped that signal from ever arriving. */ + @Test fun aLocalProcedureCarriesItsOwnBytesToTheCommitPoint() { + val coordinator = FtmsControlCoordinator() + coordinator.admitLocal(0x04, res) + val terminated = coordinator.response(0x04, FtmsControlCoordinator.SUCCESS) + assertNotNull(terminated) + assertArrayEquals(res, terminated!!.bytes) + assertNull(terminated.client) + } + + /** The C2 fix. The identity lookup and the admission are one step, so a disconnect cannot land between + * them and let a client that is already gone be promoted to owner — locking out its own reconnect. */ + @Test fun admissionAfterDisconnectIsRefusedRatherThanPromotingAGhost() { + val coordinator = FtmsControlCoordinator() + val first = coordinator.connected("A") + coordinator.disconnected("A") + + assertNull(coordinator.admit("A", 0x00, null)) // fail closed: no live identity for that address + val second = coordinator.connected("A") + assertTrue(first != second) // reconnect gets its own generation + assertEquals(second, admitted(coordinator.admit("A", 0x00, null)).client) + assertEquals(second, coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)?.client) + } + + @Test fun disconnectReportsWhenPendingProcedureWasLost() { + val coordinator = FtmsControlCoordinator() + coordinator.connected("A") + val b = coordinator.connected("B") + val lost = admitted(coordinator.admit("A", 0x00, null)) + + assertEquals(lost, coordinator.disconnected("A")?.procedure) + assertEquals(rejected(FtmsControlCoordinator.OPERATION_FAILED, b), coordinator.admit("B", 0x00, null)) coordinator.trainerReady() - assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientB, 0x00)), - coordinator.admit(clientB, 0x00), - ) + assertNotNull(admitted(coordinator.admit("B", 0x00, null))) } - @Test fun reconnectWithSameAddressHasDifferentClientIdentity() { - assertFalse( - FtmsControlCoordinator.Client("A", 1L) == FtmsControlCoordinator.Client("A", 2L), - ) + @Test fun disconnectWithoutAPendingProcedureRemovesTheIdentityAndReportsNoLoss() { + val coordinator = FtmsControlCoordinator() + coordinator.connected("A") + assertNull(coordinator.disconnected("A")) + assertNull(coordinator.identity("A")) // the key really is gone, not merely unreported + assertNull(coordinator.disconnected("A")) // idempotent: no second recycle } @Test fun firstSuccessfulRequestControlOwnsFtms() { val coordinator = FtmsControlCoordinator() - - assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x00)), - coordinator.admit(clientA, 0x00), - ) - assertEquals(clientA, coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)) - assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x05)), - coordinator.admit(clientA, 0x05), - ) + val a = coordinator.connected("A") + assertEquals(a, admitted(coordinator.admit("A", 0x00, null)).client) + assertEquals(a, coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)?.client) + assertNotNull(admitted(coordinator.admit("A", 0x05, erg))) } @Test fun secondClientCannotControlOrStealOwnership() { val coordinator = FtmsControlCoordinator() - coordinator.admit(clientA, 0x00) + coordinator.connected("A"); val b = coordinator.connected("B") + coordinator.admit("A", 0x00, null) coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) - assertEquals( - FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.CONTROL_NOT_PERMITTED), - coordinator.admit(clientB, 0x05), - ) - assertEquals( - FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.CONTROL_NOT_PERMITTED), - coordinator.admit(clientB, 0x00), - ) + assertEquals(rejected(FtmsControlCoordinator.CONTROL_NOT_PERMITTED, b), coordinator.admit("B", 0x05, erg)) + assertEquals(rejected(FtmsControlCoordinator.CONTROL_NOT_PERMITTED, b), coordinator.admit("B", 0x00, null)) } - @Test fun onlyOneProcedureCanBePending() { + /** A terminal indication that never reached its origin leaves that controller waiting forever; + * dropping the claim is what lets anyone else — including its own reconnect — arbitrate again. */ + @Test fun releasingAnUndeliverableOwnerReopensArbitration() { val coordinator = FtmsControlCoordinator() + val a = coordinator.connected("A") + val b = coordinator.connected("B") + coordinator.admit("A", 0x00, null) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) - coordinator.admit(clientA, 0x00) - assertEquals( - FtmsControlCoordinator.Admission.Rejected(FtmsControlCoordinator.OPERATION_FAILED), - coordinator.admit(clientA, 0x00), - ) - assertNull(coordinator.transportFailed(clientB, 0x00)) - assertEquals(clientA, coordinator.transportFailed(clientA, 0x00)) - assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x00)), - coordinator.admit(clientA, 0x00), - ) + assertEquals(rejected(FtmsControlCoordinator.CONTROL_NOT_PERMITTED, b), coordinator.admit("B", 0x00, null)) + assertTrue(coordinator.releaseOwner(a)) + assertFalse(coordinator.releaseOwner(a)) // only the current owner, only once + assertNotNull(admitted(coordinator.admit("B", 0x00, null))) } - @Test fun responseRoutesOnlyToMatchingOrigin() { + @Test fun onlyOneProcedureCanBePending() { val coordinator = FtmsControlCoordinator() - coordinator.admit(clientA, 0x00) + val a = coordinator.connected("A") + val first = admitted(coordinator.admit("A", 0x00, null)) + assertEquals(rejected(FtmsControlCoordinator.OPERATION_FAILED, a), coordinator.admit("A", 0x00, null)) + coordinator.transportFailed(first) + assertNotNull(admitted(coordinator.admit("A", 0x00, null))) + } + @Test fun responseRoutesOnlyToMatchingOrigin() { + val coordinator = FtmsControlCoordinator() + val a = coordinator.connected("A") + coordinator.admit("A", 0x00, null) assertNull(coordinator.response(0x05, FtmsControlCoordinator.SUCCESS)) - assertEquals(clientA, coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)) - assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x05)), - coordinator.admit(clientA, 0x05), - ) + assertEquals(a, coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)?.client) + assertNotNull(admitted(coordinator.admit("A", 0x05, erg))) } @Test fun failedRequestControlDoesNotAcquireOwnership() { val coordinator = FtmsControlCoordinator() - coordinator.admit(clientA, 0x00) - - assertEquals(clientA, coordinator.response(0x00, FtmsControlCoordinator.OPERATION_FAILED)) - assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientB, 0x00)), - coordinator.admit(clientB, 0x00), - ) + coordinator.connected("A"); val b = coordinator.connected("B") + coordinator.admit("A", 0x00, null) + assertEquals("A", coordinator.response(0x00, FtmsControlCoordinator.OPERATION_FAILED)?.client?.address) + assertEquals(b, admitted(coordinator.admit("B", 0x00, null)).client) } @Test fun ownerDisconnectAndTrainerDropClearOwnership() { val coordinator = FtmsControlCoordinator() - coordinator.admit(clientA, 0x00) + coordinator.connected("A") + coordinator.admit("A", 0x00, null) coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) - coordinator.admit(clientA, 0x05) - coordinator.disconnect(clientA) + coordinator.admit("A", 0x05, erg) + coordinator.disconnected("A") coordinator.trainerDropped() coordinator.trainerReady() - coordinator.admit(clientB, 0x00) + val b = coordinator.connected("B") + coordinator.admit("B", 0x00, null) coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) - coordinator.admit(clientB, 0x05) - assertEquals(clientB, coordinator.trainerDropped()) - assertEquals( - FtmsControlCoordinator.Admission.Admitted(FtmsControlCoordinator.Procedure(clientA, 0x00)), - coordinator.admit(clientA, 0x00), - ) + coordinator.admit("B", 0x05, erg) + assertEquals(b, coordinator.trainerDropped()) + coordinator.connected("A") + assertNotNull(admitted(coordinator.admit("A", 0x00, null))) + } + + /** A local GATT server rebuild closes the server, and Android raises no disconnect callbacks for the + * connections it kills. Keeping ownership across that stranded ERG on a dead generation. */ + @Test fun teardownDropsOwnershipAndEveryConnectionIdentity() { + val coordinator = FtmsControlCoordinator() + val a = coordinator.connected("A") + coordinator.admit("A", 0x00, null) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + + coordinator.clear() + assertNull(coordinator.identity("A")) + assertNull(coordinator.admit("A", 0x00, null)) // the old handle is gone, not merely stale + val reconnected = coordinator.connected("A") + assertTrue(a != reconnected) + assertEquals(reconnected, admitted(coordinator.admit("A", 0x00, null)).client) + assertEquals(reconnected, coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)?.client) + } + + /** The guard that stops a late arm from stripping a healthy procedure of its only deadline. The local + * button path crosses two main-loop hops, so its arm routinely lands after a newer procedure's. */ + @Test fun onlyTheLiveProcedureIsStillPending() { + val coordinator = FtmsControlCoordinator() + coordinator.connected("A") + val local = coordinator.admitLocal(0x04, res)!! + assertTrue(coordinator.isPending(local)) + + coordinator.response(0x04, FtmsControlCoordinator.SUCCESS) + assertFalse(coordinator.isPending(local)) // ended: a stale arm must bail here + + val next = admitted(coordinator.admit("A", 0x00, null)) + assertTrue(coordinator.isPending(next)) + assertFalse(coordinator.isPending(local)) // ...and must not adopt the newer one either + } + + /** A rejection issued while the client did NOT own control must carry no authority to release + * ownership that same client legitimately acquires afterwards. */ + @Test fun ownershipAuthorityIsScopedToTheClientThatActuallyOwns() { + val coordinator = FtmsControlCoordinator() + val a = coordinator.connected("A") + val b = coordinator.connected("B") + coordinator.admit("A", 0x00, null) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + + assertTrue(coordinator.owns(a)) + assertFalse(coordinator.owns(b)) // B's rejection may not release anything + coordinator.disconnected("A") + coordinator.admit("B", 0x00, null) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + assertTrue(coordinator.owns(b)) // B now owns it for real + assertFalse(coordinator.owns(a)) + } + + /** The single highest-value line in the coordinator: an app that disconnects must not keep ERG + * hostage. Nothing else covers it — trainerDropped() masks it in the broader ownership test. */ + @Test fun aDepartedOwnerDoesNotKeepControlHostage() { + val coordinator = FtmsControlCoordinator() + val a = coordinator.connected("A") + val b = coordinator.connected("B") + coordinator.admit("A", 0x00, null) + coordinator.response(0x00, FtmsControlCoordinator.SUCCESS) + assertTrue(coordinator.owns(a)) + + assertNull(coordinator.disconnected("A")) // nothing was pending, so no recycle + assertFalse(coordinator.owns(a)) // ...but ownership is gone with the connection + assertEquals(b, admitted(coordinator.admit("B", 0x00, null)).client) + assertEquals(b, coordinator.response(0x00, FtmsControlCoordinator.SUCCESS)?.client) } @Test fun trainerWriteCompletesExactlyOnce() { @@ -180,6 +318,11 @@ class RuntimeHardeningTest { @Test fun targetResistanceUsesSigned16LittleEndian() { assertArrayEquals(byteArrayOf(0x04, 0x24, 0x00), encodeTargetResistance(36)) assertArrayEquals(byteArrayOf(0x04, 0x10, 0x00), encodeTargetResistance(16)) + // the high byte and the sign: 36/16 alone pass for byteArrayOf(0x04, v.toByte(), 0) + assertArrayEquals(byteArrayOf(0x04, 0x2C, 0x01), encodeTargetResistance(300)) + assertArrayEquals(byteArrayOf(0x04, 0xFF.toByte(), 0xFF.toByte()), encodeTargetResistance(-1)) + assertArrayEquals(byteArrayOf(0x04, 0x00, 0x80.toByte()), encodeTargetResistance(Int.MIN_VALUE)) + assertArrayEquals(byteArrayOf(0x04, 0xFF.toByte(), 0x7F), encodeTargetResistance(Int.MAX_VALUE)) } // ── connect-attempt ownership (ZycleClient.connect / stop) ──────────────────────────────────── From 9b8f68a1da070c4f0e31c84b7415c1063df80f94 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:03:43 +0200 Subject: [PATCH 14/17] The wake lock follows the trainer again, with a long leash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Held from master-on to master-off, the partial lock blocked CPU suspend for as long as the switch was left on with no trainer in the room. onTimeout would never save us — that is the Android 14 foreground-service timeout and the Karoo is API 32 — so the only release was the user remembering. So it follows the trainer link again, as it did before, but with 45 minutes of slack: a mechanical stop, a phone call or a bathroom break must not cost the lock mid-session, and a trainer drop mid-ride still recovers with it held. Release is gated on a scan actually being registered with the controller. That is the invariant the whole-session hold dropped: postDelayed does not wake a suspended CPU, so releasing with no scan up leaves the trainer's advertising with nothing to arrive at. Once a scan is up the controller wakes us on a match, and the lock is retaken on STATE_CONNECTED — before discoverServices, so the whole cold bootstrap runs awake. A trainer seen but not connectable — the status=133 flap — now refreshes the timer too. It was counting as an absent trainer and dropping the lock exactly when the retry chain most needed the CPU running on schedule. 45 and not 30 because Android 12 silently downgrades a long-running scan to opportunistic at 30 minutes, without onScanFailed, so our own scanning flag keeps saying true. Colliding with that boundary would make a screen-off test unable to tell the two apart. The downgrade itself is untouched and still there: nothing restarts a scan that is already running. The lock's log lines now say why they fired and report what actually happened, instead of claiming a release before attempting it and calling the master inactive while it was on. --- .../trainerbridgeble/BridgeService.kt | 61 ++++++++++++++++--- .../trainerbridgeble/ble/TrainerSource.kt | 5 ++ .../trainerbridgeble/ble/ZycleClient.kt | 1 + 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index c8232b4..576b4c0 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -42,7 +42,7 @@ class BridgeService : Service() { @Volatile private var simSource: SimSource? = null // read from GATT binder / server callback @Volatile private var mirror: MirrorServer? = null // threads, so the reference itself must be @Volatile private var antTx: AntFecTx? = null // safely published - private var wakeLock: PowerManager.WakeLock? = null + @Volatile private var wakeLock: PowerManager.WakeLock? = null private val handler = android.os.Handler(android.os.Looper.getMainLooper()) private val lastValues = java.util.concurrent.ConcurrentHashMap() // every value seen, for a late mirror @@ -274,6 +274,7 @@ class BridgeService : Service() { status = getString(R.string.status_missing_bt_permission); listener?.invoke(); stopSelf(); return false } foreground = true + lastTrainerLinkMs = android.os.SystemClock.elapsedRealtime() if (!acquireWakeLock()) { foreground = false status = getString(R.string.status_wakelock_failed); listener?.invoke() @@ -364,6 +365,7 @@ class BridgeService : Service() { val onState: (Boolean) -> Unit = { connected -> handler.post { receiveOwner.runIfCurrent(owner) { zycleConnected = connected + if (connected) noteTrainerLink() // Only the DROP is immediate; going on the air waits for onSynced below. if (!connected) { zycleSynced = false; mirror?.setTrainerLinked(false); ErgBias.forget() } if (!connected) { config.lastSeenAddress = ""; config.lastSeenName = "" } // the config screen offers it only while live @@ -379,6 +381,7 @@ class BridgeService : Service() { ftmsReleaseGeneration = null } zycleSynced = true + noteTrainerLink() activeMirror?.setTrainerLinked(true) } } } val c: TrainerSource = if (config.simulate) SimSource(onProfile, onValue, onState, onSynced).also { simSource = it } @@ -386,7 +389,13 @@ class BridgeService : Service() { // Guarded too, or the replaced source's advertising blueprint and address get written over the // live one's. Neither needs a main-looper hop; the owner monitor provides the ordering. onAdv = { bp -> receiveOwner.runIfCurrent(owner) { lastAdvBlueprint = bp; mirror?.setAdvBlueprint(bp) } }, - onFound = { name, addr -> receiveOwner.runIfCurrent(owner) { config.lastSeenName = name ?: ""; config.lastSeenAddress = addr } }) + onFound = { name, addr -> receiveOwner.runIfCurrent(owner) { + // Binder thread: touch the @Volatile stamp only, never noteTrainerLink() — that would + // block a binder thread on the service monitor. A trainer stuck in a connect flap + // (status=133) is present, and must not be counted as absent by the idle guard. + lastTrainerLinkMs = android.os.SystemClock.elapsedRealtime() + config.lastSeenName = name ?: ""; config.lastSeenAddress = addr + } }) currentSourceKey = sourceKey(config) c.start() client = c @@ -655,7 +664,10 @@ class BridgeService : Service() { override fun onDestroy() { runCatching { unregisterReceiver(btStateReceiver) } - stopEmit(); stopReceive(); releaseWakeLock(); super.onDestroy() + stopEmit(); stopReceive(); releaseWakeLock() + foreground = false // ...or the snapshot keeps reposting itself and holds the Service alive + handler.removeCallbacks(snapshot) + super.onDestroy() } override fun onTimeout(startId: Int) { stopEmit(); stopReceive(); releaseWakeLock() @@ -663,31 +675,44 @@ class BridgeService : Service() { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); stopSelf() } - @Synchronized private fun acquireWakeLock(): Boolean { + /** elapsedRealtime of the last moment a trainer was linked, or of master-on. The lock is for an + * ACTIVE session: it must survive a trainer drop mid-ride, but not an evening of the master being + * left on with no trainer in the room, where it would block suspend for hours. */ + @Volatile private var lastTrainerLinkMs = 0L + + /** Called on every trainer link and once a minute while one is up. Re-takes the lock if the idle + * guard released it. A failure here only degrades the session — never tears it down, unlike the + * acquisition at master-on, because by this point a ride is already in progress. */ + private fun noteTrainerLink() { + lastTrainerLinkMs = android.os.SystemClock.elapsedRealtime() + if (foreground && wakeLock?.isHeld != true) acquireWakeLock("trainer back after idle release") + } + + @Synchronized private fun acquireWakeLock(why: String = "master active"): Boolean { if (wakeLock?.isHeld == true) return true val candidate = (getSystemService(POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TrainerBridgeBLE:session") val failure = runCatching { candidate.acquire(); check(candidate.isHeld) { "lock not held" } }.exceptionOrNull() if (failure != null) { runCatching { if (candidate.isHeld) candidate.release() } - FileLog.event("wakelock ACQUIRE FAILED (master active): ${failure.message ?: failure.javaClass.simpleName}") + FileLog.event("wakelock ACQUIRE FAILED ($why): ${failure.message ?: failure.javaClass.simpleName}") return false } wakeLock = candidate - FileLog.event("wakelock ACQUIRED (master active)") + FileLog.event("wakelock ACQUIRED ($why)") return true } - @Synchronized private fun releaseWakeLock() { + @Synchronized private fun releaseWakeLock(why: String = "master inactive") { val lock = wakeLock ?: return val failure = runCatching { if (lock.isHeld) lock.release() }.exceptionOrNull() val heldAfter = runCatching { lock.isHeld } if (heldAfter.getOrNull() == false) { wakeLock = null - FileLog.event("wakelock RELEASED (master inactive)") + FileLog.event("wakelock RELEASED ($why)") } else { - val reason = failure ?: heldAfter.exceptionOrNull() - FileLog.event("wakelock RELEASE FAILED (master inactive): ${reason?.message ?: "lock still held"}") + val cause = failure ?: heldAfter.exceptionOrNull() + FileLog.event("wakelock RELEASE FAILED ($why): ${cause?.message ?: "lock still held"}") } } @@ -700,6 +725,17 @@ class BridgeService : Service() { private val snapshot = object : Runnable { override fun run() { if (!foreground) return // master off / service dying: stop the loop, don't outlive it + // Idle guard. A trainer drop mid-ride is seconds to minutes, so the whole-session hold that + // recovers from one is untouched; the master left on all afternoon is not, and that case used + // to block CPU suspend until someone remembered. Runs before the log line: it must not depend + // on diagnostics being switched on. + if (zycleConnected || zycleSynced) noteTrainerLink() + // Release ONLY once the controller is holding the search for us. With no scan registered, a + // postDelayed retry cannot wake a suspended CPU and the trainer's advertising has nowhere to + // land — the guard would trade battery drain for a bridge that never comes back. + else if (wakeLock?.isHeld == true && client?.searching == true && + android.os.SystemClock.elapsedRealtime() - lastTrainerLinkMs > WAKELOCK_IDLE_MS) + releaseWakeLock("idle: no trainer seen for ${WAKELOCK_IDLE_MS / 60_000}m") if (FileLog.enabled) FileLog.event( "state master=${Config(this@BridgeService).masterEnabled} recv=$receiving emit=$emitting " + "trainer=${if (zycleSynced) "synced" else if (zycleConnected) "connected" else "-"} " + @@ -741,6 +777,11 @@ class BridgeService : Service() { private const val ANT_RESTART_DELAY_MS = 1500L // let the ANT service release the channel first private const val ERG_BIAS_PERSIST_MS = 60_000L // at most one prefs write a minute; stopReceive flushes private const val SNAPSHOT_MS = 60_000L // one state line a minute while the service is up + // Deliberately generous: a mechanical stop, a phone call or a bathroom break must NOT cost the + // lock mid-session. Only "left on and walked away" reaches this. NOT 30 min: that is exactly where + // Android 12 silently downgrades a long-running scan to opportunistic, and a device test could not + // then tell that apart from this release breaking rediscovery. + private const val WAKELOCK_IDLE_MS = 45 * 60_000L const val ACTION_MASTER_ON = "com.enderthor.trainerbridgeble.MASTER_ON" const val ACTION_MASTER_OFF = "com.enderthor.trainerbridgeble.MASTER_OFF" const val ACTION_EMIT_START = "com.enderthor.trainerbridgeble.EMIT_START" diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/TrainerSource.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/TrainerSource.kt index 822aeca..d59c247 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/TrainerSource.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/TrainerSource.kt @@ -7,6 +7,11 @@ import java.util.UUID interface TrainerSource { fun start() fun stop() + /** True while a BLE scan is actually registered with the controller. The idle wakelock guard needs + * this: releasing the CPU is only safe once the controller is holding the search for us, because a + * postDelayed retry does NOT wake a suspended CPU — with no scan up, the trainer's advertising has + * nothing to arrive at. */ + val searching: Boolean get() = false /** @return false if the write could not be dispatched (no link, unknown characteristic) — the mirror * must NOT then answer the app with success. */ fun write( diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt index f249da0..47a58e8 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt @@ -78,6 +78,7 @@ class ZycleClient( private val connecting = AtomicBoolean(false) // CAS: scan results arrive on a binder thread pool @Volatile private var stopped = false @Volatile private var scanning = false + override val searching: Boolean get() = scanning // elapsedRealtime, not wall-clock: a mid-ride clock re-sync would otherwise either trip the watchdog on // a healthy link or delay it past a real one, by the size of the correction. @Volatile private var lastMessageMs = 0L // last notification, for the silent-link watchdog From 7a71e05cd5600c9525a3a54acaba91bedbb798ac Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:19:33 +0200 Subject: [PATCH 15/17] Don't take control away from a client that hasn't subscribed yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bestcycling asks for control before it subscribes to the Control Point — in fact it never subscribes at all, it drives blind and never reads a response. The undeliverable-result handling treated that as "this client will never hear us, drop its claim", so control was granted and revoked in the same millisecond and every command it sent afterwards came back Control Not Permitted. ERG was dead from the first second of a session. A client that has not subscribed is not gone. It is connected and can still drive the trainer; it just cannot hear the answer. Releasing its claim turned a lost indication into no control at all. So the subscription cases only log now, and the claim stands. Releasing is kept for a client that is genuinely gone — stale generation, device absent, server closed — or an indication the stack actually refused. Found on the trainer bridge with a real FTMS client after six review passes had gone over this code. Every one of them asked whether ownership could get stuck; none asked whether it could be dropped when it shouldn't be. --- .../trainerbridgeble/ble/MirrorServer.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt index ce85515..2e9f412 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt @@ -610,8 +610,8 @@ class MirrorServer( }.getOrDefault(false) } - /** EVERY path that fails to hand a TERMINAL result to its origin must release that origin's claim: - * a controller waiting for a response it will never see would otherwise keep ERG locked for everyone. + /** The origin is GONE or the stack refused the indication outright: it will never see the terminal + * result, so drop its claim or it keeps ERG locked for everyone. * A rejection is not terminal for an admitted procedure and carries no such authority — a stale one * could otherwise revoke ownership the same client legitimately acquired in the meantime. */ private fun undeliverable(client: FtmsControlCoordinator.Client, why: String, terminal: Boolean) { @@ -620,6 +620,14 @@ class MirrorServer( FileLog.event("ftms terminal result undeliverable ($why) -> ${client.address}#${client.generation} — owner released") } + /** NOT the same thing: the client is still connected and can still drive the trainer, it just has no + * CCCD on the Control Point yet. Real controllers (Bestcycling) send Request Control BEFORE they + * subscribe, so revoking ownership here refused every command they sent afterwards — a lost + * indication turned into no ERG at all. Log it and leave the claim standing. */ + private fun undelivered(client: FtmsControlCoordinator.Client, why: String) { + FileLog.event("ftms result not delivered ($why) -> ${client.address}#${client.generation} — control kept") + } + private fun notifyControlResult( client: FtmsControlCoordinator.Client, value: ByteArray, @@ -627,16 +635,16 @@ class MirrorServer( ) { val uuid = GattUuids.FTMS_CONTROL_POINT val ch = chars[uuid] ?: return undeliverable(client, "no local characteristic", terminal) - val subs = subscribers[uuid] ?: return undeliverable(client, "no subscriber set", terminal) + val subs = subscribers[uuid] ?: return undelivered(client, "nobody subscribed to 0x2AD9 yet") if (ftmsControl.identity(client.address) != client) return undeliverable(client, "stale generation", terminal) - if (!synchronized(subs) { subs.contains(client.address) }) return undeliverable(client, "not subscribed", terminal) + if (!synchronized(subs) { subs.contains(client.address) }) return undelivered(client, "not subscribed yet") handler.post { val srv = server ?: return@post undeliverable(client, "server closed", terminal) if (ftmsControl.identity(client.address) != client) return@post undeliverable(client, "stale generation", terminal) val dev = clients[client.address] ?: return@post undeliverable(client, "device gone", terminal) - val currentSubs = subscribers[uuid] ?: return@post undeliverable(client, "no subscriber set", terminal) + val currentSubs = subscribers[uuid] ?: return@post undelivered(client, "nobody subscribed to 0x2AD9 yet") if (!synchronized(currentSubs) { currentSubs.contains(client.address) }) - return@post undeliverable(client, "unsubscribed before send", terminal) + return@post undelivered(client, "unsubscribed before send") if (terminal) terminalIndication = client if (!notify(srv, dev, ch, value, ch.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0)) { terminalIndication = null From 951991245d67ba1c373c5e2df97d395aa6ec82b8 Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:56:23 +0200 Subject: [PATCH 16/17] Stop the Karoo powering itself off while it is bridging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Karoo shuts itself down ten minutes after the screen sleeps when no ride is recording. Not Android's doze — Hammerhead's own state manager arms it the moment the screen goes off, and it happens at any battery level; it took the device down at 99% with the log cut mid-line. A foreground service does not stop it, nor a partial wake lock, nor the battery whitelist: all three were held at the moment it powered off. The delay lives in a system property that is not writable without root. Recording a ride prevents it, which is no answer for someone who does not want to record. The SDK turns out to expose TurnScreenOn, and logcat confirms it lands in the same state manager, on the same channel that armed the shutdown. So: dispatch it every five minutes while a session is live, and the countdown never completes. Five and not eight because a single missed tick would otherwise eat the whole margin against ten; a dispatch the host does not take is retried in twenty seconds rather than at the next slot. It stops on the same leash as the wake lock. The idle guard releases that lock after forty-five minutes with no trainer so an abandoned session can sleep, and keeping the whole device awake past that point would defeat it with a more expensive resource — a screen instead of a CPU. It also skips a recorded ride, where the shutdown cannot happen anyway and the only effect would be relighting a display the rider let sleep, and skips whenever the screen is already on, where there is no countdown to disarm. Verified on battery with the cable out: the screen slept at 54 seconds, so the shutdown was due at 10:54. It ran past thirteen minutes — same pid, no restart, no shutdown line, eight pokes all accepted, and every state line exactly sixty seconds apart. The screen was seen waking on its own twice. Alongside it: an optional flag to hold the monitor screen on, which only applies while that screen is open and now says so; a shutdown receiver, so the next time this happens the log explains its own gap instead of stopping mid-sentence; and the mirror now says when a trainer value had nobody to go to, which until now was indistinguishable from never having arrived. --- .../trainerbridgeble/BridgeService.kt | 120 +++++++++++++++++- .../com/enderthor/trainerbridgeble/Config.kt | 22 ++++ .../trainerbridgeble/ConfigActivity.kt | 6 + .../com/enderthor/trainerbridgeble/FileLog.kt | 16 ++- .../trainerbridgeble/MonitorActivity.kt | 10 ++ .../trainerbridgeble/ble/MirrorServer.kt | 35 ++++- app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 8 files changed, 205 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index 576b4c0..19814ce 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -193,6 +193,110 @@ class BridgeService : Service() { * server and the advertising set die with it, and nothing reopens them — the mirror goes silently mute * for the rest of the ride. The central half recovers on its own (its scan retries), so only the emit * half is cycled here. */ + // ── Karoo idle-shutdown keep-alive ────────────────────────────────────────────────────────────── + /** `persist.hx.idle_shutdown_delay` is 600000 ms on this firmware. Poke at 5 min, not 8: a single + * missed tick (Karoo system briefly unbound, a late handler) would otherwise eat the whole margin + * and the device powers off anyway. Hard-coded because the property is not readable from an app. */ + private val KEEP_AWAKE_MS = 5 * 60_000L + /** A dispatch that did not reach the host retries in seconds, not at the next 5-minute slot. */ + private val KEEP_AWAKE_RETRY_MS = 20_000L + private var karooSystem: io.hammerhead.karooext.KarooSystemService? = null + /** Recording (or paused) already prevents the idle shutdown, so poking then is pure cost — and it + * relights a display the rider deliberately let sleep. */ + @Volatile private var rideActive = false + + private val keepAwakeTick = object : Runnable { + override fun run() { + if (!foreground || !Config(this@BridgeService).keepAwake) { karooDisconnect(); return } + // ONE policy with the wakelock idle guard. That guard releases the lock after + // WAKELOCK_IDLE_MS with no trainer, precisely so an abandoned session can sleep; keeping the + // whole DEVICE alive past that point would defeat it with a costlier resource. It is also the + // self-healing signal: noteTrainerLink() re-takes the lock the moment a trainer returns. + // (And once the lock is gone the CPU can suspend, which freezes this postDelayed anyway — + // uptimeMillis does not advance in suspend — so the poke could not be trusted regardless.) + val next = when { + wakeLock?.isHeld != true -> { FileLog.event("keep-awake: session idle — not poking"); KEEP_AWAKE_MS } + rideActive -> { FileLog.event("keep-awake: ride active — shutdown already suppressed"); KEEP_AWAKE_MS } + // Nothing to disarm while the screen is already awake — the countdown only starts when it + // sleeps. Matters when "keep this screen on" is also enabled: without this the poke fired + // every interval doing nothing, and the log said it had done something. + screenOn() -> { FileLog.event("keep-awake: screen already on — nothing to disarm"); KEEP_AWAKE_MS } + else -> pokeScreen() + } + handler.postDelayed(this, next) + } + } + + private fun screenOn(): Boolean = runCatching { + (getSystemService(POWER_SERVICE) as PowerManager).isInteractive + }.getOrDefault(false) // unknown -> poke anyway; a wasted wake beats a missed deadline + + /** @return the delay until the next attempt: a short retry if the host did not take the call. */ + private fun pokeScreen(): Long { + val ks = karooSystem + if (ks == null) { karooConnect(); return KEEP_AWAKE_RETRY_MS } + // dispatch() returns whether a controller RECEIVED the call — it is still no acknowledgement + // that the host acted on it, so this line says "submitted", never "the screen woke". + val taken = runCatching { ks.dispatch(io.hammerhead.karooext.models.TurnScreenOn) } + .onFailure { FileLog.event("keep-awake: TurnScreenOn threw — ${it.message}") } + .getOrDefault(false) + FileLog.event("keep-awake: TurnScreenOn submitted=$taken") + return if (taken) KEEP_AWAKE_MS else KEEP_AWAKE_RETRY_MS + } + + private fun karooConnect() { + if (karooSystem != null) return + val ks = io.hammerhead.karooext.KarooSystemService(this) + karooSystem = ks + runCatching { + ks.connect { ok -> + FileLog.event("keep-awake: Karoo system connected=$ok") + if (ok) runCatching { + ks.addConsumer { st -> + rideActive = st !is io.hammerhead.karooext.models.RideState.Idle + } + } + } + }.onFailure { FileLog.event("keep-awake: connect FAILED — ${it.message}"); karooSystem = null } + } + + private fun karooDisconnect() { + handler.removeCallbacks(keepAwakeTick) + // disconnect() unregisters every consumer with it; it also unbinds unconditionally, which throws + // if the bind never took — hence the guard. + karooSystem?.let { runCatching { it.disconnect() } } + karooSystem = null + rideActive = false + } + + /** Called at master-on and whenever config changes, so the toggle takes effect without a restart. + * Pokes once immediately: a START_STICKY restart can land well into an already-running countdown, + * and waiting a full interval would then miss the deadline. */ + private fun applyKeepAwake() { + handler.removeCallbacks(keepAwakeTick) + if (foreground && Config(this).keepAwake) { + karooConnect() + handler.postDelayed(keepAwakeTick, KEEP_AWAKE_RETRY_MS) + } else karooDisconnect() + } + + /** The Karoo powers ITSELF off when idle: HxStateManagerService flips "can shutdown" the moment the + * screen goes off, and with no ride recording it takes the device down — at any battery level. A + * wakelock cannot veto that; nothing an app can do can. What we CAN do is say so, because otherwise + * the log simply stops mid-line and a reader cannot tell a device shutdown from a crashed bridge. */ + private val shutdownReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val battery = runCatching { + (getSystemService(BATTERY_SERVICE) as android.os.BatteryManager) + .getIntProperty(android.os.BatteryManager.BATTERY_PROPERTY_CAPACITY) + }.getOrDefault(-1) + // Synchronous: the log executor will not get another slice. + FileLog.eventNow("=== device ${intent?.action?.substringAfterLast('.') ?: "SHUTDOWN"} " + + "— uptime ${android.os.SystemClock.elapsedRealtime() / 60_000}m, battery $battery%, " + + "master=${Config(this@BridgeService).masterEnabled}, wake=${wakeLock?.isHeld == true}") + } + } + private val btStateReceiver = object : BroadcastReceiver() { override fun onReceive(c: Context?, intent: Intent?) { if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return @@ -211,6 +315,13 @@ class BridgeService : Service() { // broadcast — guarded anyway, so adding a non-protected action here can't kill the service at birth. runCatching { registerReceiver(btStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) } .onFailure { FileLog.event("bt state receiver not registered: ${it.message}") } + // ACTION_SHUTDOWN is not exempt from the implicit-broadcast ban, so it must be registered here + // rather than in the manifest — a manifest receiver would simply never fire. + runCatching { + registerReceiver(shutdownReceiver, IntentFilter(Intent.ACTION_SHUTDOWN).apply { + addAction(Intent.ACTION_REBOOT) + }) + }.onFailure { FileLog.event("shutdown receiver not registered: ${it.message}") } } override fun onBind(intent: Intent?): IBinder = binder @@ -220,13 +331,13 @@ class BridgeService : Service() { when (intent?.action) { ACTION_MASTER_ON -> { if (goForeground()) maybeStartReceive() } ACTION_MASTER_OFF -> { - stopEmit(); stopReceive(); releaseWakeLock() + stopEmit(); stopReceive(); releaseWakeLock(); karooDisconnect() Config(this).emitEnabled = false foreground = false ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); stopSelf() } // master off: nothing to reconfigure, and don't leave an idle started service behind - ACTION_RECONFIGURE -> if (foreground) { FileLog.enabled = Config(this).loggingEnabled; applyConfigChange() } + ACTION_RECONFIGURE -> if (foreground) { FileLog.enabled = Config(this).loggingEnabled; applyConfigChange(); applyKeepAwake() } else if (!Config(this).masterEnabled) stopSelf() // don't kill a service the master wants alive ACTION_EMIT_START -> { if (foreground) startEmit() } // never emit from a non-foreground (master-off) service ACTION_EMIT_STOP -> { Config(this).emitEnabled = false; stopEmit() } @@ -281,6 +392,7 @@ class BridgeService : Service() { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); stopSelf() return false } + applyKeepAwake() handler.removeCallbacks(snapshot); handler.post(snapshot) // stops itself once foreground goes false return true } @@ -664,13 +776,15 @@ class BridgeService : Service() { override fun onDestroy() { runCatching { unregisterReceiver(btStateReceiver) } + runCatching { unregisterReceiver(shutdownReceiver) } + karooDisconnect() stopEmit(); stopReceive(); releaseWakeLock() foreground = false // ...or the snapshot keeps reposting itself and holds the Service alive handler.removeCallbacks(snapshot) super.onDestroy() } override fun onTimeout(startId: Int) { - stopEmit(); stopReceive(); releaseWakeLock() + stopEmit(); stopReceive(); releaseWakeLock(); karooDisconnect() foreground = false // or a later EMIT_START would pass the foreground gate on a dying service ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); stopSelf() } diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/Config.kt b/app/src/main/java/com/enderthor/trainerbridgeble/Config.kt index 5a7741d..1d5dc8c 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/Config.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/Config.kt @@ -67,6 +67,26 @@ class Config(context: Context) { set(v) = p.edit().putString(KEY_ADVNAME, v).apply() /** Write the diagnostic CSV log. */ + /** Poke the Karoo awake before its idle timer fires. `persist.hx.idle_shutdown_delay` is 600000 ms: + * ten minutes after the screen sleeps with no ride recording, HxStateManagerService powers the whole + * device off — at any battery level, and a running bridge does NOT count as activity. This dispatches + * the SDK's TurnScreenOn every 5 minutes, which resets that. + * COST, stated honestly: TurnScreenOn has no counterpart that turns it back off, so each poke costs + * one full device screen timeout (~1 min on a stock Karoo), not "a brief flash" — roughly a 20% + * screen duty cycle for as long as a trainer is linked. The screen is the biggest consumer on this + * device. It stops on the same leash as the wakelock guard, so an abandoned session still sleeps. */ + var keepAwake: Boolean + get() = p.getBoolean(KEY_KEEP_AWAKE, false) + set(v) = p.edit().putBoolean(KEY_KEEP_AWAKE, v).apply() + + /** Hold the screen on while the bridge is active. The Karoo powers ITSELF off when idle: its + * HxStateManagerService arms the shutdown the moment the screen sleeps, and with no ride recording + * it takes the device down at any battery level. Recording a ride prevents it — this is for the + * sessions where you don't. Costs real battery: the screen is the biggest consumer on the device. */ + var keepScreenOn: Boolean + get() = p.getBoolean(KEY_KEEP_SCREEN, false) + set(v) = p.edit().putBoolean(KEY_KEEP_SCREEN, v).apply() + var loggingEnabled: Boolean get() = p.getBoolean(KEY_LOG, false) set(v) = p.edit().putBoolean(KEY_LOG, v).apply() @@ -111,6 +131,8 @@ class Config(context: Context) { const val KEY_NAME = "pairedName" const val KEY_ADVNAME = "advertisedName" const val KEY_LOG = "loggingEnabled" + const val KEY_KEEP_SCREEN = "keepScreenOn" + const val KEY_KEEP_AWAKE = "keepAwake" const val KEY_SIM = "simulate" const val KEY_ANT = "antOutput" const val KEY_ANT_ID = "antDeviceId" diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ConfigActivity.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ConfigActivity.kt index a51ba7c..24028ab 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ConfigActivity.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ConfigActivity.kt @@ -27,6 +27,8 @@ class ConfigActivity : Activity() { private lateinit var scanBtn: TextView private lateinit var foundList: LinearLayout private lateinit var inUseBox: LinearLayout + private lateinit var keepAwakeCheck: android.widget.CheckBox + private lateinit var keepScreenCheck: android.widget.CheckBox private lateinit var logCheck: android.widget.CheckBox private lateinit var simCheck: android.widget.CheckBox private lateinit var antCheck: android.widget.CheckBox @@ -85,6 +87,8 @@ class ConfigActivity : Activity() { // Toggles val opt = card(getString(R.string.config_options)) + keepAwakeCheck = check(getString(R.string.config_keep_awake), config.keepAwake); opt.addView(keepAwakeCheck) + keepScreenCheck = check(getString(R.string.config_keep_screen), config.keepScreenOn); opt.addView(keepScreenCheck) logCheck = check(getString(R.string.config_log), config.loggingEnabled); opt.addView(logCheck) simCheck = check(getString(R.string.config_sim), config.simulate); opt.addView(simCheck) antCheck = check(getString(R.string.config_ant_output), config.antOutputEnabled); opt.addView(antCheck) @@ -116,6 +120,8 @@ class ConfigActivity : Activity() { intField(offsetField, getString(R.string.config_offset_label), { true }) { config.offsetW = it } intField(floorField, getString(R.string.config_floor_label), { it >= 0 }) { config.invertFloorW = it } config.advertisedName = nameField.text.toString().trim() // blank = keep the device's own name + config.keepAwake = keepAwakeCheck.isChecked + config.keepScreenOn = keepScreenCheck.isChecked config.loggingEnabled = logCheck.isChecked config.simulate = simCheck.isChecked config.antOutputEnabled = antCheck.isChecked diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt b/app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt index 4c57fcd..19ad935 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt @@ -13,6 +13,10 @@ object FileLog { @Volatile var enabled = false @Volatile private var file: File? = null private val io = Executors.newSingleThreadExecutor() + /** Every mutation of the file goes through this: the executor's append+rotate and the synchronous + * shutdown line write the same path, and a rotation racing that line would lose the one record that + * tells a device shutdown apart from a crashed bridge. */ + private val fileLock = Any() fun init(context: Context) { if (file != null) return @@ -24,7 +28,7 @@ object FileLog { val f = file ?: return val ts = System.currentTimeMillis() // when it HAPPENED — the IO queue can lag under load io.execute { - runCatching { + runCatching { synchronized(fileLock) { // Nothing is throttled (a dropped line is the one you needed), so cap the file instead. // Rotate rather than truncate: truncating at the cap leaves you holding a log that starts // seconds ago, which is worthless for a ride that just ended. O(1) — a rename, no read. @@ -36,10 +40,18 @@ object FileLog { if (rotated) f.writeText("# $ts log rotated at ${MAX_BYTES / 1024 / 1024} MB (previous: ${f.name}.1)\n") } f.appendText("# $ts $msg\n") - } + } } } } + /** Synchronous append, for the one case where the queue will never drain: the device is powering + * off and we have seconds. Blocks the caller — never use it on a BLE callback or the hot path. */ + fun eventNow(msg: String) { + if (!enabled) return + val f = file ?: return + runCatching { synchronized(fileLock) { f.appendText("# ${System.currentTimeMillis()} $msg\n") } } + } + // Every notification is logged unthrottled (~2 KB/s), so 16 MB filled in ~2 h and rotation threw away // the START of the ride — which is exactly where the connect / sync / first-advertise sequence lives, // the part you turned logging on to see. 48 MB holds a ~6 h ride in one file, 12 h across the two. diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/MonitorActivity.kt b/app/src/main/java/com/enderthor/trainerbridgeble/MonitorActivity.kt index b1a610c..b4f535c 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/MonitorActivity.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/MonitorActivity.kt @@ -133,7 +133,17 @@ class MonitorActivity : Activity() { render() } + /** Only while the master is ON: leaving the app open with the bridge idle should not pin the screen. + * This is the whole mitigation — the Karoo's shutdown is armed by the screen going off, so a screen + * that never sleeps never arms it. Nothing an app can do can veto the shutdown once it starts. */ + private fun applyKeepScreenOn() { + val hold = config.keepScreenOn && config.masterEnabled + if (hold) window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + private fun render() { + applyKeepScreenOn() val s = service val master = config.masterEnabled val emitting = s?.emitting == true diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt index 2e9f412..f49f1b5 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt @@ -292,7 +292,7 @@ class MirrorServer( // subscribers/clients too: clearServices() moves every ATT handle, so a peer that reconnected to a // remembered MAC would be tracked as subscribed to characteristic objects that no longer exist. // serviceRetries, or a rebuild starts with the budget the failed build already spent. - chars.clear(); pendingServices.clear(); subscribers.clear(); serviceRetries = 0 + chars.clear(); pendingServices.clear(); subscribers.clear(); serviceRetries = 0; audienceWarned.clear() for (svc in profile.services) { if (GattUuids.isStackService(svc.uuid)) continue val service = BluetoothGattService(svc.uuid, @@ -385,6 +385,7 @@ class MirrorServer( procedureDeadline = null ftmsControl.clear() terminalIndication = null + audienceWarned.clear() clients.clear(); subscribers.clear() // ...and never keep broadcasting under the trainer's name with no server behind it: an app that // connects during the rebuild window finds no services and caches a broken device for the session. @@ -410,6 +411,7 @@ class MirrorServer( serviceAddWatchdog = null ftmsControl.clear() terminalIndication = null + audienceWarned.clear() procedureDeadline?.let { handler.removeCallbacks(it.second) } procedureDeadline = null stopAdvertising() @@ -448,6 +450,23 @@ class MirrorServer( /** For the service's periodic snapshot: how many apps are attached, and how far the level we report has * drifted from the machine's (shown/raw — they diverge by every servo step we absorbed, by design). */ + /** UUIDs we have already reported as having no listener, so the notice is one line per dry spell and + * not one per 4 Hz packet. Cleared as soon as somebody subscribes. */ + private val audienceWarned = java.util.Collections.newSetFromMap(ConcurrentHashMap()) + + /** A value arrived from the trainer and there was nobody to hand it to. Silence here reads exactly like + * "the trainer never sent it", which is the wrong conclusion to draw from a log. */ + private fun noAudience(charUuid: UUID) { + // Only for characteristics that CAN have an audience. A read-only one (Feature, ranges, device + // info) is cached for a later read, not dropped — warning about it fires on every mirror start, + // when startEmit seeds the cache with every value we already read from the trainer. + val notifies = chars[charUuid]?.properties?.and( + BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE) ?: 0 + if (notifies == 0) return + if (FileLog.enabled && audienceWarned.add(charUuid)) + FileLog.event("relay ${shortUuid(charUuid)} DROPPED — no app subscribed to it") + } + val clientCount: Int get() = clients.size val levelDebug: String get() = "${shownZycleLevel ?: "-"}/${lastRawZycleLevel ?: "-"}" @@ -579,9 +598,19 @@ class MirrorServer( else -> value } cache[charUuid] = out + // Machine Status is how FTMS ANNOUNCES a change the app did not command — the resistance knob was + // turned, the target moved. It fires only on a change, so log every one with its audience: "the + // trainer told us, and we had nobody to tell" is otherwise indistinguishable from "it never told us". + if (charUuid == GattUuids.MACHINE_STATUS && FileLog.enabled) { + val op = value.firstOrNull()?.toInt()?.and(0xFF) + val param = if (value.size > 1) FileLog.hex(value.copyOfRange(1, value.size)) else "-" + FileLog.event("machine status op=%s param=%s -> %d subscriber(s)" + .format(op?.let { "0x%02X".format(it) } ?: "-", param, subscribers[charUuid]?.size ?: 0)) + } val ch = chars[charUuid] ?: return - val subs = subscribers[charUuid] ?: return - if (subs.isEmpty()) return + val subs = subscribers[charUuid] ?: return noAudience(charUuid) + if (subs.isEmpty()) return noAudience(charUuid) + audienceWarned.remove(charUuid) logRelay(charUuid, value, out, subs.size) handler.post { val srv = server ?: return@post diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d33a837..dee589c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -42,6 +42,8 @@ Identidad anunciada Nombre con el que nos ven las apps. DÉJALO VACÍO para usar el nombre Bluetooth del propio dispositivo: es lo más simple y así no se cambia el nombre del aparato. Es cosmético en cualquier caso: no afecta a lo que la app puede hacer con el rodillo. Opciones + Evitar que el Karoo se apague (enciende la pantalla cada 5 min - gasta bateria; se apaga a los 10 min sin ride) + Mantener encendida ESTA pantalla mientras esté abierta (el Karoo se apaga solo sin ride) Guardar log (CSV) Modo simulación (sin bici) Salida ANT+ (potencia corregida a reloj/ciclocomputador) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a72383..7ad4ed9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,8 @@ Advertised identity Name the apps see us as. LEAVE IT EMPTY to use this device\'s own Bluetooth name: simplest, and your device name is never changed. Cosmetic either way — it does not affect what an app can do with the trainer. Options + Keep the Karoo awake (wakes the screen every 5 min - uses battery; it powers off 10 min after the screen sleeps with no ride) + Keep THIS screen on while it is open (the Karoo powers off by itself with no ride) Save log (CSV) Simulation mode (no bike) ANT+ output (corrected power to watch/bike computer) From 63b137b2c707fc5b6d32f2ad4863581e20cbba3a Mon Sep 17 00:00:00 2001 From: Enderthor <58392928+lockevod@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:03:31 +0200 Subject: [PATCH 17/17] Never skip the wake poke twice running, and tell riders about the shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skip trusts isInteractive to mean "no countdown is armed". It is only a proxy, and AOSP counts a dreaming screensaver state as interactive — so a firmware that armed the countdown in a state reading interactive would skip every poke forever. That is the one error the five-against-ten-minute margin cannot absorb, because it persists instead of passing. One skip is now the limit. Being wrong in this direction is free: a poke while the screen really is on does nothing. Also: the comment above applyKeepAwake still promised an immediate poke it no longer makes, which is the same defect this feature's own log lines were just fixed for, one layer up. The README now says what the Karoo does, where a rider meets it: it powers itself off ten minutes after the screen sleeps with no ride recording, and that looks exactly like the bridge crashing. Both new switches are documented with the limit that matters — the wake costs battery, and holding the screen on stops applying the moment you leave that screen. --- README.md | 21 ++++++++++++++++++- .../trainerbridgeble/BridgeService.kt | 21 +++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6903877..9573678 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,17 @@ trainer for power, speed and cadence — so recording the *corrected* power matt > Note: Karoo extensions auto-start, so the app is always loaded on the Karoo — the **App active** switch > is how you make sure it isn't consuming anything when you're not using it. +### If the Karoo switches itself off mid-session + +The Karoo powers **itself** off about ten minutes after its screen goes to sleep when no ride is being +recorded — whatever the battery level, and whatever any app is doing. It is the Karoo's own idle +behaviour, not the bridge crashing: the bridge is simply switched off with the rest of the device. + +If you record the ride on the Karoo, this never happens. If you'd rather not record — you use the Karoo +only as the bridge and something else does the recording — turn on **Keep the Karoo awake** in Config. +It briefly wakes the screen every five minutes, which is enough to stop the countdown. That costs +battery (the screen is the biggest consumer on the device), so it is off by default. + --- ## Using it on a phone @@ -122,7 +133,15 @@ Open **Configuración** from the Monitor: because Android has no per-advertisement name — and then anything else the device advertises carries that name too, until it is restored on stop. - **Options** — save diagnostic log (CSV), simulation mode (a fake trainer for testing with no hardware), - ANT+ output + its device id. + ANT+ output + its device id, and the two anti-shutdown switches below. +- **Keep the Karoo awake** (off by default) — stops the Karoo powering itself off while the bridge is + running with no ride recorded; see *If the Karoo switches itself off mid-session* above. It wakes the + screen every five minutes, so it uses noticeably more battery. It stops on its own once there has been + no trainer for a while, so a session you walked away from still lets the device sleep, and it does + nothing while a ride is recording (the Karoo doesn't switch off then anyway). +- **Keep THIS screen on** (off by default) — holds the screen awake **while the Monitor screen is open**, + which also prevents the shutdown. It stops applying the moment you switch to another screen or app, + which is why the switch above exists. --- diff --git a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt index 19814ce..e75f456 100644 --- a/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt +++ b/app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt @@ -204,6 +204,8 @@ class BridgeService : Service() { /** Recording (or paused) already prevents the idle shutdown, so poking then is pure cost — and it * relights a display the rider deliberately let sleep. */ @Volatile private var rideActive = false + /** Bounds the screen-on skip to one interval; see the branch that uses it. */ + private var screenOnSkips = 0 private val keepAwakeTick = object : Runnable { override fun run() { @@ -220,8 +222,17 @@ class BridgeService : Service() { // Nothing to disarm while the screen is already awake — the countdown only starts when it // sleeps. Matters when "keep this screen on" is also enabled: without this the poke fired // every interval doing nothing, and the log said it had done something. - screenOn() -> { FileLog.event("keep-awake: screen already on — nothing to disarm"); KEEP_AWAKE_MS } - else -> pokeScreen() + // NEVER twice running, though: isInteractive is a proxy, and AOSP counts a DREAMING state + // as interactive. If a firmware ever armed the countdown in a state that reads interactive, + // an unlimited skip would be the one error the 5-vs-10-minute margin cannot absorb — it + // would persist. A poke with the screen genuinely on is a no-op, so being wrong this way + // is free; being wrong the other way costs the device. + screenOn() && screenOnSkips == 0 -> { + screenOnSkips++ + FileLog.event("keep-awake: screen already on — no countdown to disarm, poke not needed") + KEEP_AWAKE_MS + } + else -> { screenOnSkips = 0; pokeScreen() } } handler.postDelayed(this, next) } @@ -270,11 +281,13 @@ class BridgeService : Service() { } /** Called at master-on and whenever config changes, so the toggle takes effect without a restart. - * Pokes once immediately: a START_STICKY restart can land well into an already-running countdown, - * and waiting a full interval would then miss the deadline. */ + * Posts a tick in 20 s rather than waiting a full interval: a START_STICKY restart can land well into + * an already-running countdown. (That tick may SKIP if the screen is on — which is sound, because a + * countdown can only have been armed by the screen sleeping.) */ private fun applyKeepAwake() { handler.removeCallbacks(keepAwakeTick) if (foreground && Config(this).keepAwake) { + screenOnSkips = 0 karooConnect() handler.postDelayed(keepAwakeTick, KEEP_AWAKE_RETRY_MS) } else karooDisconnect()