@tempivo/sensor-beacon 0.4.3 → 1.1.0

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 (45) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +156 -6
  3. package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +17 -2
  4. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/BleConnectCredentials.kt +25 -0
  5. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SdkMeasurementFormat.kt +111 -0
  6. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +21 -12
  7. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleVendor.kt +28 -0
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorModelHint.kt +20 -0
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorSdkScanTelemetry.kt +5 -31
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +1 -1
  11. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +5 -6
  12. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +2 -0
  13. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +20 -1
  14. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +19 -7
  15. package/dist/connect-helpers.d.ts +52 -0
  16. package/dist/connect-helpers.js +153 -0
  17. package/dist/decoder.d.ts +1 -0
  18. package/dist/decoder.js +7 -1
  19. package/dist/index.d.ts +6 -2
  20. package/dist/index.js +4 -2
  21. package/dist/model.d.ts +5 -0
  22. package/dist/model.js +25 -0
  23. package/dist/native-helpers.js +5 -2
  24. package/dist/native-module.js +2 -2
  25. package/dist/native-types.d.ts +2 -0
  26. package/dist/profile.d.ts +8 -1
  27. package/dist/profile.js +42 -19
  28. package/dist/react-native.d.ts +5 -1
  29. package/dist/react-native.js +3 -1
  30. package/dist/session-types.d.ts +2 -1
  31. package/dist/tempivo-sensor-beacon.aar +0 -0
  32. package/ios/Frameworks/TempivoSensorBridge.xcframework/Info.plist +4 -4
  33. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +28 -1
  34. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  35. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +28 -1
  36. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  37. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +8 -1
  38. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconScanner.swift +10 -1
  39. package/ios/Sources/TempivoSensorBeacon/TempivoSensorModelHint.swift +29 -0
  40. package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +37 -13
  41. package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +38 -1
  42. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +24 -12
  43. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +1 -3
  44. package/ios/TempivoSensorBeaconModule.swift +83 -16
  45. package/package.json +4 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ Partner-facing changes to `@tempivo/sensor-beacon`. Logging starts **2026-09-01**; earlier npm versions are not listed here.
4
+
5
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [Unreleased]
8
+
9
+ <!-- Publish trigger: 1.1.0 not yet on npm until CI Trusted Publisher (ci.yml) runs. -->
10
+
11
+ ## [1.1.0] - 2026-09-02
12
+
13
+ ### Changed
14
+
15
+ - First **1.x** release line (API compatible with 0.4.7 for partners already on that series).
16
+ - README / package description: Node is decode and config helpers only (no BLE radio). Native scan and GATT remain Expo, React Native, Android, and iOS.
17
+
18
+ ## [0.4.7] - 2026-09-01
19
+
20
+ ### Added
21
+
22
+ - Shared connect helpers: `tryParseSensorQrJson`, `parseSensorQrForConnect`, `buildSensorQrConnectJson`, `draftSensorQrConnectJsonFromScanDevice`, `mergeScanTargetIntoSensorQr`, `sensorQrSerialMatchesSelected`, `resolveSensorBleConnectCredentialsFromPin`, `formatSensorReadingLine`, `normalizeSensorStickerModel`.
23
+ - `parseSensorConfigurationFromDevice` / `parseSensorConfigurationFromDeviceJson` for relaxed interval parsing on GATT readback.
24
+ - README: config examples with `measurementIntervalMinutes` and `transmissionIntervalSeconds`, connect tips, alert slot budget, API profile helper.
25
+
26
+ ### Changed
27
+
28
+ - `triggerTransmission()` returns `{ ok }` only (`supported` removed).
29
+ - Native scan `readings[].typeHex` is a human label (e.g. `temperature`); `text` is value + unit (e.g. `23.5 °C`).
30
+ - Default product model is **HC7** when sticker firmware is missing or `0.0.0`.
31
+ - iOS `getCalibration()` always includes `laboratoryCalibrationDate` and `laboratoryCalibrationTimestamp` (null when unset).
32
+
33
+ ### Fixed
34
+
35
+ - iOS scan telemetry: live temperatures in `telemetry.readings` during active scan.
36
+ - BLE connect: use scan `deviceId` + MAC and PIN-derived encryption key (not numeric reset code alone).
37
+ - `getConfiguration()` no longer rejects devices with sub-60-minute measurement intervals already stored on hardware.
38
+ - Android `triggerTransmission()`: unsupported firmware command returns `{ ok: false }` instead of throwing.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @tempivo/sensor-beacon
2
2
 
