@tempivo/sensor-beacon 1.1.2 → 1.1.5

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 (31) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +35 -22
  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 +780 -0
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +124 -10
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +19 -8
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +136 -11
  11. package/android-aar/library/src/test/java/com/tempivo/sensor/beacon/PartnerBleRulesTest.kt +209 -0
  12. package/dist/native-module.d.ts +10 -0
  13. package/dist/native-module.js +46 -3
  14. package/dist/partner-dual-range-fixture.d.ts +28 -0
  15. package/dist/partner-dual-range-fixture.js +29 -0
  16. package/dist/profile.js +33 -19
  17. package/dist/proto/decode-proto-config.d.ts +3 -0
  18. package/dist/proto/decode-proto-config.js +46 -0
  19. package/dist/proto/efento-protobuf-descriptor.d.ts +964 -0
  20. package/dist/proto/efento-protobuf-descriptor.js +727 -0
  21. package/dist/proto/partner-from-proto.d.ts +9 -0
  22. package/dist/proto/partner-from-proto.js +187 -0
  23. package/dist/proto/partner-to-proto.d.ts +32 -0
  24. package/dist/proto/partner-to-proto.js +282 -0
  25. package/dist/tempivo-sensor-beacon.aar +0 -0
  26. package/ios/Frameworks/TempivoSensorBridge.xcframework/Info.plist +4 -4
  27. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  28. package/ios/Sources/TempivoSensorBeacon/EfentoRawConfigTransfer.swift +601 -0
  29. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +99 -29
  30. package/ios/TempivoSensorBeaconModule.swift +9 -0
  31. package/package.json +6 -2
@@ -84,10 +84,43 @@ internal object PartnerBleRules {
84
84
  }
85
85
 
86
86
  fun decompile(cfg: SensorConfiguration): TempivoSensorConfiguration {
87
- val padded = MutableList(12) { i ->
88
- cfg.rules.orEmpty().getOrNull(i)
89
- ?: 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
+ }
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
+ }
90
118
  }
119
+ return byId
120
+ }
121
+
122
+ fun decompile(rules: List<Rule>?, calendars: List<RuleCalendar>? = null): TempivoSensorConfiguration {
123
+ val padded = normalizeRules(rules)
91
124
  val orRefs = orSelectedPairs(padded)
92
125
  val used = BooleanArray(12)
93
126
  val alerts = mutableListOf<TempivoTemperatureAlert>()
@@ -97,8 +130,9 @@ internal object PartnerBleRules {
97
130
  val low = padded[i]
98
131
  val high = padded[i + 1]
99
132
  if (low.condition !is LowThresholdCondition || high.condition !is HighThresholdCondition) continue
100
- val lowCh = channelOf(low) ?: continue
101
- 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
102
136
  if (lowCh != highCh) continue
103
137
  val lowP = (low.condition as LowThresholdCondition).conditionParameters
104
138
  val highP = (high.condition as HighThresholdCondition).conditionParameters
@@ -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,8 +251,8 @@ 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
 
@@ -312,4 +368,62 @@ internal object PartnerBleRules {
312
368
  RuleCalendar.Type.Weekly.Day.SATURDAY -> 5
313
369
  RuleCalendar.Type.Weekly.Day.SUNDAY -> 6
314
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
+ }
315
429
  }
@@ -88,15 +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.isNotEmpty()) {
92
- val verify = decompile(modernGetConfiguration(session.connection))
93
- if (verify.temperatureAlerts.size < cfg.temperatureAlerts.size) {
94
- throw TempivoSensorException(
95
- TempivoSensorException.Code.UNKNOWN,
96
- "Device rejected alarm rules (readback ${verify.temperatureAlerts.size}/${cfg.temperatureAlerts.size}).",
97
- )
98
- }
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
99
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
+ )
100
111
  }
101
112
 
