@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.
- package/CHANGELOG.md +43 -0
- package/README.md +38 -23
- 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 +672 -0
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +188 -52
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +20 -0
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +28 -0
- package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +142 -11
- package/android-aar/library/src/test/java/com/tempivo/sensor/beacon/PartnerBleRulesTest.kt +209 -0
- package/dist/native-helpers.js +3 -1
- 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 +66 -32
- 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 +12 -0
- package/dist/proto/partner-to-proto.js +211 -0
- package/dist/tempivo-sensor-beacon.aar +0 -0
- 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
|
@@ -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
|
|
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) ?:
|
|
35
|
-
val t = alert.minC ?:
|
|
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) ?:
|
|
42
|
-
val t = alert.maxC ?:
|
|
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 ?:
|
|
49
|
-
val high = alert.highC ?:
|
|
50
|
-
val pair = findPair(used)
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
112
|
-
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
|
|
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(
|
|
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)
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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,
|
|
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,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() }
|