@tempivo/sensor-beacon 0.1.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/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/decoder.d.ts +15 -0
- package/dist/decoder.js +333 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/ingest.d.ts +6 -0
- package/dist/ingest.js +69 -0
- package/dist/measure-specs.d.ts +8 -0
- package/dist/measure-specs.js +34 -0
- package/dist/normalize.d.ts +6 -0
- package/dist/normalize.js +30 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.js +3 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tempivo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @tempivo/sensor-beacon
|
|
2
|
+
|
|
3
|
+
Decode **Tempivo sensor** BLE **manufacturer data** (company id **0x026C**). Use this in partner apps, gateways, or Node scripts that scan advertisements. **No GATT connection** is required.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @tempivo/sensor-beacon
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Node.js 18+.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
Many platforms return manufacturer bytes **without** the 2-byte company id (`6C 02`). If your stack includes the id, strip it first:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {
|
|
19
|
+
TEMPVO_SENSOR_MANUFACTURER_ID,
|
|
20
|
+
normalizeManufacturerBytes,
|
|
21
|
+
decodeSensorBeaconPayload,
|
|
22
|
+
} from '@tempivo/sensor-beacon';
|
|
23
|
+
|
|
24
|
+
// Web Bluetooth: manufacturerData.get(0x026C) → DataView
|
|
25
|
+
const payload = normalizeManufacturerBytes(manufacturerDataView);
|
|
26
|
+
const reading = decodeSensorBeaconPayload(payload);
|
|
27
|
+
if (reading) {
|
|
28
|
+
console.log(reading.serialMac, reading.firmware, reading.temperatures);
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`TEMPVO_SENSOR_MANUFACTURER_ID` is `0x026c` (same id on air as little-endian `6C 02`).
|
|
33
|
+
|
|
34
|
+
## Advertising-only workflow
|
|
35
|
+
|
|
36
|
+
1. Scan BLE advertisements (no connect).
|
|
37
|
+
2. Read manufacturer specific data for **0x026C**.
|
|
38
|
+
3. Call `normalizeManufacturerBytes` then `decodeSensorBeaconPayload`, or cache frames with `ingestManufacturerPayload` and merge with `buildSensorBeaconReading`.
|
|
39
|
+
|
|
40
|
+
FW6 devices split data across:
|
|
41
|
+
|
|
42
|
+
- **`0x03` frame** (22 bytes): serial/MAC, firmware, battery, period, timestamp.
|
|
43
|
+
- **`0x04` frame** (scan response): measurement slots (temperature, humidity, CO₂, etc.).
|
|
44
|
+
|
|
45
|
+
You may receive `0x03` and `0x04` in separate packets. Use `SensorBeaconFrameCache` to merge by device key:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import {
|
|
49
|
+
createSensorBeaconFrameCache,
|
|
50
|
+
ingestManufacturerPayload,
|
|
51
|
+
buildSensorBeaconReading,
|
|
52
|
+
macKeyFromAddress,
|
|
53
|
+
} from '@tempivo/sensor-beacon';
|
|
54
|
+
|
|
55
|
+
const cache = createSensorBeaconFrameCache();
|
|
56
|
+
const macKey = macKeyFromAddress('28:2C:02:4F:00:12');
|
|
57
|
+
|
|
58
|
+
ingestManufacturerPayload(advPayload, macKey, cache);
|
|
59
|
+
ingestManufacturerPayload(scanPayload, macKey, cache);
|
|
60
|
+
|
|
61
|
+
const reading = buildSensorBeaconReading(
|
|
62
|
+
cache.last03.get(macKey),
|
|
63
|
+
cache.last04.get(macKey)
|
|
64
|
+
);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
| Export | Purpose |
|
|
70
|
+
|--------|---------|
|
|
71
|
+
| `TEMPVO_SENSOR_MANUFACTURER_ID` | BLE company id `0x026c` |
|
|
72
|
+
| `normalizeManufacturerBytes` | Strip company id prefix when present |
|
|
73
|
+
| `decodeSensorBeaconPayload` | One-shot decode of payload bytes |
|
|
74
|
+
| `ingestManufacturerPayload` | Update frame cache from one advertisement |
|
|
75
|
+
| `buildSensorBeaconReading` | Merge cached `0x03` / `0x04` frames |
|
|
76
|
+
| `SensorBeaconReading` | Parsed device + measurements |
|
|
77
|
+
| `SensorBeaconMeasurement` | One slot in `0x04` scan data |
|
|
78
|
+
| `SensorBeaconFrameCache` | Per-device last `0x03` / `0x04` frames |
|
|
79
|
+
|
|
80
|
+
Measurement types **0x01–0x22** (temperature, humidity, pressure, IAQ, CO₂, PM, etc.) are decoded to human-readable `text` on each `SensorBeaconMeasurement`.
|
|
81
|
+
|
|
82
|
+
## Tempivo app integration
|
|
83
|
+
|
|
84
|
+
The Tempivo web app uses the same broadcast format for live debug scans. This package is the supported **partner-facing** npm build of that decoder.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SensorBeaconReading } from './types.js';
|
|
2
|
+
export declare const TEMPVO_SENSOR_MANUFACTURER_ID = 620;
|
|
3
|
+
/** FW6 `0x03` advertisement frame length (bytes after company id). */
|
|
4
|
+
export declare const ADV_FRAME_03_LEN = 22;
|
|
5
|
+
export declare function decodeMeasurementSlot(mtype: number, raw24: number): string;
|
|
6
|
+
export declare function serialKeyFromAdv03Frame(frame: Uint8Array): string | null;
|
|
7
|
+
export declare function macKeyFromAddress(macWithColons: string): string;
|
|
8
|
+
export declare function lookupDeviceKeys(primaryMacKey: string, extraSerialKey?: string | null): string[];
|
|
9
|
+
export declare function splitSensorBeaconFrames(payload: Uint8Array): Uint8Array[];
|
|
10
|
+
/** Merge cached or inline `0x03` / `0x02` / `0x04` frames into one reading. */
|
|
11
|
+
export declare function buildSensorBeaconReading(adv03: Uint8Array | null | undefined, adv04: Uint8Array | null | undefined, rawHex?: string): SensorBeaconReading | null;
|
|
12
|
+
/** One-shot decode of manufacturer payload (after company id strip). */
|
|
13
|
+
export declare function decodeSensorBeaconPayload(data: Uint8Array): SensorBeaconReading | null;
|
|
14
|
+
/** FW 7+ → modern (`false`); FW 6-/5.x → legacy (`true`). */
|
|
15
|
+
export declare function legacyHintFromFirmware(firmware: string | null | undefined): boolean | null;
|
package/dist/decoder.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { MEASURE_SPECS } from './measure-specs.js';
|
|
2
|
+
export const TEMPVO_SENSOR_MANUFACTURER_ID = 0x026c;
|
|
3
|
+
/** FW6 `0x03` advertisement frame length (bytes after company id). */
|
|
4
|
+
export const ADV_FRAME_03_LEN = 22;
|
|
5
|
+
const CELL_LABELS = ['ble_only', 'cell_ok', 'no_server', 'net_issue'];
|
|
6
|
+
function u8(b, i) {
|
|
7
|
+
return b[i] & 0xff;
|
|
8
|
+
}
|
|
9
|
+
function u16be(b, o) {
|
|
10
|
+
return (u8(b, o) << 8) | u8(b, o + 1);
|
|
11
|
+
}
|
|
12
|
+
function zigzagDecode24(raw24) {
|
|
13
|
+
const n = raw24 & 0xffffff;
|
|
14
|
+
return (n >> 1) ^ (-(n & 1));
|
|
15
|
+
}
|
|
16
|
+
function formatNum(v, mtype) {
|
|
17
|
+
if (Math.abs(v - Math.floor(v)) < 1e-6) {
|
|
18
|
+
return String(Math.round(v));
|
|
19
|
+
}
|
|
20
|
+
if (mtype === 0x01 || mtype === 0x03 || mtype === 0x0f || mtype === 0x13) {
|
|
21
|
+
return v.toFixed(1);
|
|
22
|
+
}
|
|
23
|
+
if (mtype === 0x02 || mtype === 0x1b) {
|
|
24
|
+
return String(Math.round(v));
|
|
25
|
+
}
|
|
26
|
+
return String(v);
|
|
27
|
+
}
|
|
28
|
+
function unitForType(mtype) {
|
|
29
|
+
switch (mtype) {
|
|
30
|
+
case 0x01:
|
|
31
|
+
return '°C';
|
|
32
|
+
case 0x02:
|
|
33
|
+
case 0x1b:
|
|
34
|
+
return '%';
|
|
35
|
+
case 0x03:
|
|
36
|
+
return 'hPa';
|
|
37
|
+
case 0x1a:
|
|
38
|
+
return 'ppm';
|
|
39
|
+
case 0x21:
|
|
40
|
+
return 'mV';
|
|
41
|
+
case 0x22:
|
|
42
|
+
return 'mA';
|
|
43
|
+
default:
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function decodeMeasurementSlot(mtype, raw24) {
|
|
48
|
+
const spec = MEASURE_SPECS.get(mtype);
|
|
49
|
+
if (!spec) {
|
|
50
|
+
return `0x${mtype.toString(16).padStart(2, '0').toUpperCase()}: raw 0x${(raw24 & 0xffffff).toString(16).padStart(6, '0')}`;
|
|
51
|
+
}
|
|
52
|
+
if (!spec.continuous) {
|
|
53
|
+
return `${spec.name}: (binary / status)`;
|
|
54
|
+
}
|
|
55
|
+
const z = zigzagDecode24(raw24);
|
|
56
|
+
if (spec.metaFactor === 1) {
|
|
57
|
+
const v = z * spec.resolution;
|
|
58
|
+
const unit = unitForType(mtype);
|
|
59
|
+
const num = formatNum(v, mtype);
|
|
60
|
+
return unit ? `${spec.name} ${num} ${unit}` : `${spec.name} ${num}`;
|
|
61
|
+
}
|
|
62
|
+
if (mtype === 0x06) {
|
|
63
|
+
const iaq = z % (1 << 9);
|
|
64
|
+
const cal = (z >> 9) & 0x03;
|
|
65
|
+
const calS = ['not_stab', 'cal_req', 'cal_ongoing', 'cal_done'][Math.min(cal, 3)];
|
|
66
|
+
return `${spec.name} ${iaq} (${calS})`;
|
|
67
|
+
}
|
|
68
|
+
if (mtype === 0x1a || mtype === 0x1c || mtype === 0x1d || mtype === 0x1e) {
|
|
69
|
+
const v = (z / spec.metaFactor) * spec.resolution;
|
|
70
|
+
return `${spec.name} ${v}`;
|
|
71
|
+
}
|
|
72
|
+
return `${spec.name} z=${z}`;
|
|
73
|
+
}
|
|
74
|
+
function parsedValuesFromSlot(mtype, raw24) {
|
|
75
|
+
const spec = MEASURE_SPECS.get(mtype);
|
|
76
|
+
if (!spec?.continuous || spec.metaFactor !== 1)
|
|
77
|
+
return {};
|
|
78
|
+
const z = zigzagDecode24(raw24);
|
|
79
|
+
const v = z * spec.resolution;
|
|
80
|
+
if (mtype === 0x01)
|
|
81
|
+
return { temperatureC: v };
|
|
82
|
+
if (mtype === 0x02 || mtype === 0x1b)
|
|
83
|
+
return { humidityPct: v };
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
export function serialKeyFromAdv03Frame(frame) {
|
|
87
|
+
if (frame.length < 7 || u8(frame, 0) !== 0x03)
|
|
88
|
+
return null;
|
|
89
|
+
let s = '';
|
|
90
|
+
for (let i = 1; i <= 6; i++) {
|
|
91
|
+
s += u8(frame, i).toString(16).padStart(2, '0').toUpperCase();
|
|
92
|
+
}
|
|
93
|
+
return s;
|
|
94
|
+
}
|
|
95
|
+
export function macKeyFromAddress(macWithColons) {
|
|
96
|
+
return macWithColons.replace(/:/gi, '').toUpperCase();
|
|
97
|
+
}
|
|
98
|
+
export function lookupDeviceKeys(primaryMacKey, extraSerialKey) {
|
|
99
|
+
const keys = new Set([primaryMacKey.toUpperCase()]);
|
|
100
|
+
if (extraSerialKey?.trim())
|
|
101
|
+
keys.add(extraSerialKey.toUpperCase());
|
|
102
|
+
return [...keys];
|
|
103
|
+
}
|
|
104
|
+
export function splitSensorBeaconFrames(payload) {
|
|
105
|
+
const out = [];
|
|
106
|
+
let i = 0;
|
|
107
|
+
const n = payload.length;
|
|
108
|
+
while (i < n) {
|
|
109
|
+
const b = u8(payload, i);
|
|
110
|
+
if (b === 0x03 && i + ADV_FRAME_03_LEN <= n) {
|
|
111
|
+
out.push(payload.subarray(i, i + ADV_FRAME_03_LEN));
|
|
112
|
+
i += ADV_FRAME_03_LEN;
|
|
113
|
+
}
|
|
114
|
+
else if (b === 0x04) {
|
|
115
|
+
let j = i + 1;
|
|
116
|
+
while (j + 4 <= n) {
|
|
117
|
+
if (j + 4 > n - 2)
|
|
118
|
+
break;
|
|
119
|
+
const mtype = u8(payload, j);
|
|
120
|
+
if (mtype === 0 || mtype > 0x26)
|
|
121
|
+
break;
|
|
122
|
+
j += 4;
|
|
123
|
+
}
|
|
124
|
+
if (n - j >= 2) {
|
|
125
|
+
out.push(payload.subarray(i, j + 2));
|
|
126
|
+
i = j + 2;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
out.push(payload.subarray(i));
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else if (b === 0x02 && i + 3 <= n) {
|
|
134
|
+
const end = Math.min(n, i + 26);
|
|
135
|
+
out.push(payload.subarray(i, end));
|
|
136
|
+
i = end;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
i += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
function fw5Summary(frame) {
|
|
145
|
+
if (frame.length === 0 || u8(frame, 0) !== 0x02)
|
|
146
|
+
return null;
|
|
147
|
+
const maj = frame.length > 1 ? u8(frame, 1) : null;
|
|
148
|
+
const min = frame.length > 2 ? u8(frame, 2) : null;
|
|
149
|
+
if (maj == null || min == null)
|
|
150
|
+
return null;
|
|
151
|
+
return `FW 5.x ${maj}.${min} (limited broadcast decode)`;
|
|
152
|
+
}
|
|
153
|
+
function serialMacFromAdv03(frame) {
|
|
154
|
+
return Array.from(frame.subarray(1, 7))
|
|
155
|
+
.map((x) => (x & 0xff).toString(16).padStart(2, '0').toUpperCase())
|
|
156
|
+
.join(':');
|
|
157
|
+
}
|
|
158
|
+
function parseFw6Adv03(frame) {
|
|
159
|
+
if (frame.length < ADV_FRAME_03_LEN)
|
|
160
|
+
return {};
|
|
161
|
+
const fwRaw = u16be(frame, 7);
|
|
162
|
+
const major = (fwRaw >> 11) & 0x1f;
|
|
163
|
+
const minor = (fwRaw >> 5) & 0x3f;
|
|
164
|
+
const lts = fwRaw & 0x1f;
|
|
165
|
+
const st = u8(frame, 9);
|
|
166
|
+
const cell = (st >> 6) & 3;
|
|
167
|
+
const ts = (u8(frame, 10) << 24) |
|
|
168
|
+
(u8(frame, 11) << 16) |
|
|
169
|
+
(u8(frame, 12) << 8) |
|
|
170
|
+
u8(frame, 13);
|
|
171
|
+
const pbase = u16be(frame, 14);
|
|
172
|
+
const pfact = u16be(frame, 16);
|
|
173
|
+
let readingTimestampIso = null;
|
|
174
|
+
if (ts > 1_000_000_000 && ts < 4_000_000_000) {
|
|
175
|
+
readingTimestampIso = new Date(ts * 1000).toISOString();
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
serialMac: serialMacFromAdv03(frame),
|
|
179
|
+
firmware: `${major}.${minor}.${lts}`,
|
|
180
|
+
batteryOk: (st & 1) !== 0,
|
|
181
|
+
encryptionEnabled: ((st >> 3) & 1) !== 0,
|
|
182
|
+
cellularStatus: CELL_LABELS[cell] ?? String(cell),
|
|
183
|
+
measurementCounter: ts >>> 0,
|
|
184
|
+
readingTimestampUnix: ts,
|
|
185
|
+
readingTimestampIso,
|
|
186
|
+
periodBaseSeconds: pbase,
|
|
187
|
+
periodFactor: pfact,
|
|
188
|
+
periodLabel: `${pbase}s × ${pfact}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function parseScan04(frame) {
|
|
192
|
+
const measurements = [];
|
|
193
|
+
const summaryParts = [];
|
|
194
|
+
if (frame.length < 5 || u8(frame, 0) !== 0x04) {
|
|
195
|
+
return { measurements, summaryParts };
|
|
196
|
+
}
|
|
197
|
+
let i = 1;
|
|
198
|
+
while (i + 4 <= frame.length - 2) {
|
|
199
|
+
const mtype = u8(frame, i);
|
|
200
|
+
if (mtype === 0)
|
|
201
|
+
break;
|
|
202
|
+
const raw24 = (u8(frame, i + 1) << 16) | (u8(frame, i + 2) << 8) | u8(frame, i + 3);
|
|
203
|
+
const text = decodeMeasurementSlot(mtype, raw24);
|
|
204
|
+
summaryParts.push(text);
|
|
205
|
+
measurements.push({
|
|
206
|
+
typeId: mtype,
|
|
207
|
+
typeHex: `0x${mtype.toString(16).padStart(2, '0').toUpperCase()}`,
|
|
208
|
+
raw24: raw24 & 0xffffff,
|
|
209
|
+
text,
|
|
210
|
+
...parsedValuesFromSlot(mtype, raw24),
|
|
211
|
+
});
|
|
212
|
+
i += 4;
|
|
213
|
+
}
|
|
214
|
+
return { measurements, summaryParts };
|
|
215
|
+
}
|
|
216
|
+
function bufferToHex(b) {
|
|
217
|
+
return Array.from(b)
|
|
218
|
+
.map((x) => (x & 0xff).toString(16).padStart(2, '0'))
|
|
219
|
+
.join('');
|
|
220
|
+
}
|
|
221
|
+
/** Merge cached or inline `0x03` / `0x02` / `0x04` frames into one reading. */
|
|
222
|
+
export function buildSensorBeaconReading(adv03, adv04, rawHex = '') {
|
|
223
|
+
const summaryParts = [];
|
|
224
|
+
let base = {
|
|
225
|
+
serialMac: '',
|
|
226
|
+
firmware: '',
|
|
227
|
+
batteryOk: false,
|
|
228
|
+
encryptionEnabled: false,
|
|
229
|
+
cellularStatus: '',
|
|
230
|
+
measurementCounter: null,
|
|
231
|
+
readingTimestampUnix: null,
|
|
232
|
+
readingTimestampIso: null,
|
|
233
|
+
periodBaseSeconds: null,
|
|
234
|
+
periodFactor: null,
|
|
235
|
+
periodLabel: '',
|
|
236
|
+
};
|
|
237
|
+
if (adv03?.length) {
|
|
238
|
+
const tag = u8(adv03, 0);
|
|
239
|
+
if (tag === 0x03) {
|
|
240
|
+
base = { ...base, ...parseFw6Adv03(adv03) };
|
|
241
|
+
}
|
|
242
|
+
else if (tag === 0x02) {
|
|
243
|
+
const fw5 = fw5Summary(adv03);
|
|
244
|
+
if (fw5) {
|
|
245
|
+
base.firmware = fw5;
|
|
246
|
+
summaryParts.push(fw5);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const { measurements, summaryParts: scanParts } = parseScan04(adv04 ?? new Uint8Array(0));
|
|
251
|
+
summaryParts.push(...scanParts);
|
|
252
|
+
const temperatures = [];
|
|
253
|
+
let humidity = null;
|
|
254
|
+
for (const m of measurements) {
|
|
255
|
+
if (m.temperatureC !== undefined)
|
|
256
|
+
temperatures.push(m.temperatureC);
|
|
257
|
+
if (m.humidityPct !== undefined && humidity === null)
|
|
258
|
+
humidity = m.humidityPct;
|
|
259
|
+
}
|
|
260
|
+
let summary = '';
|
|
261
|
+
if (summaryParts.length === 0) {
|
|
262
|
+
if (adv03?.length && u8(adv03, 0) === 0x03) {
|
|
263
|
+
summary =
|
|
264
|
+
base.readingTimestampIso ??
|
|
265
|
+
'Adv OK. Wait for scan response (measurements).';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
summary = summaryParts.slice(0, 4).join(' · ');
|
|
270
|
+
}
|
|
271
|
+
if (!summary && adv03 == null && adv04 == null)
|
|
272
|
+
return null;
|
|
273
|
+
const sumOut = summary || base.readingTimestampIso || '';
|
|
274
|
+
if (!base.serialMac && measurements.length === 0 && !base.firmware)
|
|
275
|
+
return null;
|
|
276
|
+
return {
|
|
277
|
+
serialMac: base.serialMac || '—',
|
|
278
|
+
firmware: base.firmware || '—',
|
|
279
|
+
batteryOk: base.batteryOk ?? false,
|
|
280
|
+
encryptionEnabled: base.encryptionEnabled ?? false,
|
|
281
|
+
cellularStatus: base.cellularStatus || '',
|
|
282
|
+
measurementCounter: base.measurementCounter ?? null,
|
|
283
|
+
readingTimestampUnix: base.readingTimestampUnix ?? null,
|
|
284
|
+
readingTimestampIso: base.readingTimestampIso ?? null,
|
|
285
|
+
periodBaseSeconds: base.periodBaseSeconds ?? null,
|
|
286
|
+
periodFactor: base.periodFactor ?? null,
|
|
287
|
+
periodLabel: base.periodLabel || '—',
|
|
288
|
+
measurements,
|
|
289
|
+
summary: sumOut,
|
|
290
|
+
temperatures,
|
|
291
|
+
humidity,
|
|
292
|
+
rawHex,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/** One-shot decode of manufacturer payload (after company id strip). */
|
|
296
|
+
export function decodeSensorBeaconPayload(data) {
|
|
297
|
+
if (data.length < 3)
|
|
298
|
+
return null;
|
|
299
|
+
const frames = splitSensorBeaconFrames(data);
|
|
300
|
+
if (frames.length === 0)
|
|
301
|
+
return null;
|
|
302
|
+
let adv03;
|
|
303
|
+
let adv04;
|
|
304
|
+
let adv02;
|
|
305
|
+
for (const fr of frames) {
|
|
306
|
+
const tag = u8(fr, 0);
|
|
307
|
+
if (tag === 0x03 && fr.length >= ADV_FRAME_03_LEN)
|
|
308
|
+
adv03 = fr;
|
|
309
|
+
else if (tag === 0x04)
|
|
310
|
+
adv04 = fr;
|
|
311
|
+
else if (tag === 0x02)
|
|
312
|
+
adv02 = fr;
|
|
313
|
+
}
|
|
314
|
+
const merged03 = adv03 ?? adv02;
|
|
315
|
+
return buildSensorBeaconReading(merged03, adv04, bufferToHex(data));
|
|
316
|
+
}
|
|
317
|
+
/** FW 7+ → modern (`false`); FW 6-/5.x → legacy (`true`). */
|
|
318
|
+
export function legacyHintFromFirmware(firmware) {
|
|
319
|
+
if (!firmware?.trim())
|
|
320
|
+
return null;
|
|
321
|
+
const t = firmware.trim();
|
|
322
|
+
if (/^FW 5/i.test(t))
|
|
323
|
+
return true;
|
|
324
|
+
const m = /^(\d+)\.\d+/.exec(t);
|
|
325
|
+
if (!m)
|
|
326
|
+
return null;
|
|
327
|
+
const major = parseInt(m[1], 10);
|
|
328
|
+
if (major >= 7)
|
|
329
|
+
return false;
|
|
330
|
+
if (major >= 1)
|
|
331
|
+
return true;
|
|
332
|
+
return null;
|
|
333
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ADV_FRAME_03_LEN, TEMPVO_SENSOR_MANUFACTURER_ID, buildSensorBeaconReading, decodeMeasurementSlot, decodeSensorBeaconPayload, legacyHintFromFirmware, lookupDeviceKeys, macKeyFromAddress, serialKeyFromAdv03Frame, splitSensorBeaconFrames, } from './decoder.js';
|
|
2
|
+
export { ingestManufacturerPayload } from './ingest.js';
|
|
3
|
+
export { dataViewToUint8Array, normalizeManufacturerBytes } from './normalize.js';
|
|
4
|
+
export { MEASURE_SPECS, type MeasureSpec } from './measure-specs.js';
|
|
5
|
+
export { createSensorBeaconFrameCache, type SensorBeaconFrameCache, type SensorBeaconMeasurement, type SensorBeaconReading, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ADV_FRAME_03_LEN, TEMPVO_SENSOR_MANUFACTURER_ID, buildSensorBeaconReading, decodeMeasurementSlot, decodeSensorBeaconPayload, legacyHintFromFirmware, lookupDeviceKeys, macKeyFromAddress, serialKeyFromAdv03Frame, splitSensorBeaconFrames, } from './decoder.js';
|
|
2
|
+
export { ingestManufacturerPayload } from './ingest.js';
|
|
3
|
+
export { dataViewToUint8Array, normalizeManufacturerBytes } from './normalize.js';
|
|
4
|
+
export { MEASURE_SPECS } from './measure-specs.js';
|
|
5
|
+
export { createSensorBeaconFrameCache, } from './types.js';
|
package/dist/ingest.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SensorBeaconFrameCache } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Store latest `0x03` / `0x02` / `0x04` frames per device key.
|
|
4
|
+
* @returns true if cache changed (UI may refresh).
|
|
5
|
+
*/
|
|
6
|
+
export declare function ingestManufacturerPayload(data: Uint8Array, primaryMacKey: string, cache: SensorBeaconFrameCache): boolean;
|
package/dist/ingest.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ADV_FRAME_03_LEN, lookupDeviceKeys, serialKeyFromAdv03Frame, splitSensorBeaconFrames, } from './decoder.js';
|
|
2
|
+
function framesEqual(a, b) {
|
|
3
|
+
if (a.length !== b.length)
|
|
4
|
+
return false;
|
|
5
|
+
for (let i = 0; i < a.length; i++) {
|
|
6
|
+
if (a[i] !== b[i])
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Store latest `0x03` / `0x02` / `0x04` frames per device key.
|
|
13
|
+
* @returns true if cache changed (UI may refresh).
|
|
14
|
+
*/
|
|
15
|
+
export function ingestManufacturerPayload(data, primaryMacKey, cache) {
|
|
16
|
+
let changed = false;
|
|
17
|
+
const frames = splitSensorBeaconFrames(data);
|
|
18
|
+
for (const fr of frames) {
|
|
19
|
+
if (fr.length === 0)
|
|
20
|
+
continue;
|
|
21
|
+
const tag = fr[0] & 0xff;
|
|
22
|
+
switch (tag) {
|
|
23
|
+
case 0x03: {
|
|
24
|
+
if (fr.length >= ADV_FRAME_03_LEN) {
|
|
25
|
+
const frame = fr.subarray(0, ADV_FRAME_03_LEN);
|
|
26
|
+
const keys = lookupDeviceKeys(primaryMacKey, serialKeyFromAdv03Frame(frame));
|
|
27
|
+
for (const key of keys) {
|
|
28
|
+
const prev = cache.last03.get(key);
|
|
29
|
+
if (!prev || !framesEqual(prev, frame)) {
|
|
30
|
+
cache.last03.set(key, frame.slice());
|
|
31
|
+
changed = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case 0x02: {
|
|
38
|
+
if (fr.length >= 3) {
|
|
39
|
+
const keys = lookupDeviceKeys(primaryMacKey);
|
|
40
|
+
for (const key of keys) {
|
|
41
|
+
const existing = cache.last03.get(key);
|
|
42
|
+
if (existing?.length && (existing[0] & 0xff) === 0x03)
|
|
43
|
+
continue;
|
|
44
|
+
const prev = cache.last03.get(key);
|
|
45
|
+
if (!prev || !framesEqual(prev, fr)) {
|
|
46
|
+
cache.last03.set(key, fr.slice());
|
|
47
|
+
changed = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case 0x04: {
|
|
54
|
+
const keys = lookupDeviceKeys(primaryMacKey);
|
|
55
|
+
for (const key of keys) {
|
|
56
|
+
const prev = cache.last04.get(key);
|
|
57
|
+
if (!prev || !framesEqual(prev, fr)) {
|
|
58
|
+
cache.last04.set(key, fr.slice());
|
|
59
|
+
changed = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
default:
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return changed;
|
|
69
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface MeasureSpec {
|
|
2
|
+
name: string;
|
|
3
|
+
resolution: number;
|
|
4
|
+
metaFactor: number;
|
|
5
|
+
continuous: boolean;
|
|
6
|
+
}
|
|
7
|
+
/** FW6 scan-response measurement types 0x01–0x22 (per Tempivo sensor broadcast spec). */
|
|
8
|
+
export declare const MEASURE_SPECS: ReadonlyMap<number, MeasureSpec>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** FW6 scan-response measurement types 0x01–0x22 (per Tempivo sensor broadcast spec). */
|
|
2
|
+
export const MEASURE_SPECS = new Map([
|
|
3
|
+
[0x01, { name: 'Temperature', resolution: 0.1, metaFactor: 1, continuous: true }],
|
|
4
|
+
[0x02, { name: 'Humidity', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
5
|
+
[0x03, { name: 'Atmospheric pressure', resolution: 0.1, metaFactor: 1, continuous: true }],
|
|
6
|
+
[0x04, { name: 'Differential pressure', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
7
|
+
[0x05, { name: 'OK/Alarm', resolution: 1.0, metaFactor: 1, continuous: false }],
|
|
8
|
+
[0x06, { name: 'IAQ', resolution: 1.0, metaFactor: 3, continuous: true }],
|
|
9
|
+
[0x07, { name: 'Flooding', resolution: 1.0, metaFactor: 1, continuous: false }],
|
|
10
|
+
[0x08, { name: 'Pulse count', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
11
|
+
[0x09, { name: 'Electricity meter', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
12
|
+
[0x0a, { name: 'Water meter', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
13
|
+
[0x0b, { name: 'Soil moisture', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
14
|
+
[0x0c, { name: 'CO', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
15
|
+
[0x0d, { name: 'NO₂', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
16
|
+
[0x0e, { name: 'H₂S', resolution: 0.01, metaFactor: 1, continuous: true }],
|
|
17
|
+
[0x0f, { name: 'Ambient light', resolution: 0.1, metaFactor: 1, continuous: true }],
|
|
18
|
+
[0x10, { name: 'PM1.0', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
19
|
+
[0x11, { name: 'PM2.5', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
20
|
+
[0x12, { name: 'PM10', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
21
|
+
[0x13, { name: 'Noise', resolution: 0.1, metaFactor: 1, continuous: true }],
|
|
22
|
+
[0x14, { name: 'NH₃', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
23
|
+
[0x15, { name: 'CH₄', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
24
|
+
[0x16, { name: 'High pressure', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
25
|
+
[0x17, { name: 'Distance', resolution: 1.0, metaFactor: 1, continuous: true }],
|
|
26
|
+
[0x1a, { name: 'CO₂', resolution: 1.0, metaFactor: 3, continuous: true }],
|
|
27
|
+
[0x1b, { name: 'Humidity', resolution: 0.1, metaFactor: 1, continuous: true }],
|
|
28
|
+
[0x1c, { name: 'Static IAQ', resolution: 1.0, metaFactor: 3, continuous: true }],
|
|
29
|
+
[0x1d, { name: 'CO₂ equivalent', resolution: 1.0, metaFactor: 3, continuous: true }],
|
|
30
|
+
[0x1e, { name: 'Breath VOC', resolution: 1.0, metaFactor: 3, continuous: true }],
|
|
31
|
+
[0x20, { name: 'Percentage', resolution: 0.01, metaFactor: 1, continuous: true }],
|
|
32
|
+
[0x21, { name: 'Voltage', resolution: 0.1, metaFactor: 1, continuous: true }],
|
|
33
|
+
[0x22, { name: 'Current', resolution: 0.01, metaFactor: 1, continuous: true }],
|
|
34
|
+
]);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function dataViewToUint8Array(dv: DataView): Uint8Array;
|
|
2
|
+
/**
|
|
3
|
+
* Strip BLE company id (0x6C 0x02 little-endian for 0x026C) when present.
|
|
4
|
+
* Web Bluetooth and Android often already return payload **without** the prefix.
|
|
5
|
+
*/
|
|
6
|
+
export declare function normalizeManufacturerBytes(data: Uint8Array | DataView | ArrayBuffer): Uint8Array;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { TEMPVO_SENSOR_MANUFACTURER_ID } from './decoder.js';
|
|
2
|
+
export function dataViewToUint8Array(dv) {
|
|
3
|
+
return new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Strip BLE company id (0x6C 0x02 little-endian for 0x026C) when present.
|
|
7
|
+
* Web Bluetooth and Android often already return payload **without** the prefix.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeManufacturerBytes(data) {
|
|
10
|
+
let bytes;
|
|
11
|
+
if (data instanceof ArrayBuffer) {
|
|
12
|
+
bytes = new Uint8Array(data);
|
|
13
|
+
}
|
|
14
|
+
else if (data instanceof DataView) {
|
|
15
|
+
bytes = dataViewToUint8Array(data);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
bytes = data;
|
|
19
|
+
}
|
|
20
|
+
if (bytes.length >= 2 && bytes[0] === 0x6c && bytes[1] === 0x02) {
|
|
21
|
+
return bytes.subarray(2);
|
|
22
|
+
}
|
|
23
|
+
if (bytes.length >= 2) {
|
|
24
|
+
const id = bytes[0] | (bytes[1] << 8);
|
|
25
|
+
if (id === TEMPVO_SENSOR_MANUFACTURER_ID) {
|
|
26
|
+
return bytes.subarray(2);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return bytes;
|
|
30
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface SensorBeaconMeasurement {
|
|
2
|
+
typeId: number;
|
|
3
|
+
typeHex: string;
|
|
4
|
+
raw24: number;
|
|
5
|
+
text: string;
|
|
6
|
+
temperatureC?: number;
|
|
7
|
+
humidityPct?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface SensorBeaconReading {
|
|
10
|
+
serialMac: string;
|
|
11
|
+
firmware: string;
|
|
12
|
+
batteryOk: boolean;
|
|
13
|
+
encryptionEnabled: boolean;
|
|
14
|
+
cellularStatus: string;
|
|
15
|
+
measurementCounter: number | null;
|
|
16
|
+
readingTimestampUnix: number | null;
|
|
17
|
+
readingTimestampIso: string | null;
|
|
18
|
+
periodBaseSeconds: number | null;
|
|
19
|
+
periodFactor: number | null;
|
|
20
|
+
periodLabel: string;
|
|
21
|
+
measurements: SensorBeaconMeasurement[];
|
|
22
|
+
summary: string;
|
|
23
|
+
temperatures: number[];
|
|
24
|
+
humidity: number | null;
|
|
25
|
+
rawHex: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SensorBeaconFrameCache {
|
|
28
|
+
last03: Map<string, Uint8Array>;
|
|
29
|
+
last04: Map<string, Uint8Array>;
|
|
30
|
+
}
|
|
31
|
+
export declare function createSensorBeaconFrameCache(): SensorBeaconFrameCache;
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tempivo/sensor-beacon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Decode Tempivo sensor BLE manufacturer advertisements (0x026C). Advertising only, no GATT connect. For integrators and custom apps.",
|
|
5
|
+
"homepage": "https://app.tempivo.com/integrations",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.mjs",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"tempivo",
|
|
33
|
+
"ble",
|
|
34
|
+
"bluetooth",
|
|
35
|
+
"beacon",
|
|
36
|
+
"sensor",
|
|
37
|
+
"manufacturer-data"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/jest": "^29.5.14",
|
|
42
|
+
"jest": "^29.7.0",
|
|
43
|
+
"ts-jest": "^29.2.5",
|
|
44
|
+
"typescript": "^6.0.2"
|
|
45
|
+
}
|
|
46
|
+
}
|