@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.
Files changed (37) hide show
  1. package/README.md +195 -79
  2. package/android/library/build.gradle +3 -0
  3. package/android/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +279 -0
  4. package/android/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +168 -0
  5. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +6 -23
  6. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +0 -2
  7. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +2 -4
  8. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorCalibration.kt +36 -0
  9. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +18 -0
  10. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +171 -0
  11. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +82 -0
  12. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +114 -0
  13. package/android/settings.gradle +1 -0
  14. package/dist/calibration.d.ts +7 -0
  15. package/dist/calibration.js +42 -0
  16. package/dist/decoder.js +4 -12
  17. package/dist/errors.d.ts +6 -0
  18. package/dist/errors.js +8 -0
  19. package/dist/index.d.ts +6 -1
  20. package/dist/index.js +5 -1
  21. package/dist/profile.d.ts +10 -0
  22. package/dist/profile.js +226 -0
  23. package/dist/qr.d.ts +16 -0
  24. package/dist/qr.js +90 -0
  25. package/dist/session-types.d.ts +64 -0
  26. package/dist/session-types.js +1 -0
  27. package/dist/tempivo-sensor-beacon.aar +0 -0
  28. package/dist/types.d.ts +3 -3
  29. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +4 -8
  30. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +3 -6
  31. package/ios/Sources/TempivoSensorBeacon/TempivoSensorCalibration.swift +22 -0
  32. package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +24 -0
  33. package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +123 -0
  34. package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +78 -0
  35. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +161 -0
  36. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +93 -0
  37. 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
+ }
@@ -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 periodBaseSeconds: Int? = null
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
- periodBaseSeconds = parsed.periodBaseSeconds
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
- periodBaseSeconds = periodBaseSeconds,
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 periodBaseSeconds: Int?,
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, 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
- periodBaseSeconds = pbase,
403
- periodFactor = pfact,
386
+ measurementIntervalSeconds = pbase * pfact,
404
387
  )
405
388
  }
406
389
  }
@@ -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
- val periodBaseSeconds: Int? = null,
24
- val periodFactor: Int? = null,
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
+ }