3
- BLE for Tempivo sensors. One package for Expo, React Native, Android, iOS, and Node.
3
+ BLE for Tempivo sensors. Native scan + GATT on **Expo / React Native / Android / iOS**. **Node** gets decode and config helpers only (no radio).
4
4
 
5
5
  ## Expo and React Native
6
6
 
@@ -84,15 +84,128 @@ sub.remove();
84
84
 
85
85
  `startScan` and `connect` request Bluetooth permission. Sticker PIN is required on `connect`. Wrong PIN is `invalidPin`.
86
86
 
87
+ **Connect tips:** stop scan before GATT connect. Pass the scan row’s `deviceId` and MAC when they differ from the sticker serial (`parseSensorQrForConnect`). 5–6 digit PINs map to an encryption key internally; numeric `resetCode` alone is not enough on all devices.
88
+
87
89
  If a bundler misses native BLE, import `@tempivo/sensor-beacon/react-native`.
88
90
 
91
+ The Expo config plugin sets **minSdk 26**, Android BLE permissions, and **`NSBluetoothAlwaysUsageDescription`**. Add **`NSCameraUsageDescription`** yourself if you scan sticker QR with the device camera.
92
+
93
+ **Native BLE API**
94
+
95
+ | Export | Purpose |
96
+ |--------|---------|
97
+ | `isNativeSensorBeaconAvailable()` | `true` when the Expo dev build / RN app includes the native module (false in Expo Go and most web builds). |
98
+ | `requestPermissions()` | Request Bluetooth permission explicitly; `startScan` and `connect` call this automatically. |
99
+ | `startScan` / `stopScan` / `isScanning` | Active BLE scan for manufacturer `0x026C`. |
100
+ | `addDeviceFoundListener` | Callback per discovered device (includes `telemetry` when decoded). |
101
+ | `connect` / `disconnect` | GATT session with parsed QR (`TempivoSensorQr`). |
102
+ | `getConfiguration` / `setConfigurationJson` | Read / write alert rules, schedule, optional intervals. |
103
+ | `triggerTransmission` | Ask firmware to uplink now; returns `{ ok }`. |
104
+ | `getCalibration` | Laboratory calibration date from extended config. |
105
+
106
+ ### QR + connect helpers
107
+
108
+ Shared with the Tempivo app (same sticker JSON, PIN → encryption key, serial match checks):
109
+
110
+ ```ts
111
+ import {
112
+ parseSensorQrForConnect,
113
+ draftSensorQrConnectJsonFromScanDevice,
114
+ buildSensorQrConnectJson,
115
+ sensorQrSerialMatchesSelected,
116
+ formatSensorReadingLine,
117
+ resolveSensorBleConnectCredentialsFromPin,
118
+ } from '@tempivo/sensor-beacon/react-native';
119
+
120
+ // After scan: draft QR from a row (model from sticker or FW hint)
121
+ const draft = draftSensorQrConnectJsonFromScanDevice(device);
122
+
123
+ // Connect: parse sticker + merge selected row deviceId / MAC
124
+ const qr = parseSensorQrForConnect(stickerJson, selectedDevice);
125
+ await connect(qr);
126
+
127
+ // Scan list: same reading line as in-app
128
+ formatSensorReadingLine({ typeHex: 'temperature', text: '23.5 °C' });
129
+ ```
130
+
131
+ Use `tryParseSensorQrJson` for camera QR (invalid JSON → null). Use `parseSensorQrJson` when the payload must be valid HC7 sticker JSON.
132
+
89
133
  ## Config JSON
90
134
 
91
135
  Same as Cellular API config profiles (`temperatureAlerts` + `schedule`). You can build it yourself. Profiles are only for REST reuse.
92
136
 
