@tempivo/sensor-beacon 0.1.0 → 0.1.1

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.
@@ -0,0 +1,406 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ import java.text.SimpleDateFormat
4
+ import java.util.Date
5
+ import java.util.Locale
6
+ import java.util.TimeZone
7
+ import kotlin.math.abs
8
+ import kotlin.math.floor
9
+
10
+ /**
11
+ * Decodes Tempivo sensor BLE manufacturer data (adv frame `0x03` + scan response `0x04`).
12
+ *
13
+ * Android [android.bluetooth.le.ScanRecord.getManufacturerSpecificData] returns the payload **after**
14
+ * the 2-byte company id — i.e. starts with `0x03` / `0x04`, not `6C 02`.
15
+ */
16
+ object TempivoSensorBeaconDecoder {
17
+
18
+ /** Bluetooth SIG company identifier (little-endian on air: `6C 02`). */
19
+ const val MANUFACTURER_COMPANY_ID: Int = 0x026C
20
+
21
+ const val ADV_FRAME_03_LEN = 22
22
+
23
+ private data class MeasureSpec(
24
+ val name: String,
25
+ val resolution: Double,
26
+ val metaFactor: Int,
27
+ val continuous: Boolean,
28
+ )
29
+
30
+ private val measureTypes: Map<Int, MeasureSpec> =
31
+ mapOf(
32
+ 0x01 to MeasureSpec("Temperature", 0.1, 1, true),
33
+ 0x02 to MeasureSpec("Humidity", 1.0, 1, true),
34
+ 0x03 to MeasureSpec("Atmospheric pressure", 0.1, 1, true),
35
+ 0x04 to MeasureSpec("Differential pressure", 1.0, 1, true),
36
+ 0x05 to MeasureSpec("OK/Alarm", 1.0, 1, false),
37
+ 0x06 to MeasureSpec("IAQ", 1.0, 3, true),
38
+ 0x07 to MeasureSpec("Flooding", 1.0, 1, false),
39
+ 0x08 to MeasureSpec("Pulse count", 1.0, 1, true),
40
+ 0x09 to MeasureSpec("Electricity meter", 1.0, 1, true),
41
+ 0x0A to MeasureSpec("Water meter", 1.0, 1, true),
42
+ 0x0B to MeasureSpec("Soil moisture", 1.0, 1, true),
43
+ 0x0C to MeasureSpec("CO", 1.0, 1, true),
44
+ 0x0D to MeasureSpec("NO₂", 1.0, 1, true),
45
+ 0x0E to MeasureSpec("H₂S", 0.01, 1, true),
46
+ 0x0F to MeasureSpec("Ambient light", 0.1, 1, true),
47
+ 0x10 to MeasureSpec("PM1.0", 1.0, 1, true),
48
+ 0x11 to MeasureSpec("PM2.5", 1.0, 1, true),
49
+ 0x12 to MeasureSpec("PM10", 1.0, 1, true),
50
+ 0x13 to MeasureSpec("Noise", 0.1, 1, true),
51
+ 0x14 to MeasureSpec("NH₃", 1.0, 1, true),
52
+ 0x15 to MeasureSpec("CH₄", 1.0, 1, true),
53
+ 0x16 to MeasureSpec("High pressure", 1.0, 1, true),
54
+ 0x17 to MeasureSpec("Distance", 1.0, 1, true),
55
+ 0x1A to MeasureSpec("CO₂", 1.0, 3, true),
56
+ 0x1B to MeasureSpec("Humidity", 0.1, 1, true),
57
+ 0x1C to MeasureSpec("Static IAQ", 1.0, 3, true),
58
+ 0x1D to MeasureSpec("CO₂ equivalent", 1.0, 3, true),
59
+ 0x1E to MeasureSpec("Breath VOC", 1.0, 3, true),
60
+ 0x20 to MeasureSpec("Percentage", 0.01, 1, true),
61
+ 0x21 to MeasureSpec("Voltage", 0.1, 1, true),
62
+ 0x22 to MeasureSpec("Current", 0.01, 1, true),
63
+ )
64
+
65
+ /** 12-hex serial from FW6 `0x03` bytes 1–6 (same as BLE MAC on supported firmware). */
66
+ fun serialKeyFromAdv03(frame: ByteArray): String? {
67
+ if (frame.size < 7 || (frame[0].toInt() and 0xFF) != 0x03) return null
68
+ return buildString(12) {
69
+ for (i in 1..6) {
70
+ append(String.format(Locale.US, "%02X", frame[i].toInt() and 0xFF))
71
+ }
72
+ }
73
+ }
74
+
75
+ fun macKey(macWithColons: String): String =
76
+ macWithColons.replace(":", "", ignoreCase = true).uppercase(Locale.US)
77
+
78
+ fun lookupKeys(primaryMacKey: String, extraSerialKey: String? = null): List<String> {
79
+ val keys = linkedSetOf(primaryMacKey.uppercase(Locale.US))
80
+ if (!extraSerialKey.isNullOrBlank()) keys.add(extraSerialKey.uppercase(Locale.US))
81
+ return keys.toList()
82
+ }
83
+
84
+ /**
85
+ * @return true if any `0x03` / `0x04` frame was stored.
86
+ */
87
+ fun ingestManufacturerPayload(
88
+ data: ByteArray,
89
+ primaryMacKey: String,
90
+ last03: MutableMap<String, ByteArray>,
91
+ last04: MutableMap<String, ByteArray>,
92
+ ): Boolean {
93
+ var changed = false
94
+ val frames = splitFrames(data)
95
+ for (fr in frames) {
96
+ if (fr.isEmpty()) continue
97
+ val tag = fr[0].toInt() and 0xFF
98
+ when (tag) {
99
+ 0x03 ->
100
+ if (fr.size >= ADV_FRAME_03_LEN) {
101
+ val frame = fr.copyOf(ADV_FRAME_03_LEN)
102
+ val keys = lookupKeys(primaryMacKey, serialKeyFromAdv03(frame))
103
+ for (key in keys) {
104
+ if (last03[key] != frame) {
105
+ last03[key] = frame
106
+ changed = true
107
+ }
108
+ }
109
+ }
110
+ 0x02 ->
111
+ if (fr.size >= 3) {
112
+ val keys = lookupKeys(primaryMacKey)
113
+ for (key in keys) {
114
+ val existing = last03[key]
115
+ if (existing != null && existing.isNotEmpty() && (existing[0].toInt() and 0xFF) == 0x03) {
116
+ continue
117
+ }
118
+ if (last03[key] != fr) {
119
+ last03[key] = fr
120
+ changed = true
121
+ }
122
+ }
123
+ }
124
+ 0x04 -> {
125
+ val keys = lookupKeys(primaryMacKey)
126
+ for (key in keys) {
127
+ if (last04[key] != fr) {
128
+ last04[key] = fr
129
+ changed = true
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ return changed
136
+ }
137
+
138
+ fun buildTelemetry(
139
+ lookupKeys: Collection<String>,
140
+ last03: Map<String, ByteArray>,
141
+ last04: Map<String, ByteArray>,
142
+ ): TempivoSensorBeaconTelemetry? {
143
+ val keys = lookupKeys.map { it.uppercase(Locale.US) }.distinct()
144
+ val d03 = keys.firstNotNullOfOrNull { last03[it] }
145
+ val d04 = keys.firstNotNullOfOrNull { last04[it] }
146
+ return buildTelemetry(d03, d04)
147
+ }
148
+
149
+ fun buildTelemetry(adv03: ByteArray?, adv04: ByteArray?): TempivoSensorBeaconTelemetry? {
150
+ val summaryParts = mutableListOf<String>()
151
+ var firmware: String? = null
152
+ var batteryOk: Boolean? = null
153
+ var encryptionEnabled: Boolean? = null
154
+ var cellularStatus: String? = null
155
+ var measurementCounter: Long? = null
156
+ var readingTimestampUnix: Long? = null
157
+ var readingTimestampIso: String? = null
158
+ var periodBaseSeconds: Int? = null
159
+ var periodFactor: Int? = null
160
+ val readings = mutableListOf<TempivoSensorBeaconReading>()
161
+
162
+ if (adv03 != null && adv03.isNotEmpty()) {
163
+ when (adv03[0].toInt() and 0xFF) {
164
+ 0x03 -> {
165
+ val parsed = parseFw6Adv03(adv03)
166
+ firmware = parsed.firmware
167
+ batteryOk = parsed.batteryOk
168
+ encryptionEnabled = parsed.encryptionEnabled
169
+ cellularStatus = parsed.cellularStatus
170
+ measurementCounter = parsed.measurementCounter
171
+ readingTimestampUnix = parsed.readingTimestampUnix
172
+ readingTimestampIso = parsed.readingTimestampIso
173
+ periodBaseSeconds = parsed.periodBaseSeconds
174
+ periodFactor = parsed.periodFactor
175
+ }
176
+ 0x02 -> {
177
+ val fw5 = fw5Summary(adv03)
178
+ if (fw5 != null) {
179
+ firmware = fw5
180
+ summaryParts.add(fw5)
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ if (adv04 != null && adv04.size >= 5 && (adv04[0].toInt() and 0xFF) == 0x04) {
187
+ var i = 1
188
+ while (i + 4 <= adv04.size - 2) {
189
+ val mtype = adv04[i].toInt() and 0xFF
190
+ if (mtype == 0) break
191
+ val raw24 =
192
+ ((adv04[i + 1].toInt() and 0xFF) shl 16) or
193
+ ((adv04[i + 2].toInt() and 0xFF) shl 8) or
194
+ (adv04[i + 3].toInt() and 0xFF)
195
+ val text = decodeSlot(mtype, raw24)
196
+ summaryParts.add(text)
197
+ readings +=
198
+ TempivoSensorBeaconReading(
199
+ typeHex = String.format(Locale.US, "0x%02X", mtype),
200
+ raw24 = raw24 and 0xFFFFFF,
201
+ text = text,
202
+ )
203
+ i += 4
204
+ }
205
+ }
206
+
207
+ val readingsCount = summaryParts.count { !it.contains("FW 5.x") }
208
+
209
+ val summary: String =
210
+ if (summaryParts.isEmpty()) {
211
+ if (adv03 != null && (adv03[0].toInt() and 0xFF) == 0x03) {
212
+ readingTimestampIso?.takeIf { it.isNotEmpty() }
213
+ ?: "Adv OK. Wait for scan response (measurements)."
214
+ } else {
215
+ ""
216
+ }
217
+ } else {
218
+ summaryParts.take(4).joinToString(" · ")
219
+ }
220
+
221
+ if (summary.isEmpty() && adv03 == null && adv04 == null) return null
222
+
223
+ val sumOut =
224
+ summary.ifEmpty {
225
+ readingTimestampIso.orEmpty()
226
+ }
227
+
228
+ return TempivoSensorBeaconTelemetry(
229
+ firmware = firmware,
230
+ batteryOk = batteryOk,
231
+ encryptionEnabled = encryptionEnabled,
232
+ cellularStatus = cellularStatus,
233
+ measurementCounter = measurementCounter,
234
+ readingTimestampUnix = readingTimestampUnix,
235
+ readingTimestampIso = readingTimestampIso,
236
+ periodBaseSeconds = periodBaseSeconds,
237
+ periodFactor = periodFactor,
238
+ readings = readings,
239
+ readingsCount = readingsCount,
240
+ summary = sumOut.ifEmpty { null },
241
+ )
242
+ }
243
+
244
+ /** Firmware 7+ → modern (`false`); older families → legacy (`true`). */
245
+ fun legacyHintFromFirmware(firmware: String?): Boolean? {
246
+ if (firmware.isNullOrBlank()) return null
247
+ val t = firmware.trim()
248
+ if (t.startsWith("FW 5", ignoreCase = true)) return true
249
+ val m = Regex("""^(\d+)\.\d+""").find(t) ?: return null
250
+ val major = m.groupValues[1].toIntOrNull() ?: return null
251
+ if (major >= 7) return false
252
+ if (major >= 1) return true
253
+ return null
254
+ }
255
+
256
+ private data class Adv03Parsed(
257
+ val firmware: String?,
258
+ val batteryOk: Boolean?,
259
+ val encryptionEnabled: Boolean?,
260
+ val cellularStatus: String?,
261
+ val measurementCounter: Long?,
262
+ val readingTimestampUnix: Long?,
263
+ val readingTimestampIso: String?,
264
+ val periodBaseSeconds: Int?,
265
+ val periodFactor: Int?,
266
+ )
267
+
268
+ private fun fw5Summary(frame: ByteArray): String? {
269
+ if (frame.isEmpty() || (frame[0].toInt() and 0xFF) != 0x02) return null
270
+ val maj = frame.getOrNull(1)?.toInt()?.and(0xFF) ?: return null
271
+ val min = frame.getOrNull(2)?.toInt()?.and(0xFF) ?: return null
272
+ return "FW 5.x $maj.$min (limited broadcast decode)"
273
+ }
274
+
275
+ private fun splitFrames(payload: ByteArray): List<ByteArray> {
276
+ val out = mutableListOf<ByteArray>()
277
+ var i = 0
278
+ val n = payload.size
279
+ while (i < n) {
280
+ val b = payload[i].toInt() and 0xFF
281
+ if (b == 0x03 && i + ADV_FRAME_03_LEN <= n) {
282
+ out.add(payload.copyOfRange(i, i + ADV_FRAME_03_LEN))
283
+ i += ADV_FRAME_03_LEN
284
+ } else if (b == 0x04) {
285
+ var j = i + 1
286
+ while (j + 4 <= n) {
287
+ if (j + 4 > n - 2) break
288
+ val mtype = payload[j].toInt() and 0xFF
289
+ if (mtype == 0 || mtype > 0x26) break
290
+ j += 4
291
+ }
292
+ if (n - j >= 2) {
293
+ out.add(payload.copyOfRange(i, j + 2))
294
+ i = j + 2
295
+ } else {
296
+ out.add(payload.copyOfRange(i, n))
297
+ break
298
+ }
299
+ } else if (b == 0x02 && i + 3 <= n) {
300
+ val end = minOf(n, i + 26)
301
+ out.add(payload.copyOfRange(i, end))
302
+ i = end
303
+ } else {
304
+ i++
305
+ }
306
+ }
307
+ return out
308
+ }
309
+
310
+ private fun zigzagDecode24(raw24: Int): Int {
311
+ val n = raw24 and 0xFFFFFF
312
+ return (n shr 1) xor (-(n and 1))
313
+ }
314
+
315
+ private fun decodeSlot(mtype: Int, raw24: Int): String {
316
+ val spec = measureTypes[mtype]
317
+ if (spec == null) {
318
+ return String.format(Locale.US, "0x%02X: 0x%06X", mtype, raw24 and 0xFFFFFF)
319
+ }
320
+ if (!spec.continuous) {
321
+ return "(status)"
322
+ }
323
+ val z = zigzagDecode24(raw24)
324
+ if (spec.metaFactor == 1) {
325
+ val v = z * spec.resolution
326
+ val unit =
327
+ when (mtype) {
328
+ 0x01 -> "°C"
329
+ 0x02, 0x1B -> "%"
330
+ 0x03 -> "hPa"
331
+ 0x1A -> "ppm"
332
+ 0x21 -> "mV"
333
+ 0x22 -> "mA"
334
+ else -> ""
335
+ }
336
+ val num =
337
+ if (abs(v - floor(v)) < 1e-6) {
338
+ String.format(Locale.US, "%.0f", v)
339
+ } else {
340
+ when (mtype) {
341
+ 0x01, 0x03, 0x0F, 0x13 -> String.format(Locale.US, "%.1f", v)
342
+ 0x02, 0x1B -> String.format(Locale.US, "%.0f", v)
343
+ else -> String.format(Locale.US, "%g", v)
344
+ }
345
+ }
346
+ return if (unit.isEmpty()) num else "$num $unit"
347
+ }
348
+ if (mtype == 0x06) {
349
+ val iaq = z % (1 shl 9)
350
+ val cal = (z shr 9) and 0x03
351
+ val calS = arrayOf("not_stab", "cal_req", "cal_ongoing", "cal_done")[cal.coerceAtMost(3)]
352
+ return "$iaq ($calS)"
353
+ }
354
+ if (mtype == 0x1A || mtype == 0x1C || mtype == 0x1D || mtype == 0x1E) {
355
+ val v = (z / spec.metaFactor) * spec.resolution
356
+ return String.format(Locale.US, "%g", v)
357
+ }
358
+ return z.toString()
359
+ }
360
+
361
+ private fun parseFw6Adv03(adv03: ByteArray): Adv03Parsed {
362
+ if (adv03.size < ADV_FRAME_03_LEN) {
363
+ return Adv03Parsed(null, null, null, null, null, null, null, null, null)
364
+ }
365
+ val fwRaw = ((adv03[7].toInt() and 0xFF) shl 8) or (adv03[8].toInt() and 0xFF)
366
+ val major = (fwRaw shr 11) and 0x1F
367
+ val minor = (fwRaw shr 5) and 0x3F
368
+ val lts = fwRaw and 0x1F
369
+ val firmware = "$major.$minor.$lts"
370
+
371
+ val st = adv03[9].toInt() and 0xFF
372
+ val batteryOk = (st and 1) != 0
373
+ val encryptionEnabled = ((st shr 3) and 1) != 0
374
+ val cell = (st shr 6) and 3
375
+ val cellLabels = arrayOf("ble_only", "cell_ok", "no_server", "net_issue")
376
+ val cellularStatus = cellLabels[cell]
377
+
378
+ val ts =
379
+ ((adv03[10].toInt() and 0xFF) shl 24) or
380
+ ((adv03[11].toInt() and 0xFF) shl 16) or
381
+ ((adv03[12].toInt() and 0xFF) shl 8) or
382
+ (adv03[13].toInt() and 0xFF)
383
+ val measurementCounter = ts.toLong() and 0xFFFFFFFFL
384
+ val readingTimestampUnix = ts.toLong()
385
+ var readingTimestampIso: String? = null
386
+ if (ts > 1_000_000_000L && ts < 4_000_000_000L) {
387
+ val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
388
+ fmt.timeZone = TimeZone.getTimeZone("UTC")
389
+ readingTimestampIso = fmt.format(Date(ts * 1000L))
390
+ }
391
+ val pbase = ((adv03[14].toInt() and 0xFF) shl 8) or (adv03[15].toInt() and 0xFF)
392
+ val pfact = ((adv03[16].toInt() and 0xFF) shl 8) or (adv03[17].toInt() and 0xFF)
393
+
394
+ return Adv03Parsed(
395
+ firmware = firmware,
396
+ batteryOk = batteryOk,
397
+ encryptionEnabled = encryptionEnabled,
398
+ cellularStatus = cellularStatus,
399
+ measurementCounter = measurementCounter,
400
+ readingTimestampUnix = readingTimestampUnix,
401
+ readingTimestampIso = readingTimestampIso,
402
+ periodBaseSeconds = pbase,
403
+ periodFactor = pfact,
404
+ )
405
+ }
406
+ }
@@ -0,0 +1,160 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ import android.Manifest
4
+ import android.bluetooth.BluetoothManager
5
+ import android.bluetooth.le.ScanCallback
6
+ import android.bluetooth.le.ScanResult
7
+ import android.bluetooth.le.ScanSettings
8
+ import android.content.Context
9
+ import android.content.pm.PackageManager
10
+ import android.os.Build
11
+ import androidx.core.content.ContextCompat
12
+ import java.util.Locale
13
+ import java.util.concurrent.ConcurrentHashMap
14
+
15
+ /**
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).
20
+ */
21
+ class TempivoSensorBeaconScanner(
22
+ context: Context,
23
+ ) {
24
+ fun interface Listener {
25
+ fun onDeviceFound(device: TempivoSensorBeaconDevice)
26
+ }
27
+
28
+ class ScanNotPermittedException(message: String) : Exception(message)
29
+
30
+ private val appContext = context.applicationContext
31
+ private val advLast03 = ConcurrentHashMap<String, ByteArray>()
32
+ private val advLast04 = ConcurrentHashMap<String, ByteArray>()
33
+ private var listener: Listener? = null
34
+ private var scanning = false
35
+
36
+ private val leScanCallback =
37
+ object : ScanCallback() {
38
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
39
+ handleScanResult(result)
40
+ }
41
+
42
+ override fun onBatchScanResults(results: MutableList<ScanResult>) {
43
+ for (result in results) {
44
+ handleScanResult(result)
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Clears cached `0x03` / `0x04` frames. */
50
+ fun clearCache() {
51
+ advLast03.clear()
52
+ advLast04.clear()
53
+ }
54
+
55
+ fun startScan(listener: Listener) {
56
+ if (scanning) {
57
+ stopScan()
58
+ }
59
+ if (!hasBleScanPermission()) {
60
+ throw ScanNotPermittedException("BLE scan permission not granted")
61
+ }
62
+ val scanner = bluetoothLeScanner()
63
+ ?: throw IllegalStateException("Bluetooth LE scanner unavailable")
64
+ this.listener = listener
65
+ val settings =
66
+ ScanSettings.Builder()
67
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
68
+ // Active scan: needed for scan-response frame (`0x04`) with measurements.
69
+ .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
70
+ .build()
71
+ scanner.startScan(null, settings, leScanCallback)
72
+ scanning = true
73
+ }
74
+
75
+ fun stopScan() {
76
+ if (!scanning) return
77
+ val scanner = bluetoothLeScanner() ?: return
78
+ if (!hasBleScanPermission()) {
79
+ scanning = false
80
+ listener = null
81
+ return
82
+ }
83
+ try {
84
+ scanner.stopScan(leScanCallback)
85
+ } catch (_: Exception) {
86
+ }
87
+ scanning = false
88
+ listener = null
89
+ }
90
+
91
+ fun isScanning(): Boolean = scanning
92
+
93
+ private fun handleScanResult(result: ScanResult) {
94
+ val md =
95
+ result.scanRecord?.getManufacturerSpecificData(TempivoSensorBeaconDecoder.MANUFACTURER_COMPANY_ID)
96
+ ?: return
97
+ val addr = result.device?.address ?: return
98
+ val key = TempivoSensorBeaconDecoder.macKey(addr)
99
+ val changed =
100
+ TempivoSensorBeaconDecoder.ingestManufacturerPayload(md, key, advLast03, advLast04)
101
+ if (!changed && !advLast03.containsKey(key) && !advLast04.containsKey(key)) {
102
+ return
103
+ }
104
+ val device = buildDevice(result, key) ?: return
105
+ listener?.onDeviceFound(device)
106
+ }
107
+
108
+ private fun buildDevice(result: ScanResult, key: String): TempivoSensorBeaconDevice? {
109
+ val addr = result.device?.address ?: return null
110
+ val serial = key.uppercase(Locale.US)
111
+ val macColons = macWithColonsFromKey(serial)
112
+ val telemetry =
113
+ TempivoSensorBeaconDecoder.buildTelemetry(
114
+ TempivoSensorBeaconDecoder.lookupKeys(serial),
115
+ advLast03,
116
+ advLast04,
117
+ )
118
+ val summary =
119
+ telemetry?.summary?.takeIf { it.isNotEmpty() }
120
+ ?: serial
121
+ val legacyHint = TempivoSensorBeaconDecoder.legacyHintFromFirmware(telemetry?.firmware)
122
+ return TempivoSensorBeaconDevice(
123
+ deviceId = addr,
124
+ bluetoothMacAddress = macColons,
125
+ serialNumber = serial,
126
+ rssi = result.rssi,
127
+ telemetry = telemetry,
128
+ summary = summary,
129
+ legacyHint = legacyHint,
130
+ )
131
+ }
132
+
133
+ private fun bluetoothLeScanner() =
134
+ (appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
135
+ ?.adapter
136
+ ?.bluetoothLeScanner
137
+
138
+ private fun hasBleScanPermission(): Boolean {
139
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
140
+ return ContextCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_SCAN) ==
141
+ PackageManager.PERMISSION_GRANTED
142
+ }
143
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
144
+ return ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
145
+ PackageManager.PERMISSION_GRANTED
146
+ }
147
+ return true
148
+ }
149
+
150
+ private fun macWithColonsFromKey(key: String): String {
151
+ val k = key.uppercase(Locale.US)
152
+ if (k.length != 12) return key
153
+ return buildString(17) {
154
+ for (i in 0 until 12 step 2) {
155
+ if (isNotEmpty()) append(':')
156
+ append(k, i, i + 2)
157
+ }
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,42 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ /**
4
+ * One decoded measurement slot from a scan-response frame (`0x04`).
5
+ */
6
+ data class TempivoSensorBeaconReading(
7
+ val typeHex: String,
8
+ val raw24: Int,
9
+ val text: String,
10
+ )
11
+
12
+ /**
13
+ * Parsed advertisement telemetry (FW6 `0x03` + optional `0x04` measurements).
14
+ */
15
+ data class TempivoSensorBeaconTelemetry(
16
+ val firmware: String? = null,
17
+ val batteryOk: Boolean? = null,
18
+ val encryptionEnabled: Boolean? = null,
19
+ val cellularStatus: String? = null,
20
+ val measurementCounter: Long? = null,
21
+ val readingTimestampUnix: Long? = null,
22
+ val readingTimestampIso: String? = null,
23
+ val periodBaseSeconds: Int? = null,
24
+ val periodFactor: Int? = null,
25
+ val readings: List<TempivoSensorBeaconReading> = emptyList(),
26
+ val readingsCount: Int = 0,
27
+ val summary: String? = null,
28
+ )
29
+
30
+ /**
31
+ * Device reported from an active BLE scan (manufacturer `0x026C` only; no connection).
32
+ */
33
+ data class TempivoSensorBeaconDevice(
34
+ val deviceId: String,
35
+ val bluetoothMacAddress: String,
36
+ val serialNumber: String,
37
+ val rssi: Int,
38
+ val telemetry: TempivoSensorBeaconTelemetry?,
39
+ val summary: String?,
40
+ /** `true` for older firmware families; `false` for current; `null` if unknown. */
41
+ val legacyHint: Boolean?,
42
+ )
@@ -0,0 +1,18 @@
1
+ pluginManagement {
2
+ repositories {
3
+ google()
4
+ mavenCentral()
5
+ gradlePluginPortal()
6
+ }
7
+ }
8
+
9
+ dependencyResolutionManagement {
10
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11
+ repositories {
12
+ google()
13
+ mavenCentral()
14
+ }
15
+ }
16
+
17
+ rootProject.name = "tempivo-sensor-beacon"
18
+ include(":library")
package/dist/decoder.js CHANGED
@@ -47,29 +47,29 @@ function unitForType(mtype) {
47
47
  export function decodeMeasurementSlot(mtype, raw24) {
48
48
  const spec = MEASURE_SPECS.get(mtype);
49
49
  if (!spec) {
50
- return `0x${mtype.toString(16).padStart(2, '0').toUpperCase()}: raw 0x${(raw24 & 0xffffff).toString(16).padStart(6, '0')}`;
50
+ return `0x${mtype.toString(16).padStart(2, '0').toUpperCase()}: 0x${(raw24 & 0xffffff).toString(16).padStart(6, '0')}`;
51
51
  }
52
52
  if (!spec.continuous) {
53
- return `${spec.name}: (binary / status)`;
53
+ return '(status)';
54
54
  }
55
55
  const z = zigzagDecode24(raw24);
56
56
  if (spec.metaFactor === 1) {
57
57
  const v = z * spec.resolution;
58
58
  const unit = unitForType(mtype);
59
59
  const num = formatNum(v, mtype);
60
- return unit ? `${spec.name} ${num} ${unit}` : `${spec.name} ${num}`;
60
+ return unit ? `${num} ${unit}` : num;
61
61
  }
62
62
  if (mtype === 0x06) {
63
63
  const iaq = z % (1 << 9);
64
64
  const cal = (z >> 9) & 0x03;
65
65
  const calS = ['not_stab', 'cal_req', 'cal_ongoing', 'cal_done'][Math.min(cal, 3)];
66
- return `${spec.name} ${iaq} (${calS})`;
66
+ return `${iaq} (${calS})`;
67
67
  }
68
68
  if (mtype === 0x1a || mtype === 0x1c || mtype === 0x1d || mtype === 0x1e) {
69
69
  const v = (z / spec.metaFactor) * spec.resolution;
70
- return `${spec.name} ${v}`;
70
+ return String(v);
71
71
  }
72
- return `${spec.name} z=${z}`;
72
+ return String(z);
73
73
  }
74
74
  function parsedValuesFromSlot(mtype, raw24) {
75
75
  const spec = MEASURE_SPECS.get(mtype);
Binary file
@@ -0,0 +1,22 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "TempivoSensorBeacon",
6
+ platforms: [.iOS(.v15)],
7
+ products: [
8
+ .library(
9
+ name: "TempivoSensorBeacon",
10
+ targets: ["TempivoSensorBeacon"]
11
+ ),
12
+ ],
13
+ targets: [
14
+ .target(
15
+ name: "TempivoSensorBeacon",
16
+ path: "Sources/TempivoSensorBeacon",
17
+ linkerSettings: [
18
+ .linkedFramework("CoreBluetooth"),
19
+ ]
20
+ ),
21
+ ]
22
+ )