@tempivo/sensor-beacon 1.1.1 → 1.1.4

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 (32) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +38 -23
  3. package/android/build.gradle +2 -2
  4. package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +8 -0
  5. package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +8 -0
  6. package/android-aar/library/build.gradle +5 -0
  7. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/EfentoRawConfigTransfer.kt +672 -0
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +188 -52
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +20 -0
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +28 -0
  11. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +142 -11
  12. package/android-aar/library/src/test/java/com/tempivo/sensor/beacon/PartnerBleRulesTest.kt +209 -0
  13. package/dist/native-helpers.js +3 -1
  14. package/dist/native-module.d.ts +10 -0
  15. package/dist/native-module.js +46 -3
  16. package/dist/partner-dual-range-fixture.d.ts +28 -0
  17. package/dist/partner-dual-range-fixture.js +29 -0
  18. package/dist/profile.js +66 -32
  19. package/dist/proto/decode-proto-config.d.ts +3 -0
  20. package/dist/proto/decode-proto-config.js +46 -0
  21. package/dist/proto/efento-protobuf-descriptor.d.ts +964 -0
  22. package/dist/proto/efento-protobuf-descriptor.js +727 -0
  23. package/dist/proto/partner-from-proto.d.ts +9 -0
  24. package/dist/proto/partner-from-proto.js +187 -0
  25. package/dist/proto/partner-to-proto.d.ts +12 -0
  26. package/dist/proto/partner-to-proto.js +211 -0
  27. package/dist/tempivo-sensor-beacon.aar +0 -0
  28. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  29. package/ios/Sources/TempivoSensorBeacon/EfentoRawConfigTransfer.swift +601 -0
  30. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +99 -29
  31. package/ios/TempivoSensorBeaconModule.swift +9 -0
  32. package/package.json +6 -2
