@tempivo/sensor-beacon 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.1.2] - 2026-09-05
10
+
11
+ ### Fixed
12
+
13
+ - BLE get: `schedule: { always: true }` no longer reads back as `weekdays: [6]`.
14
+ - BLE set: dual ambient + probe ranges with `transmitOnReturn` keep alerts on get (interleaved OR after each pair).
15
+ - Native compile fail-closed on bad/missing alert fields or full slots (no silent drop).
16
+ - Post-set readback requires alert count ≥ written count.
17
+ - iOS parse aligned with Android/TS (required thresholds, schedule fields, slot budget).
18
+
19
+ ### Changed
20
+
21
+ - README: range + `transmitOnReturn` uses **3** slots; weekday BLE get caveat.
22
+
9
23
  ## [1.1.1] - 2026-09-03
10
24
 
11
25
  ### Changed
package/README.md CHANGED
@@ -263,7 +263,9 @@ When **both** are set on write, uplink must be at most **60×** the measurement
263
263
 
264
264
  Use `parseSensorConfigurationJson` to validate before write. Use `parseSensorConfigurationFromDevice` / `parseSensorConfigurationFromDeviceJson` when parsing a **readback** from the device (relaxed interval rules). For `GET /devices/config-profiles/{slug}`, use `configurationFromApiProfile(response.profile)` or pass the `{ profile }` envelope directly to `setConfigurationJson`.
265
265
 
266
- **Alert slot budget:** the device has **12** rule slots. A `range` with default `transmitOnReturn: true` uses **two** slots (breach + return). Up to **4** such ranges, or more min/max alerts if you disable `transmitOnReturn` on ranges.
266
+ **Alert slot budget:** the device has **12** rule slots. A `range` with default `transmitOnReturn: true` uses **three** slots (`low` + `high` + OR). Up to **4** such ranges. A lone `min`/`max` uses one; with return on a range disabled, a range uses two.
267
+
268
+ **Weekday schedules** (`Mon–Fri`, etc.): BLE **get** may not round-trip weekdays on current partner firmware (day-mask decode). Prefer `{ "always": true }` when possible. After set, if get looks wrong (e.g. only Sunday), trust what you wrote.
267
269
 
268
270
  **Each `temperatureAlerts[]` item**
269
271
 
@@ -16,7 +16,8 @@ import pl.efento.mobile.bluetooth.model.sensor.TriggeringMode
16
16
 
17
17
  /**
18
18
  * Compile Cellular API `temperatureAlerts` + `schedule` into device rules.
19
- * Range + `transmitOnReturn` uses the same OR layout as POST /devices/config-profiles.
19
+ * Range + `transmitOnReturn` uses an OR slot immediately after each low/high pair
20
+ * (same layout as a single range with return; required for dual-range BLE writes).
20
21
  */
