@tempivo/sensor-beacon 0.3.0 → 0.3.2

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 (30) hide show
  1. package/README.md +5 -5
  2. package/android/build.gradle +2 -2
  3. package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +13 -5
  4. package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +4 -2
  5. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +6 -3
  6. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +81 -38
  7. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +14 -0
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +40 -11
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +17 -0
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +9 -0
  11. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +28 -8
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/native-helpers.d.ts +1 -0
  15. package/dist/native-helpers.js +18 -3
  16. package/dist/native-module.js +1 -0
  17. package/dist/qr.d.ts +4 -0
  18. package/dist/qr.js +17 -0
  19. package/dist/tempivo-sensor-beacon.aar +0 -0
  20. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +3 -1
  21. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  22. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +3 -1
  23. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  24. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconScanner.swift +80 -4
  25. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +15 -0
  26. package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +15 -1
  27. package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +17 -0
  28. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +36 -8
  29. package/ios/TempivoSensorBeaconModule.swift +11 -2
  30. package/package.json +1 -1
package/README.md CHANGED
@@ -82,7 +82,7 @@ stopScan();
82
82
  sub.remove();
83
83
  ```
84
84
 
85
- `startScan` and `connect` request Bluetooth permission. Sticker PIN is required on `connect`. Wrong PIN is `invalidPin`.
85
+ `startScan` and `connect` request Bluetooth permission. Sticker PIN is required on `connect`. Wrong PIN is `invalidPin`. `connect` stops an active scan first. HC5 (legacy GATT) and HC7 use the same call. If the first GATT path fails, the native layer retries the other. `setConfigurationJson` / `getConfiguration` use the same JSON on both. HC5 writes each rule over GATT (`setRule`). Weekly `schedule` windows apply on FW 7+ only. On HC5, rules stay on 24/7.
86
86
 
87
87
  If a bundler misses native BLE, import `@tempivo/sensor-beacon/react-native`.
88
88
 
@@ -110,7 +110,7 @@ Same as Cellular API config profiles (`temperatureAlerts` + `schedule`). You can
110
110
  | Field | Meaning |
111
111
  |------|---------|
112
112
  | `temperatureAlerts` | Alert list. Empty array is valid. |
113
- | `schedule` | `{ "always": true }` is 24/7. |
113
+ | `schedule` | `{ "always": true }` is 24/7. Weekday windows apply on FW 7+. HC5 is always 24/7. |
114
114
 
115
115
  Ignored over BLE: `slug`, `name`, `measurementIntervalMinutes`, `transmissionIntervalSeconds`. Do not pass `alarmRules`.
116
116
 
@@ -149,13 +149,13 @@ Sticker QR is JSON. Parse with `parseSensorQrJson`.
149
149
  |------|------|---------|
150
150
  | `serial` | string | Device serial, 12 hex chars, no colons. From QR `sn`. |
151
151
  | `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. |
152
+ | `model` | string | Sticker model. Omitted QR `model` becomes **HC7**. `HC5` selects legacy GATT. Same `connect` call works for both. |
153
153
  | `sessionType` | `modern` \| `legacy` | GATT path. HC7 / firmware 7+ is `modern`. HC5 / firmware 6.x is `legacy`. |
154
154
  | `bluetoothMac` | string | BLE MAC with colons, derived from `serial`. |
155
155
 
156
156
  ## Scan result
157
157
 
158
- Filter manufacturer data on company id **0x026C**. Active scanning is on so scan-response packets arrive.
158
+ iOS uses the same hardware scan as Tempivo. Manufacturer ads (`0x026C`) alone often miss HC7 / FW 7+. Android still filters `0x026C`.
159
159
 
160
160
  | Field | Meaning |
161
161
  |------|---------|
@@ -173,7 +173,7 @@ Filter manufacturer data on company id **0x026C**. Active scanning is on so scan
173
173
  | `firmware` | `major.minor.patch`, e.g. `7.3.4`. |
174
174
  | `batteryOk` | Battery status flag. |
175
175
  | `encryptionEnabled` | Advertisement payload encryption flag. |
176
- | `cellularStatus` | `ble_only`, `cell_ok`, `no_server`, or `net_issue`. |
176
+ | `cellularStatus` | Advert attachment bits only: `ble_only`, `cell_ok`, `no_server`, or `net_issue`. Not ECL, SINR, or RSSI. |
177
177
  | `readingTimestampUnix` | Sample time as unix seconds, or null. |
178
178
  | `readingTimestampIso` | Same instant as UTC ISO-8601, or null. |
179
179
  | `measurementIntervalSeconds` | Sample interval, in seconds. |
@@ -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.1'
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.1"
15
15
  }