@@ -0,0 +1,209 @@
1
+ package com.tempivo.sensor.beacon
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+ import pl.efento.mobile.bluetooth.model.sensor.Action
8
+ import pl.efento.mobile.bluetooth.model.sensor.DisabledCondition
9
+ import pl.efento.mobile.bluetooth.model.sensor.HighThresholdCondition
10
+ import pl.efento.mobile.bluetooth.model.sensor.LogicOperator
11
+ import pl.efento.mobile.bluetooth.model.sensor.LogicOperatorCondition
12
+ import pl.efento.mobile.bluetooth.model.sensor.LowThresholdCondition
13
+ import pl.efento.mobile.bluetooth.model.sensor.Rule
14
+
15
+ class PartnerBleRulesTest {
16
+ private val simon =
17
+ TempivoSensorConfiguration(
18
+ temperatureAlerts =
19
+ listOf(
20
+ TempivoTemperatureAlert(
21
+ type = "range",
22
+ channel = "ambient",
23
+ lowC = 2.0,
24
+ highC = 8.0,
25
+ hysteresisC = 0.5,
26
+ transmitOnBreach = true,
27
+ transmitOnReturn = true,
28
+ ),
29
+ TempivoTemperatureAlert(
30
+ type = "range",
31
+ channel = "probe",
32
+ lowC = -30.0,
33
+ highC = -18.0,
34
+ hysteresisC = 0.5,
35
+ transmitOnBreach = true,
36
+ transmitOnReturn = true,
37
+ ),
38
+ ),
39
+ schedule = TempivoSchedule(always = true),
40
+ measurementIntervalMinutes = 5.0,
41
+ transmissionIntervalSeconds = 3600,
42
+ )
43
+
44
+ @Test
45
+ fun compileDecompileSimonJsonKeepsBothRanges() {
46
+ val (rules, _) = PartnerBleRules.compile(simon)
47
+ val kinds = rules.take(6).map { kind(it) }
48
+ assertEquals(listOf("LOW", "HIGH", "OR", "LOW", "HIGH", "OR"), kinds)
49
+
50
+ val got = PartnerBleRules.decompile(rules)
51
+ assertEquals(2, got.temperatureAlerts.size)
52
+ assertEquals("ambient", got.temperatureAlerts[0].channel)
53
+ assertEquals(2.0, got.temperatureAlerts[0].lowC!!, 0.01)
54
+ assertEquals(8.0, got.temperatureAlerts[0].highC!!, 0.01)
55
+ assertEquals("probe", got.temperatureAlerts[1].channel)
56
+ assertEquals(-30.0, got.temperatureAlerts[1].lowC!!, 0.01)
57
+ assertEquals(-18.0, got.temperatureAlerts[1].highC!!, 0.01)
58
+ assertTrue(got.temperatureAlerts.all { it.transmitOnReturn })
59
+ }
60
+
61
+ @Test
62
+ fun setSameJsonTwiceIsAccepted() {
63
+ val (rules, _) = PartnerBleRules.compile(simon)
64
+ val empty = List(12) { i -> Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION) }
65
+
66
+ val first = PartnerBleRules.configurationWriteAccepted(simon.temperatureAlerts, rules, empty, rules)
67
+ assertTrue(first)
68
+
69
+ val second = PartnerBleRules.configurationWriteAccepted(simon.temperatureAlerts, rules, rules, rules)
70
+ assertTrue(second)
71
+ }
72
+
73
+ @Test
74
+ fun oldReadbackCountCheckFailsWhenDecompileIsEmpty() {
75
+ val (rules, _) = PartnerBleRules.compile(simon)
76
+ val emptyGet = PartnerBleRules.decompile(emptyList()).temperatureAlerts
77
+ assertEquals(0, emptyGet.size)
78
+ assertTrue(emptyGet.size < simon.temperatureAlerts.size)
79
+ }
80
+
81
+ @Test
82
+ fun liveDeskProtoDecompilesAndIdempotentSetIsAccepted() {
83
+ // Live get from desk sensor after Simon JSON (channel_mask 1 / 4, 0.1 °C units).
84
+ val fromDevice =
85
+ listOf(
86
+ threshold(1, 1, true, 2.0, 0.5, Action.NO_ACTION),
87
+ threshold(2, 1, false, 8.0, 0.5, Action.NO_ACTION),
88
+ orSlot(3, setOf(1, 2)),
89
+ threshold(4, 3, true, -30.0, 0.5, Action.NO_ACTION),
90
+ threshold(5, 3, false, -18.0, 0.5, Action.NO_ACTION),
91
+ orSlot(6, setOf(4, 5)),
92
+ ) + List(6) { i -> Rule(i + 7, emptySet(), DisabledCondition, Action.NO_ACTION) }
93
+
94
+ val got = PartnerBleRules.decompile(fromDevice)
95
+ assertEquals(2, got.temperatureAlerts.size)
96
+ assertEquals(listOf("ambient", "probe"), got.temperatureAlerts.map { it.channel })
97
+
98
+ val (intended, _) = PartnerBleRules.compile(simon)
99
+ assertTrue(PartnerBleRules.compiledRulesMatchDevice(intended, fromDevice))
100
+ assertTrue(
101
+ PartnerBleRules.configurationWriteAccepted(
102
+ simon.temperatureAlerts,
103
+ intended,
104
+ fromDevice,
105
+ fromDevice,
106
+ ),
107
+ )
108
+ }
109
+
110
+ @Test
111
+ fun zeroBasedSlotsDecompileToAmbientAndProbe() {
112
+ val fromDevice =
113
+ listOf(
114
+ threshold(1, 0, true, 2.0, 0.5, Action.NO_ACTION),
115
+ threshold(2, 0, false, 8.0, 0.5, Action.NO_ACTION),
116
+ orSlot(3, setOf(1, 2)),
117
+ threshold(4, 2, true, -30.0, 0.5, Action.NO_ACTION),
118
+ threshold(5, 2, false, -18.0, 0.5, Action.NO_ACTION),
119
+ orSlot(6, setOf(4, 5)),
120
+ ) + List(6) { i -> Rule(i + 7, emptySet(), DisabledCondition, Action.NO_ACTION) }
121
+ val got = PartnerBleRules.decompile(fromDevice)
122
+ assertEquals(2, got.temperatureAlerts.size)
123
+ assertEquals(listOf("ambient", "probe"), got.temperatureAlerts.map { it.channel })
124
+ }
125
+
126
+ @Test
127
+ fun fw7ExtraRuleSlotsStillDecompileById() {
128
+ val active =
129
+ listOf(
130
+ threshold(1, 1, true, 2.0, 0.5, Action.NO_ACTION),
131
+ threshold(2, 1, false, 8.0, 0.5, Action.NO_ACTION),
132
+ orSlot(3, setOf(1, 2)),
133
+ threshold(4, 3, true, -30.0, 0.5, Action.NO_ACTION),
134
+ threshold(5, 3, false, -18.0, 0.5, Action.NO_ACTION),
135
+ orSlot(6, setOf(4, 5)),
136
+ )
137
+ // Simulate FW7 returning >12 entries: padding first, then real ids.
138
+ val padding = List(12) { i -> Rule(100 + i, emptySet(), DisabledCondition, Action.NO_ACTION) }
139
+ val fromDevice = padding + active
140
+ val got = PartnerBleRules.decompile(fromDevice)
141
+ assertEquals(2, got.temperatureAlerts.size)
142
+ assertEquals(listOf("ambient", "probe"), got.temperatureAlerts.map { it.channel })
143
+ }
144
+
145
+ @Test
146
+ fun emptyGetAfterSameConfigIsStillAccepted() {
147
+ val (rules, _) = PartnerBleRules.compile(simon)
148
+ val empty = List(12) { i -> Rule(i + 1, emptySet(), DisabledCondition, Action.NO_ACTION) }
149
+ assertTrue(
150
+ PartnerBleRules.configurationWriteAccepted(simon.temperatureAlerts, rules, rules, empty),
151
+ )
152
+ // Blind HC7 get (empty before and after) must not reject a successful write.
153
+ assertTrue(
154
+ PartnerBleRules.configurationWriteAccepted(simon.temperatureAlerts, rules, empty, empty),
155
+ )
156
+ }
157
+
158
+ private fun threshold(
159
+ id: Int,
160
+ channel: Int,
161
+ low: Boolean,
162
+ t: Double,
163
+ hyst: Double,
164
+ action: Action,
165
+ ): Rule {
166
+ val params =
167
+ pl.efento.mobile.bluetooth.model.sensor.TemperatureLowHighThresholdConditionParameters(
168
+ t,
169
+ hyst,
170
+ pl.efento.mobile.bluetooth.model.sensor.TriggeringMode.CONSECUTIVE_SAMPLES,
171
+ 1,
172
+ )
173
+ val condition =
174
+ if (low) {
175
+ pl.efento.mobile.bluetooth.model.sensor.LowThresholdCondition(params)
176
+ } else {
177
+ pl.efento.mobile.bluetooth.model.sensor.HighThresholdCondition(params)
178
+ }
179
+ return Rule(id, setOf(channel), condition, action)
180
+ }
181
+
182
+ private fun orSlot(id: Int, selected: Set<Int>): Rule =
183
+ Rule(
184
+ id,
185
+ emptySet(),
186
+ pl.efento.mobile.bluetooth.model.sensor.LogicOperatorCondition(
187
+ pl.efento.mobile.bluetooth.model.sensor.LogicOperatorConditionParameters(
188
+ LogicOperator.OR,
189
+ selected,
190
+ emptySet(),
191
+ 0,
192
+ 0,
193
+ ),
194
+ ),
195
+ Action.TRIGGER_TRANSMISSION_WITH_ACK,
196
+ )
197
+
198
+ private fun kind(rule: Rule): String =
199
+ when (rule.condition) {
200
+ is LowThresholdCondition -> "LOW"
201
+ is HighThresholdCondition -> "HIGH"
202
+ is LogicOperatorCondition -> {
203
+ val p = (rule.condition as LogicOperatorCondition).conditionParameters
204
+ if (p.logicOperator == LogicOperator.OR) "OR" else "LOGIC"
205
+ }
206
+ is DisabledCondition -> "OFF"
207
+ else -> rule.condition.javaClass.simpleName
208
+ }
209
+ }
@@ -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);
@@ -8,7 +8,17 @@ export declare function isScanning(): boolean;
8
8
  export declare function addDeviceFoundListener(listener: (device: TempivoSensorBeaconDevice) => void): TempivoDeviceFoundSubscription;