21
22
  internal object PartnerBleRules {
22
23
  fun compile(cfg: TempivoSensorConfiguration): Pair<List<Rule>, List<RuleCalendar>> {
@@ -24,30 +25,30 @@ internal object PartnerBleRules {
24
25
  Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION)
25
26
  }
26
27
  val used = BooleanArray(12)
27
- val returnLowSlots = mutableListOf<Int>()
28
28
 
29
29
  for (alert in cfg.temperatureAlerts) {
30
30
  val channel = if (alert.channel == "probe") 3 else 1
31
31
  val hyst = alert.hysteresisC ?: 1.0
32
32
  when (alert.type) {
33
33
  "min" -> {
34
- val slot = findFree(used) ?: continue
35
- val t = alert.minC ?: continue
34
+ val slot = findFree(used) ?: throw compileFail("Not enough free alarm rule slots.")
35
+ val t = alert.minC ?: throw compileFail("min alert is missing minC.")
36
36
  val action = if (alert.transmitOnBreach) Action.TRIGGER_TRANSMISSION_WITH_ACK else Action.NO_ACTION
37
37
  putThreshold(rules, slot, channel, low = true, t, hyst, action)
38
38
  used[slot] = true
39
39
  }
40
40
  "max" -> {
41
- val slot = findFree(used) ?: continue
42
- val t = alert.maxC ?: continue
41
+ val slot = findFree(used) ?: throw compileFail("Not enough free alarm rule slots.")
42
+ val t = alert.maxC ?: throw compileFail("max alert is missing maxC.")
43
43
  val action = if (alert.transmitOnBreach) Action.TRIGGER_TRANSMISSION_WITH_ACK else Action.NO_ACTION
44
44
  putThreshold(rules, slot, channel, low = false, t, hyst, action)
45
45
  used[slot] = true
46
46
  }
47
47
  "range" -> {
48
- val low = alert.lowC ?: continue
49
- val high = alert.highC ?: continue
50
- val pair = findPair(used) ?: continue
48
+ val low = alert.lowC ?: throw compileFail("range alert is missing lowC.")
49
+ val high = alert.highC ?: throw compileFail("range alert is missing highC.")
50
+ val pair = findPair(used)
51
+ ?: throw compileFail("Not enough free alarm rule slots for another range (need two consecutive slots).")
51
52
  val ret = alert.transmitOnReturn
52
53
  val action =
53
54
  when {
@@ -59,31 +60,19 @@ internal object PartnerBleRules {
59
60
  putThreshold(rules, pair + 1, channel, low = false, high, hyst, action)
60
61
  used[pair] = true
61
62
  used[pair + 1] = true
62
- if (ret) returnLowSlots.add(pair)
63
+ // Place OR immediately after each pair (low, high, OR, …).
64
+ // Batching all pairs then ORs at the end clears dual-range configs on device.
65
+ if (ret) {
66
+ val logicSlot = findFree(used)
67
+ ?: throw compileFail("Not enough free alarm rule slots for transmit-on-return (OR logic).")
68
+ putReturnOr(rules, logicSlot, pair)
69
+ used[logicSlot] = true
70
+ }
63
71
  }
72
+ else -> throw compileFail("Unsupported temperature alert type: ${alert.type}.")
64
73
  }
65
74
  }
66
75
 
67
- for (lowSlot in returnLowSlots) {
68
- val logicSlot = findFree(used) ?: break
69
- rules[logicSlot] =
70
- Rule(
71
- logicSlot + 1,
72
- emptySet(),
73
- LogicOperatorCondition(
74
- LogicOperatorConditionParameters(
75
- LogicOperator.OR,
76
- setOf(lowSlot + 1, lowSlot + 2),
77
- emptySet(),
78
- 0,
79
- 0,
80
- ),
81
- ),
82
- Action.TRIGGER_TRANSMISSION_WITH_ACK,
83
- )
84
- used[logicSlot] = true
85
- }
86
-
87
76
  val active = rules.filter { it.condition !is DisabledCondition }.map { it.id }.toSet()
88
77
  val calendars = MutableList(6) { i ->
89
78
  RuleCalendar(i + 1, emptySet(), RuleCalendar.Type.Disabled)
@@ -129,6 +118,17 @@ internal object PartnerBleRules {
129
118
  )
130
119
  used[i] = true
131
120
  used[i + 1] = true
121
+ if (hasReturn) {
122
+ for (j in 0 until 12) {
123
+ val c = padded[j].condition as? LogicOperatorCondition ?: continue
124
+ val p = c.conditionParameters
125
+ if (p.logicOperator != LogicOperator.OR) continue
126
+ if (p.selectedRules == setOf(i + 1, i + 2)) {
127
+ used[j] = true
128
+ break
129
+ }
130
+ }
131
+ }
132
132
  }
133
133
 
134
134
  for (i in 0 until 12) {
@@ -200,6 +200,9 @@ internal object PartnerBleRules {
200
200
  else -> null
201
201
  }
202
202
 
203
+ private fun compileFail(message: String): Nothing =
204
+ throw TempivoSensorException(TempivoSensorException.Code.INVALID_CONFIG, message)
205
+
203
206
  private fun findPair(used: BooleanArray): Int? {
204
207
  for (i in 0..10) {
205
208
  if (!used[i] && !used[i + 1]) return i
@@ -221,7 +224,7 @@ internal object PartnerBleRules {
221
224
  hyst: Double,
222
225
  action: Action,
223
226
  ) {
224
- if (slot !in 0..11) return
227
+ if (slot !in 0..11) throw compileFail("Alarm rule slot out of range: $slot.")
225
228
  val params =
226
229
  TemperatureLowHighThresholdConditionParameters(
227
230
  threshold,
@@ -233,22 +236,41 @@ internal object PartnerBleRules {
233
236
  rules[slot] = Rule(slot + 1, setOf(channel), condition, action)
234
237
  }
235
238
 
239
+ private fun putReturnOr(rules: MutableList<Rule>, logicSlot: Int, lowSlot: Int) {
240
+ rules[logicSlot] =
241
+ Rule(
242
+ logicSlot + 1,
243
+ emptySet(),
244
+ LogicOperatorCondition(
245
+ LogicOperatorConditionParameters(
246
+ LogicOperator.OR,
247
+ setOf(lowSlot + 1, lowSlot + 2),
248
+ emptySet(),
249
+ 0,
250
+ 0,
251
+ ),
252
+ ),
253
+ Action.TRIGGER_TRANSMISSION_WITH_ACK,
254
+ )
255
+ }
256
+
236
257
  private fun decompileSchedule(calendars: List<RuleCalendar>?): TempivoSchedule {
237
258
  val weekly = calendars.orEmpty().map { it.type }.filterIsInstance<RuleCalendar.Type.Weekly>().firstOrNull()
238
259
  ?: return TempivoSchedule(always = true)
239
260
  val days = weekly.days.map { dayToPartnerIndex(it) }.sorted()
240
- val always = days.size == 7 && weekly.fromTime == 0 && weekly.toTime >= 1439
241
- return if (always) {
242
- TempivoSchedule(always = true)
243
- } else {
244
- TempivoSchedule(
245
- always = false,
246
- weekdays = days,
247
- from = TempivoSensorProfile.hhmmFromMinutes(weekly.fromTime),
248
- to = TempivoSensorProfile.hhmmFromMinutes(weekly.toTime),
249
- utcOffsetMinutes = weekly.timezoneOffset,
250
- )
261
+ val fullDay = weekly.fromTime == 0 && weekly.toTime >= 1439
262
+ // Efento BLE SDK day-mask decode keeps only Sunday (`(mask & bit) != 1`).
263
+ // always:true (all days, 00:00–23:59) therefore reads back as weekdays [6].
264
+ if (fullDay && (days.size == 7 || days.isEmpty() || days == listOf(6))) {
265
+ return TempivoSchedule(always = true)
251
266
  }
267
+ return TempivoSchedule(
268
+ always = false,
269
+ weekdays = days,
270
+ from = TempivoSensorProfile.hhmmFromMinutes(weekly.fromTime),
271
+ to = TempivoSensorProfile.hhmmFromMinutes(weekly.toTime),
272
+ utcOffsetMinutes = weekly.timezoneOffset,
273
+ )
252
274
  }
253
275
 
254
276
  private fun weeklyType(schedule: TempivoSchedule): RuleCalendar.Type {
@@ -88,6 +88,15 @@ internal object SensorBleRuntime {
88
88
  next = next.copy(transmissionInterval = TransmissionInterval(sec))
89
89
  }
90
90
  session.connection.commands.setConfiguration(next) { }
91
+ if (cfg.temperatureAlerts.isNotEmpty()) {
92
+ val verify = decompile(modernGetConfiguration(session.connection))
93
+ if (verify.temperatureAlerts.size < cfg.temperatureAlerts.size) {
94
+ throw TempivoSensorException(
95
+ TempivoSensorException.Code.UNKNOWN,
96
+ "Device rejected alarm rules (readback ${verify.temperatureAlerts.size}/${cfg.temperatureAlerts.size}).",
97
+ )
98
+ }
99
+ }
91
100
  }
92
101
 
93
102
  suspend fun triggerTransmission(session: Session): TempivoSensorSession.TriggerResult {
@@ -68,6 +68,7 @@ object TempivoSensorProfile {
68
68
  val item = arr.optJSONObject(i) ?: throw invalid("temperatureAlerts[$i] must be an object.")
69
69
  alerts.add(parseAlert(item, i))
70
70
  }
71
+ assertAlertSlotBudget(alerts)
71
72
  val intervals = parseOptionalIntervals(body)
72
73
  return TempivoSensorConfiguration(
73
74
  alerts,
@@ -111,6 +112,33 @@ object TempivoSensorProfile {
111
112
  return root.toString()
112
113
  }
113
114
 
115
+
116
+ private fun assertAlertSlotBudget(alerts: List<TempivoTemperatureAlert>) {
117
+ val used = BooleanArray(12)
118
+ fun findPair(): Int? {
119
+ for (i in 0..10) if (!used[i] && !used[i + 1]) return i
120
+ return null
121
+ }
122
+ fun findFree(): Int? {
123
+ for (i in 0 until 12) if (!used[i]) return i
124
+ return null
125
+ }
126
+ for (alert in alerts) {
127
+ if (alert.type == "range") {
128
+ val pair = findPair() ?: throw invalid("Not enough free alarm rule slots for another range (need two consecutive slots).")
129
+ used[pair] = true
130
+ used[pair + 1] = true
131
+ if (alert.transmitOnReturn) {
132
+ val logic = findFree() ?: throw invalid("Not enough free alarm rule slots for transmit-on-return (OR logic).")
133
+ used[logic] = true
134
+ }
135
+ } else {
136
+ val slot = findFree() ?: throw invalid("Not enough free alarm rule slots.")
137
+ used[slot] = true
138
+ }
139
+ }
140
+ }
141
+
114
142
  private fun parseOptionalIntervals(body: JSONObject): Pair<Double?, Int?> {
115
143
  var measurement: Double? = null
116
144
  var transmission: Int? = null
@@ -1,4 +1,5 @@
1
1
  import { TempivoSensorError } from './errors.js';
2
+ import { pickPartnerConfigurationJson } from './profile.js';
2
3
  const SENSOR_CODES = [
3
4
  'invalidPin',
4
5
  'invalidQr',
@@ -22,7 +23,8 @@ export function qrToConnectJson(qr) {
22
23
  return JSON.stringify(body);
23
24
  }
24
25
  export function configToJson(config) {
25
- return typeof config === 'string' ? config : JSON.stringify(config);
26
+ // Always validate partner shape + slot budget before native setConfiguration.
27
+ return pickPartnerConfigurationJson(typeof config === 'string' ? config : JSON.stringify(config));
26
28
  }
27
29
  function isSensorCode(value) {
28
30
  return !!value && SENSOR_CODES.includes(value);
package/dist/profile.js CHANGED
@@ -39,10 +39,12 @@ function findFreeSlot(used) {
39
39
  }
40
40
  return null;
41
41
  }
42
- /** Same 12-slot layout as Cellular API, including one extra slot per range with transmitOnReturn. */
42
+ /**
43
+ * 12-slot BLE layout: each range with transmitOnReturn uses low+high then an OR
44
+ * slot immediately after (same order native compile writes).
45
+ */
43
46
  function assertAlertSlotBudget(alerts) {
44
47
  const used = Array(12).fill(false);
45
- const returnLowSlots = [];
46
48
  for (const alert of alerts) {
47
49
  if (alert.type === 'range') {
48
50
  const pair = findPairStart(used);
@@ -51,8 +53,13 @@ function assertAlertSlotBudget(alerts) {
51
53
  }
52
54
  used[pair] = true;
53
55
  used[pair + 1] = true;
54
- if (alert.transmitOnReturn)
55
- returnLowSlots.push(pair);
56
+ if (alert.transmitOnReturn) {
57
+ const logic = findFreeSlot(used);
58
+ if (logic === null) {
59
+ throw new TempivoSensorError('invalidConfig', 'Not enough free alarm rule slots for transmit-on-return (OR logic).');
60
+ }
61
+ used[logic] = true;
62
+ }
56
63
  }
57
64
  else {
58
65
  const slot = findFreeSlot(used);
@@ -62,15 +69,27 @@ function assertAlertSlotBudget(alerts) {
62
69
  used[slot] = true;
63
70
  }
64
71
  }
65
- for (const _low of returnLowSlots) {
66
- const slot = findFreeSlot(used);
67
- if (slot === null) {
68
- throw new TempivoSensorError('invalidConfig', 'Not enough free alarm rule slots for transmit-on-return (OR logic).');
69
- }
70
- used[slot] = true;
72
+ }
73
+ /**
74
+ * Efento BLE SDK (2.3/2.4) day-mask decode compares `(mask & bit) != 1`, so only
75
+ * Sunday (bit 0) survives. Writing `schedule: { always: true }` (all 7 days,
76
+ * 00:00–23:59) therefore reads back as `weekdays: [6]`. Normalize that shape on
77
+ * device read so get matches set.
78
+ */
79
+ function normalizeScheduleFromDevice(schedule) {
80
+ if ('always' in schedule && schedule.always)
81
+ return schedule;
82
+ if (!('weekdays' in schedule))
83
+ return schedule;
84
+ if (schedule.from !== '00:00' || schedule.to !== '23:59')
85
+ return schedule;
86
+ const days = [...new Set(schedule.weekdays)].sort((a, b) => a - b);
87
+ if (days.length === 7 || days.length === 0 || (days.length === 1 && days[0] === 6)) {
88
+ return { always: true };
71
89
  }
90
+ return schedule;
72
91
  }
73
- function parseSchedule(raw) {
92
+ function parseSchedule(raw, fromDevice = false) {
74
93
  if (raw == null)
75
94
  return { always: true };
76
95
  if (!isRecord(raw)) {
@@ -99,7 +118,8 @@ function parseSchedule(raw) {
99
118
  if (utc === undefined || !Number.isInteger(utc)) {
100
119
  throw new TempivoSensorError('invalidConfig', 'schedule.utcOffsetMinutes is required.');
101
120
  }
102
- return { weekdays: days, from, to, utcOffsetMinutes: utc };
121
+ const schedule = { weekdays: days, from, to, utcOffsetMinutes: utc };
122
+ return fromDevice ? normalizeScheduleFromDevice(schedule) : schedule;
103
123
  }
104
124
  function parseAlert(item, index) {
105
125
  if (!isRecord(item)) {
@@ -249,7 +269,7 @@ function parseSensorConfigurationBody(body, options = {}) {
249
269
  const intervals = parseOptionalIntervals(body, options);
250
270
  return {
251
271
  temperatureAlerts,
252
- schedule: parseSchedule(body.schedule),
272
+ schedule: parseSchedule(body.schedule, options.fromDevice === true),
253
273
  ...intervals,
254
274
  };
255
275
  }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tempivo/sensor-beacon",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Tempivo sensor BLE SDK. Native scan and GATT for Expo, React Native, Android, and iOS. Node helpers decode advertisements and parse QR or config (no radio).",
5
5
  "homepage": "https://app.tempivo.com/integrations",
6
6
  "repository": {