@tempivo/sensor-beacon 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +22 -11
  2. package/android/build.gradle +13 -5
  3. package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +5 -13
  4. package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +2 -4
  5. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +17 -6
  6. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +50 -83
  7. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +46 -18
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +19 -6
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +0 -17
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +59 -2
  11. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +0 -9
  12. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +9 -30
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.js +3 -3
  15. package/dist/native-helpers.d.ts +0 -1
  16. package/dist/native-helpers.js +3 -18
  17. package/dist/native-module.js +0 -1
  18. package/dist/normalize.d.ts +6 -0
  19. package/dist/normalize.js +29 -0
  20. package/dist/profile.d.ts +5 -0
  21. package/dist/profile.js +47 -1
  22. package/dist/qr.d.ts +2 -6
  23. package/dist/qr.js +2 -19
  24. package/dist/session-types.d.ts +8 -3
  25. package/dist/tempivo-sensor-beacon.aar +0 -0
  26. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +1 -3
  27. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  28. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +1 -3
  29. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  30. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +12 -2
  31. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconScanner.swift +4 -80
  32. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +0 -15
  33. package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +1 -15
  34. package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +57 -3
  35. package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +0 -17
  36. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +8 -36
  37. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +10 -1
  38. package/ios/TempivoSensorBeaconModule.swift +2 -11
  39. package/package.json +6 -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`. `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.
85
+ `startScan` and `connect` request Bluetooth permission. Sticker PIN is required on `connect`. Wrong PIN is `invalidPin`.
86
86
 
87
87
  If a bundler misses native BLE, import `@tempivo/sensor-beacon/react-native`.
88
88
 
@@ -110,9 +110,11 @@ 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. Weekday windows apply on FW 7+. HC5 is always 24/7. |
113
+ | `schedule` | `{ "always": true }` is 24/7. |
114
114
 
115
- Ignored over BLE: `slug`, `name`, `measurementIntervalMinutes`, `transmissionIntervalSeconds`. Do not pass `alarmRules`.
115
+ Ignored over BLE: `slug`, `name`. Do not pass `alarmRules`.
116
+
117
+ Optional intervals (same limits as Cellular API): `measurementIntervalMinutes` (60–600), `transmissionIntervalSeconds` (3600–604800). When both are set, uplink must be at most 60× the measurement interval. Omitted fields leave the device’s current intervals unchanged.
116
118
 
117
119
  **Each `temperatureAlerts[]` item**
118
120
 
@@ -142,20 +144,20 @@ Ignored over BLE: `slug`, `name`, `measurementIntervalMinutes`, `transmissionInt
142
144
  Sticker QR is JSON. Parse with `parseSensorQrJson`.
143
145
 
144
146
  ```json
