@@ -24,7 +24,8 @@ import kotlinx.coroutines.flow.StateFlow
2424import kotlinx.coroutines.flow.asSharedFlow
2525import kotlinx.coroutines.flow.asStateFlow
2626import kotlinx.coroutines.launch
27- import java.io.ByteArrayOutputStream
27+ import com.eried.eucplanet.ble.virtual.VirtualWheel
28+ import com.eried.eucplanet.ble.virtual.VirtualWheelRegistry
2829import java.util.UUID
2930import javax.inject.Inject
3031import javax.inject.Singleton
@@ -39,15 +40,13 @@ enum class ConnectionState {
3940
4041@Singleton
4142class 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}
0 commit comments