Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
/app/build/
/app/release/
/graphify-out/
/.local-notes/
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

---

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions app/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
478 changes: 427 additions & 51 deletions app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions app/src/main/java/com/enderthor/trainerbridgeble/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -36,11 +40,22 @@ 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")
}
} }
}
}

private const val MAX_BYTES = 16L * 1024 * 1024 // two files kept, so 32 MB total
/** 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.
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("") } } } }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -143,7 +153,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
Expand Down
Loading