@tempivo/sensor-beacon 0.1.1 → 0.3.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 (73) hide show
  1. package/README.md +222 -78
  2. package/android/build.gradle +38 -2
  3. package/android/src/main/AndroidManifest.xml +10 -0
  4. package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +233 -0
  5. package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +122 -0
  6. package/android-aar/build.gradle +4 -0
  7. package/{android → android-aar}/library/build.gradle +3 -0
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +279 -0
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +168 -0
  10. package/{android → android-aar}/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +6 -23
  11. package/{android → android-aar}/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +0 -2
  12. package/{android → android-aar}/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +2 -4
  13. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorCalibration.kt +36 -0
  14. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +18 -0
  15. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +171 -0
  16. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +82 -0
  17. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +127 -0
  18. package/{android → android-aar}/settings.gradle +1 -0
  19. package/app.plugin.cjs +142 -0
  20. package/dist/calibration.d.ts +7 -0
  21. package/dist/calibration.js +42 -0
  22. package/dist/decoder.js +4 -12
  23. package/dist/errors.d.ts +6 -0
  24. package/dist/errors.js +8 -0
  25. package/dist/expo-modules-core.web-stub.d.ts +5 -0
  26. package/dist/expo-modules-core.web-stub.js +9 -0
  27. package/dist/index.d.ts +8 -1
  28. package/dist/index.js +6 -1
  29. package/dist/native-bridge.d.ts +14 -0
  30. package/dist/native-bridge.js +51 -0
  31. package/dist/native-helpers.d.ts +5 -0
  32. package/dist/native-helpers.js +33 -0
  33. package/dist/native-module.d.ts +14 -0
  34. package/dist/native-module.js +104 -0
  35. package/dist/native-types.d.ts +41 -0
  36. package/dist/native-types.js +1 -0
  37. package/dist/profile.d.ts +10 -0
  38. package/dist/profile.js +226 -0
  39. package/dist/qr.d.ts +16 -0
  40. package/dist/qr.js +90 -0
  41. package/dist/react-native.d.ts +1 -0
  42. package/dist/react-native.js +1 -0
  43. package/dist/session-types.d.ts +64 -0
  44. package/dist/session-types.js +1 -0
  45. package/dist/tempivo-sensor-beacon.aar +0 -0
  46. package/dist/types.d.ts +3 -3
  47. package/expo-module.config.json +12 -0
  48. package/ios/Frameworks/TempivoSensorBridge.xcframework/Info.plist +43 -0
  49. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +159 -0
  50. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/Info.plist +35 -0
  51. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/Modules/module.modulemap +8 -0
  52. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  53. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/Headers/TempivoSensorBridge.h +159 -0
  54. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/Info.plist +31 -0
  55. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/Modules/module.modulemap +8 -0
  56. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64-simulator/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  57. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +4 -8
  58. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +3 -6
  59. package/ios/Sources/TempivoSensorBeacon/TempivoSensorCalibration.swift +22 -0
  60. package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +24 -0
  61. package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +123 -0
  62. package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +78 -0
  63. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +161 -0
  64. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +93 -0
  65. package/ios/TempivoSensorBeacon.podspec +35 -0
  66. package/ios/TempivoSensorBeaconModule.swift +208 -0
  67. package/package.json +55 -7
  68. /package/{android → android-aar}/gradle/wrapper/gradle-wrapper.jar +0 -0
  69. /package/{android → android-aar}/gradle/wrapper/gradle-wrapper.properties +0 -0
  70. /package/{android → android-aar}/gradle.properties +0 -0
  71. /package/{android → android-aar}/gradlew +0 -0
  72. /package/{android → android-aar}/library/consumer-rules.pro +0 -0
  73. /package/{android → android-aar}/library/src/main/AndroidManifest.xml +0 -0
