@tempivo/sensor-beacon 1.1.4 → 1.1.6

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 (29) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +13 -8
  3. package/android/build.gradle +2 -2
  4. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/EfentoRawConfigTransfer.kt +149 -41
  5. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +7 -1
  6. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +3 -9
  7. package/dist/connect-helpers.d.ts +1 -1
  8. package/dist/connect-helpers.js +2 -2
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/native-bridge.d.ts +1 -0
  12. package/dist/native-bridge.js +3 -0
  13. package/dist/native-helpers.js +21 -2
  14. package/dist/native-module.d.ts +3 -0
  15. package/dist/native-module.js +17 -0
  16. package/dist/production-defaults.d.ts +18 -0
  17. package/dist/production-defaults.js +139 -0
  18. package/dist/proto/partner-to-proto.d.ts +20 -0
  19. package/dist/proto/partner-to-proto.js +74 -3
  20. package/dist/qr.d.ts +2 -0
  21. package/dist/qr.js +16 -3
  22. package/dist/react-native.d.ts +3 -13
  23. package/dist/react-native.js +2 -13
  24. package/dist/tempivo-sensor-beacon.aar +0 -0
  25. package/ios/Frameworks/TempivoSensorBridge.xcframework/Info.plist +4 -4
  26. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +1 -1
  27. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/TempivoSensorBridge +1 -1
  28. package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +8 -2
  29. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,7 +6,27 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- <!-- Publish trigger: 1.1.4 not on npm until CI Trusted Publisher (ci.yml) runs. -->
9
+ ## [1.1.6] - 2026-09-25
10
+
11
+ ### Changed
12
+
13
+ - Parsed sticker model **TS10** stays `TS10` on `TempivoSensorQr` (connect / native still maps to HC7).
14
+ - Production defaults check is looser: PLMN `1000000` and legacy `0xFFFFFFFF` as automatic, DEL-only APN user/password as unused, DNS `255.255.255.255` as automatic, any regional NB-IoT band mask, any non-empty firmware string.
15
+ - React Native entry re-exports native BLE helpers live from the native module (avoids a stale snapshot when Metro loads).
16
+ - Docs: prefer Cellular API for laboratory calibration date; BLE `getCalibration` is not the source of truth. Cloud field is not released yet and returns `null` for now. Examples no longer call `getCalibration()`.
17
+
18
+ ## [1.1.5] - 2026-09-08
19
+
20
+ ### Fixed
21
+
22
+ - Android raw ProtoConfig GATT: wait for write ACK before read (long writes at MTU 23 no longer race).
23
+ - Android: do not reject large set frames when the link stays at MTU 23; use ATT long write (`WRITE_TYPE_DEFAULT`).
24
+ - Android: request MTU 517 once after a short post-connect delay; never gate or crash on mismatched MTU.
25
+ - Partner BLE set: reject / strip cellular and server ProtoConfig fields so a rules write cannot wipe uplink settings.
26
+
27
+ ### Changed
28
+
29
+ - Native Android module version `0.4.5` (rebuild required for Expo / RN apps).
10
30
 
11
31
  ## [1.1.4] - 2026-09-07
12
32
 
package/README.md CHANGED
@@ -50,7 +50,6 @@ import {
50
50
  setConfigurationJson,
51
51
  getConfiguration,
52
52
  triggerTransmission,
53
- getCalibration,
54
53
  disconnect,
55
54
  } from '@tempivo/sensor-beacon';
56
55
 
@@ -82,7 +81,6 @@ try {
82
81
  // other queued Cellular fields can still downlink. To cancel the whole queue:
83
82
  // DELETE /api/v1/devices/{serial}/config (see docs/cellular-api.md).
84
83
  const applied = await getConfiguration();
85
- const cal = await getCalibration();
86
84
  } finally {
87
85
  await disconnect(); // required so advertising / startScan can see the sensor again
88
86
  }
@@ -109,7 +107,7 @@ The Expo config plugin sets **minSdk 26**, Android BLE permissions, and **`NSBlu
109
107
  | `connect` / `disconnect` | GATT session with parsed QR (`TempivoSensorQr`). |
110
108
  | `getConfiguration` / `setConfigurationJson` | Read / write alert rules, schedule, optional intervals. |
111
109
  | `triggerTransmission` | Ask firmware to uplink now; returns `{ ok }`. Cloud pending syncs after that uplink (or schedule/button), not from the BLE write alone. |
112
- | `getCalibration` | Laboratory calibration date from extended config. |
110
+ | `getCalibration` | Legacy BLE read of laboratory calibration date. Prefer the Cellular API (see below). |
113
111
 
114
112
  **Cloud queue:** BLE updates the sensor only. After uplink, Tempivo drops from Cellular `pendingConfig` only fields the device already matches; other queued fields can still downlink. Cancel the whole queue with `DELETE /api/v1/devices/{serial}/config` (see Tempivo Cellular API docs).
115
113
 
@@ -305,14 +303,14 @@ Use `parseSensorConfigurationJson` to validate before write. Use `parseSensorCon
305
303
  Sticker QR is JSON. Parse with `parseSensorQrJson`.
306
304
 
307
305
  ```json
308
- { "sn": "282C024F0012", "pin": "111111" }
306
+ { "sn": "282C024F0012", "pin": "111111", "model": "TS10" }
309
307
  ```
310
308
 
311
309
  | Field | Type | Meaning |
312
310
  |------|------|---------|
313
311
  | `serial` | string | Device serial, 12 hex chars, no colons. From QR `sn`. |
314
312
  | `pin` | string | Sticker PIN. Required on `connect`. Also accepted as QR `resetCode`. |
315
- | `model` | string | Optional. Defaults to **HC7**. **HC5** is rejected. |
313
+ | `model` | string | Optional. Defaults to **HC7**. **TS10** is Tempivo’s HC7 alias (normalized to HC7). **HC5** is rejected. |
316
314
  | `sessionType` | `modern` | Always modern GATT (HC7 / firmware 7+). |
317
315
  | `bluetoothMac` | string | BLE MAC with colons, derived from `serial` or scan row. |
318
316
  | `deviceId` | string | Optional. Platform BLE id from scan. Pass when connecting to a selected scan row. |
@@ -358,7 +356,16 @@ Filter manufacturer data on company id **0x026C**. Active scanning is on so scan
358
356
 
359
357
  `triggerTransmission()` returns `{ ok }`. `ok: false` means the firmware rejected the command; it is not thrown as an error.
360
358
 
361
- `getCalibration()` returns `{ laboratoryCalibrationDate, laboratoryCalibrationTimestamp }`. Date is `YYYY-MM-DD`, or null if unset.
359
+ ### Laboratory calibration date
360
+
361
+ Prefer **cloud to cloud**, not BLE:
362
+
363
+ - After the sensor is registered, use **`GET /api/v1/assets/{assetId}`** (`laboratoryCalibrationDate`, and `calibrationCertificate` when a PDF is linked).
364
+ - Download PDF: **`GET /api/v1/assets/{assetId}/calibration-certificate`**.
365
+
366
+ This cloud field is **not released yet**. Until Tempivo turns it on, the API returns **`null`** for `laboratoryCalibrationDate` (except sample / demo serials documented in the Cellular API). Do not use BLE for production calibration dates.
367
+
368
+ BLE `getCalibration()` exists for tooling only and is not shown in the examples above.
362
369
 
363
370
  ## Node / TypeScript helpers
364
371
 
@@ -403,7 +410,6 @@ try {
403
410
  session.setConfigurationJson(profileJson)
404
411
  val trigger = session.triggerTransmission()
405
412
  val applied = session.getConfiguration()
406
- val cal = session.getCalibration()
407
413
  } finally {
408
414
  // Without disconnect, the sensor will not advertise for startScan.
409
415
  session.disconnect()
@@ -429,7 +435,6 @@ defer { session.disconnect() } // advertising resumes after disconnect
429
435
  try session.setConfigurationJson(profileJson)
430
436
  _ = try session.triggerTransmission()
431
437
  let applied = try session.getConfiguration()
432
- let cal = try session.getCalibration()
433
438
  ```
434
439
 
435
440
  ```swift
@@ -4,14 +4,14 @@ plugins {
4
4
  }
5
5
 
6
6
  group = 'com.tempivo.sensor.beacon'
7
- version = '0.4.4'
7
+ version = '0.4.5'
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.4.4"
14
+ versionName "0.4.5"
15
15
  }
