@tempivo/sensor-beacon 1.1.1 → 1.1.4

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 (32) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +38 -23
  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 +672 -0
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +188 -52
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +20 -0
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +28 -0
  11. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +142 -11
  12. package/android-aar/library/src/test/java/com/tempivo/sensor/beacon/PartnerBleRulesTest.kt +209 -0
  13. package/dist/native-helpers.js +3 -1
  14. package/dist/native-module.d.ts +10 -0
  15. package/dist/native-module.js +46 -3
  16. package/dist/partner-dual-range-fixture.d.ts +28 -0
  17. package/dist/partner-dual-range-fixture.js +29 -0
  18. package/dist/profile.js +66 -32
  19. package/dist/proto/decode-proto-config.d.ts +3 -0
  20. package/dist/proto/decode-proto-config.js +46 -0
  21. package/dist/proto/efento-protobuf-descriptor.d.ts +964 -0
  22. package/dist/proto/efento-protobuf-descriptor.js +727 -0
  23. package/dist/proto/partner-from-proto.d.ts +9 -0
  24. package/dist/proto/partner-from-proto.js +187 -0
  25. package/dist/proto/partner-to-proto.d.ts +12 -0
  26. package/dist/proto/partner-to-proto.js +211 -0
  27. package/dist/tempivo-sensor-beacon.aar +0 -0
  28. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  29. package/ios/Sources/TempivoSensorBeacon/EfentoRawConfigTransfer.swift +601 -0
  30. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +99 -29
  31. package/ios/TempivoSensorBeaconModule.swift +9 -0
  32. package/package.json +6 -2
