@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.
- package/CHANGELOG.md +40 -0
- package/README.md +35 -22
- package/android/build.gradle +2 -2
- package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +8 -0
- package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +8 -0
- package/android-aar/library/build.gradle +5 -0
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/EfentoRawConfigTransfer.kt +780 -0
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +124 -10
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +19 -8
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +136 -11
- package/android-aar/library/src/test/java/com/tempivo/sensor/beacon/PartnerBleRulesTest.kt +209 -0
- package/dist/native-module.d.ts +10 -0
- package/dist/native-module.js +46 -3
- package/dist/partner-dual-range-fixture.d.ts +28 -0
- package/dist/partner-dual-range-fixture.js +29 -0
- package/dist/profile.js +33 -19
- package/dist/proto/decode-proto-config.d.ts +3 -0
- package/dist/proto/decode-proto-config.js +46 -0
- package/dist/proto/efento-protobuf-descriptor.d.ts +964 -0
- package/dist/proto/efento-protobuf-descriptor.js +727 -0
- package/dist/proto/partner-from-proto.d.ts +9 -0
- package/dist/proto/partner-from-proto.js +187 -0
- package/dist/proto/partner-to-proto.d.ts +32 -0
- package/dist/proto/partner-to-proto.js +282 -0
- package/dist/tempivo-sensor-beacon.aar +0 -0
- package/ios/Frameworks/TempivoSensorBridge.xcframework/Info.plist +4 -4
- package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
- package/ios/Sources/TempivoSensorBeacon/EfentoRawConfigTransfer.swift +601 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +99 -29
- package/ios/TempivoSensorBeaconModule.swift +9 -0
- package/package.json +6 -2
package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/EfentoRawConfigTransfer.kt
ADDED
|
@@ -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
|
+
}
|