@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
package/README.md CHANGED
@@ -1,129 +1,245 @@
1
1
  # @tempivo/sensor-beacon
2
2
 
3
- Tempivo partner SDK for **BLE advertising** from Tempivo sensors (manufacturer id **0x026C**). **No GATT connection.**
3
+ Tempivo partner SDK for sensors over BLE. Advertisement decode (manufacturer id **0x026C**) plus GATT session for alert rules, an immediate uplink, and laboratory calibration date.
4
4
 
5
- One npm package:
5
+ ```bash
6
+ npm install @tempivo/sensor-beacon
7
+ ```
6
8
 
7
9
  | Path | Use |
8
10
  |------|-----|
9
- | `dist/` | Node / TypeScript decoder |
10
- | `android/` + `dist/tempivo-sensor-beacon.aar` | Android scan + decode |
11
- | `ios/` | iOS Swift Package (scan + decode) |
11
+ | `dist/` | Node / TypeScript: advertising decode, QR parse, config JSON |
12
+ | `android/` + `dist/tempivo-sensor-beacon.aar` | Android scan + GATT |
13
+ | `ios/` | iOS Swift Package (scan + session) |
12
14
 
13
- ```bash
14
- npm install @tempivo/sensor-beacon
15
- ```
15
+ GATT runs on a phone (Android / iOS), not in Node. Always pass the sticker PIN to `connect`. Wrong PIN maps to `invalidPin`.
16
16
 
17
- ## Node / TypeScript
17
+ ## QR object
18
18
 
19
- Node.js 18+. Import from the package root:
19
+ Sticker QR is JSON. Parse with `parseSensorQrJson` (Node) or `TempivoSensorQrParser.parse` (Android / iOS).
20
+
21
+ ```json
22
+ { "sn": "282C024F0012", "pin": "111111", "model": "HC7" }
23
+ ```
20
24
 
21
25
  ```ts
22
- import {
23
- TEMPVO_SENSOR_MANUFACTURER_ID,
24
- normalizeManufacturerBytes,
25
- decodeSensorBeaconPayload,
26
- } from '@tempivo/sensor-beacon';
26
+ const qr = parseSensorQrJson(stickerQrText);
27
+ ```
27
28
 
28
- const reading = decodeSensorBeaconPayload(normalizeManufacturerBytes(rawBytes));
29
+ | Field | Type | Meaning |
30
+ |-------|------|---------|
31
+ | `serial` | string | Device serial, 12 hex chars, no colons. From QR `sn`. |
32
+ | `pin` | string | Sticker PIN. Required on `connect`. Also accepted as QR `resetCode`. |
33
+ | `model` | string | Sticker model. Omitted QR `model` becomes **HC7**. `HC5` selects legacy GATT. |
34
+ | `sessionType` | `modern` \| `legacy` | GATT path. HC7 / firmware 7+ is `modern`. HC5 / firmware 6.x is `legacy`. |
35
+ | `bluetoothMac` | string | BLE MAC with colons, derived from `serial`. |
36
+
37
+ ## Config JSON (same as Cellular API)
38
+
39
+ `setConfiguration` / `getConfiguration` use the same `temperatureAlerts` + `schedule` shape as `POST /api/v1/devices/config-profiles`.
40
+
41
+ ```json
42
+ {
43
+ "schedule": { "always": true },
44
+ "temperatureAlerts": [
45
+ {
46
+ "type": "range",
47
+ "channel": "probe",
48
+ "lowC": -30,
49
+ "highC": -10,
50
+ "hysteresisC": 0.5,
51
+ "transmitOnBreach": true,
52
+ "transmitOnReturn": true
53
+ }
54
+ ]
55
+ }
29
56
  ```
30
57
 
