@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.
- package/README.md +195 -79
- package/android/library/build.gradle +3 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +279 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +168 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +6 -23
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +0 -2
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +2 -4
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorCalibration.kt +36 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +18 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +171 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +82 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +114 -0
- package/android/settings.gradle +1 -0
- package/dist/calibration.d.ts +7 -0
- package/dist/calibration.js +42 -0
- package/dist/decoder.js +4 -12
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +8 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -1
- package/dist/profile.d.ts +10 -0
- package/dist/profile.js +226 -0
- package/dist/qr.d.ts +16 -0
- package/dist/qr.js +90 -0
- package/dist/session-types.d.ts +64 -0
- package/dist/session-types.js +1 -0
- package/dist/tempivo-sensor-beacon.aar +0 -0
- package/dist/types.d.ts +3 -3
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +4 -8
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +3 -6
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorCalibration.swift +22 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +24 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +123 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +78 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +161 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +93 -0
- package/package.json +10 -3
|
@@ -0,0 +1,114 @@
|
|
|
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.sync.Mutex
|
|
7
|
+
import kotlinx.coroutines.sync.withLock
|
|
8
|
+
import kotlinx.coroutines.withContext
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GATT session: connect, read/write alert rules and schedule, trigger uplink, read lab calibration date.
|
|
12
|
+
*/
|
|
13
|
+
class TempivoSensorSession(
|
|
14
|
+
context: Context,
|
|
15
|
+
) {
|
|
16
|
+
enum class SessionType {
|
|
17
|
+
MODERN,
|
|
18
|
+
LEGACY,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
data class TriggerResult(
|
|
22
|
+
val ok: Boolean,
|
|
23
|
+
val supported: Boolean,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
private val application = context.applicationContext as Application
|
|
27
|
+
private val mutex = Mutex()
|
|
28
|
+
private var session: SensorBleRuntime.Session? = null
|
|
29
|
+
|
|
30
|
+
suspend fun connect(
|
|
31
|
+
serial: String,
|
|
32
|
+
bluetoothMac: String,
|
|
33
|
+
pin: String,
|
|
34
|
+
sessionType: SessionType = SessionType.MODERN,
|
|
35
|
+
) {
|
|
36
|
+
val pinInt =
|
|
37
|
+
pin.trim().toIntOrNull()
|
|
38
|
+
?: throw TempivoSensorException(TempivoSensorException.Code.INVALID_PIN, "PIN must be numeric.")
|
|
39
|
+
try {
|
|
40
|
+
mutex.withLock {
|
|
41
|
+
SensorBleRuntime.disconnect(session)
|
|
42
|
+
session = null
|
|
43
|
+
session =
|
|
44
|
+
withContext(Dispatchers.Main) {
|
|
45
|
+
SensorBleRuntime.connect(
|
|
46
|
+
application = application,
|
|
47
|
+
serial = TempivoSensorQrParser.normalizeSerial(serial),
|
|
48
|
+
bluetoothMac = bluetoothMac,
|
|
49
|
+
pin = pinInt,
|
|
50
|
+
legacy = sessionType == SessionType.LEGACY,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch (e: Throwable) {
|
|
55
|
+
throw SensorBleRuntime.mapError(e).let {
|
|
56
|
+
if (it.code == TempivoSensorException.Code.UNKNOWN) {
|
|
57
|
+
TempivoSensorException(TempivoSensorException.Code.CONNECT_FAILED, "Could not connect.", e)
|
|
58
|
+
} else {
|
|
59
|
+
it
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
suspend fun connect(qr: TempivoSensorQr) {
|
|
66
|
+
connect(qr.serial, qr.bluetoothMac, qr.pin, qr.sessionType)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
suspend fun disconnect() {
|
|
70
|
+
mutex.withLock {
|
|
71
|
+
SensorBleRuntime.disconnect(session)
|
|
72
|
+
session = null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
suspend fun getConfiguration(): TempivoSensorConfiguration {
|
|
77
|
+
return runGatt {
|
|
78
|
+
SensorBleRuntime.decompile(SensorBleRuntime.readConfiguration(requireSession()))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
suspend fun getConfigurationJson(): String = TempivoSensorProfile.toJson(getConfiguration())
|
|
83
|
+
|
|
84
|
+
suspend fun setConfiguration(configuration: TempivoSensorConfiguration) {
|
|
85
|
+
val (rules, calendars) = SensorBleRuntime.compileRules(configuration)
|
|
86
|
+
runGatt {
|
|
87
|
+
SensorBleRuntime.writeRules(requireSession(), rules, calendars)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
suspend fun setConfigurationJson(json: String) {
|
|
92
|
+
setConfiguration(TempivoSensorProfile.parseJson(json))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
suspend fun triggerTransmission(): TriggerResult {
|
|
96
|
+
return runGatt { SensorBleRuntime.triggerTransmission(requireSession()) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
suspend fun getCalibration(): TempivoSensorCalibration {
|
|
100
|
+
return runGatt { SensorBleRuntime.readCalibration(requireSession()) }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private fun requireSession(): SensorBleRuntime.Session =
|
|
104
|
+
session
|
|
105
|
+
?: throw TempivoSensorException(TempivoSensorException.Code.NOT_CONNECTED, "Not connected.")
|
|
106
|
+
|
|
107
|
+
private suspend fun <T> runGatt(block: suspend () -> T): T {
|
|
108
|
+
try {
|
|
109
|
+
return mutex.withLock { block() }
|
|
110
|
+
} catch (e: Throwable) {
|
|
111
|
+
throw SensorBleRuntime.mapError(e)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
package/android/settings.gradle
CHANGED
|
@@ -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
|
+
}
|
package/dist/decoder.js
CHANGED
|
@@ -151,9 +151,7 @@ function fw5Summary(frame) {
|
|
|
151
151
|
return `FW 5.x ${maj}.${min} (limited broadcast decode)`;
|
|
152
152
|
}
|
|
153
153
|
function serialMacFromAdv03(frame) {
|
|
154
|
-
return
|
|
155
|
-
.map((x) => (x & 0xff).toString(16).padStart(2, '0').toUpperCase())
|
|
156
|
-
.join(':');
|
|
154
|
+
return serialKeyFromAdv03Frame(frame) ?? '';
|
|
157
155
|
}
|
|
158
156
|
function parseFw6Adv03(frame) {
|
|
159
157
|
if (frame.length < ADV_FRAME_03_LEN)
|
|
@@ -183,9 +181,7 @@ function parseFw6Adv03(frame) {
|
|
|
183
181
|
measurementCounter: ts >>> 0,
|
|
184
182
|
readingTimestampUnix: ts,
|
|
185
183
|
readingTimestampIso,
|
|
186
|
-
|
|
187
|
-
periodFactor: pfact,
|
|
188
|
-
periodLabel: `${pbase}s × ${pfact}`,
|
|
184
|
+
measurementIntervalSeconds: pbase * pfact,
|
|
189
185
|
};
|
|
190
186
|
}
|
|
191
187
|
function parseScan04(frame) {
|
|
@@ -230,9 +226,7 @@ export function buildSensorBeaconReading(adv03, adv04, rawHex = '') {
|
|
|
230
226
|
measurementCounter: null,
|
|
231
227
|
readingTimestampUnix: null,
|
|
232
228
|
readingTimestampIso: null,
|
|
233
|
-
|
|
234
|
-
periodFactor: null,
|
|
235
|
-
periodLabel: '',
|
|
229
|
+
measurementIntervalSeconds: null,
|
|
236
230
|
};
|
|
237
231
|
if (adv03?.length) {
|
|
238
232
|
const tag = u8(adv03, 0);
|
|
@@ -282,9 +276,7 @@ export function buildSensorBeaconReading(adv03, adv04, rawHex = '') {
|
|
|
282
276
|
measurementCounter: base.measurementCounter ?? null,
|
|
283
277
|
readingTimestampUnix: base.readingTimestampUnix ?? null,
|
|
284
278
|
readingTimestampIso: base.readingTimestampIso ?? null,
|
|
285
|
-
|
|
286
|
-
periodFactor: base.periodFactor ?? null,
|
|
287
|
-
periodLabel: base.periodLabel || '—',
|
|
279
|
+
measurementIntervalSeconds: base.measurementIntervalSeconds ?? null,
|
|
288
280
|
measurements,
|
|
289
281
|
summary: sumOut,
|
|
290
282
|
temperatures,
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Partner-facing error codes. Native runtimes map internal exceptions to these. */
|
|
2
|
+
export type TempivoSensorErrorCode = 'invalidPin' | 'invalidQr' | 'invalidConfig' | 'notConnected' | 'unsupportedCommand' | 'runtimeUnavailable' | 'connectFailed' | 'unknown';
|
|
3
|
+
export declare class TempivoSensorError extends Error {
|
|
4
|
+
readonly code: TempivoSensorErrorCode;
|
|
5
|
+
constructor(code: TempivoSensorErrorCode, message: string);
|
|
6
|
+
}
|
package/dist/errors.js
ADDED
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
export { ADV_FRAME_03_LEN, TEMPVO_SENSOR_MANUFACTURER_ID, buildSensorBeaconReading, decodeMeasurementSlot, decodeSensorBeaconPayload,
|
|
1
|
+
export { ADV_FRAME_03_LEN, TEMPVO_SENSOR_MANUFACTURER_ID, buildSensorBeaconReading, decodeMeasurementSlot, decodeSensorBeaconPayload, lookupDeviceKeys, macKeyFromAddress, serialKeyFromAdv03Frame, splitSensorBeaconFrames, } from './decoder.js';
|
|
2
2
|
export { ingestManufacturerPayload } from './ingest.js';
|
|
3
3
|
export { dataViewToUint8Array, normalizeManufacturerBytes } from './normalize.js';
|
|
4
4
|
export { MEASURE_SPECS, type MeasureSpec } from './measure-specs.js';
|
|
5
5
|
export { createSensorBeaconFrameCache, type SensorBeaconFrameCache, type SensorBeaconMeasurement, type SensorBeaconReading, } from './types.js';
|
|
6
|
+
export { TempivoSensorError, type TempivoSensorErrorCode } from './errors.js';
|
|
7
|
+
export { DEFAULT_SENSOR_MODEL, bluetoothMacFromSerial, normalizeSensorSerial, encodeSensorPinPayload, parseSensorQrJson, sessionTypeFromModel, } from './qr.js';
|
|
8
|
+
export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } from './calibration.js';
|
|
9
|
+
export { DEFAULT_ALERT_HYSTERESIS_C, compileSensorProfileToBleJson, configurationFromApiProfile, parseSensorConfiguration, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
|
|
10
|
+
export type { TempivoSchedule, TempivoScheduleAlways, TempivoScheduleWeek, TempivoSensorCalibration, TempivoSensorConfiguration, TempivoSensorQr, TempivoSensorSessionType, TempivoTemperatureAlert, TempivoTemperatureChannel, TempivoTemperatureMaxAlert, TempivoTemperatureMinAlert, TempivoTemperatureRangeAlert, TempivoTriggerTransmissionResult, } from './session-types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
export { ADV_FRAME_03_LEN, TEMPVO_SENSOR_MANUFACTURER_ID, buildSensorBeaconReading, decodeMeasurementSlot, decodeSensorBeaconPayload,
|
|
1
|
+
export { ADV_FRAME_03_LEN, TEMPVO_SENSOR_MANUFACTURER_ID, buildSensorBeaconReading, decodeMeasurementSlot, decodeSensorBeaconPayload, lookupDeviceKeys, macKeyFromAddress, serialKeyFromAdv03Frame, splitSensorBeaconFrames, } from './decoder.js';
|
|
2
2
|
export { ingestManufacturerPayload } from './ingest.js';
|
|
3
3
|
export { dataViewToUint8Array, normalizeManufacturerBytes } from './normalize.js';
|
|
4
4
|
export { MEASURE_SPECS } from './measure-specs.js';
|
|
5
5
|
export { createSensorBeaconFrameCache, } from './types.js';
|
|
6
|
+
export { TempivoSensorError } from './errors.js';
|
|
7
|
+
export { DEFAULT_SENSOR_MODEL, bluetoothMacFromSerial, normalizeSensorSerial, encodeSensorPinPayload, parseSensorQrJson, sessionTypeFromModel, } from './qr.js';
|
|
8
|
+
export { calibrationFromExtendedJson, decodeLaboratoryCalibrationTimestamp, } from './calibration.js';
|
|
9
|
+
export { DEFAULT_ALERT_HYSTERESIS_C, compileSensorProfileToBleJson, configurationFromApiProfile, parseSensorConfiguration, parseSensorConfigurationJson, pickPartnerConfigurationJson, } from './profile.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TempivoSensorConfiguration } from './session-types.js';
|
|
2
|
+
/** Same default as Cellular API config profiles. */
|
|
3
|
+
export declare const DEFAULT_ALERT_HYSTERESIS_C = 1;
|
|
4
|
+
/** Validate Cellular API config-profile JSON for BLE setConfiguration. */
|
|
5
|
+
export declare function parseSensorConfiguration(input: unknown): TempivoSensorConfiguration;
|
|
6
|
+
/** Same as {@link parseSensorConfiguration}: GET `{ profile }` or the profile object. */
|
|
7
|
+
export declare const configurationFromApiProfile: typeof parseSensorConfiguration;
|
|
8
|
+
export declare function parseSensorConfigurationJson(json: string): TempivoSensorConfiguration;
|
|
9
|
+
export declare function compileSensorProfileToBleJson(input: unknown): string;
|
|
10
|
+
export declare function pickPartnerConfigurationJson(json: string): string;
|
package/dist/profile.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { TempivoSensorError } from './errors.js';
|
|
2
|
+
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
3
|
+
/** Same default as Cellular API config profiles. */
|
|
4
|
+
export const DEFAULT_ALERT_HYSTERESIS_C = 1;
|
|
5
|
+
function isRecord(v) {
|
|
6
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
7
|
+
}
|
|
8
|
+
function parseFiniteNumber(v) {
|
|
9
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
10
|
+
return v;
|
|
11
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
12
|
+
const n = Number(v);
|
|
13
|
+
if (Number.isFinite(n))
|
|
14
|
+
return n;
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
function parseChannel(v) {
|
|
19
|
+
return v === 'ambient' || v === 'probe' ? v : null;
|
|
20
|
+
}
|
|
21
|
+
function parseOptionalBool(v, fallback, field) {
|
|
22
|
+
if (v === undefined)
|
|
23
|
+
return fallback;
|
|
24
|
+
if (typeof v === 'boolean')
|
|
25
|
+
return v;
|
|
26
|
+
throw new TempivoSensorError('invalidConfig', `${field} must be a boolean.`);
|
|
27
|
+
}
|
|
28
|
+
function findPairStart(used) {
|
|
29
|
+
for (let i = 0; i < 11; i++) {
|
|
30
|
+
if (!used[i] && !used[i + 1])
|
|
31
|
+
return i;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function findFreeSlot(used) {
|
|
36
|
+
for (let i = 0; i < 12; i++) {
|
|
37
|
+
if (!used[i])
|
|
38
|
+
return i;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/** Same 12-slot layout as Cellular API, including one extra slot per range with transmitOnReturn. */
|
|
43
|
+
function assertAlertSlotBudget(alerts) {
|
|
44
|
+
const used = Array(12).fill(false);
|
|
45
|
+
const returnLowSlots = [];
|
|
46
|
+
for (const alert of alerts) {
|
|
47
|
+
if (alert.type === 'range') {
|
|
48
|
+
const pair = findPairStart(used);
|
|
49
|
+
if (pair === null) {
|
|
50
|
+
throw new TempivoSensorError('invalidConfig', 'Not enough free alarm rule slots for another range (need two consecutive slots).');
|
|
51
|
+
}
|
|
52
|
+
used[pair] = true;
|
|
53
|
+
used[pair + 1] = true;
|
|
54
|
+
if (alert.transmitOnReturn)
|
|
55
|
+
returnLowSlots.push(pair);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const slot = findFreeSlot(used);
|
|
59
|
+
if (slot === null) {
|
|
60
|
+
throw new TempivoSensorError('invalidConfig', 'Not enough free alarm rule slots.');
|
|
61
|
+
}
|
|
62
|
+
used[slot] = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
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;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function parseSchedule(raw) {
|
|
74
|
+
if (raw == null)
|
|
75
|
+
return { always: true };
|
|
76
|
+
if (!isRecord(raw)) {
|
|
77
|
+
throw new TempivoSensorError('invalidConfig', 'schedule must be an object.');
|
|
78
|
+
}
|
|
79
|
+
if (raw.always === true)
|
|
80
|
+
return { always: true };
|
|
81
|
+
const weekdays = raw.weekdays;
|
|
82
|
+
if (!Array.isArray(weekdays) || weekdays.length === 0) {
|
|
83
|
+
throw new TempivoSensorError('invalidConfig', 'schedule.weekdays is required.');
|
|
84
|
+
}
|
|
85
|
+
const days = [];
|
|
86
|
+
for (const d of weekdays) {
|
|
87
|
+
const n = parseFiniteNumber(d);
|
|
88
|
+
if (n === undefined || !Number.isInteger(n) || n < 0 || n > 6) {
|
|
89
|
+
throw new TempivoSensorError('invalidConfig', 'schedule.weekdays must be 0 to 6.');
|
|
90
|
+
}
|
|
91
|
+
days.push(n);
|
|
92
|
+
}
|
|
93
|
+
const from = typeof raw.from === 'string' ? raw.from.trim() : '';
|
|
94
|
+
const to = typeof raw.to === 'string' ? raw.to.trim() : '';
|
|
95
|
+
if (!TIME_RE.test(from) || !TIME_RE.test(to)) {
|
|
96
|
+
throw new TempivoSensorError('invalidConfig', 'schedule.from and schedule.to must be HH:MM.');
|
|
97
|
+
}
|
|
98
|
+
const utc = parseFiniteNumber(raw.utcOffsetMinutes);
|
|
99
|
+
if (utc === undefined || !Number.isInteger(utc)) {
|
|
100
|
+
throw new TempivoSensorError('invalidConfig', 'schedule.utcOffsetMinutes is required.');
|
|
101
|
+
}
|
|
102
|
+
return { weekdays: days, from, to, utcOffsetMinutes: utc };
|
|
103
|
+
}
|
|
104
|
+
function parseAlert(item, index) {
|
|
105
|
+
if (!isRecord(item)) {
|
|
106
|
+
throw new TempivoSensorError('invalidConfig', `temperatureAlerts[${index}] must be an object.`);
|
|
107
|
+
}
|
|
108
|
+
const channel = parseChannel(item.channel);
|
|
109
|
+
if (!channel) {
|
|
110
|
+
throw new TempivoSensorError('invalidConfig', `temperatureAlerts[${index}].channel must be ambient or probe.`);
|
|
111
|
+
}
|
|
112
|
+
if (item.hysteresisC !== undefined) {
|
|
113
|
+
const hystRaw = parseFiniteNumber(item.hysteresisC);
|
|
114
|
+
if (hystRaw === undefined || hystRaw < 0) {
|
|
115
|
+
throw new TempivoSensorError('invalidConfig', `temperatureAlerts[${index}].hysteresisC must be a number 0 or greater.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const hysteresisC = parseFiniteNumber(item.hysteresisC) ?? DEFAULT_ALERT_HYSTERESIS_C;
|
|
119
|
+
const transmitOnBreach = parseOptionalBool(item.transmitOnBreach, true, `temperatureAlerts[${index}].transmitOnBreach`);
|
|
120
|
+
if (item.type === 'range') {
|
|
121
|
+
const lowC = parseFiniteNumber(item.lowC);
|
|
122
|
+
const highC = parseFiniteNumber(item.highC);
|
|
123
|
+
if (lowC === undefined || highC === undefined) {
|
|
124
|
+
throw new TempivoSensorError('invalidConfig', `temperatureAlerts[${index}] needs lowC and highC.`);
|
|
125
|
+
}
|
|
126
|
+
if (highC <= lowC) {
|
|
127
|
+
throw new TempivoSensorError('invalidConfig', `temperatureAlerts[${index}]: highC must be greater than lowC.`);
|
|
128
|
+
}
|
|
129
|
+
const transmitOnReturn = parseOptionalBool(item.transmitOnReturn, true, `temperatureAlerts[${index}].transmitOnReturn`);
|
|
130
|
+
return {
|
|
131
|
+
type: 'range',
|
|
132
|
+
channel,
|
|
133
|
+
lowC,
|
|
134
|
+
highC,
|
|
135
|
+
hysteresisC,
|
|
136
|
+
transmitOnBreach,
|
|
137
|
+
transmitOnReturn,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (item.type === 'min') {
|
|
141
|
+
const minC = parseFiniteNumber(item.minC);
|
|
142
|
+
if (minC === undefined) {
|
|
143
|
+
throw new TempivoSensorError('invalidConfig', `temperatureAlerts[${index}] needs minC.`);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
type: 'min',
|
|
147
|
+
channel,
|
|
148
|
+
minC,
|
|
149
|
+
hysteresisC,
|
|
150
|
+
transmitOnBreach,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (item.type === 'max') {
|
|
154
|
+
const maxC = parseFiniteNumber(item.maxC);
|
|
155
|
+
if (maxC === undefined) {
|
|
156
|
+
throw new TempivoSensorError('invalidConfig', `temperatureAlerts[${index}] needs maxC.`);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
type: 'max',
|
|
160
|
+
channel,
|
|
161
|
+
maxC,
|
|
162
|
+
hysteresisC,
|
|
163
|
+
transmitOnBreach,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
throw new TempivoSensorError('invalidConfig', `temperatureAlerts[${index}].type must be range, min, or max.`);
|
|
167
|
+
}
|
|
168
|
+
function looksLikeSimpleProfile(rec) {
|
|
169
|
+
return (rec.temperatureAlerts !== undefined ||
|
|
170
|
+
rec.schedule !== undefined ||
|
|
171
|
+
typeof rec.slug === 'string');
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* GET /devices/config-profiles/{slug} returns `{ profile }`. Also accepts the profile object.
|
|
175
|
+
*/
|
|
176
|
+
function unwrapApiProfile(input) {
|
|
177
|
+
if (Array.isArray(input.profiles)) {
|
|
178
|
+
throw new TempivoSensorError('invalidConfig', 'Pass one profile from GET /devices/config-profiles/{slug}, not the list.');
|
|
179
|
+
}
|
|
180
|
+
const nested = input.profile;
|
|
181
|
+
if (isRecord(nested) && looksLikeSimpleProfile(nested)) {
|
|
182
|
+
return nested;
|
|
183
|
+
}
|
|
184
|
+
return input;
|
|
185
|
+
}
|
|
186
|
+
function rejectWireAlarmRules(rec) {
|
|
187
|
+
if (rec.temperatureAlerts === undefined && rec.alarmRules !== undefined) {
|
|
188
|
+
throw new TempivoSensorError('invalidConfig', 'Use a config profile with temperatureAlerts (GET /devices/config-profiles/{slug}), not alarmRules.');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Validate Cellular API config-profile JSON for BLE setConfiguration. */
|
|
192
|
+
export function parseSensorConfiguration(input) {
|
|
193
|
+
if (!isRecord(input)) {
|
|
194
|
+
throw new TempivoSensorError('invalidConfig', 'Configuration must be a JSON object.');
|
|
195
|
+
}
|
|
196
|
+
const body = unwrapApiProfile(input);
|
|
197
|
+
rejectWireAlarmRules(body);
|
|
198
|
+
const alertsRaw = body.temperatureAlerts;
|
|
199
|
+
if (alertsRaw !== undefined && !Array.isArray(alertsRaw)) {
|
|
200
|
+
throw new TempivoSensorError('invalidConfig', 'temperatureAlerts must be an array.');
|
|
201
|
+
}
|
|
202
|
+
const temperatureAlerts = (alertsRaw ?? []).map((item, i) => parseAlert(item, i));
|
|
203
|
+
assertAlertSlotBudget(temperatureAlerts);
|
|
204
|
+
return {
|
|
205
|
+
temperatureAlerts,
|
|
206
|
+
schedule: parseSchedule(body.schedule),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** Same as {@link parseSensorConfiguration}: GET `{ profile }` or the profile object. */
|
|
210
|
+
export const configurationFromApiProfile = parseSensorConfiguration;
|
|
211
|
+
export function parseSensorConfigurationJson(json) {
|
|
212
|
+
let parsed;
|
|
213
|
+
try {
|
|
214
|
+
parsed = JSON.parse(json);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
throw new TempivoSensorError('invalidConfig', 'Configuration is not valid JSON.');
|
|
218
|
+
}
|
|
219
|
+
return parseSensorConfiguration(parsed);
|
|
220
|
+
}
|
|
221
|
+
export function compileSensorProfileToBleJson(input) {
|
|
222
|
+
return JSON.stringify(parseSensorConfiguration(input));
|
|
223
|
+
}
|
|
224
|
+
export function pickPartnerConfigurationJson(json) {
|
|
225
|
+
return JSON.stringify(parseSensorConfigurationJson(json));
|
|
226
|
+
}
|
package/dist/qr.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TempivoSensorQr, TempivoSensorSessionType } from './session-types.js';
|
|
2
|
+
export declare function normalizeSensorSerial(value: string): string;
|
|
3
|
+
/** `282C024F0012` → `28:2C:02:4F:00:12`. */
|
|
4
|
+
export declare function bluetoothMacFromSerial(serial: string): string;
|
|
5
|
+
/** BLE default when QR omits `model`. HC5 / FW 6.x still select legacy. */
|
|
6
|
+
export declare const DEFAULT_SENSOR_MODEL = "HC7";
|
|
7
|
+
export declare function sessionTypeFromModel(model: string | undefined | null): TempivoSensorSessionType;
|
|
8
|
+
/**
|
|
9
|
+
* 3-byte big-endian numeric PIN for GATT command payloads that check a reset code
|
|
10
|
+
* (`setConfiguration`, trigger, and many other privileged writes).
|
|
11
|
+
*/
|
|
12
|
+
export declare function encodeSensorPinPayload(pin: string): Uint8Array;
|
|
13
|
+
/**
|
|
14
|
+
* Parse sensor label QR JSON: `{"sn":"…","pin":"…","model":"HC7"}`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseSensorQrJson(text: string): TempivoSensorQr;
|
package/dist/qr.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { TempivoSensorError } from './errors.js';
|
|
2
|
+
export function normalizeSensorSerial(value) {
|
|
3
|
+
return value.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
4
|
+
}
|
|
5
|
+
/** `282C024F0012` → `28:2C:02:4F:00:12`. */
|
|
6
|
+
export function bluetoothMacFromSerial(serial) {
|
|
7
|
+
const key = normalizeSensorSerial(serial);
|
|
8
|
+
if (key.length !== 12 || !/^[0-9A-F]+$/.test(key)) {
|
|
9
|
+
throw new TempivoSensorError('invalidQr', 'Serial must be 12 hex characters.');
|
|
10
|
+
}
|
|
11
|
+
const parts = [];
|
|
12
|
+
for (let i = 0; i < 12; i += 2)
|
|
13
|
+
parts.push(key.slice(i, i + 2));
|
|
14
|
+
return parts.join(':');
|
|
15
|
+
}
|
|
16
|
+
/** BLE default when QR omits `model`. HC5 / FW 6.x still select legacy. */
|
|
17
|
+
export const DEFAULT_SENSOR_MODEL = 'HC7';
|
|
18
|
+
export function sessionTypeFromModel(model) {
|
|
19
|
+
if (model == null || model.trim() === '')
|
|
20
|
+
return 'modern';
|
|
21
|
+
const m = model.trim().toUpperCase().replace(/-/g, '');
|
|
22
|
+
if (m === 'HC5' || m.startsWith('6'))
|
|
23
|
+
return 'legacy';
|
|
24
|
+
return 'modern';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 3-byte big-endian numeric PIN for GATT command payloads that check a reset code
|
|
28
|
+
* (`setConfiguration`, trigger, and many other privileged writes).
|
|
29
|
+
*/
|
|
30
|
+
export function encodeSensorPinPayload(pin) {
|
|
31
|
+
const t = pin.trim();
|
|
32
|
+
if (!/^\d{1,8}$/.test(t)) {
|
|
33
|
+
throw new TempivoSensorError('invalidPin', 'PIN must be numeric.');
|
|
34
|
+
}
|
|
35
|
+
const n = Number.parseInt(t, 10);
|
|
36
|
+
if (!Number.isInteger(n) || n < 0 || n > 0xffffff) {
|
|
37
|
+
throw new TempivoSensorError('invalidPin', 'PIN must fit in 3 bytes.');
|
|
38
|
+
}
|
|
39
|
+
return Uint8Array.of((n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff);
|
|
40
|
+
}
|
|
41
|
+
function pinFromRecord(rec) {
|
|
42
|
+
const raw = rec.pin ?? rec.resetCode;
|
|
43
|
+
if (typeof raw === 'string') {
|
|
44
|
+
const t = raw.trim();
|
|
45
|
+
return t.length > 0 ? t : undefined;
|
|
46
|
+
}
|
|
47
|
+
if (typeof raw === 'number' && Number.isFinite(raw))
|
|
48
|
+
return String(Math.trunc(raw));
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse sensor label QR JSON: `{"sn":"…","pin":"…","model":"HC7"}`.
|
|
53
|
+
*/
|
|
54
|
+
export function parseSensorQrJson(text) {
|
|
55
|
+
let rec;
|
|
56
|
+
try {
|
|
57
|
+
const o = JSON.parse(text.trim());
|
|
58
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) {
|
|
59
|
+
throw new TempivoSensorError('invalidQr', 'QR must be a JSON object.');
|
|
60
|
+
}
|
|
61
|
+
rec = o;
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (e instanceof TempivoSensorError)
|
|
65
|
+
throw e;
|
|
66
|
+
throw new TempivoSensorError('invalidQr', 'QR is not valid JSON.');
|
|
67
|
+
}
|
|
68
|
+
const snRaw = typeof rec.sn === 'string'
|
|
69
|
+
? rec.sn
|
|
70
|
+
: typeof rec.sn === 'number' && Number.isFinite(rec.sn)
|
|
71
|
+
? String(Math.trunc(rec.sn))
|
|
72
|
+
: '';
|
|
73
|
+
const serial = normalizeSensorSerial(snRaw);
|
|
74
|
+
const pin = pinFromRecord(rec);
|
|
75
|
+
if (!serial || serial.length < 8) {
|
|
76
|
+
throw new TempivoSensorError('invalidQr', 'QR is missing serial (sn).');
|
|
77
|
+
}
|
|
78
|
+
if (!pin) {
|
|
79
|
+
throw new TempivoSensorError('invalidQr', 'QR is missing PIN.');
|
|
80
|
+
}
|
|
81
|
+
const rawModel = typeof rec.model === 'string' ? rec.model.trim() : '';
|
|
82
|
+
const model = rawModel || DEFAULT_SENSOR_MODEL;
|
|
83
|
+
return {
|
|
84
|
+
serial,
|
|
85
|
+
pin,
|
|
86
|
+
model,
|
|
87
|
+
sessionType: sessionTypeFromModel(model),
|
|
88
|
+
bluetoothMac: bluetoothMacFromSerial(serial.length === 12 ? serial : serial.slice(-12)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type TempivoSensorSessionType = 'modern' | 'legacy';
|
|
2
|
+
export type TempivoTemperatureChannel = 'ambient' | 'probe';
|
|
3
|
+
export type TempivoTemperatureRangeAlert = {
|
|
4
|
+
type: 'range';
|
|
5
|
+
channel: TempivoTemperatureChannel;
|
|
6
|
+
lowC: number;
|
|
7
|
+
highC: number;
|
|
8
|
+
hysteresisC?: number;
|
|
9
|
+
transmitOnBreach?: boolean;
|
|
10
|
+
/** Extra uplink when temp returns inside the band. Same as Cellular API. Default `true`. */
|
|
11
|
+
transmitOnReturn?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type TempivoTemperatureMinAlert = {
|
|
14
|
+
type: 'min';
|
|
15
|
+
channel: TempivoTemperatureChannel;
|
|
16
|
+
minC: number;
|
|
17
|
+
hysteresisC?: number;
|
|
18
|
+
transmitOnBreach?: boolean;
|
|
19
|
+
};
|
|
20
|
+
export type TempivoTemperatureMaxAlert = {
|
|
21
|
+
type: 'max';
|
|
22
|
+
channel: TempivoTemperatureChannel;
|
|
23
|
+
maxC: number;
|
|
24
|
+
hysteresisC?: number;
|
|
25
|
+
transmitOnBreach?: boolean;
|
|
26
|
+
};
|
|
27
|
+
export type TempivoTemperatureAlert = TempivoTemperatureRangeAlert | TempivoTemperatureMinAlert | TempivoTemperatureMaxAlert;
|
|
28
|
+
export type TempivoScheduleAlways = {
|
|
29
|
+
always: true;
|
|
30
|
+
};
|
|
31
|
+
export type TempivoScheduleWeek = {
|
|
32
|
+
/** 0 = Monday … 6 = Sunday. */
|
|
33
|
+
weekdays: number[];
|
|
34
|
+
/** 24h `HH:MM`. */
|
|
35
|
+
from: string;
|
|
36
|
+
/** 24h `HH:MM`. */
|
|
37
|
+
to: string;
|
|
38
|
+
utcOffsetMinutes: number;
|
|
39
|
+
};
|
|
40
|
+
export type TempivoSchedule = TempivoScheduleAlways | TempivoScheduleWeek;
|
|
41
|
+
/**
|
|
42
|
+
* Same JSON as Cellular API `POST /api/v1/devices/config-profiles`
|
|
43
|
+
* (`temperatureAlerts` + `schedule`). Extra profile fields (`slug`, `name`, intervals) are ignored over BLE.
|
|
44
|
+
*/
|
|
45
|
+
export type TempivoSensorConfiguration = {
|
|
46
|
+
temperatureAlerts: TempivoTemperatureAlert[];
|
|
47
|
+
schedule: TempivoSchedule;
|
|
48
|
+
};
|
|
49
|
+
export type TempivoSensorQr = {
|
|
50
|
+
serial: string;
|
|
51
|
+
pin: string;
|
|
52
|
+
/** Sticker model. Omitted QR `model` parses as HC7. */
|
|
53
|
+
model?: string;
|
|
54
|
+
sessionType: TempivoSensorSessionType;
|
|
55
|
+
bluetoothMac: string;
|
|
56
|
+
};
|
|
57
|
+
export type TempivoSensorCalibration = {
|
|
58
|
+
laboratoryCalibrationDate: string | null;
|
|
59
|
+
laboratoryCalibrationTimestamp: number | null;
|
|
60
|
+
};
|
|
61
|
+
export type TempivoTriggerTransmissionResult = {
|
|
62
|
+
ok: boolean;
|
|
63
|
+
supported: boolean;
|
|
64
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
Binary file
|