137
+ **Alerts only** (intervals unchanged on the device):
138
+
139
+ ```json
140
+ {
141
+ "schedule": { "always": true },
142
+ "temperatureAlerts": [
143
+ {
144
+ "type": "range",
145
+ "channel": "probe",
146
+ "lowC": -30,
147
+ "highC": -10,
148
+ "hysteresisC": 0.5,
149
+ "transmitOnBreach": true,
150
+ "transmitOnReturn": true
151
+ }
152
+ ]
153
+ }
154
+ ```
155
+
156
+ **Min and max alerts** (single threshold; no `transmitOnReturn`):
157
+
158
+ ```json
159
+ {
160
+ "schedule": { "always": true },
161
+ "temperatureAlerts": [
162
+ {
163
+ "type": "min",
164
+ "channel": "probe",
165
+ "minC": -25,
166
+ "hysteresisC": 0.5,
167
+ "transmitOnBreach": true
168
+ },
169
+ {
170
+ "type": "max",
171
+ "channel": "ambient",
172
+ "maxC": 8,
173
+ "hysteresisC": 1,
174
+ "transmitOnBreach": true
175
+ }
176
+ ]
177
+ }
178
+ ```
179
+
180
+ **Weekday schedule** (Mon–Fri 08:00–18:00, UTC+1):
181
+
182
+ ```json
183
+ {
184
+ "schedule": {
185
+ "weekdays": [0, 1, 2, 3, 4],
186
+ "from": "08:00",
187
+ "to": "18:00",
188
+ "utcOffsetMinutes": 60
189
+ },
190
+ "temperatureAlerts": [
191
+ {
192
+ "type": "range",
193
+ "channel": "probe",
194
+ "lowC": 2,
195
+ "highC": 8,
196
+ "hysteresisC": 0.5
197
+ }
198
+ ]
199
+ }
200
+ ```
201
+
202
+ **Alerts + sample and uplink intervals** (each optional field is written when present; omit to leave that interval unchanged):
203
+
93
204
  ```json
94
205
  {
95
206
  "schedule": { "always": true },
207
+ "measurementIntervalMinutes": 60,
208
+ "transmissionIntervalSeconds": 3600,
96
209
  "temperatureAlerts": [
97
210
  {
98
211
  "type": "range",
@@ -107,6 +220,25 @@ Same as Cellular API config profiles (`temperatureAlerts` + `schedule`). You can
107
220
  }
108
221
  ```
109
222
 
223
+ ```ts
224
+ await setConfigurationJson({
225
+ schedule: { always: true },
226
+ measurementIntervalMinutes: 60,
227
+ transmissionIntervalSeconds: 3600,
228
+ temperatureAlerts: [
229
+ {
230
+ type: 'range',
231
+ channel: 'probe',
232
+ lowC: -30,
233
+ highC: -10,
234
+ hysteresisC: 0.5,
235
+ },
236
+ ],
237
+ });
238
+ const applied = await getConfiguration();
239
+ await triggerTransmission();
240
+ ```
241
+
110
242
  | Field | Meaning |
111
243
  |------|---------|
112
244
  | `temperatureAlerts` | Alert list. Empty array is valid. |
@@ -114,7 +246,18 @@ Same as Cellular API config profiles (`temperatureAlerts` + `schedule`). You can
114
246
 
115
247
  Ignored over BLE: `slug`, `name`. Do not pass `alarmRules`.
116
248
 
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.
249
+ **Optional intervals** (same limits as Cellular API):
250
+
251
+ | Field | Write (`setConfigurationJson`) | Read (`getConfiguration`) |
252
+ |------|--------------------------------|---------------------------|
253
+ | `measurementIntervalMinutes` | 60–600. Omitted: leave device value. | Returns current device value (may be below 60 if set outside the SDK). |
254
+ | `transmissionIntervalSeconds` | 3600–604800 (1 h–7 d). Omitted: leave device value. | Returns current device value. |
255
+
256
+ When **both** are set on write, uplink must be at most **60×** the measurement interval. Example: 60 min sampling → max `3600` s uplink; 120 min → max `7200` s.
257
+
258
+ Use `parseSensorConfigurationJson` to validate before write. Use `parseSensorConfigurationFromDevice` / `parseSensorConfigurationFromDeviceJson` when parsing a **readback** from the device (relaxed interval rules). For `GET /devices/config-profiles/{slug}`, use `configurationFromApiProfile(response.profile)` or pass the `{ profile }` envelope directly to `setConfigurationJson`.
259
+
260
+ **Alert slot budget:** the device has **12** rule slots. A `range` with default `transmitOnReturn: true` uses **two** slots (breach + return). Up to **4** such ranges, or more min/max alerts if you disable `transmitOnReturn` on ranges.
118
261
 
