Skip to content

Commit 68fa5c1

Browse files
committed
Merge main 0.3.1 into wear-os-watch-ultra
Brings in the multi-wheel-support work (V12, P6, virtual sim) and the verified P6 telemetry offset corrections so the watch dashboard's PWM / torque / temp readouts are correct on every supported wheel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> # Conflicts: # .github/workflows/branch-apk.yml # BRANCH.md
2 parents 0839e3b + 928fc5a commit 68fa5c1

48 files changed

Lines changed: 5777 additions & 261 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,13 @@ keystore.properties
2020
release/
2121
/.playwright-mcp
2222
/.claude
23-
/.claude
23+
24+
# Scratch files (analysis, screenshots, ad-hoc)
25+
.tmp_*
26+
*.btsnoop
27+
*.cfa
28+
*.log
29+
30+
# Stray build-output paths that have leaked into the repo before
31+
**/eucplanet_*.png
32+
**/build/eucplanet_*

BRANCH.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ Ultra). The phone holds the BLE link to the wheel and pushes a compact
77
telemetry snapshot to the watch over the Wearable Data Layer; the watch is a
88
thin client and never talks to the wheel directly.
99

10+
This branch is built on top of `main` (V14 + V12 + P6 multi-wheel support
11+
verified through 0.3.1), so the watch dashboard inherits the corrected P6
12+
telemetry offsets (PWM, torque, MOS+motor temps, signed reverse speed).
13+
1014
Concretely shipped here:
1115

1216
- **Full-bleed speed dial** that wraps the entire watch face. Same arc
@@ -33,6 +37,9 @@ Concretely shipped here:
3337
- **Resolution-clean.** All sizes derive from `BoxWithConstraints.maxWidth`
3438
so the layout looks right on small round watches (~390 dp) and on Watch
3539
Ultra (~454 dp) without separate code paths.
40+
- **Auto-start ping** on phone-app open and a manual "Play" button next to
41+
the Auto-start setting so users can verify pairing without backgrounding
42+
and relaunching the phone app.
3643

3744
## Architecture
3845

