@tempivo/sensor-beacon 1.1.2 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +35 -22
  3. package/android/build.gradle +2 -2
  4. package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +8 -0
  5. package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +8 -0
  6. package/android-aar/library/build.gradle +5 -0
  7. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/EfentoRawConfigTransfer.kt +780 -0
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +124 -10
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +19 -8
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +136 -11
  11. package/android-aar/library/src/test/java/com/tempivo/sensor/beacon/PartnerBleRulesTest.kt +209 -0
  12. package/dist/native-module.d.ts +10 -0
  13. package/dist/native-module.js +46 -3
  14. package/dist/partner-dual-range-fixture.d.ts +28 -0
  15. package/dist/partner-dual-range-fixture.js +29 -0
  16. package/dist/profile.js +33 -19
  17. package/dist/proto/decode-proto-config.d.ts +3 -0
  18. package/dist/proto/decode-proto-config.js +46 -0
  19. package/dist/proto/efento-protobuf-descriptor.d.ts +964 -0
  20. package/dist/proto/efento-protobuf-descriptor.js +727 -0
  21. package/dist/proto/partner-from-proto.d.ts +9 -0
  22. package/dist/proto/partner-from-proto.js +187 -0
  23. package/dist/proto/partner-to-proto.d.ts +32 -0
  24. package/dist/proto/partner-to-proto.js +282 -0
  25. package/dist/tempivo-sensor-beacon.aar +0 -0
  26. package/ios/Frameworks/TempivoSensorBridge.xcframework/Info.plist +4 -4
  27. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  28. package/ios/Sources/TempivoSensorBeacon/EfentoRawConfigTransfer.swift +601 -0
  29. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +99 -29
  30. package/ios/TempivoSensorBeaconModule.swift +9 -0
  31. package/package.json +6 -2