16
16
  compileOptions {
17
17
  sourceCompatibility JavaVersion.VERSION_17
@@ -54,8 +54,15 @@ final class SensorBeaconNative {
54
54
 
55
55
  Map<String, Object> connect(Context context, String qrJson) {
56
56
  try {
57
+ stopScan();
58
+ try {
59
+ Thread.sleep(200);
60
+ } catch (InterruptedException ignored) {
61
+ Thread.currentThread().interrupt();
62
+ }
57
63
  TempivoSensorQr qr = TempivoSensorQrParser.INSTANCE.parse(qrJson);
58
- requireSession(context).connectBlocking(qr);
64
+ TempivoSensorBeaconScanner.SeenDevice seen = scanner != null ? scanner.lookup(qr.getSerial()) : null;
65
+ requireSession(context).connectBlocking(qr, seen);
59
66
  return qrMap(qr);
60
67
  } catch (Throwable error) {
61
68
  throw wrap(error);
@@ -135,13 +142,14 @@ final class SensorBeaconNative {
135
142
  }
136
143
  TempivoSensorException sensor = findSensor(error);
137
144
  if (sensor != null) {
138
- return new NativeError(toJs(sensor.getCode()), sensor.getMessage(), error);
145
+ return new NativeError(toJs(sensor.getCode()), TempivoSensorException.sanitizeClientMessage(sensor.getMessage()), null);
139
146
  }
140
147
  if (error instanceof TempivoSensorBeaconScanner.ScanNotPermittedException) {
141
- return new NativeError("runtimeUnavailable", error.getMessage(), error);
148
+ String permitted = TempivoSensorException.sanitizeClientMessage(error.getMessage());
149
+ return new NativeError("runtimeUnavailable", permitted.isEmpty() ? "Bluetooth permission is required." : permitted, null);
142
150
  }
143
- String message = error.getMessage();
144
- return new NativeError("unknown", message != null ? message : "Sensor request failed.", error);
151
+ String message = TempivoSensorException.sanitizeClientMessage(error.getMessage());
152
+ return new NativeError("unknown", message.isEmpty() ? "Sensor request failed." : message, null);
145
153
  }
146
154
 
147
155
  private static TempivoSensorException findSensor(Throwable error) {
@@ -8,6 +8,7 @@ import expo.modules.kotlin.Promise
8
8
  import expo.modules.kotlin.exception.CodedException
9
9
  import expo.modules.kotlin.modules.Module
10
10
  import expo.modules.kotlin.modules.ModuleDefinition
11
+ import com.tempivo.sensor.beacon.TempivoSensorException
11
12
 
12
13
  class TempivoSensorBeaconModule : Module() {
13
14
  private val native = SensorBeaconNative()
@@ -116,7 +117,8 @@ private fun Throwable.toSensorException(): CodedException {
116
117
  if (this is CodedException) return this
117
118
  val native = this as? SensorBeaconNative.NativeError ?: cause as? SensorBeaconNative.NativeError
118
119
  if (native != null) {
119
- return CodedException(native.code, native.message, this)
120
+ return CodedException(native.code, native.message, null)
120
121
  }
121
- return CodedException("unknown", message ?: "Sensor request failed.", this)
122
+ val clean = TempivoSensorException.sanitizeClientMessage(message)
123
+ return CodedException("unknown", clean.ifEmpty { "Sensor request failed." }, null)
122
124
  }
@@ -93,9 +93,12 @@ internal object PartnerBleRules {
93
93
  return rules to calendars
94
94
  }
95
95
 
96
- fun decompile(cfg: SensorConfiguration): TempivoSensorConfiguration {
96
+ fun decompile(cfg: SensorConfiguration): TempivoSensorConfiguration =
97
+ decompile(cfg.rules.orEmpty(), cfg.ruleCalendars)
98
+
99
+ fun decompile(rules: List<Rule>, calendars: List<RuleCalendar>? = null): TempivoSensorConfiguration {
97
100
  val padded = MutableList(12) { i ->
98
- cfg.rules.orEmpty().getOrNull(i)
101
+ rules.getOrNull(i)
99
102
  ?: Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION)
100
103
  }
101
104
  val orRefs = orSelectedPairs(padded)
@@ -164,7 +167,7 @@ internal object PartnerBleRules {
164
167
  }
165
168
  }
166
169
 
167
- return TempivoSensorConfiguration(alerts, decompileSchedule(cfg.ruleCalendars))
170
+ return TempivoSensorConfiguration(alerts, decompileSchedule(calendars))
168
171
  }
169
172
 
170
173
  private fun orSelectedPairs(rules: List<Rule>): List<Pair<Int, Int>> {
@@ -2,12 +2,14 @@ package com.tempivo.sensor.beacon
2
2
 
3
3
  import android.app.Application
4
4
  import kotlinx.coroutines.delay
5
+ import kotlinx.coroutines.withTimeout
5
6
  import pl.efento.mobile.bluetooth.EfentoBluetooth
6
7
  import pl.efento.mobile.bluetooth.api.SensorConnection
7
8
  import pl.efento.mobile.bluetooth.api.SensorLegacyConnection
8
9
  import pl.efento.mobile.bluetooth.exception.InvalidResetCodeException
9
10
  import pl.efento.mobile.bluetooth.exception.UnsupportedCommandException
10
11
  import pl.efento.mobile.bluetooth.model.BluetoothMacAddress
12
+ import pl.efento.mobile.bluetooth.model.sensor.DisabledCondition
11
13
  import pl.efento.mobile.bluetooth.model.sensor.Rule
12
14
  import pl.efento.mobile.bluetooth.model.sensor.RuleCalendar
13
15
  import pl.efento.mobile.bluetooth.model.sensor.SensorConfiguration
@@ -31,15 +33,26 @@ internal object SensorBleRuntime {
31
33
  fun mapError(e: Throwable): TempivoSensorException {
32
34
  if (e is TempivoSensorException) return e
33
35
  val className = e.javaClass.name
36
+ val detail = clientSafeDetail(e.message)
34
37
  return when {
35
38
  e is InvalidResetCodeException || className.contains("InvalidResetCode") ->
36
- TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "Invalid PIN.", e)
39
+ TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "Invalid PIN.")
37
40
  e is UnsupportedCommandException || className.contains("UnsupportedCommand") ->
38
- TempivoSensorException(TempivoSensorException.Code.UNSUPPORTED_COMMAND, "Unsupported command.", e)
41
+ TempivoSensorException(TempivoSensorException.Code.UNSUPPORTED_COMMAND, "Unsupported command.")
39
42
  className.contains("NotConnected") ->
40
- TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected.", e)
43
+ TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected.")
44
+ className.contains("Connect", ignoreCase = true) ||
45
+ detail.contains("connect", ignoreCase = true) ||
46
+ detail.contains("timeout", ignoreCase = true) ->
47
+ TempivoSensorException(
48
+ TempivoSensorException.Code.CONNECT_FAILED,
49
+ if (detail.isNotEmpty()) "Could not connect. $detail" else "Could not connect.",
50
+ )
41
51
  else ->
42
- TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Sensor request failed.", e)
52
+ TempivoSensorException(
53
+ TempivoSensorException.Code.UNKNOWN,
54
+ if (detail.isNotEmpty()) "Sensor request failed. $detail" else "Sensor request failed.",
55
+ )
43
56
  }
44
57
  }
45
58
 
@@ -49,32 +62,57 @@ internal object SensorBleRuntime {
49
62
  bluetoothMac: String,
50
63
  pin: Int,
51
64
  legacy: Boolean,
65
+ deviceId: String = serial,
66
+ encryptionKey: String = "",
52
67
  ): Session {
53
68
  initialize(application)
54
69
  val mac = BluetoothMacAddress(bluetoothMac)
55
- // PIN stays on the connection: set, trigger, calibration writes, and many other commands check it.
56
- return if (legacy) {
57
- val c =
58
- EfentoBluetooth.sensorLegacyConnection(
59
- deviceID = serial,
60
- bluetoothMacAddress = mac,
61
- resetCode = pin,
62
- encryptionKey = "",
63
- )
64
- c.connect()
65
- if (pin >= 0) c.resetCode = pin
66
- Session.Legacy(c)
67
- } else {
68
- val c =
69
- EfentoBluetooth.sensorConnection(
70
- deviceID = serial,
71
- bluetoothMacAddress = mac,
72
- resetCode = pin,
73
- encryptionKey = "",
74
- )
75
- c.connect()
76
- if (pin >= 0) c.resetCode = pin
77
- Session.Modern(c)
70
+ val id = deviceId.ifBlank { serial }
71
+ suspend fun open(useLegacy: Boolean): Session {
72
+ return if (useLegacy) {
73
+ val c =
74
+ EfentoBluetooth.sensorLegacyConnection(
75
+ deviceID = id,
76
+ bluetoothMacAddress = mac,
77
+ resetCode = pin,
78
+ encryptionKey = encryptionKey,
79
+ )
80
+ c.connect()
81
+ if (pin >= 0) c.resetCode = pin
82
+ if (encryptionKey.isNotEmpty()) c.encryptionKey = encryptionKey
83
+ Session.Legacy(c)
84
+ } else {
85
+ val c =
86
+ EfentoBluetooth.sensorConnection(
87
+ deviceID = id,
88
+ bluetoothMacAddress = mac,
89
+ resetCode = pin,
90
+ encryptionKey = encryptionKey,
91
+ )
92
+ c.connect()
93
+ if (pin >= 0) c.resetCode = pin
94
+ if (encryptionKey.isNotEmpty()) c.encryptionKey = encryptionKey
95
+ Session.Modern(c)
96
+ }
97
+ }
98
+ return try {
99
+ open(legacy)
100
+ } catch (first: Throwable) {
101
+ val mapped = mapError(first)
102
+ if (mapped.code == TempivoSensorException.Code.INVALID_PIN) throw mapped
103
+ try {
104
+ open(!legacy)
105
+ } catch (second: Throwable) {
106
+ val mappedSecond = mapError(second)
107
+ if (mappedSecond.code == TempivoSensorException.Code.UNKNOWN) {
108
+ throw TempivoSensorException(
109
+ TempivoSensorException.Code.CONNECT_FAILED,
110
+ mapped.message + " Then " + mappedSecond.message,
111
+ second,
112
+ )
113
+ }
114
+ throw mappedSecond
115
+ }
78
116
  }
79
117
  }
80
118
 
@@ -86,14 +124,11 @@ internal object SensorBleRuntime {
86
124
  }
87
125
  }
88
126
 
89
- suspend fun readConfiguration(session: Session): SensorConfiguration {
127
+ suspend fun readPartnerConfiguration(session: Session): TempivoSensorConfiguration {
90
128
  return when (session) {
91
- is Session.Modern -> modernGetConfiguration(session.connection)
129
+ is Session.Modern -> PartnerBleRules.decompile(modernGetConfiguration(session.connection))
92
130
  is Session.Legacy ->
93
- throw TempivoSensorException(
94
- TempivoSensorException.Code.UNSUPPORTED_COMMAND,
95
- "Configuration rules require a current-generation sensor session.",
96
- )
131
+ PartnerBleRules.decompile(session.connection.commands.getRules { }, emptyList())
97
132
  }
98
133
  }
99
134
 
@@ -104,11 +139,19 @@ internal object SensorBleRuntime {
104
139
  val next = base.copy(rules = rules.take(12), ruleCalendars = calendars.take(6))
105
140
  session.connection.commands.setConfiguration(next) { }
106
141
  }
107
- is Session.Legacy ->
108
- throw TempivoSensorException(
109
- TempivoSensorException.Code.UNSUPPORTED_COMMAND,
110
- "Configuration rules require a current-generation sensor session.",
111
- )
142
+ is Session.Legacy -> writeLegacyRules(session.connection, rules)
143
+ }
144
+ }
145
+
146
+ private suspend fun writeLegacyRules(legacy: SensorLegacyConnection, rules: List<Rule>) {
147
+ val cmd = legacy.commands
148
+ val current = runCatching { cmd.getRules { } }.getOrNull().orEmpty()
149
+ val currentActive =
150
+ current.filter { it.condition !is DisabledCondition }.map { it.id }.toSet()
151
+ for (rule in rules.take(12)) {
152
+ val disabled = rule.condition is DisabledCondition
153
+ if (disabled && rule.id !in currentActive) continue
154
+ withTimeout(20_000) { cmd.setRule(rule) { } }
112
155
  }
113
156
  }
114
157
 
@@ -62,6 +62,20 @@ object TempivoSensorBeaconDecoder {
62
62
  0x22 to MeasureSpec("Current", 0.01, 1, true),
63
63
  )
64
64
 
65
+ /** FW 7+ → modern (`false`); FW 6.x / 5.x → legacy (`true`). */
66
+ fun legacyHintFromFirmware(firmware: String?): Boolean? {
67
+ val t = firmware?.trim().orEmpty()
68
+ if (t.isEmpty()) return null
69
+ if (t.startsWith("FW 5", ignoreCase = true) || t.startsWith("FW5", ignoreCase = true)) return true
70
+ val m = Regex("^(\\d+)\\.\\d+").find(t) ?: return null
71
+ val major = m.groupValues[1].toIntOrNull() ?: return null
72
+ return when {
73
+ major >= 7 -> false
74
+ major >= 1 -> true
75
+ else -> null
76
+ }
77
+ }
78
+
65
79
  /** 12-hex serial from FW6 `0x03` bytes 1–6 (same as BLE MAC on supported firmware). */
66
80
  fun serialKeyFromAdv03(frame: ByteArray): String? {
67
81
  if (frame.size < 7 || (frame[0].toInt() and 0xFF) != 0x03) return null
@@ -28,8 +28,17 @@ class TempivoSensorBeaconScanner(
28
28
  class ScanNotPermittedException(message: String) : Exception(message)
29
29
 
30
30
  private val appContext = context.applicationContext
31
+ data class SeenDevice(
32
+ val deviceId: String,
33
+ val bluetoothMac: String,
34
+ val serial: String,
35
+ val firmware: String?,
36
+ val encryptionEnabled: Boolean?,
37
+ )
38
+
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) {
@@ -107,25 +122,39 @@ class TempivoSensorBeaconScanner(
107
122
 
108
123
  private fun buildDevice(result: ScanResult, key: String): TempivoSensorBeaconDevice? {
109
124
  val addr = result.device?.address ?: return null
110
- val serial = key.uppercase(Locale.US)
111
- val macColons = macWithColonsFromKey(serial)
125
+ val frame = advLast03[key]
126
+ val advSerial = frame?.let { TempivoSensorBeaconDecoder.serialKeyFromAdv03(it) }
127
+ val serial = (advSerial ?: key).uppercase(Locale.US)
128
+ val macColons = macWithColonsFromKey(if (key.length == 12) key else serial)
112
129
  val telemetry =
113
130
  TempivoSensorBeaconDecoder.buildTelemetry(
114
- TempivoSensorBeaconDecoder.lookupKeys(serial),
131
+ TempivoSensorBeaconDecoder.lookupKeys(key, advSerial),
115
132
  advLast03,
116
133
  advLast04,
117
134
  )
118
135
  val summary =
119
136
  telemetry?.summary?.takeIf { it.isNotEmpty() }
120
137
  ?: serial
121
- return TempivoSensorBeaconDevice(
122
- deviceId = addr,
123
- bluetoothMacAddress = macColons,
124
- serialNumber = serial,
125
- rssi = result.rssi,
126
- telemetry = telemetry,
127
- summary = summary,
128
- )
138
+ val device =
139
+ TempivoSensorBeaconDevice(
140
+ deviceId = addr,
141
+ bluetoothMacAddress = macColons,
142
+ serialNumber = serial,
143
+ rssi = result.rssi,
144
+ telemetry = telemetry,
145
+ summary = summary,
146
+ )
147
+ val seen =
148
+ SeenDevice(
149
+ deviceId = addr,
150
+ bluetoothMac = macColons,
151
+ serial = serial,
152
+ firmware = telemetry?.firmware,
153
+ encryptionEnabled = telemetry?.encryptionEnabled,
154
+ )
155
+ lastSeen[serial] = seen
156
+ lastSeen[key.uppercase(Locale.US)] = seen
157
+ return device
129
158
  }
130
159
 
131
160
  private fun bluetoothLeScanner() =
@@ -1,10 +1,27 @@
1
1
  package com.tempivo.sensor.beacon
2
2
 
3
+ internal fun clientSafeDetail(raw: String?): String {
4
+ if (raw.isNullOrBlank()) return ""
5
+ return raw
6
+ .replace(VENDOR_PKG.toRegex(), "")
7
+ .replace(VENDOR_WORD.toRegex(), "")
8
+ .replace(Regex("\\s+"), " ")
9
+ .trim(' ', ':', '-', '.', '\n', '\t', '/')
10
+ }
11
+
12
+ private const val VENDOR_PKG = """(?i)pl\.[A-Za-z][A-Za-z0-9_.$]*"""
13
+ private const val VENDOR_WORD = """(?i)\befento[A-Za-z0-9_$]*"""
14
+
3
15
  class TempivoSensorException(
4
16
  val code: Code,
5
17
  message: String,
6
18
  cause: Throwable? = null,
7
19
  ) : Exception(message, cause) {
20
+ companion object {
21
+ @JvmStatic
22
+ fun sanitizeClientMessage(raw: String?): String = clientSafeDetail(raw)
23
+ }
24
+
8
25
  enum class Code {
9
26
  INVALID_PIN,
10
27
  INVALID_QR,
@@ -40,6 +40,15 @@ object TempivoSensorQrParser {
40
40
  }
41
41
  }
42
42
 
43
+ fun resolveSessionType(model: String?, firmware: String?): TempivoSensorSession.SessionType {
44
+ val hint = TempivoSensorBeaconDecoder.legacyHintFromFirmware(firmware)
45
+ return when (hint) {
46
+ true -> TempivoSensorSession.SessionType.LEGACY
47
+ false -> TempivoSensorSession.SessionType.MODERN
48
+ null -> sessionTypeFromModel(model)
49
+ }
50
+ }
51
+
43
52
  fun parse(json: String): TempivoSensorQr {
44
53
  val rec =
45
54
  try {
@@ -33,10 +33,17 @@ class TempivoSensorSession(
33
33
  bluetoothMac: String,
34
34
  pin: String,
35
35
  sessionType: SessionType = SessionType.MODERN,
36
+ deviceId: String? = null,
37
+ firmware: String? = null,
36
38
  ) {
37
39
  val pinInt =
38
40
  pin.trim().toIntOrNull()
39
41
  ?: throw TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "PIN must be numeric.")
42
+ val resolved =
43
+ TempivoSensorQrParser.resolveSessionType(
44
+ if (sessionType == SessionType.LEGACY) "HC5" else "HC7",
45
+ firmware,
46
+ )
40
47
  try {
41
48
  mutex.withLock {
42
49
  SensorBleRuntime.disconnect(session)
@@ -48,14 +55,20 @@ class TempivoSensorSession(
48
55
  serial = TempivoSensorQrParser.normalizeSerial(serial),
49
56
  bluetoothMac = bluetoothMac,
50
57
  pin = pinInt,
51
- legacy = sessionType == SessionType.LEGACY,
58
+ legacy = resolved == SessionType.LEGACY,
59
+ deviceId = deviceId?.ifBlank { null } ?: serial,
60
+ encryptionKey = pin.trim(),
52
61
  )
53
62
  }
54
63
  }
55
64
  } catch (e: Throwable) {
56
65
  throw SensorBleRuntime.mapError(e).let {
57
66
  if (it.code == TempivoSensorException.Code.UNKNOWN) {
58
- TempivoSensorException(TempivoSensorException.Code.CONNECT_FAILED, "Could not connect.", e)
67
+ TempivoSensorException(
68
+ TempivoSensorException.Code.CONNECT_FAILED,
69
+ it.message ?: "Could not connect.",
70
+ e,
71
+ )
59
72
  } else {
60
73
  it
61
74
  }
@@ -63,8 +76,15 @@ class TempivoSensorSession(
63
76
  }
64
77
  }
65
78
 
66
- suspend fun connect(qr: TempivoSensorQr) {
67
- connect(qr.serial, qr.bluetoothMac, qr.pin, qr.sessionType)
79
+ suspend fun connect(qr: TempivoSensorQr, seen: TempivoSensorBeaconScanner.SeenDevice? = null) {
80
+ connect(
81
+ serial = qr.serial,
82
+ bluetoothMac = seen?.bluetoothMac ?: qr.bluetoothMac,
83
+ pin = qr.pin,
84
+ sessionType = qr.sessionType,
85
+ deviceId = seen?.deviceId,
86
+ firmware = seen?.firmware,
87
+ )
68
88
  }
69
89
 
70
90
  suspend fun disconnect() {
@@ -75,9 +95,7 @@ class TempivoSensorSession(
75
95
  }
76
96
 
77
97
  suspend fun getConfiguration(): TempivoSensorConfiguration {
78
- return runGatt {
79
- SensorBleRuntime.decompile(SensorBleRuntime.readConfiguration(requireSession()))
80
- }
98
+ return runGatt { SensorBleRuntime.readPartnerConfiguration(requireSession()) }
81
99
  }
82
100
 
83
101
  suspend fun getConfigurationJson(): String = TempivoSensorProfile.toJson(getConfiguration())
@@ -101,7 +119,9 @@ class TempivoSensorSession(
101
119
  return runGatt { SensorBleRuntime.readCalibration(requireSession()) }
102
120
  }
103
121
 
104
- fun connectBlocking(qr: TempivoSensorQr) = runBlocking { connect(qr) }
122
+ @JvmOverloads
123
+ fun connectBlocking(qr: TempivoSensorQr, seen: TempivoSensorBeaconScanner.SeenDevice? = null) =
124
+ runBlocking { connect(qr, seen) }
105
125
 
106
126
  fun disconnectBlocking() = runBlocking { disconnect() }
107
127
 
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { dataViewToUint8Array, 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
- export { DEFAULT_SENSOR_MODEL, bluetoothMacFromSerial, normalizeSensorSerial, encodeSensorPinPayload, parseSensorQrJson, sessionTypeFromModel, } from './qr.js';
7
+ export { DEFAULT_SENSOR_MODEL, bluetoothMacFromSerial, normalizeSensorSerial, encodeSensorPinPayload, parseSensorQrJson, resolveConnectSessionType, sessionTypeFromModel, } from './qr.js';
8
8
  export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } from './calibration.js';
9
9
  export { 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';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ export { dataViewToUint8Array, 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
- export { DEFAULT_SENSOR_MODEL, bluetoothMacFromSerial, normalizeSensorSerial, encodeSensorPinPayload, parseSensorQrJson, sessionTypeFromModel, } from './qr.js';
7
+ export { DEFAULT_SENSOR_MODEL, bluetoothMacFromSerial, normalizeSensorSerial, encodeSensorPinPayload, parseSensorQrJson, resolveConnectSessionType, sessionTypeFromModel, } from './qr.js';
8
8
  export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } from './calibration.js';
9
9
  export { 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';
@@ -2,4 +2,5 @@ import { TempivoSensorError } from './errors.js';
2
2
  import type { TempivoSensorConfiguration, TempivoSensorQr } from './session-types.js';
3
3
  export declare function qrToConnectJson(qr: string | TempivoSensorQr): string;
4
4
  export declare function configToJson(config: string | TempivoSensorConfiguration): string;
5
+ export declare function sanitizePartnerErrorText(text: string): string;
5
6
  export declare function mapNativeError(error: unknown): TempivoSensorError;
@@ -24,10 +24,25 @@ export function configToJson(config) {
24
24
  function isSensorCode(value) {
25
25
  return !!value && SENSOR_CODES.includes(value);
26
26
  }
27
+ export function sanitizePartnerErrorText(text) {
28
+ return text
29
+ .replace(/\bpl\.[A-Za-z][A-Za-z0-9_.$]*/gi, '')
30
+ .replace(/\befento[A-Za-z0-9_$]*/gi, '')
31
+ .replace(/\s+/g, ' ')
32
+ .replace(/^[:\s.\-/]+|[:\s.\-/]+$/g, '')
33
+ .trim();
34
+ }
27
35
  export function mapNativeError(error) {
28
- if (error instanceof TempivoSensorError)
29
- return error;
36
+ if (error instanceof TempivoSensorError) {
37
+ const clean = sanitizePartnerErrorText(error.message);
38
+ return new TempivoSensorError(error.code, clean || 'Sensor request failed.');
39
+ }
30
40
  const raw = error;
31
41
  const code = isSensorCode(raw?.code) ? raw.code : 'unknown';
32
- return new TempivoSensorError(code, raw?.message || 'Sensor request failed.');
42
+ let message = raw?.message || 'Sensor request failed.';
43
+ const caused = /Caused by:\s*(.+)$/s.exec(message);
44
+ if (caused?.[1])
45
+ message = caused[1].trim();
46
+ message = sanitizePartnerErrorText(message) || 'Sensor request failed.';
47
+ return new TempivoSensorError(code, message);
33
48
  }
@@ -55,6 +55,7 @@ export function addDeviceFoundListener(listener) {
55
55
  export async function connect(qr) {
56
56
  await ensureBlePermission();
57
57
  try {
58
+ getNative().stopScan();
58
59
  return await getNative().connect(qrToConnectJson(qr));
59
60
  }
60
61
  catch (error) {
package/dist/qr.d.ts CHANGED
@@ -5,6 +5,10 @@ export declare function bluetoothMacFromSerial(serial: string): string;
5
5
  /** BLE default when QR omits `model`. HC5 / FW 6.x still select legacy. */
6
6
  export declare const DEFAULT_SENSOR_MODEL = "HC7";
7
7
  export declare function sessionTypeFromModel(model: string | undefined | null): TempivoSensorSessionType;
8
+ /**
9
+ * Advertisement firmware wins over sticker model. FW 7+ is modern. FW 6.x / 5.x is legacy (HC5).
10
+ */
11
+ export declare function resolveConnectSessionType(model: string | undefined | null, firmware?: string | null): TempivoSensorSessionType;
8
12
  /**
9
13
  * 3-byte big-endian numeric PIN for GATT command payloads that check a reset code
10
14
  * (`setConfiguration`, trigger, and many other privileged writes).
package/dist/qr.js CHANGED
@@ -23,6 +23,23 @@ export function sessionTypeFromModel(model) {
23
23
  return 'legacy';
24
24
  return 'modern';
25
25
  }
26
+ /**
27
+ * Advertisement firmware wins over sticker model. FW 7+ is modern. FW 6.x / 5.x is legacy (HC5).
28
+ */
29
+ export function resolveConnectSessionType(model, firmware) {
30
+ const t = firmware?.trim() ?? '';
31
+ if (/^FW\s*5/i.test(t))
32
+ return 'legacy';
33
+ const m = /^(\d+)\.\d+/.exec(t);
34
+ if (m) {
35
+ const major = Number.parseInt(m[1], 10);
36
+ if (major >= 7)
37
+ return 'modern';
38
+ if (major >= 1)
39
+ return 'legacy';
40
+ }
41
+ return sessionTypeFromModel(model);
42
+ }
26
43
  /**
27
44
  * 3-byte big-endian numeric PIN for GATT command payloads that check a reset code
28
45
  * (`setConfiguration`, trigger, and many other privileged writes).
Binary file
@@ -145,12 +145,14 @@ __attribute__((swift_name("TempivoSensorBridge")))
145
145
  @interface TSBTempivoSensorBridge : TSBBase
146
146
  - (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer));
147
147
  + (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead")));
148
- - (NSString *)connectPayloadJsonSerial:(NSString *)serial bluetoothMac:(NSString *)bluetoothMac pin:(int32_t)pin legacy:(BOOL)legacy __attribute__((swift_name("connectPayloadJson(serial:bluetoothMac:pin:legacy:)")));
148
+ - (NSString *)connectPayloadJsonSerial:(NSString *)serial bluetoothMac:(NSString *)bluetoothMac pin:(int32_t)pin legacy:(BOOL)legacy deviceId:(NSString *)deviceId encryptionKey:(NSString *)encryptionKey __attribute__((swift_name("connectPayloadJson(serial:bluetoothMac:pin:legacy:deviceId:encryptionKey:)")));
149
149
  - (void)disconnect __attribute__((swift_name("disconnect()")));
150
150
  - (NSString *)getCalibrationPayloadJson __attribute__((swift_name("getCalibrationPayloadJson()")));
151
151
  - (NSString *)getConfigurationPayloadJson __attribute__((swift_name("getConfigurationPayloadJson()")));
152
152
  - (void)initialize __attribute__((swift_name("initialize()")));
153
153
  - (NSString *)setConfigurationPayloadJsonJson:(NSString *)json __attribute__((swift_name("setConfigurationPayloadJson(json:)")));
154
+ - (void)startDeviceScanOnDeviceJson:(void (^)(NSString *))onDeviceJson __attribute__((swift_name("startDeviceScan(onDeviceJson:)")));
155
+ - (void)stopDeviceScan __attribute__((swift_name("stopDeviceScan()")));
154
156
  - (NSString *)triggerTransmissionPayloadJson __attribute__((swift_name("triggerTransmissionPayloadJson()")));
155
157
  @end
156
158
 
@@ -145,12 +145,14 @@ __attribute__((swift_name("TempivoSensorBridge")))
145
145
  @interface TSBTempivoSensorBridge : TSBBase
146
146
  - (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer));
147
147
  + (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead")));
148
- - (NSString *)connectPayloadJsonSerial:(NSString *)serial bluetoothMac:(NSString *)bluetoothMac pin:(int32_t)pin legacy:(BOOL)legacy __attribute__((swift_name("connectPayloadJson(serial:bluetoothMac:pin:legacy:)")));
148
+ - (NSString *)connectPayloadJsonSerial:(NSString *)serial bluetoothMac:(NSString *)bluetoothMac pin:(int32_t)pin legacy:(BOOL)legacy deviceId:(NSString *)deviceId encryptionKey:(NSString *)encryptionKey __attribute__((swift_name("connectPayloadJson(serial:bluetoothMac:pin:legacy:deviceId:encryptionKey:)")));
149
149
  - (void)disconnect __attribute__((swift_name("disconnect()")));
150
150
  - (NSString *)getCalibrationPayloadJson __attribute__((swift_name("getCalibrationPayloadJson()")));
151
151
  - (NSString *)getConfigurationPayloadJson __attribute__((swift_name("getConfigurationPayloadJson()")));
152
152
  - (void)initialize __attribute__((swift_name("initialize()")));
153
153
  - (NSString *)setConfigurationPayloadJsonJson:(NSString *)json __attribute__((swift_name("setConfigurationPayloadJson(json:)")));
154
+ - (void)startDeviceScanOnDeviceJson:(void (^)(NSString *))onDeviceJson __attribute__((swift_name("startDeviceScan(onDeviceJson:)")));
155
+ - (void)stopDeviceScan __attribute__((swift_name("stopDeviceScan()")));
154
156
  - (NSString *)triggerTransmissionPayloadJson __attribute__((swift_name("triggerTransmissionPayloadJson()")));
155
157
  @end
156
158
 
@@ -1,6 +1,10 @@
1
1
  import CoreBluetooth
2
2
  import Foundation
3
3
 
4
+ #if canImport(TempivoSensorBridge)
5
+ import TempivoSensorBridge
6
+ #endif
7
+
4
8
  public protocol TempivoSensorBeaconScannerDelegate: AnyObject {
5
9
  func sensorBeaconScanner(_ scanner: TempivoSensorBeaconScanner, didDiscover discovery: SensorBeaconDiscovery)
6
10
  func sensorBeaconScanner(_ scanner: TempivoSensorBeaconScanner, didUpdateState state: CBManagerState)
@@ -10,15 +14,21 @@ public extension TempivoSensorBeaconScannerDelegate {
10
14
  func sensorBeaconScanner(_ scanner: TempivoSensorBeaconScanner, didUpdateState state: CBManagerState) {}
11
15
  }
12
16
 
13
- /// Active BLE scan for Tempivo sensor beacons (manufacturer `0x026C`). Advertising only. No GATT or connect.
17
+ /// Active BLE scan. iPhone uses the same hardware runtime scan as Tempivo.
18
+ /// Manufacturer-only CoreBluetooth misses HC7 / FW 7+ on iPhone.
14
19
  public final class TempivoSensorBeaconScanner: NSObject {
15
20
 
16
21
  public weak var delegate: TempivoSensorBeaconScannerDelegate?
17
22
 
18
23
  private var central: CBCentralManager!
19
24
  private var frameCache = SensorBeaconFrameCache()
25
+ private var lastSeen: [String: TempivoSensorSeenDevice] = [:]
20
26
  private var isScanning = false
21
27
  private var pendingStart = false
28
+ #if canImport(TempivoSensorBridge)
29
+ private let runtime = TempivoSensorBridge()
30
+ private var useRuntimeScan: Bool { !ProcessInfo.processInfo.isiOSAppOnMac }
31
+ #endif
22
32
 
23
33
  public override init() {
24
34
  super.init()
@@ -36,10 +46,25 @@ public final class TempivoSensorBeaconScanner: NSObject {
36
46
  /// Clears cached `0x03` / `0x04` frames. Does not stop an active scan.
37
47
  public func clearCache() {
38
48
  frameCache.clear()
49
+ lastSeen.removeAll()
50
+ }
51
+
52
+ public func lookup(serial: String) -> TempivoSensorSeenDevice? {
53
+ lastSeen[TempivoSensorBeaconDecoder.macKey(serial)]
39
54
  }
40
55
 
41
- /// Starts an active scan (duplicate advertisements allowed). Requires Bluetooth powered on.
56
+ /// Starts an active scan. iPhone: hardware runtime. Mac: manufacturer advertisements.
42
57
  public func startScanning() {
58
+ #if canImport(TempivoSensorBridge)
59
+ if useRuntimeScan {
60
+ runtime.initialize()
61
+ isScanning = true
62
+ runtime.startDeviceScan { [weak self] json in
63
+ self?.handleRuntimeScanJson(json)
64
+ }
65
+ return
66
+ }
67
+ #endif
43
68
  pendingStart = true
44
69
  if central.state == .poweredOn {
45
70
  beginScanningIfNeeded()
@@ -49,8 +74,12 @@ public final class TempivoSensorBeaconScanner: NSObject {
49
74
  /// Stops scanning and clears pending start.
50
75
  public func stopScanning() {
51
76
  pendingStart = false
52
- guard isScanning else { return }
53
- central.stopScan()
77
+ #if canImport(TempivoSensorBridge)
78
+ runtime.stopDeviceScan()
79
+ #endif
80
+ if isScanning {
81
+ central.stopScan()
82
+ }
54
83
  isScanning = false
55
84
  }
56
85
 
@@ -133,8 +162,55 @@ public final class TempivoSensorBeaconScanner: NSObject {
133
162
  bluetoothMacAddress: bluetoothMacAddress,
134
163
  reading: reading
135
164
  )
165
+ if let serial = (serialNumber ?? reading?.serialNumber).map({ TempivoSensorBeaconDecoder.macKey($0) }),
166
+ !serial.isEmpty {
167
+ let seen = TempivoSensorSeenDevice(
168
+ deviceId: deviceId,
169
+ bluetoothMac: bluetoothMacAddress ?? reading?.bluetoothMacAddress,
170
+ serial: serial,
171
+ firmware: reading?.firmware
172
+ )
173
+ lastSeen[serial] = seen
174
+ }
136
175
  delegate?.sensorBeaconScanner(self, didDiscover: discovery)
137
176
  }
177
+
178
+ private func handleRuntimeScanJson(_ raw: String) {
179
+ guard let data = raw.data(using: .utf8),
180
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
181
+ else { return }
182
+ let deviceId = (obj["deviceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
183
+ let serial = (obj["serialNumber"] as? String).map { TempivoSensorBeaconDecoder.macKey($0) } ?? ""
184
+ guard !deviceId.isEmpty, !serial.isEmpty else { return }
185
+ let mac = obj["bluetoothMacAddress"] as? String
186
+ let rssi = (obj["rssi"] as? NSNumber)?.intValue ?? (obj["rssi"] as? Int) ?? 0
187
+ let firmware = obj["firmware"] as? String
188
+ let reading = SensorBeaconReading(
189
+ serialNumber: serial,
190
+ bluetoothMacAddress: mac,
191
+ firmware: firmware
192
+ )
193
+ rememberSeen(deviceId: deviceId, bluetoothMac: mac, serial: serial, firmware: firmware)
194
+ delegate?.sensorBeaconScanner(
195
+ self,
196
+ didDiscover: SensorBeaconDiscovery(
197
+ deviceId: deviceId,
198
+ rssi: rssi,
199
+ serialNumber: serial,
200
+ bluetoothMacAddress: mac,
201
+ reading: reading
202
+ )
203
+ )
204
+ }
205
+
206
+ private func rememberSeen(deviceId: String, bluetoothMac: String?, serial: String, firmware: String?) {
207
+ lastSeen[serial] = TempivoSensorSeenDevice(
208
+ deviceId: deviceId,
209
+ bluetoothMac: bluetoothMac,
210
+ serial: serial,
211
+ firmware: firmware
212
+ )
213
+ }
138
214
  }
139
215
 
140
216
  extension TempivoSensorBeaconScanner: CBCentralManagerDelegate {
@@ -101,3 +101,18 @@ public struct SensorBeaconDiscovery: Sendable, Equatable {
101
101
  self.reading = reading
102
102
  }
103
103
  }
104
+
105
+ /// Last advertisement used to resolve GATT connect (scan device id + firmware).
106
+ public struct TempivoSensorSeenDevice: Sendable, Equatable {
107
+ public let deviceId: String
108
+ public let bluetoothMac: String?
109
+ public let serial: String
110
+ public let firmware: String?
111
+
112
+ public init(deviceId: String, bluetoothMac: String?, serial: String, firmware: String?) {
113
+ self.deviceId = deviceId
114
+ self.bluetoothMac = bluetoothMac
115
+ self.serial = serial
116
+ self.firmware = firmware
117
+ }
118
+ }
@@ -17,7 +17,21 @@ public struct TempivoSensorError: Error, LocalizedError, Sendable {
17
17
 
18
18
  public init(_ code: TempivoSensorErrorCode, _ message: String) {
19
19
  self.code = code
20
- self.message = message
20
+ self.message = Self.sanitizeClientMessage(message)
21
+ }
22
+
23
+ public static func sanitizeClientMessage(_ raw: String) -> String {
24
+ var text = raw
25
+ let full = NSRange(location: 0, length: text.utf16.count)
26
+ if let regex = try? NSRegularExpression(pattern: #"(?i)pl\.[A-Za-z][A-Za-z0-9_.$]*"#) {
27
+ text = regex.stringByReplacingMatches(in: text, range: full, withTemplate: "")
28
+ }
29
+ let full2 = NSRange(location: 0, length: text.utf16.count)
30
+ if let regex = try? NSRegularExpression(pattern: #"(?i)\befento[A-Za-z0-9_$]*"#) {
31
+ text = regex.stringByReplacingMatches(in: text, range: full2, withTemplate: "")
32
+ }
33
+ text = text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
34
+ return text.trimmingCharacters(in: CharacterSet(charactersIn: " :-.\n\t/"))
21
35
  }
22
36
 
23
37
  public var errorDescription: String? { message }
@@ -30,6 +30,23 @@ public enum TempivoSensorQrParser {
30
30
  public static let defaultModel = "HC7"
31
31
 
32
32
  public static func sessionType(fromModel model: String?) -> TempivoSensorSessionType {
33
+ sessionType(fromModel: model, firmware: nil)
34
+ }
35
+
36
+ public static func sessionType(fromModel model: String?, firmware: String?) -> TempivoSensorSessionType {
37
+ if let firmware {
38
+ let t = firmware.trimmingCharacters(in: .whitespacesAndNewlines)
39
+ if t.range(of: "^FW\\s*5", options: [.regularExpression, .caseInsensitive]) != nil {
40
+ return .legacy
41
+ }
42
+ if let match = t.range(of: "^(\\d+)\\.\\d+", options: .regularExpression) {
43
+ let majorStr = String(t[match]).split(separator: ".").first.flatMap { Int($0) }
44
+ if let major = majorStr {
45
+ if major >= 7 { return .modern }
46
+ if major >= 1 { return .legacy }
47
+ }
48
+ }
49
+ }
33
50
  guard let model, !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
34
51
  return .modern
35
52
  }
@@ -15,10 +15,19 @@ public final class TempivoSensorSession: @unchecked Sendable {
15
15
 
16
16
  public init() {}
17
17
 
18
- public func connect(serial: String, bluetoothMac: String, pin: String, sessionType: TempivoSensorSessionType = .modern) throws {
19
- guard let pinInt = Int32(pin.trimmingCharacters(in: .whitespacesAndNewlines)) else {
18
+ public func connect(
19
+ serial: String,
20
+ bluetoothMac: String,
21
+ pin: String,
22
+ sessionType: TempivoSensorSessionType = .modern,
23
+ deviceId: String? = nil,
24
+ firmware: String? = nil
25
+ ) throws {
26
+ let trimmedPin = pin.trimmingCharacters(in: .whitespacesAndNewlines)
27
+ guard let pinInt = Int32(trimmedPin) else {
20
28
  throw TempivoSensorError(.invalidPin, "PIN must be numeric.")
21
29
  }
30
+ let resolved = TempivoSensorQrParser.sessionType(fromModel: sessionType == .legacy ? "HC5" : "HC7", firmware: firmware)
22
31
  #if canImport(TempivoSensorBridge)
23
32
  lock.lock()
24
33
  defer { lock.unlock() }
@@ -28,7 +37,9 @@ public final class TempivoSensorSession: @unchecked Sendable {
28
37
  serial: TempivoSensorQrParser.normalizeSerial(serial),
29
38
  bluetoothMac: bluetoothMac,
30
39
  pin: pinInt,
31
- legacy: sessionType == .legacy
40
+ legacy: resolved == .legacy,
41
+ deviceId: deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
42
+ encryptionKey: trimmedPin
32
43
  )
33
44
  )
34
45
  try throwIfBridgeError(result)
@@ -38,8 +49,22 @@ public final class TempivoSensorSession: @unchecked Sendable {
38
49
  #endif
39
50
  }
40
51
 
41
- public func connect(qr: TempivoSensorQr) throws {
42
- try connect(serial: qr.serial, bluetoothMac: qr.bluetoothMac, pin: qr.pin, sessionType: qr.sessionType)
52
+ public func connect(qr: TempivoSensorQr, seen: TempivoSensorSeenDevice? = nil) throws {
53
+ let deviceId = seen?.deviceId.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
54
+ if deviceId.isEmpty {
55
+ throw TempivoSensorError(
56
+ .connectFailed,
57
+ "Sensor not in last scan. Scan first, then connect."
58
+ )
59
+ }
60
+ try connect(
61
+ serial: qr.serial,
62
+ bluetoothMac: seen?.bluetoothMac ?? qr.bluetoothMac,
63
+ pin: qr.pin,
64
+ sessionType: qr.sessionType,
65
+ deviceId: deviceId,
66
+ firmware: seen?.firmware
67
+ )
43
68
  }
44
69
 
45
70
  public func disconnect() {
@@ -148,14 +173,17 @@ public final class TempivoSensorSession: @unchecked Sendable {
148
173
 
149
174
  private func throwIfBridgeError(_ result: [String: Any]) throws {
150
175
  guard let code = intValue(result["errorCode"]), code != 0 else { return }
176
+ let detail = TempivoSensorError.sanitizeClientMessage(
177
+ (result["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
178
+ )
151
179
  switch code {
152
180
  case 1: throw TempivoSensorError(.invalidPin, "Invalid PIN.")
153
181
  case 2: throw TempivoSensorError(.notConnected, "Not connected.")
154
182
  case 3: throw TempivoSensorError(.unsupportedCommand, "Unsupported command.")
155
183
  case 4: throw TempivoSensorError(.runtimeUnavailable, "Sensor GATT is not available on this device.")
156
- case 5: throw TempivoSensorError(.connectFailed, "Could not connect.")
157
- case 6: throw TempivoSensorError(.invalidConfig, (result["message"] as? String) ?? "Invalid configuration.")
158
- default: throw TempivoSensorError(.unknown, "Sensor request failed.")
184
+ case 5: throw TempivoSensorError(.connectFailed, detail.isEmpty ? "Could not connect." : "Could not connect. \(detail)")
185
+ case 6: throw TempivoSensorError(.invalidConfig, detail.isEmpty ? "Invalid configuration." : detail)
186
+ default: throw TempivoSensorError(.unknown, detail.isEmpty ? "Sensor request failed." : "Sensor request failed. \(detail)")
159
187
  }
160
188
  }
161
189
  }
@@ -28,8 +28,13 @@ public class TempivoSensorBeaconModule: Module, TempivoSensorBeaconScannerDelega
28
28
 
29
29
  AsyncFunction("connect") { (qrJson: String) -> [String: Any] in
30
30
  do {
31
+ self.scanner?.stopScanning()
32
+ if !Thread.isMainThread {
33
+ Thread.sleep(forTimeInterval: 0.2)
34
+ }
31
35
  let qr = try TempivoSensorQrParser.parse(qrJson)
32
- try self.session.connect(qr: qr)
36
+ let seen = self.scanner?.lookup(serial: qr.serial)
37
+ try self.session.connect(qr: qr, seen: seen)
33
38
  return self.qrPayload(qr)
34
39
  } catch {
35
40
  throw self.mapError(error)
@@ -189,7 +194,11 @@ public class TempivoSensorBeaconModule: Module, TempivoSensorBeaconScannerDelega
189
194
  if let sensor = error as? TempivoSensorError {
190
195
  return SensorBeaconException(code: sensor.code.rawValue, reason: sensor.message)
191
196
  }
192
- return SensorBeaconException(code: "unknown", reason: error.localizedDescription)
197
+ let reason = TempivoSensorError.sanitizeClientMessage(error.localizedDescription)
198
+ return SensorBeaconException(
199
+ code: "unknown",
200
+ reason: reason.isEmpty ? "Sensor request failed." : reason
201
+ )
193
202
  }
194
203
  }
195
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tempivo/sensor-beacon",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Tempivo sensor BLE SDK for Expo, React Native, Android, iOS, and Node.",
5
5
  "homepage": "https://app.tempivo.com/integrations",
6
6
  "author": "Tempivo",