@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.
- package/README.md +82 -39
- package/android/build.gradle +4 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/android/gradle.properties +3 -0
- package/android/gradlew +251 -0
- package/android/library/build.gradle +27 -0
- package/android/library/consumer-rules.pro +1 -0
- package/android/library/src/main/AndroidManifest.xml +2 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +406 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +160 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +42 -0
- package/android/settings.gradle +18 -0
- package/dist/decoder.js +6 -6
- package/dist/tempivo-sensor-beacon.aar +0 -0
- package/ios/Package.swift +22 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +413 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconScanner.swift +159 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +106 -0
- package/package.json +12 -5
|
@@ -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()}:
|
|
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
|
|
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 ? `${
|
|
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 `${
|
|
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
|
|
70
|
+
return String(v);
|
|
71
71
|
}
|
|
72
|
-
return
|
|
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
|
+
)
|