-130 KB
Binary file not shown.

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ android {
2727
applicationId = "com.eried.eucplanet"
2828
minSdk = 29
2929
targetSdk = 35
30-
versionCode = 7
31-
versionName = "0.3.0"
30+
versionCode = 8
31+
versionName = "0.3.1"
3232

3333
val buildStamp = SimpleDateFormat("yyMMdd.HHmm")
3434
.apply { timeZone = TimeZone.getTimeZone("UTC") }

app/src/main/java/com/eried/eucplanet/ble/BleConnectionManager.kt

Lines changed: 99 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import kotlinx.coroutines.flow.StateFlow
2424
import kotlinx.coroutines.flow.asSharedFlow
2525
import kotlinx.coroutines.flow.asStateFlow
2626
import kotlinx.coroutines.launch
27-
import java.io.ByteArrayOutputStream
27+
import com.eried.eucplanet.ble.virtual.VirtualWheel
28+
import com.eried.eucplanet.ble.virtual.VirtualWheelRegistry
2829
import java.util.UUID
2930
import javax.inject.Inject
3031
import javax.inject.Singleton
@@ -39,15 +40,13 @@ enum class ConnectionState {
3940

4041
@Singleton
4142
class BleConnectionManager @Inject constructor(
42-
@ApplicationContext private val context: Context
43+
@ApplicationContext private val context: Context,
44+
private val wheelAdapter: WheelAdapter
4345
) {
4446
companion object {
4547
private const val TAG = "BleConnection"
4648

47-
// Nordic UART Service UUIDs
48-
val NUS_SERVICE_UUID: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e")
49-
val NUS_RX_UUID: UUID = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e") // write
50-
val NUS_TX_UUID: UUID = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e") // notify
49+
// Client Characteristic Configuration Descriptor — same for every wheel family.
5150
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
5251
}
5352

@@ -57,41 +56,108 @@ class BleConnectionManager @Inject constructor(
5756
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
5857
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
5958

60-
private val _receivedPackets = MutableSharedFlow<ParsedPacket>(extraBufferCapacity = 64)
61-
val receivedPackets: SharedFlow<ParsedPacket> = _receivedPackets.asSharedFlow()
59+
private val _decodedResults = MutableSharedFlow<DecodeResult>(extraBufferCapacity = 64)
60+
/** Stream of decoded results from the active wheel adapter. */
61+
val decodedResults: SharedFlow<DecodeResult> = _decodedResults.asSharedFlow()
6262

6363
private var gatt: BluetoothGatt? = null
6464
private var rxCharacteristic: BluetoothGattCharacteristic? = null
6565
private var currentAddress: String? = null
66+
/** BLE advertised name from the most recent connect call, kept across reconnects. */
67+
private var currentName: String? = null
6668
private var shouldReconnect = true
6769

6870
// Write serialization - only one BLE write at a time
6971
private val writeChannel = Channel<ByteArray>(Channel.BUFFERED)
7072
private var writeReady = false
7173

72-
// Packet reassembly buffer
73-
private val reassemblyBuffer = ByteArrayOutputStream()
74+
// Active virtual wheel when in demo mode (address starts with "VIRTUAL:").
75+
// When non-null, writes are routed to the simulator instead of GATT and the
76+
// simulator's response bytes are fed back through the adapter pipeline as if
77+
// they had arrived as real BLE notifications. GATT handles stay null.
78+
@Volatile private var virtualWheel: VirtualWheel? = null
7479

7580
init {
7681
scope.launch { processWriteQueue() }
7782
}
7883

7984
@SuppressLint("MissingPermission")
80-
fun connect(address: String) {
85+
fun connect(address: String, name: String? = null) {
86+
// Demo / simulator mode: VIRTUAL:<id> bypasses GATT and connects to a fake wheel.
87+
val virtualId = VirtualWheelRegistry.parsePseudoAddress(address)
88+
if (virtualId != null) {
89+
connectVirtual(virtualId)
90+
return
91+
}
92+
8193
currentAddress = address
94+
// Hold on to the name so the auto-reconnect path keeps the same hint —
95+
// otherwise a P6 that briefly drops would come back as an unknown wheel.
96+
currentName = name ?: currentName
8297
shouldReconnect = true
8398
_connectionState.value = ConnectionState.CONNECTING
8499

100+
// Adapter pre-selects model from the BLE name; needed for the InMotion
101+
// P6 because its legacy carType query returns zeros and we'd otherwise
102+
// never identify it before sending V14-shaped queries the wheel ignores.
103+
wheelAdapter.notifyConnectingTo(currentName)
104+
85105
val device: BluetoothDevice = bluetoothManager.adapter.getRemoteDevice(address)
86106
gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
87107
}
88108

109+
/**
110+
* Skip GATT entirely and run a simulated wheel. The fake produces the same
111+
* raw-byte responses a real wheel would emit, so the parser/adapter/repository
112+
* pipeline runs unchanged. Disconnect by calling [disconnect] as usual.
113+
*/
114+
private fun connectVirtual(id: String) {
115+
val wheel = VirtualWheelRegistry.create(id) ?: run {
116+
Log.e(TAG, "Unknown virtual wheel id: $id")
117+
return
118+
}
119+
Log.i(TAG, "Connecting to virtual wheel: ${wheel.displayName}")
120+
wheel.reset()
121+
virtualWheel = wheel
122+
currentAddress = VirtualWheelRegistry.pseudoAddress(id)
123+
shouldReconnect = false
124+
_connectionState.value = ConnectionState.CONNECTING
125+
scope.launch {
126+
// Brief delays so the UI's connection-state animations actually animate.
127+
delay(150)
128+
_connectionState.value = ConnectionState.INITIALIZING
129+
for (resp in wheel.onConnect()) emitVirtualResponse(resp)
130+
delay(150)
131+
// Mark CONNECTED last so writeReady gates open AFTER on-connect responses
132+
// have already filtered through the adapter — same ordering a real wheel
133+
// gets, where notifications start landing once the GATT subscription is up.
134+
writeReady = true
135+
_connectionState.value = ConnectionState.CONNECTED
136+
}
137+
}
138+
139+
private fun emitVirtualResponse(rawBytes: ByteArray) {
140+
for (result in wheelAdapter.onRawNotification(rawBytes)) {
141+
_decodedResults.tryEmit(result)
142+
}
143+
}
144+
89145
@SuppressLint("MissingPermission")
90146
fun disconnect() {
91147
shouldReconnect = false
92148
currentAddress = null
149+
currentName = null
93150
rxCharacteristic = null
94151
writeReady = false
152+
153+
// Virtual wheel: just drop the reference; no GATT to tear down.
154+
if (virtualWheel != null) {
155+
wheelAdapter.onDisconnect()
156+
virtualWheel = null
157+
_connectionState.value = ConnectionState.DISCONNECTED
158+
return
159+
}
160+
95161
val g = gatt
96162
if (g == null) {
97163
_connectionState.value = ConnectionState.DISCONNECTED
@@ -125,6 +191,13 @@ class BleConnectionManager @Inject constructor(
125191
@SuppressLint("MissingPermission")
126192
private suspend fun processWriteQueue() {
127193
for (data in writeChannel) {
194+
// Virtual mode: hand the write to the simulator and feed any responses
195+
// back through the adapter pipeline. No GATT, no writeReady gating.
196+
val virtual = virtualWheel
197+
if (virtual != null) {
198+
for (resp in virtual.onWrite(data)) emitVirtualResponse(resp)
199+
continue
200+
}
128201
if (!writeReady || rxCharacteristic == null || gatt == null) {
129202
Log.w(TAG, "Write skipped: ready=$writeReady rx=${rxCharacteristic != null} gatt=${gatt != null}")
130203
continue
@@ -174,6 +247,8 @@ class BleConnectionManager @Inject constructor(
174247
Log.i(TAG, "Disconnected from GATT (status=$status, shouldReconnect=$shouldReconnect)")
175248
rxCharacteristic = null
176249
writeReady = false
250+
// Reset adapter framing state for the next connection
251+
wheelAdapter.onDisconnect()
177252
// Close the GATT here so the underlying connection is fully torn down
178253
try { gatt.close() } catch (_: Exception) {}
179254
if (this@BleConnectionManager.gatt === gatt) {
@@ -202,17 +277,18 @@ class BleConnectionManager @Inject constructor(
202277
return
203278
}
204279

205-
val nusService = gatt.getService(NUS_SERVICE_UUID)
206-
if (nusService == null) {
207-
Log.e(TAG, "NUS service not found")
280+
val profile = wheelAdapter.bleProfile()
281+
val service = gatt.getService(profile.serviceUuid)
282+
if (service == null) {
283+
Log.e(TAG, "Adapter service ${profile.serviceUuid} not found on this wheel")
208284
return
209285
}
210286

211-
rxCharacteristic = nusService.getCharacteristic(NUS_RX_UUID)
212-
val txCharacteristic = nusService.getCharacteristic(NUS_TX_UUID)
287+
rxCharacteristic = service.getCharacteristic(profile.writeCharacteristic)
288+
val txCharacteristic = service.getCharacteristic(profile.notifyCharacteristic)
213289

214290
if (rxCharacteristic == null || txCharacteristic == null) {
215-
Log.e(TAG, "NUS characteristics not found")
291+
Log.e(TAG, "Adapter characteristics not found on service ${profile.serviceUuid}")
216292
return
217293
}
218294

@@ -232,7 +308,7 @@ class BleConnectionManager @Inject constructor(
232308

233309
writeReady = true
234310
_connectionState.value = ConnectionState.CONNECTED
235-
Log.i(TAG, "NUS service ready")
311+
Log.i(TAG, "Service ${profile.serviceUuid} ready (adapter=${wheelAdapter.familyId})")
236312
}
237313

238314
override fun onCharacteristicWrite(
@@ -263,48 +339,13 @@ class BleConnectionManager @Inject constructor(
263339
}
264340

265341
/**
266-
* Process incoming BLE notification data. Handles packet reassembly
267-
* since NUS may split packets across multiple notifications.
342+
* Forward each BLE notification to the active wheel adapter and emit any
343+
* DecodeResults it produces. Framing (reassembly, parsing) lives in the
344+
* adapter — each protocol family has its own.
268345
*/
269346
private fun processIncomingData(data: ByteArray) {
270-
reassemblyBuffer.write(data)
271-
val buffer = reassemblyBuffer.toByteArray()
272-
273-
// Scan for complete packets
274-
var start = -1
275-
for (i in 0 until buffer.size - 1) {
276-
if (buffer[i] == InMotionV2Protocol.HEADER && buffer[i + 1] == InMotionV2Protocol.HEADER) {
277-
if (start >= 0) {
278-
// Found next header - previous packet ends here
279-
val packetBytes = buffer.copyOfRange(start, i)
280-
tryParseAndEmit(packetBytes)
281-
}
282-
start = i
283-
}
284-
}
285-
286-
if (start >= 0) {
287-
// Try to parse from start to end of buffer
288-
val candidate = buffer.copyOfRange(start, buffer.size)
289-
if (candidate.size >= 5) {
290-
val packet = InMotionV2Protocol.parsePacket(candidate)
291-
if (packet != null) {
292-
_receivedPackets.tryEmit(packet)
293-
reassemblyBuffer.reset()
294-
return
295-
}
296-
}
297-
// Keep remaining data in buffer
298-
reassemblyBuffer.reset()
299-
reassemblyBuffer.write(candidate)
300-
} else {
301-
// No header found, clear buffer
302-
reassemblyBuffer.reset()
347+
for (result in wheelAdapter.onRawNotification(data)) {
348+
_decodedResults.tryEmit(result)
303349
}
304350
}
305-
306-
private fun tryParseAndEmit(data: ByteArray) {
307-
val packet = InMotionV2Protocol.parsePacket(data) ?: return
308-
_receivedPackets.tryEmit(packet)
309-
}
310351
}

app/src/main/java/com/eried/eucplanet/ble/BleScanner.kt

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,24 @@ class BleScanner @Inject constructor(
2828
private val bluetoothAdapter = bluetoothManager.adapter
2929
private var scanCallback: ScanCallback? = null
3030

31+
/**
32+
* Start a BLE scan and emit one [BleDevice] per advertisement match.
33+
*
34+
* @param showAll when true, every named peripheral is forwarded (matches
35+
* WheelLog's behaviour). When false, only names that match
36+
* a known wheel prefix are forwarded — useful for keeping
37+
* the scan list short and free of unrelated devices in
38+
* typical usage.
39+
*/
3140
@SuppressLint("MissingPermission")
32-
fun scanForDevices(): Flow<BleDevice> = callbackFlow {
41+
fun scanForDevices(showAll: Boolean = false): Flow<BleDevice> = callbackFlow {
3342
val scanner = bluetoothAdapter?.bluetoothLeScanner
3443
?: throw IllegalStateException("Bluetooth not available")
3544

3645
val callback = object : ScanCallback() {
3746
override fun onScanResult(callbackType: Int, result: ScanResult) {
3847
val name = result.device.name ?: return
39-
if (name.startsWith("Adventure-") || name.startsWith("InMotion")) {
48+
if (showAll || isLikelyWheel(name)) {
4049
trySend(BleDevice(
4150
name = name,
4251
address = result.device.address,
@@ -63,6 +72,32 @@ class BleScanner @Inject constructor(
6372
}
6473
}
6574

75+
/**
76+
* BLE-name allowlist for the default ("known wheels only") scan mode.
77+
*
78+
* V14 advertises as `Adventure-<id>`, P6 as `P6-<id>`. The InMotion V2
79+
* registry covers V8 through V13 — those wheels broadcast as
80+
* `V<digits><letters?>-<id>` (V11-…, V11Y-…, V12HS-…, V13Pro-…) per
81+
* community captures. We don't have one of each here to confirm, so
82+
* the regex errs inclusive. The generic `InMotion` prefix catches
83+
* anything that ships with the brand name in the advertised name.
84+
* Users with an unusual name can flip the "show all" switch on the
85+
* scan screen.
86+
*/
87+
private fun isLikelyWheel(name: String): Boolean {
88+
if (name.startsWith("Adventure-")) return true
89+
if (name.startsWith("P6-")) return true
90+
if (name.startsWith("InMotion")) return true
91+
// V8-…, V9-…, V10-…, V11-…, V11Y-…, V12HS-…, V13Pro-…: leading V
92+
// followed by at least one digit and at least one more character
93+
// (separator, model letter, or further digit). Rejects bare "V" /
94+
// "V1" / "V12" beacons, accepts the InMotion V2 family.
95+
if (name.length < 3 || name[0] != 'V' || !name[1].isDigit()) return false
96+
var i = 2
97+
while (i < name.length && name[i].isDigit()) i++
98+
return i < name.length
99+
}
100+
66101
@SuppressLint("MissingPermission")
67102
fun stopScan() {
68103
val scanner = bluetoothAdapter?.bluetoothLeScanner ?: return

0 commit comments

Comments
 (0)