@tempivo/sensor-beacon
v1.1.5
Published
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).
Maintainers
Readme
@tempivo/sensor-beacon
BLE for Tempivo sensors. Native scan + GATT on Expo / React Native / Android / iOS. Node gets decode and config helpers only (no radio).
Expo and React Native
Needs a development build (or bare React Native). Expo Go is not supported.
npx expo install @tempivo/sensor-beaconapp.json:
{
"expo": {
"plugins": ["@tempivo/sensor-beacon"]
}
}Then rebuild native:
npx expo prebuild
npx expo run:ios
# or
npx expo run:androidAndroid minSdk is 26. The plugin sets that on prebuild.
Bare React Native (no Expo app yet):
npx install-expo-modules@latest
npm install @tempivo/sensor-beaconAdd NSBluetoothAlwaysUsageDescription on iOS if you do not use Expo prebuild.
import {
parseSensorQrJson,
startScan,
stopScan,
addDeviceFoundListener,
connect,
setConfigurationJson,
getConfiguration,
triggerTransmission,
getCalibration,
disconnect,
} from '@tempivo/sensor-beacon';
const sub = await addDeviceFoundListener((device) => {
console.log(device.serialNumber, device.rssi, device.summary);
});
await startScan();
// Stop scan before GATT connect. While connected, the sensor does not advertise.
const qr = parseSensorQrJson(stickerQrText);
await connect(qr);
try {
await setConfigurationJson({
schedule: { always: true },
temperatureAlerts: [
{
type: 'range',
channel: 'probe',
lowC: -30,
highC: -10,
hysteresisC: 0.5,
},
],
});
// BLE write is on-device only. Cloud pending / last report update after a cellular uplink:
await triggerTransmission();
// Tempivo then drops from the cloud queue only fields the device already matches;
// other queued Cellular fields can still downlink. To cancel the whole queue:
// DELETE /api/v1/devices/{serial}/config (see docs/cellular-api.md).
const applied = await getConfiguration();
const cal = await getCalibration();
} finally {
await disconnect(); // required so advertising / startScan can see the sensor again
}
stopScan();
sub.remove();startScan and connect request Bluetooth permission. Sticker PIN is required on connect. Wrong PIN is invalidPin.
Connect tips: stop scan before GATT connect. Pass the scan row’s deviceId and MAC when they differ from the sticker serial (parseSensorQrForConnect). 5–6 digit PINs map to an encryption key internally; numeric resetCode alone is not enough on all devices. Call disconnect() when finished. While any device holds a GATT connection, the sensor does not advertise, so startScan will not show beacon / live readings for that sensor until everyone disconnects (including other apps).
If a bundler misses native BLE, import @tempivo/sensor-beacon/react-native.
The Expo config plugin sets minSdk 26, Android BLE permissions, and NSBluetoothAlwaysUsageDescription. Add NSCameraUsageDescription yourself if you scan sticker QR with the device camera.
Native BLE API
| Export | Purpose |
|--------|---------|
| isNativeSensorBeaconAvailable() | true when the Expo dev build / RN app includes the native module (false in Expo Go and most web builds). |
| requestPermissions() | Request Bluetooth permission explicitly; startScan and connect call this automatically. |
| startScan / stopScan / isScanning | Active BLE scan for manufacturer 0x026C. |
| addDeviceFoundListener | Callback per discovered device (includes telemetry when decoded). |
| connect / disconnect | GATT session with parsed QR (TempivoSensorQr). |
| getConfiguration / setConfigurationJson | Read / write alert rules, schedule, optional intervals. |
| triggerTransmission | Ask firmware to uplink now; returns { ok }. Cloud pending syncs after that uplink (or schedule/button), not from the BLE write alone. |
| getCalibration | Laboratory calibration date from extended config. |
Cloud queue: BLE updates the sensor only. After uplink, Tempivo drops from Cellular pendingConfig only fields the device already matches; other queued fields can still downlink. Cancel the whole queue with DELETE /api/v1/devices/{serial}/config (see Tempivo Cellular API docs).
QR + connect helpers
Shared with the Tempivo app (same sticker JSON, PIN → encryption key, serial match checks):
import {
parseSensorQrForConnect,
draftSensorQrConnectJsonFromScanDevice,
buildSensorQrConnectJson,
sensorQrSerialMatchesSelected,
formatSensorReadingLine,
resolveSensorBleConnectCredentialsFromPin,
} from '@tempivo/sensor-beacon/react-native';
// After scan: draft QR from a row (model from sticker or FW hint)
const draft = draftSensorQrConnectJsonFromScanDevice(device);
// Connect: parse sticker + merge selected row deviceId / MAC
const qr = parseSensorQrForConnect(stickerJson, selectedDevice);
await connect(qr);
try {
await setConfigurationJson(profileJson);
await getConfiguration();
} finally {
await disconnect();
}
// Scan list: same reading line as in-app
formatSensorReadingLine({ typeHex: 'temperature', text: '23.5 °C' });Use tryParseSensorQrJson for camera QR (invalid JSON → null). Use parseSensorQrJson when the payload must be valid HC7 sticker JSON.
Config JSON
Same as Cellular API config profiles (temperatureAlerts + schedule). You can build it yourself. Profiles are only for REST reuse.
Alerts only (intervals unchanged on the device):
{
"schedule": { "always": true },
"temperatureAlerts": [
{
"type": "range",
"channel": "probe",
"lowC": -30,
"highC": -10,
"hysteresisC": 0.5,
"transmitOnBreach": true,
"transmitOnReturn": true
}
]
}Min and max alerts (single threshold; no transmitOnReturn):
{
"schedule": { "always": true },
"temperatureAlerts": [
{
"type": "min",
"channel": "probe",
"minC": -25,
"hysteresisC": 0.5,
"transmitOnBreach": true
},
{
"type": "max",
"channel": "ambient",
"maxC": 8,
"hysteresisC": 1,
"transmitOnBreach": true
}
]
}Weekday schedule (Mon–Fri 08:00–18:00, UTC+1):
{
"schedule": {
"weekdays": [0, 1, 2, 3, 4],
"from": "08:00",
"to": "18:00",
"utcOffsetMinutes": 60
},
"temperatureAlerts": [
{
"type": "range",
"channel": "probe",
"lowC": 2,
"highC": 8,
"hysteresisC": 0.5
}
]
}Alerts + sample and uplink intervals (each optional field is written when present; omit to leave that interval unchanged):
{
"schedule": { "always": true },
"measurementIntervalMinutes": 60,
"transmissionIntervalSeconds": 3600,
"temperatureAlerts": [
{
"type": "range",
"channel": "probe",
"lowC": -30,
"highC": -10,
"hysteresisC": 0.5,
"transmitOnBreach": true,
"transmitOnReturn": true
}
]
}await setConfigurationJson({
schedule: { always: true },
measurementIntervalMinutes: 60,
transmissionIntervalSeconds: 3600,
temperatureAlerts: [
{
type: 'range',
channel: 'probe',
lowC: -30,
highC: -10,
hysteresisC: 0.5,
},
],
});
const applied = await getConfiguration();
await triggerTransmission();| Field | Meaning |
|------|---------|
| temperatureAlerts | Alert list. Empty array is valid. |
| schedule | { "always": true } is 24/7. |
Ignored over BLE: slug, name. Do not pass alarmRules.
Optional intervals (same limits as Cellular API):
| Field | Write (setConfigurationJson) | Read (getConfiguration) |
|------|--------------------------------|---------------------------|
| measurementIntervalMinutes | 1–600. Omitted: leave device value. | Returns current device value. |
| transmissionIntervalSeconds | 3600–604800 (1 h–7 d). Omitted: leave device value. | Returns current device value. |
When both are set on write, uplink must be at most 60× the measurement interval. Example: 1 min sampling → max 3600 s uplink; 60 min → max 3600 s; 120 min → max 7200 s.
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.
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.
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.
Each temperatureAlerts[] item
| Field | Meaning |
|------|---------|
| type | range (band), min (below), or max (above). |
| channel | ambient or probe. |
| lowC / highC | Inclusive band for type: "range". |
| minC | Threshold for type: "min". |
| maxC | Threshold for type: "max". |
| hysteresisC | Degrees of hysteresis. Omitted: 1. |
| transmitOnBreach | Uplink when the alert trips. Omitted: true. |
| transmitOnReturn | Extra uplink when temp returns inside a range. Omitted: true. Range only. |
schedule weekday window (instead of always)
| Field | Meaning |
|------|---------|
| weekdays | 0 = Monday … 6 = Sunday. |
| from / to | 24h HH:MM. |
| utcOffsetMinutes | Offset from UTC for from / to. |
GET /api/v1/devices/config-profiles/{slug} returns { "profile": { … } }. Pass that body or profile to setConfigurationJson.
QR object
Sticker QR is JSON. Parse with parseSensorQrJson.
{ "sn": "282C024F0012", "pin": "111111" }| Field | Type | Meaning |
|------|------|---------|
| serial | string | Device serial, 12 hex chars, no colons. From QR sn. |
| pin | string | Sticker PIN. Required on connect. Also accepted as QR resetCode. |
| model | string | Optional. Defaults to HC7. HC5 is rejected. |
| sessionType | modern | Always modern GATT (HC7 / firmware 7+). |
| bluetoothMac | string | BLE MAC with colons, derived from serial or scan row. |
| deviceId | string | Optional. Platform BLE id from scan. Pass when connecting to a selected scan row. |
Scan result
Filter manufacturer data on company id 0x026C. Active scanning is on so scan-response packets arrive.
Advertising vs GATT: beacon packets (manufacturer 0x026C, frames 0x03 / 0x04) are only sent while the sensor is not in a GATT session. If your app, another phone, or another tool is connected, scan results for that sensor disappear or freeze until disconnect() (or the link drops). Do not expect live telemetry.readings during connect.
| Field | Meaning |
|------|---------|
| deviceId | Platform BLE id. Often randomized. Not the sticker serial. |
| bluetoothMacAddress | MAC from the advertisement, with colons. |
| serialNumber | 12 hex chars from advertisement frame 0x03. Same as QR sn. |
| rssi | Signal strength in dBm. |
| summary | Short display line. |
| telemetry | Decoded frames 0x03 + 0x04, or null until enough bytes arrive. |
telemetry
| Field | Meaning |
|------|---------|
| firmware | major.minor.patch, e.g. 7.3.4. Partner GATT requires firmware 7+. |
| batteryOk | Battery status flag. |
| encryptionEnabled | Advertisement payload encryption flag. |
| cellularStatus | ble_only, cell_ok, no_server, or net_issue. |
| readingTimestampUnix | Sample time as unix seconds, or null. |
| readingTimestampIso | Same instant as UTC ISO-8601, or null. |
| measurementIntervalSeconds | Sample interval, in seconds. |
| readings | Decoded scan-response slots. |
| readingsCount | Number of measurement slots. |
| summary | Same short line as on the device object. |
| measurementCounter | Counter from frame 0x03, or null. |
Each readings[] slot
| Field | Meaning |
|------|---------|
| typeHex | Measurement label, e.g. temperature, humidity (not a raw hex code on native scan). |
| raw24 | Undecoded 24-bit payload. |
| text | Display value with unit, e.g. 21.8 °C. Use formatSensorReadingLine(reading) for temperature: 21.8 °C. |
triggerTransmission() returns { ok }. ok: false means the firmware rejected the command; it is not thrown as an error.
getCalibration() returns { laboratoryCalibrationDate, laboratoryCalibrationTimestamp }. Date is YYYY-MM-DD, or null if unset.
Node / TypeScript helpers
Decode advertisements and parse QR / config without native BLE.
import {
normalizeManufacturerBytes,
decodeSensorBeaconPayload,
parseSensorQrJson,
parseSensorConfigurationJson,
configurationFromApiProfile,
} from '@tempivo/sensor-beacon';
const qr = parseSensorQrJson('{"sn":"282C024F0012","pin":"111111"}');
const reading = decodeSensorBeaconPayload(normalizeManufacturerBytes(advertisementBytes));
const cfg = parseSensorConfigurationJson(/* config JSON above */);
const fromApi = configurationFromApiProfile(apiProfile); // GET …/config-profiles/{slug}Invalid QR or config throws TempivoSensorError (invalidQr, invalidPin, invalidConfig).
Android (Kotlin)
AAR after npm run build:android (from packages/tempivo-sensor-beacon):
| Path | Use |
|------|-----|
| android/libs/tempivo-sensor-beacon.aar | Expo / React Native autolinking (android/build.gradle) |
| dist/tempivo-sensor-beacon.aar | Same file; handy for plain Android files(...) deps |
Sources: android-aar/library. Request BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and on some OEMs ACCESS_FINE_LOCATION even on API 31+ (location on older APIs).
Scan: temperature and other live values are in scan-response frame 0x04 (telemetry.readings). Frame 0x03 alone fills firmware / battery / cellular but leaves readings empty until an active scan delivers 0x04 (usually within a second or two). Keep scanning; do not stop on the first event. Regression coverage: src/scan-telemetry.regression.test.ts.
val qr = TempivoSensorQrParser.parse(stickerQrText)
val session = TempivoSensorSession(context)
session.connect(qr)
try {
session.setConfigurationJson(profileJson)
val trigger = session.triggerTransmission()
val applied = session.getConfiguration()
val cal = session.getCalibration()
} finally {
// Without disconnect, the sensor will not advertise for startScan.
session.disconnect()
}val scanner = TempivoSensorBeaconScanner(context)
scanner.startScan { device ->
Log.d("scan", "${device.serialNumber} rssi=${device.rssi} ${device.summary}")
}iOS (Swift)
Needs a physical device for GATT. Simulator can scan advertisements only. Set NSBluetoothAlwaysUsageDescription in Info.plist.
let qr = try TempivoSensorQrParser.parse(stickerQrText)
let session = TempivoSensorSession()
try session.connect(qr: qr)
defer { session.disconnect() } // advertising resumes after disconnect
try session.setConfigurationJson(profileJson)
_ = try session.triggerTransmission()
let applied = try session.getConfiguration()
let cal = try session.getCalibration()func sensorBeaconScanner(_ scanner: TempivoSensorBeaconScanner, didDiscover discovery: SensorBeaconDiscovery) {
print(discovery.serialNumber ?? "", discovery.rssi, discovery.reading?.summary ?? "")
}Errors
Native GATT maps to: invalidPin, invalidQr, invalidConfig, notConnected, unsupportedCommand, connectFailed, runtimeUnavailable, unknown.
Tests
npm run test:unitChangelog
See CHANGELOG.md.
License
MIT
