@tempivo/sensor-beacon 1.1.1 → 1.1.4

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 (32) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +38 -23
  3. package/android/build.gradle +2 -2
  4. package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +8 -0
  5. package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +8 -0
  6. package/android-aar/library/build.gradle +5 -0
  7. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/EfentoRawConfigTransfer.kt +672 -0
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +188 -52
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +20 -0
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +28 -0
  11. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +142 -11
  12. package/android-aar/library/src/test/java/com/tempivo/sensor/beacon/PartnerBleRulesTest.kt +209 -0
  13. package/dist/native-helpers.js +3 -1
  14. package/dist/native-module.d.ts +10 -0
  15. package/dist/native-module.js +46 -3
  16. package/dist/partner-dual-range-fixture.d.ts +28 -0
  17. package/dist/partner-dual-range-fixture.js +29 -0
  18. package/dist/profile.js +66 -32
  19. package/dist/proto/decode-proto-config.d.ts +3 -0
  20. package/dist/proto/decode-proto-config.js +46 -0
  21. package/dist/proto/efento-protobuf-descriptor.d.ts +964 -0
  22. package/dist/proto/efento-protobuf-descriptor.js +727 -0
  23. package/dist/proto/partner-from-proto.d.ts +9 -0
  24. package/dist/proto/partner-from-proto.js +187 -0
  25. package/dist/proto/partner-to-proto.d.ts +12 -0
  26. package/dist/proto/partner-to-proto.js +211 -0
  27. package/dist/tempivo-sensor-beacon.aar +0 -0
  28. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  29. package/ios/Sources/TempivoSensorBeacon/EfentoRawConfigTransfer.swift +601 -0
  30. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +99 -29
  31. package/ios/TempivoSensorBeaconModule.swift +9 -0
  32. package/package.json +6 -2
@@ -16,7 +16,8 @@ import pl.efento.mobile.bluetooth.model.sensor.TriggeringMode
16
16
 
17
17
  /**
18
18
  * Compile Cellular API `temperatureAlerts` + `schedule` into device rules.
19
- * Range + `transmitOnReturn` uses the same OR layout as POST /devices/config-profiles.
19
+ * Range + `transmitOnReturn` uses an OR slot immediately after each low/high pair
20
+ * (same layout as a single range with return; required for dual-range BLE writes).
20
21
  */