31
- Many stacks return manufacturer bytes **without** the 2-byte company id (`6C 02`). Use `normalizeManufacturerBytes` when needed.
58
+ **Root**
59
+
60
+ | Field | Meaning |
61
+ |-------|---------|
62
+ | `temperatureAlerts` | Alert list. Empty array is valid (no temperature alerts). |
63
+ | `schedule` | When alerts are active. `{ "always": true }` is 24/7. |
64
+
65
+ Ignored over BLE: `slug`, `name`, `measurementIntervalMinutes`, `transmissionIntervalSeconds`. Do not pass `GET /devices/{serial}/config` (`alarmRules`).
66
+
67
+ **Each `temperatureAlerts[]` item**
68
+
69
+ | Field | Meaning |
70
+ |-------|---------|
71
+ | `type` | `range` (band), `min` (below), or `max` (above). |
72
+ | `channel` | `ambient` or `probe`. |
73
+ | `lowC` / `highC` | Inclusive band for `type: "range"`. |
74
+ | `minC` | Threshold for `type: "min"`. |
75
+ | `maxC` | Threshold for `type: "max"`. |
76
+ | `hysteresisC` | Degrees of hysteresis. Omitted: **1**. |
77
+ | `transmitOnBreach` | Uplink when the alert trips. Omitted: **true**. |
78
+ | `transmitOnReturn` | Extra uplink when temp returns inside a `range`. Omitted: **true**. Range only. |
32
79
 
33
- ### Frame cache (`0x03` + `0x04`)
80
+ **`schedule` weekday window** (instead of `always`)
34
81
 
35
- FW6 devices split meta (advertisement) and measurements (scan response):
82
+ | Field | Meaning |
83
+ |-------|---------|
84
+ | `weekdays` | **0 = Monday** … **6 = Sunday**. |
85
+ | `from` / `to` | 24h `HH:MM`. |
86
+ | `utcOffsetMinutes` | Offset from UTC for `from` / `to`. |
87
+
88
+ ## Fetch a profile, apply over BLE
89
+
90
+ `GET /api/v1/devices/config-profiles/{slug}` returns `{ "profile": { … } }`. Pass the GET body or `profile` to `setConfigurationJson`.
36
91
 
37
92
  ```ts
38
- import {
39
- createSensorBeaconFrameCache,
40
- ingestManufacturerPayload,
41
- buildSensorBeaconReading,
42
- macKeyFromAddress,
43
- } from '@tempivo/sensor-beacon';
93
+ import { parseSensorQrJson, configurationFromApiProfile } from '@tempivo/sensor-beacon';
94
+
95
+ const qr = parseSensorQrJson(stickerQrText);
96
+ const res = await fetch(
97
+ `https://app.tempivo.com/api/v1/devices/config-profiles/${slug}`,
98
+ { headers: { Authorization: `Bearer ${apiKey}` } }
99
+ );
100
+ const body = await res.json();
101
+ const cfg = configurationFromApiProfile(body); // or body.profile
102
+ ```
103
+
104
+ On the phone, after `connect(qr)`:
44
105
 
45
- const cache = createSensorBeaconFrameCache();
46
- const macKey = macKeyFromAddress('28:2C:02:4F:00:12');
47
- ingestManufacturerPayload(advPayload, macKey, cache);
48
- ingestManufacturerPayload(scanPayload, macKey, cache);
49
- const reading = buildSensorBeaconReading(cache.last03.get(macKey), cache.last04.get(macKey));
106
+ ```kotlin
107
+ session.setConfigurationJson(profileJson) // GET body or profile object
108
+ session.triggerTransmission()
50
109
  ```
51
110
 
52
- ### TypeScript API
111
+ BLE does not write measurement interval, transmission interval, server, APN, name, or token.
53
112
 
54
- | Export | Purpose |
55
- |--------|---------|
56
- | `TEMPVO_SENSOR_MANUFACTURER_ID` | BLE company id `0x026c` |
57
- | `normalizeManufacturerBytes` | Strip company id prefix when present |
58
- | `decodeSensorBeaconPayload` | One-shot decode |
59
- | `ingestManufacturerPayload` | Update frame cache |
60
- | `buildSensorBeaconReading` | Merge cached frames |
61
- | `SensorBeaconReading` | Parsed device + measurements |
113
+ ## Node / TypeScript helpers
62
114
 
63
- Measurement types **0x01–0x22** decode to human-readable `text` on each slot.
115
+ ```ts
116
+ import {
117
+ normalizeManufacturerBytes,
118
+ decodeSensorBeaconPayload,
119
+ parseSensorQrJson,
120
+ parseSensorConfigurationJson,
121
+ } from '@tempivo/sensor-beacon';
64
122
 
