@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.
Files changed (24) hide show
  1. package/README.md +18 -7
  2. package/android/build.gradle +13 -5
  3. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +15 -1
  4. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +12 -2
  5. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +46 -4
  6. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +58 -16
  7. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +59 -2
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +1 -2
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +2 -2
  11. package/dist/normalize.d.ts +6 -0
  12. package/dist/normalize.js +29 -0
  13. package/dist/profile.d.ts +5 -0
  14. package/dist/profile.js +47 -1
  15. package/dist/qr.d.ts +2 -2
  16. package/dist/qr.js +2 -2
  17. package/dist/session-types.d.ts +8 -3
  18. package/dist/tempivo-sensor-beacon.aar +0 -0
  19. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  20. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  21. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +12 -2
  22. package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +57 -3
  23. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +10 -1
  24. 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`, `measurementIntervalMinutes`, `transmissionIntervalSeconds`. Do not pass `alarmRules`.
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", "model": "HC7" }
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 | Sticker model. Omitted QR `model` becomes **HC7**. `HC5` selects legacy GATT. |
153
- | `sessionType` | `modern` \| `legacy` | GATT path. HC7 / firmware 7+ is `modern`. HC5 / firmware 6.x is `legacy`. |
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","model":"HC7"}');
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: `dist/tempivo-sensor-beacon.aar` after `npm run build:android`. Sources: `android-aar/library`. Request `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` (API 31+) or location on older APIs.
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)
@@ -4,14 +4,14 @@ plugins {
4
4
  }
5
5
 
6
6
  group = 'com.tempivo.sensor.beacon'
7
- version = '0.3.0'
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.0"
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
- def aarFile = file("libs/tempivo-sensor-beacon.aar")
28
- if (!aarFile.exists()) {
29
- throw new GradleException("Missing android/libs/tempivo-sensor-beacon.aar. From the package root run: npm run build:android")
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(alerts, decompileSchedule(cfg.ruleCalendars))
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 writeRules(session: Session, rules: List<Rule>, calendars: List<RuleCalendar>) {
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
- val next = base.copy(rules = rules.take(12), ruleCalendars = calendars.take(6))
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 ->
@@ -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] != frame) {
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] != fr) {
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
- val keys = lookupKeys(primaryMacKey)
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] != fr) {
169
+ if (!last04[key].contentEquals(fr)) {
128
170
  last04[key] = fr
129
171
  changed = true
130
172
  }
@@ -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 md =
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
- val changed =
100
- TempivoSensorBeaconDecoder.ingestManufacturerPayload(md, key, advLast03, advLast04)
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 serial = key.uppercase(Locale.US)
111
- val macColons = macWithColonsFromKey(serial)
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(serial),
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
- return TempivoSensorBeaconDevice(
122
- deviceId = addr,
123
- bluetoothMacAddress = macColons,
124
- serialNumber = serial,
125
- rssi = result.rssi,
126
- telemetry = telemetry,
127
- summary = summary,
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") || it.has("schedule") || it.has("slug")
56
+ it.has("temperatureAlerts") ||
57
+ it.has("schedule") ||
58
+ it.has("slug") ||
59
+ it.has("measurementIntervalMinutes") ||
60
+ it.has("transmissionIntervalSeconds")
51
61
  } ?: root
52
62
  if (!body.has("temperatureAlerts") && body.has("alarmRules")) {
53
63
  throw invalid("Use a config profile with temperatureAlerts (GET /devices/config-profiles/{slug}), not alarmRules.")
@@ -58,7 +68,13 @@ object TempivoSensorProfile {
58
68
  val item = arr.optJSONObject(i) ?: throw invalid("temperatureAlerts[$i] must be an object.")
59
69
  alerts.add(parseAlert(item, i))
60
70
  }
61
- return TempivoSensorConfiguration(alerts, parseSchedule(body.optJSONObject("schedule")))
71
+ val intervals = parseOptionalIntervals(body)
72
+ return TempivoSensorConfiguration(
73
+ alerts,
74
+ parseSchedule(body.optJSONObject("schedule")),
75
+ measurementIntervalMinutes = intervals.first,
76
+ transmissionIntervalSeconds = intervals.second,
77
+ )
62
78
  }
63
79
 
64
80
  fun toJson(cfg: TempivoSensorConfiguration): String {
@@ -90,9 +106,50 @@ object TempivoSensorProfile {
90
106
  s.put("utcOffsetMinutes", cfg.schedule.utcOffsetMinutes)
91
107
  }
92
108
  root.put("schedule", s)
109
+ cfg.measurementIntervalMinutes?.let { root.put("measurementIntervalMinutes", it) }
110
+ cfg.transmissionIntervalSeconds?.let { root.put("transmissionIntervalSeconds", it) }
93
111
  return root.toString()
94
112
  }
95
113
 
114
+ private fun parseOptionalIntervals(body: JSONObject): Pair<Double?, Int?> {
115
+ var measurement: Double? = null
116
+ var transmission: Int? = null
117
+ if (body.has("measurementIntervalMinutes") && !body.isNull("measurementIntervalMinutes")) {
118
+ val n = body.optDouble("measurementIntervalMinutes")
119
+ if (!n.isFinite() || n <= 0) {
120
+ throw invalid("measurementIntervalMinutes must be a positive number.")
121
+ }
122
+ if (n < MIN_MEASUREMENT_MINUTES) {
123
+ throw invalid("measurementIntervalMinutes must be at least $MIN_MEASUREMENT_MINUTES (1 hour).")
124
+ }
125
+ if (n > MAX_MEASUREMENT_MINUTES) {
126
+ throw invalid("measurementIntervalMinutes must be at most $MAX_MEASUREMENT_MINUTES.")
127
+ }
128
+ measurement = n
129
+ }
130
+ if (body.has("transmissionIntervalSeconds") && !body.isNull("transmissionIntervalSeconds")) {
131
+ val asDouble = body.optDouble("transmissionIntervalSeconds")
132
+ if (!asDouble.isFinite() || asDouble <= 0 || asDouble != kotlin.math.floor(asDouble)) {
133
+ throw invalid("transmissionIntervalSeconds must be a positive integer.")
134
+ }
135
+ val n = asDouble.toInt()
136
+ if (n < MIN_TRANSMISSION_SECONDS) {
137
+ throw invalid("transmissionIntervalSeconds must be at least $MIN_TRANSMISSION_SECONDS (1 hour).")
138
+ }
139
+ if (n > MAX_TRANSMISSION_SECONDS) {
140
+ throw invalid("transmissionIntervalSeconds must be at most $MAX_TRANSMISSION_SECONDS.")
141
+ }
142
+ transmission = n
143
+ }
144
+ if (measurement != null && transmission != null) {
145
+ val maxTx = measurement * 60.0 * 60.0
146
+ if (transmission > maxTx) {
147
+ throw invalid("transmissionIntervalSeconds must be at most 60 times the measurement interval.")
148
+ }
149
+ }
150
+ return measurement to transmission
151
+ }
152
+
96
153
  private fun parseAlert(item: JSONObject, index: Int): TempivoTemperatureAlert {
97
154
  val channel = item.optString("channel")
98
155
  if (channel != "ambient" && channel != "probe") {
@@ -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.writeRules(requireSession(), rules, calendars)
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';
@@ -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`. HC5 / FW 6.x still select legacy. */
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":"…","model":"HC7"}`.
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`. HC5 / FW 6.x still select legacy. */
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":"…","model":"HC7"}`.
52
+ * Parse sensor label QR JSON: `{"sn":"…","pin":"…"}`.
53
53
  */
54
54
  export function parseSensorQrJson(text) {
55
55
  let rec;
@@ -39,17 +39,22 @@ export type TempivoScheduleWeek = {
39
39
  };
40
40
  export type TempivoSchedule = TempivoScheduleAlways | TempivoScheduleWeek;
41
41
  /**
42
- * Same JSON as Cellular API `POST /api/v1/devices/config-profiles`
43
- * (`temperatureAlerts` + `schedule`). Extra profile fields (`slug`, `name`, intervals) are ignored over BLE.
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
- /** Sticker model. Omitted QR `model` parses as HC7. */
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
@@ -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 { return false }
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 keys = lookupKeys(primaryMacKey: primaryMacKey)
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 || nested["schedule"] != nil || nested["slug"] != 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
- return TempivoSensorConfiguration(temperatureAlerts: alerts, schedule: try parseSchedule(body["schedule"] as? [String: Any]))
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
- let root: [String: Any] = ["temperatureAlerts": alerts, "schedule": schedule]
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(temperatureAlerts: [TempivoTemperatureAlert], schedule: TempivoSchedule) {
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.0",
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",