9
9
  export declare function connect(qr: string | TempivoSensorQr): Promise<TempivoNativeQr>;
10
10
  export declare function disconnect(): Promise<void>;
11
+ /**
12
+ * Partner configuration from the device.
13
+ * Return shape is only {@link TempivoSensorConfiguration} (no debug fields).
14
+ * iOS and Android use raw ProtoConfig GATT; return shape is only partner fields.
15
+ */
11
16
  export declare function getConfiguration(): Promise<TempivoSensorConfiguration>;
17
+ /**
18
+ * Write partner configuration to the device.
19
+ * Same public signature as before. Encodes partner config to ProtoConfig and writes raw GATT
20
+ * when `writeProtoConfigBase64` is available (iOS and Android).
21
+ */
12
22
  export declare function setConfigurationJson(config: string | TempivoSensorConfiguration): Promise<void>;
13
23
  export declare function triggerTransmission(): Promise<TempivoTriggerTransmissionResult>;
14
24
  export declare function getCalibration(): Promise<TempivoNativeCalibration>;
@@ -1,7 +1,9 @@
1
1
  import { requireOptionalNativeModule } from 'expo-modules-core';
2
2
  import { TempivoSensorError } from './errors.js';
3
3
  import { configToJson, mapNativeError, qrToConnectJson } from './native-helpers.js';
