@tempivo/sensor-beacon 0.3.0 → 0.3.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 +18 -7
- package/android/build.gradle +13 -5
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +15 -1
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +12 -2
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +46 -4
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +58 -16
- 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/TempivoSensorSession.kt +1 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -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 +2 -2
- package/dist/qr.js +2 -2
- package/dist/session-types.d.ts +8 -3
- package/dist/tempivo-sensor-beacon.aar +0 -0
- package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
- 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/TempivoSensorProfile.swift +57 -3
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +10 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -112,7 +112,9 @@ Same as Cellular API config profiles (`temperatureAlerts` + `schedule`). You can
|
|
|
112
112
|
| `temperatureAlerts` | Alert list. Empty array is valid. |
|
|
113
113
|
| `schedule` | `{ "always": true }` is 24/7. |
|
|
114
114
|
|
|
115
|
-
Ignored over BLE: `slug`, `name
|
|
115
|
+
Ignored over BLE: `slug`, `name`. Do not pass `alarmRules`.
|
|
116
|
+
|
|
117
|
+
Optional intervals (same limits as Cellular API): `measurementIntervalMinutes` (60–600), `transmissionIntervalSeconds` (3600–604800). When both are set, uplink must be at most 60× the measurement interval. Omitted fields leave the device’s current intervals unchanged.
|
|
116
118
|
|
|
117
119
|
**Each `temperatureAlerts[]` item**
|
|
118
120
|
|
|
@@ -142,15 +144,15 @@ Ignored over BLE: `slug`, `name`, `measurementIntervalMinutes`, `transmissionInt
|
|
|
142
144
|
Sticker QR is JSON. Parse with `parseSensorQrJson`.
|
|
143
145
|
|
|
144
146
|
```json
|
|
145
|
-
{ "sn": "282C024F0012", "pin": "111111"
|
|
147
|
+
{ "sn": "282C024F0012", "pin": "111111" }
|
|
146
148
|
```
|
|
147
149
|
|
|
148
150
|
| Field | Type | Meaning |
|
|
149
151
|
|------|------|---------|
|
|
150
152
|
| `serial` | string | Device serial, 12 hex chars, no colons. From QR `sn`. |
|
|
151
153
|
| `pin` | string | Sticker PIN. Required on `connect`. Also accepted as QR `resetCode`. |
|
|
152
|
-
| `model` | string |
|
|
153
|
-
| `sessionType` | `modern` \| `legacy` | GATT path.
|
|
154
|
+
| `model` | string | Optional. Firmware from the last scan wins when present. |
|
|
155
|
+
| `sessionType` | `modern` \| `legacy` | GATT path. Firmware 7+ is `modern`. Firmware 6.x is `legacy`. |
|
|
154
156
|
| `bluetoothMac` | string | BLE MAC with colons, derived from `serial`. |
|
|
155
157
|
|
|
156
158
|
## Scan result
|
|
@@ -170,7 +172,7 @@ Filter manufacturer data on company id **0x026C**. Active scanning is on so scan
|
|
|
170
172
|
|
|
171
173
|
| Field | Meaning |
|
|
172
174
|
|------|---------|
|
|
173
|
-
| `firmware` | `major.minor.patch`, e.g. `7.3.4`. |
|
|
175
|
+
| `firmware` | `major.minor.patch`, e.g. `7.3.4`. Firmware 7+ is modern GATT. |
|
|
174
176
|
| `batteryOk` | Battery status flag. |
|
|
175
177
|
| `encryptionEnabled` | Advertisement payload encryption flag. |
|
|
176
178
|
| `cellularStatus` | `ble_only`, `cell_ok`, `no_server`, or `net_issue`. |
|
|
@@ -206,7 +208,7 @@ import {
|
|
|
206
208
|
parseSensorConfigurationJson,
|
|
207
209
|
} from '@tempivo/sensor-beacon';
|
|
208
210
|
|
|
209
|
-
const qr = parseSensorQrJson('{"sn":"282C024F0012","pin":"111111"
|
|
211
|
+
const qr = parseSensorQrJson('{"sn":"282C024F0012","pin":"111111"}');
|
|
210
212
|
const reading = decodeSensorBeaconPayload(normalizeManufacturerBytes(advertisementBytes));
|
|
211
213
|
const cfg = parseSensorConfigurationJson(/* config JSON above */);
|
|
212
214
|
```
|
|
@@ -215,7 +217,16 @@ Invalid QR or config throws `TempivoSensorError` (`invalidQr`, `invalidPin`, `in
|
|
|
215
217
|
|
|
216
218
|
## Android (Kotlin)
|
|
217
219
|
|
|
218
|
-
AAR
|
|
220
|
+
AAR after `npm run build:android` (from `packages/tempivo-sensor-beacon`):
|
|
221
|
+
|
|
222
|
+
| Path | Use |
|
|
223
|
+
|------|-----|
|
|
224
|
+
| `android/libs/tempivo-sensor-beacon.aar` | Expo / React Native autolinking (`android/build.gradle`) |
|
|
225
|
+
| `dist/tempivo-sensor-beacon.aar` | Same file; handy for plain Android `files(...)` deps |
|
|
226
|
+
|
|
227
|
+
Sources: `android-aar/library`. Request `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` (API 31+) or location on older APIs.
|
|
228
|
+
|
|
229
|
+
**Scan:** temperature and other live values are in scan-response frame **`0x04`** (`telemetry.readings`). Frame **`0x03`** alone fills firmware / battery / cellular but leaves `readings` empty until an active scan delivers `0x04` (usually within a second or two). Keep scanning; do not stop on the first event.
|
|
219
230
|
|
|
220
231
|
```kotlin
|
|
221
232
|
val qr = TempivoSensorQrParser.parse(stickerQrText)
|
package/android/build.gradle
CHANGED
|
@@ -4,14 +4,14 @@ plugins {
|
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
group = 'com.tempivo.sensor.beacon'
|
|
7
|
-
version = '0.3.
|
|
7
|
+
version = '0.3.3'
|
|
8
8
|
|
|
9
9
|
android {
|
|
10
10
|
namespace "expo.modules.tempivosensorbeacon"
|
|
11
11
|
defaultConfig {
|
|
12
12
|
minSdk 26
|
|
13
13
|
versionCode 1
|
|
14
|
-
versionName "0.3.
|
|
14
|
+
versionName "0.3.3"
|
|
15
15
|
}
|
|
16
16
|
compileOptions {
|
|
17
17
|
sourceCompatibility JavaVersion.VERSION_17
|
|
@@ -24,9 +24,17 @@ android {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// Prefer android/libs (what Expo autolinking uses). Fall back to dist/ from `npm run build:android`.
|
|
28
|
+
def aarCandidates = [
|
|
29
|
+
file("${projectDir}/libs/tempivo-sensor-beacon.aar"),
|
|
30
|
+
file("${projectDir}/../dist/tempivo-sensor-beacon.aar"),
|
|
31
|
+
]
|
|
32
|
+
def aarFile = aarCandidates.find { it.exists() }
|
|
33
|
+
if (aarFile == null) {
|
|
34
|
+
throw new GradleException(
|
|
35
|
+
"Missing tempivo-sensor-beacon.aar (looked in android/libs/ and dist/). " +
|
|
36
|
+
"From packages/tempivo-sensor-beacon run: npm run build:android"
|
|
37
|
+
)
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
dependencies {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.tempivo.sensor.beacon
|
|
2
2
|
|
|
3
|
+
import pl.efento.mobile.bluetooth.model.MeasurementPeriod
|
|
3
4
|
import pl.efento.mobile.bluetooth.model.sensor.Action
|
|
4
5
|
import pl.efento.mobile.bluetooth.model.sensor.DisabledCondition
|
|
5
6
|
import pl.efento.mobile.bluetooth.model.sensor.HighThresholdCondition
|
|
@@ -164,7 +165,20 @@ internal object PartnerBleRules {
|
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
return TempivoSensorConfiguration(
|
|
168
|
+
return TempivoSensorConfiguration(
|
|
169
|
+
alerts,
|
|
170
|
+
decompileSchedule(cfg.ruleCalendars),
|
|
171
|
+
measurementIntervalMinutes = decompileMeasurementMinutes(cfg.measurementPeriod),
|
|
172
|
+
transmissionIntervalSeconds = cfg.transmissionInterval?.value?.takeIf { it > 0 },
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private fun decompileMeasurementMinutes(period: MeasurementPeriod?): Double? {
|
|
177
|
+
if (period == null) return null
|
|
178
|
+
val factor = if (period.factor == 0) 1 else period.factor
|
|
179
|
+
val sec = period.base * factor
|
|
180
|
+
if (sec <= 0) return null
|
|
181
|
+
return sec / 60.0
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
private fun orSelectedPairs(rules: List<Rule>): List<Pair<Int, Int>> {
|
|
@@ -8,9 +8,11 @@ import pl.efento.mobile.bluetooth.api.SensorLegacyConnection
|
|
|
8
8
|
import pl.efento.mobile.bluetooth.exception.InvalidResetCodeException
|
|
9
9
|
import pl.efento.mobile.bluetooth.exception.UnsupportedCommandException
|
|
10
10
|
import pl.efento.mobile.bluetooth.model.BluetoothMacAddress
|
|
11
|
+
import pl.efento.mobile.bluetooth.model.MeasurementPeriod
|
|
11
12
|
import pl.efento.mobile.bluetooth.model.sensor.Rule
|
|
12
13
|
import pl.efento.mobile.bluetooth.model.sensor.RuleCalendar
|
|
13
14
|
import pl.efento.mobile.bluetooth.model.sensor.SensorConfiguration
|
|
15
|
+
import pl.efento.mobile.bluetooth.model.sensor.cellular.TransmissionInterval
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Internal adapter to the sensor BLE runtime. Keep vendor types inside this file.
|
|
@@ -97,11 +99,19 @@ internal object SensorBleRuntime {
|
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
suspend fun
|
|
102
|
+
suspend fun writeConfiguration(session: Session, cfg: TempivoSensorConfiguration) {
|
|
103
|
+
val (rules, calendars) = compileRules(cfg)
|
|
101
104
|
when (session) {
|
|
102
105
|
is Session.Modern -> {
|
|
103
106
|
val base = modernGetConfiguration(session.connection)
|
|
104
|
-
|
|
107
|
+
var next = base.copy(rules = rules.take(12), ruleCalendars = calendars.take(6))
|
|
108
|
+
cfg.measurementIntervalMinutes?.let { minutes ->
|
|
109
|
+
val baseSec = (minutes * 60.0).toInt().coerceAtLeast(1)
|
|
110
|
+
next = next.copy(measurementPeriod = MeasurementPeriod(baseSec, 1))
|
|
111
|
+
}
|
|
112
|
+
cfg.transmissionIntervalSeconds?.let { sec ->
|
|
113
|
+
next = next.copy(transmissionInterval = TransmissionInterval(sec))
|
|
114
|
+
}
|
|
105
115
|
session.connection.commands.setConfiguration(next) { }
|
|
106
116
|
}
|
|
107
117
|
is Session.Legacy ->
|
package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt
CHANGED
|
@@ -12,12 +12,18 @@ import kotlin.math.floor
|
|
|
12
12
|
*
|
|
13
13
|
* Android [android.bluetooth.le.ScanRecord.getManufacturerSpecificData] returns the payload **after**
|
|
14
14
|
* the 2-byte company id — i.e. starts with `0x03` / `0x04`, not `6C 02`.
|
|
15
|
+
*
|
|
16
|
+
* Prefer [manufacturerPayloadsFromScanRecordBytes]: `getManufacturerSpecificData(0x026C)` can keep only
|
|
17
|
+
* one blob when both advertisement (`0x03`) and scan response (`0x04`) share the same company id.
|
|
15
18
|
*/
|
|
16
19
|
object TempivoSensorBeaconDecoder {
|
|
17
20
|
|
|
18
21
|
/** Bluetooth SIG company identifier (little-endian on air: `6C 02`). */
|
|
19
22
|
const val MANUFACTURER_COMPANY_ID: Int = 0x026C
|
|
20
23
|
|
|
24
|
+
/** AD type for Manufacturer Specific Data. */
|
|
25
|
+
private const val AD_TYPE_MANUFACTURER: Int = 0xFF
|
|
26
|
+
|
|
21
27
|
const val ADV_FRAME_03_LEN = 22
|
|
22
28
|
|
|
23
29
|
private data class MeasureSpec(
|
|
@@ -81,6 +87,35 @@ object TempivoSensorBeaconDecoder {
|
|
|
81
87
|
return keys.toList()
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Extract every Tempivo manufacturer payload from a raw [android.bluetooth.le.ScanRecord] byte
|
|
92
|
+
* array (AD length/type structures). Each returned blob starts with frame tag `0x03` / `0x04`
|
|
93
|
+
* (company id already stripped), matching [getManufacturerSpecificData].
|
|
94
|
+
*/
|
|
95
|
+
fun manufacturerPayloadsFromScanRecordBytes(scanRecordBytes: ByteArray?): List<ByteArray> {
|
|
96
|
+
if (scanRecordBytes == null || scanRecordBytes.isEmpty()) return emptyList()
|
|
97
|
+
val out = mutableListOf<ByteArray>()
|
|
98
|
+
var i = 0
|
|
99
|
+
val n = scanRecordBytes.size
|
|
100
|
+
while (i < n) {
|
|
101
|
+
val len = scanRecordBytes[i].toInt() and 0xFF
|
|
102
|
+
if (len == 0) break
|
|
103
|
+
if (i + 1 + len > n) break
|
|
104
|
+
val type = scanRecordBytes[i + 1].toInt() and 0xFF
|
|
105
|
+
if (type == AD_TYPE_MANUFACTURER && len >= 3) {
|
|
106
|
+
val company =
|
|
107
|
+
(scanRecordBytes[i + 2].toInt() and 0xFF) or
|
|
108
|
+
((scanRecordBytes[i + 3].toInt() and 0xFF) shl 8)
|
|
109
|
+
if (company == MANUFACTURER_COMPANY_ID && len > 3) {
|
|
110
|
+
// AD length counts type + company + payload; payload starts at i+4.
|
|
111
|
+
out += scanRecordBytes.copyOfRange(i + 4, i + 1 + len)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
i += 1 + len
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
84
119
|
/**
|
|
85
120
|
* @return true if any `0x03` / `0x04` frame was stored.
|
|
86
121
|
*/
|
|
@@ -101,7 +136,7 @@ object TempivoSensorBeaconDecoder {
|
|
|
101
136
|
val frame = fr.copyOf(ADV_FRAME_03_LEN)
|
|
102
137
|
val keys = lookupKeys(primaryMacKey, serialKeyFromAdv03(frame))
|
|
103
138
|
for (key in keys) {
|
|
104
|
-
if (last03[key]
|
|
139
|
+
if (!last03[key].contentEquals(frame)) {
|
|
105
140
|
last03[key] = frame
|
|
106
141
|
changed = true
|
|
107
142
|
}
|
|
@@ -115,16 +150,23 @@ object TempivoSensorBeaconDecoder {
|
|
|
115
150
|
if (existing != null && existing.isNotEmpty() && (existing[0].toInt() and 0xFF) == 0x03) {
|
|
116
151
|
continue
|
|
117
152
|
}
|
|
118
|
-
if (last03[key]
|
|
153
|
+
if (!last03[key].contentEquals(fr)) {
|
|
119
154
|
last03[key] = fr
|
|
120
155
|
changed = true
|
|
121
156
|
}
|
|
122
157
|
}
|
|
123
158
|
}
|
|
124
159
|
0x04 -> {
|
|
125
|
-
|
|
160
|
+
// Also index under any serial already learned from `0x03` for this MAC.
|
|
161
|
+
val serialFromCached03 =
|
|
162
|
+
lookupKeys(primaryMacKey)
|
|
163
|
+
.asSequence()
|
|
164
|
+
.mapNotNull { last03[it] }
|
|
165
|
+
.mapNotNull { serialKeyFromAdv03(it) }
|
|
166
|
+
.firstOrNull()
|
|
167
|
+
val keys = lookupKeys(primaryMacKey, serialFromCached03)
|
|
126
168
|
for (key in keys) {
|
|
127
|
-
if (last04[key]
|
|
169
|
+
if (!last04[key].contentEquals(fr)) {
|
|
128
170
|
last04[key] = fr
|
|
129
171
|
changed = true
|
|
130
172
|
}
|
package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt
CHANGED
|
@@ -27,9 +27,18 @@ class TempivoSensorBeaconScanner(
|
|
|
27
27
|
|
|
28
28
|
class ScanNotPermittedException(message: String) : Exception(message)
|
|
29
29
|
|
|
30
|
+
data class SeenDevice(
|
|
31
|
+
val deviceId: String,
|
|
32
|
+
val bluetoothMac: String,
|
|
33
|
+
val serial: String,
|
|
34
|
+
val firmware: String?,
|
|
35
|
+
val encryptionEnabled: Boolean?,
|
|
36
|
+
)
|
|
37
|
+
|
|
30
38
|
private val appContext = context.applicationContext
|
|
31
39
|
private val advLast03 = ConcurrentHashMap<String, ByteArray>()
|
|
32
40
|
private val advLast04 = ConcurrentHashMap<String, ByteArray>()
|
|
41
|
+
private val lastSeen = ConcurrentHashMap<String, SeenDevice>()
|
|
33
42
|
private var listener: Listener? = null
|
|
34
43
|
private var scanning = false
|
|
35
44
|
|
|
@@ -50,6 +59,12 @@ class TempivoSensorBeaconScanner(
|
|
|
50
59
|
fun clearCache() {
|
|
51
60
|
advLast03.clear()
|
|
52
61
|
advLast04.clear()
|
|
62
|
+
lastSeen.clear()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun lookup(serial: String): SeenDevice? {
|
|
66
|
+
val key = TempivoSensorBeaconDecoder.macKey(serial)
|
|
67
|
+
return lastSeen[key]
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
fun startScan(listener: Listener) {
|
|
@@ -91,13 +106,26 @@ class TempivoSensorBeaconScanner(
|
|
|
91
106
|
fun isScanning(): Boolean = scanning
|
|
92
107
|
|
|
93
108
|
private fun handleScanResult(result: ScanResult) {
|
|
94
|
-
val
|
|
95
|
-
result.scanRecord?.getManufacturerSpecificData(TempivoSensorBeaconDecoder.MANUFACTURER_COMPANY_ID)
|
|
96
|
-
?: return
|
|
109
|
+
val record = result.scanRecord ?: return
|
|
97
110
|
val addr = result.device?.address ?: return
|
|
98
111
|
val key = TempivoSensorBeaconDecoder.macKey(addr)
|
|
99
|
-
|
|
100
|
-
|
|
112
|
+
// Raw AD parse keeps both 0x03 and 0x04 when they share company id 0x026C.
|
|
113
|
+
// getManufacturerSpecificData(0x026C) can retain only one of the two.
|
|
114
|
+
val payloads =
|
|
115
|
+
TempivoSensorBeaconDecoder.manufacturerPayloadsFromScanRecordBytes(record.bytes)
|
|
116
|
+
.ifEmpty {
|
|
117
|
+
val single =
|
|
118
|
+
record.getManufacturerSpecificData(TempivoSensorBeaconDecoder.MANUFACTURER_COMPANY_ID)
|
|
119
|
+
if (single != null) listOf(single) else emptyList()
|
|
120
|
+
}
|
|
121
|
+
if (payloads.isEmpty()) return
|
|
122
|
+
|
|
123
|
+
var changed = false
|
|
124
|
+
for (md in payloads) {
|
|
125
|
+
if (TempivoSensorBeaconDecoder.ingestManufacturerPayload(md, key, advLast03, advLast04)) {
|
|
126
|
+
changed = true
|
|
127
|
+
}
|
|
128
|
+
}
|
|
101
129
|
if (!changed && !advLast03.containsKey(key) && !advLast04.containsKey(key)) {
|
|
102
130
|
return
|
|
103
131
|
}
|
|
@@ -107,25 +135,39 @@ class TempivoSensorBeaconScanner(
|
|
|
107
135
|
|
|
108
136
|
private fun buildDevice(result: ScanResult, key: String): TempivoSensorBeaconDevice? {
|
|
109
137
|
val addr = result.device?.address ?: return null
|
|
110
|
-
val
|
|
111
|
-
val
|
|
138
|
+
val frame = advLast03[key]
|
|
139
|
+
val advSerial = frame?.let { TempivoSensorBeaconDecoder.serialKeyFromAdv03(it) }
|
|
140
|
+
val serial = (advSerial ?: key).uppercase(Locale.US)
|
|
141
|
+
val macColons = macWithColonsFromKey(if (key.length == 12) key else serial)
|
|
112
142
|
val telemetry =
|
|
113
143
|
TempivoSensorBeaconDecoder.buildTelemetry(
|
|
114
|
-
TempivoSensorBeaconDecoder.lookupKeys(
|
|
144
|
+
TempivoSensorBeaconDecoder.lookupKeys(key, advSerial),
|
|
115
145
|
advLast03,
|
|
116
146
|
advLast04,
|
|
117
147
|
)
|
|
118
148
|
val summary =
|
|
119
149
|
telemetry?.summary?.takeIf { it.isNotEmpty() }
|
|
120
150
|
?: serial
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
val device =
|
|
152
|
+
TempivoSensorBeaconDevice(
|
|
153
|
+
deviceId = addr,
|
|
154
|
+
bluetoothMacAddress = macColons,
|
|
155
|
+
serialNumber = serial,
|
|
156
|
+
rssi = result.rssi,
|
|
157
|
+
telemetry = telemetry,
|
|
158
|
+
summary = summary,
|
|
159
|
+
)
|
|
160
|
+
val seen =
|
|
161
|
+
SeenDevice(
|
|
162
|
+
deviceId = addr,
|
|
163
|
+
bluetoothMac = macColons,
|
|
164
|
+
serial = serial,
|
|
165
|
+
firmware = telemetry?.firmware,
|
|
166
|
+
encryptionEnabled = telemetry?.encryptionEnabled,
|
|
167
|
+
)
|
|
168
|
+
lastSeen[serial] = seen
|
|
169
|
+
lastSeen[key.uppercase(Locale.US)] = seen
|
|
170
|
+
return device
|
|
129
171
|
}
|
|
130
172
|
|
|
131
173
|
private fun bluetoothLeScanner() =
|
|
@@ -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") {
|
|
@@ -83,9 +83,8 @@ class TempivoSensorSession(
|
|
|
83
83
|
suspend fun getConfigurationJson(): String = TempivoSensorProfile.toJson(getConfiguration())
|
|
84
84
|
|
|
85
85
|
suspend fun setConfiguration(configuration: TempivoSensorConfiguration) {
|
|
86
|
-
val (rules, calendars) = SensorBleRuntime.compileRules(configuration)
|
|
87
86
|
runGatt {
|
|
88
|
-
SensorBleRuntime.
|
|
87
|
+
SensorBleRuntime.writeConfiguration(requireSession(), configuration)
|
|
89
88
|
}
|
|
90
89
|
}
|
|
91
90
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export { ADV_FRAME_03_LEN, TEMPVO_SENSOR_MANUFACTURER_ID, buildSensorBeaconReading, decodeMeasurementSlot, decodeSensorBeaconPayload, lookupDeviceKeys, macKeyFromAddress, serialKeyFromAdv03Frame, splitSensorBeaconFrames, } from './decoder.js';
|
|
2
2
|
export { ingestManufacturerPayload } from './ingest.js';
|
|
3
|
-
export { dataViewToUint8Array, normalizeManufacturerBytes } from './normalize.js';
|
|
3
|
+
export { dataViewToUint8Array, manufacturerPayloadsFromScanRecordBytes, normalizeManufacturerBytes } from './normalize.js';
|
|
4
4
|
export { MEASURE_SPECS, type MeasureSpec } from './measure-specs.js';
|
|
5
5
|
export { createSensorBeaconFrameCache, type SensorBeaconFrameCache, type SensorBeaconMeasurement, type SensorBeaconReading, } from './types.js';
|
|
6
6
|
export { TempivoSensorError, type TempivoSensorErrorCode } from './errors.js';
|
|
7
7
|
export { DEFAULT_SENSOR_MODEL, bluetoothMacFromSerial, normalizeSensorSerial, encodeSensorPinPayload, parseSensorQrJson, sessionTypeFromModel, } from './qr.js';
|
|
8
8
|
export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } from './calibration.js';
|
|
9
|
-
export { DEFAULT_ALERT_HYSTERESIS_C, compileSensorProfileToBleJson, configurationFromApiProfile, parseSensorConfiguration, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
|
|
9
|
+
export { BLE_MAX_MEASUREMENT_INTERVAL_MINUTES, BLE_MAX_TRANSMISSION_INTERVAL_SECONDS, BLE_MIN_MEASUREMENT_INTERVAL_MINUTES, BLE_MIN_TRANSMISSION_INTERVAL_SECONDS, DEFAULT_ALERT_HYSTERESIS_C, compileSensorProfileToBleJson, configurationFromApiProfile, parseSensorConfiguration, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
|
|
10
10
|
export type { TempivoSchedule, TempivoScheduleAlways, TempivoScheduleWeek, TempivoSensorCalibration, TempivoSensorConfiguration, TempivoSensorQr, TempivoSensorSessionType, TempivoTemperatureAlert, TempivoTemperatureChannel, TempivoTemperatureMaxAlert, TempivoTemperatureMinAlert, TempivoTemperatureRangeAlert, TempivoTriggerTransmissionResult, } from './session-types.js';
|
|
11
11
|
export { addDeviceFoundListener, connect, disconnect, getCalibration, getConfiguration, isNativeSensorBeaconAvailable, isScanning, requestPermissions, setConfigurationJson, startScan, stopScan, triggerTransmission, } from './native-bridge.js';
|
|
12
12
|
export type { TempivoDeviceFoundSubscription, TempivoNativeCalibration, TempivoNativeMeasurement, TempivoNativePermissionResponse, TempivoNativePermissionStatus, TempivoNativeQr, TempivoNativeTelemetry, TempivoSensorBeaconDevice, } from './native-types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export { ADV_FRAME_03_LEN, TEMPVO_SENSOR_MANUFACTURER_ID, buildSensorBeaconReading, decodeMeasurementSlot, decodeSensorBeaconPayload, lookupDeviceKeys, macKeyFromAddress, serialKeyFromAdv03Frame, splitSensorBeaconFrames, } from './decoder.js';
|
|
2
2
|
export { ingestManufacturerPayload } from './ingest.js';
|
|
3
|
-
export { dataViewToUint8Array, normalizeManufacturerBytes } from './normalize.js';
|
|
3
|
+
export { dataViewToUint8Array, manufacturerPayloadsFromScanRecordBytes, normalizeManufacturerBytes } from './normalize.js';
|
|
4
4
|
export { MEASURE_SPECS } from './measure-specs.js';
|
|
5
5
|
export { createSensorBeaconFrameCache, } from './types.js';
|
|
6
6
|
export { TempivoSensorError } from './errors.js';
|
|
7
7
|
export { DEFAULT_SENSOR_MODEL, bluetoothMacFromSerial, normalizeSensorSerial, encodeSensorPinPayload, parseSensorQrJson, sessionTypeFromModel, } from './qr.js';
|
|
8
8
|
export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } from './calibration.js';
|
|
9
|
-
export { DEFAULT_ALERT_HYSTERESIS_C, compileSensorProfileToBleJson, configurationFromApiProfile, parseSensorConfiguration, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
|
|
9
|
+
export { BLE_MAX_MEASUREMENT_INTERVAL_MINUTES, BLE_MAX_TRANSMISSION_INTERVAL_SECONDS, BLE_MIN_MEASUREMENT_INTERVAL_MINUTES, BLE_MIN_TRANSMISSION_INTERVAL_SECONDS, DEFAULT_ALERT_HYSTERESIS_C, compileSensorProfileToBleJson, configurationFromApiProfile, parseSensorConfiguration, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
|
|
10
10
|
export { addDeviceFoundListener, connect, disconnect, getCalibration, getConfiguration, isNativeSensorBeaconAvailable, isScanning, requestPermissions, setConfigurationJson, startScan, stopScan, triggerTransmission, } from './native-bridge.js';
|
package/dist/normalize.d.ts
CHANGED
|
@@ -4,3 +4,9 @@ export declare function dataViewToUint8Array(dv: DataView): Uint8Array;
|
|
|
4
4
|
* Web Bluetooth and Android often already return payload **without** the prefix.
|
|
5
5
|
*/
|
|
6
6
|
export declare function normalizeManufacturerBytes(data: Uint8Array | DataView | ArrayBuffer): Uint8Array;
|
|
7
|
+
/**
|
|
8
|
+
* Parse Android-style `ScanRecord.getBytes()` AD structures and return every Tempivo
|
|
9
|
+
* manufacturer payload (company id stripped). Use this when both `0x03` and `0x04` share
|
|
10
|
+
* company `0x026C` — `getManufacturerSpecificData(0x026C)` can keep only one blob.
|
|
11
|
+
*/
|
|
12
|
+
export declare function manufacturerPayloadsFromScanRecordBytes(scanRecordBytes: Uint8Array | null | undefined): Uint8Array[];
|
package/dist/normalize.js
CHANGED
|
@@ -28,3 +28,32 @@ export function normalizeManufacturerBytes(data) {
|
|
|
28
28
|
}
|
|
29
29
|
return bytes;
|
|
30
30
|
}
|
|
31
|
+
const AD_TYPE_MANUFACTURER = 0xff;
|
|
32
|
+
/**
|
|
33
|
+
* Parse Android-style `ScanRecord.getBytes()` AD structures and return every Tempivo
|
|
34
|
+
* manufacturer payload (company id stripped). Use this when both `0x03` and `0x04` share
|
|
35
|
+
* company `0x026C` — `getManufacturerSpecificData(0x026C)` can keep only one blob.
|
|
36
|
+
*/
|
|
37
|
+
export function manufacturerPayloadsFromScanRecordBytes(scanRecordBytes) {
|
|
38
|
+
if (!scanRecordBytes || scanRecordBytes.length === 0)
|
|
39
|
+
return [];
|
|
40
|
+
const out = [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
const n = scanRecordBytes.length;
|
|
43
|
+
while (i < n) {
|
|
44
|
+
const len = scanRecordBytes[i] & 0xff;
|
|
45
|
+
if (len === 0)
|
|
46
|
+
break;
|
|
47
|
+
if (i + 1 + len > n)
|
|
48
|
+
break;
|
|
49
|
+
const type = scanRecordBytes[i + 1] & 0xff;
|
|
50
|
+
if (type === AD_TYPE_MANUFACTURER && len >= 3) {
|
|
51
|
+
const company = (scanRecordBytes[i + 2] & 0xff) | ((scanRecordBytes[i + 3] & 0xff) << 8);
|
|
52
|
+
if (company === TEMPVO_SENSOR_MANUFACTURER_ID && len > 3) {
|
|
53
|
+
out.push(scanRecordBytes.subarray(i + 4, i + 1 + len));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
i += 1 + len;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
package/dist/profile.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { TempivoSensorConfiguration } from './session-types.js';
|
|
2
2
|
/** Same default as Cellular API config profiles. */
|
|
3
3
|
export declare const DEFAULT_ALERT_HYSTERESIS_C = 1;
|
|
4
|
+
/** Same limits as Cellular API simple profiles. */
|
|
5
|
+
export declare const BLE_MIN_MEASUREMENT_INTERVAL_MINUTES = 60;
|
|
6
|
+
export declare const BLE_MAX_MEASUREMENT_INTERVAL_MINUTES = 600;
|
|
7
|
+
export declare const BLE_MIN_TRANSMISSION_INTERVAL_SECONDS = 3600;
|
|
8
|
+
export declare const BLE_MAX_TRANSMISSION_INTERVAL_SECONDS = 604800;
|
|
4
9
|
/** Validate Cellular API config-profile JSON for BLE setConfiguration. */
|
|
5
10
|
export declare function parseSensorConfiguration(input: unknown): TempivoSensorConfiguration;
|
|
6
11
|
/** Same as {@link parseSensorConfiguration}: GET `{ profile }` or the profile object. */
|
package/dist/profile.js
CHANGED
|
@@ -168,7 +168,9 @@ function parseAlert(item, index) {
|
|
|
168
168
|
function looksLikeSimpleProfile(rec) {
|
|
169
169
|
return (rec.temperatureAlerts !== undefined ||
|
|
170
170
|
rec.schedule !== undefined ||
|
|
171
|
-
typeof rec.slug === 'string'
|
|
171
|
+
typeof rec.slug === 'string' ||
|
|
172
|
+
rec.measurementIntervalMinutes !== undefined ||
|
|
173
|
+
rec.transmissionIntervalSeconds !== undefined);
|
|
172
174
|
}
|
|
173
175
|
/**
|
|
174
176
|
* GET /devices/config-profiles/{slug} returns `{ profile }`. Also accepts the profile object.
|
|
@@ -188,6 +190,48 @@ function rejectWireAlarmRules(rec) {
|
|
|
188
190
|
throw new TempivoSensorError('invalidConfig', 'Use a config profile with temperatureAlerts (GET /devices/config-profiles/{slug}), not alarmRules.');
|
|
189
191
|
}
|
|
190
192
|
}
|
|
193
|
+
/** Same limits as Cellular API simple profiles. */
|
|
194
|
+
export const BLE_MIN_MEASUREMENT_INTERVAL_MINUTES = 60;
|
|
195
|
+
export const BLE_MAX_MEASUREMENT_INTERVAL_MINUTES = 600;
|
|
196
|
+
export const BLE_MIN_TRANSMISSION_INTERVAL_SECONDS = 3600;
|
|
197
|
+
export const BLE_MAX_TRANSMISSION_INTERVAL_SECONDS = 604_800;
|
|
198
|
+
function parseOptionalIntervals(body) {
|
|
199
|
+
const out = {};
|
|
200
|
+
if (body.measurementIntervalMinutes !== undefined) {
|
|
201
|
+
const n = parseFiniteNumber(body.measurementIntervalMinutes);
|
|
202
|
+
if (n === undefined || !Number.isFinite(n) || n <= 0) {
|
|
203
|
+
throw new TempivoSensorError('invalidConfig', 'measurementIntervalMinutes must be a positive number.');
|
|
204
|
+
}
|
|
205
|
+
if (n < BLE_MIN_MEASUREMENT_INTERVAL_MINUTES) {
|
|
206
|
+
throw new TempivoSensorError('invalidConfig', `measurementIntervalMinutes must be at least ${BLE_MIN_MEASUREMENT_INTERVAL_MINUTES} (1 hour).`);
|
|
207
|
+
}
|
|
208
|
+
if (n > BLE_MAX_MEASUREMENT_INTERVAL_MINUTES) {
|
|
209
|
+
throw new TempivoSensorError('invalidConfig', `measurementIntervalMinutes must be at most ${BLE_MAX_MEASUREMENT_INTERVAL_MINUTES}.`);
|
|
210
|
+
}
|
|
211
|
+
out.measurementIntervalMinutes = n;
|
|
212
|
+
}
|
|
213
|
+
if (body.transmissionIntervalSeconds !== undefined) {
|
|
214
|
+
const n = parseFiniteNumber(body.transmissionIntervalSeconds);
|
|
215
|
+
if (n === undefined || !Number.isInteger(n) || n <= 0) {
|
|
216
|
+
throw new TempivoSensorError('invalidConfig', 'transmissionIntervalSeconds must be a positive integer.');
|
|
217
|
+
}
|
|
218
|
+
if (n < BLE_MIN_TRANSMISSION_INTERVAL_SECONDS) {
|
|
219
|
+
throw new TempivoSensorError('invalidConfig', `transmissionIntervalSeconds must be at least ${BLE_MIN_TRANSMISSION_INTERVAL_SECONDS} (1 hour).`);
|
|
220
|
+
}
|
|
221
|
+
if (n > BLE_MAX_TRANSMISSION_INTERVAL_SECONDS) {
|
|
222
|
+
throw new TempivoSensorError('invalidConfig', `transmissionIntervalSeconds must be at most ${BLE_MAX_TRANSMISSION_INTERVAL_SECONDS}.`);
|
|
223
|
+
}
|
|
224
|
+
out.transmissionIntervalSeconds = n;
|
|
225
|
+
}
|
|
226
|
+
if (out.measurementIntervalMinutes != null &&
|
|
227
|
+
out.transmissionIntervalSeconds != null) {
|
|
228
|
+
const maxTx = out.measurementIntervalMinutes * 60 * 60;
|
|
229
|
+
if (out.transmissionIntervalSeconds > maxTx) {
|
|
230
|
+
throw new TempivoSensorError('invalidConfig', 'transmissionIntervalSeconds must be at most 60 times the measurement interval.');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
191
235
|
/** Validate Cellular API config-profile JSON for BLE setConfiguration. */
|
|
192
236
|
export function parseSensorConfiguration(input) {
|
|
193
237
|
if (!isRecord(input)) {
|
|
@@ -201,9 +245,11 @@ export function parseSensorConfiguration(input) {
|
|
|
201
245
|
}
|
|
202
246
|
const temperatureAlerts = (alertsRaw ?? []).map((item, i) => parseAlert(item, i));
|
|
203
247
|
assertAlertSlotBudget(temperatureAlerts);
|
|
248
|
+
const intervals = parseOptionalIntervals(body);
|
|
204
249
|
return {
|
|
205
250
|
temperatureAlerts,
|
|
206
251
|
schedule: parseSchedule(body.schedule),
|
|
252
|
+
...intervals,
|
|
207
253
|
};
|
|
208
254
|
}
|
|
209
255
|
/** Same as {@link parseSensorConfiguration}: GET `{ profile }` or the profile object. */
|
package/dist/qr.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { TempivoSensorQr, TempivoSensorSessionType } from './session-types.
|
|
|
2
2
|
export declare function normalizeSensorSerial(value: string): string;
|
|
3
3
|
/** `282C024F0012` → `28:2C:02:4F:00:12`. */
|
|
4
4
|
export declare function bluetoothMacFromSerial(serial: string): string;
|
|
5
|
-
/** BLE default when QR omits `model`.
|
|
5
|
+
/** BLE default when QR omits `model`. Firmware 6.x still selects legacy. */
|
|
6
6
|
export declare const DEFAULT_SENSOR_MODEL = "HC7";
|
|
7
7
|
export declare function sessionTypeFromModel(model: string | undefined | null): TempivoSensorSessionType;
|
|
8
8
|
/**
|
|
@@ -11,6 +11,6 @@ export declare function sessionTypeFromModel(model: string | undefined | null):
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function encodeSensorPinPayload(pin: string): Uint8Array;
|
|
13
13
|
/**
|
|
14
|
-
* Parse sensor label QR JSON: `{"sn":"…","pin":"…"
|
|
14
|
+
* Parse sensor label QR JSON: `{"sn":"…","pin":"…"}`.
|
|
15
15
|
*/
|
|
16
16
|
export declare function parseSensorQrJson(text: string): TempivoSensorQr;
|
package/dist/qr.js
CHANGED
|
@@ -13,7 +13,7 @@ export function bluetoothMacFromSerial(serial) {
|
|
|
13
13
|
parts.push(key.slice(i, i + 2));
|
|
14
14
|
return parts.join(':');
|
|
15
15
|
}
|
|
16
|
-
/** BLE default when QR omits `model`.
|
|
16
|
+
/** BLE default when QR omits `model`. Firmware 6.x still selects legacy. */
|
|
17
17
|
export const DEFAULT_SENSOR_MODEL = 'HC7';
|
|
18
18
|
export function sessionTypeFromModel(model) {
|
|
19
19
|
if (model == null || model.trim() === '')
|
|
@@ -49,7 +49,7 @@ function pinFromRecord(rec) {
|
|
|
49
49
|
return undefined;
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
* Parse sensor label QR JSON: `{"sn":"…","pin":"…"
|
|
52
|
+
* Parse sensor label QR JSON: `{"sn":"…","pin":"…"}`.
|
|
53
53
|
*/
|
|
54
54
|
export function parseSensorQrJson(text) {
|
|
55
55
|
let rec;
|
package/dist/session-types.d.ts
CHANGED
|
@@ -39,17 +39,22 @@ export type TempivoScheduleWeek = {
|
|
|
39
39
|
};
|
|
40
40
|
export type TempivoSchedule = TempivoScheduleAlways | TempivoScheduleWeek;
|
|
41
41
|
/**
|
|
42
|
-
* Same JSON as Cellular API
|
|
43
|
-
*
|
|
42
|
+
* Same JSON as Cellular API config profiles (`temperatureAlerts` + `schedule`),
|
|
43
|
+
* plus optional `measurementIntervalMinutes` / `transmissionIntervalSeconds` for BLE.
|
|
44
|
+
* Extra profile fields (`slug`, `name`) are ignored over BLE.
|
|
44
45
|
*/
|
|
45
46
|
export type TempivoSensorConfiguration = {
|
|
46
47
|
temperatureAlerts: TempivoTemperatureAlert[];
|
|
47
48
|
schedule: TempivoSchedule;
|
|
49
|
+
/** Sampling interval in minutes (Cellular API). Optional. */
|
|
50
|
+
measurementIntervalMinutes?: number;
|
|
51
|
+
/** Uplink interval in seconds (Cellular API). Optional. */
|
|
52
|
+
transmissionIntervalSeconds?: number;
|
|
48
53
|
};
|
|
49
54
|
export type TempivoSensorQr = {
|
|
50
55
|
serial: string;
|
|
51
56
|
pin: string;
|
|
52
|
-
/**
|
|
57
|
+
/** Optional sticker field. Firmware from the last scan wins. Firmware 7+ is modern GATT. */
|
|
53
58
|
model?: string;
|
|
54
59
|
sessionType: TempivoSensorSessionType;
|
|
55
60
|
bluetoothMac: string;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -154,7 +154,12 @@ public enum TempivoSensorBeaconDecoder {
|
|
|
154
154
|
let bytes = [UInt8](manufacturerData)
|
|
155
155
|
guard bytes.count >= 3 else { return false }
|
|
156
156
|
let company = UInt16(bytes[0]) | (UInt16(bytes[1]) << 8)
|
|
157
|
-
guard company == TEMPVO_SENSOR_MANUFACTURER_ID else {
|
|
157
|
+
guard company == TEMPVO_SENSOR_MANUFACTURER_ID else {
|
|
158
|
+
// Already-normalized payload (no company id).
|
|
159
|
+
return ingestNormalizedPayload(bytes, primaryMacKey: primaryMacKey, last03: &last03, last04: &last04)
|
|
160
|
+
}
|
|
161
|
+
// ADV + SCAN_RSP may be concatenated as `6C02`+frame03+`6C02`+frame04.
|
|
162
|
+
// strip once then let splitSensorFrames skip the second company id bytes.
|
|
158
163
|
return ingestNormalizedPayload(Array(bytes.dropFirst(2)), primaryMacKey: primaryMacKey, last03: &last03, last04: &last04)
|
|
159
164
|
}
|
|
160
165
|
|
|
@@ -180,7 +185,12 @@ public enum TempivoSensorBeaconDecoder {
|
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
} else if tag == 0x04 {
|
|
183
|
-
let
|
|
188
|
+
let serialFromCached03 =
|
|
189
|
+
lookupKeys(primaryMacKey: primaryMacKey)
|
|
190
|
+
.compactMap { last03[$0] }
|
|
191
|
+
.compactMap { serialKeyFromAdv03([UInt8]($0)) }
|
|
192
|
+
.first
|
|
193
|
+
let keys = lookupKeys(primaryMacKey: primaryMacKey, extraSerialKey: serialFromCached03)
|
|
184
194
|
let data = Data(fr)
|
|
185
195
|
for key in keys {
|
|
186
196
|
if last04[key] != data {
|
|
@@ -16,7 +16,11 @@ public enum TempivoSensorProfile {
|
|
|
16
16
|
}
|
|
17
17
|
let body: [String: Any]
|
|
18
18
|
if let nested = obj["profile"] as? [String: Any],
|
|
19
|
-
nested["temperatureAlerts"] != nil ||
|
|
19
|
+
nested["temperatureAlerts"] != nil ||
|
|
20
|
+
nested["schedule"] != nil ||
|
|
21
|
+
nested["slug"] != nil ||
|
|
22
|
+
nested["measurementIntervalMinutes"] != nil ||
|
|
23
|
+
nested["transmissionIntervalSeconds"] != nil {
|
|
20
24
|
body = nested
|
|
21
25
|
} else {
|
|
22
26
|
body = obj
|
|
@@ -26,7 +30,13 @@ public enum TempivoSensorProfile {
|
|
|
26
30
|
}
|
|
27
31
|
let rawAlerts = body["temperatureAlerts"] as? [Any] ?? []
|
|
28
32
|
let alerts = try rawAlerts.enumerated().map { try parseAlert($0.element, index: $0.offset) }
|
|
29
|
-
|
|
33
|
+
let intervals = try parseOptionalIntervals(body)
|
|
34
|
+
return TempivoSensorConfiguration(
|
|
35
|
+
temperatureAlerts: alerts,
|
|
36
|
+
schedule: try parseSchedule(body["schedule"] as? [String: Any]),
|
|
37
|
+
measurementIntervalMinutes: intervals.measurement,
|
|
38
|
+
transmissionIntervalSeconds: intervals.transmission
|
|
39
|
+
)
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
public static func jsonString(_ cfg: TempivoSensorConfiguration) throws -> String {
|
|
@@ -56,11 +66,55 @@ public enum TempivoSensorProfile {
|
|
|
56
66
|
"utcOffsetMinutes": cfg.schedule.utcOffsetMinutes,
|
|
57
67
|
]
|
|
58
68
|
}
|
|
59
|
-
|
|
69
|
+
var root: [String: Any] = ["temperatureAlerts": alerts, "schedule": schedule]
|
|
70
|
+
if let m = cfg.measurementIntervalMinutes { root["measurementIntervalMinutes"] = m }
|
|
71
|
+
if let t = cfg.transmissionIntervalSeconds { root["transmissionIntervalSeconds"] = t }
|
|
60
72
|
let data = try JSONSerialization.data(withJSONObject: root, options: [])
|
|
61
73
|
return String(data: data, encoding: .utf8) ?? "{}"
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
private static let minMeasurementMinutes: Double = 60
|
|
77
|
+
private static let maxMeasurementMinutes: Double = 600
|
|
78
|
+
private static let minTransmissionSeconds = 3600
|
|
79
|
+
private static let maxTransmissionSeconds = 604_800
|
|
80
|
+
|
|
81
|
+
private static func parseOptionalIntervals(_ body: [String: Any]) throws -> (measurement: Double?, transmission: Int?) {
|
|
82
|
+
var measurement: Double?
|
|
83
|
+
var transmission: Int?
|
|
84
|
+
if body["measurementIntervalMinutes"] != nil {
|
|
85
|
+
guard let n = number(body["measurementIntervalMinutes"]), n > 0 else {
|
|
86
|
+
throw TempivoSensorError(.invalidConfig, "measurementIntervalMinutes must be a positive number.")
|
|
87
|
+
}
|
|
88
|
+
if n < minMeasurementMinutes {
|
|
89
|
+
throw TempivoSensorError(.invalidConfig, "measurementIntervalMinutes must be at least \(Int(minMeasurementMinutes)) (1 hour).")
|
|
90
|
+
}
|
|
91
|
+
if n > maxMeasurementMinutes {
|
|
92
|
+
throw TempivoSensorError(.invalidConfig, "measurementIntervalMinutes must be at most \(Int(maxMeasurementMinutes)).")
|
|
93
|
+
}
|
|
94
|
+
measurement = n
|
|
95
|
+
}
|
|
96
|
+
if body["transmissionIntervalSeconds"] != nil {
|
|
97
|
+
guard let n = number(body["transmissionIntervalSeconds"]), n > 0, n == floor(n) else {
|
|
98
|
+
throw TempivoSensorError(.invalidConfig, "transmissionIntervalSeconds must be a positive integer.")
|
|
99
|
+
}
|
|
100
|
+
let sec = Int(n)
|
|
101
|
+
if sec < minTransmissionSeconds {
|
|
102
|
+
throw TempivoSensorError(.invalidConfig, "transmissionIntervalSeconds must be at least \(minTransmissionSeconds) (1 hour).")
|
|
103
|
+
}
|
|
104
|
+
if sec > maxTransmissionSeconds {
|
|
105
|
+
throw TempivoSensorError(.invalidConfig, "transmissionIntervalSeconds must be at most \(maxTransmissionSeconds).")
|
|
106
|
+
}
|
|
107
|
+
transmission = sec
|
|
108
|
+
}
|
|
109
|
+
if let measurement, let transmission {
|
|
110
|
+
let maxTx = measurement * 60 * 60
|
|
111
|
+
if Double(transmission) > maxTx {
|
|
112
|
+
throw TempivoSensorError(.invalidConfig, "transmissionIntervalSeconds must be at most 60 times the measurement interval.")
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return (measurement, transmission)
|
|
116
|
+
}
|
|
117
|
+
|
|
64
118
|
private static func parseAlert(_ raw: Any, index: Int) throws -> TempivoTemperatureAlert {
|
|
65
119
|
guard let item = raw as? [String: Any] else {
|
|
66
120
|
throw TempivoSensorError(.invalidConfig, "temperatureAlerts[\(index)] must be an object.")
|
|
@@ -65,10 +65,19 @@ public struct TempivoSchedule: Sendable, Equatable {
|
|
|
65
65
|
public struct TempivoSensorConfiguration: Sendable, Equatable {
|
|
66
66
|
public var temperatureAlerts: [TempivoTemperatureAlert]
|
|
67
67
|
public var schedule: TempivoSchedule
|
|
68
|
+
public var measurementIntervalMinutes: Double?
|
|
69
|
+
public var transmissionIntervalSeconds: Int?
|
|
68
70
|
|
|
69
|
-
public init(
|
|
71
|
+
public init(
|
|
72
|
+
temperatureAlerts: [TempivoTemperatureAlert],
|
|
73
|
+
schedule: TempivoSchedule,
|
|
74
|
+
measurementIntervalMinutes: Double? = nil,
|
|
75
|
+
transmissionIntervalSeconds: Int? = nil
|
|
76
|
+
) {
|
|
70
77
|
self.temperatureAlerts = temperatureAlerts
|
|
71
78
|
self.schedule = schedule
|
|
79
|
+
self.measurementIntervalMinutes = measurementIntervalMinutes
|
|
80
|
+
self.transmissionIntervalSeconds = transmissionIntervalSeconds
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tempivo/sensor-beacon",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Tempivo sensor BLE SDK for Expo, React Native, Android, iOS, and Node.",
|
|
5
5
|
"homepage": "https://app.tempivo.com/integrations",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/tempivo/tempivo-app.git",
|
|
9
|
+
"directory": "packages/tempivo-sensor-beacon"
|
|
10
|
+
},
|
|
6
11
|
"author": "Tempivo",
|
|
7
12
|
"type": "module",
|
|
8
13
|
"main": "dist/index.js",
|