@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.
- package/CHANGELOG.md +40 -0
- package/README.md +35 -22
- package/android/build.gradle +2 -2
- package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +8 -0
- package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +8 -0
- package/android-aar/library/build.gradle +5 -0
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/EfentoRawConfigTransfer.kt +780 -0
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +124 -10
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +19 -8
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +136 -11
- package/android-aar/library/src/test/java/com/tempivo/sensor/beacon/PartnerBleRulesTest.kt +209 -0
- package/dist/native-module.d.ts +10 -0
- package/dist/native-module.js +46 -3
- package/dist/partner-dual-range-fixture.d.ts +28 -0
- package/dist/partner-dual-range-fixture.js +29 -0
- package/dist/profile.js +33 -19
- package/dist/proto/decode-proto-config.d.ts +3 -0
- package/dist/proto/decode-proto-config.js +46 -0
- package/dist/proto/efento-protobuf-descriptor.d.ts +964 -0
- package/dist/proto/efento-protobuf-descriptor.js +727 -0
- package/dist/proto/partner-from-proto.d.ts +9 -0
- package/dist/proto/partner-from-proto.js +187 -0
- package/dist/proto/partner-to-proto.d.ts +32 -0
- package/dist/proto/partner-to-proto.js +282 -0
- package/dist/tempivo-sensor-beacon.aar +0 -0
- package/ios/Frameworks/TempivoSensorBridge.xcframework/Info.plist +4 -4
- package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
- package/ios/Sources/TempivoSensorBeacon/EfentoRawConfigTransfer.swift +601 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +99 -29
- package/ios/TempivoSensorBeaconModule.swift +9 -0
- 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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
101
|
-
val
|
|
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(
|
|
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.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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,
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
167
|
+
return runGatt {
|
|
168
|
+
ensureSdkConnected()
|
|
169
|
+
SensorBleRuntime.triggerTransmission(requireSession())
|
|
170
|
+
}
|
|
98
171
|
}
|
|
99
172
|
|
|
100
173
|
suspend fun getCalibration(): TempivoSensorCalibration {
|
|
101
|
-
return runGatt {
|
|
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
|
+
}
|
package/dist/native-module.d.ts
CHANGED
|
@@ -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>;
|