@tempivo/sensor-beacon 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -79
- package/android/library/build.gradle +3 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +279 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +168 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +6 -23
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +0 -2
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +2 -4
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorCalibration.kt +36 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +18 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +171 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +82 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +114 -0
- package/android/settings.gradle +1 -0
- package/dist/calibration.d.ts +7 -0
- package/dist/calibration.js +42 -0
- package/dist/decoder.js +4 -12
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +8 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -1
- package/dist/profile.d.ts +10 -0
- package/dist/profile.js +226 -0
- package/dist/qr.d.ts +16 -0
- package/dist/qr.js +90 -0
- package/dist/session-types.d.ts +64 -0
- package/dist/session-types.js +1 -0
- package/dist/tempivo-sensor-beacon.aar +0 -0
- package/dist/types.d.ts +3 -3
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +4 -8
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +3 -6
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorCalibration.swift +22 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +24 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +123 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +78 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +161 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +93 -0
- package/package.json +10 -3
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
package com.tempivo.sensor.beacon
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import kotlinx.coroutines.delay
|
|
5
|
+
import pl.efento.mobile.bluetooth.EfentoBluetooth
|
|
6
|
+
import pl.efento.mobile.bluetooth.api.SensorConnection
|
|
7
|
+
import pl.efento.mobile.bluetooth.api.SensorLegacyConnection
|
|
8
|
+
import pl.efento.mobile.bluetooth.exception.InvalidResetCodeException
|
|
9
|
+
import pl.efento.mobile.bluetooth.exception.UnsupportedCommandException
|
|
10
|
+
import pl.efento.mobile.bluetooth.model.BluetoothMacAddress
|
|
11
|
+
import pl.efento.mobile.bluetooth.model.sensor.Rule
|
|
12
|
+
import pl.efento.mobile.bluetooth.model.sensor.RuleCalendar
|
|
13
|
+
import pl.efento.mobile.bluetooth.model.sensor.SensorConfiguration
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Internal adapter to the sensor BLE runtime. Keep vendor types inside this file.
|
|
17
|
+
*/
|
|
18
|
+
internal object SensorBleRuntime {
|
|
19
|
+
private val initMonitor = Any()
|
|
20
|
+
@Volatile private var initialized = false
|
|
21
|
+
|
|
22
|
+
fun initialize(application: Application) {
|
|
23
|
+
if (initialized) return
|
|
24
|
+
synchronized(initMonitor) {
|
|
25
|
+
if (initialized) return
|
|
26
|
+
EfentoBluetooth.init(application)
|
|
27
|
+
initialized = true
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fun mapError(e: Throwable): TempivoSensorException {
|
|
32
|
+
if (e is TempivoSensorException) return e
|
|
33
|
+
val className = e.javaClass.name
|
|
34
|
+
return when {
|
|
35
|
+
e is InvalidResetCodeException || className.contains("InvalidResetCode") ->
|
|
36
|
+
TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "Invalid PIN.", e)
|
|
37
|
+
e is UnsupportedCommandException || className.contains("UnsupportedCommand") ->
|
|
38
|
+
TempivoSensorException(TempivoSensorException.Code.UNSUPPORTED_COMMAND, "Unsupported command.", e)
|
|
39
|
+
className.contains("NotConnected") ->
|
|
40
|
+
TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected.", e)
|
|
41
|
+
else ->
|
|
42
|
+
TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Sensor request failed.", e)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
suspend fun connect(
|
|
47
|
+
application: Application,
|
|
48
|
+
serial: String,
|
|
49
|
+
bluetoothMac: String,
|
|
50
|
+
pin: Int,
|
|
51
|
+
legacy: Boolean,
|
|
52
|
+
): Session {
|
|
53
|
+
initialize(application)
|
|
54
|
+
val mac = BluetoothMacAddress(bluetoothMac)
|
|
55
|
+
// PIN stays on the connection: set, trigger, calibration writes, and many other commands check it.
|
|
56
|
+
return if (legacy) {
|
|
57
|
+
val c =
|
|
58
|
+
EfentoBluetooth.sensorLegacyConnection(
|
|
59
|
+
deviceID = serial,
|
|
60
|
+
bluetoothMacAddress = mac,
|
|
61
|
+
resetCode = pin,
|
|
62
|
+
encryptionKey = "",
|
|
63
|
+
)
|
|
64
|
+
c.connect()
|
|
65
|
+
if (pin >= 0) c.resetCode = pin
|
|
66
|
+
Session.Legacy(c)
|
|
67
|
+
} else {
|
|
68
|
+
val c =
|
|
69
|
+
EfentoBluetooth.sensorConnection(
|
|
70
|
+
deviceID = serial,
|
|
71
|
+
bluetoothMacAddress = mac,
|
|
72
|
+
resetCode = pin,
|
|
73
|
+
encryptionKey = "",
|
|
74
|
+
)
|
|
75
|
+
c.connect()
|
|
76
|
+
if (pin >= 0) c.resetCode = pin
|
|
77
|
+
Session.Modern(c)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
suspend fun disconnect(session: Session?) {
|
|
82
|
+
when (session) {
|
|
83
|
+
is Session.Modern -> runCatching { session.connection.disconnect() }
|
|
84
|
+
is Session.Legacy -> runCatching { session.connection.disconnect() }
|
|
85
|
+
null -> Unit
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
suspend fun readConfiguration(session: Session): SensorConfiguration {
|
|
90
|
+
return when (session) {
|
|
91
|
+
is Session.Modern -> modernGetConfiguration(session.connection)
|
|
92
|
+
is Session.Legacy ->
|
|
93
|
+
throw TempivoSensorException(
|
|
94
|
+
TempivoSensorException.Code.UNSUPPORTED_COMMAND,
|
|
95
|
+
"Configuration rules require a current-generation sensor session.",
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
suspend fun writeRules(session: Session, rules: List<Rule>, calendars: List<RuleCalendar>) {
|
|
101
|
+
when (session) {
|
|
102
|
+
is Session.Modern -> {
|
|
103
|
+
val base = modernGetConfiguration(session.connection)
|
|
104
|
+
val next = base.copy(rules = rules.take(12), ruleCalendars = calendars.take(6))
|
|
105
|
+
session.connection.commands.setConfiguration(next) { }
|
|
106
|
+
}
|
|
107
|
+
is Session.Legacy ->
|
|
108
|
+
throw TempivoSensorException(
|
|
109
|
+
TempivoSensorException.Code.UNSUPPORTED_COMMAND,
|
|
110
|
+
"Configuration rules require a current-generation sensor session.",
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
suspend fun triggerTransmission(session: Session): TempivoSensorSession.TriggerResult {
|
|
116
|
+
return when (session) {
|
|
117
|
+
is Session.Modern -> {
|
|
118
|
+
session.connection.commands.triggerCommunicationWithServer { }
|
|
119
|
+
TempivoSensorSession.TriggerResult(ok = true, supported = true)
|
|
120
|
+
}
|
|
121
|
+
is Session.Legacy -> {
|
|
122
|
+
try {
|
|
123
|
+
session.connection.cellularCommands.triggerCommunicationWithServer { }
|
|
124
|
+
TempivoSensorSession.TriggerResult(ok = true, supported = true)
|
|
125
|
+
} catch (e: UnsupportedCommandException) {
|
|
126
|
+
TempivoSensorSession.TriggerResult(ok = false, supported = false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
suspend fun readCalibration(session: Session): TempivoSensorCalibration {
|
|
133
|
+
return when (session) {
|
|
134
|
+
is Session.Modern -> {
|
|
135
|
+
val ext = session.connection.commands.getExtendedConfiguration { }
|
|
136
|
+
TempivoSensorCalibrationDecoder.decode(ext.laboratoryCalibrationTimestamp)
|
|
137
|
+
}
|
|
138
|
+
is Session.Legacy -> TempivoSensorCalibration(null, null)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fun compileRules(cfg: TempivoSensorConfiguration): Pair<List<Rule>, List<RuleCalendar>> =
|
|
143
|
+
PartnerBleRules.compile(cfg)
|
|
144
|
+
|
|
145
|
+
fun decompile(cfg: SensorConfiguration): TempivoSensorConfiguration =
|
|
146
|
+
PartnerBleRules.decompile(cfg)
|
|
147
|
+
|
|
148
|
+
private suspend fun modernGetConfiguration(modern: SensorConnection): SensorConfiguration {
|
|
149
|
+
var last: Exception? = null
|
|
150
|
+
repeat(3) { attempt ->
|
|
151
|
+
try {
|
|
152
|
+
if (attempt > 0) {
|
|
153
|
+
runCatching { modern.commands.getDeviceInfo { } }
|
|
154
|
+
}
|
|
155
|
+
return modern.commands.getConfiguration { }
|
|
156
|
+
} catch (e: Exception) {
|
|
157
|
+
last = e
|
|
158
|
+
if (attempt < 2) delay(400)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
throw last ?: TempivoSensorException(TempivoSensorException.Code.UNKNOWN, "Could not read configuration.")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
sealed class Session {
|
|
165
|
+
class Modern(val connection: SensorConnection) : Session()
|
|
166
|
+
class Legacy(val connection: SensorLegacyConnection) : Session()
|
|
167
|
+
}
|
|
168
|
+
}
|
package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt
CHANGED
|
@@ -155,8 +155,7 @@ object TempivoSensorBeaconDecoder {
|
|
|
155
155
|
var measurementCounter: Long? = null
|
|
156
156
|
var readingTimestampUnix: Long? = null
|
|
157
157
|
var readingTimestampIso: String? = null
|
|
158
|
-
var
|
|
159
|
-
var periodFactor: Int? = null
|
|
158
|
+
var measurementIntervalSeconds: Int? = null
|
|
160
159
|
val readings = mutableListOf<TempivoSensorBeaconReading>()
|
|
161
160
|
|
|
162
161
|
if (adv03 != null && adv03.isNotEmpty()) {
|
|
@@ -170,8 +169,7 @@ object TempivoSensorBeaconDecoder {
|
|
|
170
169
|
measurementCounter = parsed.measurementCounter
|
|
171
170
|
readingTimestampUnix = parsed.readingTimestampUnix
|
|
172
171
|
readingTimestampIso = parsed.readingTimestampIso
|
|
173
|
-
|
|
174
|
-
periodFactor = parsed.periodFactor
|
|
172
|
+
measurementIntervalSeconds = parsed.measurementIntervalSeconds
|
|
175
173
|
}
|
|
176
174
|
0x02 -> {
|
|
177
175
|
val fw5 = fw5Summary(adv03)
|
|
@@ -233,26 +231,13 @@ object TempivoSensorBeaconDecoder {
|
|
|
233
231
|
measurementCounter = measurementCounter,
|
|
234
232
|
readingTimestampUnix = readingTimestampUnix,
|
|
235
233
|
readingTimestampIso = readingTimestampIso,
|
|
236
|
-
|
|
237
|
-
periodFactor = periodFactor,
|
|
234
|
+
measurementIntervalSeconds = measurementIntervalSeconds,
|
|
238
235
|
readings = readings,
|
|
239
236
|
readingsCount = readingsCount,
|
|
240
237
|
summary = sumOut.ifEmpty { null },
|
|
241
238
|
)
|
|
242
239
|
}
|
|
243
240
|
|
|
244
|
-
/** Firmware 7+ → modern (`false`); older families → legacy (`true`). */
|
|
245
|
-
fun legacyHintFromFirmware(firmware: String?): Boolean? {
|
|
246
|
-
if (firmware.isNullOrBlank()) return null
|
|
247
|
-
val t = firmware.trim()
|
|
248
|
-
if (t.startsWith("FW 5", ignoreCase = true)) return true
|
|
249
|
-
val m = Regex("""^(\d+)\.\d+""").find(t) ?: return null
|
|
250
|
-
val major = m.groupValues[1].toIntOrNull() ?: return null
|
|
251
|
-
if (major >= 7) return false
|
|
252
|
-
if (major >= 1) return true
|
|
253
|
-
return null
|
|
254
|
-
}
|
|
255
|
-
|
|
256
241
|
private data class Adv03Parsed(
|
|
257
242
|
val firmware: String?,
|
|
258
243
|
val batteryOk: Boolean?,
|
|
@@ -261,8 +246,7 @@ object TempivoSensorBeaconDecoder {
|
|
|
261
246
|
val measurementCounter: Long?,
|
|
262
247
|
val readingTimestampUnix: Long?,
|
|
263
248
|
val readingTimestampIso: String?,
|
|
264
|
-
val
|
|
265
|
-
val periodFactor: Int?,
|
|
249
|
+
val measurementIntervalSeconds: Int?,
|
|
266
250
|
)
|
|
267
251
|
|
|
268
252
|
private fun fw5Summary(frame: ByteArray): String? {
|
|
@@ -360,7 +344,7 @@ object TempivoSensorBeaconDecoder {
|
|
|
360
344
|
|
|
361
345
|
private fun parseFw6Adv03(adv03: ByteArray): Adv03Parsed {
|
|
362
346
|
if (adv03.size < ADV_FRAME_03_LEN) {
|
|
363
|
-
return Adv03Parsed(null, null, null, null, null, null, null, null
|
|
347
|
+
return Adv03Parsed(null, null, null, null, null, null, null, null)
|
|
364
348
|
}
|
|
365
349
|
val fwRaw = ((adv03[7].toInt() and 0xFF) shl 8) or (adv03[8].toInt() and 0xFF)
|
|
366
350
|
val major = (fwRaw shr 11) and 0x1F
|
|
@@ -399,8 +383,7 @@ object TempivoSensorBeaconDecoder {
|
|
|
399
383
|
measurementCounter = measurementCounter,
|
|
400
384
|
readingTimestampUnix = readingTimestampUnix,
|
|
401
385
|
readingTimestampIso = readingTimestampIso,
|
|
402
|
-
|
|
403
|
-
periodFactor = pfact,
|
|
386
|
+
measurementIntervalSeconds = pbase * pfact,
|
|
404
387
|
)
|
|
405
388
|
}
|
|
406
389
|
}
|
package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt
CHANGED
|
@@ -118,7 +118,6 @@ class TempivoSensorBeaconScanner(
|
|
|
118
118
|
val summary =
|
|
119
119
|
telemetry?.summary?.takeIf { it.isNotEmpty() }
|
|
120
120
|
?: serial
|
|
121
|
-
val legacyHint = TempivoSensorBeaconDecoder.legacyHintFromFirmware(telemetry?.firmware)
|
|
122
121
|
return TempivoSensorBeaconDevice(
|
|
123
122
|
deviceId = addr,
|
|
124
123
|
bluetoothMacAddress = macColons,
|
|
@@ -126,7 +125,6 @@ class TempivoSensorBeaconScanner(
|
|
|
126
125
|
rssi = result.rssi,
|
|
127
126
|
telemetry = telemetry,
|
|
128
127
|
summary = summary,
|
|
129
|
-
legacyHint = legacyHint,
|
|
130
128
|
)
|
|
131
129
|
}
|
|
132
130
|
|
|
@@ -20,8 +20,8 @@ data class TempivoSensorBeaconTelemetry(
|
|
|
20
20
|
val measurementCounter: Long? = null,
|
|
21
21
|
val readingTimestampUnix: Long? = null,
|
|
22
22
|
val readingTimestampIso: String? = null,
|
|
23
|
-
|
|
24
|
-
val
|
|
23
|
+
/** Sample interval from the advertisement, in seconds. */
|
|
24
|
+
val measurementIntervalSeconds: Int? = null,
|
|
25
25
|
val readings: List<TempivoSensorBeaconReading> = emptyList(),
|
|
26
26
|
val readingsCount: Int = 0,
|
|
27
27
|
val summary: String? = null,
|
|
@@ -37,6 +37,4 @@ data class TempivoSensorBeaconDevice(
|
|
|
37
37
|
val rssi: Int,
|
|
38
38
|
val telemetry: TempivoSensorBeaconTelemetry?,
|
|
39
39
|
val summary: String?,
|
|
40
|
-
/** `true` for older firmware families; `false` for current; `null` if unknown. */
|
|
41
|
-
val legacyHint: Boolean?,
|
|
42
40
|
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package com.tempivo.sensor.beacon
|
|
2
|
+
|
|
3
|
+
import java.util.Calendar
|
|
4
|
+
import java.util.Locale
|
|
5
|
+
import java.util.TimeZone
|
|
6
|
+
|
|
7
|
+
data class TempivoSensorCalibration(
|
|
8
|
+
val laboratoryCalibrationDate: String?,
|
|
9
|
+
val laboratoryCalibrationTimestamp: Long?,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
object TempivoSensorCalibrationDecoder {
|
|
13
|
+
private const val SECONDS_PER_DAY = 86_400L
|
|
14
|
+
|
|
15
|
+
fun decode(raw: Long?): TempivoSensorCalibration {
|
|
16
|
+
if (raw == null || raw <= 0L) {
|
|
17
|
+
return TempivoSensorCalibration(null, null)
|
|
18
|
+
}
|
|
19
|
+
val date =
|
|
20
|
+
if (raw < 100_000L) {
|
|
21
|
+
isoDateUtc(raw * SECONDS_PER_DAY)
|
|
22
|
+
} else {
|
|
23
|
+
isoDateUtc(raw)
|
|
24
|
+
}
|
|
25
|
+
return TempivoSensorCalibration(date, raw)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private fun isoDateUtc(unixSeconds: Long): String {
|
|
29
|
+
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"), Locale.US)
|
|
30
|
+
cal.timeInMillis = unixSeconds * 1000L
|
|
31
|
+
val y = cal.get(Calendar.YEAR)
|
|
32
|
+
val m = cal.get(Calendar.MONTH) + 1
|
|
33
|
+
val d = cal.get(Calendar.DAY_OF_MONTH)
|
|
34
|
+
return String.format(Locale.US, "%04d-%02d-%02d", y, m, d)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
package com.tempivo.sensor.beacon
|
|
2
|
+
|
|
3
|
+
class TempivoSensorException(
|
|
4
|
+
val code: Code,
|
|
5
|
+
message: String,
|
|
6
|
+
cause: Throwable? = null,
|
|
7
|
+
) : Exception(message, cause) {
|
|
8
|
+
enum class Code {
|
|
9
|
+
INVALID_PIN,
|
|
10
|
+
INVALID_QR,
|
|
11
|
+
INVALID_CONFIG,
|
|
12
|
+
NOT_CONNECTED,
|
|
13
|
+
UNSUPPORTED_COMMAND,
|
|
14
|
+
RUNTIME_UNAVAILABLE,
|
|
15
|
+
CONNECT_FAILED,
|
|
16
|
+
UNKNOWN,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
package com.tempivo.sensor.beacon
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
|
|
6
|
+
data class TempivoTemperatureAlert(
|
|
7
|
+
val type: String,
|
|
8
|
+
val channel: String,
|
|
9
|
+
val lowC: Double? = null,
|
|
10
|
+
val highC: Double? = null,
|
|
11
|
+
val minC: Double? = null,
|
|
12
|
+
val maxC: Double? = null,
|
|
13
|
+
val hysteresisC: Double? = null,
|
|
14
|
+
val transmitOnBreach: Boolean = true,
|
|
15
|
+
val transmitOnReturn: Boolean = true,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
data class TempivoSchedule(
|
|
19
|
+
val always: Boolean,
|
|
20
|
+
val weekdays: List<Int> = emptyList(),
|
|
21
|
+
val from: String? = null,
|
|
22
|
+
val to: String? = null,
|
|
23
|
+
val utcOffsetMinutes: Int = 0,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
data class TempivoSensorConfiguration(
|
|
27
|
+
val temperatureAlerts: List<TempivoTemperatureAlert>,
|
|
28
|
+
val schedule: TempivoSchedule,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
object TempivoSensorProfile {
|
|
32
|
+
private val timeRe = Regex("^([01]\\d|2[0-3]):([0-5]\\d)$")
|
|
33
|
+
|
|
34
|
+
fun parseJson(json: String): TempivoSensorConfiguration {
|
|
35
|
+
val root =
|
|
36
|
+
try {
|
|
37
|
+
JSONObject(json)
|
|
38
|
+
} catch (_: Exception) {
|
|
39
|
+
throw TempivoSensorException(TempivoSensorException.Code.INVALID_CONFIG, "Configuration is not valid JSON.")
|
|
40
|
+
}
|
|
41
|
+
return parse(root)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fun parse(root: JSONObject): TempivoSensorConfiguration {
|
|
45
|
+
if (root.has("profiles") && root.optJSONArray("profiles") != null) {
|
|
46
|
+
throw invalid("Pass one profile from GET /devices/config-profiles/{slug}, not the list.")
|
|
47
|
+
}
|
|
48
|
+
val body =
|
|
49
|
+
root.optJSONObject("profile")?.takeIf {
|
|
50
|
+
it.has("temperatureAlerts") || it.has("schedule") || it.has("slug")
|
|
51
|
+
} ?: root
|
|
52
|
+
if (!body.has("temperatureAlerts") && body.has("alarmRules")) {
|
|
53
|
+
throw invalid("Use a config profile with temperatureAlerts (GET /devices/config-profiles/{slug}), not alarmRules.")
|
|
54
|
+
}
|
|
55
|
+
val alerts = mutableListOf<TempivoTemperatureAlert>()
|
|
56
|
+
val arr = body.optJSONArray("temperatureAlerts") ?: JSONArray()
|
|
57
|
+
for (i in 0 until arr.length()) {
|
|
58
|
+
val item = arr.optJSONObject(i) ?: throw invalid("temperatureAlerts[$i] must be an object.")
|
|
59
|
+
alerts.add(parseAlert(item, i))
|
|
60
|
+
}
|
|
61
|
+
return TempivoSensorConfiguration(alerts, parseSchedule(body.optJSONObject("schedule")))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fun toJson(cfg: TempivoSensorConfiguration): String {
|
|
65
|
+
val root = JSONObject()
|
|
66
|
+
val arr = JSONArray()
|
|
67
|
+
for (a in cfg.temperatureAlerts) {
|
|
68
|
+
val o = JSONObject()
|
|
69
|
+
o.put("type", a.type)
|
|
70
|
+
o.put("channel", a.channel)
|
|
71
|
+
if (a.lowC != null) o.put("lowC", a.lowC)
|
|
72
|
+
if (a.highC != null) o.put("highC", a.highC)
|
|
73
|
+
if (a.minC != null) o.put("minC", a.minC)
|
|
74
|
+
if (a.maxC != null) o.put("maxC", a.maxC)
|
|
75
|
+
if (a.hysteresisC != null) o.put("hysteresisC", a.hysteresisC)
|
|
76
|
+
o.put("transmitOnBreach", a.transmitOnBreach)
|
|
77
|
+
if (a.type == "range") o.put("transmitOnReturn", a.transmitOnReturn)
|
|
78
|
+
arr.put(o)
|
|
79
|
+
}
|
|
80
|
+
root.put("temperatureAlerts", arr)
|
|
81
|
+
val s = JSONObject()
|
|
82
|
+
if (cfg.schedule.always) {
|
|
83
|
+
s.put("always", true)
|
|
84
|
+
} else {
|
|
85
|
+
val days = JSONArray()
|
|
86
|
+
cfg.schedule.weekdays.forEach { days.put(it) }
|
|
87
|
+
s.put("weekdays", days)
|
|
88
|
+
s.put("from", cfg.schedule.from)
|
|
89
|
+
s.put("to", cfg.schedule.to)
|
|
90
|
+
s.put("utcOffsetMinutes", cfg.schedule.utcOffsetMinutes)
|
|
91
|
+
}
|
|
92
|
+
root.put("schedule", s)
|
|
93
|
+
return root.toString()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private fun parseAlert(item: JSONObject, index: Int): TempivoTemperatureAlert {
|
|
97
|
+
val channel = item.optString("channel")
|
|
98
|
+
if (channel != "ambient" && channel != "probe") {
|
|
99
|
+
throw invalid("temperatureAlerts[$index].channel must be ambient or probe.")
|
|
100
|
+
}
|
|
101
|
+
val hysteresis = if (item.has("hysteresisC") && !item.isNull("hysteresisC")) item.optDouble("hysteresisC") else 1.0
|
|
102
|
+
val transmit = if (item.has("transmitOnBreach")) item.optBoolean("transmitOnBreach", true) else true
|
|
103
|
+
val transmitOnReturn = if (item.has("transmitOnReturn")) item.optBoolean("transmitOnReturn", true) else true
|
|
104
|
+
return when (item.optString("type")) {
|
|
105
|
+
"range" -> {
|
|
106
|
+
if (!item.has("lowC") || !item.has("highC")) {
|
|
107
|
+
throw invalid("temperatureAlerts[$index] needs lowC and highC.")
|
|
108
|
+
}
|
|
109
|
+
val low = item.optDouble("lowC")
|
|
110
|
+
val high = item.optDouble("highC")
|
|
111
|
+
if (high <= low) throw invalid("temperatureAlerts[$index]: highC must be greater than lowC.")
|
|
112
|
+
TempivoTemperatureAlert(
|
|
113
|
+
"range",
|
|
114
|
+
channel,
|
|
115
|
+
lowC = low,
|
|
116
|
+
highC = high,
|
|
117
|
+
hysteresisC = hysteresis,
|
|
118
|
+
transmitOnBreach = transmit,
|
|
119
|
+
transmitOnReturn = transmitOnReturn,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
"min" -> {
|
|
123
|
+
if (!item.has("minC")) throw invalid("temperatureAlerts[$index] needs minC.")
|
|
124
|
+
TempivoTemperatureAlert("min", channel, minC = item.optDouble("minC"), hysteresisC = hysteresis, transmitOnBreach = transmit)
|
|
125
|
+
}
|
|
126
|
+
"max" -> {
|
|
127
|
+
if (!item.has("maxC")) throw invalid("temperatureAlerts[$index] needs maxC.")
|
|
128
|
+
TempivoTemperatureAlert("max", channel, maxC = item.optDouble("maxC"), hysteresisC = hysteresis, transmitOnBreach = transmit)
|
|
129
|
+
}
|
|
130
|
+
else -> throw invalid("temperatureAlerts[$index].type must be range, min, or max.")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private fun parseSchedule(raw: JSONObject?): TempivoSchedule {
|
|
135
|
+
if (raw == null || raw.optBoolean("always", false)) return TempivoSchedule(always = true)
|
|
136
|
+
val weekdays = raw.optJSONArray("weekdays") ?: throw invalid("schedule.weekdays is required.")
|
|
137
|
+
val days = mutableListOf<Int>()
|
|
138
|
+
for (i in 0 until weekdays.length()) {
|
|
139
|
+
val n = weekdays.optInt(i, -1)
|
|
140
|
+
if (n !in 0..6) throw invalid("schedule.weekdays must be 0 to 6.")
|
|
141
|
+
days.add(n)
|
|
142
|
+
}
|
|
143
|
+
if (days.isEmpty()) throw invalid("schedule.weekdays is required.")
|
|
144
|
+
val from = raw.optString("from")
|
|
145
|
+
val to = raw.optString("to")
|
|
146
|
+
if (!timeRe.matches(from) || !timeRe.matches(to)) {
|
|
147
|
+
throw invalid("schedule.from and schedule.to must be HH:MM.")
|
|
148
|
+
}
|
|
149
|
+
if (!raw.has("utcOffsetMinutes")) throw invalid("schedule.utcOffsetMinutes is required.")
|
|
150
|
+
return TempivoSchedule(
|
|
151
|
+
always = false,
|
|
152
|
+
weekdays = days,
|
|
153
|
+
from = from,
|
|
154
|
+
to = to,
|
|
155
|
+
utcOffsetMinutes = raw.optInt("utcOffsetMinutes"),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fun minutesFromHhmm(value: String): Int {
|
|
160
|
+
val m = timeRe.matchEntire(value) ?: throw invalid("Invalid time $value.")
|
|
161
|
+
return m.groupValues[1].toInt() * 60 + m.groupValues[2].toInt()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fun hhmmFromMinutes(minutes: Int): String {
|
|
165
|
+
val clamped = minutes.coerceIn(0, 1439)
|
|
166
|
+
return String.format(java.util.Locale.US, "%02d:%02d", clamped / 60, clamped % 60)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private fun invalid(message: String): TempivoSensorException =
|
|
170
|
+
TempivoSensorException(TempivoSensorException.Code.INVALID_CONFIG, message)
|
|
171
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package com.tempivo.sensor.beacon
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
import java.util.Locale
|
|
5
|
+
|
|
6
|
+
data class TempivoSensorQr(
|
|
7
|
+
val serial: String,
|
|
8
|
+
val pin: String,
|
|
9
|
+
val model: String?,
|
|
10
|
+
val sessionType: TempivoSensorSession.SessionType,
|
|
11
|
+
val bluetoothMac: String,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
object TempivoSensorQrParser {
|
|
15
|
+
const val DEFAULT_MODEL = "HC7"
|
|
16
|
+
|
|
17
|
+
fun normalizeSerial(value: String): String =
|
|
18
|
+
value.trim().uppercase(Locale.US).replace(Regex("[^A-Z0-9]"), "")
|
|
19
|
+
|
|
20
|
+
fun bluetoothMacFromSerial(serial: String): String {
|
|
21
|
+
val key = normalizeSerial(serial)
|
|
22
|
+
if (key.length != 12 || !key.matches(Regex("[0-9A-F]{12}"))) {
|
|
23
|
+
throw TempivoSensorException(TempivoSensorException.Code.INVALID_QR, "Serial must be 12 hex characters.")
|
|
24
|
+
}
|
|
25
|
+
return buildString(17) {
|
|
26
|
+
for (i in 0 until 12 step 2) {
|
|
27
|
+
if (isNotEmpty()) append(':')
|
|
28
|
+
append(key, i, i + 2)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fun sessionTypeFromModel(model: String?): TempivoSensorSession.SessionType {
|
|
34
|
+
if (model.isNullOrBlank()) return TempivoSensorSession.SessionType.MODERN
|
|
35
|
+
val m = model.trim().uppercase(Locale.US).replace("-", "")
|
|
36
|
+
return if (m == "HC5" || m.startsWith("6")) {
|
|
37
|
+
TempivoSensorSession.SessionType.LEGACY
|
|
38
|
+
} else {
|
|
39
|
+
TempivoSensorSession.SessionType.MODERN
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fun parse(json: String): TempivoSensorQr {
|
|
44
|
+
val rec =
|
|
45
|
+
try {
|
|
46
|
+
JSONObject(json.trim())
|
|
47
|
+
} catch (_: Exception) {
|
|
48
|
+
throw TempivoSensorException(TempivoSensorException.Code.INVALID_QR, "QR is not valid JSON.")
|
|
49
|
+
}
|
|
50
|
+
val snRaw =
|
|
51
|
+
when {
|
|
52
|
+
rec.has("sn") && !rec.isNull("sn") -> rec.opt("sn")?.toString().orEmpty()
|
|
53
|
+
else -> ""
|
|
54
|
+
}
|
|
55
|
+
val serial = normalizeSerial(snRaw)
|
|
56
|
+
val pin = pinFrom(rec)
|
|
57
|
+
if (serial.length < 8) {
|
|
58
|
+
throw TempivoSensorException(TempivoSensorException.Code.INVALID_QR, "QR is missing serial (sn).")
|
|
59
|
+
}
|
|
60
|
+
if (pin.isEmpty()) {
|
|
61
|
+
throw TempivoSensorException(TempivoSensorException.Code.INVALID_QR, "QR is missing PIN.")
|
|
62
|
+
}
|
|
63
|
+
val model = rec.optString("model").trim().ifEmpty { DEFAULT_MODEL }
|
|
64
|
+
val hex = if (serial.length == 12) serial else serial.takeLast(12)
|
|
65
|
+
return TempivoSensorQr(
|
|
66
|
+
serial = serial,
|
|
67
|
+
pin = pin,
|
|
68
|
+
model = model,
|
|
69
|
+
sessionType = sessionTypeFromModel(model),
|
|
70
|
+
bluetoothMac = bluetoothMacFromSerial(hex),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private fun pinFrom(rec: JSONObject): String {
|
|
75
|
+
val raw = if (rec.has("pin") && !rec.isNull("pin")) rec.opt("pin") else rec.opt("resetCode")
|
|
76
|
+
return when (raw) {
|
|
77
|
+
null, JSONObject.NULL -> ""
|
|
78
|
+
is Number -> raw.toLong().toString()
|
|
79
|
+
else -> raw.toString().trim()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|