@tempivo/sensor-beacon 0.3.2 → 0.4.3
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/README.md +22 -11
- package/android/build.gradle +13 -5
- package/android/src/main/AndroidManifest.xml +2 -1
- package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +6 -16
- package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +13 -6
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +17 -6
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +34 -124
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorSdkScanTelemetry.kt +138 -0
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +99 -18
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +165 -65
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +0 -17
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +59 -2
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +7 -17
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +9 -38
- package/dist/decoder.d.ts +0 -2
- package/dist/decoder.js +0 -17
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/native-helpers.d.ts +0 -1
- package/dist/native-helpers.js +3 -18
- package/dist/native-module.js +5 -2
- package/dist/normalize.d.ts +6 -0
- package/dist/normalize.js +29 -0
- package/dist/profile.d.ts +5 -0
- package/dist/profile.js +47 -1
- package/dist/qr.d.ts +4 -7
- package/dist/qr.js +9 -24
- package/dist/react-native.d.ts +24 -1
- package/dist/react-native.js +22 -1
- package/dist/session-types.d.ts +11 -4
- package/dist/tempivo-sensor-beacon.aar +0 -0
- package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +1 -3
- package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
- package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +1 -3
- package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +12 -2
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconScanner.swift +4 -80
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +0 -15
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +1 -15
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +57 -3
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +9 -23
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +10 -36
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +10 -2
- package/ios/TempivoSensorBeaconModule.swift +139 -25
- package/package.json +6 -1
package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.tempivo.sensor.beacon
|
|
2
2
|
|
|
3
|
+
import android.bluetooth.le.ScanRecord
|
|
3
4
|
import java.text.SimpleDateFormat
|
|
4
5
|
import java.util.Date
|
|
5
6
|
import java.util.Locale
|
|
@@ -12,12 +13,18 @@ import kotlin.math.floor
|
|
|
12
13
|
*
|
|
13
14
|
* Android [android.bluetooth.le.ScanRecord.getManufacturerSpecificData] returns the payload **after**
|
|
14
15
|
* the 2-byte company id — i.e. starts with `0x03` / `0x04`, not `6C 02`.
|
|
16
|
+
*
|
|
17
|
+
* Prefer [manufacturerPayloadsFromScanRecordBytes]: `getManufacturerSpecificData(0x026C)` can keep only
|
|
18
|
+
* one blob when both advertisement (`0x03`) and scan response (`0x04`) share the same company id.
|
|
15
19
|
*/
|
|
16
20
|
object TempivoSensorBeaconDecoder {
|
|
17
21
|
|
|
18
22
|
/** Bluetooth SIG company identifier (little-endian on air: `6C 02`). */
|
|
19
23
|
const val MANUFACTURER_COMPANY_ID: Int = 0x026C
|
|
20
24
|
|
|
25
|
+
/** AD type for Manufacturer Specific Data. */
|
|
26
|
+
private const val AD_TYPE_MANUFACTURER: Int = 0xFF
|
|
27
|
+
|
|
21
28
|
const val ADV_FRAME_03_LEN = 22
|
|
22
29
|
|
|
23
30
|
private data class MeasureSpec(
|
|
@@ -62,20 +69,6 @@ object TempivoSensorBeaconDecoder {
|
|
|
62
69
|
0x22 to MeasureSpec("Current", 0.01, 1, true),
|
|
63
70
|
)
|
|
64
71
|
|
|
65
|
-
/** FW 7+ → modern (`false`); FW 6.x / 5.x → legacy (`true`). */
|
|
66
|
-
fun legacyHintFromFirmware(firmware: String?): Boolean? {
|
|
67
|
-
val t = firmware?.trim().orEmpty()
|
|
68
|
-
if (t.isEmpty()) return null
|
|
69
|
-
if (t.startsWith("FW 5", ignoreCase = true) || t.startsWith("FW5", ignoreCase = true)) return true
|
|
70
|
-
val m = Regex("^(\\d+)\\.\\d+").find(t) ?: return null
|
|
71
|
-
val major = m.groupValues[1].toIntOrNull() ?: return null
|
|
72
|
-
return when {
|
|
73
|
-
major >= 7 -> false
|
|
74
|
-
major >= 1 -> true
|
|
75
|
-
else -> null
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
72
|
/** 12-hex serial from FW6 `0x03` bytes 1–6 (same as BLE MAC on supported firmware). */
|
|
80
73
|
fun serialKeyFromAdv03(frame: ByteArray): String? {
|
|
81
74
|
if (frame.size < 7 || (frame[0].toInt() and 0xFF) != 0x03) return null
|
|
@@ -95,6 +88,87 @@ object TempivoSensorBeaconDecoder {
|
|
|
95
88
|
return keys.toList()
|
|
96
89
|
}
|
|
97
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Extract every Tempivo manufacturer payload from a raw [android.bluetooth.le.ScanRecord] byte
|
|
93
|
+
* array (AD length/type structures). Each returned blob starts with frame tag `0x03` / `0x04`
|
|
94
|
+
* (company id already stripped), matching [getManufacturerSpecificData].
|
|
95
|
+
*/
|
|
96
|
+
fun manufacturerPayloadsFromScanRecordBytes(scanRecordBytes: ByteArray?): List<ByteArray> {
|
|
97
|
+
if (scanRecordBytes == null || scanRecordBytes.isEmpty()) return emptyList()
|
|
98
|
+
val out = mutableListOf<ByteArray>()
|
|
99
|
+
var i = 0
|
|
100
|
+
val n = scanRecordBytes.size
|
|
101
|
+
while (i < n) {
|
|
102
|
+
val len = scanRecordBytes[i].toInt() and 0xFF
|
|
103
|
+
if (len == 0) break
|
|
104
|
+
if (i + 1 + len > n) break
|
|
105
|
+
val type = scanRecordBytes[i + 1].toInt() and 0xFF
|
|
106
|
+
if (type == AD_TYPE_MANUFACTURER && len >= 3) {
|
|
107
|
+
val company =
|
|
108
|
+
(scanRecordBytes[i + 2].toInt() and 0xFF) or
|
|
109
|
+
((scanRecordBytes[i + 3].toInt() and 0xFF) shl 8)
|
|
110
|
+
if (company == MANUFACTURER_COMPANY_ID && len > 3) {
|
|
111
|
+
// AD length counts type + company + payload; payload starts at i+4.
|
|
112
|
+
out += scanRecordBytes.copyOfRange(i + 4, i + 1 + len)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
i += 1 + len
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* All Tempivo manufacturer payloads from a scan record.
|
|
122
|
+
*
|
|
123
|
+
* `getBytes()` may contain only the advertising `0x03` block while
|
|
124
|
+
* [ScanRecord.getManufacturerSpecificData] holds a **concatenated** `0x03` + `0x04` blob
|
|
125
|
+
* (Android merges both when they share company id `0x026C`). Always merge every source.
|
|
126
|
+
*/
|
|
127
|
+
fun manufacturerPayloadsFromScanRecord(record: ScanRecord): List<ByteArray> {
|
|
128
|
+
val out = mutableListOf<ByteArray>()
|
|
129
|
+
fun addUnique(payloads: List<ByteArray>) {
|
|
130
|
+
for (payload in payloads) {
|
|
131
|
+
if (payload.isEmpty()) continue
|
|
132
|
+
for (part in expandConcatenatedManufacturerPayload(payload)) {
|
|
133
|
+
if (out.none { it.contentEquals(part) }) {
|
|
134
|
+
out += part
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
addUnique(manufacturerPayloadsFromScanRecordBytes(record.bytes))
|
|
141
|
+
|
|
142
|
+
val sparse = record.manufacturerSpecificData
|
|
143
|
+
if (sparse != null) {
|
|
144
|
+
for (i in 0 until sparse.size()) {
|
|
145
|
+
if (sparse.keyAt(i) == MANUFACTURER_COMPANY_ID) {
|
|
146
|
+
sparse.valueAt(i)?.let { addUnique(listOf(it)) }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
record.getManufacturerSpecificData(MANUFACTURER_COMPANY_ID)?.let { addUnique(listOf(it)) }
|
|
151
|
+
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Android often returns one manufacturer blob with adv `0x03` immediately followed by
|
|
157
|
+
* scan-response `0x04`. Raw AD parsing may only surface the first block.
|
|
158
|
+
*/
|
|
159
|
+
private fun expandConcatenatedManufacturerPayload(payload: ByteArray): List<ByteArray> {
|
|
160
|
+
if (payload.size > ADV_FRAME_03_LEN && (payload[0].toInt() and 0xFF) == 0x03) {
|
|
161
|
+
val tail = payload.copyOfRange(ADV_FRAME_03_LEN, payload.size)
|
|
162
|
+
if (tail.isNotEmpty() && (tail[0].toInt() and 0xFF) == 0x04) {
|
|
163
|
+
return listOf(
|
|
164
|
+
payload.copyOfRange(0, ADV_FRAME_03_LEN),
|
|
165
|
+
tail,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return listOf(payload)
|
|
170
|
+
}
|
|
171
|
+
|
|
98
172
|
/**
|
|
99
173
|
* @return true if any `0x03` / `0x04` frame was stored.
|
|
100
174
|
*/
|
|
@@ -115,7 +189,7 @@ object TempivoSensorBeaconDecoder {
|
|
|
115
189
|
val frame = fr.copyOf(ADV_FRAME_03_LEN)
|
|
116
190
|
val keys = lookupKeys(primaryMacKey, serialKeyFromAdv03(frame))
|
|
117
191
|
for (key in keys) {
|
|
118
|
-
if (last03[key]
|
|
192
|
+
if (!last03[key].contentEquals(frame)) {
|
|
119
193
|
last03[key] = frame
|
|
120
194
|
changed = true
|
|
121
195
|
}
|
|
@@ -129,16 +203,23 @@ object TempivoSensorBeaconDecoder {
|
|
|
129
203
|
if (existing != null && existing.isNotEmpty() && (existing[0].toInt() and 0xFF) == 0x03) {
|
|
130
204
|
continue
|
|
131
205
|
}
|
|
132
|
-
if (last03[key]
|
|
206
|
+
if (!last03[key].contentEquals(fr)) {
|
|
133
207
|
last03[key] = fr
|
|
134
208
|
changed = true
|
|
135
209
|
}
|
|
136
210
|
}
|
|
137
211
|
}
|
|
138
212
|
0x04 -> {
|
|
139
|
-
|
|
213
|
+
// Also index under any serial already learned from `0x03` for this MAC.
|
|
214
|
+
val serialFromCached03 =
|
|
215
|
+
lookupKeys(primaryMacKey)
|
|
216
|
+
.asSequence()
|
|
217
|
+
.mapNotNull { last03[it] }
|
|
218
|
+
.mapNotNull { serialKeyFromAdv03(it) }
|
|
219
|
+
.firstOrNull()
|
|
220
|
+
val keys = lookupKeys(primaryMacKey, serialFromCached03)
|
|
140
221
|
for (key in keys) {
|
|
141
|
-
if (last04[key]
|
|
222
|
+
if (!last04[key].contentEquals(fr)) {
|
|
142
223
|
last04[key] = fr
|
|
143
224
|
changed = true
|
|
144
225
|
}
|
package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.tempivo.sensor.beacon
|
|
2
2
|
|
|
3
3
|
import android.Manifest
|
|
4
|
+
import android.app.Application
|
|
4
5
|
import android.bluetooth.BluetoothManager
|
|
5
6
|
import android.bluetooth.le.ScanCallback
|
|
6
7
|
import android.bluetooth.le.ScanResult
|
|
@@ -11,12 +12,23 @@ import android.os.Build
|
|
|
11
12
|
import androidx.core.content.ContextCompat
|
|
12
13
|
import java.util.Locale
|
|
13
14
|
import java.util.concurrent.ConcurrentHashMap
|
|
15
|
+
import kotlinx.coroutines.CoroutineScope
|
|
16
|
+
import kotlinx.coroutines.Dispatchers
|
|
17
|
+
import kotlinx.coroutines.Job
|
|
18
|
+
import kotlinx.coroutines.SupervisorJob
|
|
19
|
+
import kotlinx.coroutines.cancel
|
|
20
|
+
import kotlinx.coroutines.delay
|
|
21
|
+
import kotlinx.coroutines.flow.catch
|
|
22
|
+
import kotlinx.coroutines.isActive
|
|
23
|
+
import kotlinx.coroutines.launch
|
|
24
|
+
import kotlinx.coroutines.withContext
|
|
25
|
+
import pl.efento.mobile.bluetooth.EfentoBluetooth
|
|
26
|
+
import pl.efento.mobile.bluetooth.model.Device as EfentoDevice
|
|
27
|
+
import pl.efento.mobile.bluetooth.model.Sensor
|
|
14
28
|
|
|
15
29
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* The host app must declare and grant runtime BLE permissions (`BLUETOOTH_SCAN` on API 31+,
|
|
19
|
-
* or `ACCESS_FINE_LOCATION` on older APIs).
|
|
30
|
+
* Tempivo sensor discovery — same strategy as the Tempivo Capacitor app:
|
|
31
|
+
* alternate Efento SDK `deviceFlow()` with manufacturer `0x026C` adv-only windows.
|
|
20
32
|
*/
|
|
21
33
|
class TempivoSensorBeaconScanner(
|
|
22
34
|
context: Context,
|
|
@@ -27,7 +39,6 @@ class TempivoSensorBeaconScanner(
|
|
|
27
39
|
|
|
28
40
|
class ScanNotPermittedException(message: String) : Exception(message)
|
|
29
41
|
|
|
30
|
-
private val appContext = context.applicationContext
|
|
31
42
|
data class SeenDevice(
|
|
32
43
|
val deviceId: String,
|
|
33
44
|
val bluetoothMac: String,
|
|
@@ -36,30 +47,40 @@ class TempivoSensorBeaconScanner(
|
|
|
36
47
|
val encryptionEnabled: Boolean?,
|
|
37
48
|
)
|
|
38
49
|
|
|
50
|
+
companion object {
|
|
51
|
+
private const val SDK_SCAN_WINDOW_MS = 12_000L
|
|
52
|
+
private const val ADV_SCAN_WINDOW_MS = 5_000L
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private val application = context.applicationContext as Application
|
|
39
56
|
private val advLast03 = ConcurrentHashMap<String, ByteArray>()
|
|
40
57
|
private val advLast04 = ConcurrentHashMap<String, ByteArray>()
|
|
41
58
|
private val lastSeen = ConcurrentHashMap<String, SeenDevice>()
|
|
59
|
+
private val lastSensorBySerial = ConcurrentHashMap<String, Sensor>()
|
|
42
60
|
private var listener: Listener? = null
|
|
43
61
|
private var scanning = false
|
|
62
|
+
private var advOnlyWindowActive = false
|
|
63
|
+
private val scanScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
64
|
+
private var scanJob: Job? = null
|
|
44
65
|
|
|
45
66
|
private val leScanCallback =
|
|
46
67
|
object : ScanCallback() {
|
|
47
68
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
|
48
|
-
|
|
69
|
+
handleAdvScanResult(result)
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
|
52
73
|
for (result in results) {
|
|
53
|
-
|
|
74
|
+
handleAdvScanResult(result)
|
|
54
75
|
}
|
|
55
76
|
}
|
|
56
77
|
}
|
|
57
78
|
|
|
58
|
-
/** Clears cached `0x03` / `0x04` frames. */
|
|
59
79
|
fun clearCache() {
|
|
60
80
|
advLast03.clear()
|
|
61
81
|
advLast04.clear()
|
|
62
82
|
lastSeen.clear()
|
|
83
|
+
lastSensorBySerial.clear()
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
fun lookup(serial: String): SeenDevice? {
|
|
@@ -68,62 +89,114 @@ class TempivoSensorBeaconScanner(
|
|
|
68
89
|
}
|
|
69
90
|
|
|
70
91
|
fun startScan(listener: Listener) {
|
|
71
|
-
if (scanning)
|
|
72
|
-
stopScan()
|
|
73
|
-
}
|
|
92
|
+
if (scanning) stopScan()
|
|
74
93
|
if (!hasBleScanPermission()) {
|
|
75
94
|
throw ScanNotPermittedException("BLE scan permission not granted")
|
|
76
95
|
}
|
|
77
|
-
|
|
78
|
-
?: throw IllegalStateException("Bluetooth LE scanner unavailable")
|
|
96
|
+
SensorBleRuntime.initialize(application)
|
|
79
97
|
this.listener = listener
|
|
80
|
-
val settings =
|
|
81
|
-
ScanSettings.Builder()
|
|
82
|
-
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
|
83
|
-
// Active scan: needed for scan-response frame (`0x04`) with measurements.
|
|
84
|
-
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
|
85
|
-
.build()
|
|
86
|
-
scanner.startScan(null, settings, leScanCallback)
|
|
87
98
|
scanning = true
|
|
99
|
+
advOnlyWindowActive = false
|
|
100
|
+
clearCache()
|
|
101
|
+
stopParallelAdvScan()
|
|
102
|
+
scanJob =
|
|
103
|
+
scanScope.launch {
|
|
104
|
+
val sdkScanner = EfentoBluetooth.scanner()
|
|
105
|
+
while (isActive && scanning) {
|
|
106
|
+
advOnlyWindowActive = false
|
|
107
|
+
val sdkCollect =
|
|
108
|
+
launch {
|
|
109
|
+
sdkScanner
|
|
110
|
+
.deviceFlow()
|
|
111
|
+
.catch { /* keep alternating windows alive */ }
|
|
112
|
+
.collect { device ->
|
|
113
|
+
emitSdkDevice(device)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
delay(SDK_SCAN_WINDOW_MS)
|
|
117
|
+
sdkCollect.cancel()
|
|
118
|
+
sdkCollect.join()
|
|
119
|
+
if (!isActive || !scanning) break
|
|
120
|
+
|
|
121
|
+
advOnlyWindowActive = true
|
|
122
|
+
withContext(Dispatchers.Main) { startParallelAdvScan() }
|
|
123
|
+
delay(ADV_SCAN_WINDOW_MS)
|
|
124
|
+
advOnlyWindowActive = false
|
|
125
|
+
withContext(Dispatchers.Main) { stopParallelAdvScan() }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
88
128
|
}
|
|
89
129
|
|
|
90
130
|
fun stopScan() {
|
|
91
|
-
if (!scanning) return
|
|
92
|
-
val scanner = bluetoothLeScanner() ?: return
|
|
93
|
-
if (!hasBleScanPermission()) {
|
|
94
|
-
scanning = false
|
|
95
|
-
listener = null
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
try {
|
|
99
|
-
scanner.stopScan(leScanCallback)
|
|
100
|
-
} catch (_: Exception) {
|
|
101
|
-
}
|
|
102
131
|
scanning = false
|
|
132
|
+
advOnlyWindowActive = false
|
|
133
|
+
scanJob?.cancel()
|
|
134
|
+
scanJob = null
|
|
135
|
+
stopParallelAdvScan()
|
|
103
136
|
listener = null
|
|
104
137
|
}
|
|
105
138
|
|
|
106
139
|
fun isScanning(): Boolean = scanning
|
|
107
140
|
|
|
108
|
-
private fun
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
141
|
+
private fun emitSdkDevice(device: EfentoDevice) {
|
|
142
|
+
if (device !is Sensor) return
|
|
143
|
+
val mapped = SensorSdkScanTelemetry.deviceFromSensor(device)
|
|
144
|
+
val serial = mapped.serialNumber.uppercase(Locale.US)
|
|
145
|
+
lastSensorBySerial[serial] = device
|
|
146
|
+
rememberSeen(mapped)
|
|
147
|
+
listener?.onDeviceFound(mapped)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private fun handleAdvScanResult(result: ScanResult) {
|
|
151
|
+
if (!advOnlyWindowActive) return
|
|
152
|
+
val record = result.scanRecord ?: return
|
|
112
153
|
val addr = result.device?.address ?: return
|
|
113
154
|
val key = TempivoSensorBeaconDecoder.macKey(addr)
|
|
114
|
-
val
|
|
155
|
+
val payloads = TempivoSensorBeaconDecoder.manufacturerPayloadsFromScanRecord(record)
|
|
156
|
+
if (payloads.isEmpty()) return
|
|
157
|
+
|
|
158
|
+
for (md in payloads) {
|
|
115
159
|
TempivoSensorBeaconDecoder.ingestManufacturerPayload(md, key, advLast03, advLast04)
|
|
116
|
-
|
|
160
|
+
}
|
|
161
|
+
val serialHint =
|
|
162
|
+
payloads.asSequence()
|
|
163
|
+
.mapNotNull { TempivoSensorBeaconDecoder.serialKeyFromAdv03(it) }
|
|
164
|
+
.firstOrNull()
|
|
165
|
+
val lookupKeys = TempivoSensorBeaconDecoder.lookupKeys(key, serialHint)
|
|
166
|
+
if (lookupKeys.none { advLast03.containsKey(it) || advLast04.containsKey(it) }) {
|
|
117
167
|
return
|
|
118
168
|
}
|
|
119
|
-
|
|
169
|
+
|
|
170
|
+
val advSerial =
|
|
171
|
+
lookupKeys.asSequence()
|
|
172
|
+
.mapNotNull { advLast03[it] }
|
|
173
|
+
.mapNotNull { TempivoSensorBeaconDecoder.serialKeyFromAdv03(it) }
|
|
174
|
+
.firstOrNull()
|
|
175
|
+
val serial = (advSerial ?: key).uppercase(Locale.US)
|
|
176
|
+
|
|
177
|
+
lastSensorBySerial[serial]?.let { sensor ->
|
|
178
|
+
val refreshed = SensorSdkScanTelemetry.deviceFromSensor(sensor)
|
|
179
|
+
rememberSeen(refreshed)
|
|
180
|
+
listener?.onDeviceFound(refreshed)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
val device = buildAdvDevice(result, key, lookupKeys) ?: return
|
|
185
|
+
rememberSeen(device)
|
|
120
186
|
listener?.onDeviceFound(device)
|
|
121
187
|
}
|
|
122
188
|
|
|
123
|
-
private fun
|
|
189
|
+
private fun buildAdvDevice(
|
|
190
|
+
result: ScanResult,
|
|
191
|
+
key: String,
|
|
192
|
+
lookupKeys: List<String>,
|
|
193
|
+
): TempivoSensorBeaconDevice? {
|
|
124
194
|
val addr = result.device?.address ?: return null
|
|
125
|
-
val
|
|
126
|
-
|
|
195
|
+
val advSerial =
|
|
196
|
+
lookupKeys.asSequence()
|
|
197
|
+
.mapNotNull { advLast03[it] }
|
|
198
|
+
.mapNotNull { TempivoSensorBeaconDecoder.serialKeyFromAdv03(it) }
|
|
199
|
+
.firstOrNull()
|
|
127
200
|
val serial = (advSerial ?: key).uppercase(Locale.US)
|
|
128
201
|
val macColons = macWithColonsFromKey(if (key.length == 12) key else serial)
|
|
129
202
|
val telemetry =
|
|
@@ -132,43 +205,70 @@ class TempivoSensorBeaconScanner(
|
|
|
132
205
|
advLast03,
|
|
133
206
|
advLast04,
|
|
134
207
|
)
|
|
135
|
-
val summary =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
208
|
+
val summary = telemetry?.summary?.takeIf { it.isNotEmpty() } ?: serial
|
|
209
|
+
return TempivoSensorBeaconDevice(
|
|
210
|
+
deviceId = addr,
|
|
211
|
+
bluetoothMacAddress = macColons,
|
|
212
|
+
serialNumber = serial,
|
|
213
|
+
rssi = result.rssi,
|
|
214
|
+
telemetry = telemetry,
|
|
215
|
+
summary = summary,
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private fun rememberSeen(device: TempivoSensorBeaconDevice) {
|
|
147
220
|
val seen =
|
|
148
221
|
SeenDevice(
|
|
149
|
-
deviceId =
|
|
150
|
-
bluetoothMac =
|
|
151
|
-
serial =
|
|
152
|
-
firmware = telemetry?.firmware,
|
|
153
|
-
encryptionEnabled = telemetry?.encryptionEnabled,
|
|
222
|
+
deviceId = device.deviceId,
|
|
223
|
+
bluetoothMac = device.bluetoothMacAddress,
|
|
224
|
+
serial = device.serialNumber,
|
|
225
|
+
firmware = device.telemetry?.firmware,
|
|
226
|
+
encryptionEnabled = device.telemetry?.encryptionEnabled,
|
|
154
227
|
)
|
|
155
|
-
lastSeen[
|
|
156
|
-
lastSeen[
|
|
157
|
-
|
|
228
|
+
lastSeen[device.serialNumber.uppercase(Locale.US)] = seen
|
|
229
|
+
lastSeen[TempivoSensorBeaconDecoder.macKey(device.serialNumber)] = seen
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private fun startParallelAdvScan() {
|
|
233
|
+
if (!hasBleScanPermission()) return
|
|
234
|
+
val scanner = bluetoothLeScanner() ?: return
|
|
235
|
+
val settings =
|
|
236
|
+
ScanSettings.Builder()
|
|
237
|
+
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
|
238
|
+
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
|
239
|
+
.build()
|
|
240
|
+
try {
|
|
241
|
+
scanner.startScan(null, settings, leScanCallback)
|
|
242
|
+
} catch (_: Exception) {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private fun stopParallelAdvScan() {
|
|
247
|
+
if (!hasBleScanPermission()) return
|
|
248
|
+
val scanner = bluetoothLeScanner() ?: return
|
|
249
|
+
try {
|
|
250
|
+
scanner.stopScan(leScanCallback)
|
|
251
|
+
} catch (_: Exception) {
|
|
252
|
+
}
|
|
158
253
|
}
|
|
159
254
|
|
|
160
255
|
private fun bluetoothLeScanner() =
|
|
161
|
-
(
|
|
256
|
+
(application.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
|
|
162
257
|
?.adapter
|
|
163
258
|
?.bluetoothLeScanner
|
|
164
259
|
|
|
165
260
|
private fun hasBleScanPermission(): Boolean {
|
|
166
261
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
167
|
-
|
|
168
|
-
|
|
262
|
+
val scanGranted =
|
|
263
|
+
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_SCAN) ==
|
|
264
|
+
PackageManager.PERMISSION_GRANTED
|
|
265
|
+
val locationGranted =
|
|
266
|
+
ContextCompat.checkSelfPermission(application, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
267
|
+
PackageManager.PERMISSION_GRANTED
|
|
268
|
+
return scanGranted && locationGranted
|
|
169
269
|
}
|
|
170
270
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
171
|
-
return ContextCompat.checkSelfPermission(
|
|
271
|
+
return ContextCompat.checkSelfPermission(application, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
172
272
|
PackageManager.PERMISSION_GRANTED
|
|
173
273
|
}
|
|
174
274
|
return true
|
|
@@ -1,27 +1,10 @@
|
|
|
1
1
|
package com.tempivo.sensor.beacon
|
|
2
2
|
|
|
3
|
-
internal fun clientSafeDetail(raw: String?): String {
|
|
4
|
-
if (raw.isNullOrBlank()) return ""
|
|
5
|
-
return raw
|
|
6
|
-
.replace(VENDOR_PKG.toRegex(), "")
|
|
7
|
-
.replace(VENDOR_WORD.toRegex(), "")
|
|
8
|
-
.replace(Regex("\\s+"), " ")
|
|
9
|
-
.trim(' ', ':', '-', '.', '\n', '\t', '/')
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
private const val VENDOR_PKG = """(?i)pl\.[A-Za-z][A-Za-z0-9_.$]*"""
|
|
13
|
-
private const val VENDOR_WORD = """(?i)\befento[A-Za-z0-9_$]*"""
|
|
14
|
-
|
|
15
3
|
class TempivoSensorException(
|
|
16
4
|
val code: Code,
|
|
17
5
|
message: String,
|
|
18
6
|
cause: Throwable? = null,
|
|
19
7
|
) : Exception(message, cause) {
|
|
20
|
-
companion object {
|
|
21
|
-
@JvmStatic
|
|
22
|
-
fun sanitizeClientMessage(raw: String?): String = clientSafeDetail(raw)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
8
|
enum class Code {
|
|
26
9
|
INVALID_PIN,
|
|
27
10
|
INVALID_QR,
|
|
@@ -26,10 +26,16 @@ data class TempivoSchedule(
|
|
|
26
26
|
data class TempivoSensorConfiguration(
|
|
27
27
|
val temperatureAlerts: List<TempivoTemperatureAlert>,
|
|
28
28
|
val schedule: TempivoSchedule,
|
|
29
|
+
val measurementIntervalMinutes: Double? = null,
|
|
30
|
+
val transmissionIntervalSeconds: Int? = null,
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
object TempivoSensorProfile {
|
|
32
34
|
private val timeRe = Regex("^([01]\\d|2[0-3]):([0-5]\\d)$")
|
|
35
|
+
private const val MIN_MEASUREMENT_MINUTES = 60.0
|
|
36
|
+
private const val MAX_MEASUREMENT_MINUTES = 600.0
|
|
37
|
+
private const val MIN_TRANSMISSION_SECONDS = 3600
|
|
38
|
+
private const val MAX_TRANSMISSION_SECONDS = 604_800
|
|
33
39
|
|
|
34
40
|
fun parseJson(json: String): TempivoSensorConfiguration {
|
|
35
41
|
val root =
|
|
@@ -47,7 +53,11 @@ object TempivoSensorProfile {
|
|
|
47
53
|
}
|
|
48
54
|
val body =
|
|
49
55
|
root.optJSONObject("profile")?.takeIf {
|
|
50
|
-
it.has("temperatureAlerts") ||
|
|
56
|
+
it.has("temperatureAlerts") ||
|
|
57
|
+
it.has("schedule") ||
|
|
58
|
+
it.has("slug") ||
|
|
59
|
+
it.has("measurementIntervalMinutes") ||
|
|
60
|
+
it.has("transmissionIntervalSeconds")
|
|
51
61
|
} ?: root
|
|
52
62
|
if (!body.has("temperatureAlerts") && body.has("alarmRules")) {
|
|
53
63
|
throw invalid("Use a config profile with temperatureAlerts (GET /devices/config-profiles/{slug}), not alarmRules.")
|
|
@@ -58,7 +68,13 @@ object TempivoSensorProfile {
|
|
|
58
68
|
val item = arr.optJSONObject(i) ?: throw invalid("temperatureAlerts[$i] must be an object.")
|
|
59
69
|
alerts.add(parseAlert(item, i))
|
|
60
70
|
}
|
|
61
|
-
|
|
71
|
+
val intervals = parseOptionalIntervals(body)
|
|
72
|
+
return TempivoSensorConfiguration(
|
|
73
|
+
alerts,
|
|
74
|
+
parseSchedule(body.optJSONObject("schedule")),
|
|
75
|
+
measurementIntervalMinutes = intervals.first,
|
|
76
|
+
transmissionIntervalSeconds = intervals.second,
|
|
77
|
+
)
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
fun toJson(cfg: TempivoSensorConfiguration): String {
|
|
@@ -90,9 +106,50 @@ object TempivoSensorProfile {
|
|
|
90
106
|
s.put("utcOffsetMinutes", cfg.schedule.utcOffsetMinutes)
|
|
91
107
|
}
|
|
92
108
|
root.put("schedule", s)
|
|
109
|
+
cfg.measurementIntervalMinutes?.let { root.put("measurementIntervalMinutes", it) }
|
|
110
|
+
cfg.transmissionIntervalSeconds?.let { root.put("transmissionIntervalSeconds", it) }
|
|
93
111
|
return root.toString()
|
|
94
112
|
}
|
|
95
113
|
|
|
114
|
+
private fun parseOptionalIntervals(body: JSONObject): Pair<Double?, Int?> {
|
|
115
|
+
var measurement: Double? = null
|
|
116
|
+
var transmission: Int? = null
|
|
117
|
+
if (body.has("measurementIntervalMinutes") && !body.isNull("measurementIntervalMinutes")) {
|
|
118
|
+
val n = body.optDouble("measurementIntervalMinutes")
|
|
119
|
+
if (!n.isFinite() || n <= 0) {
|
|
120
|
+
throw invalid("measurementIntervalMinutes must be a positive number.")
|
|
121
|
+
}
|
|
122
|
+
if (n < MIN_MEASUREMENT_MINUTES) {
|
|
123
|
+
throw invalid("measurementIntervalMinutes must be at least $MIN_MEASUREMENT_MINUTES (1 hour).")
|
|
124
|
+
}
|
|
125
|
+
if (n > MAX_MEASUREMENT_MINUTES) {
|
|
126
|
+
throw invalid("measurementIntervalMinutes must be at most $MAX_MEASUREMENT_MINUTES.")
|
|
127
|
+
}
|
|
128
|
+
measurement = n
|
|
129
|
+
}
|
|
130
|
+
if (body.has("transmissionIntervalSeconds") && !body.isNull("transmissionIntervalSeconds")) {
|
|
131
|
+
val asDouble = body.optDouble("transmissionIntervalSeconds")
|
|
132
|
+
if (!asDouble.isFinite() || asDouble <= 0 || asDouble != kotlin.math.floor(asDouble)) {
|
|
133
|
+
throw invalid("transmissionIntervalSeconds must be a positive integer.")
|
|
134
|
+
}
|
|
135
|
+
val n = asDouble.toInt()
|
|
136
|
+
if (n < MIN_TRANSMISSION_SECONDS) {
|
|
137
|
+
throw invalid("transmissionIntervalSeconds must be at least $MIN_TRANSMISSION_SECONDS (1 hour).")
|
|
138
|
+
}
|
|
139
|
+
if (n > MAX_TRANSMISSION_SECONDS) {
|
|
140
|
+
throw invalid("transmissionIntervalSeconds must be at most $MAX_TRANSMISSION_SECONDS.")
|
|
141
|
+
}
|
|
142
|
+
transmission = n
|
|
143
|
+
}
|
|
144
|
+
if (measurement != null && transmission != null) {
|
|
145
|
+
val maxTx = measurement * 60.0 * 60.0
|
|
146
|
+
if (transmission > maxTx) {
|
|
147
|
+
throw invalid("transmissionIntervalSeconds must be at most 60 times the measurement interval.")
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return measurement to transmission
|
|
151
|
+
}
|
|
152
|
+
|
|
96
153
|
private fun parseAlert(item: JSONObject, index: Int): TempivoTemperatureAlert {
|
|
97
154
|
val channel = item.optString("channel")
|
|
98
155
|
if (channel != "ambient" && channel != "probe") {
|
|
@@ -7,7 +7,6 @@ data class TempivoSensorQr(
|
|
|
7
7
|
val serial: String,
|
|
8
8
|
val pin: String,
|
|
9
9
|
val model: String?,
|
|
10
|
-
val sessionType: TempivoSensorSession.SessionType,
|
|
11
10
|
val bluetoothMac: String,
|
|
12
11
|
)
|
|
13
12
|
|
|
@@ -30,22 +29,13 @@ object TempivoSensorQrParser {
|
|
|
30
29
|
}
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
fun
|
|
34
|
-
if (model.isNullOrBlank()) return TempivoSensorSession.SessionType.MODERN
|
|
32
|
+
fun assertSupportedModel(model: String) {
|
|
35
33
|
val m = model.trim().uppercase(Locale.US).replace("-", "")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
fun resolveSessionType(model: String?, firmware: String?): TempivoSensorSession.SessionType {
|
|
44
|
-
val hint = TempivoSensorBeaconDecoder.legacyHintFromFirmware(firmware)
|
|
45
|
-
return when (hint) {
|
|
46
|
-
true -> TempivoSensorSession.SessionType.LEGACY
|
|
47
|
-
false -> TempivoSensorSession.SessionType.MODERN
|
|
48
|
-
null -> sessionTypeFromModel(model)
|
|
34
|
+
if (m == "HC5") {
|
|
35
|
+
throw TempivoSensorException(
|
|
36
|
+
TempivoSensorException.Code.INVALID_QR,
|
|
37
|
+
"HC5 is not supported by the partner SDK. Use HC7 sensors.",
|
|
38
|
+
)
|
|
49
39
|
}
|
|
50
40
|
}
|
|
51
41
|
|
|
@@ -70,12 +60,12 @@ object TempivoSensorQrParser {
|
|
|
70
60
|
throw TempivoSensorException(TempivoSensorException.Code.INVALID_QR, "QR is missing PIN.")
|
|
71
61
|
}
|
|
72
62
|
val model = rec.optString("model").trim().ifEmpty { DEFAULT_MODEL }
|
|
63
|
+
assertSupportedModel(model)
|
|
73
64
|
val hex = if (serial.length == 12) serial else serial.takeLast(12)
|
|
74
65
|
return TempivoSensorQr(
|
|
75
66
|
serial = serial,
|
|
76
67
|
pin = pin,
|
|
77
68
|
model = model,
|
|
78
|
-
sessionType = sessionTypeFromModel(model),
|
|
79
69
|
bluetoothMac = bluetoothMacFromSerial(hex),
|
|
80
70
|
)
|
|
81
71
|
}
|