21
22
  internal object PartnerBleRules {
22
23
  fun compile(cfg: TempivoSensorConfiguration): Pair<List<Rule>, List<RuleCalendar>> {
@@ -24,30 +25,30 @@ internal object PartnerBleRules {
24
25
  Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION)
25
26
  }
26
27
  val used = BooleanArray(12)
27
- val returnLowSlots = mutableListOf<Int>()
28
28
 
29
29
  for (alert in cfg.temperatureAlerts) {
30
30
  val channel = if (alert.channel == "probe") 3 else 1
31
31
  val hyst = alert.hysteresisC ?: 1.0
32
32
  when (alert.type) {
33
33
  "min" -> {
34
- val slot = findFree(used) ?: continue
35
- val t = alert.minC ?: continue
34
+ val slot = findFree(used) ?: throw compileFail("Not enough free alarm rule slots.")
35
+ val t = alert.minC ?: throw compileFail("min alert is missing minC.")
36
36
  val action = if (alert.transmitOnBreach) Action.TRIGGER_TRANSMISSION_WITH_ACK else Action.NO_ACTION
37
37
  putThreshold(rules, slot, channel, low = true, t, hyst, action)
38
38
  used[slot] = true
39
39
  }
40
40
  "max" -> {
41
- val slot = findFree(used) ?: continue
42
- val t = alert.maxC ?: continue
41
+ val slot = findFree(used) ?: throw compileFail("Not enough free alarm rule slots.")
42
+ val t = alert.maxC ?: throw compileFail("max alert is missing maxC.")
43
43
  val action = if (alert.transmitOnBreach) Action.TRIGGER_TRANSMISSION_WITH_ACK else Action.NO_ACTION
44
44
  putThreshold(rules, slot, channel, low = false, t, hyst, action)
45
45
  used[slot] = true
46
46
  }
47
47
  "range" -> {
48
- val low = alert.lowC ?: continue
49
- val high = alert.highC ?: continue
50
- val pair = findPair(used) ?: continue
48
+ val low = alert.lowC ?: throw compileFail("range alert is missing lowC.")
49
+ val high = alert.highC ?: throw compileFail("range alert is missing highC.")
50
+ val pair = findPair(used)
51
+ ?: throw compileFail("Not enough free alarm rule slots for another range (need two consecutive slots).")
51
52
  val ret = alert.transmitOnReturn
52
53
  val action =
53
54
  when {
@@ -59,31 +60,19 @@ internal object PartnerBleRules {
59
60
  putThreshold(rules, pair + 1, channel, low = false, high, hyst, action)
60
61
  used[pair] = true
61
62
  used[pair + 1] = true
62
- if (ret) returnLowSlots.add(pair)
63
+ // Place OR immediately after each pair (low, high, OR, …).
64
+ // Batching all pairs then ORs at the end clears dual-range configs on device.
65
+ if (ret) {
66
+ val logicSlot = findFree(used)
67
+ ?: throw compileFail("Not enough free alarm rule slots for transmit-on-return (OR logic).")
68
+ putReturnOr(rules, logicSlot, pair)
69
+ used[logicSlot] = true
70
+ }
63
71
  }
72
+ else -> throw compileFail("Unsupported temperature alert type: ${alert.type}.")
64
73
  }
65
74
  }
66
75
 
67
- for (lowSlot in returnLowSlots) {
68
- val logicSlot = findFree(used) ?: break
69
- rules[logicSlot] =
70
- Rule(
71
- logicSlot + 1,
72
- emptySet(),
73
- LogicOperatorCondition(
74
- LogicOperatorConditionParameters(
75
- LogicOperator.OR,
76
- setOf(lowSlot + 1, lowSlot + 2),
77
- emptySet(),
78
- 0,
79
- 0,
80
- ),
81
- ),
82
- Action.TRIGGER_TRANSMISSION_WITH_ACK,
83
- )
84
- used[logicSlot] = true
85
- }
86
-
87
76
  val active = rules.filter { it.condition !is DisabledCondition }.map { it.id }.toSet()
88
77
  val calendars = MutableList(6) { i ->
89
78
  RuleCalendar(i + 1, emptySet(), RuleCalendar.Type.Disabled)
@@ -95,10 +84,43 @@ internal object PartnerBleRules {
95
84
  }
96
85
 
97
86
  fun decompile(cfg: SensorConfiguration): TempivoSensorConfiguration {
98
- val padded = MutableList(12) { i ->
99
- cfg.rules.orEmpty().getOrNull(i)
100
- ?: Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION)
87
+ val body = decompile(cfg.rules, cfg.ruleCalendars)
88
+ return body.copy(
89
+ measurementIntervalMinutes = decompileMeasurementMinutes(cfg.measurementPeriod),
90
+ transmissionIntervalSeconds = cfg.transmissionInterval?.value?.takeIf { it > 0 },
91
+ )
92
+ }
93
+
94
+ fun normalizeRules(rules: List<Rule>?): List<Rule> {
95
+ val src = rules.orEmpty()
96
+ val byId = MutableList(12) { i -> Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION) }
97
+ var placed = 0
98
+ for (r in src) {
99
+ val idx = r.id - 1
100
+ if (idx in 0..11) {
101
+ byId[idx] = r
102
+ placed++
103
+ }
101
104
  }
105
+ if (placed == 0 && src.isNotEmpty()) {
106
+ for (i in 0 until minOf(12, src.size)) {
107
+ byId[i] = src[i]
108
+ }
109
+ }
110
+ if (src.size > 12) {
111
+ for (r in src) {
112
+ val idx = r.id - 1
113
+ if (idx !in 0..11) continue
114
+ if (byId[idx].condition is DisabledCondition && r.condition !is DisabledCondition) {
115
+ byId[idx] = r
116
+ }
117
+ }
118
+ }
119
+ return byId
120
+ }
121
+
122
+ fun decompile(rules: List<Rule>?, calendars: List<RuleCalendar>? = null): TempivoSensorConfiguration {
123
+ val padded = normalizeRules(rules)
102
124
  val orRefs = orSelectedPairs(padded)
103
125
  val used = BooleanArray(12)
104
126
  val alerts = mutableListOf<TempivoTemperatureAlert>()
@@ -108,8 +130,9 @@ internal object PartnerBleRules {
108
130
  val low = padded[i]
109
131
  val high = padded[i + 1]
110
132
  if (low.condition !is LowThresholdCondition || high.condition !is HighThresholdCondition) continue
111
- val lowCh = channelOf(low) ?: continue
112
- val highCh = channelOf(high) ?: continue
133
+ val inferred = if (alerts.none { it.type == "range" }) "ambient" else "probe"
134
+ val lowCh = channelOf(low) ?: channelOf(high) ?: inferred
135
+ val highCh = channelOf(high) ?: channelOf(low) ?: inferred
113
136
  if (lowCh != highCh) continue
114
137
  val lowP = (low.condition as LowThresholdCondition).conditionParameters
115
138
  val highP = (high.condition as HighThresholdCondition).conditionParameters
@@ -129,6 +152,17 @@ internal object PartnerBleRules {
129
152
  )
130
153
  used[i] = true
131
154
  used[i + 1] = true
155
+ if (hasReturn) {
156
+ for (j in 0 until 12) {
157
+ val c = padded[j].condition as? LogicOperatorCondition ?: continue
158
+ val p = c.conditionParameters
159
+ if (p.logicOperator != LogicOperator.OR) continue
160
+ if (p.selectedRules == setOf(i + 1, i + 2)) {
161
+ used[j] = true
162
+ break
163
+ }
164
+ }
165
+ }
132
166
  }
133
167
 
134
168
  for (i in 0 until 12) {
@@ -167,12 +201,34 @@ internal object PartnerBleRules {
167
201
 
168
202
  return TempivoSensorConfiguration(
169
203
  alerts,
170
- decompileSchedule(cfg.ruleCalendars),
171
- measurementIntervalMinutes = decompileMeasurementMinutes(cfg.measurementPeriod),
172
- transmissionIntervalSeconds = cfg.transmissionInterval?.value?.takeIf { it > 0 },
204
+ decompileSchedule(calendars),
173
205
  )
174
206
  }
175
207
 
208
+ /**
209
+ * Post-set check used by [SensorBleRuntime.writeConfiguration].
210
+ * Same JSON already on the device is success, not a reject.
211
+ */
212
+ fun configurationWriteAccepted(
213
+ written: List<TempivoTemperatureAlert>,
214
+ intendedRules: List<Rule>,
215
+ beforeRules: List<Rule>?,
216
+ afterRules: List<Rule>?,
217
+ ): Boolean {
218
+ if (written.isEmpty()) return true
219
+ val alreadyOnDevice =
220
+ compiledRulesMatchDevice(intendedRules, beforeRules) ||
221
+ decompiledAlertsCover(written, decompile(beforeRules).temperatureAlerts)
222
+ val sameAsWritten =
223
+ compiledRulesMatchDevice(intendedRules, afterRules) ||
224
+ decompiledAlertsCover(written, decompile(afterRules).temperatureAlerts)
225
+ if (sameAsWritten || alreadyOnDevice) return true
226
+ // HC7 vendor get often returns all-OFF even when dual-range is on flash.
227
+ val afterNorm = normalizeRules(afterRules)
228
+ val afterBlind = afterNorm.all { it.condition is DisabledCondition }
229
+ return afterBlind
230
+ }
231
+
176
232
  private fun decompileMeasurementMinutes(period: MeasurementPeriod?): Double? {
177
233
  if (period == null) return null
178
234
  val factor = if (period.factor == 0) 1 else period.factor
@@ -195,11 +251,14 @@ internal object PartnerBleRules {
195
251
 
196
252
  private fun channelOf(rule: Rule): String? =
197
253
  when {
198
- rule.channels.contains(3) -> "probe"
199
- rule.channels.contains(1) -> "ambient"
254
+ rule.channels.contains(3) || rule.channels.contains(2) -> "probe"
255
+ rule.channels.contains(1) || rule.channels.contains(0) -> "ambient"
200
256
  else -> null
201
257
  }
202
258
 
259
+ private fun compileFail(message: String): Nothing =
260
+ throw TempivoSensorException(TempivoSensorException.Code.INVALID_CONFIG, message)
261
+
203
262
  private fun findPair(used: BooleanArray): Int? {
204
263
  for (i in 0..10) {
205
264
  if (!used[i] && !used[i + 1]) return i
@@ -221,7 +280,7 @@ internal object PartnerBleRules {
221
280
  hyst: Double,
222
281
  action: Action,
223
282
  ) {
224
- if (slot !in 0..11) return
283
+ if (slot !in 0..11) throw compileFail("Alarm rule slot out of range: $slot.")
225
284
  val params =
226
285
  TemperatureLowHighThresholdConditionParameters(
227
286
  threshold,
@@ -233,22 +292,41 @@ internal object PartnerBleRules {
233
292
  rules[slot] = Rule(slot + 1, setOf(channel), condition, action)
234
293
  }
235
294
 
295
+ private fun putReturnOr(rules: MutableList<Rule>, logicSlot: Int, lowSlot: Int) {
296
+ rules[logicSlot] =
297
+ Rule(
298
+ logicSlot + 1,
299
+ emptySet(),
300
+ LogicOperatorCondition(
301
+ LogicOperatorConditionParameters(
302
+ LogicOperator.OR,
303
+ setOf(lowSlot + 1, lowSlot + 2),
304
+ emptySet(),
305
+ 0,
306
+ 0,
307
+ ),
308
+ ),
309
+ Action.TRIGGER_TRANSMISSION_WITH_ACK,
310
+ )
311
+ }
312
+
236
313
  private fun decompileSchedule(calendars: List<RuleCalendar>?): TempivoSchedule {
237
314
  val weekly = calendars.orEmpty().map { it.type }.filterIsInstance<RuleCalendar.Type.Weekly>().firstOrNull()
238
315
  ?: return TempivoSchedule(always = true)
239
316
  val days = weekly.days.map { dayToPartnerIndex(it) }.sorted()
240
- val always = days.size == 7 && weekly.fromTime == 0 && weekly.toTime >= 1439
241
- return if (always) {
242
- TempivoSchedule(always = true)
243
- } else {
244
- TempivoSchedule(
245
- always = false,
246
- weekdays = days,
247
- from = TempivoSensorProfile.hhmmFromMinutes(weekly.fromTime),
248
- to = TempivoSensorProfile.hhmmFromMinutes(weekly.toTime),
249
- utcOffsetMinutes = weekly.timezoneOffset,
250
- )
317
+ val fullDay = weekly.fromTime == 0 && weekly.toTime >= 1439
318
+ // Efento BLE SDK day-mask decode keeps only Sunday (`(mask & bit) != 1`).
319
+ // always:true (all days, 00:00–23:59) therefore reads back as weekdays [6].
320
+ if (fullDay && (days.size == 7 || days.isEmpty() || days == listOf(6))) {
321
+ return TempivoSchedule(always = true)
251
322
  }
323
+ return TempivoSchedule(
324
+ always = false,
325
+ weekdays = days,
326
+ from = TempivoSensorProfile.hhmmFromMinutes(weekly.fromTime),
327
+ to = TempivoSensorProfile.hhmmFromMinutes(weekly.toTime),
328
+ utcOffsetMinutes = weekly.timezoneOffset,
329
+ )
252
330
  }
253
331
 
254
332
  private fun weeklyType(schedule: TempivoSchedule): RuleCalendar.Type {
@@ -290,4 +368,62 @@ internal object PartnerBleRules {
290
368
  RuleCalendar.Type.Weekly.Day.SATURDAY -> 5
291
369
  RuleCalendar.Type.Weekly.Day.SUNDAY -> 6
292
370
  }
371
+
372
+ /** True when on-device slots already match what we would write (same config, not a reject). */
373
+ fun compiledRulesMatchDevice(intended: List<Rule>, device: List<Rule>?): Boolean {
374
+ val got = normalizeRules(device)
375
+ val want = normalizeRules(intended)
376
+ for (i in 0 until 12) {
377
+ if (slotSignature(want[i]) != slotSignature(got[i])) return false
378
+ }
379
+ return true
380
+ }
381
+
382
+ fun decompiledAlertsCover(
383
+ written: List<TempivoTemperatureAlert>,
384
+ got: List<TempivoTemperatureAlert>,
385
+ ): Boolean {
386
+ if (written.isEmpty()) return true
387
+ val remaining = got.toMutableList()
388
+ for (want in written) {
389
+ val idx = remaining.indexOfFirst { alertsClose(want, it) }
390
+ if (idx < 0) return false
391
+ remaining.removeAt(idx)
392
+ }
393
+ return true
394
+ }
395
+
396
+ private fun alertsClose(a: TempivoTemperatureAlert, b: TempivoTemperatureAlert): Boolean {
397
+ if (a.type != b.type || a.channel != b.channel) return false
398
+ return when (a.type) {
399
+ "range" -> near(a.lowC, b.lowC) && near(a.highC, b.highC)
400
+ "min" -> near(a.minC, b.minC)
401
+ "max" -> near(a.maxC, b.maxC)
402
+ else -> false
403
+ }
404
+ }
405
+
406
+ private fun near(a: Double?, b: Double?): Boolean {
407
+ if (a == null || b == null) return a == null && b == null
408
+ return kotlin.math.abs(a - b) < 0.05
409
+ }
410
+
411
+ private fun slotSignature(rule: Rule?): String {
412
+ if (rule == null || rule.condition is DisabledCondition) return "off"
413
+ return when (val c = rule.condition) {
414
+ is LowThresholdCondition -> {
415
+ val p = c.conditionParameters
416
+ "L:${p.thresholdValue}:${p.hysteresisValue}:${rule.action}"
417
+ }
418
+ is HighThresholdCondition -> {
419
+ val p = c.conditionParameters
420
+ "H:${p.thresholdValue}:${p.hysteresisValue}:${rule.action}"
421
+ }
422
+ is LogicOperatorCondition -> {
423
+ val p = c.conditionParameters
424
+ "O:${p.logicOperator}:${p.selectedRules.sorted()}:${rule.action}"
425
+ }
426
+ else -> "?:${c.javaClass.simpleName}:${rule.action}"
427
+ }
428
+ }
293
429
  }
@@ -88,6 +88,26 @@ internal object SensorBleRuntime {
88
88
  next = next.copy(transmissionInterval = TransmissionInterval(sec))
89
89
  }
90
90
  session.connection.commands.setConfiguration(next) { }
91
+ if (cfg.temperatureAlerts.isEmpty()) return
92
+ val after = modernGetConfiguration(session.connection)
93
+ if (PartnerBleRules.configurationWriteAccepted(cfg.temperatureAlerts, rules, base.rules, after.rules)) {
94
+ return
95
+ }
96
+ val verify = decompile(after)
97
+ val slots =
98
+ (0 until 12).joinToString(",") { i ->
99
+ val r = after.rules.orEmpty().getOrNull(i)
100
+ when (r?.condition) {
101
+ is pl.efento.mobile.bluetooth.model.sensor.LowThresholdCondition -> "LOW"
102
+ is pl.efento.mobile.bluetooth.model.sensor.HighThresholdCondition -> "HIGH"
103
+ is pl.efento.mobile.bluetooth.model.sensor.LogicOperatorCondition -> "OR"
104
+ else -> "OFF"
105
+ }
106
+ }
107
+ throw TempivoSensorException(
108
+ TempivoSensorException.Code.UNKNOWN,
109
+ "Device alarm rules do not match the configuration that was written (readback ${verify.temperatureAlerts.size}/${cfg.temperatureAlerts.size} slots=$slots).",
110
+ )
91
111
  }
92
112
 
93
113
  suspend fun triggerTransmission(session: Session): TempivoSensorSession.TriggerResult {
@@ -68,6 +68,7 @@ object TempivoSensorProfile {
68
68
  val item = arr.optJSONObject(i) ?: throw invalid("temperatureAlerts[$i] must be an object.")
69
69
  alerts.add(parseAlert(item, i))
70
70
  }
71
+ assertAlertSlotBudget(alerts)
71
72
  val intervals = parseOptionalIntervals(body)
72
73
  return TempivoSensorConfiguration(
73
74
  alerts,
@@ -111,6 +112,33 @@ object TempivoSensorProfile {
111
112
  return root.toString()
112
113
  }
113
114
 
115
+
116
+ private fun assertAlertSlotBudget(alerts: List<TempivoTemperatureAlert>) {
117
+ val used = BooleanArray(12)
118
+ fun findPair(): Int? {
119
+ for (i in 0..10) if (!used[i] && !used[i + 1]) return i
120
+ return null
121
+ }
122
+ fun findFree(): Int? {
123
+ for (i in 0 until 12) if (!used[i]) return i
124
+ return null
125
+ }
126
+ for (alert in alerts) {
127
+ if (alert.type == "range") {
128
+ val pair = findPair() ?: throw invalid("Not enough free alarm rule slots for another range (need two consecutive slots).")
129
+ used[pair] = true
130
+ used[pair + 1] = true
131
+ if (alert.transmitOnReturn) {
132
+ val logic = findFree() ?: throw invalid("Not enough free alarm rule slots for transmit-on-return (OR logic).")
133
+ used[logic] = true
134
+ }
135
+ } else {
136
+ val slot = findFree() ?: throw invalid("Not enough free alarm rule slots.")
137
+ used[slot] = true
138
+ }
139
+ }
140
+ }
141
+
114
142
  private fun parseOptionalIntervals(body: JSONObject): Pair<Double?, Int?> {
115
143
  var measurement: Double? = null
116
144
  var transmission: Int? = null
@@ -1,14 +1,17 @@
1
1
  package com.tempivo.sensor.beacon
2
2
 
3
3
  import android.content.Context
4
+ import android.util.Base64
4
5
  import kotlinx.coroutines.Dispatchers
6
+ import kotlinx.coroutines.delay
5
7
  import kotlinx.coroutines.runBlocking
6
8
  import kotlinx.coroutines.sync.Mutex
7
9
  import kotlinx.coroutines.sync.withLock
8
10
  import kotlinx.coroutines.withContext
11
+ import org.json.JSONObject
9
12
 
10
13
  /**
11
- * GATT session: connect, read/write alert rules and schedule, trigger uplink, read lab calibration date.
14
+ * GATT session: connect (SDK), alert rules via raw ProtoConfig 0x0C/0x0D, trigger/calibration via SDK.
12
15
  */
13
16
  class TempivoSensorSession(
14
17
  context: Context,
@@ -20,6 +23,11 @@ class TempivoSensorSession(
20
23
  private val application = context.applicationContext as android.app.Application
21
24
  private val mutex = Mutex()
22
25
  private var session: SensorBleRuntime.Session? = null
26
+ private var sdkConnected = false
27
+ private var lastSerial = ""
28
+ private var lastMac = ""
29
+ private var lastPin = ""
30
+ private var lastDeviceId = ""
23
31
 
24
32
  suspend fun connect(
25
33
  serial: String,
@@ -39,6 +47,7 @@ class TempivoSensorSession(
39
47
  mutex.withLock {
40
48
  SensorBleRuntime.disconnect(session)
41
49
  session = null
50
+ sdkConnected = false
42
51
  session =
43
52
  withContext(Dispatchers.Main) {
44
53
  SensorBleRuntime.connect(
@@ -48,6 +57,11 @@ class TempivoSensorSession(
48
57
  pin = trimmedPin,
49
58
  )
50
59
  }
60
+ sdkConnected = true
61
+ lastSerial = serial
62
+ lastMac = bluetoothMac
63
+ lastPin = trimmedPin
64
+ lastDeviceId = resolvedDeviceId
51
65
  }
52
66
  } catch (e: Throwable) {
53
67
  throw SensorBleRuntime.mapError(e).let {
@@ -70,35 +84,97 @@ class TempivoSensorSession(
70
84
 
71
85
  suspend fun disconnect() {
72
86
  mutex.withLock {
87
+ EfentoRawConfigTransfer.closeLink()
73
88
  SensorBleRuntime.disconnect(session)
74
89
  session = null
90
+ sdkConnected = false
91
+ lastSerial = ""
92
+ lastMac = ""
93
+ lastPin = ""
94
+ lastDeviceId = ""
75
95
  }
76
96
  }
77
97
 
78
98
  suspend fun getConfiguration(): TempivoSensorConfiguration {
79
- return runGatt {
80
- SensorBleRuntime.decompile(SensorBleRuntime.readConfiguration(requireSession()))
81
- }
99
+ // Partner apps should use JS decompile of protoBase64; AAR returns the JSON shell fields only.
100
+ return TempivoSensorProfile.parseJson(getConfigurationJson())
82
101
  }
83
102
 
84
- suspend fun getConfigurationJson(): String = TempivoSensorProfile.toJson(getConfiguration())
103
+ /** Always raw GATT ProtoConfig get (0x0C). Vendor SDK getConfiguration is not used for rules. */
104
+ suspend fun getConfigurationJson(): String {
105
+ val proto =
106
+ withRawConfigLink {
107
+ EfentoRawConfigTransfer.readProtoConfig(
108
+ context = application,
109
+ address = it,
110
+ bluetoothMac = lastMac,
111
+ pin = lastPin,
112
+ )
113
+ }
114
+ val b64 = Base64.encodeToString(proto, Base64.NO_WRAP)
115
+ return JSONObject()
116
+ .put("temperatureAlerts", org.json.JSONArray())
117
+ .put("schedule", JSONObject().put("always", true))
118
+ .put("protoBase64", b64)
119
+ .put("configSource", "rawGatt")
120
+ .toString()
121
+ }
85
122
 
86
123
  suspend fun setConfiguration(configuration: TempivoSensorConfiguration) {
87
- runGatt {
88
- SensorBleRuntime.writeConfiguration(requireSession(), configuration)
89
- }
124
+ setConfigurationJson(TempivoSensorProfile.toJson(configuration))
90
125
  }
91
126
 
127
+ /** Partner JSON is not written here. Use [writeProtoConfigBase64] from JS after encode. */
92
128
  suspend fun setConfigurationJson(json: String) {
93
- setConfiguration(TempivoSensorProfile.parseJson(json))
129
+ TempivoSensorProfile.parseJson(json)
130
+ throw TempivoSensorException(
131
+ TempivoSensorException.Code.UNSUPPORTED_COMMAND,
132
+ "Use writeProtoConfigBase64 after encoding partner config to ProtoConfig.",
133
+ )
134
+ }
135
+
136
+ /** Raw GATT set (0x0D): full ProtoConfig bytes as base64. */
137
+ suspend fun writeProtoConfigBase64(base64: String) {
138
+ val proto =
139
+ try {
140
+ Base64.decode(base64, Base64.DEFAULT)
141
+ } catch (e: IllegalArgumentException) {
142
+ throw TempivoSensorException(
143
+ TempivoSensorException.Code.INVALID_CONFIG,
144
+ "protoBase64 is missing or invalid.",
145
+ e,
146
+ )
147
+ }
148
+ if (proto.isEmpty()) {
149
+ throw TempivoSensorException(
150
+ TempivoSensorException.Code.INVALID_CONFIG,
151
+ "protoBase64 is missing or invalid.",
152
+ )
153
+ }
154
+ withRawConfigLink {
155
+ EfentoRawConfigTransfer.writeProtoConfig(
156
+ context = application,
157
+ address = it,
158
+ bluetoothMac = lastMac,
159
+ pin = lastPin,
160
+ proto = proto,
161
+ )
162
+ ByteArray(0)
163
+ }
94
164
  }
95
165
 
96
166
  suspend fun triggerTransmission(): TriggerResult {
97
- return runGatt { SensorBleRuntime.triggerTransmission(requireSession()) }
167
+ return runGatt {
168
+ ensureSdkConnected()
169
+ SensorBleRuntime.triggerTransmission(requireSession())
170
+ }
98
171
  }
99
172
 
100
173
  suspend fun getCalibration(): TempivoSensorCalibration {
101
- return runGatt { SensorBleRuntime.readCalibration(requireSession()) }
174
+ return runGatt {
175
+ ensureSdkConnected()
176
+ SensorBleRuntime.readCalibration(requireSession())
177
+ }
102
178
  }
103
179
 
104
180
  fun connectBlocking(qr: TempivoSensorQr, deviceId: String, bluetoothMac: String) =
@@ -112,6 +188,8 @@ class TempivoSensorSession(
112
188
 
113
189
  fun setConfigurationJsonBlocking(json: String) = runBlocking { setConfigurationJson(json) }
114
190
 
191
+ fun writeProtoConfigBase64Blocking(base64: String) = runBlocking { writeProtoConfigBase64(base64) }
192
+
115
193
  fun triggerTransmissionBlocking(): TriggerResult = runBlocking { triggerTransmission() }
116
194
 
117
195
  fun getCalibrationBlocking(): TempivoSensorCalibration = runBlocking { getCalibration() }
@@ -120,6 +198,59 @@ class TempivoSensorSession(
120
198
  session
121
199
  ?: throw TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected.")
122
200
 
201
+ private fun requireRawAddress(): String {
202
+ if (lastMac.isEmpty() || lastPin.isEmpty()) {
203
+ throw TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected. Scan and connect first.")
204
+ }
205
+ // Prefer scan/SDK device id when it looks like a BLE address; else MAC.
206
+ val id = lastDeviceId.trim()
207
+ val idHex = id.replace(":", "").replace("-", "")
208
+ return if (idHex.length == 12 && idHex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
209
+ id
210
+ } else {
211
+ lastMac
212
+ }
213
+ }
214
+
215
+ private suspend fun <T> withRawConfigLink(body: suspend (String) -> T): T {
216
+ val address = requireRawAddress()
217
+ releaseSdkForRaw()
218
+ return try {
219
+ withContext(Dispatchers.IO) { body(address) }
220
+ } catch (e: Throwable) {
221
+ throw SensorBleRuntime.mapError(e)
222
+ }
223
+ }
224
+
225
+ private suspend fun releaseSdkForRaw() {
226
+ mutex.withLock {
227
+ val didDisconnect = sdkConnected
228
+ if (sdkConnected) {
229
+ SensorBleRuntime.disconnect(session)
230
+ session = null
231
+ sdkConnected = false
232
+ }
233
+ if (didDisconnect) delay(1_000)
234
+ }
235
+ }
236
+
237
+ private suspend fun ensureSdkConnected() {
238
+ if (sdkConnected && session != null) return
239
+ if (lastSerial.isEmpty() || lastMac.isEmpty() || lastPin.isEmpty() || lastDeviceId.isEmpty()) {
240
+ throw TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected.")
241
+ }
242
+ session =
243
+ withContext(Dispatchers.Main) {
244
+ SensorBleRuntime.connect(
245
+ application = application,
246
+ deviceId = lastDeviceId,
247
+ bluetoothMac = lastMac,
248
+ pin = lastPin,
249
+ )
250
+ }
251
+ sdkConnected = true
252
+ }
253
+
123
254
  private suspend fun <T> runGatt(block: suspend () -> T): T {
124
255
  try {
125
256
  return mutex.withLock { block() }