119
262
  **Each `temperatureAlerts[]` item**
120
263
 
@@ -153,7 +296,8 @@ Sticker QR is JSON. Parse with `parseSensorQrJson`.
153
296
  | `pin` | string | Sticker PIN. Required on `connect`. Also accepted as QR `resetCode`. |
154
297
  | `model` | string | Optional. Defaults to **HC7**. **HC5** is rejected. |
155
298
  | `sessionType` | `modern` | Always modern GATT (HC7 / firmware 7+). |
156
- | `bluetoothMac` | string | BLE MAC with colons, derived from `serial`. |
299
+ | `bluetoothMac` | string | BLE MAC with colons, derived from `serial` or scan row. |
300
+ | `deviceId` | string | Optional. Platform BLE id from scan. Pass when connecting to a selected scan row. |
157
301
 
158
302
  ## Scan result
159
303
 
@@ -188,11 +332,11 @@ Filter manufacturer data on company id **0x026C**. Active scanning is on so scan
188
332
 
189
333
  | Field | Meaning |
190
334
  |------|---------|
191
- | `typeHex` | Slot type, e.g. `0x01` temperature, `0x02` humidity. |
335
+ | `typeHex` | Measurement label, e.g. `temperature`, `humidity` (not a raw hex code on native scan). |
192
336
  | `raw24` | Undecoded 24-bit payload. |
193
- | `text` | Display value with unit, e.g. `21.8 °C`. |
337
+ | `text` | Display value with unit, e.g. `21.8 °C`. Use `formatSensorReadingLine(reading)` for `temperature: 21.8 °C`. |
194
338
 
195
- `triggerTransmission()` returns `{ ok, supported }`. `supported: false` is not an error.
339
+ `triggerTransmission()` returns `{ ok }`. `ok: false` means the firmware rejected the command; it is not thrown as an error.
196
340
 
197
341
  `getCalibration()` returns `{ laboratoryCalibrationDate, laboratoryCalibrationTimestamp }`. Date is `YYYY-MM-DD`, or null if unset.
198
342
 
@@ -206,11 +350,13 @@ import {
206
350
  decodeSensorBeaconPayload,
207
351
  parseSensorQrJson,
208
352
  parseSensorConfigurationJson,
353
+ configurationFromApiProfile,
209
354
  } from '@tempivo/sensor-beacon';
210
355
 
211
356
  const qr = parseSensorQrJson('{"sn":"282C024F0012","pin":"111111"}');
212
357
  const reading = decodeSensorBeaconPayload(normalizeManufacturerBytes(advertisementBytes));
213
358
  const cfg = parseSensorConfigurationJson(/* config JSON above */);
359
+ const fromApi = configurationFromApiProfile(apiProfile); // GET …/config-profiles/{slug}
214
360
  ```
215
361
 
216
362
  Invalid QR or config throws `TempivoSensorError` (`invalidQr`, `invalidPin`, `invalidConfig`).
@@ -281,6 +427,10 @@ Native GATT maps to: `invalidPin`, `invalidQr`, `invalidConfig`, `notConnected`,
281
427
  npm run test:unit
282
428
  ```
283
429
 
430
+ ## Changelog
431
+
432
+ See [CHANGELOG.md](./CHANGELOG.md).
433
+
284
434
  ## License
285
435
 
286
436
  MIT