@@ -0,0 +1,672 @@
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.Looper
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
+ internal object EfentoRawConfigTransfer {
28
+ private val TRANSFER_SERVICE = UUID.fromString("6E20F700-6126-4FCD-99C1-EB8A182501A2")
29
+ private val TRANSFER_WRITE = UUID.fromString("6E20F701-6126-4FCD-99C1-EB8A182501A2")
30
+ private val TRANSFER_NOTIFY = UUID.fromString("6E20F702-6126-4FCD-99C1-EB8A182501A2")
31
+ private val PRIMARY_SERVICE = UUID.fromString("69E1F600-BC3C-4DC0-8127-CFCDD295C7F7")
32
+ private val PRIMARY_WRITE = UUID.fromString("69E1F601-BC3C-4DC0-8127-CFCDD295C7F7")
33
+ private val PRIMARY_NOTIFY = UUID.fromString("69E1F602-BC3C-4DC0-8127-CFCDD295C7F7")
34
+ private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
35
+
36
+ private const val HDR_REQ: Byte = 0xAA.toByte()
37
+ private const val HDR_RSP: Byte = 0x55
38
+ private const val CMD_GET: Byte = 0x0C
39
+ private const val CMD_SET: Byte = 0x0D
40
+ private const val NOTIFY_CONFIGURATION: Byte = 0x03
41
+
42
+ private val link = Link()
43
+
44
+ @SuppressLint("MissingPermission")
45
+ fun readProtoConfig(
46
+ context: Context,
47
+ address: String,
48
+ bluetoothMac: String,
49
+ pin: String,
50
+ timeoutMs: Long = 25_000L,
51
+ ): ByteArray =
52
+ link.runCommand(
53
+ context = context.applicationContext,
54
+ address = address,
55
+ bluetoothMac = bluetoothMac,
56
+ pin = pin,
57
+ writeProto = null,
58
+ timeoutMs = timeoutMs,
59
+ )
60
+
61
+ @SuppressLint("MissingPermission")
62
+ fun writeProtoConfig(
63
+ context: Context,
64
+ address: String,
65
+ bluetoothMac: String,
66
+ pin: String,
67
+ proto: ByteArray,
68
+ timeoutMs: Long = 25_000L,
69
+ ) {
70
+ link.runCommand(
71
+ context = context.applicationContext,
72
+ address = address,
73
+ bluetoothMac = bluetoothMac,
74
+ pin = pin,
75
+ writeProto = proto,
76
+ timeoutMs = timeoutMs,
77
+ )
78
+ }
79
+
80
+ fun closeLink() {
81
+ link.close()
82
+ }
83
+
84
+ @SuppressLint("MissingPermission")
85
+ private class Link {
86
+ private val handler = Handler(Looper.getMainLooper())
87
+ private val lock = Any()
88
+
89
+ private var gatt: BluetoothGatt? = null
90
+ private var writeChar: BluetoothGattCharacteristic? = null
91
+ private var notifyChar: BluetoothGattCharacteristic? = null
92
+ private var usingTransfer = true
93
+ private var discoveryStarted = false
94
+ private var connectedAddress: String? = null
95
+
96
+ private var bluetoothMac = ""
97
+ private var pin = ""
98
+ private var writeProto: ByteArray? = null
99
+ private var notifyChunks = ArrayList<ByteArray>()
100
+ private var startResponse: ByteArray? = null
101
+ private var commandSent = false
102
+ private var finished = true
103
+ private var commandToken = 0L
104
+ private var doneLatch: CountDownLatch? = null
105
+ private var resultRef = AtomicReference<Result<ByteArray>?>(null)
106
+
107
+ private val cmd: Byte
108
+ get() = if (writeProto == null) CMD_GET else CMD_SET
109
+
110
+ fun runCommand(
111
+ context: Context,
112
+ address: String,
113
+ bluetoothMac: String,
114
+ pin: String,
115
+ writeProto: ByteArray?,
116
+ timeoutMs: Long,
117
+ ): ByteArray {
118
+ var last: Throwable? = null
119
+ repeat(2) { attempt ->
120
+ try {
121
+ return attemptCommand(context, address, bluetoothMac, pin, writeProto, timeoutMs)
122
+ } catch (e: Throwable) {
123
+ last = e
124
+ close()
125
+ if (attempt == 0) Thread.sleep(1_000)
126
+ }
127
+ }
128
+ throw last ?: TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Raw config transfer failed.")
129
+ }
130
+
131
+ fun close() {
132
+ synchronized(lock) {
133
+ finished = true
134
+ runCatching { gatt?.disconnect() }
135
+ runCatching { gatt?.close() }
136
+ gatt = null
137
+ writeChar = null
138
+ notifyChar = null
139
+ discoveryStarted = false
140
+ connectedAddress = null
141
+ doneLatch = null
142
+ resultRef.set(null)
143
+ }
144
+ }
145
+
146
+ private fun attemptCommand(
147
+ context: Context,
148
+ address: String,
149
+ bluetoothMac: String,
150
+ pin: String,
151
+ writeProto: ByteArray?,
152
+ timeoutMs: Long,
153
+ ): ByteArray {
154
+ val latch = CountDownLatch(1)
155
+ val token: Long
156
+ synchronized(lock) {
157
+ token = System.nanoTime()
158
+ commandToken = token
159
+ this.bluetoothMac = bluetoothMac
160
+ this.pin = pin
161
+ this.writeProto = writeProto
162
+ notifyChunks = ArrayList()
163
+ startResponse = null
164
+ commandSent = false
165
+ finished = false
166
+ doneLatch = latch
167
+ resultRef.set(null)
168
+
169
+ val existing = gatt
170
+ if (existing != null && connectedAddress.equals(address, ignoreCase = true)) {
171
+ handler.post { advance() }
172
+ } else {
173
+ runCatching { existing?.disconnect() }
174
+ runCatching { existing?.close() }
175
+ gatt = null
176
+ writeChar = null
177
+ notifyChar = null
178
+ discoveryStarted = false
179
+ connectedAddress = address
180
+ val adapter = bluetoothAdapter(context)
181
+ ?: throw TempivoSensorException(
182
+ TempivoSensorException.Code.RUNTIME_UNAVAILABLE,
183
+ "Bluetooth is not available.",
184
+ )
185
+ if (!adapter.isEnabled) {
186
+ throw TempivoSensorException(
187
+ TempivoSensorException.Code.RUNTIME_UNAVAILABLE,
188
+ "Bluetooth is not powered on.",
189
+ )
190
+ }
191
+ val device =
192
+ try {
193
+ adapter.getRemoteDevice(normalizeAddress(address))
194
+ } catch (e: IllegalArgumentException) {
195
+ throw TempivoSensorException(
196
+ TempivoSensorException.Code.CONNECT_FAILED,
197
+ "Invalid BLE address.",
198
+ e,
199
+ )
200
+ }
201
+ handler.post {
202
+ val g =
203
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
204
+ device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
205
+ } else {
206
+ @Suppress("DEPRECATION")
207
+ device.connectGatt(context, false, callback)
208
+ }
209
+ synchronized(lock) {
210
+ gatt = g
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ val ok = latch.await(timeoutMs, TimeUnit.MILLISECONDS)
217
+ if (!ok) {
218
+ fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Raw config BLE timeout."))
219
+ latch.await(500, TimeUnit.MILLISECONDS)
220
+ }
221
+ val result =
222
+ resultRef.get()
223
+ ?: throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Raw config BLE timeout.")
224
+ return result.getOrThrow()
225
+ }
226
+
227
+ private fun advance() {
228
+ synchronized(lock) {
229
+ if (finished) return
230
+ val g = gatt ?: return
231
+ val w = writeChar
232
+ val n = notifyChar
233
+ if (w == null || n == null) {
234
+ if (!discoveryStarted) {
235
+ discoveryStarted = true
236
+ g.discoverServices()
237
+ }
238
+ return
239
+ }
240
+ if (!commandSent) {
241
+ commandSent = true
242
+ sendCommand(g, w)
243
+ }
244
+ }
245
+ }
246
+
247
+ private fun sendCommand(g: BluetoothGatt, write: BluetoothGattCharacteristic) {
248
+ val pinBytes =
249
+ pinPayloadBe3(pin)
250
+ ?: run {
251
+ fail(TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "PIN must be numeric (be3)."))
252
+ return
253
+ }
254
+ val payload =
255
+ if (writeProto != null) {
256
+ pinBytes + writeProto!!
257
+ } else {
258
+ // Get 0x0C: empty PIN payload after SDK pairing.
259
+ ByteArray(0)
260
+ }
261
+ val frame = buildCommandFrame(bluetoothMac, cmd, payload)
262
+ val max = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
263
+ // Typical ATT payload after MTU negotiation; fall back conservatively.
264
+ 512
265
+ } else {
266
+ 20
267
+ }
268
+ if (frame.size > max) {
269
+ fail(
270
+ TempivoSensorException(
271
+ TempivoSensorException.Code.INVALID_CONFIG,
272
+ "Config frame is ${frame.size} B but this link accepts $max B per write.",
273
+ ),
274
+ )
275
+ return
276
+ }
277
+ write.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
278
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
279
+ g.writeCharacteristic(write, frame, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
280
+ } else {
281
+ @Suppress("DEPRECATION")
282
+ write.value = frame
283
+ @Suppress("DEPRECATION")
284
+ g.writeCharacteristic(write)
285
+ }
286
+ val token = commandToken
287
+ handler.postDelayed({
288
+ synchronized(lock) {
289
+ if (finished || commandToken != token) return@synchronized
290
+ val w = writeChar ?: return@synchronized
291
+ val gg = gatt ?: return@synchronized
292
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
293
+ gg.readCharacteristic(w)
294
+ } else {
295
+ @Suppress("DEPRECATION")
296
+ gg.readCharacteristic(w)
297
+ }
298
+ }
299
+ }, 350)
300
+ if (writeProto != null) {
301
+ handler.postDelayed({
302
+ synchronized(lock) {
303
+ if (commandToken == token) finishWriteIfReady()
304
+ }
305
+ }, 800)
306
+ } else {
307
+ handler.postDelayed({
308
+ synchronized(lock) {
309
+ if (commandToken == token) tryAssemble()
310
+ }
311
+ }, 1_000)
312
+ }
313
+ }
314
+
315
+ private fun succeed(data: ByteArray) {
316
+ if (finished) return
317
+ finished = true
318
+ resultRef.set(Result.success(data))
319
+ doneLatch?.countDown()
320
+ }
321
+
322
+ private fun fail(error: Throwable) {
323
+ if (finished) return
324
+ finished = true
325
+ resultRef.set(Result.failure(error))
326
+ doneLatch?.countDown()
327
+ }
328
+
329
+ private val callback =
330
+ object : BluetoothGattCallback() {
331
+ override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
332
+ if (newState == BluetoothProfile.STATE_CONNECTED) {
333
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
334
+ g.requestMtu(517)
335
+ } else {
336
+ synchronized(lock) {
337
+ writeChar = null
338
+ notifyChar = null
339
+ discoveryStarted = false
340
+ }
341
+ advance()
342
+ }
343
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
344
+ synchronized(lock) {
345
+ writeChar = null
346
+ notifyChar = null
347
+ discoveryStarted = false
348
+ if (!finished) {
349
+ fail(
350
+ TempivoSensorException(
351
+ TempivoSensorException.Code.CONNECT_FAILED,
352
+ "Sensor dropped the config link.",
353
+ ),
354
+ )
355
+ }
356
+ }
357
+ }
358
+ }
359
+
360
+ override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
361
+ synchronized(lock) {
362
+ writeChar = null
363
+ notifyChar = null
364
+ discoveryStarted = false
365
+ }
366
+ advance()
367
+ }
368
+
369
+ override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
370
+ if (status != BluetoothGatt.GATT_SUCCESS) {
371
+ fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "GATT service discovery failed."))
372
+ return
373
+ }
374
+ val transfer = g.getService(TRANSFER_SERVICE)
375
+ val primary = g.getService(PRIMARY_SERVICE)
376
+ val service = transfer ?: primary
377
+ if (service == null) {
378
+ fail(
379
+ TempivoSensorException(
380
+ TempivoSensorException.Code.UNSUPPORTED_COMMAND,
381
+ "No sensor config GATT service.",
382
+ ),
383
+ )
384
+ return
385
+ }
386
+ usingTransfer = transfer != null
387
+ val writeUuid = if (usingTransfer) TRANSFER_WRITE else PRIMARY_WRITE
388
+ val notifyUuid = if (usingTransfer) TRANSFER_NOTIFY else PRIMARY_NOTIFY
389
+ val write = service.getCharacteristic(writeUuid)
390
+ val notify = service.getCharacteristic(notifyUuid)
391
+ if (write == null || notify == null) {
392
+ fail(
393
+ TempivoSensorException(
394
+ TempivoSensorException.Code.UNSUPPORTED_COMMAND,
395
+ "Missing write/notify characteristics.",
396
+ ),
397
+ )
398
+ return
399
+ }
400
+ synchronized(lock) {
401
+ writeChar = write
402
+ notifyChar = notify
403
+ }
404
+ g.setCharacteristicNotification(notify, true)
405
+ val cccd = notify.getDescriptor(CCCD)
406
+ if (cccd != null) {
407
+ val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
408
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
409
+ g.writeDescriptor(cccd, enable)
410
+ } else {
411
+ @Suppress("DEPRECATION")
412
+ cccd.value = enable
413
+ @Suppress("DEPRECATION")
414
+ g.writeDescriptor(cccd)
415
+ }
416
+ } else {
417
+ advance()
418
+ }
419
+ }
420
+
421
+ override fun onDescriptorWrite(
422
+ g: BluetoothGatt,
423
+ descriptor: BluetoothGattDescriptor,
424
+ status: Int,
425
+ ) {
426
+ if (status != BluetoothGatt.GATT_SUCCESS) {
427
+ fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Notify enable failed."))
428
+ return
429
+ }
430
+ advance()
431
+ }
432
+
433
+ override fun onCharacteristicWrite(
434
+ g: BluetoothGatt,
435
+ characteristic: BluetoothGattCharacteristic,
436
+ status: Int,
437
+ ) {
438
+ if (status != BluetoothGatt.GATT_SUCCESS) {
439
+ fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Config write failed ($status)."))
440
+ }
441
+ }
442
+
443
+ @Deprecated("Deprecated in Java")
444
+ override fun onCharacteristicChanged(
445
+ g: BluetoothGatt,
446
+ characteristic: BluetoothGattCharacteristic,
447
+ ) {
448
+ @Suppress("DEPRECATION")
449
+ val value = characteristic.value ?: return
450
+ onNotifyOrRead(characteristic.uuid, value, fromNotify = true)
451
+ }
452
+
453
+ override fun onCharacteristicChanged(
454
+ g: BluetoothGatt,
455
+ characteristic: BluetoothGattCharacteristic,
456
+ value: ByteArray,
457
+ ) {
458
+ onNotifyOrRead(characteristic.uuid, value, fromNotify = true)
459
+ }
460
+
461
+ @Deprecated("Deprecated in Java")
462
+ override fun onCharacteristicRead(
463
+ g: BluetoothGatt,
464
+ characteristic: BluetoothGattCharacteristic,
465
+ status: Int,
466
+ ) {
467
+ if (status != BluetoothGatt.GATT_SUCCESS) return
468
+ @Suppress("DEPRECATION")
469
+ val value = characteristic.value ?: return
470
+ onNotifyOrRead(characteristic.uuid, value, fromNotify = false)
471
+ }
472
+
473
+ override fun onCharacteristicRead(
474
+ g: BluetoothGatt,
475
+ characteristic: BluetoothGattCharacteristic,
476
+ value: ByteArray,
477
+ status: Int,
478
+ ) {
479
+ if (status != BluetoothGatt.GATT_SUCCESS) return
480
+ onNotifyOrRead(characteristic.uuid, value, fromNotify = false)
481
+ }
482
+ }
483
+
484
+ private fun onNotifyOrRead(uuid: UUID, value: ByteArray, fromNotify: Boolean) {
485
+ synchronized(lock) {
486
+ if (finished || value.isEmpty()) return
487
+ val writeUuid = writeChar?.uuid
488
+ val notifyUuid = notifyChar?.uuid
489
+ if (uuid == writeUuid && !fromNotify) {
490
+ startResponse = value
491
+ if (writeProto != null) finishWriteIfReady()
492
+ return
493
+ }
494
+ if (writeProto == null && uuid == notifyUuid) {
495
+ notifyChunks.add(value)
496
+ tryAssemble()
497
+ }
498
+ }
499
+ }
500
+
501
+ private fun finishWriteIfReady() {
502
+ val resp = startResponse ?: return
503
+ if (writeProto == null) return
504
+ try {
505
+ parseStartResponse(bluetoothMac, cmd, resp)
506
+ succeed(ByteArray(0))
507
+ } catch (e: Throwable) {
508
+ fail(e)
509
+ }
510
+ }
511
+
512
+ private fun tryAssemble() {
513
+ startResponse?.let { resp ->
514
+ runCatching { parseStartResponse(bluetoothMac, cmd, resp) }
515
+ }
516
+ try {
517
+ val merged = assemble(bluetoothMac, notifyChunks, preferCode03 = usingTransfer)
518
+ if (merged.isNotEmpty()) succeed(merged)
519
+ } catch (_: Throwable) {
520
+ // Wait for more chunks until timeout.
521
+ }
522
+ }
523
+ }
524
+
525
+ private fun bluetoothAdapter(context: Context): BluetoothAdapter? {
526
+ val mgr = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
527
+ return mgr?.adapter
528
+ }
529
+
530
+ private fun normalizeAddress(address: String): String {
531
+ val h = address.replace(":", "").replace("-", "").trim()
532
+ if (h.length == 12 && h.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
533
+ return h.chunked(2).joinToString(":") { it.uppercase() }
534
+ }
535
+ return address.uppercase()
536
+ }
537
+
538
+ private fun pinPayloadBe3(pin: String): ByteArray? {
539
+ val digits = pin.trim()
540
+ val n = digits.toLongOrNull() ?: return null
541
+ if (n < 0 || n > 0xFFFFFF) return null
542
+ return byteArrayOf(
543
+ ((n shr 16) and 0xFF).toByte(),
544
+ ((n shr 8) and 0xFF).toByte(),
545
+ (n and 0xFF).toByte(),
546
+ )
547
+ }
548
+
549
+ private fun macBytes(mac: String): ByteArray {
550
+ val h = mac.replace(":", "").replace("-", "").trim().lowercase()
551
+ if (h.length != 12 || !h.all { it in '0'..'9' || it in 'a'..'f' }) {
552
+ throw TempivoSensorException(TempivoSensorException.Code.INVALID_QR, "Invalid Bluetooth MAC.")
553
+ }
554
+ return ByteArray(6) { i ->
555
+ h.substring(i * 2, i * 2 + 2).toInt(16).toByte()
556
+ }
557
+ }
558
+
559
+ private fun crc16Ccitt(data: ByteArray): Int {
560
+ var crc = 0xFFFF
561
+ for (byte in data) {
562
+ val b = byte.toInt() and 0xFF
563
+ for (bit in 0 until 8) {
564
+ val inp = ((b shr (7 - bit)) and 1) == 1
565
+ val cbit = ((crc shr 15) and 1) == 1
566
+ crc = (crc shl 1) and 0xFFFF
567
+ if (cbit != inp) crc = crc xor 0x1021
568
+ }
569
+ }
570
+ return crc and 0xFFFF
571
+ }
572
+
573
+ private fun buildCommandFrame(mac: String, cmd: Byte, payload: ByteArray): ByteArray {
574
+ val m = runCatching { macBytes(mac) }.getOrElse { ByteArray(6) }
575
+ val body = ByteArray(2 + payload.size + 2)
576
+ body[0] = HDR_REQ
577
+ body[1] = cmd
578
+ System.arraycopy(payload, 0, body, 2, payload.size)
579
+ val crc = crc16Ccitt(m + body.copyOf(2 + payload.size))
580
+ body[body.size - 2] = ((crc shr 8) and 0xFF).toByte()
581
+ body[body.size - 1] = (crc and 0xFF).toByte()
582
+ return body
583
+ }
584
+
585
+ private fun parseStartResponse(mac: String, cmd: Byte, frame: ByteArray): ByteArray {
586
+ if (frame.size < 5) {
587
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Short config response.")
588
+ }
589
+ if (frame[0] == 0x66.toByte()) {
590
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Encrypted config response (key required).")
591
+ }
592
+ if (frame[0] != HDR_RSP) {
593
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Unexpected config response header.")
594
+ }
595
+ if (frame[1] != cmd) {
596
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Config command echo mismatch.")
597
+ }
598
+ val m = macBytes(mac)
599
+ val expect = crc16Ccitt(m + frame.copyOf(frame.size - 2))
600
+ val got = ((frame[frame.size - 2].toInt() and 0xFF) shl 8) or (frame[frame.size - 1].toInt() and 0xFF)
601
+ if (expect != got) {
602
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Config response CRC mismatch.")
603
+ }
604
+ val st = frame[2].toInt() and 0xFF
605
+ if (st != 0) {
606
+ throw TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "Config command status $st.")
607
+ }
608
+ return frame.copyOfRange(3, frame.size - 2)
609
+ }
610
+
611
+ private fun assemble(mac: String, chunks: List<ByteArray>, preferCode03: Boolean): ByteArray {
612
+ val m = macBytes(mac)
613
+ var buf = ByteArray(0)
614
+ var merged = ByteArray(0)
615
+ var complete = false
616
+
617
+ fun crcValid(frame: ByteArray): Boolean {
618
+ if (frame.size < 4) return false
619
+ val expect = crc16Ccitt(m + frame.copyOf(frame.size - 2))
620
+ val got = ((frame[frame.size - 2].toInt() and 0xFF) shl 8) or (frame[frame.size - 1].toInt() and 0xFF)
621
+ return expect == got
622
+ }
623
+
624
+ fun feed(payload: ByteArray) {
625
+ var off = 0
626
+ val inc = payload
627
+ while (off < inc.size || (buf.size >= 2 && !complete)) {
628
+ if (buf.size < 2) {
629
+ val need = 2 - buf.size
630
+ val take = minOf(need, inc.size - off)
631
+ if (take <= 0) break
632
+ buf += inc.copyOfRange(off, off + take)
633
+ off += take
634
+ if (buf.size < 2) break
635
+ }
636
+ val flen = ((buf[0].toInt() and 0xFF) shl 8) or (buf[1].toInt() and 0xFF)
637
+ if (flen == 0) {
638
+ complete = true
639
+ buf = ByteArray(0)
640
+ return
641
+ }
642
+ val missing = flen - buf.size
643
+ if (missing > 0) {
644
+ val take = minOf(missing, inc.size - off)
645
+ if (take <= 0) break
646
+ buf += inc.copyOfRange(off, off + take)
647
+ off += take
648
+ if (buf.size < flen) continue
649
+ }
650
+ val frame = buf.copyOfRange(0, flen)
651
+ buf = if (buf.size > flen) buf.copyOfRange(flen, buf.size) else ByteArray(0)
652
+ if (!crcValid(frame)) continue
653
+ if (flen >= 4) {
654
+ merged += frame.copyOfRange(2, flen - 2)
655
+ }
656
+ }
657
+ }
658
+
659
+ for (raw in chunks) {
660
+ if (raw.size >= 2 && raw[0] == NOTIFY_CONFIGURATION) {
661
+ feed(raw.copyOfRange(1, raw.size))
662
+ } else if (raw.size >= 4) {
663
+ feed(raw)
664
+ }
665
+ if (complete && merged.isNotEmpty()) break
666
+ }
667
+ if (!complete || merged.isEmpty()) {
668
+ throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Incomplete config transfer.")
669
+ }
670
+ return merged
671
+ }
672
+ }