@@ -0,0 +1,780 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.bluetooth.BluetoothAdapter
5
+ import android.bluetooth.BluetoothDevice
6
+ import android.bluetooth.BluetoothGatt
7
+ import android.bluetooth.BluetoothGattCallback
8
+ import android.bluetooth.BluetoothGattCharacteristic
9
+ import android.bluetooth.BluetoothGattDescriptor
10
+ import android.bluetooth.BluetoothManager
11
+ import android.bluetooth.BluetoothProfile
12
+ import android.content.Context
13
+ import android.os.Build
14
+ import android.os.Handler
15
+ import android.os.HandlerThread
16
+ import java.util.UUID
17
+ import java.util.concurrent.CountDownLatch
18
+ import java.util.concurrent.TimeUnit
19
+ import java.util.concurrent.atomic.AtomicReference
20
+
21
+ /**
22
+ * Wire-level ProtoConfig transfer (get 0x0C / set 0x0D) via Android [BluetoothGatt].
23
+ *
24
+ * Mirrors iOS `EfentoRawConfigTransfer`: one persistent GATT link reused across commands.
25
+ * Disconnect the vendor SDK session before calling.
26
+ *
27
+ * Important Android details:
28
+ * - GATT work runs on a dedicated [HandlerThread] (not the main looper). Blocking the main
29
+ * thread while posting `connectGatt` there deadlocks until timeout.
30
+ * - Service discovery starts on connect. Do not wait for `onMtuChanged` (often never fires).
31
+ */
32
+ internal object EfentoRawConfigTransfer {
33
+ private val TRANSFER_SERVICE = UUID.fromString("6E20F700-6126-4FCD-99C1-EB8A182501A2")
34
+ private val TRANSFER_WRITE = UUID.fromString("6E20F701-6126-4FCD-99C1-EB8A182501A2")
35
+ private val TRANSFER_NOTIFY = UUID.fromString("6E20F702-6126-4FCD-99C1-EB8A182501A2")
36
+ private val PRIMARY_SERVICE = UUID.fromString("69E1F600-BC3C-4DC0-8127-CFCDD295C7F7")
37
+ private val PRIMARY_WRITE = UUID.fromString("69E1F601-BC3C-4DC0-8127-CFCDD295C7F7")
38
+ private val PRIMARY_NOTIFY = UUID.fromString("69E1F602-BC3C-4DC0-8127-CFCDD295C7F7")
39
+ private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
40
+
41
+ private const val HDR_REQ: Byte = 0xAA.toByte()
42
+ private const val HDR_RSP: Byte = 0x55
43
+ private const val CMD_GET: Byte = 0x0C
44
+ private const val CMD_SET: Byte = 0x0D
45
+ private const val NOTIFY_CONFIGURATION: Byte = 0x03
46
+
47
+ private val link = Link()
48
+
49
+ @SuppressLint("MissingPermission")
50
+ fun readProtoConfig(
51
+ context: Context,
52
+ address: String,
53
+ bluetoothMac: String,
54
+ pin: String,
55
+ timeoutMs: Long = 40_000L,
56
+ ): ByteArray =
57
+ link.runCommand(
58
+ context = context.applicationContext,
59
+ address = address,
60
+ bluetoothMac = bluetoothMac,
61
+ pin = pin,
62
+ writeProto = null,
63
+ timeoutMs = timeoutMs,
64
+ )
65
+
66
+ @SuppressLint("MissingPermission")
67
+ fun writeProtoConfig(
68
+ context: Context,
69
+ address: String,
70
+ bluetoothMac: String,
71
+ pin: String,
72
+ proto: ByteArray,
73
+ timeoutMs: Long = 40_000L,
74
+ ) {
75
+ link.runCommand(
76
+ context = context.applicationContext,
77
+ address = address,
78
+ bluetoothMac = bluetoothMac,
79
+ pin = pin,
80
+ writeProto = proto,
81
+ timeoutMs = timeoutMs,
82
+ )
83
+ }
84
+
85
+ fun closeLink() {
86
+ link.close()
87
+ }
88
+
89
+ @SuppressLint("MissingPermission")
90
+ private class Link {
91
+ /** Dedicated BLE looper so connectGatt/callbacks never depend on the app main thread. */
92
+ private val bleThread =
93
+ HandlerThread("tempivo-raw-config-ble").apply { start() }
94
+ private val handler = Handler(bleThread.looper)
95
+ private val lock = Any()
96
+
97
+ private var gatt: BluetoothGatt? = null
98
+ private var writeChar: BluetoothGattCharacteristic? = null
99
+ private var notifyChar: BluetoothGattCharacteristic? = null
100
+ private var usingTransfer = true
101
+ private var discoveryStarted = false
102
+ private var connectedAddress: String? = null
103
+ private var negotiatedMtu = 23
104
+ private var mtuRequested = false
105
+ private var awaitingMtu = false
106
+ private var mtuAttempts = 0
107
+
108
+ private var bluetoothMac = ""
109
+ private var pin = ""
110
+ private var writeProto: ByteArray? = null
111
+ private var notifyChunks = ArrayList<ByteArray>()
112
+ private var startResponse: ByteArray? = null
113
+ private var commandSent = false
114
+ private var finished = true
115
+ private var commandToken = 0L
116
+ private var doneLatch: CountDownLatch? = null
117
+ private var resultRef = AtomicReference<Result<ByteArray>?>(null)
118
+
119
+ private val cmd: Byte
120
+ get() = if (writeProto == null) CMD_GET else CMD_SET
121
+
122
+ fun runCommand(
123
+ context: Context,
124
+ address: String,
125
+ bluetoothMac: String,
126
+ pin: String,
127
+ writeProto: ByteArray?,
128
+ timeoutMs: Long,
129
+ ): ByteArray {
130
+ var last: Throwable? = null
131
+ repeat(3) { attempt ->
132
+ try {
133
+ return attemptCommand(context, address, bluetoothMac, pin, writeProto, timeoutMs)
134
+ } catch (e: Throwable) {
135
+ last = e
136
+ close()
137
+ if (attempt < 2) Thread.sleep(2_000L * (attempt + 1))
138
+ }
139
+ }
140
+ throw last ?: TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Raw config transfer failed.")
141
+ }
142
+
143
+ fun close() {
144
+ synchronized(lock) {
145
+ finished = true
146
+ runCatching { gatt?.disconnect() }
147
+ runCatching { gatt?.close() }
148
+ gatt = null
149
+ writeChar = null
150
+ notifyChar = null
151
+ discoveryStarted = false
152
+ connectedAddress = null
153
+ negotiatedMtu = 23
154
+ mtuRequested = false
155
+ awaitingMtu = false
156
+ mtuAttempts = 0
157
+ doneLatch = null
158
+ resultRef.set(null)
159
+ }
160
+ }
161
+
162
+ private fun attemptCommand(
163
+ context: Context,
164
+ address: String,
165
+ bluetoothMac: String,
166
+ pin: String,
167
+ writeProto: ByteArray?,
168
+ timeoutMs: Long,
169
+ ): ByteArray {
170
+ val latch = CountDownLatch(1)
171
+ val token: Long
172
+ val macKey = normalizeAddress(bluetoothMac.ifBlank { address })
173
+ synchronized(lock) {
174
+ token = System.nanoTime()
175
+ commandToken = token
176
+ this.bluetoothMac = bluetoothMac
177
+ this.pin = pin
178
+ this.writeProto = writeProto
179
+ notifyChunks = ArrayList()
180
+ startResponse = null
181
+ commandSent = false
182
+ finished = false
183
+ doneLatch = latch
184
+ resultRef.set(null)
185
+ mtuAttempts = 0
186
+
187
+ val existing = gatt
188
+ val sameLink =
189
+ existing != null &&
190
+ connectedAddress != null &&
191
+ normalizeAddress(connectedAddress!!) == macKey &&
192
+ writeChar != null &&
193
+ notifyChar != null
194
+ if (sameLink) {
195
+ // Re-request MTU on reused links if a large write is pending.
196
+ mtuRequested = negotiatedMtu >= 100
197
+ handler.post { advance() }
198
+ } else {
199
+ runCatching { existing?.disconnect() }
200
+ runCatching { existing?.close() }
201
+ gatt = null
202
+ writeChar = null
203
+ notifyChar = null
204
+ discoveryStarted = false
205
+ negotiatedMtu = 23
206
+ mtuRequested = false
207
+ awaitingMtu = false
208
+ mtuAttempts = 0
209
+ val adapter = bluetoothAdapter(context)
210
+ ?: throw TempivoSensorException(
211
+ TempivoSensorException.Code.RUNTIME_UNAVAILABLE,
212
+ "Bluetooth is not available.",
213
+ )
214
+ if (!adapter.isEnabled) {
215
+ throw TempivoSensorException(
216
+ TempivoSensorException.Code.RUNTIME_UNAVAILABLE,
217
+ "Bluetooth is not powered on.",
218
+ )
219
+ }
220
+ // Always connect with the public Bluetooth MAC (CRC + stack). Avoid opaque SDK ids.
221
+ val connectAddress = macKey
222
+ val device =
223
+ try {
224
+ adapter.getRemoteDevice(connectAddress)
225
+ } catch (e: IllegalArgumentException) {
226
+ throw TempivoSensorException(
227
+ TempivoSensorException.Code.CONNECT_FAILED,
228
+ "Invalid BLE address.",
229
+ e,
230
+ )
231
+ }
232
+ connectedAddress = connectAddress
233
+ handler.post {
234
+ val g =
235
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
236
+ device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
237
+ } else {
238
+ @Suppress("DEPRECATION")
239
+ device.connectGatt(context, false, callback)
240
+ }
241
+ synchronized(lock) {
242
+ gatt = g
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ val ok = latch.await(timeoutMs, TimeUnit.MILLISECONDS)
249
+ if (!ok) {
250
+ fail(
251
+ TempivoSensorException(
252
+ TempivoSensorException.Code.UNKNOWN,
253
+ "Raw config BLE timeout (no GATT response).",
254
+ ),
255
+ )
256
+ latch.await(500, TimeUnit.MILLISECONDS)
257
+ }
258
+ val result =
259
+ resultRef.get()
260
+ ?: throw TempivoSensorException(
261
+ TempivoSensorException.Code.UNKNOWN,
262
+ "Raw config BLE timeout (no GATT response).",
263
+ )
264
+ return result.getOrThrow()
265
+ }
266
+
267
+ private fun requestMtuThenAdvance(g: BluetoothGatt) {
268
+ // Kept for callers: at most one MTU request per connection (Android 14+ ignores more).
269
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && !mtuRequested) {
270
+ mtuRequested = true
271
+ awaitingMtu = true
272
+ val ok = runCatching { g.requestMtu(517) }.getOrDefault(false)
273
+ if (ok) {
274
+ handler.postDelayed({
275
+ synchronized(lock) {
276
+ if (!finished && awaitingMtu) {
277
+ awaitingMtu = false
278
+ advance()
279
+ }
280
+ }
281
+ }, 1_500)
282
+ return
283
+ }
284
+ awaitingMtu = false
285
+ }
286
+ advance()
287
+ }
288
+
289
+ private fun advance() {
290
+ synchronized(lock) {
291
+ if (finished) return
292
+ if (awaitingMtu) return
293
+ val g = gatt ?: return
294
+ val w = writeChar
295
+ val n = notifyChar
296
+ if (w == null || n == null) {
297
+ if (!discoveryStarted) {
298
+ discoveryStarted = true
299
+ g.discoverServices()
300
+ }
301
+ return
302
+ }
303
+ if (!commandSent) {
304
+ commandSent = true
305
+ sendCommand(g, w)
306
+ }
307
+ }
308
+ }
309
+
310
+ private fun sendCommand(g: BluetoothGatt, write: BluetoothGattCharacteristic) {
311
+ val pinBytes =
312
+ pinPayloadBe3(pin)
313
+ ?: run {
314
+ fail(TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "PIN must be numeric (be3)."))
315
+ return
316
+ }
317
+ val payload =
318
+ if (writeProto != null) {
319
+ pinBytes + writeProto!!
320
+ } else {
321
+ // Get 0x0C: empty PIN payload after SDK pairing.
322
+ ByteArray(0)
323
+ }
324
+ val frame = buildCommandFrame(bluetoothMac, cmd, payload)
325
+ // Never gate on negotiatedMtu. This HC7 answers MTU 23 on Android (even when we
326
+ // request 517; Android 14+ also ignores later requestMtu on the same link).
327
+ // WRITE_TYPE_DEFAULT lets the stack use ATT Prepare Write / Execute (long write)
328
+ // for frames larger than ATT payload, which is what set (0x0D) needs (~237 B).
329
+ write.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
330
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
331
+ g.writeCharacteristic(write, frame, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
332
+ } else {
333
+ @Suppress("DEPRECATION")
334
+ write.value = frame
335
+ @Suppress("DEPRECATION")
336
+ g.writeCharacteristic(write)
337
+ }
338
+ // Do not read yet: long writes (237 B at MTU 23) finish asynchronously.
339
+ // onCharacteristicWrite schedules the follow-up read / assemble.
340
+ }
341
+
342
+ private fun afterCommandWriteAck() {
343
+ val token = commandToken
344
+ val delayMs = if (writeProto != null) 200L else 100L
345
+ handler.postDelayed({
346
+ synchronized(lock) {
347
+ if (finished || commandToken != token) return@synchronized
348
+ val w = writeChar ?: return@synchronized
349
+ val gg = gatt ?: return@synchronized
350
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
351
+ gg.readCharacteristic(w)
352
+ } else {
353
+ @Suppress("DEPRECATION")
354
+ gg.readCharacteristic(w)
355
+ }
356
+ }
357
+ }, delayMs)
358
+ if (writeProto != null) {
359
+ handler.postDelayed({
360
+ synchronized(lock) {
361
+ if (commandToken == token) finishWriteIfReady()
362
+ }
363
+ }, 1_500)
364
+ } else {
365
+ handler.postDelayed({
366
+ synchronized(lock) {
367
+ if (commandToken == token) tryAssemble()
368
+ }
369
+ }, 1_200)
370
+ }
371
+ }
372
+
373
+ private fun succeed(data: ByteArray) {
374
+ if (finished) return
375
+ finished = true
376
+ resultRef.set(Result.success(data))
377
+ doneLatch?.countDown()
378
+ }
379
+
380
+ private fun fail(error: Throwable) {
381
+ if (finished) return
382
+ finished = true
383
+ resultRef.set(Result.failure(error))
384
+ doneLatch?.countDown()
385
+ }
386
+
387
+ private val callback =
388
+ object : BluetoothGattCallback() {
389
+ override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
390
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
391
+ synchronized(lock) {
392
+ writeChar = null
393
+ notifyChar = null
394
+ discoveryStarted = false
395
+ negotiatedMtu = 23
396
+ mtuRequested = false
397
+ awaitingMtu = false
398
+ }
399
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
400
+ runCatching {
401
+ g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
402
+ }
403
+ }
404
+ // Delay after connect before the first (and only) requestMtu so the host
405
+ // controller can stabilize the ACL link (typical guidance: 300–600 ms).
406
+ // Android 14+ often applies ~517 on that first request and ignores later ones.
407
+ // Peripheral may still answer 23; set then relies on ATT long write, not a
408
+ // hardcoded 20/185 B gate. Max single ATT PDU is negotiatedMtu - 3 (≤514).
409
+ awaitingMtu = true
410
+ handler.postDelayed({
411
+ synchronized(lock) {
412
+ if (finished || gatt !== g) return@synchronized
413
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && !mtuRequested) {
414
+ mtuRequested = true
415
+ val ok = runCatching { g.requestMtu(517) }.getOrDefault(false)
416
+ if (ok) {
417
+ handler.postDelayed({
418
+ synchronized(lock) {
419
+ if (!finished && awaitingMtu) {
420
+ awaitingMtu = false
421
+ advance()
422
+ }
423
+ }
424
+ }, 1_500)
425
+ return@synchronized
426
+ }
427
+ }
428
+ awaitingMtu = false
429
+ advance()
430
+ }
431
+ }, 600)
432
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
433
+ synchronized(lock) {
434
+ writeChar = null
435
+ notifyChar = null
436
+ discoveryStarted = false
437
+ if (!finished) {
438
+ val msg =
439
+ if (status == 133) {
440
+ "Config GATT connect failed (status 133). Retrying."
441
+ } else {
442
+ "Sensor dropped the config link."
443
+ }
444
+ fail(
445
+ TempivoSensorException(
446
+ TempivoSensorException.Code.CONNECT_FAILED,
447
+ msg,
448
+ ),
449
+ )
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
456
+ synchronized(lock) {
457
+ if (status == BluetoothGatt.GATT_SUCCESS && mtu > 0) {
458
+ negotiatedMtu = mtu
459
+ }
460
+ awaitingMtu = false
461
+ }
462
+ // Start or continue discovery / command after MTU.
463
+ advance()
464
+ }
465
+
466
+ override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
467
+ if (status != BluetoothGatt.GATT_SUCCESS) {
468
+ fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "GATT service discovery failed."))
469
+ return
470
+ }
471
+ val transfer = g.getService(TRANSFER_SERVICE)
472
+ val primary = g.getService(PRIMARY_SERVICE)
473
+ val service = transfer ?: primary
474
+ if (service == null) {
475
+ fail(
476
+ TempivoSensorException(
477
+ TempivoSensorException.Code.UNSUPPORTED_COMMAND,
478
+ "No sensor config GATT service.",
479
+ ),
480
+ )
481
+ return
482
+ }
483
+ usingTransfer = transfer != null
484
+ val writeUuid = if (usingTransfer) TRANSFER_WRITE else PRIMARY_WRITE
485
+ val notifyUuid = if (usingTransfer) TRANSFER_NOTIFY else PRIMARY_NOTIFY
486
+ val write = service.getCharacteristic(writeUuid)
487
+ val notify = service.getCharacteristic(notifyUuid)
488
+ if (write == null || notify == null) {
489
+ fail(
490
+ TempivoSensorException(
491
+ TempivoSensorException.Code.UNSUPPORTED_COMMAND,
492
+ "Missing write/notify characteristics.",
493
+ ),
494
+ )
495
+ return
496
+ }
497
+ synchronized(lock) {
498
+ writeChar = write
499
+ notifyChar = notify
500
+ }
501
+ g.setCharacteristicNotification(notify, true)
502
+ // Write char may also notify the start response on some FW.
503
+ if ((write.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
504
+ (write.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0
505
+ ) {
506
+ g.setCharacteristicNotification(write, true)
507
+ }
508
+ val cccd = notify.getDescriptor(CCCD)
509
+ if (cccd != null) {
510
+ val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
511
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
512
+ g.writeDescriptor(cccd, enable)
513
+ } else {
514
+ @Suppress("DEPRECATION")
515
+ cccd.value = enable
516
+ @Suppress("DEPRECATION")
517
+ g.writeDescriptor(cccd)
518
+ }
519
+ } else {
520
+ advance()
521
+ }
522
+ }
523
+
524
+ override fun onDescriptorWrite(
525
+ g: BluetoothGatt,
526
+ descriptor: BluetoothGattDescriptor,
527
+ status: Int,
528
+ ) {
529
+ if (status != BluetoothGatt.GATT_SUCCESS) {
530
+ fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Notify enable failed."))
531
+ return
532
+ }
533
+ // Do not call requestMtu again here. Android 14+ ignores subsequent MTU
534
+ // requests on the same connection; HC7 often stays at 23 anyway.
535
+ advance()
536
+ }
537
+
538
+ override fun onCharacteristicWrite(
539
+ g: BluetoothGatt,
540
+ characteristic: BluetoothGattCharacteristic,
541
+ status: Int,
542
+ ) {
543
+ if (status != BluetoothGatt.GATT_SUCCESS) {
544
+ fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Config write failed ($status)."))
545
+ return
546
+ }
547
+ afterCommandWriteAck()
548
+ }
549
+
550
+ @Deprecated("Deprecated in Java")
551
+ override fun onCharacteristicChanged(
552
+ g: BluetoothGatt,
553
+ characteristic: BluetoothGattCharacteristic,
554
+ ) {
555
+ @Suppress("DEPRECATION")
556
+ val value = characteristic.value ?: return
557
+ onNotifyOrRead(characteristic.uuid, value, fromNotify = true)
558
+ }
559
+
560
+ override fun onCharacteristicChanged(
561
+ g: BluetoothGatt,
562
+ characteristic: BluetoothGattCharacteristic,
563
+ value: ByteArray,
564
+ ) {
565
+ onNotifyOrRead(characteristic.uuid, value, fromNotify = true)
566
+ }
567
+
568
+ @Deprecated("Deprecated in Java")
569
+ override fun onCharacteristicRead(
570
+ g: BluetoothGatt,
571
+ characteristic: BluetoothGattCharacteristic,
572
+ status: Int,
573
+ ) {
574
+ if (status != BluetoothGatt.GATT_SUCCESS) return
575
+ @Suppress("DEPRECATION")
576
+ val value = characteristic.value ?: return
577
+ onNotifyOrRead(characteristic.uuid, value, fromNotify = false)
578
+ }
579
+
580
+ override fun onCharacteristicRead(
581
+ g: BluetoothGatt,
582
+ characteristic: BluetoothGattCharacteristic,
583
+ value: ByteArray,
584
+ status: Int,
585
+ ) {
586
+ if (status != BluetoothGatt.GATT_SUCCESS) return
587
+ onNotifyOrRead(characteristic.uuid, value, fromNotify = false)
588
+ }
589
+ }
590
+
591
+ private fun onNotifyOrRead(uuid: UUID, value: ByteArray, fromNotify: Boolean) {
592
+ synchronized(lock) {
593
+ if (finished || value.isEmpty()) return
594
+ val writeUuid = writeChar?.uuid
595
+ val notifyUuid = notifyChar?.uuid
596
+ // iOS accepts start response on the write characteristic for both read and notify.
597
+ if (uuid == writeUuid) {
598
+ startResponse = value
599
+ if (writeProto != null) finishWriteIfReady()
600
+ return
601
+ }
602
+ if (writeProto == null && uuid == notifyUuid) {
603
+ notifyChunks.add(value)
604
+ tryAssemble()
605
+ }
606
+ }
607
+ }
608
+
609
+ private fun finishWriteIfReady() {
610
+ val resp = startResponse ?: return
611
+ if (writeProto == null) return
612
+ try {
613
+ parseStartResponse(bluetoothMac, cmd, resp)
614
+ succeed(ByteArray(0))
615
+ } catch (e: Throwable) {
616
+ fail(e)
617
+ }
618
+ }
619
+
620
+ private fun tryAssemble() {
621
+ startResponse?.let { resp ->
622
+ runCatching { parseStartResponse(bluetoothMac, cmd, resp) }
623
+ }
624
+ try {
625
+ val merged = assemble(bluetoothMac, notifyChunks, preferCode03 = usingTransfer)
626
+ if (merged.isNotEmpty()) succeed(merged)
627
+ } catch (_: Throwable) {
628
+ // Wait for more chunks until timeout.
629
+ }
630
+ }
631
+ }
632
+
633
+ private fun bluetoothAdapter(context: Context): BluetoothAdapter? {
634
+ val mgr = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
635
+ return mgr?.adapter
636
+ }
637
+
638
+ private fun normalizeAddress(address: String): String {
639
+ val h = address.replace(":", "").replace("-", "").trim()
640
+ if (h.length == 12 && h.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
641
+ return h.chunked(2).joinToString(":") { it.uppercase() }
642
+ }
643
+ return address.uppercase()
644
+ }
645
+
646
+ private fun pinPayloadBe3(pin: String): ByteArray? {
647
+ val digits = pin.trim()
648
+ val n = digits.toLongOrNull() ?: return null
649
+ if (n < 0 || n > 0xFFFFFF) return null
650
+ return byteArrayOf(
651
+ ((n shr 16) and 0xFF).toByte(),
652
+ ((n shr 8) and 0xFF).toByte(),
653
+ (n and 0xFF).toByte(),
654
+ )
655
+ }
656
+
657
+ private fun macBytes(mac: String): ByteArray {
658
+ val h = mac.replace(":", "").replace("-", "").trim().lowercase()
659
+ if (h.length != 12 || !h.all { it in '0'..'9' || it in 'a'..'f' }) {
660
+ throw TempivoSensorException(TempivoSensorException.Code.INVALID_QR, "Invalid Bluetooth MAC.")
661
+ }
662
+ return ByteArray(6) { i ->
663
+ h.substring(i * 2, i * 2 + 2).toInt(16).toByte()
664
+ }
665
+ }
666
+
667
+ private fun crc16Ccitt(data: ByteArray): Int {
668
+ var crc = 0xFFFF
669
+ for (byte in data) {
670
+ val b = byte.toInt() and 0xFF
671
+ for (bit in 0 until 8) {
672
+ val inp = ((b shr (7 - bit)) and 1) == 1
673
+ val cbit = ((crc shr 15) and 1) == 1
674
+ crc = (crc shl 1) and 0xFFFF
675
+ if (cbit != inp) crc = crc xor 0x1021
676
+ }
677
+ }
678
+ return crc and 0xFFFF
679
+ }
680
+
681
+ private fun buildCommandFrame(mac: String, cmd: Byte, payload: ByteArray): ByteArray {
682
+ val m = runCatching { macBytes(mac) }.getOrElse { ByteArray(6) }
683
+ val body = ByteArray(2 + payload.size + 2)
684
+ body[0] = HDR_REQ
685
+ body[1] = cmd
686
+ System.arraycopy(payload, 0, body, 2, payload.size)
687
+ val crc = crc16Ccitt(m + body.copyOf(2 + payload.size))
688
+ body[body.size - 2] = ((crc shr 8) and 0xFF).toByte()
689
+ body[body.size - 1] = (crc and 0xFF).toByte()
690
+ return body
691
+ }
692
+
693
+ private fun parseStartResponse(mac: String, cmd: Byte, frame: ByteArray): ByteArray {
694
+ if (frame.size < 5) {
695
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Short config response.")
696
+ }
697
+ if (frame[0] == 0x66.toByte()) {
698
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Encrypted config response (key required).")
699
+ }
700
+ if (frame[0] != HDR_RSP) {
701
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Unexpected config response header.")
702
+ }
703
+ if (frame[1] != cmd) {
704
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Config command echo mismatch.")
705
+ }
706
+ val m = macBytes(mac)
707
+ val expect = crc16Ccitt(m + frame.copyOf(frame.size - 2))
708
+ val got = ((frame[frame.size - 2].toInt() and 0xFF) shl 8) or (frame[frame.size - 1].toInt() and 0xFF)
709
+ if (expect != got) {
710
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Config response CRC mismatch.")
711
+ }
712
+ val st = frame[2].toInt() and 0xFF
713
+ if (st != 0) {
714
+ throw TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "Config command status $st.")
715
+ }
716
+ return frame.copyOfRange(3, frame.size - 2)
717
+ }
718
+
719
+ private fun assemble(mac: String, chunks: List<ByteArray>, preferCode03: Boolean): ByteArray {
720
+ val m = macBytes(mac)
721
+ var buf = ByteArray(0)
722
+ var merged = ByteArray(0)
723
+ var complete = false
724
+
725
+ fun crcValid(frame: ByteArray): Boolean {
726
+ if (frame.size < 4) return false
727
+ val expect = crc16Ccitt(m + frame.copyOf(frame.size - 2))
728
+ val got = ((frame[frame.size - 2].toInt() and 0xFF) shl 8) or (frame[frame.size - 1].toInt() and 0xFF)
729
+ return expect == got
730
+ }
731
+
732
+ fun feed(payload: ByteArray) {
733
+ var off = 0
734
+ val inc = payload
735
+ while (off < inc.size || (buf.size >= 2 && !complete)) {
736
+ if (buf.size < 2) {
737
+ val need = 2 - buf.size
738
+ val take = minOf(need, inc.size - off)
739
+ if (take <= 0) break
740
+ buf += inc.copyOfRange(off, off + take)
741
+ off += take
742
+ if (buf.size < 2) break
743
+ }
744
+ val flen = ((buf[0].toInt() and 0xFF) shl 8) or (buf[1].toInt() and 0xFF)
745
+ if (flen == 0) {
746
+ complete = true
747
+ buf = ByteArray(0)
748
+ return
749
+ }
750
+ val missing = flen - buf.size
751
+ if (missing > 0) {
752
+ val take = minOf(missing, inc.size - off)
753
+ if (take <= 0) break
754
+ buf += inc.copyOfRange(off, off + take)
755
+ off += take
756
+ if (buf.size < flen) continue
757
+ }
758
+ val frame = buf.copyOfRange(0, flen)
759
+ buf = if (buf.size > flen) buf.copyOfRange(flen, buf.size) else ByteArray(0)
760
+ if (!crcValid(frame)) continue
761
+ if (flen >= 4) {
762
+ merged += frame.copyOfRange(2, flen - 2)
763
+ }
764
+ }
765
+ }
766
+
767
+ for (raw in chunks) {
768
+ if (raw.size >= 2 && raw[0] == NOTIFY_CONFIGURATION) {
769
+ feed(raw.copyOfRange(1, raw.size))
770
+ } else if (raw.size >= 4) {
771
+ feed(raw)
772
+ }
773
+ if (complete && merged.isNotEmpty()) break
774
+ }
775
+ if (!complete || merged.isEmpty()) {
776
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Incomplete config transfer.")
777
+ }
778
+ return merged
779
+ }
780
+ }