16
16
  compileOptions {
17
17
  sourceCompatibility JavaVersion.VERSION_17
@@ -12,7 +12,7 @@ import android.bluetooth.BluetoothProfile
12
12
  import android.content.Context
13
13
  import android.os.Build
14
14
  import android.os.Handler
15
- import android.os.Looper
15
+ import android.os.HandlerThread
16
16
  import java.util.UUID
17
17
  import java.util.concurrent.CountDownLatch
18
18
  import java.util.concurrent.TimeUnit
@@ -23,6 +23,11 @@ import java.util.concurrent.atomic.AtomicReference
23
23
  *
24
24
  * Mirrors iOS `EfentoRawConfigTransfer`: one persistent GATT link reused across commands.
25
25
  * Disconnect the vendor SDK session before calling.
26
+ *
27
+ * Important Android details:
28
+ * - GATT work runs on a dedicated [HandlerThread] (not the main looper). Blocking the main
29
+ * thread while posting `connectGatt` there deadlocks until timeout.
30
+ * - Service discovery starts on connect. Do not wait for `onMtuChanged` (often never fires).
26
31
  */
27
32
  internal object EfentoRawConfigTransfer {
28
33
  private val TRANSFER_SERVICE = UUID.fromString("6E20F700-6126-4FCD-99C1-EB8A182501A2")
@@ -47,7 +52,7 @@ internal object EfentoRawConfigTransfer {
47
52
  address: String,
48
53
  bluetoothMac: String,
49
54
  pin: String,
50
- timeoutMs: Long = 25_000L,
55
+ timeoutMs: Long = 40_000L,
51
56
  ): ByteArray =
52
57
  link.runCommand(
53
58
  context = context.applicationContext,
@@ -65,7 +70,7 @@ internal object EfentoRawConfigTransfer {
65
70
  bluetoothMac: String,
66
71
  pin: String,
67
72
  proto: ByteArray,
68
- timeoutMs: Long = 25_000L,
73
+ timeoutMs: Long = 40_000L,
69
74
  ) {
70
75
  link.runCommand(
71
76
  context = context.applicationContext,
@@ -83,7 +88,10 @@ internal object EfentoRawConfigTransfer {
83
88
 
84
89
  @SuppressLint("MissingPermission")
85
90
  private class Link {
86
- private val handler = Handler(Looper.getMainLooper())
91
+ /** Dedicated BLE looper so connectGatt/callbacks never depend on the app main thread. */
92
+ private val bleThread =
93
+ HandlerThread("tempivo-raw-config-ble").apply { start() }
94
+ private val handler = Handler(bleThread.looper)
87
95
  private val lock = Any()
88
96
 
89
97
  private var gatt: BluetoothGatt? = null
@@ -92,6 +100,10 @@ internal object EfentoRawConfigTransfer {
92
100
  private var usingTransfer = true
93
101
  private var discoveryStarted = false
94
102
  private var connectedAddress: String? = null
103
+ private var negotiatedMtu = 23
104
+ private var mtuRequested = false
105
+ private var awaitingMtu = false
106
+ private var mtuAttempts = 0
95
107
 
96
108
  private var bluetoothMac = ""
97
109
  private var pin = ""
@@ -116,13 +128,13 @@ internal object EfentoRawConfigTransfer {
116
128
  timeoutMs: Long,
117
129
  ): ByteArray {
118
130
  var last: Throwable? = null
119
- repeat(2) { attempt ->
131
+ repeat(3) { attempt ->
120
132
  try {
121
133
  return attemptCommand(context, address, bluetoothMac, pin, writeProto, timeoutMs)
122
134
  } catch (e: Throwable) {
123
135
  last = e
124
136
  close()
125
- if (attempt == 0) Thread.sleep(1_000)
137
+ if (attempt < 2) Thread.sleep(2_000L * (attempt + 1))
126
138
  }
127
139
  }
128
140
  throw last ?: TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Raw config transfer failed.")
@@ -138,6 +150,10 @@ internal object EfentoRawConfigTransfer {
138
150
  notifyChar = null
139
151
  discoveryStarted = false
140
152
  connectedAddress = null
153
+ negotiatedMtu = 23
154
+ mtuRequested = false
155
+ awaitingMtu = false
156
+ mtuAttempts = 0
141
157
  doneLatch = null
142
158
  resultRef.set(null)
143
159
  }
@@ -153,6 +169,7 @@ internal object EfentoRawConfigTransfer {
153
169
  ): ByteArray {
154
170
  val latch = CountDownLatch(1)
155
171
  val token: Long
172
+ val macKey = normalizeAddress(bluetoothMac.ifBlank { address })
156
173
  synchronized(lock) {
157
174
  token = System.nanoTime()
158
175
  commandToken = token
@@ -165,9 +182,18 @@ internal object EfentoRawConfigTransfer {
165
182
  finished = false
166
183
  doneLatch = latch
167
184
  resultRef.set(null)
185
+ mtuAttempts = 0
168
186
 
169
187
  val existing = gatt
170
- if (existing != null && connectedAddress.equals(address, ignoreCase = true)) {
188
+ val sameLink =
189
+ existing != null &&
190
+ connectedAddress != null &&
191
+ normalizeAddress(connectedAddress!!) == macKey &&
192
+ writeChar != null &&
193
+ notifyChar != null
194
+ if (sameLink) {
195
+ // Re-request MTU on reused links if a large write is pending.
196
+ mtuRequested = negotiatedMtu >= 100
171
197
  handler.post { advance() }
172
198
  } else {
173
199
  runCatching { existing?.disconnect() }
@@ -176,7 +202,10 @@ internal object EfentoRawConfigTransfer {
176
202
  writeChar = null
177
203
  notifyChar = null
178
204
  discoveryStarted = false
179
- connectedAddress = address
205
+ negotiatedMtu = 23
206
+ mtuRequested = false
207
+ awaitingMtu = false
208
+ mtuAttempts = 0
180
209
  val adapter = bluetoothAdapter(context)
181
210
  ?: throw TempivoSensorException(
182
211
  TempivoSensorException.Code.RUNTIME_UNAVAILABLE,
@@ -188,9 +217,11 @@ internal object EfentoRawConfigTransfer {
188
217
  "Bluetooth is not powered on.",
189
218
  )
190
219
  }
220
+ // Always connect with the public Bluetooth MAC (CRC + stack). Avoid opaque SDK ids.
221
+ val connectAddress = macKey
191
222
  val device =
192
223
  try {
193
- adapter.getRemoteDevice(normalizeAddress(address))
224
+ adapter.getRemoteDevice(connectAddress)
194
225
  } catch (e: IllegalArgumentException) {
195
226
  throw TempivoSensorException(
196
227
  TempivoSensorException.Code.CONNECT_FAILED,
@@ -198,6 +229,7 @@ internal object EfentoRawConfigTransfer {
198
229
  e,
199
230
  )
200
231
  }
232
+ connectedAddress = connectAddress
201
233
  handler.post {
202
234
  val g =
203
235
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -215,18 +247,49 @@ internal object EfentoRawConfigTransfer {
215
247
 
216
248
  val ok = latch.await(timeoutMs, TimeUnit.MILLISECONDS)
217
249
  if (!ok) {
218
- fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Raw config BLE timeout."))
250
+ fail(
251
+ TempivoSensorException(
252
+ TempivoSensorException.Code.UNKNOWN,
253
+ "Raw config BLE timeout (no GATT response).",
254
+ ),
255
+ )
219
256
  latch.await(500, TimeUnit.MILLISECONDS)
220
257
  }
221
258
  val result =
222
259
  resultRef.get()
223
- ?: throw TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Raw config BLE timeout.")
260
+ ?: throw TempivoSensorException(
261
+ TempivoSensorException.Code.UNKNOWN,
262
+ "Raw config BLE timeout (no GATT response).",
263
+ )
224
264
  return result.getOrThrow()
225
265
  }
226
266
 
267
+ private fun requestMtuThenAdvance(g: BluetoothGatt) {
268
+ // Kept for callers: at most one MTU request per connection (Android 14+ ignores more).
269
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && !mtuRequested) {
270
+ mtuRequested = true
271
+ awaitingMtu = true
272
+ val ok = runCatching { g.requestMtu(517) }.getOrDefault(false)
273
+ if (ok) {
274
+ handler.postDelayed({
275
+ synchronized(lock) {
276
+ if (!finished && awaitingMtu) {
277
+ awaitingMtu = false
278
+ advance()
279
+ }
280
+ }
281
+ }, 1_500)
282
+ return
283
+ }
284
+ awaitingMtu = false
285
+ }
286
+ advance()
287
+ }
288
+
227
289
  private fun advance() {
228
290
  synchronized(lock) {
229
291
  if (finished) return
292
+ if (awaitingMtu) return
230
293
  val g = gatt ?: return
231
294
  val w = writeChar
232
295
  val n = notifyChar
@@ -259,21 +322,10 @@ internal object EfentoRawConfigTransfer {
259
322
  ByteArray(0)
260
323
  }
261
324
  val frame = buildCommandFrame(bluetoothMac, cmd, payload)
262
- val max = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
263
- // Typical ATT payload after MTU negotiation; fall back conservatively.
264
- 512
265
- } else {
266
- 20
267
- }
268
- if (frame.size > max) {
269
- fail(
270
- TempivoSensorException(
271
- TempivoSensorException.Code.INVALID_CONFIG,
272
- "Config frame is ${frame.size} B but this link accepts $max B per write.",
273
- ),
274
- )
275
- return
276
- }
325
+ // Never gate on negotiatedMtu. This HC7 answers MTU 23 on Android (even when we
326
+ // request 517; Android 14+ also ignores later requestMtu on the same link).
327
+ // WRITE_TYPE_DEFAULT lets the stack use ATT Prepare Write / Execute (long write)
328
+ // for frames larger than ATT payload, which is what set (0x0D) needs (~237 B).
277
329
  write.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
278
330
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
279
331
  g.writeCharacteristic(write, frame, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
@@ -283,7 +335,13 @@ internal object EfentoRawConfigTransfer {
283
335
  @Suppress("DEPRECATION")
284
336
  g.writeCharacteristic(write)
285
337
  }
338
+ // Do not read yet: long writes (237 B at MTU 23) finish asynchronously.
339
+ // onCharacteristicWrite schedules the follow-up read / assemble.
340
+ }
341
+
342
+ private fun afterCommandWriteAck() {
286
343
  val token = commandToken
344
+ val delayMs = if (writeProto != null) 200L else 100L
287
345
  handler.postDelayed({
288
346
  synchronized(lock) {
289
347
  if (finished || commandToken != token) return@synchronized
@@ -296,19 +354,19 @@ internal object EfentoRawConfigTransfer {
296
354
  gg.readCharacteristic(w)
297
355
  }
298
356
  }
299
- }, 350)
357
+ }, delayMs)
300
358
  if (writeProto != null) {
301
359
  handler.postDelayed({
302
360
  synchronized(lock) {
303
361
  if (commandToken == token) finishWriteIfReady()
304
362
  }
305
- }, 800)
363
+ }, 1_500)
306
364
  } else {
307
365
  handler.postDelayed({
308
366
  synchronized(lock) {
309
367
  if (commandToken == token) tryAssemble()
310
368
  }
311
- }, 1_000)
369
+ }, 1_200)
312
370
  }
313
371
  }
314
372
 
@@ -330,26 +388,63 @@ internal object EfentoRawConfigTransfer {
330
388
  object : BluetoothGattCallback() {
331
389
  override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
332
390
  if (newState == BluetoothProfile.STATE_CONNECTED) {
391
+ synchronized(lock) {
392
+ writeChar = null
393
+ notifyChar = null
394
+ discoveryStarted = false
395
+ negotiatedMtu = 23
396
+ mtuRequested = false
397
+ awaitingMtu = false
398
+ }
333
399
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
334
- g.requestMtu(517)
335
- } else {
336
- synchronized(lock) {
337
- writeChar = null
338
- notifyChar = null
339
- discoveryStarted = false
400
+ runCatching {
401
+ g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
340
402
  }
341
- advance()
342
403
  }
404
+ // Delay after connect before the first (and only) requestMtu so the host
405
+ // controller can stabilize the ACL link (typical guidance: 300–600 ms).
406
+ // Android 14+ often applies ~517 on that first request and ignores later ones.
407
+ // Peripheral may still answer 23; set then relies on ATT long write, not a
408
+ // hardcoded 20/185 B gate. Max single ATT PDU is negotiatedMtu - 3 (≤514).
409
+ awaitingMtu = true
410
+ handler.postDelayed({
411
+ synchronized(lock) {
412
+ if (finished || gatt !== g) return@synchronized
413
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && !mtuRequested) {
414
+ mtuRequested = true
415
+ val ok = runCatching { g.requestMtu(517) }.getOrDefault(false)
416
+ if (ok) {
417
+ handler.postDelayed({
418
+ synchronized(lock) {
419
+ if (!finished && awaitingMtu) {
420
+ awaitingMtu = false
421
+ advance()
422
+ }
423
+ }
424
+ }, 1_500)
425
+ return@synchronized
426
+ }
427
+ }
428
+ awaitingMtu = false
429
+ advance()
430
+ }
431
+ }, 600)
343
432
  } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
344
433
  synchronized(lock) {
345
434
  writeChar = null
346
435
  notifyChar = null
347
436
  discoveryStarted = false
348
437
  if (!finished) {
438
+ val msg =
439
+ if (status == 133) {
440
+ "Config GATT connect failed (status 133). Retrying."
441
+ } else {
442
+ "Sensor dropped the config link."
443
+ }
349
444
  fail(
350
445
  TempivoSensorException(
351
446
  TempivoSensorException.Code.CONNECT_FAILED,
352
- "Sensor dropped the config link.",
447
+ msg,
353
448
  ),
354
449
  )
355
450
  }
@@ -359,10 +454,12 @@ internal object EfentoRawConfigTransfer {
359
454
 
360
455
  override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
361
456
  synchronized(lock) {
362
- writeChar = null
363
- notifyChar = null
364
- discoveryStarted = false
457
+ if (status == BluetoothGatt.GATT_SUCCESS && mtu > 0) {
458
+ negotiatedMtu = mtu
459
+ }
460
+ awaitingMtu = false
365
461
  }
462
+ // Start or continue discovery / command after MTU.
366
463
  advance()
367
464
  }
368
465
 
@@ -402,6 +499,12 @@ internal object EfentoRawConfigTransfer {
402
499
  notifyChar = notify
403
500
  }
404
501
  g.setCharacteristicNotification(notify, true)
502
+ // Write char may also notify the start response on some FW.
503
+ if ((write.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
504
+ (write.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0
505
+ ) {
506
+ g.setCharacteristicNotification(write, true)
507
+ }
405
508
  val cccd = notify.getDescriptor(CCCD)
406
509
  if (cccd != null) {
407
510
  val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@@ -427,6 +530,8 @@ internal object EfentoRawConfigTransfer {
427
530
  fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Notify enable failed."))
428
531
  return
429
532
  }
533
+ // Do not call requestMtu again here. Android 14+ ignores subsequent MTU
534
+ // requests on the same connection; HC7 often stays at 23 anyway.
430
535
  advance()
431
536
  }
432
537
 
@@ -437,7 +542,9 @@ internal object EfentoRawConfigTransfer {
437
542
  ) {
438
543
  if (status != BluetoothGatt.GATT_SUCCESS) {
439
544
  fail(TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Config write failed ($status)."))
545
+ return
440
546
  }
547
+ afterCommandWriteAck()
441
548
  }
442
549
 
443
550
  @Deprecated("Deprecated in Java")
@@ -486,7 +593,8 @@ internal object EfentoRawConfigTransfer {
486
593
  if (finished || value.isEmpty()) return
487
594
  val writeUuid = writeChar?.uuid
488
595
  val notifyUuid = notifyChar?.uuid
489
- if (uuid == writeUuid && !fromNotify) {
596
+ // iOS accepts start response on the write characteristic for both read and notify.
597
+ if (uuid == writeUuid) {
490
598
  startResponse = value
491
599
  if (writeProto != null) finishWriteIfReady()
492
600
  return
@@ -40,6 +40,12 @@ object TempivoSensorQrParser {
40
40
  return bluetoothMacFromSerial(trimmed)
41
41
  }
42
42
 
43
+ fun normalizeModel(model: String): String {
44
+ val m = model.trim().uppercase(Locale.US).replace("-", "")
45
+ if (m.isEmpty() || m == "HC7" || m == "TS10") return DEFAULT_MODEL
46
+ return model.trim()
47
+ }
48
+
43
49
  fun assertSupportedModel(model: String) {
44
50
  val m = model.trim().uppercase(Locale.US).replace("-", "")
45
51
  if (m == "HC5") {
@@ -70,7 +76,7 @@ object TempivoSensorQrParser {
70
76
  if (pin.isEmpty()) {
71
77
  throw TempivoSensorException(TempivoSensorException.Code.INVALID_QR, "QR is missing PIN.")
72
78
  }
73
- val model = rec.optString("model").trim().ifEmpty { DEFAULT_MODEL }
79
+ val model = normalizeModel(rec.optString("model"))
74
80
  assertSupportedModel(model)
75
81
  val hex = if (serial.length == 12) serial else serial.takeLast(12)
76
82
  val macOverride =
@@ -202,14 +202,8 @@ class TempivoSensorSession(
202
202
  if (lastMac.isEmpty() || lastPin.isEmpty()) {
203
203
  throw TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected. Scan and connect first.")
204
204
  }
205
- // Prefer scan/SDK device id when it looks like a BLE address; else MAC.
206
- val id = lastDeviceId.trim()
207
- val idHex = id.replace(":", "").replace("-", "")
208
- return if (idHex.length == 12 && idHex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
209
- id
210
- } else {
211
- lastMac
212
- }
205
+ // Always the public Bluetooth MAC for raw GATT (CRC + connectGatt).
206
+ return lastMac
213
207
  }
214
208
 
215
209
  private suspend fun <T> withRawConfigLink(body: suspend (String) -> T): T {
@@ -230,7 +224,7 @@ class TempivoSensorSession(
230
224
  session = null
231
225
  sdkConnected = false
232
226
  }
233
- if (didDisconnect) delay(1_000)
227
+ if (didDisconnect) delay(1_500)
234
228
  }
235
229
  }
236
230
 
@@ -26,7 +26,7 @@ export declare function serialHexFromSensorQrScan(data: string | {
26
26
  * False when both are 12-hex and differ (wrong sticker for selected device).
27
27
  */
28
28
  export declare function sensorQrSerialMatchesSelected(qrSerialHex: string | null | undefined, selectedSerial: string | null | undefined): boolean;
29
- /** Normalize sticker `model` to HC5/HC7, or null if missing/unknown. */
29
+ /** Normalize sticker `model` to HC5/HC7, or null if missing/unknown. TS10 is Tempivo’s HC7 alias. */
30
30
  export declare function normalizeSensorStickerModel(model: string | undefined | null): SensorStickerModel | null;
31
31
  /**
32
32
  * Map label PIN to native connect credentials (resetCode + encryptionKey for 5–6 digit codes).
@@ -64,14 +64,14 @@ export function sensorQrSerialMatchesSelected(qrSerialHex, selectedSerial) {
64
64
  return true;
65
65
  return qr === sel;
66
66
  }
67
- /** Normalize sticker `model` to HC5/HC7, or null if missing/unknown. */
67
+ /** Normalize sticker `model` to HC5/HC7, or null if missing/unknown. TS10 is Tempivo’s HC7 alias. */
68
68
  export function normalizeSensorStickerModel(model) {
69
69
  if (model == null || model.trim() === '')
70
70
  return null;
71
71
  const m = model.trim().toUpperCase().replace(/-/g, '');
72
72
  if (m === 'HC5')
73
73
  return 'HC5';
74
- if (m === 'HC7')
74
+ if (m === 'HC7' || m === 'TS10')
75
75
  return 'HC7';
76
76
  return null;
77
77
  }
package/dist/index.d.ts CHANGED
@@ -12,5 +12,5 @@ export type { ParsedSensorQrLoose, SensorBleConnectCredentials, } from './connec
12
12
  export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } from './calibration.js';
13
13
  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, parseSensorConfigurationFromDevice, parseSensorConfigurationFromDeviceJson, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
14
14
  export type { TempivoSchedule, TempivoScheduleAlways, TempivoScheduleWeek, TempivoSensorCalibration, TempivoSensorConfiguration, TempivoSensorQr, TempivoSensorSessionType, TempivoTemperatureAlert, TempivoTemperatureChannel, TempivoTemperatureMaxAlert, TempivoTemperatureMinAlert, TempivoTemperatureRangeAlert, TempivoTriggerTransmissionResult, } from './session-types.js';
15
- export { addDeviceFoundListener, connect, disconnect, getCalibration, getConfiguration, isNativeSensorBeaconAvailable, isScanning, requestPermissions, setConfigurationJson, startScan, stopScan, triggerTransmission, } from './native-bridge.js';
15
+ export { addDeviceFoundListener, connect, disconnect, getCalibration, getConfiguration, getProtoConfiguration, isNativeSensorBeaconAvailable, isScanning, requestPermissions, setConfigurationJson, startScan, stopScan, triggerTransmission, } from './native-bridge.js';
16
16
  export type { TempivoDeviceFoundSubscription, TempivoNativeCalibration, TempivoNativeMeasurement, TempivoNativePermissionResponse, TempivoNativePermissionStatus, TempivoNativeQr, TempivoNativeTelemetry, TempivoSensorBeaconDevice, } from './native-types.js';
package/dist/index.js CHANGED
@@ -9,4 +9,4 @@ export { isMissingAdvertisementFirmware, sensorModelFromFirmware, } from './mode
9
9
  export { buildSensorQrConnectJson, draftSensorQrConnectJsonFromScanDevice, formatSensorReadingLine, mergeScanTargetIntoSensorQr, normalizeSensorStickerModel, parseSensorQrForConnect, pinFromSensorQrRecord, resolveSensorBleConnectCredentialsFromPin, sensorQrConnectJsonFromLoose, sensorQrSerialMatchesSelected, serialHexFromSensorQrScan, tryParseSensorQrJson, } from './connect-helpers.js';
10
10
  export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } from './calibration.js';
11
11
  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, parseSensorConfigurationFromDevice, parseSensorConfigurationFromDeviceJson, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
12
- export { addDeviceFoundListener, connect, disconnect, getCalibration, getConfiguration, isNativeSensorBeaconAvailable, isScanning, requestPermissions, setConfigurationJson, startScan, stopScan, triggerTransmission, } from './native-bridge.js';
12
+ export { addDeviceFoundListener, connect, disconnect, getCalibration, getConfiguration, getProtoConfiguration, isNativeSensorBeaconAvailable, isScanning, requestPermissions, setConfigurationJson, startScan, stopScan, triggerTransmission, } from './native-bridge.js';
@@ -12,3 +12,4 @@ export declare function getConfiguration(): Promise<TempivoSensorConfiguration>;
12
12
  export declare function setConfigurationJson(config: string | TempivoSensorConfiguration): Promise<void>;
13
13
  export declare function triggerTransmission(): Promise<TempivoTriggerTransmissionResult>;
14
14
  export declare function getCalibration(): Promise<TempivoNativeCalibration>;
15
+ export declare function getProtoConfiguration(): Promise<Record<string, unknown>>;
@@ -49,3 +49,6 @@ export async function triggerTransmission() {
49
49
  export async function getCalibration() {
50
50
  return (await loadNative()).getCalibration();
51
51
  }
52
+ export async function getProtoConfiguration() {
53
+ return (await loadNative()).getProtoConfiguration();
54
+ }
@@ -1,5 +1,6 @@
1
1
  import { TempivoSensorError } from './errors.js';
2
2
  import { pickPartnerConfigurationJson } from './profile.js';
3
+ import { normalizeQrModelField } from './qr.js';
3
4
  const SENSOR_CODES = [
4
5
  'invalidPin',
5
6
  'invalidQr',
@@ -10,13 +11,31 @@ const SENSOR_CODES = [
10
11
  'connectFailed',
11
12
  'unknown',
12
13
  ];
14
+ function rewriteStickerModel(json) {
15
+ try {
16
+ const parsed = JSON.parse(json);
17
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
18
+ return json;
19
+ const rec = parsed;
20
+ if (typeof rec.model !== 'string')
21
+ return json;
22
+ const next = normalizeQrModelField(rec.model);
23
+ if (next === rec.model)
24
+ return json;
25
+ rec.model = next;
26
+ return JSON.stringify(rec);
27
+ }
28
+ catch {
29
+ return json;
30
+ }
31
+ }
13
32
  export function qrToConnectJson(qr) {
14
33
  if (typeof qr === 'string')
15
- return qr;
34
+ return rewriteStickerModel(qr);
16
35
  const body = {
17
36
  sn: qr.serial,
18
37
  pin: qr.pin,
19
- model: qr.model ?? 'HC7',
38
+ model: normalizeQrModelField(qr.model ?? 'HC7'),
20
39
  };
21
40
  if (qr.deviceId)
22
41
  body.deviceId = qr.deviceId;
@@ -1,4 +1,5 @@
1
1
  import type { TempivoDeviceFoundSubscription, TempivoNativeCalibration, TempivoNativePermissionResponse, TempivoNativeQr, TempivoSensorBeaconDevice } from './native-types.js';
2
+ import { type ProtoConfigObject } from './proto/decode-proto-config.js';
2
3
  import type { TempivoSensorConfiguration, TempivoSensorQr, TempivoTriggerTransmissionResult } from './session-types.js';
3
4
  export declare function isNativeSensorBeaconAvailable(): boolean;
4
5
  export declare function requestPermissions(): Promise<TempivoNativePermissionResponse>;
@@ -21,4 +22,6 @@ export declare function getConfiguration(): Promise<TempivoSensorConfiguration>;
21
22
  */
22
23
  export declare function setConfigurationJson(config: string | TempivoSensorConfiguration): Promise<void>;
23
24
  export declare function triggerTransmission(): Promise<TempivoTriggerTransmissionResult>;
25
+ /** Decoded device ProtoConfig (server, APN, intervals). Requires an open BLE session. */
26
+ export declare function getProtoConfiguration(): Promise<ProtoConfigObject>;
24
27
  export declare function getCalibration(): Promise<TempivoNativeCalibration>;
@@ -1,6 +1,7 @@
1
1
  import { requireOptionalNativeModule } from 'expo-modules-core';
2
2
  import { TempivoSensorError } from './errors.js';
3
3
  import { configToJson, mapNativeError, qrToConnectJson } from './native-helpers.js';
4
+ import { decodeProtoConfigBase64 } from './proto/decode-proto-config.js';
4
5
  import { partnerConfigFromProtoBase64 } from './proto/partner-from-proto.js';
5
6
  import { partnerConfigToProtoBase64 } from './proto/partner-to-proto.js';
6
7
  import { parseSensorConfigurationFromDevice } from './profile.js';
@@ -141,6 +142,22 @@ export async function triggerTransmission() {
141
142
  throw mapNativeError(error);
142
143
  }
143
144
  }
145
+ /** Decoded device ProtoConfig (server, APN, intervals). Requires an open BLE session. */
146
+ export async function getProtoConfiguration() {
147
+ try {
148
+ const json = await getNative().getConfigurationJson();
149
+ const parsed = JSON.parse(json);
150
+ const protoB64 = typeof parsed.protoBase64 === 'string' ? parsed.protoBase64 : null;
151
+ if (!protoB64) {
152
+ throw new TempivoSensorError('unknown', 'Device config did not include a proto frame.');
153
+ }
154
+ lastProtoBase64 = protoB64;
155
+ return decodeProtoConfigBase64(protoB64);
156
+ }
157
+ catch (error) {
158
+ throw mapNativeError(error);
159
+ }
160
+ }
144
161
  export async function getCalibration() {
145
162
  try {
146
163
  return await getNative().getCalibration();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Factory software defaults from the NB-IoT default-settings sheet (sw 7.02.xx).
3
+ * Compared against a decoded ProtoConfig. Hardware packaging is not on the radio.
4
+ */
5
+ export type ProductionCheckLine = {
6
+ ok: boolean;
7
+ name: string;
8
+ expected: string;
9
+ actual: string;
10
+ };
11
+ export type ProductionCheckResult = {
12
+ pass: boolean;
13
+ lines: ProductionCheckLine[];
14
+ };
15
+ export declare function checkProductionDefaults(proto: Record<string, unknown>, extra?: {
16
+ firmware?: string | null;
17
+ hasLiveReading?: boolean | null;
18
+ }): ProductionCheckResult;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Factory software defaults from the NB-IoT default-settings sheet (sw 7.02.xx).
3
+ * Compared against a decoded ProtoConfig. Hardware packaging is not on the radio.
4
+ */
5
+ const U32_MAX = 0xffffffff;
6
+ /** Current automatic PLMN. 0xFFFFFFFF is the legacy automatic value. */
7
+ const PLMN_AUTOMATIC = 1_000_000;
8
+ /** Single DEL byte: username/password not used. */
9
+ const DEL = '\u007f';
10
+ function u32(value) {
11
+ if (typeof value === 'bigint') {
12
+ const n = Number(value);
13
+ return Number.isFinite(n) ? n >>> 0 : null;
14
+ }
15
+ if (typeof value !== 'number' || !Number.isFinite(value))
16
+ return null;
17
+ return value < 0 ? value >>> 0 : value;
18
+ }
19
+ function line(name, expected, actual, ok) {
20
+ return { ok, name, expected, actual };
21
+ }
22
+ function showCredential(value) {
23
+ if (typeof value === 'string' && isDelOnly(value))
24
+ return 'not used';
25
+ return show(value);
26
+ }
27
+ function show(value) {
28
+ if (value == null)
29
+ return 'missing';
30
+ if (typeof value === 'string')
31
+ return value === '' ? 'empty' : value;
32
+ if (typeof value === 'number')
33
+ return String(value);
34
+ if (Array.isArray(value))
35
+ return value.length === 0 ? 'empty' : JSON.stringify(value);
36
+ return JSON.stringify(value);
37
+ }
38
+ function eqU32(actual, expected) {
39
+ const n = u32(actual);
40
+ return n != null && n === (expected >>> 0);
41
+ }
42
+ function eqStr(actual, expected) {
43
+ return typeof actual === 'string' && actual.trim().toLowerCase() === expected.toLowerCase();
44
+ }
45
+ function isBlankOrNa(value) {
46
+ if (value == null)
47
+ return true;
48
+ if (typeof value !== 'string')
49
+ return false;
50
+ const s = value.trim();
51
+ return s === '' || s.toUpperCase() === 'N/A' || /^0+$/.test(s) || isDelOnly(s);
52
+ }
53
+ function isDelOnly(value) {
54
+ return value.length > 0 && [...value].every((ch) => ch === DEL);
55
+ }
56
+ function plmnAutomatic(value) {
57
+ const n = u32(value);
58
+ return n === U32_MAX || n === PLMN_AUTOMATIC;
59
+ }
60
+ function dnsAutomatic(value) {
61
+ if (value == null)
62
+ return true;
63
+ if (Array.isArray(value)) {
64
+ if (value.length === 0)
65
+ return true;
66
+ if (value.every((n) => u32(n) === 0))
67
+ return true;
68
+ return value.length === 4 && value.every((n) => u32(n) === 255);
69
+ }
70
+ return u32(value) === 0 || u32(value) === 0xffffffff;
71
+ }
72
+ function rulesOff(rules) {
73
+ if (rules == null)
74
+ return true;
75
+ if (!Array.isArray(rules))
76
+ return false;
77
+ return rules.every((rule) => {
78
+ if (rule == null || typeof rule !== 'object')
79
+ return false;
80
+ const condition = rule.condition;
81
+ if (condition == null || condition === 0 || condition === 1)
82
+ return true;
83
+ const name = String(condition).toUpperCase();
84
+ return name === 'CONDITION_DISABLED' || name === 'CONDITION_UNSPECIFIED' || name === 'DISABLED';
85
+ });
86
+ }
87
+ function firmwareOk(firmware) {
88
+ return firmware.trim().length > 0;
89
+ }
90
+ export function checkProductionDefaults(proto, extra) {
91
+ const lines = [];
92
+ const pushNum = (name, key, expected, label = String(expected)) => {
93
+ const actual = proto[key];
94
+ lines.push(line(name, label, show(u32(actual) ?? actual), eqU32(actual, expected)));
95
+ };
96
+ const pushStr = (name, key, expected) => {
97
+ const actual = proto[key];
98
+ lines.push(line(name, expected, show(actual), eqStr(actual, expected)));
99
+ };
100
+ pushNum('Measurement period base', 'measurementPeriodBase', 300);
101
+ pushNum('Measurement period factor', 'measurementPeriodFactor', 1);
102
+ pushNum('BLE turn off', 'bleTurnoffTime', U32_MAX, 'never');
103
+ pushNum('BLE on network issues', 'networkTroubleshooting', 1, 'DISABLE');
104
+ pushStr('APN', 'apn', 'iot.1nce.net');
105
+ lines.push(line('APN user', 'N/A', showCredential(proto.apnUsername), isBlankOrNa(proto.apnUsername)));
106
+ lines.push(line('APN password', 'N/A', showCredential(proto.apnPassword), isBlankOrNa(proto.apnPassword)));
107
+ lines.push(line('PLMN', 'Automatic', show(u32(proto.plmnSelection) ?? proto.plmnSelection), plmnAutomatic(proto.plmnSelection)));
108
+ {
109
+ const mask = u32(proto.modemBandsMask);
110
+ lines.push(line('NB-IoT bands', 'regional', mask == null ? 'missing' : `0x${mask.toString(16)}`, mask != null));
111
+ }
112
+ pushNum('Supervision period', 'supervisionPeriod', 86400);
113
+ pushNum('Transmission interval', 'transmissionInterval', 10800);
114
+ pushNum('Transfer limit', 'transferLimit', 65535, 'Disabled');
115
+ pushNum('Transfer limit period', 'transferLimitTimer', 65535, 'Disabled');
116
+ pushNum('ACK interval', 'ackInterval', U32_MAX, 'ALWAYS');
117
+ pushNum('Cloud token mode', 'cloudTokenConfig', 2, 'IMEI');
118
+ lines.push(line('Cloud token value', 'zeros', show(proto.cloudToken), isBlankOrNa(proto.cloudToken)));
119
+ pushStr('Server', 'dataServerIp', 'assets.tempivo.com');
120
+ pushNum('Server port', 'dataServerPort', 5683);
121
+ pushStr('Measurements endpoint', 'dataEndpoint', '/m');
122
+ pushStr('Configuration endpoint', 'configurationEndpoint', '/c');
123
+ pushStr('Device info endpoint', 'deviceInfoEndpoint', '/i');
124
+ pushStr('Time endpoint', 'timeEndpoint', '/t');
125
+ pushStr('Extended configuration endpoint', 'extendedConfigurationEndpoint', '/e');
126
+ lines.push(line('DNS server', 'Automatic', show(proto.dnsServerIp), dnsAutomatic(proto.dnsServerIp)));
127
+ pushNum('DNS TTL', 'dnsTtlConfig', 864002, 'refresh if communication fails');
128
+ pushStr('Update server', 'updateServerIp', 'nbiot.efento.io');
129
+ pushNum('Update UDP port', 'updateServerPortUdp', 50000);
130
+ pushNum('Update CoAP port', 'updateServerPortCoap', 5680);
131
+ lines.push(line('Alarm rules', 'OFF', Array.isArray(proto.rules) ? `${proto.rules.length} rules` : 'none', rulesOff(proto.rules)));
132
+ if (extra?.firmware != null && extra.firmware.trim() !== '') {
133
+ lines.push(line('Firmware', 'any', extra.firmware.trim(), firmwareOk(extra.firmware)));
134
+ }
135
+ if (extra?.hasLiveReading != null) {
136
+ lines.push(line('Live measurement', 'temperature or humidity', extra.hasLiveReading ? 'seen' : 'none', extra.hasLiveReading));
137
+ }
138
+ return { pass: lines.every((row) => row.ok), lines };
139
+ }
@@ -4,6 +4,26 @@
4
4
  */
5
5
  import type { TempivoSensorConfiguration } from '../session-types.js';
6
6
  import { type ProtoConfigObject } from './decode-proto-config.js';
7
+ /**
8
+ * Fields carried from the device baseline into a set (0x0D).
9
+ *
10
+ * A device read returns the full config, including server endpoints, APN, cloud token and
11
+ * telemetry. Echoing all of that back exceeds the single-write ATT limit (~512 B) and would
12
+ * rewrite cellular settings we never intended to touch, so only rules-related fields are kept.
13
+ */
14
+ export declare const WRITE_BASELINE_FIELDS: readonly ["serialNumber", "channelTypes", "measurementPeriodBase", "measurementPeriodFactor", "transmissionInterval"];
15
+ /**
16
+ * ProtoConfig keys that must never appear on a partner alarm-rule write.
17
+ * Writing these can break cellular uplink (device looks "offline" in Tempivo).
18
+ */
19
+ export declare const FORBIDDEN_PARTNER_WRITE_FIELDS: readonly ["dataServerIp", "dataServerPort", "updateServerIp", "updateServerPortUdp", "updateServerPortCoap", "apn", "apnUsername", "apnPassword", "cloudToken", "cloudTokenConfig", "cloudTokenCoapOption", "dataEndpoint", "configurationEndpoint", "deviceInfoEndpoint", "timeEndpoint", "extendedConfigurationEndpoint", "dnsServerIp", "networkKey", "encryptionKey", "plmnSelection", "modemBandsMask", "disableModemRequest", "cellularConfigParams", "networkSearch", "networkTroubleshooting"];
20
+ /**
21
+ * Fail closed if a ProtoConfig object still carries cellular / server fields.
22
+ * Call before encoding a partner set (0x0D).
23
+ */
24
+ export declare function assertSafePartnerProtoWrite(cfg: ProtoConfigObject, options?: {
25
+ allowExtraKeys?: boolean;
26
+ }): void;
7
27
  /** Patch partner alerts/schedule/intervals onto a baseline ProtoConfig object. */
8
28
  export declare function applyPartnerConfigToProtoObject(baseline: ProtoConfigObject, partner: TempivoSensorConfiguration): ProtoConfigObject;
9
29
  export declare function encodeProtoConfigObject(cfg: ProtoConfigObject): Uint8Array;
@@ -1,4 +1,4 @@
1
- import { decodeProtoConfigBase64 } from './decode-proto-config.js';
1
+ import { decodeProtoConfigBase64, decodeProtoConfigBytes, } from './decode-proto-config.js';
2
2
  import protobuf from 'protobufjs/light.js';
3
3
  import descriptor from './efento-protobuf-descriptor.js';
4
4
  const root = protobuf.Root.fromJSON(descriptor);
@@ -150,13 +150,80 @@ function disabledCalendars() {
150
150
  * telemetry. Echoing all of that back exceeds the single-write ATT limit (~512 B) and would
151
151
  * rewrite cellular settings we never intended to touch, so only rules-related fields are kept.
152
152
  */
153
- const WRITE_BASELINE_FIELDS = [
153
+ export const WRITE_BASELINE_FIELDS = [
154
154
  'serialNumber',
155
155
  'channelTypes',
156
156
  'measurementPeriodBase',
157
157
  'measurementPeriodFactor',
158
158
  'transmissionInterval',
159
159
  ];
160
+ /**
161
+ * ProtoConfig keys that must never appear on a partner alarm-rule write.
162
+ * Writing these can break cellular uplink (device looks "offline" in Tempivo).
163
+ */
164
+ export const FORBIDDEN_PARTNER_WRITE_FIELDS = [
165
+ 'dataServerIp',
166
+ 'dataServerPort',
167
+ 'updateServerIp',
168
+ 'updateServerPortUdp',
169
+ 'updateServerPortCoap',
170
+ 'apn',
171
+ 'apnUsername',
172
+ 'apnPassword',
173
+ 'cloudToken',
174
+ 'cloudTokenConfig',
175
+ 'cloudTokenCoapOption',
176
+ 'dataEndpoint',
177
+ 'configurationEndpoint',
178
+ 'deviceInfoEndpoint',
179
+ 'timeEndpoint',
180
+ 'extendedConfigurationEndpoint',
181
+ 'dnsServerIp',
182
+ 'networkKey',
183
+ 'encryptionKey',
184
+ 'plmnSelection',
185
+ 'modemBandsMask',
186
+ 'disableModemRequest',
187
+ 'cellularConfigParams',
188
+ 'networkSearch',
189
+ 'networkTroubleshooting',
190
+ ];
191
+ const ALLOWED_WRITE_KEYS = new Set([
192
+ ...WRITE_BASELINE_FIELDS,
193
+ 'rules',
194
+ 'calendars',
195
+ ]);
196
+ function isPresentProtoValue(value) {
197
+ if (value == null)
198
+ return false;
199
+ if (typeof value === 'string')
200
+ return value.length > 0;
201
+ if (Array.isArray(value))
202
+ return value.length > 0;
203
+ if (typeof value === 'boolean')
204
+ return true;
205
+ if (typeof value === 'number')
206
+ return Number.isFinite(value);
207
+ if (typeof value === 'object')
208
+ return Object.keys(value).length > 0;
209
+ return true;
210
+ }
211
+ /**
212
+ * Fail closed if a ProtoConfig object still carries cellular / server fields.
213
+ * Call before encoding a partner set (0x0D).
214
+ */
215
+ export function assertSafePartnerProtoWrite(cfg, options = {}) {
216
+ const forbiddenHit = FORBIDDEN_PARTNER_WRITE_FIELDS.filter((field) => isPresentProtoValue(cfg[field]));
217
+ if (forbiddenHit.length > 0) {
218
+ throw new Error(`Partner BLE set must not write cellular/server fields: ${forbiddenHit.join(', ')}.`);
219
+ }
220
+ if (options.allowExtraKeys)
221
+ return;
222
+ const unknown = Object.keys(cfg).filter((key) => isPresentProtoValue(cfg[key]) && !ALLOWED_WRITE_KEYS.has(key));
223
+ if (unknown.length > 0) {
224
+ throw new Error(`Partner BLE set includes unsupported ProtoConfig fields: ${unknown.join(', ')}.`);
225
+ }
226
+ }
160
227
  /** Patch partner alerts/schedule/intervals onto a baseline ProtoConfig object. */
161
228
  export function applyPartnerConfigToProtoObject(baseline, partner) {
162
229
  const { rules, mask } = compileAlerts(partner.temperatureAlerts ?? []);
@@ -179,6 +246,7 @@ export function applyPartnerConfigToProtoObject(baseline, partner) {
179
246
  if (partner.transmissionIntervalSeconds != null && partner.transmissionIntervalSeconds > 0) {
180
247
  out.transmissionInterval = partner.transmissionIntervalSeconds;
181
248
  }
249
+ assertSafePartnerProtoWrite(out);
182
250
  return out;
183
251
  }
184
252
  export function encodeProtoConfigObject(cfg) {
@@ -207,5 +275,8 @@ export function bytesToBase64(bytes) {
207
275
  export function partnerConfigToProtoBase64(partner, baselineProtoBase64) {
208
276
  const baseline = decodeProtoConfigBase64(baselineProtoBase64);
209
277
  const next = applyPartnerConfigToProtoObject(baseline, partner);
210
- return bytesToBase64(encodeProtoConfigObject(next));
278
+ const encoded = encodeProtoConfigObject(next);
279
+ // After wire encode, only re-check cellular/server keys (protobuf may add defaults).
280
+ assertSafePartnerProtoWrite(decodeProtoConfigBytes(encoded), { allowExtraKeys: true });
281
+ return bytesToBase64(encoded);
211
282
  }
package/dist/qr.d.ts CHANGED
@@ -5,6 +5,8 @@ export declare function bluetoothMacFromSerial(serial: string): string;
5
5
  /** BLE default when QR omits `model`. */
6
6
  export declare const DEFAULT_SENSOR_MODEL = "HC7";
7
7
  export declare function assertSupportedSensorModel(model: string): void;
8
+ /** Sticker `model` → HC7 (incl. Tempivo alias TS10). Unknown strings pass through trimmed. */
9
+ export declare function normalizeQrModelField(model: string | undefined | null): string;
8
10
  export declare function sessionTypeFromModel(_model: string | undefined | null): TempivoSensorSessionType;
9
11
  /**
10
12
  * 3-byte big-endian numeric PIN for GATT command payloads that check a reset code
package/dist/qr.js CHANGED
@@ -18,9 +18,20 @@ export const DEFAULT_SENSOR_MODEL = 'HC7';
18
18
  export function assertSupportedSensorModel(model) {
19
19
  const m = model.trim().toUpperCase().replace(/-/g, '');
20
20
  if (m === 'HC5') {
21
- throw new TempivoSensorError('invalidQr', 'HC5 is not supported by the partner SDK. Use HC7 sensors.');
21
+ throw new TempivoSensorError('invalidQr', 'HC5 is not supported by the partner SDK. Use HC7 / TS10 sensors.');
22
22
  }
23
23
  }
24
+ /** Sticker `model` → HC7 (incl. Tempivo alias TS10). Unknown strings pass through trimmed. */
25
+ export function normalizeQrModelField(model) {
26
+ if (model == null || model.trim() === '')
27
+ return DEFAULT_SENSOR_MODEL;
28
+ const m = model.trim().toUpperCase().replace(/-/g, '');
29
+ if (m === 'TS10' || m === 'HC7')
30
+ return 'HC7';
31
+ if (m === 'HC5')
32
+ return 'HC5';
33
+ return model.trim();
34
+ }
24
35
  export function sessionTypeFromModel(_model) {
25
36
  return 'modern';
26
37
  }
@@ -80,8 +91,10 @@ export function parseSensorQrJson(text) {
80
91
  throw new TempivoSensorError('invalidQr', 'QR is missing PIN.');
81
92
  }
82
93
  const rawModel = typeof rec.model === 'string' ? rec.model.trim() : '';
83
- const model = rawModel || DEFAULT_SENSOR_MODEL;
84
- assertSupportedSensorModel(model);
94
+ const normalized = normalizeQrModelField(rawModel || DEFAULT_SENSOR_MODEL);
95
+ assertSupportedSensorModel(normalized);
96
+ const sticker = rawModel.toUpperCase().replace(/-/g, '');
97
+ const model = sticker === 'TS10' ? 'TS10' : normalized;
85
98
  return {
86
99
  serial,
87
100
  pin,
@@ -13,16 +13,6 @@ export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } fr
13
13
  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, parseSensorConfigurationFromDevice, parseSensorConfigurationFromDeviceJson, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
14
14
  export type { TempivoSchedule, TempivoScheduleAlways, TempivoScheduleWeek, TempivoSensorCalibration, TempivoSensorConfiguration, TempivoSensorQr, TempivoSensorSessionType, TempivoTemperatureAlert, TempivoTemperatureChannel, TempivoTemperatureMaxAlert, TempivoTemperatureMinAlert, TempivoTemperatureRangeAlert, TempivoTriggerTransmissionResult, } from './session-types.js';
15
15
  export type { TempivoDeviceFoundSubscription, TempivoNativeCalibration, TempivoNativeMeasurement, TempivoNativePermissionResponse, TempivoNativePermissionStatus, TempivoNativeQr, TempivoNativeTelemetry, TempivoSensorBeaconDevice, } from './native-types.js';
16
- import * as native from './native-module.js';
17
- export declare const isNativeSensorBeaconAvailable: typeof native.isNativeSensorBeaconAvailable;
18
- export declare const requestPermissions: typeof native.requestPermissions;
19
- export declare const startScan: typeof native.startScan;
20
- export declare const stopScan: typeof native.stopScan;
21
- export declare const isScanning: typeof native.isScanning;
22
- export declare const addDeviceFoundListener: typeof native.addDeviceFoundListener;
23
- export declare const connect: typeof native.connect;
24
- export declare const disconnect: typeof native.disconnect;
25
- export declare const getConfiguration: typeof native.getConfiguration;
26
- export declare const setConfigurationJson: typeof native.setConfigurationJson;
27
- export declare const triggerTransmission: typeof native.triggerTransmission;
28
- export declare const getCalibration: typeof native.getCalibration;
16
+ export { isNativeSensorBeaconAvailable, requestPermissions, startScan, stopScan, isScanning, addDeviceFoundListener, connect, disconnect, getConfiguration, setConfigurationJson, triggerTransmission, getCalibration, getProtoConfiguration, } from './native-module.js';
17
+ export { checkProductionDefaults } from './production-defaults.js';
18
+ export type { ProductionCheckLine, ProductionCheckResult } from './production-defaults.js';
@@ -9,16 +9,5 @@ export { isMissingAdvertisementFirmware, sensorModelFromFirmware } from './model
9
9
  export { buildSensorQrConnectJson, draftSensorQrConnectJsonFromScanDevice, formatSensorReadingLine, mergeScanTargetIntoSensorQr, normalizeSensorStickerModel, parseSensorQrForConnect, pinFromSensorQrRecord, resolveSensorBleConnectCredentialsFromPin, sensorQrConnectJsonFromLoose, sensorQrSerialMatchesSelected, serialHexFromSensorQrScan, tryParseSensorQrJson, } from './connect-helpers.js';
10
10
  export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } from './calibration.js';
11
11
  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, parseSensorConfigurationFromDevice, parseSensorConfigurationFromDeviceJson, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
12
- import * as native from './native-module.js';
13
- export const isNativeSensorBeaconAvailable = native.isNativeSensorBeaconAvailable;
14
- export const requestPermissions = native.requestPermissions;
15
- export const startScan = native.startScan;
16
- export const stopScan = native.stopScan;
17
- export const isScanning = native.isScanning;
18
- export const addDeviceFoundListener = native.addDeviceFoundListener;
19
- export const connect = native.connect;
20
- export const disconnect = native.disconnect;
21
- export const getConfiguration = native.getConfiguration;
22
- export const setConfigurationJson = native.setConfigurationJson;
23
- export const triggerTransmission = native.triggerTransmission;
24
- export const getCalibration = native.getCalibration;
12
+ export { isNativeSensorBeaconAvailable, requestPermissions, startScan, stopScan, isScanning, addDeviceFoundListener, connect, disconnect, getConfiguration, setConfigurationJson, triggerTransmission, getCalibration, getProtoConfiguration, } from './native-module.js';
13
+ export { checkProductionDefaults } from './production-defaults.js';
Binary file
@@ -8,7 +8,7 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>TempivoSensorBridge.framework/TempivoSensorBridge</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64</string>
11
+ <string>ios-arm64-simulator</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>TempivoSensorBridge.framework</string>
14
14
  <key>SupportedArchitectures</key>
@@ -17,12 +17,14 @@
17
17
  </array>
18
18
  <key>SupportedPlatform</key>
19
19
  <string>ios</string>
20
+ <key>SupportedPlatformVariant</key>
21
+ <string>simulator</string>
20
22
  </dict>
21
23
  <dict>
22
24
  <key>BinaryPath</key>
23
25
  <string>TempivoSensorBridge.framework/TempivoSensorBridge</string>
24
26
  <key>LibraryIdentifier</key>
25
- <string>ios-arm64-simulator</string>
27
+ <string>ios-arm64</string>
26
28
  <key>LibraryPath</key>
27
29
  <string>TempivoSensorBridge.framework</string>
28
30
  <key>SupportedArchitectures</key>
@@ -31,8 +33,6 @@
31
33
  </array>
32
34
  <key>SupportedPlatform</key>
33
35
  <string>ios</string>
34
- <key>SupportedPlatformVariant</key>
35
- <string>simulator</string>
36
36
  </dict>
37
37
  </array>
38
38
  <key>CFBundlePackageType</key>
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge
3
3
  size: 78397208 bytes
4
- sha256: 552a1de0b927f4ad3920434257b884153472c680fa71b3fc3533c7ddf23cc903
4
+ sha256: 47d3b00a0b04f500eaa566d288ee0884e55da39ce593c844dc164b415ef8ccab
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/TempivoSensorBridge
3
3
  size: 35252152 bytes
4
- sha256: 6cc29cf4dc284801eebfc4e1e7eb1afeb789e916e1181a732ed7e577714fd112
4
+ sha256: 6ff38e03c9e174414232b38328a14ccca43929477cb2769ca7c604f14341f188
@@ -54,6 +54,12 @@ public enum TempivoSensorQrParser {
54
54
 
55
55
  public static let defaultModel = "HC7"
56
56
 
57
+ public static func normalizeModel(_ model: String) -> String {
58
+ let m = model.trimmingCharacters(in: .whitespacesAndNewlines).uppercased().replacingOccurrences(of: "-", with: "")
59
+ if m.isEmpty || m == "HC7" || m == "TS10" { return defaultModel }
60
+ return model.trimmingCharacters(in: .whitespacesAndNewlines)
61
+ }
62
+
57
63
  public static func assertSupportedModel(_ model: String) throws {
58
64
  let m = model.trimmingCharacters(in: .whitespacesAndNewlines).uppercased().replacingOccurrences(of: "-", with: "")
59
65
  if m == "HC5" {
@@ -84,8 +90,8 @@ public enum TempivoSensorQrParser {
84
90
  guard !pin.isEmpty else {
85
91
  throw TempivoSensorError(.invalidQr, "QR is missing PIN.")
86
92
  }
87
- let rawModel = (obj["model"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
88
- let model = rawModel.isEmpty ? defaultModel : rawModel
93
+ let rawModel = (obj["model"] as? String) ?? ""
94
+ let model = normalizeModel(rawModel)
89
95
  try assertSupportedModel(model)
90
96
  let hex = serial.count == 12 ? serial : String(serial.suffix(12))
91
97
  let macOverride =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tempivo/sensor-beacon",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Tempivo sensor BLE SDK. Native scan and GATT for Expo, React Native, Android, and iOS. Node helpers decode advertisements and parse QR or config (no radio).",
5
5
  "homepage": "https://app.tempivo.com/integrations",
6
6
  "repository": {
@@ -56,7 +56,7 @@
56
56
  "build:ios-bridge": "bash scripts/build-ios-bridge.sh && node scripts/copy-ios-bridge.mjs",
57
57
  "copy:ios-bridge": "node scripts/copy-ios-bridge.mjs",
58
58
  "build:all": "npm run build:ts && npm run build:android",
59
- "prepare": "npm run build:ts",
59
+ "prepare": "node scripts/prepare-build.mjs",
60
60
  "test": "npm run test:all",
61
61
  "test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.mjs --testPathIgnorePatterns=ble.integration",
62
62
  "test:ble": "TEMPIVO_BLE_INTEGRATION=1 node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.mjs --testPathPattern=ble.integration",