@@ -54,8 +54,23 @@ final class SensorBeaconNative {
54
54
 
55
55
  Map<String, Object> connect(Context context, String qrJson) {
56
56
  try {
57
+ stopScan();
57
58
  TempivoSensorQr qr = TempivoSensorQrParser.INSTANCE.parse(qrJson);
58
- requireSession(context).connectBlocking(qr);
59
+ String deviceId = qr.getDeviceId();
60
+ String mac = qr.getBluetoothMac();
61
+ if (scanner != null) {
62
+ TempivoSensorBeaconScanner.SeenDevice seen = scanner.lookup(qr.getSerial());
63
+ if (seen != null) {
64
+ if (deviceId == null || deviceId.isEmpty()) {
65
+ deviceId = seen.getDeviceId();
66
+ }
67
+ mac = seen.getBluetoothMac();
68
+ }
69
+ }
70
+ if (deviceId == null || deviceId.isEmpty()) {
71
+ deviceId = qr.getSerial();
72
+ }
73
+ requireSession(context).connectBlocking(qr, deviceId, mac);
59
74
  return qrMap(qr);
60
75
  } catch (Throwable error) {
61
76
  throw wrap(error);
@@ -93,7 +108,6 @@ final class SensorBeaconNative {
93
108
  TempivoSensorSession.TriggerResult result = requireSessionConnected().triggerTransmissionBlocking();
94
109
  Map<String, Object> payload = new HashMap<>();
95
110
  payload.put("ok", result.getOk());
96
- payload.put("supported", result.getSupported());
97
111
  return payload;
98
112
  } catch (Throwable error) {
99
113
  throw wrap(error);
@@ -199,6 +213,7 @@ final class SensorBeaconNative {
199
213
  payload.put("bluetoothMacAddress", device.getBluetoothMacAddress());
200
214
  payload.put("serialNumber", device.getSerialNumber());
201
215
  payload.put("rssi", device.getRssi());
216
+ payload.put("model", device.getModel());
202
217
  payload.put("summary", device.getSummary());
203
218
  TempivoSensorBeaconTelemetry telemetry = device.getTelemetry();
204
219
  payload.put("telemetry", telemetry == null ? null : telemetryMap(telemetry));
@@ -0,0 +1,25 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ /** PIN → reset code + encryption key (HC7 stickers: keep full string for leading zeros). */
4
+ internal data class BleConnectCredentials(
5
+ val resetCode: Int,
6
+ val encryptionKey: String,
7
+ )
8
+
9
+ internal object BleConnectCredentialsResolver {
10
+ fun fromPin(pin: String): BleConnectCredentials {
11
+ val trimmed = pin.trim()
12
+ if (trimmed.isEmpty()) return BleConnectCredentials(-1, "")
13
+ if (!trimmed.matches(Regex("^\\d+$"))) {
14
+ return BleConnectCredentials(-1, trimmed)
15
+ }
16
+ val parsed = trimmed.toIntOrNull() ?: return BleConnectCredentials(-1, trimmed)
17
+ val encryptionKey =
18
+ if (trimmed.length > 4 || trimmed.startsWith("0")) {
19
+ trimmed
20
+ } else {
21
+ ""
22
+ }
23
+ return BleConnectCredentials(parsed, encryptionKey)
24
+ }
25
+ }
@@ -0,0 +1,111 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ import pl.efento.mobile.bluetooth.measurement.Measurement
4
+ import java.util.Locale
5
+ import kotlin.math.abs
6
+ import kotlin.math.floor
7
+
8
+ /** Human-readable measurement type for scan readings (field name kept as typeHex). */
9
+ internal object SdkMeasurementFormat {
10
+ fun typeHex(m: Measurement<*>): String {
11
+ val id = typeId(m)
12
+ if (id != null) return labelForTypeId(id)
13
+ return m.type.toString().substringAfterLast('.').lowercase(Locale.US)
14
+ }
15
+
16
+ fun labelForTypeId(id: Int): String =
17
+ when (id) {
18
+ 0x01 -> "temperature"
19
+ 0x02, 0x1B -> "humidity"
20
+ 0x03 -> "pressure"
21
+ 0x04 -> "differential pressure"
22
+ 0x05 -> "ok/alarm"
23
+ 0x06 -> "iaq"
24
+ 0x07 -> "flooding"
25
+ 0x08 -> "pulse count"
26
+ 0x09 -> "electricity meter"
27
+ 0x0A -> "water meter"
28
+ 0x0B -> "soil moisture"
29
+ 0x0C -> "co"
30
+ 0x0D -> "no₂"
31
+ 0x0E -> "h₂s"
32
+ 0x0F -> "ambient light"
33
+ 0x10 -> "pm1.0"
34
+ 0x11 -> "pm2.5"
35
+ 0x12 -> "pm10"
36
+ 0x13 -> "noise"
37
+ 0x14 -> "nh₃"
38
+ 0x15 -> "ch₄"
39
+ 0x16 -> "high pressure"
40
+ 0x17 -> "distance"
41
+ 0x1A -> "co₂"
42
+ 0x1C -> "static iaq"
43
+ 0x1D -> "co₂ equivalent"
44
+ 0x1E -> "breath voc"
45
+ 0x20 -> "percentage"
46
+ 0x21 -> "voltage"
47
+ 0x22 -> "current"
48
+ else -> "type_${String.format(Locale.US, "%02x", id)}"
49
+ }
50
+
51
+ fun text(m: Measurement<*>): String {
52
+ val mtype = typeId(m)
53
+ val v = numericValue(m) ?: return m.value.toString()
54
+ if (mtype == null) return formatNum(v)
55
+ if (mtype == 0x06) {
56
+ val z = v.toInt()
57
+ val iaq = z % (1 shl 9)
58
+ val cal = (z shr 9) and 0x03
59
+ val calS = arrayOf("not_stab", "cal_req", "cal_ongoing", "cal_done")[cal.coerceAtMost(3)]
60
+ return "$iaq ($calS)"
61
+ }
62
+ val unit =
63
+ when (mtype) {
64
+ 0x01 -> "°C"
65
+ 0x02, 0x1B -> "%"
66
+ 0x03 -> "hPa"
67
+ 0x1A -> "ppm"
68
+ 0x21 -> "mV"
69
+ 0x22 -> "mA"
70
+ else -> ""
71
+ }
72
+ val num =
73
+ if (abs(v - floor(v)) < 1e-6) {
74
+ String.format(Locale.US, "%.0f", v)
75
+ } else {
76
+ when (mtype) {
77
+ 0x01, 0x03, 0x0F, 0x13 -> String.format(Locale.US, "%.1f", v)
78
+ 0x02, 0x1B -> String.format(Locale.US, "%.0f", v)
79
+ else -> String.format(Locale.US, "%g", v)
80
+ }
81
+ }
82
+ return if (unit.isEmpty()) num else "$num $unit"
83
+ }
84
+
85
+ private fun typeId(m: Measurement<*>): Int? {
86
+ val typeName = m.type.toString()
87
+ Regex("0x([0-9A-Fa-f]+)").find(typeName)?.groupValues?.get(1)?.toIntOrNull(16)?.let { return it }
88
+ return when (typeName.substringAfterLast('.').uppercase(Locale.US)) {
89
+ "TEMPERATURE" -> 0x01
90
+ "HUMIDITY" -> 0x02
91
+ "PRESSURE" -> 0x03
92
+ else -> null
93
+ }
94
+ }
95
+
96
+ private fun numericValue(m: Measurement<*>): Double? =
97
+ when (val v = m.value) {
98
+ is Double -> v
99
+ is Float -> v.toDouble()
100
+ is Int -> v.toDouble()
101
+ is Long -> v.toDouble()
102
+ else -> null
103
+ }
104
+
105
+ private fun formatNum(v: Double): String =
106
+ if (abs(v - floor(v)) < 0.0001) {
107
+ v.toInt().toString()
108
+ } else {
109
+ v.toString()
110
+ }
111
+ }
@@ -2,7 +2,6 @@ package com.tempivo.sensor.beacon
2
2
 
3
3
  import android.app.Application
4
4
  import kotlinx.coroutines.delay
5
- import pl.efento.mobile.bluetooth.EfentoBluetooth
6
5
  import pl.efento.mobile.bluetooth.api.SensorConnection
7
6
  import pl.efento.mobile.bluetooth.exception.InvalidResetCodeException
8
7
  import pl.efento.mobile.bluetooth.exception.UnsupportedCommandException
@@ -24,7 +23,7 @@ internal object SensorBleRuntime {
24
23
  if (initialized) return
25
24
  synchronized(initMonitor) {
26
25
  if (initialized) return
27
- EfentoBluetooth.init(application)
26
+ SensorBleVendor.init(application)
28
27
  initialized = true
29
28
  }
30
29
  }
@@ -46,24 +45,30 @@ internal object SensorBleRuntime {
46
45
 
47
46
  suspend fun connect(
48
47
  application: Application,
49
- serial: String,
48
+ deviceId: String,
50
49
  bluetoothMac: String,
51
- pin: Int,
50
+ pin: String,
52
51
  ): Session {
53
52
  initialize(application)
53
+ val creds = BleConnectCredentialsResolver.fromPin(pin)
54
54
  val mac = BluetoothMacAddress(bluetoothMac)
55
55
  val c =
56
- EfentoBluetooth.sensorConnection(
57
- deviceID = serial,
58
- bluetoothMacAddress = mac,
59
- resetCode = pin,
60
- encryptionKey = "",
56
+ SensorBleVendor.sensorConnection(
57
+ deviceId = deviceId,
58
+ bluetoothMac = mac,
59
+ resetCode = creds.resetCode,
60
+ encryptionKey = creds.encryptionKey,
61
61
  )
62
62
  c.connect()
63
- if (pin >= 0) c.resetCode = pin
63
+ applyConnectCredentials(c, creds)
64
64
  return Session(c)
65
65
  }
66
66
 
67
+ private fun applyConnectCredentials(connection: SensorConnection, creds: BleConnectCredentials) {
68
+ if (creds.resetCode >= 0) connection.resetCode = creds.resetCode
69
+ if (creds.encryptionKey.isNotEmpty()) connection.encryptionKey = creds.encryptionKey
70
+ }
71
+
67
72
  suspend fun disconnect(session: Session?) {
68
73
  session?.let { runCatching { it.connection.disconnect() } }
69
74
  }
@@ -86,8 +91,12 @@ internal object SensorBleRuntime {
86
91
  }
87
92
 
88
93
  suspend fun triggerTransmission(session: Session): TempivoSensorSession.TriggerResult {
89
- session.connection.commands.triggerCommunicationWithServer { }
90
- return TempivoSensorSession.TriggerResult(ok = true, supported = true)
94
+ return try {
95
+ session.connection.commands.triggerCommunicationWithServer { }
96
+ TempivoSensorSession.TriggerResult(ok = true)
97
+ } catch (e: UnsupportedCommandException) {
98
+ TempivoSensorSession.TriggerResult(ok = false)
99
+ }
91
100
  }
92
101
 
93
102
  suspend fun readCalibration(session: Session): TempivoSensorCalibration {
@@ -0,0 +1,28 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ import android.app.Application
4
+ import pl.efento.mobile.bluetooth.EfentoBluetooth
5
+ import pl.efento.mobile.bluetooth.api.SensorConnection
6
+ import pl.efento.mobile.bluetooth.model.BluetoothMacAddress
7
+
8
+ /** Single entry point to the bundled sensor BLE runtime (vendor types stay in this file). */
9
+ internal object SensorBleVendor {
10
+ fun init(application: Application) {
11
+ EfentoBluetooth.init(application)
12
+ }
13
+
14
+ fun scanner() = EfentoBluetooth.scanner()
15
+
16
+ fun sensorConnection(
17
+ deviceId: String,
18
+ bluetoothMac: BluetoothMacAddress,
19
+ resetCode: Int,
20
+ encryptionKey: String,
21
+ ): SensorConnection =
22
+ EfentoBluetooth.sensorConnection(
23
+ deviceID = deviceId,
24
+ bluetoothMacAddress = bluetoothMac,
25
+ resetCode = resetCode,
26
+ encryptionKey = encryptionKey,
27
+ )
28
+ }
@@ -0,0 +1,20 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ /** Sticker model hint from advertisement firmware. Missing FW → HC7 (modern fleet default). */
4
+ internal object SensorModelHint {
5
+ const val DEFAULT_MODEL = TempivoSensorQrParser.DEFAULT_MODEL
6
+
7
+ fun isMissingFirmware(firmware: String?): Boolean {
8
+ val t = firmware?.trim().orEmpty()
9
+ return t.isEmpty() || t == "0.0.0"
10
+ }
11
+
12
+ fun fromFirmware(firmware: String?): String {
13
+ if (isMissingFirmware(firmware)) return DEFAULT_MODEL
14
+ val t = firmware!!.trim()
15
+ if (t.startsWith("FW 5", ignoreCase = true)) return "HC5"
16
+ val major = Regex("""^(?:FW\s*)?(\d+)\.\d+""", RegexOption.IGNORE_CASE).find(t)?.groupValues?.get(1)?.toIntOrNull()
17
+ ?: return DEFAULT_MODEL
18
+ return if (major >= 7) "HC7" else if (major >= 1) "HC5" else DEFAULT_MODEL
19
+ }
20
+ }
@@ -1,11 +1,8 @@
1
1
  package com.tempivo.sensor.beacon
2
2
 
3
- import pl.efento.mobile.bluetooth.measurement.Measurement
4
3
  import pl.efento.mobile.bluetooth.model.Sensor
5
- import kotlin.math.abs
6
- import kotlin.math.floor
7
4
 
8
- /** Advertisement telemetry from Efento SDK [Sensor] (same path as Tempivo Capacitor app). */
5
+ /** Advertisement telemetry from runtime scan [Sensor]. */
9
6
  internal object SensorSdkScanTelemetry {
10
7
  fun fromSensor(sensor: Sensor): TempivoSensorBeaconTelemetry {
11
8
  val readings = buildReadings(sensor)
@@ -44,6 +41,7 @@ internal object SensorSdkScanTelemetry {
44
41
  bluetoothMacAddress = mac,
45
42
  serialNumber = serial,
46
43
  rssi = sensor.rssi,
44
+ model = SensorModelHint.fromFirmware(telemetry.firmware),
47
45
  telemetry = telemetry,
48
46
  summary = telemetry.summary ?: serial,
49
47
  )
@@ -70,43 +68,19 @@ internal object SensorSdkScanTelemetry {
70
68
  if (!measurement.isValid) continue
71
69
  out +=
72
70
  TempivoSensorBeaconReading(
73
- typeHex = measurementTypeHex(measurement),
71
+ typeHex = SdkMeasurementFormat.typeHex(measurement),
74
72
  raw24 = 0,
75
- text = formatMeasurementText(measurement),
73
+ text = SdkMeasurementFormat.text(measurement),
76
74
  )
77
75
  }
78
76
  return out
79
77
  }
80
78
 
81
- private fun measurementTypeHex(m: Measurement<*>): String {
82
- val typeName = m.type.toString()
83
- return Regex("0x[0-9A-Fa-f]+").find(typeName)?.value ?: typeName
84
- }
85
-
86
- private fun formatMeasurementText(m: Measurement<*>): String {
87
- val type = m.type.toString().substringAfterLast('.').replace('_', ' ')
88
- val v = m.value
89
- return when (v) {
90
- is Double -> "$type ${formatNum(v)}"
91
- is Float -> "$type ${formatNum(v.toDouble())}"
92
- is Int -> "$type $v"
93
- is Long -> "$type $v"
94
- else -> "$type $v"
95
- }
96
- }
97
-
98
- private fun formatNum(v: Double): String =
99
- if (abs(v - floor(v)) < 0.0001) {
100
- v.toInt().toString()
101
- } else {
102
- v.toString()
103
- }
104
-
105
79
  private fun measurementSummaryParts(sensor: Sensor): List<String> {
106
80
  val out = mutableListOf<String>()
107
81
  for ((_, measurement) in sensor.measurements) {
108
82
  if (!measurement.isValid) continue
109
- out.add(formatMeasurementText(measurement))
83
+ out.add(SdkMeasurementFormat.text(measurement))
110
84
  }
111
85
  return out
112
86
  }
@@ -289,7 +289,7 @@ object TempivoSensorBeaconDecoder {
289
289
  summaryParts.add(text)
290
290
  readings +=
291
291
  TempivoSensorBeaconReading(
292
- typeHex = String.format(Locale.US, "0x%02X", mtype),
292
+ typeHex = SdkMeasurementFormat.labelForTypeId(mtype),
293
293
  raw24 = raw24 and 0xFFFFFF,
294
294
  text = text,
295
295
  )