4
- import { parseSensorConfigurationFromDeviceJson } from './profile.js';
4
+ import { partnerConfigFromProtoBase64 } from './proto/partner-from-proto.js';
5
+ import { partnerConfigToProtoBase64 } from './proto/partner-to-proto.js';
6
+ import { parseSensorConfigurationFromDevice } from './profile.js';
5
7
  function getNative() {
6
8
  const native = requireOptionalNativeModule('TempivoSensorBeacon');
7
9
  if (!native) {
@@ -67,24 +69,65 @@ export async function connect(qr) {
67
69
  }
68
70
  export async function disconnect() {
69
71
  try {
72
+ lastProtoBase64 = null;
70
73
  await getNative().disconnect();
71
74
  }
72
75
  catch (error) {
73
76
  throw mapNativeError(error);
74
77
  }
75
78
  }
79
+ /** Last ProtoConfig from iOS raw get; used as set baseline. Not part of the partner API. */
80
+ let lastProtoBase64 = null;
81
+ /**
82
+ * Partner configuration from the device.
83
+ * Return shape is only {@link TempivoSensorConfiguration} (no debug fields).
84
+ * iOS and Android use raw ProtoConfig GATT; return shape is only partner fields.
85
+ */
76
86
  export async function getConfiguration() {
77
87
  try {
78
88
  const json = await getNative().getConfigurationJson();
79
- return parseSensorConfigurationFromDeviceJson(json);
89
+ const parsed = JSON.parse(json);
90
+ const protoB64 = typeof parsed.protoBase64 === 'string' ? parsed.protoBase64 : null;
91
+ if (protoB64) {
92
+ lastProtoBase64 = protoB64;
93
+ return partnerConfigFromProtoBase64(protoB64);
94
+ }
95
+ lastProtoBase64 = null;
96
+ return parseSensorConfigurationFromDevice(parsed);
80
97
  }
81
98
  catch (error) {
82
99
  throw mapNativeError(error);
83
100
  }
84
101
  }
102
+ /**
103
+ * Write partner configuration to the device.
104
+ * Same public signature as before. Encodes partner config to ProtoConfig and writes raw GATT
105
+ * when `writeProtoConfigBase64` is available (iOS and Android).
106
+ */
85
107
  export async function setConfigurationJson(config) {
86
108
  try {
87
- await getNative().setConfigurationJson(configToJson(config));
109
+ const partner = typeof config === 'string'
110
+ ? JSON.parse(config)
111
+ : config;
112
+ const native = getNative();
113
+ if (typeof native.writeProtoConfigBase64 === 'function') {
114
+ let baseline = lastProtoBase64;
115
+ if (!baseline) {
116
+ await getConfiguration();
117
+ baseline = lastProtoBase64;
118
+ }
119
+ if (!baseline) {
120
+ throw new TempivoSensorError('invalidConfig', 'Cannot set: missing baseline ProtoConfig from device get.');
121
+ }
122
+ const nextB64 = partnerConfigToProtoBase64(partner, baseline);
123
+ await native.writeProtoConfigBase64(nextB64);
124
+ lastProtoBase64 = nextB64;
125
+ return;
126
+ }
127
+ if (typeof native.setConfigurationJson !== 'function') {
128
+ throw new TempivoSensorError('runtimeUnavailable', 'Native setConfigurationJson is unavailable in this build.');
129
+ }
130
+ await native.setConfigurationJson(configToJson(partner));
88
131
  }
89
132
  catch (error) {
90
133
  throw mapNativeError(error);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Partner BLE dual-range + always payload used in regression tests.
3
+ * Before SDK 1.1.2, get could return empty alerts and/or Sunday-only schedule.
4
+ */
5
+ export declare const PARTNER_DUAL_RANGE_ALWAYS_BODY: {
6
+ schedule: {
7
+ always: boolean;
8
+ };
9
+ temperatureAlerts: ({
10
+ type: "range";
11
+ channel: "ambient";
12
+ lowC: number;
13
+ highC: number;
14
+ hysteresisC: number;
15
+ transmitOnBreach: boolean;
16
+ transmitOnReturn: boolean;
17
+ } | {
18
+ type: "range";
19
+ channel: "probe";
20
+ lowC: number;
21
+ highC: number;
22
+ hysteresisC: number;
23
+ transmitOnBreach: boolean;
24
+ transmitOnReturn: boolean;
25
+ })[];
26
+ measurementIntervalMinutes: number;
27
+ transmissionIntervalSeconds: number;
28
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Partner BLE dual-range + always payload used in regression tests.
3
+ * Before SDK 1.1.2, get could return empty alerts and/or Sunday-only schedule.
4
+ */
5
+ export const PARTNER_DUAL_RANGE_ALWAYS_BODY = {
6
+ schedule: { always: true },
7
+ temperatureAlerts: [
8
+ {
9
+ type: 'range',
10
+ channel: 'ambient',
11
+ lowC: 2,
12
+ highC: 8,
13
+ hysteresisC: 0.5,
14
+ transmitOnBreach: true,
15
+ transmitOnReturn: true,
16
+ },
17
+ {
18
+ type: 'range',
19
+ channel: 'probe',
20
+ lowC: -30,
21
+ highC: -18,
22
+ hysteresisC: 0.5,
23
+ transmitOnBreach: true,
24
+ transmitOnReturn: true,
25
+ },
26
+ ],
27
+ measurementIntervalMinutes: 5,
28
+ transmissionIntervalSeconds: 3600,
29
+ };
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)) {
@@ -198,35 +218,49 @@ export const BLE_MAX_TRANSMISSION_INTERVAL_SECONDS = 604_800;
198
218
  function parseOptionalIntervals(body, options = {}) {
199
219
  const validatePartnerLimits = !options.fromDevice;
200
220
  const out = {};
201
- if (body.measurementIntervalMinutes !== undefined) {
221
+ if (body.measurementIntervalMinutes !== undefined && body.measurementIntervalMinutes !== null) {
202
222
  const n = parseFiniteNumber(body.measurementIntervalMinutes);
203
223
  if (n === undefined || !Number.isFinite(n) || n <= 0) {
204
- throw new TempivoSensorError('invalidConfig', 'measurementIntervalMinutes must be a positive number.');
205
- }
206
- if (validatePartnerLimits) {
207
- if (n < BLE_MIN_MEASUREMENT_INTERVAL_MINUTES) {
208
- throw new TempivoSensorError('invalidConfig', `measurementIntervalMinutes must be at least ${BLE_MIN_MEASUREMENT_INTERVAL_MINUTES}.`);
224
+ if (options.fromDevice) {
225
+ // Vendor may emit 0/null; omit rather than fail the whole get.
226
+ }
227
+ else {
228
+ throw new TempivoSensorError('invalidConfig', 'measurementIntervalMinutes must be a positive number.');
209
229
  }
210
- if (n > BLE_MAX_MEASUREMENT_INTERVAL_MINUTES) {
211
- throw new TempivoSensorError('invalidConfig', `measurementIntervalMinutes must be at most ${BLE_MAX_MEASUREMENT_INTERVAL_MINUTES}.`);
230
+ }
231
+ else {
232
+ if (validatePartnerLimits) {
233
+ if (n < BLE_MIN_MEASUREMENT_INTERVAL_MINUTES) {
234
+ throw new TempivoSensorError('invalidConfig', `measurementIntervalMinutes must be at least ${BLE_MIN_MEASUREMENT_INTERVAL_MINUTES}.`);
235
+ }
236
+ if (n > BLE_MAX_MEASUREMENT_INTERVAL_MINUTES) {
237
+ throw new TempivoSensorError('invalidConfig', `measurementIntervalMinutes must be at most ${BLE_MAX_MEASUREMENT_INTERVAL_MINUTES}.`);
238
+ }
212
239
  }
240
+ out.measurementIntervalMinutes = n;
213
241
  }
214
- out.measurementIntervalMinutes = n;
215
242
  }
216
- if (body.transmissionIntervalSeconds !== undefined) {
243
+ if (body.transmissionIntervalSeconds !== undefined && body.transmissionIntervalSeconds !== null) {
217
244
  const n = parseFiniteNumber(body.transmissionIntervalSeconds);
218
- if (n === undefined || !Number.isInteger(n) || n <= 0) {
219
- throw new TempivoSensorError('invalidConfig', 'transmissionIntervalSeconds must be a positive integer.');
220
- }
221
- if (validatePartnerLimits) {
222
- if (n < BLE_MIN_TRANSMISSION_INTERVAL_SECONDS) {
223
- throw new TempivoSensorError('invalidConfig', `transmissionIntervalSeconds must be at least ${BLE_MIN_TRANSMISSION_INTERVAL_SECONDS} (1 hour).`);
245
+ if (n === undefined || !Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
246
+ if (options.fromDevice) {
247
+ // omit
248
+ }
249
+ else {
250
+ throw new TempivoSensorError('invalidConfig', 'transmissionIntervalSeconds must be a positive integer.');
224
251
  }
225
- if (n > BLE_MAX_TRANSMISSION_INTERVAL_SECONDS) {
226
- throw new TempivoSensorError('invalidConfig', `transmissionIntervalSeconds must be at most ${BLE_MAX_TRANSMISSION_INTERVAL_SECONDS}.`);
252
+ }
253
+ else {
254
+ if (validatePartnerLimits) {
255
+ if (n < BLE_MIN_TRANSMISSION_INTERVAL_SECONDS) {
256
+ throw new TempivoSensorError('invalidConfig', `transmissionIntervalSeconds must be at least ${BLE_MIN_TRANSMISSION_INTERVAL_SECONDS} (1 hour).`);
257
+ }
258
+ if (n > BLE_MAX_TRANSMISSION_INTERVAL_SECONDS) {
259
+ throw new TempivoSensorError('invalidConfig', `transmissionIntervalSeconds must be at most ${BLE_MAX_TRANSMISSION_INTERVAL_SECONDS}.`);
260
+ }
227
261
  }
262
+ out.transmissionIntervalSeconds = n;
228
263
  }
229
- out.transmissionIntervalSeconds = n;
230
264
  }
231
265
  if (validatePartnerLimits &&
232
266
  out.measurementIntervalMinutes != null &&
@@ -249,7 +283,7 @@ function parseSensorConfigurationBody(body, options = {}) {
249
283
  const intervals = parseOptionalIntervals(body, options);
250
284
  return {
251
285
  temperatureAlerts,
252
- schedule: parseSchedule(body.schedule),
286
+ schedule: parseSchedule(body.schedule, options.fromDevice === true),
253
287
  ...intervals,
254
288
  };
255
289
  }
@@ -0,0 +1,3 @@
1
+ export type ProtoConfigObject = Record<string, unknown>;
2
+ export declare function decodeProtoConfigBytes(bytes: Uint8Array): ProtoConfigObject;
3
+ export declare function decodeProtoConfigBase64(b64: string): ProtoConfigObject;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Decode Efento ProtoConfig bytes (same descriptor as web bluetooth module).
3
+ */
4
+ import protobuf from 'protobufjs/light.js';
5
+ import descriptor from './efento-protobuf-descriptor.js';
6
+ const root = protobuf.Root.fromJSON(descriptor);
7
+ const ProtoConfig = root.lookupType('ProtoConfig');
8
+ export function decodeProtoConfigBytes(bytes) {
9
+ const msg = ProtoConfig.decode(bytes);
10
+ return ProtoConfig.toObject(msg, {
11
+ enums: String,
12
+ longs: Number,
13
+ bytes: Array,
14
+ arrays: true,
15
+ objects: true,
16
+ oneofs: true,
17
+ });
18
+ }
19
+ function base64ToBytes(b64) {
20
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
21
+ const clean = b64.replace(/=+$/, '').replace(/[^A-Za-z0-9+/]/g, '');
22
+ const out = new Uint8Array((clean.length * 3) >> 2);
23
+ let o = 0;
24
+ for (let i = 0; i < clean.length; i += 4) {
25
+ const a = chars.indexOf(clean[i] ?? 'A');
26
+ const b = chars.indexOf(clean[i + 1] ?? 'A');
27
+ const c = chars.indexOf(clean[i + 2] ?? 'A');
28
+ const d = chars.indexOf(clean[i + 3] ?? 'A');
29
+ out[o++] = (a << 2) | (b >> 4);
30
+ if (clean[i + 2] != null)
31
+ out[o++] = ((b & 15) << 4) | (c >> 2);
32
+ if (clean[i + 3] != null)
33
+ out[o++] = ((c & 3) << 6) | d;
34
+ }
35
+ return out.subarray(0, o);
36
+ }
37
+ export function decodeProtoConfigBase64(b64) {
38
+ if (typeof atob === 'function') {
39
+ const binary = atob(b64);
40
+ const bytes = new Uint8Array(binary.length);
41
+ for (let i = 0; i < binary.length; i++)
42
+ bytes[i] = binary.charCodeAt(i);
43
+ return decodeProtoConfigBytes(bytes);
44
+ }
45
+ return decodeProtoConfigBytes(base64ToBytes(b64));
46
+ }