@@ -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
+ }
@@ -0,0 +1,127 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ import android.app.Application
4
+ import android.content.Context
5
+ import kotlinx.coroutines.Dispatchers
6
+ import kotlinx.coroutines.runBlocking
7
+ import kotlinx.coroutines.sync.Mutex
8
+ import kotlinx.coroutines.sync.withLock
9
+ import kotlinx.coroutines.withContext
10
+
11
+ /**
12
+ * GATT session: connect, read/write alert rules and schedule, trigger uplink, read lab calibration date.
13
+ */
14
+ class TempivoSensorSession(
15
+ context: Context,
16
+ ) {
17
+ enum class SessionType {
18
+ MODERN,
19
+ LEGACY,
20
+ }
21
+
22
+ data class TriggerResult(
23
+ val ok: Boolean,
24
+ val supported: Boolean,
25
+ )
26
+
27
+ private val application = context.applicationContext as Application
28
+ private val mutex = Mutex()
29
+ private var session: SensorBleRuntime.Session? = null
30
+
31
+ suspend fun connect(
32
+ serial: String,
33
+ bluetoothMac: String,
34
+ pin: String,
35
+ sessionType: SessionType = SessionType.MODERN,
36
+ ) {
37
+ val pinInt =
38
+ pin.trim().toIntOrNull()
39
+ ?: throw TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "PIN must be numeric.")
40
+ try {
41
+ mutex.withLock {
42
+ SensorBleRuntime.disconnect(session)
43
+ session = null
44
+ session =
45
+ withContext(Dispatchers.Main) {
46
+ SensorBleRuntime.connect(
47
+ application = application,
48
+ serial = TempivoSensorQrParser.normalizeSerial(serial),
49
+ bluetoothMac = bluetoothMac,
50
+ pin = pinInt,
51
+ legacy = sessionType == SessionType.LEGACY,
52
+ )
53
+ }
54
+ }
55
+ } catch (e: Throwable) {
56
+ throw SensorBleRuntime.mapError(e).let {
57
+ if (it.code == TempivoSensorException.Code.UNKNOWN) {
58
+ TempivoSensorException(TempivoSensorException.Code.CONNECT_FAILED, "Could not connect.", e)
59
+ } else {
60
+ it
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ suspend fun connect(qr: TempivoSensorQr) {
67
+ connect(qr.serial, qr.bluetoothMac, qr.pin, qr.sessionType)
68
+ }
69
+
70
+ suspend fun disconnect() {
71
+ mutex.withLock {
72
+ SensorBleRuntime.disconnect(session)
73
+ session = null
74
+ }
75
+ }
76
+
77
+ suspend fun getConfiguration(): TempivoSensorConfiguration {
78
+ return runGatt {
79
+ SensorBleRuntime.decompile(SensorBleRuntime.readConfiguration(requireSession()))
80
+ }
81
+ }
82
+
83
+ suspend fun getConfigurationJson(): String = TempivoSensorProfile.toJson(getConfiguration())
84
+
85
+ suspend fun setConfiguration(configuration: TempivoSensorConfiguration) {
86
+ val (rules, calendars) = SensorBleRuntime.compileRules(configuration)
87
+ runGatt {
88
+ SensorBleRuntime.writeRules(requireSession(), rules, calendars)
89
+ }
90
+ }
91
+
92
+ suspend fun setConfigurationJson(json: String) {
93
+ setConfiguration(TempivoSensorProfile.parseJson(json))
94
+ }
95
+
96
+ suspend fun triggerTransmission(): TriggerResult {
97
+ return runGatt { SensorBleRuntime.triggerTransmission(requireSession()) }
98
+ }
99
+
100
+ suspend fun getCalibration(): TempivoSensorCalibration {
101
+ return runGatt { SensorBleRuntime.readCalibration(requireSession()) }
102
+ }
103
+
104
+ fun connectBlocking(qr: TempivoSensorQr) = runBlocking { connect(qr) }
105
+
106
+ fun disconnectBlocking() = runBlocking { disconnect() }
107
+
108
+ fun getConfigurationJsonBlocking(): String = runBlocking { getConfigurationJson() }
109
+
110
+ fun setConfigurationJsonBlocking(json: String) = runBlocking { setConfigurationJson(json) }
111
+
112
+ fun triggerTransmissionBlocking(): TriggerResult = runBlocking { triggerTransmission() }
113
+
114
+ fun getCalibrationBlocking(): TempivoSensorCalibration = runBlocking { getCalibration() }
115
+
116
+ private fun requireSession(): SensorBleRuntime.Session =
117
+ session
118
+ ?: throw TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected.")
119
+
120
+ private suspend fun <T> runGatt(block: suspend () -> T): T {
121
+ try {
122
+ return mutex.withLock { block() }
123
+ } catch (e: Throwable) {
124
+ throw SensorBleRuntime.mapError(e)
125
+ }
126
+ }
127
+ }
@@ -11,6 +11,7 @@ dependencyResolutionManagement {
11
11
  repositories {
12
12
  google()
13
13
  mavenCentral()
14
+ maven { url "https://maven.efento.io/releases" }
14
15
  }
15
16
  }
16
17
 
package/app.plugin.cjs ADDED
@@ -0,0 +1,142 @@
1
+ const Module = require('module');
2
+ const path = require('path');
3
+
4
+ function loadConfigPlugins() {
5
+ const candidates = [process.cwd(), path.dirname(process.cwd())];
6
+ for (const base of candidates) {
7
+ try {
8
+ const id = require.resolve('expo/config-plugins', { paths: [base] });
9
+ return require(id);
10
+ } catch {
11
+ // try next
12
+ }
13
+ }
14
+ try {
15
+ const fromExpo = require.resolve('expo/package.json', { paths: [process.cwd()] });
16
+ return Module.createRequire(fromExpo)('expo/config-plugins');
17
+ } catch {
18
+ return require('expo/config-plugins');
19
+ }
20
+ }
21
+
22
+ const {
23
+ AndroidConfig,
24
+ withInfoPlist,
25
+ withProjectBuildGradle,
26
+ withSettingsGradle,
27
+ withGradleProperties,
28
+ } = loadConfigPlugins();
29
+
30
+ const MAVEN_URL = 'https://maven.efento.io/releases';
31
+ const DEFAULT_BLE_MESSAGE = 'Tempivo uses Bluetooth to find and configure sensors.';
32
+ const MIN_SDK = 26;
33
+
34
+ function insertKotlinStdlibForce(contents, isKts) {
35
+ if (contents.includes('TEMPIVO_KOTLIN_STDLIB_FORCE')) return contents;
36
+ const extra = isKts
37
+ ? `
38
+ // TEMPIVO_KOTLIN_STDLIB_FORCE
39
+ subprojects {
40
+ configurations.configureEach {
41
+ resolutionStrategy {
42
+ force("org.jetbrains.kotlin:kotlin-stdlib:2.1.20")
43
+ force("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.20")
44
+ force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20")
45
+ force("org.jetbrains.kotlin:kotlin-reflect:2.1.20")
46
+ }
47
+ }
48
+ }
49
+ `
50
+ : `
51
+ // TEMPIVO_KOTLIN_STDLIB_FORCE
52
+ subprojects { sub ->
53
+ sub.configurations.configureEach {
54
+ resolutionStrategy {
55
+ force 'org.jetbrains.kotlin:kotlin-stdlib:2.1.20'
56
+ force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.20'
57
+ force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.20'
58
+ force 'org.jetbrains.kotlin:kotlin-reflect:2.1.20'
59
+ }
60
+ }
61
+ }
62
+ `;
63
+ return `${contents.trimEnd()}\n${extra}`;
64
+ }
65
+
66
+ function insertMavenRepo(contents, isKts) {
67
+ if (contents.includes('maven.efento.io')) return contents;
68
+ const line = isKts
69
+ ? `\n maven { url = uri("${MAVEN_URL}") }`
70
+ : `\n maven { url '${MAVEN_URL}' }`;
71
+ const token = 'mavenCentral()';
72
+ const idx = contents.lastIndexOf(token);
73
+ if (idx === -1) return contents;
74
+ const at = idx + token.length;
75
+ return contents.slice(0, at) + line + contents.slice(at);
76
+ }
77
+
78
+ function ensureGradleProperty(items, key, value, shouldReplace) {
79
+ const existing = items.find((item) => item.type === 'property' && item.key === key);
80
+ if (existing && existing.type === 'property') {
81
+ if (shouldReplace(existing.value)) existing.value = value;
82
+ return;
83
+ }
84
+ items.push({ type: 'property', key, value });
85
+ }
86
+
87
+ function withTempivoSensorBeacon(config, props = {}) {
88
+ const bleMessage =
89
+ (props && props.bluetoothPermission) ||
90
+ config.ios?.infoPlist?.NSBluetoothAlwaysUsageDescription ||
91
+ DEFAULT_BLE_MESSAGE;
92
+
93
+ config.android = config.android || {};
94
+ if (!config.android.minSdkVersion || Number(config.android.minSdkVersion) < MIN_SDK) {
95
+ config.android.minSdkVersion = MIN_SDK;
96
+ }
97
+
98
+ config = withGradleProperties(config, (mod) => {
99
+ ensureGradleProperty(
100
+ mod.modResults,
101
+ 'android.minSdkVersion',
102
+ String(MIN_SDK),
103
+ (current) => !current || Number(current) < MIN_SDK,
104
+ );
105
+ return mod;
106
+ });
107
+
108
+ config = withInfoPlist(config, (mod) => {
109
+ if (!mod.modResults.NSBluetoothAlwaysUsageDescription) {
110
+ mod.modResults.NSBluetoothAlwaysUsageDescription = bleMessage;
111
+ }
112
+ if (!mod.modResults.NSBluetoothPeripheralUsageDescription) {
113
+ mod.modResults.NSBluetoothPeripheralUsageDescription = bleMessage;
114
+ }
115
+ return mod;
116
+ });
117
+
118
+ config = AndroidConfig.Permissions.withPermissions(config, [
119
+ 'android.permission.BLUETOOTH',
120
+ 'android.permission.BLUETOOTH_ADMIN',
121
+ 'android.permission.BLUETOOTH_SCAN',
122
+ 'android.permission.BLUETOOTH_CONNECT',
123
+ 'android.permission.ACCESS_FINE_LOCATION',
124
+ ]);
125
+
126
+ config = withSettingsGradle(config, (mod) => {
127
+ const isKts = mod.modResults.language === 'kt' || (mod.modResults.path || '').endsWith('.kts');
128
+ mod.modResults.contents = insertMavenRepo(mod.modResults.contents, isKts);
129
+ return mod;
130
+ });
131
+
132
+ config = withProjectBuildGradle(config, (mod) => {
133
+ const isKts = mod.modResults.language === 'kt' || (mod.modResults.path || '').endsWith('.kts');
134
+ mod.modResults.contents = insertMavenRepo(mod.modResults.contents, isKts);
135
+ mod.modResults.contents = insertKotlinStdlibForce(mod.modResults.contents, isKts);
136
+ return mod;
137
+ });
138
+
139
+ return config;
140
+ }
141
+
142
+ module.exports = withTempivoSensorBeacon;
@@ -0,0 +1,7 @@
1
+ import type { TempivoSensorCalibration } from './session-types.js';
2
+ /**
3
+ * Device extended config may send lab date as Unix seconds or as days since 1970.
4
+ * Values below 100_000 are treated as days (year 2243 in seconds, year 2243 in days is far larger).
5
+ */
6
+ export declare function decodeLaboratoryCalibrationTimestamp(raw: number | null | undefined): TempivoSensorCalibration;
7
+ export declare function calibrationFromExtendedJson(json: string): TempivoSensorCalibration;
@@ -0,0 +1,42 @@
1
+ const SECONDS_PER_DAY = 86_400;
2
+ function pad2(n) {
3
+ return n < 10 ? `0${n}` : String(n);
4
+ }
5
+ function isoDateUtcFromUnixSeconds(seconds) {
6
+ const d = new Date(seconds * 1000);
7
+ return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
8
+ }
9
+ function isoDateUtcFromEpochDays(days) {
10
+ return isoDateUtcFromUnixSeconds(days * SECONDS_PER_DAY);
11
+ }
12
+ /**
13
+ * Device extended config may send lab date as Unix seconds or as days since 1970.
14
+ * Values below 100_000 are treated as days (year 2243 in seconds, year 2243 in days is far larger).
15
+ */
16
+ export function decodeLaboratoryCalibrationTimestamp(raw) {
17
+ if (raw == null || !Number.isFinite(raw) || raw <= 0) {
18
+ return { laboratoryCalibrationDate: null, laboratoryCalibrationTimestamp: null };
19
+ }
20
+ const n = Math.trunc(raw);
21
+ if (n < 100_000) {
22
+ return {
23
+ laboratoryCalibrationDate: isoDateUtcFromEpochDays(n),
24
+ laboratoryCalibrationTimestamp: n,
25
+ };
26
+ }
27
+ return {
28
+ laboratoryCalibrationDate: isoDateUtcFromUnixSeconds(n),
29
+ laboratoryCalibrationTimestamp: n,
30
+ };
31
+ }
32
+ export function calibrationFromExtendedJson(json) {
33
+ try {
34
+ const o = JSON.parse(json);
35
+ const raw = o.laboratoryCalibrationTimestamp ?? o.laboratory_calibration_date;
36
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : null;
37
+ return decodeLaboratoryCalibrationTimestamp(Number.isFinite(n) ? n : null);
38
+ }
39
+ catch {
40
+ return { laboratoryCalibrationDate: null, laboratoryCalibrationTimestamp: null };
41
+ }
42
+ }