@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.
Files changed (45) hide show
  1. package/README.md +22 -11
  2. package/android/build.gradle +13 -5
  3. package/android/src/main/AndroidManifest.xml +2 -1
  4. package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +6 -16
  5. package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +13 -6
  6. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +17 -6
  7. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +34 -124
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorSdkScanTelemetry.kt +138 -0
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +99 -18
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +165 -65
  11. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +0 -17
  12. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +59 -2
  13. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +7 -17
  14. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +9 -38
  15. package/dist/decoder.d.ts +0 -2
  16. package/dist/decoder.js +0 -17
  17. package/dist/index.d.ts +3 -3
  18. package/dist/index.js +3 -3
  19. package/dist/native-helpers.d.ts +0 -1
  20. package/dist/native-helpers.js +3 -18
  21. package/dist/native-module.js +5 -2
  22. package/dist/normalize.d.ts +6 -0
  23. package/dist/normalize.js +29 -0
  24. package/dist/profile.d.ts +5 -0
  25. package/dist/profile.js +47 -1
  26. package/dist/qr.d.ts +4 -7
  27. package/dist/qr.js +9 -24
  28. package/dist/react-native.d.ts +24 -1
  29. package/dist/react-native.js +22 -1
  30. package/dist/session-types.d.ts +11 -4
  31. package/dist/tempivo-sensor-beacon.aar +0 -0
  32. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +1 -3
  33. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  34. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +1 -3
  35. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  36. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +12 -2
  37. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconScanner.swift +4 -80
  38. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +0 -15
  39. package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +1 -15
  40. package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +57 -3
  41. package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +9 -23
  42. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +10 -36
  43. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +10 -2
  44. package/ios/TempivoSensorBeaconModule.swift +139 -25
  45. package/package.json +6 -1
@@ -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] != frame) {
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] != fr) {
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
- val keys = lookupKeys(primaryMacKey)
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] != fr) {
222
+ if (!last04[key].contentEquals(fr)) {
142
223
  last04[key] = fr
143
224
  changed = true
144
225
  }
@@ -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
- * Active BLE scan for Tempivo sensors (manufacturer `0x026C`). Advertisement decode only; no GATT.
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
- handleScanResult(result)
69
+ handleAdvScanResult(result)
49
70
  }
50
71
 
51
72
  override fun onBatchScanResults(results: MutableList<ScanResult>) {
52
73
  for (result in results) {
53
- handleScanResult(result)
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
- val scanner = bluetoothLeScanner()
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 handleScanResult(result: ScanResult) {
109
- val md =
110
- result.scanRecord?.getManufacturerSpecificData(TempivoSensorBeaconDecoder.MANUFACTURER_COMPANY_ID)
111
- ?: return
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 changed =
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
- if (!changed && !advLast03.containsKey(key) && !advLast04.containsKey(key)) {
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
- val device = buildDevice(result, key) ?: return
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 buildDevice(result: ScanResult, key: String): TempivoSensorBeaconDevice? {
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 frame = advLast03[key]
126
- val advSerial = frame?.let { TempivoSensorBeaconDecoder.serialKeyFromAdv03(it) }
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
- telemetry?.summary?.takeIf { it.isNotEmpty() }
137
- ?: serial
138
- val device =
139
- TempivoSensorBeaconDevice(
140
- deviceId = addr,
141
- bluetoothMacAddress = macColons,
142
- serialNumber = serial,
143
- rssi = result.rssi,
144
- telemetry = telemetry,
145
- summary = summary,
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 = addr,
150
- bluetoothMac = macColons,
151
- serial = 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[serial] = seen
156
- lastSeen[key.uppercase(Locale.US)] = seen
157
- return device
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
- (appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
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
- return ContextCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_SCAN) ==
168
- PackageManager.PERMISSION_GRANTED
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(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
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") || it.has("schedule") || it.has("slug")
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
- return TempivoSensorConfiguration(alerts, parseSchedule(body.optJSONObject("schedule")))
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 sessionTypeFromModel(model: String?): TempivoSensorSession.SessionType {
34
- if (model.isNullOrBlank()) return TempivoSensorSession.SessionType.MODERN
32
+ fun assertSupportedModel(model: String) {
35
33
  val m = model.trim().uppercase(Locale.US).replace("-", "")
36
- return if (m == "HC5" || m.startsWith("6")) {
37
- TempivoSensorSession.SessionType.LEGACY
38
- } else {
39
- TempivoSensorSession.SessionType.MODERN
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
  }