145
- { "sn": "282C024F0012", "pin": "111111", "model": "HC7" }
147
+ { "sn": "282C024F0012", "pin": "111111" }
146
148
  ```
147
149
 
148
150
  | Field | Type | Meaning |
149
151
  |------|------|---------|
150
152
  | `serial` | string | Device serial, 12 hex chars, no colons. From QR `sn`. |
151
153
  | `pin` | string | Sticker PIN. Required on `connect`. Also accepted as QR `resetCode`. |
152
- | `model` | string | Sticker model. Omitted QR `model` becomes **HC7**. `HC5` selects legacy GATT. Same `connect` call works for both. |
153
- | `sessionType` | `modern` \| `legacy` | GATT path. HC7 / firmware 7+ is `modern`. HC5 / firmware 6.x is `legacy`. |
154
+ | `model` | string | Optional. Firmware from the last scan wins when present. |
155
+ | `sessionType` | `modern` \| `legacy` | GATT path. Firmware 7+ is `modern`. Firmware 6.x is `legacy`. |
154
156
  | `bluetoothMac` | string | BLE MAC with colons, derived from `serial`. |
155
157
 
156
158
  ## Scan result
157
159
 
158
- iOS uses the same hardware scan as Tempivo. Manufacturer ads (`0x026C`) alone often miss HC7 / FW 7+. Android still filters `0x026C`.
160
+ Filter manufacturer data on company id **0x026C**. Active scanning is on so scan-response packets arrive.
159
161
 
160
162
  | Field | Meaning |
161
163
  |------|---------|
@@ -170,10 +172,10 @@ iOS uses the same hardware scan as Tempivo. Manufacturer ads (`0x026C`) alone of
170
172
 
171
173
  | Field | Meaning |
172
174
  |------|---------|
173
- | `firmware` | `major.minor.patch`, e.g. `7.3.4`. |
175
+ | `firmware` | `major.minor.patch`, e.g. `7.3.4`. Firmware 7+ is modern GATT. |
174
176
  | `batteryOk` | Battery status flag. |
175
177
  | `encryptionEnabled` | Advertisement payload encryption flag. |
176
- | `cellularStatus` | Advert attachment bits only: `ble_only`, `cell_ok`, `no_server`, or `net_issue`. Not ECL, SINR, or RSSI. |
178
+ | `cellularStatus` | `ble_only`, `cell_ok`, `no_server`, or `net_issue`. |
177
179
  | `readingTimestampUnix` | Sample time as unix seconds, or null. |
178
180
  | `readingTimestampIso` | Same instant as UTC ISO-8601, or null. |
179
181
  | `measurementIntervalSeconds` | Sample interval, in seconds. |
@@ -206,7 +208,7 @@ import {
206
208
  parseSensorConfigurationJson,
207
209
  } from '@tempivo/sensor-beacon';
208
210
 
209
- const qr = parseSensorQrJson('{"sn":"282C024F0012","pin":"111111","model":"HC7"}');
211
+ const qr = parseSensorQrJson('{"sn":"282C024F0012","pin":"111111"}');
210
212
  const reading = decodeSensorBeaconPayload(normalizeManufacturerBytes(advertisementBytes));
211
213
  const cfg = parseSensorConfigurationJson(/* config JSON above */);
212
214
  ```
