@tempivo/sensor-beacon 0.1.0 → 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 (46) hide show
  1. package/README.md +209 -50
  2. package/android/build.gradle +4 -0
  3. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  4. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  5. package/android/gradle.properties +3 -0
  6. package/android/gradlew +251 -0
  7. package/android/library/build.gradle +30 -0
  8. package/android/library/consumer-rules.pro +1 -0
  9. package/android/library/src/main/AndroidManifest.xml +2 -0
  10. package/android/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +279 -0
  11. package/android/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +168 -0
  12. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +389 -0
  13. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +158 -0
  14. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +40 -0
  15. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorCalibration.kt +36 -0
  16. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +18 -0
  17. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +171 -0
  18. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +82 -0
  19. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +114 -0
  20. package/android/settings.gradle +19 -0
  21. package/dist/calibration.d.ts +7 -0
  22. package/dist/calibration.js +42 -0
  23. package/dist/decoder.js +10 -18
  24. package/dist/errors.d.ts +6 -0
  25. package/dist/errors.js +8 -0
  26. package/dist/index.d.ts +6 -1
  27. package/dist/index.js +5 -1
  28. package/dist/profile.d.ts +10 -0
  29. package/dist/profile.js +226 -0
  30. package/dist/qr.d.ts +16 -0
  31. package/dist/qr.js +90 -0
  32. package/dist/session-types.d.ts +64 -0
  33. package/dist/session-types.js +1 -0
  34. package/dist/tempivo-sensor-beacon.aar +0 -0
  35. package/dist/types.d.ts +3 -3
  36. package/ios/Package.swift +22 -0
  37. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +409 -0
  38. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconScanner.swift +159 -0
  39. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +103 -0
  40. package/ios/Sources/TempivoSensorBeacon/TempivoSensorCalibration.swift +22 -0
  41. package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +24 -0
  42. package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +123 -0
  43. package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +78 -0
  44. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +161 -0
  45. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +93 -0
  46. package/package.json +20 -6
@@ -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
package/dist/types.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface SensorBeaconMeasurement {
7
7
  humidityPct?: number;
8
8
  }
9
9
  export interface SensorBeaconReading {
10
+ /** 12 hex chars from frame 0x03 (uppercase, no colons). */
10
11
  serialMac: string;
11
12
  firmware: string;
12
13
  batteryOk: boolean;
@@ -15,9 +16,8 @@ export interface SensorBeaconReading {
15
16
  measurementCounter: number | null;
16
17
  readingTimestampUnix: number | null;
17
18
  readingTimestampIso: string | null;
18
- periodBaseSeconds: number | null;
19
- periodFactor: number | null;
20
- periodLabel: string;
19
+ /** Sample interval from the advertisement, in seconds. */
20
+ measurementIntervalSeconds: number | null;
21
21
  measurements: SensorBeaconMeasurement[];
22
22
  summary: string;
23
23
  temperatures: number[];
@@ -0,0 +1,22 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "TempivoSensorBeacon",
6
+ platforms: [.iOS(.v15)],
7
+ products: [
8
+ .library(
9
+ name: "TempivoSensorBeacon",
10
+ targets: ["TempivoSensorBeacon"]
11
+ ),
12
+ ],
13
+ targets: [
14
+ .target(
15
+ name: "TempivoSensorBeacon",
16
+ path: "Sources/TempivoSensorBeacon",
17
+ linkerSettings: [
18
+ .linkedFramework("CoreBluetooth"),
19
+ ]
20
+ ),
21
+ ]
22
+ )