102
113
  suspend fun triggerTransmission(session: Session): TempivoSensorSession.TriggerResult {
@@ -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,53 @@ 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
+ // Always the public Bluetooth MAC for raw GATT (CRC + connectGatt).
206
+ return lastMac
207
+ }
208
+
209
+ private suspend fun <T> withRawConfigLink(body: suspend (String) -> T): T {
210
+ val address = requireRawAddress()
211
+ releaseSdkForRaw()
212
+ return try {
213
+ withContext(Dispatchers.IO) { body(address) }
214
+ } catch (e: Throwable) {
215
+ throw SensorBleRuntime.mapError(e)
216
+ }
217
+ }
218
+
219
+ private suspend fun releaseSdkForRaw() {
220
+ mutex.withLock {
221
+ val didDisconnect = sdkConnected
222
+ if (sdkConnected) {
223
+ SensorBleRuntime.disconnect(session)
224
+ session = null
225
+ sdkConnected = false
226
+ }
227
+ if (didDisconnect) delay(1_500)
228
+ }
229
+ }
230
+
231
+ private suspend fun ensureSdkConnected() {
232
+ if (sdkConnected && session != null) return
233
+ if (lastSerial.isEmpty() || lastMac.isEmpty() || lastPin.isEmpty() || lastDeviceId.isEmpty()) {
234
+ throw TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected.")
235
+ }
236
+ session =
237
+ withContext(Dispatchers.Main) {
238
+ SensorBleRuntime.connect(
239
+ application = application,
240
+ deviceId = lastDeviceId,
241
+ bluetoothMac = lastMac,
242
+ pin = lastPin,
243
+ )
244
+ }
245
+ sdkConnected = true
246
+ }
247
+
123
248
  private suspend fun <T> runGatt(block: suspend () -> T): T {
124
249
  try {
125
250
  return mutex.withLock { block() }
@@ -0,0 +1,209 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+ import pl.efento.mobile.bluetooth.model.sensor.Action
8
+ import pl.efento.mobile.bluetooth.model.sensor.DisabledCondition
9
+ import pl.efento.mobile.bluetooth.model.sensor.HighThresholdCondition
10
+ import pl.efento.mobile.bluetooth.model.sensor.LogicOperator
11
+ import pl.efento.mobile.bluetooth.model.sensor.LogicOperatorCondition
12
+ import pl.efento.mobile.bluetooth.model.sensor.LowThresholdCondition
13
+ import pl.efento.mobile.bluetooth.model.sensor.Rule
14
+
15
+ class PartnerBleRulesTest {
16
+ private val simon =
17
+ TempivoSensorConfiguration(
18
+ temperatureAlerts =
19
+ listOf(
20
+ TempivoTemperatureAlert(
21
+ type = "range",
22
+ channel = "ambient",
23
+ lowC = 2.0,
24
+ highC = 8.0,
25
+ hysteresisC = 0.5,
26
+ transmitOnBreach = true,
27
+ transmitOnReturn = true,
28
+ ),
29
+ TempivoTemperatureAlert(
30
+ type = "range",
31
+ channel = "probe",
32
+ lowC = -30.0,
33
+ highC = -18.0,
34
+ hysteresisC = 0.5,
35
+ transmitOnBreach = true,
36
+ transmitOnReturn = true,
37
+ ),
38
+ ),
39
+ schedule = TempivoSchedule(always = true),
40
+ measurementIntervalMinutes = 5.0,
41
+ transmissionIntervalSeconds = 3600,
42
+ )
43
+
44
+ @Test
45
+ fun compileDecompileSimonJsonKeepsBothRanges() {
46
+ val (rules, _) = PartnerBleRules.compile(simon)
47
+ val kinds = rules.take(6).map { kind(it) }
48
+ assertEquals(listOf("LOW", "HIGH", "OR", "LOW", "HIGH", "OR"), kinds)
49
+
50
+ val got = PartnerBleRules.decompile(rules)
51
+ assertEquals(2, got.temperatureAlerts.size)
52
+ assertEquals("ambient", got.temperatureAlerts[0].channel)
53
+ assertEquals(2.0, got.temperatureAlerts[0].lowC!!, 0.01)
54
+ assertEquals(8.0, got.temperatureAlerts[0].highC!!, 0.01)
55
+ assertEquals("probe", got.temperatureAlerts[1].channel)
56
+ assertEquals(-30.0, got.temperatureAlerts[1].lowC!!, 0.01)
57
+ assertEquals(-18.0, got.temperatureAlerts[1].highC!!, 0.01)
58
+ assertTrue(got.temperatureAlerts.all { it.transmitOnReturn })
59
+ }
60
+
61
+ @Test
62
+ fun setSameJsonTwiceIsAccepted() {
63
+ val (rules, _) = PartnerBleRules.compile(simon)
64
+ val empty = List(12) { i -> Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION) }
65
+
66
+ val first = PartnerBleRules.configurationWriteAccepted(simon.temperatureAlerts, rules, empty, rules)
67
+ assertTrue(first)
68
+
69
+ val second = PartnerBleRules.configurationWriteAccepted(simon.temperatureAlerts, rules, rules, rules)
70
+ assertTrue(second)
71
+ }
72
+
73
+ @Test
74
+ fun oldReadbackCountCheckFailsWhenDecompileIsEmpty() {
75
+ val (rules, _) = PartnerBleRules.compile(simon)
76
+ val emptyGet = PartnerBleRules.decompile(emptyList()).temperatureAlerts
77
+ assertEquals(0, emptyGet.size)
78
+ assertTrue(emptyGet.size < simon.temperatureAlerts.size)
79
+ }
80
+
81
+ @Test
82
+ fun liveDeskProtoDecompilesAndIdempotentSetIsAccepted() {
83
+ // Live get from desk sensor after Simon JSON (channel_mask 1 / 4, 0.1 °C units).
84
+ val fromDevice =
85
+ listOf(
86
+ threshold(1, 1, true, 2.0, 0.5, Action.NO_ACTION),
87
+ threshold(2, 1, false, 8.0, 0.5, Action.NO_ACTION),
88
+ orSlot(3, setOf(1, 2)),
89
+ threshold(4, 3, true, -30.0, 0.5, Action.NO_ACTION),
90
+ threshold(5, 3, false, -18.0, 0.5, Action.NO_ACTION),
91
+ orSlot(6, setOf(4, 5)),
92
+ ) + List(6) { i -> Rule(i + 7, emptySet(), DisabledCondition, Action.NO_ACTION) }
93
+
94
+ val got = PartnerBleRules.decompile(fromDevice)
95
+ assertEquals(2, got.temperatureAlerts.size)
96
+ assertEquals(listOf("ambient", "probe"), got.temperatureAlerts.map { it.channel })
97
+
98
+ val (intended, _) = PartnerBleRules.compile(simon)
99
+ assertTrue(PartnerBleRules.compiledRulesMatchDevice(intended, fromDevice))
100
+ assertTrue(
101
+ PartnerBleRules.configurationWriteAccepted(
102
+ simon.temperatureAlerts,
103
+ intended,
104
+ fromDevice,
105
+ fromDevice,
106
+ ),
107
+ )
108
+ }
109
+
110
+ @Test
111
+ fun zeroBasedSlotsDecompileToAmbientAndProbe() {
112
+ val fromDevice =
113
+ listOf(
114
+ threshold(1, 0, true, 2.0, 0.5, Action.NO_ACTION),
115
+ threshold(2, 0, false, 8.0, 0.5, Action.NO_ACTION),
116
+ orSlot(3, setOf(1, 2)),
117
+ threshold(4, 2, true, -30.0, 0.5, Action.NO_ACTION),
118
+ threshold(5, 2, false, -18.0, 0.5, Action.NO_ACTION),
119
+ orSlot(6, setOf(4, 5)),
120
+ ) + List(6) { i -> Rule(i + 7, emptySet(), DisabledCondition, Action.NO_ACTION) }
121
+ val got = PartnerBleRules.decompile(fromDevice)
122
+ assertEquals(2, got.temperatureAlerts.size)
123
+ assertEquals(listOf("ambient", "probe"), got.temperatureAlerts.map { it.channel })
124
+ }
125
+
126
+ @Test
127
+ fun fw7ExtraRuleSlotsStillDecompileById() {
128
+ val active =
129
+ listOf(
130
+ threshold(1, 1, true, 2.0, 0.5, Action.NO_ACTION),
131
+ threshold(2, 1, false, 8.0, 0.5, Action.NO_ACTION),
132
+ orSlot(3, setOf(1, 2)),
133
+ threshold(4, 3, true, -30.0, 0.5, Action.NO_ACTION),
134
+ threshold(5, 3, false, -18.0, 0.5, Action.NO_ACTION),
135
+ orSlot(6, setOf(4, 5)),
136
+ )
137
+ // Simulate FW7 returning >12 entries: padding first, then real ids.
138
+ val padding = List(12) { i -> Rule(100 + i, emptySet(), DisabledCondition, Action.NO_ACTION) }
139
+ val fromDevice = padding + active
140
+ val got = PartnerBleRules.decompile(fromDevice)
141
+ assertEquals(2, got.temperatureAlerts.size)
142
+ assertEquals(listOf("ambient", "probe"), got.temperatureAlerts.map { it.channel })
143
+ }
144
+
145
+ @Test
146
+ fun emptyGetAfterSameConfigIsStillAccepted() {
147
+ val (rules, _) = PartnerBleRules.compile(simon)
148
+ val empty = List(12) { i -> Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION) }
149
+ assertTrue(
150
+ PartnerBleRules.configurationWriteAccepted(simon.temperatureAlerts, rules, rules, empty),
151
+ )
152
+ // Blind HC7 get (empty before and after) must not reject a successful write.
153
+ assertTrue(
154
+ PartnerBleRules.configurationWriteAccepted(simon.temperatureAlerts, rules, empty, empty),
155
+ )
156
+ }
157
+
158
+ private fun threshold(
159
+ id: Int,
160
+ channel: Int,
161
+ low: Boolean,
162
+ t: Double,
163
+ hyst: Double,
164
+ action: Action,
165
+ ): Rule {
166
+ val params =
167
+ pl.efento.mobile.bluetooth.model.sensor.TemperatureLowHighThresholdConditionParameters(
168
+ t,
169
+ hyst,
170
+ pl.efento.mobile.bluetooth.model.sensor.TriggeringMode.CONSECUTIVE_SAMPLES,
171
+ 1,
172
+ )
173
+ val condition =
174
+ if (low) {
175
+ pl.efento.mobile.bluetooth.model.sensor.LowThresholdCondition(params)
176
+ } else {
177
+ pl.efento.mobile.bluetooth.model.sensor.HighThresholdCondition(params)
178
+ }
179
+ return Rule(id, setOf(channel), condition, action)
180
+ }
181
+
182
+ private fun orSlot(id: Int, selected: Set<Int>): Rule =
183
+ Rule(
184
+ id,
185
+ emptySet(),
186
+ pl.efento.mobile.bluetooth.model.sensor.LogicOperatorCondition(
187
+ pl.efento.mobile.bluetooth.model.sensor.LogicOperatorConditionParameters(
188
+ LogicOperator.OR,
189
+ selected,
190
+ emptySet(),
191
+ 0,
192
+ 0,
193
+ ),
194
+ ),
195
+ Action.TRIGGER_TRANSMISSION_WITH_ACK,
196
+ )
197
+
198
+ private fun kind(rule: Rule): String =
199
+ when (rule.condition) {
200
+ is LowThresholdCondition -> "LOW"
201
+ is HighThresholdCondition -> "HIGH"
202
+ is LogicOperatorCondition -> {
203
+ val p = (rule.condition as LogicOperatorCondition).conditionParameters
204
+ if (p.logicOperator == LogicOperator.OR) "OR" else "LOGIC"
205
+ }
206
+ is DisabledCondition -> "OFF"
207
+ else -> rule.condition.javaClass.simpleName
208
+ }
209
+ }
@@ -8,7 +8,17 @@ export declare function isScanning(): boolean;
8
8
  export declare function addDeviceFoundListener(listener: (device: TempivoSensorBeaconDevice) => void): TempivoDeviceFoundSubscription;
9
9
  export declare function connect(qr: string | TempivoSensorQr): Promise<TempivoNativeQr>;
10
10
  export declare function disconnect(): Promise<void>;
11
+ /**
12
+ * Partner configuration from the device.
13
+ * Return shape is only {@link TempivoSensorConfiguration} (no debug fields).
14
+ * iOS and Android use raw ProtoConfig GATT; return shape is only partner fields.
15
+ */
11
16
  export declare function getConfiguration(): Promise<TempivoSensorConfiguration>;
17
+ /**
18
+ * Write partner configuration to the device.
19
+ * Same public signature as before. Encodes partner config to ProtoConfig and writes raw GATT
20
+ * when `writeProtoConfigBase64` is available (iOS and Android).
21
+ */
12
22
  export declare function setConfigurationJson(config: string | TempivoSensorConfiguration): Promise<void>;
13
23
  export declare function triggerTransmission(): Promise<TempivoTriggerTransmissionResult>;
14
24
  export declare function getCalibration(): Promise<TempivoNativeCalibration>;