@@ -215,7 +217,16 @@ Invalid QR or config throws `TempivoSensorError` (`invalidQr`, `invalidPin`, `in
215
217
 
216
218
  ## Android (Kotlin)
217
219
 
218
- AAR: `dist/tempivo-sensor-beacon.aar` after `npm run build:android`. Sources: `android-aar/library`. Request `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` (API 31+) or location on older APIs.
220
+ AAR after `npm run build:android` (from `packages/tempivo-sensor-beacon`):
221
+
222
+ | Path | Use |
223
+ |------|-----|
224
+ | `android/libs/tempivo-sensor-beacon.aar` | Expo / React Native autolinking (`android/build.gradle`) |
225
+ | `dist/tempivo-sensor-beacon.aar` | Same file; handy for plain Android `files(...)` deps |
226
+
227
+ Sources: `android-aar/library`. Request `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` (API 31+) or location on older APIs.
228
+
229
+ **Scan:** temperature and other live values are in scan-response frame **`0x04`** (`telemetry.readings`). Frame **`0x03`** alone fills firmware / battery / cellular but leaves `readings` empty until an active scan delivers `0x04` (usually within a second or two). Keep scanning; do not stop on the first event.
219
230
 
220
231
  ```kotlin
221
232
  val qr = TempivoSensorQrParser.parse(stickerQrText)
@@ -4,14 +4,14 @@ plugins {
4
4
  }
5
5
 
6
6
  group = 'com.tempivo.sensor.beacon'
7
- version = '0.3.1'
7
+ version = '0.3.3'
8
8
 
9
9
  android {
10
10
  namespace "expo.modules.tempivosensorbeacon"
11
11
  defaultConfig {
12
12
  minSdk 26
13
13
  versionCode 1
14
- versionName "0.3.1"
14
+ versionName "0.3.3"
15
15
  }
16
16
  compileOptions {
17
17
  sourceCompatibility JavaVersion.VERSION_17
@@ -24,9 +24,17 @@ android {
24
24
  }
25
25
  }
26
26
 
27
- def aarFile = file("libs/tempivo-sensor-beacon.aar")
28
- if (!aarFile.exists()) {
29
- throw new GradleException("Missing android/libs/tempivo-sensor-beacon.aar. From the package root run: npm run build:android")
27
+ // Prefer android/libs (what Expo autolinking uses). Fall back to dist/ from `npm run build:android`.
28
+ def aarCandidates = [
29
+ file("${projectDir}/libs/tempivo-sensor-beacon.aar"),
30
+ file("${projectDir}/../dist/tempivo-sensor-beacon.aar"),
31
+ ]
32
+ def aarFile = aarCandidates.find { it.exists() }
33
+ if (aarFile == null) {
34
+ throw new GradleException(
35
+ "Missing tempivo-sensor-beacon.aar (looked in android/libs/ and dist/). " +
36
+ "From packages/tempivo-sensor-beacon run: npm run build:android"
37
+ )
30
38
  }
31
39
 
32
40
  dependencies {
@@ -54,15 +54,8 @@ 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
- }
63
57
  TempivoSensorQr qr = TempivoSensorQrParser.INSTANCE.parse(qrJson);
64
- TempivoSensorBeaconScanner.SeenDevice seen = scanner != null ? scanner.lookup(qr.getSerial()) : null;
65
- requireSession(context).connectBlocking(qr, seen);
58
+ requireSession(context).connectBlocking(qr);
66
59
  return qrMap(qr);
67
60
  } catch (Throwable error) {
68
61
  throw wrap(error);
@@ -142,14 +135,13 @@ final class SensorBeaconNative {
142
135
  }
143
136
  TempivoSensorException sensor = findSensor(error);
144
137
  if (sensor != null) {
145
- return new NativeError(toJs(sensor.getCode()), TempivoSensorException.sanitizeClientMessage(sensor.getMessage()), null);
138
+ return new NativeError(toJs(sensor.getCode()), sensor.getMessage(), error);
146
139
  }
147
140
  if (error instanceof TempivoSensorBeaconScanner.ScanNotPermittedException) {
148
- String permitted = TempivoSensorException.sanitizeClientMessage(error.getMessage());
149
- return new NativeError("runtimeUnavailable", permitted.isEmpty() ? "Bluetooth permission is required." : permitted, null);
141
+ return new NativeError("runtimeUnavailable", error.getMessage(), error);
150
142
  }
151
- String message = TempivoSensorException.sanitizeClientMessage(error.getMessage());
152
- return new NativeError("unknown", message.isEmpty() ? "Sensor request failed." : message, null);
143
+ String message = error.getMessage();
144
+ return new NativeError("unknown", message != null ? message : "Sensor request failed.", error);
153
145
  }
154
146
 
155
147
  private static TempivoSensorException findSensor(Throwable error) {
@@ -8,7 +8,6 @@ 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
12
11
 
13
12
  class TempivoSensorBeaconModule : Module() {
14
13
  private val native = SensorBeaconNative()
@@ -117,8 +116,7 @@ private fun Throwable.toSensorException(): CodedException {
117
116
  if (this is CodedException) return this
118
117
  val native = this as? SensorBeaconNative.NativeError ?: cause as? SensorBeaconNative.NativeError
119
118
  if (native != null) {
120
- return CodedException(native.code, native.message, null)
119
+ return CodedException(native.code, native.message, this)
121
120
  }
122
- val clean = TempivoSensorException.sanitizeClientMessage(message)
123
- return CodedException("unknown", clean.ifEmpty { "Sensor request failed." }, null)
121
+ return CodedException("unknown", message ?: "Sensor request failed.", this)
124
122
  }
@@ -1,5 +1,6 @@
1
1
  package com.tempivo.sensor.beacon
2
2
 
3
+ import pl.efento.mobile.bluetooth.model.MeasurementPeriod
3
4
  import pl.efento.mobile.bluetooth.model.sensor.Action
4
5
  import pl.efento.mobile.bluetooth.model.sensor.DisabledCondition
5
6
  import pl.efento.mobile.bluetooth.model.sensor.HighThresholdCondition
@@ -93,12 +94,9 @@ internal object PartnerBleRules {
93
94
  return rules to calendars
94
95
  }
95
96
 
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
+ fun decompile(cfg: SensorConfiguration): TempivoSensorConfiguration {
100
98
  val padded = MutableList(12) { i ->
101
- rules.getOrNull(i)
99
+ cfg.rules.orEmpty().getOrNull(i)
102
100
  ?: Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION)
103
101
  }
104
102
  val orRefs = orSelectedPairs(padded)
@@ -167,7 +165,20 @@ internal object PartnerBleRules {
167
165
  }
168
166
  }
169
167
 
170
- return TempivoSensorConfiguration(alerts, decompileSchedule(calendars))
168
+ return TempivoSensorConfiguration(
169
+ alerts,
170
+ decompileSchedule(cfg.ruleCalendars),
171
+ measurementIntervalMinutes = decompileMeasurementMinutes(cfg.measurementPeriod),
172
+ transmissionIntervalSeconds = cfg.transmissionInterval?.value?.takeIf { it > 0 },
173
+ )
174
+ }
175
+
176
+ private fun decompileMeasurementMinutes(period: MeasurementPeriod?): Double? {
177
+ if (period == null) return null
178
+ val factor = if (period.factor == 0) 1 else period.factor
179
+ val sec = period.base * factor
180
+ if (sec <= 0) return null
181
+ return sec / 60.0
171
182
  }
172
183
 
173
184
  private fun orSelectedPairs(rules: List<Rule>): List<Pair<Int, Int>> {
@@ -2,17 +2,17 @@ package com.tempivo.sensor.beacon
2
2
 
3
3
  import android.app.Application
4
4
  import kotlinx.coroutines.delay
5
- import kotlinx.coroutines.withTimeout
6
5
  import pl.efento.mobile.bluetooth.EfentoBluetooth
7
6
  import pl.efento.mobile.bluetooth.api.SensorConnection
8
7
  import pl.efento.mobile.bluetooth.api.SensorLegacyConnection
9
8
  import pl.efento.mobile.bluetooth.exception.InvalidResetCodeException
10
9
  import pl.efento.mobile.bluetooth.exception.UnsupportedCommandException
11
10
  import pl.efento.mobile.bluetooth.model.BluetoothMacAddress
12
- import pl.efento.mobile.bluetooth.model.sensor.DisabledCondition
11
+ import pl.efento.mobile.bluetooth.model.MeasurementPeriod
13
12
  import pl.efento.mobile.bluetooth.model.sensor.Rule
14
13
  import pl.efento.mobile.bluetooth.model.sensor.RuleCalendar
15
14
  import pl.efento.mobile.bluetooth.model.sensor.SensorConfiguration
15
+ import pl.efento.mobile.bluetooth.model.sensor.cellular.TransmissionInterval
16
16
 
17
17
  /**
18
18
  * Internal adapter to the sensor BLE runtime. Keep vendor types inside this file.
@@ -33,26 +33,15 @@ internal object SensorBleRuntime {
33
33
  fun mapError(e: Throwable): TempivoSensorException {
34
34
  if (e is TempivoSensorException) return e
35
35
  val className = e.javaClass.name
36
- val detail = clientSafeDetail(e.message)
37
36
  return when {
38
37
  e is InvalidResetCodeException || className.contains("InvalidResetCode") ->
39
- TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "Invalid PIN.")
38
+ TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "Invalid PIN.", e)
40
39
  e is UnsupportedCommandException || className.contains("UnsupportedCommand") ->
41
- TempivoSensorException(TempivoSensorException.Code.UNSUPPORTED_COMMAND, "Unsupported command.")
40
+ TempivoSensorException(TempivoSensorException.Code.UNSUPPORTED_COMMAND, "Unsupported command.", e)
42
41
  className.contains("NotConnected") ->
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
- )
42
+ TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected.", e)
51
43
  else ->
52
- TempivoSensorException(
53
- TempivoSensorException.Code.UNKNOWN,
54
- if (detail.isNotEmpty()) "Sensor request failed. $detail" else "Sensor request failed.",
55
- )
44
+ TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Sensor request failed.", e)
56
45
  }
57
46
  }
58
47
 
@@ -62,57 +51,32 @@ internal object SensorBleRuntime {
62
51
  bluetoothMac: String,
63
52
  pin: Int,
64
53
  legacy: Boolean,
65
- deviceId: String = serial,
66
- encryptionKey: String = "",
67
54
  ): Session {
68
55
  initialize(application)
69
56
  val mac = BluetoothMacAddress(bluetoothMac)
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
- }
57
+ // PIN stays on the connection: set, trigger, calibration writes, and many other commands check it.
58
+ return if (legacy) {
59
+ val c =
60
+ EfentoBluetooth.sensorLegacyConnection(
61
+ deviceID = serial,
62
+ bluetoothMacAddress = mac,
63
+ resetCode = pin,
64
+ encryptionKey = "",
65
+ )
66
+ c.connect()
67
+ if (pin >= 0) c.resetCode = pin
68
+ Session.Legacy(c)
69
+ } else {
70
+ val c =
71
+ EfentoBluetooth.sensorConnection(
72
+ deviceID = serial,
73
+ bluetoothMacAddress = mac,
74
+ resetCode = pin,
75
+ encryptionKey = "",
76
+ )
77
+ c.connect()
78
+ if (pin >= 0) c.resetCode = pin
79
+ Session.Modern(c)
116
80
  }
117
81
  }
118
82
 
@@ -124,34 +88,37 @@ internal object SensorBleRuntime {
124
88
  }
125
89
  }
126
90
 
127
- suspend fun readPartnerConfiguration(session: Session): TempivoSensorConfiguration {
91
+ suspend fun readConfiguration(session: Session): SensorConfiguration {
128
92
  return when (session) {
129
- is Session.Modern -> PartnerBleRules.decompile(modernGetConfiguration(session.connection))
93
+ is Session.Modern -> modernGetConfiguration(session.connection)
130
94
  is Session.Legacy ->
131
- PartnerBleRules.decompile(session.connection.commands.getRules { }, emptyList())
95
+ throw TempivoSensorException(
96
+ TempivoSensorException.Code.UNSUPPORTED_COMMAND,
97
+ "Configuration rules require a current-generation sensor session.",
98
+ )
132
99
  }
133
100
  }
134
101
 
135
- suspend fun writeRules(session: Session, rules: List<Rule>, calendars: List<RuleCalendar>) {
102
+ suspend fun writeConfiguration(session: Session, cfg: TempivoSensorConfiguration) {
103
+ val (rules, calendars) = compileRules(cfg)
136
104
  when (session) {
137
105
  is Session.Modern -> {
138
106
  val base = modernGetConfiguration(session.connection)
139
- val next = base.copy(rules = rules.take(12), ruleCalendars = calendars.take(6))
107
+ var next = base.copy(rules = rules.take(12), ruleCalendars = calendars.take(6))
108
+ cfg.measurementIntervalMinutes?.let { minutes ->
109
+ val baseSec = (minutes * 60.0).toInt().coerceAtLeast(1)
110
+ next = next.copy(measurementPeriod = MeasurementPeriod(baseSec, 1))
111
+ }
112
+ cfg.transmissionIntervalSeconds?.let { sec ->
113
+ next = next.copy(transmissionInterval = TransmissionInterval(sec))
114
+ }
140
115
  session.connection.commands.setConfiguration(next) { }
141
116
  }
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) { } }
117
+ is Session.Legacy ->
118
+ throw TempivoSensorException(
119
+ TempivoSensorException.Code.UNSUPPORTED_COMMAND,
120
+ "Configuration rules require a current-generation sensor session.",
121
+ )
155
122
  }
156
123
  }
157
124
 
@@ -12,12 +12,18 @@ import kotlin.math.floor
12
12
  *
13
13
  * Android [android.bluetooth.le.ScanRecord.getManufacturerSpecificData] returns the payload **after**
14
14
  * the 2-byte company id — i.e. starts with `0x03` / `0x04`, not `6C 02`.
15
+ *
16
+ * Prefer [manufacturerPayloadsFromScanRecordBytes]: `getManufacturerSpecificData(0x026C)` can keep only
17
+ * one blob when both advertisement (`0x03`) and scan response (`0x04`) share the same company id.
15
18
  */
16
19
  object TempivoSensorBeaconDecoder {
17
20
 
18
21
  /** Bluetooth SIG company identifier (little-endian on air: `6C 02`). */
19
22
  const val MANUFACTURER_COMPANY_ID: Int = 0x026C
20
23
 
24
+ /** AD type for Manufacturer Specific Data. */
25
+ private const val AD_TYPE_MANUFACTURER: Int = 0xFF
26
+
21
27
  const val ADV_FRAME_03_LEN = 22
22
28
 
23
29
  private data class MeasureSpec(
@@ -62,20 +68,6 @@ object TempivoSensorBeaconDecoder {
62
68
  0x22 to MeasureSpec("Current", 0.01, 1, true),
63
69
  )
64
70
 
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
-
79
71
  /** 12-hex serial from FW6 `0x03` bytes 1–6 (same as BLE MAC on supported firmware). */
80
72
  fun serialKeyFromAdv03(frame: ByteArray): String? {
81
73
  if (frame.size < 7 || (frame[0].toInt() and 0xFF) != 0x03) return null
@@ -95,6 +87,35 @@ object TempivoSensorBeaconDecoder {
95
87
  return keys.toList()
96
88
  }
97
89
 
90
+ /**
91
+ * Extract every Tempivo manufacturer payload from a raw [android.bluetooth.le.ScanRecord] byte
92
+ * array (AD length/type structures). Each returned blob starts with frame tag `0x03` / `0x04`
93
+ * (company id already stripped), matching [getManufacturerSpecificData].
94
+ */
95
+ fun manufacturerPayloadsFromScanRecordBytes(scanRecordBytes: ByteArray?): List<ByteArray> {
96
+ if (scanRecordBytes == null || scanRecordBytes.isEmpty()) return emptyList()
97
+ val out = mutableListOf<ByteArray>()
98
+ var i = 0
99
+ val n = scanRecordBytes.size
100
+ while (i < n) {
101
+ val len = scanRecordBytes[i].toInt() and 0xFF
102
+ if (len == 0) break
103
+ if (i + 1 + len > n) break
104
+ val type = scanRecordBytes[i + 1].toInt() and 0xFF
105
+ if (type == AD_TYPE_MANUFACTURER && len >= 3) {
106
+ val company =
107
+ (scanRecordBytes[i + 2].toInt() and 0xFF) or
108
+ ((scanRecordBytes[i + 3].toInt() and 0xFF) shl 8)
109
+ if (company == MANUFACTURER_COMPANY_ID && len > 3) {
110
+ // AD length counts type + company + payload; payload starts at i+4.
111
+ out += scanRecordBytes.copyOfRange(i + 4, i + 1 + len)
112
+ }
113
+ }
114
+ i += 1 + len
115
+ }
116
+ return out
117
+ }
118
+
98
119
  /**
99
120
  * @return true if any `0x03` / `0x04` frame was stored.
100
121
  */
@@ -115,7 +136,7 @@ object TempivoSensorBeaconDecoder {
115
136
  val frame = fr.copyOf(ADV_FRAME_03_LEN)
116
137
  val keys = lookupKeys(primaryMacKey, serialKeyFromAdv03(frame))
117
138
  for (key in keys) {
118
- if (last03[key] != frame) {
139
+ if (!last03[key].contentEquals(frame)) {
119
140
  last03[key] = frame
120
141
  changed = true
121
142
  }
@@ -129,16 +150,23 @@ object TempivoSensorBeaconDecoder {
129
150
  if (existing != null && existing.isNotEmpty() && (existing[0].toInt() and 0xFF) == 0x03) {
130
151
  continue
131
152
  }
132
- if (last03[key] != fr) {
153
+ if (!last03[key].contentEquals(fr)) {
133
154
  last03[key] = fr
134
155
  changed = true
135
156
  }
136
157
  }
137
158
  }
138
159
  0x04 -> {
139
- val keys = lookupKeys(primaryMacKey)
160
+ // Also index under any serial already learned from `0x03` for this MAC.
161
+ val serialFromCached03 =
162
+ lookupKeys(primaryMacKey)
163
+ .asSequence()
164
+ .mapNotNull { last03[it] }
165
+ .mapNotNull { serialKeyFromAdv03(it) }
166
+ .firstOrNull()
167
+ val keys = lookupKeys(primaryMacKey, serialFromCached03)
140
168
  for (key in keys) {
141
- if (last04[key] != fr) {
169
+ if (!last04[key].contentEquals(fr)) {
142
170
  last04[key] = fr
143
171
  changed = true
144
172
  }
@@ -27,7 +27,6 @@ class TempivoSensorBeaconScanner(
27
27
 
28
28
  class ScanNotPermittedException(message: String) : Exception(message)
29
29
 
30
- private val appContext = context.applicationContext
31
30
  data class SeenDevice(
32
31
  val deviceId: String,
33
32
  val bluetoothMac: String,
@@ -36,6 +35,7 @@ class TempivoSensorBeaconScanner(
36
35
  val encryptionEnabled: Boolean?,
37
36
  )
38
37
 
38
+ private val appContext = context.applicationContext
39
39
  private val advLast03 = ConcurrentHashMap<String, ByteArray>()
40
40
  private val advLast04 = ConcurrentHashMap<String, ByteArray>()
41
41
  private val lastSeen = ConcurrentHashMap<String, SeenDevice>()
@@ -106,13 +106,26 @@ class TempivoSensorBeaconScanner(
106
106
  fun isScanning(): Boolean = scanning
107
107
 
108
108
  private fun handleScanResult(result: ScanResult) {
109
- val md =
110
- result.scanRecord?.getManufacturerSpecificData(TempivoSensorBeaconDecoder.MANUFACTURER_COMPANY_ID)
111
- ?: return
109
+ val record = result.scanRecord ?: return
112
110
  val addr = result.device?.address ?: return
113
111
  val key = TempivoSensorBeaconDecoder.macKey(addr)
114
- val changed =
115
- TempivoSensorBeaconDecoder.ingestManufacturerPayload(md, key, advLast03, advLast04)
112
+ // Raw AD parse keeps both 0x03 and 0x04 when they share company id 0x026C.
113
+ // getManufacturerSpecificData(0x026C) can retain only one of the two.
114
+ val payloads =
115
+ TempivoSensorBeaconDecoder.manufacturerPayloadsFromScanRecordBytes(record.bytes)
116
+ .ifEmpty {
117
+ val single =
118
+ record.getManufacturerSpecificData(TempivoSensorBeaconDecoder.MANUFACTURER_COMPANY_ID)
119
+ if (single != null) listOf(single) else emptyList()
120
+ }
121
+ if (payloads.isEmpty()) return
122
+
123
+ var changed = false
124
+ for (md in payloads) {
125
+ if (TempivoSensorBeaconDecoder.ingestManufacturerPayload(md, key, advLast03, advLast04)) {
126
+ changed = true
127
+ }
128
+ }
116
129
  if (!changed && !advLast03.containsKey(key) && !advLast04.containsKey(key)) {
117
130
  return
118
131
  }
@@ -1,27 +1,10 @@
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
-
15
3
  class TempivoSensorException(
16
4
  val code: Code,
17
5
  message: String,
18
6
  cause: Throwable? = null,
19
7
  ) : Exception(message, cause) {
20
- companion object {
21
- @JvmStatic
22
- fun sanitizeClientMessage(raw: String?): String = clientSafeDetail(raw)
23
- }
24
-
25
8
  enum class Code {
26
9
  INVALID_PIN,
27
10
  INVALID_QR,