65
- ## Android
123
+ const qr = parseSensorQrJson('{"sn":"282C024F0012","pin":"111111","model":"HC7"}');
124
+ const reading = decodeSensorBeaconPayload(normalizeManufacturerBytes(advertisementBytes));
125
+ const cfg = parseSensorConfigurationJson(/* config JSON above */);
126
+ ```
66
127
 
67
- Published artifact: `dist/tempivo-sensor-beacon.aar` (built before npm publish).
128
+ Invalid QR or config throws `TempivoSensorError` (`invalidQr`, `invalidPin`, `invalidConfig`).
129
+
130
+ ## Android (GATT)
131
+
132
+ `connect` / `setConfigurationJson` / `triggerTransmission` are `suspend`. Request `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` (API 31+) or location on older APIs.
68
133
 
69
134
  ```kotlin
70
- // app/build.gradle.kts
71
- dependencies {
72
- implementation(
73
- files(
74
- "${rootProject.projectDir}/../node_modules/@tempivo/sensor-beacon/dist/tempivo-sensor-beacon.aar",
75
- ),
76
- )
135
+ val qr = TempivoSensorQrParser.parse(stickerQrText)
136
+ val session = TempivoSensorSession(context)
137
+
138
+ session.connect(qr)
139
+ try {
140
+ session.setConfigurationJson(profileJson)
141
+ val trigger = session.triggerTransmission()
142
+ val applied = session.getConfiguration()
143
+ val cal = session.getCalibration()
144
+ } finally {
145
+ session.disconnect()
77
146
  }
78
147
  ```
79
148
 
80
- Adjust the path to your `node_modules` layout.
149
+ **`triggerTransmission()`**
81
150
 
82
- ```kotlin
83
- import com.tempivo.sensor.beacon.TempivoSensorBeaconScanner
151
+ | Field | Meaning |
152
+ |-------|---------|
153
+ | `ok` | Device accepted the uplink request. |
154
+ | `supported` | Firmware implements the command. `false` is not an error. |
84
155
 
85
- val scanner = TempivoSensorBeaconScanner(context)
86
- scanner.startScan { device ->
87
- // device.serialNumber, device.rssi, device.telemetry, device.summary
88
- }
89
- ```
156
+ **`getCalibration()`**
90
157
 
91
- Declare and request `BLUETOOTH_SCAN` (API 31+) or location permissions for legacy APIs.
158
+ | Field | Meaning |
159
+ |-------|---------|
160
+ | `laboratoryCalibrationDate` | ISO date (`YYYY-MM-DD`), or null if unset. |
161
+ | `laboratoryCalibrationTimestamp` | Same instant as unix seconds, or null. No expiry field on the device. |
92
162
 
93
- ## iOS
163
+ ## iOS (GATT)
94
164
 
95
- 1. **File Add Package Dependencies… Add Local…**
96
- 2. Path: `node_modules/@tempivo/sensor-beacon/ios`
97
- 3. Add product **TempivoSensorBeacon**
98
- 4. Set **`NSBluetoothAlwaysUsageDescription`** in Info.plist
165
+ Needs a physical device and the native `TempivoSensorBridge` XCFramework (`scripts/build-ios-bridge.sh`). Set **`NSBluetoothAlwaysUsageDescription`** in Info.plist. Simulator can scan advertisements only.
99
166
 
100
167
  ```swift
101
- import TempivoSensorBeacon
168
+ let qr = try TempivoSensorQrParser.parse(stickerQrText)
169
+ let session = TempivoSensorSession()
170
+ try session.connect(qr: qr)
171
+ defer { session.disconnect() }
172
+ try session.setConfigurationJson(profileJson)
173
+ _ = try session.triggerTransmission()
174
+ let applied = try session.getConfiguration()
175
+ let cal = try session.getCalibration()
176
+ ```
102
177
 
103
- final class ScanHost: NSObject, TempivoSensorBeaconScannerDelegate {
104
- private let scanner = TempivoSensorBeaconScanner()
178
+ ## Advertising (no pairing)
105
179
 
106
- override init() {
107
- super.init()
108
- scanner.delegate = self
109
- }
180
+ Filter manufacturer data on company id **0x026C**. Use **active scanning** so scan-response packets arrive. Product model is **not** in the broadcast. Connect uses QR `model` (HC7 is modern GATT).
181
+
182
+ ```kotlin
183
+ val scanner = TempivoSensorBeaconScanner(context)
184
+ scanner.startScan { device ->
185
+ Log.d("scan", "${device.serialNumber} rssi=${device.rssi} ${device.summary}")
186
+ }
187
+ ```
110
188
 
111
- func start() { scanner.startScanning() }
189
+ **Android `TempivoSensorBeaconDevice`**
190
+
191
+ | Field | Meaning |
192
+ |-------|---------|
193
+ | `deviceId` | Android BLE address. Often randomized. Not the sticker serial. |
194
+ | `bluetoothMacAddress` | MAC from the advertisement, with colons. |
195
+ | `serialNumber` | 12 hex chars from advertisement frame `0x03`. Same as QR `sn`. |
196
+ | `rssi` | Signal strength in dBm. |
197
+ | `summary` | Short display line. After scan response: measurement texts joined with ` · `. Before that: serial, or `Adv OK. Wait for scan response (measurements).` |
198
+ | `telemetry` | Decoded frames `0x03` + `0x04`, or null until enough bytes arrive. |
199
+
200
+ **`telemetry` (Android) / `reading` (iOS and Node)**
201
+
202
+ | Field | Meaning |
203
+ |-------|---------|
204
+ | `firmware` | `major.minor.patch` from the advertisement, e.g. `7.3.4`. |
205
+ | `batteryOk` | Battery status flag from the advertisement. |
206
+ | `encryptionEnabled` | Advertisement payload encryption flag. |
207
+ | `cellularStatus` | `ble_only`, `cell_ok`, `no_server`, or `net_issue`. |
208
+ | `readingTimestampUnix` | Sample time as unix seconds, or null. |
209
+ | `readingTimestampIso` | Same instant as UTC ISO-8601, or null. |
210
+ | `measurementIntervalSeconds` | Sample interval from the advertisement, in seconds. |
211
+ | `readings` | Decoded scan-response slots (see below). |
212
+ | `readingsCount` | Number of measurement slots. |
213
+ | `summary` | Same short line as on the device object. |
214
+ | `measurementCounter` | Counter from frame `0x03`, or null. |
215
+
216
+ Node also exposes `serialMac`, `temperatures[]`, `humidity`, and `rawHex`.
217
+
218
+ **Each `readings[]` / `measurements[]` slot**
219
+
220
+ | Field | Meaning |
221
+ |-------|---------|
222
+ | `typeHex` | Slot type, e.g. `0x01` temperature, `0x02` humidity. |
223
+ | `raw24` | Undecoded 24-bit payload. |
224
+ | `text` | Display value with unit, e.g. `21.8 °C`. No type name in the string. |
225
+ | `temperatureC` / `humidityPct` | Node only: numeric value when the slot is that type. |
226
+
227
+ iOS `SensorBeaconDiscovery` has `deviceId`, `rssi`, `serialNumber`, `bluetoothMacAddress`, and `reading` (same telemetry fields).
112
228
 
113
- func sensorBeaconScanner(_ scanner: TempivoSensorBeaconScanner, didDiscover discovery: SensorBeaconDiscovery) {
114
- print(discovery.serialNumber ?? discovery.deviceId, discovery.reading?.summary ?? "")
115
- }
229
+ ```swift
230
+ func sensorBeaconScanner(_ scanner: TempivoSensorBeaconScanner, didDiscover discovery: SensorBeaconDiscovery) {
231
+ print(discovery.serialNumber ?? "", discovery.rssi, discovery.reading?.summary ?? "")
116
232
  }
117
233
  ```
118
234
 
119
- Requires iOS 15+. Use a physical device for BLE.
235
+ ## Errors
236
+
237
+ Native GATT maps to: `invalidPin`, `invalidQr`, `invalidConfig`, `notConnected`, `unsupportedCommand`, `connectFailed`, `runtimeUnavailable`, `unknown`.
120
238
 
121
- ## Build locally
239
+ ## Tests
122
240
 
123
241
  ```bash
124
- npm run build:ts # dist/ only
125
- npm run build:android # requires Android SDK
126
- npm run build:all # both
242
+ npm run test:unit
127
243
  ```
128
244
 
129
245
  ## License
@@ -24,4 +24,7 @@ android {
24
24
 
25
25
  dependencies {
26
26
  implementation "androidx.core:core-ktx:1.17.0"
27
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
28
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2"
29
+ implementation "pl.efento.mobile:bluetooth:2.3.0"
27
30
  }
@@ -0,0 +1,279 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ import pl.efento.mobile.bluetooth.model.sensor.Action
4
+ import pl.efento.mobile.bluetooth.model.sensor.DisabledCondition
5
+ import pl.efento.mobile.bluetooth.model.sensor.HighThresholdCondition
6
+ import pl.efento.mobile.bluetooth.model.sensor.LogicOperator
7
+ import pl.efento.mobile.bluetooth.model.sensor.LogicOperatorCondition
8
+ import pl.efento.mobile.bluetooth.model.sensor.LogicOperatorConditionParameters
9
+ import pl.efento.mobile.bluetooth.model.sensor.LowThresholdCondition
10
+ import pl.efento.mobile.bluetooth.model.sensor.Rule
11
+ import pl.efento.mobile.bluetooth.model.sensor.RuleCalendar
12
+ import pl.efento.mobile.bluetooth.model.sensor.SensorConfiguration
13
+ import pl.efento.mobile.bluetooth.model.sensor.TemperatureLowHighThresholdConditionParameters
14
+ import pl.efento.mobile.bluetooth.model.sensor.TriggeringMode
15
+
16
+ /**
17
+ * Compile Cellular API `temperatureAlerts` + `schedule` into device rules.
18
+ * Range + `transmitOnReturn` uses the same OR layout as POST /devices/config-profiles.
19
+ */
20
+ internal object PartnerBleRules {
21
+ fun compile(cfg: TempivoSensorConfiguration): Pair<List<Rule>, List<RuleCalendar>> {
22
+ val rules = MutableList(12) { i ->
23
+ Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION)
24
+ }
25
+ val used = BooleanArray(12)
26
+ val returnLowSlots = mutableListOf<Int>()
27
+
28
+ for (alert in cfg.temperatureAlerts) {
29
+ val channel = if (alert.channel == "probe") 3 else 1
30
+ val hyst = alert.hysteresisC ?: 1.0
31
+ when (alert.type) {
32
+ "min" -> {
33
+ val slot = findFree(used) ?: continue
34
+ val t = alert.minC ?: continue
35
+ val action = if (alert.transmitOnBreach) Action.TRIGGER_TRANSMISSION_WITH_ACK else Action.NO_ACTION
36
+ putThreshold(rules, slot, channel, low = true, t, hyst, action)
37
+ used[slot] = true
38
+ }
39
+ "max" -> {
40
+ val slot = findFree(used) ?: continue
41
+ val t = alert.maxC ?: continue
42
+ val action = if (alert.transmitOnBreach) Action.TRIGGER_TRANSMISSION_WITH_ACK else Action.NO_ACTION
43
+ putThreshold(rules, slot, channel, low = false, t, hyst, action)
44
+ used[slot] = true
45
+ }
46
+ "range" -> {
47
+ val low = alert.lowC ?: continue
48
+ val high = alert.highC ?: continue
49
+ val pair = findPair(used) ?: continue
50
+ val ret = alert.transmitOnReturn
51
+ val action =
52
+ when {
53
+ ret -> Action.NO_ACTION
54
+ alert.transmitOnBreach -> Action.TRIGGER_TRANSMISSION_WITH_ACK
55
+ else -> Action.NO_ACTION
56
+ }
57
+ putThreshold(rules, pair, channel, low = true, low, hyst, action)
58
+ putThreshold(rules, pair + 1, channel, low = false, high, hyst, action)
59
+ used[pair] = true
60
+ used[pair + 1] = true
61
+ if (ret) returnLowSlots.add(pair)
62
+ }
63
+ }
64
+ }
65
+
66
+ for (lowSlot in returnLowSlots) {
67
+ val logicSlot = findFree(used) ?: break
68
+ rules[logicSlot] =
69
+ Rule(
70
+ logicSlot + 1,
71
+ emptySet(),
72
+ LogicOperatorCondition(
73
+ LogicOperatorConditionParameters(
74
+ LogicOperator.OR,
75
+ setOf(lowSlot + 1, lowSlot + 2),
76
+ emptySet(),
77
+ 0,
78
+ 0,
79
+ ),
80
+ ),
81
+ Action.TRIGGER_TRANSMISSION_WITH_ACK,
82
+ )
83
+ used[logicSlot] = true
84
+ }
85
+
86
+ val active = rules.filter { it.condition !is DisabledCondition }.map { it.id }.toSet()
87
+ val calendars = MutableList(6) { i ->
88
+ RuleCalendar(i + 1, emptySet(), RuleCalendar.Type.Disabled)
89
+ }
90
+ if (active.isNotEmpty()) {
91
+ calendars[0] = RuleCalendar(1, active, weeklyType(cfg.schedule))
92
+ }
93
+ return rules to calendars
94
+ }
95
+
96
+ fun decompile(cfg: SensorConfiguration): TempivoSensorConfiguration {
97
+ val padded = MutableList(12) { i ->
98
+ cfg.rules.orEmpty().getOrNull(i)
99
+ ?: Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION)
100
+ }
101
+ val orRefs = orSelectedPairs(padded)
102
+ val used = BooleanArray(12)
103
+ val alerts = mutableListOf<TempivoTemperatureAlert>()
104
+
105
+ for (i in 0..10) {
106
+ if (used[i] || used[i + 1]) continue
107
+ val low = padded[i]
108
+ val high = padded[i + 1]
109
+ if (low.condition !is LowThresholdCondition || high.condition !is HighThresholdCondition) continue
110
+ val lowCh = channelOf(low) ?: continue
111
+ val highCh = channelOf(high) ?: continue
112
+ if (lowCh != highCh) continue
113
+ val lowP = (low.condition as LowThresholdCondition).conditionParameters
114
+ val highP = (high.condition as HighThresholdCondition).conditionParameters
115
+ val hasReturn = orRefs.any { it.first == i + 1 && it.second == i + 2 }
116
+ val breachOnly =
117
+ !hasReturn && (low.action != Action.NO_ACTION || high.action != Action.NO_ACTION)
118
+ alerts.add(
119
+ TempivoTemperatureAlert(
120
+ type = "range",
121
+ channel = lowCh,
122
+ lowC = lowP.thresholdValue.toDouble(),
123
+ highC = highP.thresholdValue.toDouble(),
124
+ hysteresisC = lowP.hysteresisValue?.toDouble() ?: 1.0,
125
+ transmitOnBreach = hasReturn || breachOnly,
126
+ transmitOnReturn = hasReturn,
127
+ ),
128
+ )
129
+ used[i] = true
130
+ used[i + 1] = true
131
+ }
132
+
133
+ for (i in 0 until 12) {
134
+ if (used[i]) continue
135
+ val r = padded[i]
136
+ when (val c = r.condition) {
137
+ is LowThresholdCondition -> {
138
+ val ch = channelOf(r) ?: continue
139
+ alerts.add(
140
+ TempivoTemperatureAlert(
141
+ type = "min",
142
+ channel = ch,
143
+ minC = c.conditionParameters.thresholdValue.toDouble(),
144
+ hysteresisC = c.conditionParameters.hysteresisValue?.toDouble() ?: 1.0,
145
+ transmitOnBreach = r.action != Action.NO_ACTION,
146
+ ),
147
+ )
148
+ used[i] = true
149
+ }
150
+ is HighThresholdCondition -> {
151
+ val ch = channelOf(r) ?: continue
152
+ alerts.add(
153
+ TempivoTemperatureAlert(
154
+ type = "max",
155
+ channel = ch,
156
+ maxC = c.conditionParameters.thresholdValue.toDouble(),
157
+ hysteresisC = c.conditionParameters.hysteresisValue?.toDouble() ?: 1.0,
158
+ transmitOnBreach = r.action != Action.NO_ACTION,
159
+ ),
160
+ )
161
+ used[i] = true
162
+ }
163
+ else -> Unit
164
+ }
165
+ }
166
+
167
+ return TempivoSensorConfiguration(alerts, decompileSchedule(cfg.ruleCalendars))
168
+ }
169
+
170
+ private fun orSelectedPairs(rules: List<Rule>): List<Pair<Int, Int>> {
171
+ val out = mutableListOf<Pair<Int, Int>>()
172
+ for (r in rules) {
173
+ val c = r.condition as? LogicOperatorCondition ?: continue
174
+ val p = c.conditionParameters
175
+ if (p.logicOperator != LogicOperator.OR) continue
176
+ val ids = p.selectedRules.sorted()
177
+ if (ids.size == 2) out.add(ids[0] to ids[1])
178
+ }
179
+ return out
180
+ }
181
+
182
+ private fun channelOf(rule: Rule): String? =
183
+ when {
184
+ rule.channels.contains(3) -> "probe"
185
+ rule.channels.contains(1) -> "ambient"
186
+ else -> null
187
+ }
188
+
189
+ private fun findPair(used: BooleanArray): Int? {
190
+ for (i in 0..10) {
191
+ if (!used[i] && !used[i + 1]) return i
192
+ }
193
+ return null
194
+ }
195
+
196
+ private fun findFree(used: BooleanArray): Int? {
197
+ for (i in 0 until 12) if (!used[i]) return i
198
+ return null
199
+ }
200
+
201
+ private fun putThreshold(
202
+ rules: MutableList<Rule>,
203
+ slot: Int,
204
+ channel: Int,
205
+ low: Boolean,
206
+ threshold: Double,
207
+ hyst: Double,
208
+ action: Action,
209
+ ) {
210
+ if (slot !in 0..11) return
211
+ val params =
212
+ TemperatureLowHighThresholdConditionParameters(
213
+ threshold,
214
+ hyst,
215
+ TriggeringMode.CONSECUTIVE_SAMPLES,
216
+ 1,
217
+ )
218
+ val condition = if (low) LowThresholdCondition(params) else HighThresholdCondition(params)
219
+ rules[slot] = Rule(slot + 1, setOf(channel), condition, action)
220
+ }
221
+
222
+ private fun decompileSchedule(calendars: List<RuleCalendar>?): TempivoSchedule {
223
+ val weekly = calendars.orEmpty().map { it.type }.filterIsInstance<RuleCalendar.Type.Weekly>().firstOrNull()
224
+ ?: return TempivoSchedule(always = true)
225
+ val days = weekly.days.map { dayToPartnerIndex(it) }.sorted()
226
+ val always = days.size == 7 && weekly.fromTime == 0 && weekly.toTime >= 1439
227
+ return if (always) {
228
+ TempivoSchedule(always = true)
229
+ } else {
230
+ TempivoSchedule(
231
+ always = false,
232
+ weekdays = days,
233
+ from = TempivoSensorProfile.hhmmFromMinutes(weekly.fromTime),
234
+ to = TempivoSensorProfile.hhmmFromMinutes(weekly.toTime),
235
+ utcOffsetMinutes = weekly.timezoneOffset,
236
+ )
237
+ }
238
+ }
239
+
240
+ private fun weeklyType(schedule: TempivoSchedule): RuleCalendar.Type {
241
+ if (schedule.always) {
242
+ return RuleCalendar.Type.Weekly(
243
+ days = RuleCalendar.Type.Weekly.Day.entries.toSet(),
244
+ fromTime = 0,
245
+ toTime = 1439,
246
+ timezoneOffset = 0,
247
+ )
248
+ }
249
+ val days = schedule.weekdays.map { partnerIndexToDay(it) }.toSet()
250
+ return RuleCalendar.Type.Weekly(
251
+ days = days,
252
+ fromTime = TempivoSensorProfile.minutesFromHhmm(schedule.from ?: "00:00"),
253
+ toTime = TempivoSensorProfile.minutesFromHhmm(schedule.to ?: "23:59"),
254
+ timezoneOffset = schedule.utcOffsetMinutes,
255
+ )
256
+ }
257
+
258
+ private fun partnerIndexToDay(index: Int): RuleCalendar.Type.Weekly.Day =
259
+ when (index) {
260
+ 0 -> RuleCalendar.Type.Weekly.Day.MONDAY
261
+ 1 -> RuleCalendar.Type.Weekly.Day.TUESDAY
262
+ 2 -> RuleCalendar.Type.Weekly.Day.WEDNESDAY
263
+ 3 -> RuleCalendar.Type.Weekly.Day.THURSDAY
264
+ 4 -> RuleCalendar.Type.Weekly.Day.FRIDAY
265
+ 5 -> RuleCalendar.Type.Weekly.Day.SATURDAY
266
+ else -> RuleCalendar.Type.Weekly.Day.SUNDAY
267
+ }
268
+
269
+ private fun dayToPartnerIndex(day: RuleCalendar.Type.Weekly.Day): Int =
270
+ when (day) {
271
+ RuleCalendar.Type.Weekly.Day.MONDAY -> 0
272
+ RuleCalendar.Type.Weekly.Day.TUESDAY -> 1
273
+ RuleCalendar.Type.Weekly.Day.WEDNESDAY -> 2
274
+ RuleCalendar.Type.Weekly.Day.THURSDAY -> 3
275
+ RuleCalendar.Type.Weekly.Day.FRIDAY -> 4
276
+ RuleCalendar.Type.Weekly.Day.SATURDAY -> 5
277
+ RuleCalendar.Type.Weekly.Day.SUNDAY -> 6
278
+ }
279
+ }