matterbridge-bthome 0.0.1
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/CHANGELOG.md +48 -0
- package/LICENSE +202 -0
- package/README.md +276 -0
- package/bin/bthome.js +2 -0
- package/bmc-button.svg +22 -0
- package/dist/BTHome.js +349 -0
- package/dist/BTHomeDecoder.js +52 -0
- package/dist/BTHomeShellyMdDecoder.js +70 -0
- package/dist/BTHomeSpec.js +154 -0
- package/dist/index.js +4 -0
- package/dist/platform.js +197 -0
- package/matterbridge-bthome.schema.json +71 -0
- package/matterbridge.svg +50 -0
- package/npm-shrinkwrap.json +1698 -0
- package/package.json +65 -0
package/dist/BTHome.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { hasParameter, isValidNumber, isValidString } from 'matterbridge/utils';
|
|
3
|
+
import { AnsiLogger, nf, BLUE, GREEN, MAGENTA, YELLOW } from 'matterbridge/logger';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { decodeBTHome } from './BTHomeDecoder.js';
|
|
6
|
+
import { decodeShellyManufacturerData } from './BTHomeShellyMdDecoder.js';
|
|
7
|
+
import { CYAN } from 'node-ansi-logger';
|
|
8
|
+
const _blushellies = [
|
|
9
|
+
'38:39:8f:8b:d2:29',
|
|
10
|
+
'28:68:47:fc:9a:6b',
|
|
11
|
+
'28:db:a7:b5:d1:ca',
|
|
12
|
+
'0c:ae:5f:5a:0b:fa',
|
|
13
|
+
'0c:ef:f6:01:8d:b8',
|
|
14
|
+
'0c:ef:f6:f1:d7:7b',
|
|
15
|
+
'7c:c6:b6:58:b9:a0',
|
|
16
|
+
'7c:c6:b6:65:2d:87',
|
|
17
|
+
'7c:c6:b6:bd:7a:9a',
|
|
18
|
+
'60:ef:ab:3f:c9:7b',
|
|
19
|
+
'38:39:8f:99:58:49',
|
|
20
|
+
'7c:c6:b6:2b:17:b6',
|
|
21
|
+
'38:39:8f:a0:9e:34',
|
|
22
|
+
];
|
|
23
|
+
const _shellies = [
|
|
24
|
+
'34:cd:b0:77:bc:d6',
|
|
25
|
+
'b0:b2:1c:fa:ad:1a',
|
|
26
|
+
'ec:62:60:8c:9c:02',
|
|
27
|
+
'8c:bf:ea:9d:e2:9e',
|
|
28
|
+
'cc:7b:5c:8a:ea:2e',
|
|
29
|
+
'34:b7:da:ca:c8:32',
|
|
30
|
+
'1c:69:20:44:f1:42',
|
|
31
|
+
'42:27:b3:f0:fc:29',
|
|
32
|
+
];
|
|
33
|
+
export class BTHome extends EventEmitter {
|
|
34
|
+
noble;
|
|
35
|
+
log;
|
|
36
|
+
isScanning = false;
|
|
37
|
+
filterBle = false;
|
|
38
|
+
filterBTHome = false;
|
|
39
|
+
filterShellyBle = false;
|
|
40
|
+
filterAddress = [];
|
|
41
|
+
bthomePeripherals = new Map();
|
|
42
|
+
blePeripherals = new Map();
|
|
43
|
+
constructor(filterBle = false, filterBTHome = true, filterShellyBle = false, filterAddress = [], logLevel = "debug") {
|
|
44
|
+
super();
|
|
45
|
+
this.log = new AnsiLogger({ logName: 'BTHome', logTimestampFormat: 4, logLevel });
|
|
46
|
+
this.filterBle = filterBle;
|
|
47
|
+
this.filterBTHome = filterBTHome;
|
|
48
|
+
this.filterShellyBle = filterShellyBle;
|
|
49
|
+
this.filterAddress = filterAddress;
|
|
50
|
+
for (const address of this.filterAddress)
|
|
51
|
+
address.toLowerCase().trim();
|
|
52
|
+
this.log.debug('BTHome constructor called with parameters:');
|
|
53
|
+
this.log.debug(` - filterBTHome: ${filterBTHome}`);
|
|
54
|
+
this.log.debug(` - filterShellyBle: ${filterShellyBle}`);
|
|
55
|
+
this.log.debug(` - filterAddress: ${filterAddress.join(', ')}`);
|
|
56
|
+
this.handleDiscovery = this.handleDiscovery.bind(this);
|
|
57
|
+
}
|
|
58
|
+
isShellyBlePeripheral(peripheral) {
|
|
59
|
+
if (peripheral.advertisement.localName === undefined || peripheral.advertisement.localName === null || peripheral.advertisement.localName === '')
|
|
60
|
+
return false;
|
|
61
|
+
if (!peripheral.advertisement.localName.startsWith('Shelly') && peripheral.advertisement.localName !== 'WallDisplay')
|
|
62
|
+
return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
isBTHomePeripheral(peripheral) {
|
|
66
|
+
if (Array.from(this.bthomePeripherals.values()).find((device) => device.mac === peripheral.address))
|
|
67
|
+
return true;
|
|
68
|
+
if (peripheral.advertisement.serviceData && peripheral.advertisement.serviceData.length) {
|
|
69
|
+
return peripheral.advertisement.serviceData.find((entry) => entry.uuid === 'fcd2') !== undefined;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
async handleDiscovery(peripheral) {
|
|
74
|
+
if (this.filterBle) {
|
|
75
|
+
let assignedNumber = undefined;
|
|
76
|
+
let manufacturerData = undefined;
|
|
77
|
+
if (peripheral.advertisement.manufacturerData && peripheral.advertisement.manufacturerData.length >= 2) {
|
|
78
|
+
assignedNumber = '0x' + peripheral.advertisement.manufacturerData.readUInt16LE(0).toString(16).padStart(4, '0');
|
|
79
|
+
manufacturerData = '0x' + peripheral.advertisement.manufacturerData.toString('hex');
|
|
80
|
+
}
|
|
81
|
+
let bleDevice = this.blePeripherals.get(peripheral.id);
|
|
82
|
+
if (!bleDevice) {
|
|
83
|
+
bleDevice = {
|
|
84
|
+
id: peripheral.id,
|
|
85
|
+
address: peripheral.address,
|
|
86
|
+
addressType: peripheral.addressType,
|
|
87
|
+
connectable: peripheral.connectable,
|
|
88
|
+
advertisement: peripheral.advertisement,
|
|
89
|
+
rssi: peripheral.rssi,
|
|
90
|
+
mtu: null,
|
|
91
|
+
services: [],
|
|
92
|
+
state: 'disconnected',
|
|
93
|
+
localName: peripheral.advertisement.localName ?? '',
|
|
94
|
+
lastSeen: new Date(),
|
|
95
|
+
};
|
|
96
|
+
this.blePeripherals.set(peripheral.id, bleDevice);
|
|
97
|
+
this.log.info(`[${GREEN}New${nf}] Device ${MAGENTA}${peripheral.address}${nf} Rssi: ${CYAN}${peripheral.rssi}${nf} Name: ${CYAN}${bleDevice.localName}${nf}`);
|
|
98
|
+
if (assignedNumber)
|
|
99
|
+
this.log.debug(` ManufacturerData Key: ${assignedNumber} Value: ${manufacturerData}`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
bleDevice.address = peripheral.address;
|
|
103
|
+
bleDevice.addressType = peripheral.addressType;
|
|
104
|
+
bleDevice.connectable = peripheral.connectable;
|
|
105
|
+
bleDevice.advertisement = peripheral.advertisement;
|
|
106
|
+
bleDevice.rssi = peripheral.rssi;
|
|
107
|
+
bleDevice.mtu = peripheral.mtu;
|
|
108
|
+
bleDevice.services = peripheral.services;
|
|
109
|
+
bleDevice.state = peripheral.state;
|
|
110
|
+
bleDevice.localName = peripheral.advertisement.localName ?? '';
|
|
111
|
+
bleDevice.lastSeen = new Date();
|
|
112
|
+
this.log.info(`[${YELLOW}Chg${nf}] Device ${MAGENTA}${peripheral.address}${nf} Rssi: ${CYAN}${peripheral.rssi}${nf} Name: ${CYAN}${bleDevice.localName}${nf}`);
|
|
113
|
+
if (assignedNumber)
|
|
114
|
+
this.log.debug(` ManufacturerData Key: ${assignedNumber} Value: ${manufacturerData}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const isShelly = this.isShellyBlePeripheral(peripheral);
|
|
118
|
+
const isBTHome = this.isBTHomePeripheral(peripheral);
|
|
119
|
+
if (this.filterBTHome && !isBTHome)
|
|
120
|
+
return;
|
|
121
|
+
if (this.filterShellyBle && !isShelly)
|
|
122
|
+
return;
|
|
123
|
+
if (this.filterAddress.length > 0 && !this.filterAddress.includes(peripheral.address.toLowerCase().trim()))
|
|
124
|
+
return;
|
|
125
|
+
if (isBTHome) {
|
|
126
|
+
this.log.debug(`${BLUE}Message from Shelly BLU id ${peripheral.id}:`);
|
|
127
|
+
}
|
|
128
|
+
else if (isShelly) {
|
|
129
|
+
this.log.debug(`${GREEN}Message from Shelly device id ${peripheral.id}:`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.log.debug(`Message from peripheral id ${peripheral.id}:`);
|
|
133
|
+
}
|
|
134
|
+
this.log.debug(` - Address: ${peripheral.address} (${peripheral.addressType})`);
|
|
135
|
+
this.log.debug(` - Connectable: ${peripheral.connectable}`);
|
|
136
|
+
this.log.debug(` - RSSI: ${peripheral.rssi}`);
|
|
137
|
+
if (peripheral.advertisement.localName) {
|
|
138
|
+
this.log.debug(` - Local Name: ${peripheral.advertisement.localName}`);
|
|
139
|
+
}
|
|
140
|
+
if (peripheral.advertisement.serviceUuids.length) {
|
|
141
|
+
this.log.debug(` - Advertised Services: ${peripheral.advertisement.serviceUuids.join(', ')}`);
|
|
142
|
+
}
|
|
143
|
+
const serviceData = peripheral.advertisement.serviceData;
|
|
144
|
+
if (serviceData && serviceData.length) {
|
|
145
|
+
this.log.debug(' - Service Data:');
|
|
146
|
+
serviceData.forEach((entry) => {
|
|
147
|
+
if (entry.uuid === 'fcd2') {
|
|
148
|
+
const bthome = decodeBTHome(entry.data);
|
|
149
|
+
this.log.debug(` BTHome Service Data (${entry.data.toString('hex')}): ${JSON.stringify(bthome)}`);
|
|
150
|
+
let device;
|
|
151
|
+
if (this.bthomePeripherals.has(peripheral.address)) {
|
|
152
|
+
device = this.bthomePeripherals.get(peripheral.address);
|
|
153
|
+
device.rssi = peripheral.rssi ?? device.rssi;
|
|
154
|
+
device.localName = peripheral.advertisement.localName ?? device.localName;
|
|
155
|
+
device.version = bthome.version ?? device.version;
|
|
156
|
+
device.encrypted = bthome.encrypted ?? device.encrypted;
|
|
157
|
+
device.trigger = bthome.trigger ?? device.trigger;
|
|
158
|
+
device.data = Object.assign(device.data, bthome.readings);
|
|
159
|
+
device.packetId = isValidNumber(bthome.readings.packetId, 0) ? bthome.readings.packetId : 0;
|
|
160
|
+
device.lastSeen = new Date();
|
|
161
|
+
this.emit('update', device);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
device = {
|
|
165
|
+
mac: peripheral.address,
|
|
166
|
+
rssi: peripheral.rssi,
|
|
167
|
+
localName: isValidString(peripheral.advertisement.localName, 3) ? peripheral.advertisement.localName : 'BTHome ' + peripheral.address,
|
|
168
|
+
version: bthome.version,
|
|
169
|
+
encrypted: bthome.encrypted,
|
|
170
|
+
trigger: bthome.trigger,
|
|
171
|
+
data: bthome.readings,
|
|
172
|
+
packetId: isValidNumber(bthome.readings.packetId, 0) ? bthome.readings.packetId : 0,
|
|
173
|
+
lastSeen: new Date(),
|
|
174
|
+
};
|
|
175
|
+
this.bthomePeripherals.set(peripheral.address, device);
|
|
176
|
+
this.emit('discovered', device);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.log.debug(` ${entry.uuid}: ${entry.data.toString('hex')}`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (peripheral.advertisement.manufacturerData && peripheral.advertisement.manufacturerData.length >= 2) {
|
|
185
|
+
const assignedNumber = peripheral.advertisement.manufacturerData.readUInt16LE(0);
|
|
186
|
+
if (assignedNumber === 0x0ba9) {
|
|
187
|
+
const data = decodeShellyManufacturerData(peripheral.advertisement.manufacturerData);
|
|
188
|
+
this.log.debug(` - Shelly Manufacturer Data:`);
|
|
189
|
+
if (data) {
|
|
190
|
+
this.log.debug(` - Flags: ${JSON.stringify(data.flags)}`);
|
|
191
|
+
this.log.debug(` - Model ID: ${data.modelId} short name ${data.modelIdShortName ?? ''} long name ${data.modelIdLongName ?? ''}`);
|
|
192
|
+
this.log.debug(` - MAC: ${data.mac}`);
|
|
193
|
+
if (this.bthomePeripherals.has(peripheral.address)) {
|
|
194
|
+
const device = this.bthomePeripherals.get(peripheral.address);
|
|
195
|
+
device.modelId = data.modelId;
|
|
196
|
+
device.modelIdShortName = data.modelIdShortName;
|
|
197
|
+
device.modelIdLongName = data.modelIdLongName;
|
|
198
|
+
this.emit('update', device);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else if (assignedNumber === 0x004c) {
|
|
203
|
+
this.log.debug(` - Apple Manufacturer Data: ${peripheral.advertisement.manufacturerData.toString('hex')}`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
this.log.debug(` - Manufacturer Data: ${peripheral.advertisement.manufacturerData.toString('hex')}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (peripheral.advertisement.txPowerLevel) {
|
|
210
|
+
this.log.debug(` - TX Power Level: ${peripheral.advertisement.txPowerLevel}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async waitForPoweredOn() {
|
|
214
|
+
if (!this.noble)
|
|
215
|
+
throw new Error('Noble is not loaded');
|
|
216
|
+
if (this.noble.state === 'poweredOn')
|
|
217
|
+
return;
|
|
218
|
+
if (this.noble.state === 'unsupported' || this.noble.state === 'unauthorized')
|
|
219
|
+
throw new Error(`Bluetooth adapter not usable (state=${this.noble.state})`);
|
|
220
|
+
this.log.info(`Bluetooth adapter state is ${this.noble.state}`);
|
|
221
|
+
this.log.info('Waiting 30 seconds for the Bluetooth adapter state to be poweredOn…');
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const onStateChange = (state) => {
|
|
224
|
+
this.log.info(`Bluetooth adapter changed state to ${state}`);
|
|
225
|
+
if (state === 'poweredOn') {
|
|
226
|
+
clearTimeout(timeout);
|
|
227
|
+
this.noble?.removeListener('stateChange', onStateChange);
|
|
228
|
+
resolve();
|
|
229
|
+
}
|
|
230
|
+
else if (state === 'unsupported' || state === 'unauthorized') {
|
|
231
|
+
clearTimeout(timeout);
|
|
232
|
+
this.noble?.removeListener('stateChange', onStateChange);
|
|
233
|
+
reject(new Error(`Bluetooth adapter is not usable (state=${state})`));
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const timeout = setTimeout(() => {
|
|
237
|
+
this.noble?.removeListener('stateChange', onStateChange);
|
|
238
|
+
reject(new Error(`Timeout waiting for the Bluetooth adapter to be powered on (state=${this.noble?.state})`));
|
|
239
|
+
}, 30000);
|
|
240
|
+
this.noble?.on('stateChange', onStateChange);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
async start() {
|
|
244
|
+
if (this.isScanning) {
|
|
245
|
+
this.log.warn('BLE scan already started');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
this.noble = await import('@stoprocent/noble')
|
|
249
|
+
.then((noble) => noble.default)
|
|
250
|
+
.catch((err) => {
|
|
251
|
+
this.log.error(`Error loading noble: ${err instanceof Error ? err.message : String(err)}`);
|
|
252
|
+
throw err;
|
|
253
|
+
});
|
|
254
|
+
this.log.info('Checking the Bluetooth adapter state…');
|
|
255
|
+
try {
|
|
256
|
+
await this.waitForPoweredOn();
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
this.log.error(`Adapter error: ${err instanceof Error ? err.message : String(err)}`);
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
this.log.info(`Bluetooth adapter state is ${this.noble.state}`);
|
|
263
|
+
this.log.info('Starting BLE scan…');
|
|
264
|
+
try {
|
|
265
|
+
await this.noble.startScanningAsync([], true);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
this.log.error(`Scan start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
this.noble.on('discover', this.handleDiscovery);
|
|
272
|
+
this.isScanning = true;
|
|
273
|
+
this.log.info('BLE scan started');
|
|
274
|
+
}
|
|
275
|
+
async stop() {
|
|
276
|
+
if (!this.isScanning) {
|
|
277
|
+
this.log.warn('BLE scan already stopped');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (!this.noble) {
|
|
281
|
+
this.log.warn('Noble is not loaded');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
this.log.info('Stopping BLE scan…');
|
|
286
|
+
this.noble.removeListener('discover', this.handleDiscovery);
|
|
287
|
+
await this.noble.stopScanningAsync();
|
|
288
|
+
this.isScanning = false;
|
|
289
|
+
this.log.info('BLE scan stopped');
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
this.log.error(`Error stopping BLE scan: ${err instanceof Error ? err.message : String(err)}`);
|
|
293
|
+
}
|
|
294
|
+
this.noble = undefined;
|
|
295
|
+
this.blePeripherals.clear();
|
|
296
|
+
this.bthomePeripherals.clear();
|
|
297
|
+
}
|
|
298
|
+
logDevices() {
|
|
299
|
+
this.log.debug(`Discovered ${this.bthomePeripherals.size} BTHome devices:`);
|
|
300
|
+
this.bthomePeripherals.forEach((device) => {
|
|
301
|
+
this.log.debug(`- ${device.mac}:`);
|
|
302
|
+
this.log.debug(` - RSSI: ${device.rssi}`);
|
|
303
|
+
this.log.debug(` - Local Name: ${device.localName}`);
|
|
304
|
+
this.log.debug(` - Version: ${device.version}`);
|
|
305
|
+
this.log.debug(` - Encrypted: ${device.encrypted}`);
|
|
306
|
+
this.log.debug(` - Trigger: ${device.trigger}`);
|
|
307
|
+
this.log.debug(` - Packet ID: ${device.packetId}`);
|
|
308
|
+
this.log.debug(` - Last Seen: ${device.lastSeen.toLocaleString()}`);
|
|
309
|
+
this.log.debug(` - Model ID: ${device.modelId} short name ${device.modelIdShortName} long name ${device.modelIdLongName}`);
|
|
310
|
+
this.log.debug(` - Data: ${JSON.stringify(device.data, null, 2)}`);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function getStringArrayParameter(name) {
|
|
315
|
+
const args = process.argv.slice(2);
|
|
316
|
+
const idx = args.indexOf(`--${name}`) || args.indexOf(`-${name}`);
|
|
317
|
+
if (idx < 0)
|
|
318
|
+
return [];
|
|
319
|
+
const values = [];
|
|
320
|
+
for (let i = idx + 1; i < args.length && !args[i].startsWith('-'); i++) {
|
|
321
|
+
values.push(args[i]);
|
|
322
|
+
}
|
|
323
|
+
return values;
|
|
324
|
+
}
|
|
325
|
+
if (process.argv.includes('--scan')) {
|
|
326
|
+
const bthome = new BTHome(hasParameter('ble'), hasParameter('bthome'), hasParameter('shellyble'), hasParameter('address') ? getStringArrayParameter('address') : [], hasParameter('logger') ? process.argv[process.argv.indexOf('--logger') + 1] : "debug");
|
|
327
|
+
process.on('SIGINT', async () => {
|
|
328
|
+
bthome.logDevices();
|
|
329
|
+
await bthome.stop();
|
|
330
|
+
process.exit(0);
|
|
331
|
+
});
|
|
332
|
+
process.on('SIGTERM', async () => {
|
|
333
|
+
bthome.logDevices();
|
|
334
|
+
await bthome.stop();
|
|
335
|
+
process.exit(0);
|
|
336
|
+
});
|
|
337
|
+
process.on('uncaughtException', async (error) => {
|
|
338
|
+
bthome.log.error('BTHome uncaught Exception:', error);
|
|
339
|
+
await bthome.stop();
|
|
340
|
+
});
|
|
341
|
+
process.on('unhandledRejection', async (reason) => {
|
|
342
|
+
bthome.log.error('BTHome unhandled Rejection:', reason);
|
|
343
|
+
await bthome.stop();
|
|
344
|
+
});
|
|
345
|
+
bthome.start().catch((error) => {
|
|
346
|
+
bthome.log.error('BTHome error starting BTHome discovery:', error);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { BTHOME_SPEC } from './BTHomeSpec.js';
|
|
2
|
+
export function decodeBTHome(buf) {
|
|
3
|
+
const info = buf.readUInt8(0);
|
|
4
|
+
const version = (info >> 5) & 0b00000111;
|
|
5
|
+
const encrypted = Boolean(info & 0b00000001);
|
|
6
|
+
const trigger = Boolean(info & 0b00000100);
|
|
7
|
+
const readings = {};
|
|
8
|
+
const unknown = [];
|
|
9
|
+
let offset = 1;
|
|
10
|
+
const ids = [];
|
|
11
|
+
while (offset < buf.length) {
|
|
12
|
+
const id = buf.readUInt8(offset++);
|
|
13
|
+
ids.push(id);
|
|
14
|
+
const spec = BTHOME_SPEC[id];
|
|
15
|
+
if (!spec) {
|
|
16
|
+
unknown.push(`0x${id.toString(16)} → 0x${buf.slice(offset - 1).toString('hex')}`);
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
let value;
|
|
20
|
+
if (spec.parser) {
|
|
21
|
+
value = spec.parser(buf, offset);
|
|
22
|
+
if (spec.bytes != null) {
|
|
23
|
+
offset += spec.bytes;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const len = buf.readUInt8(offset);
|
|
27
|
+
offset += 1 + len;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
if (spec.bytes == null) {
|
|
32
|
+
throw new Error(`BTHome spec for ${spec.name} is missing 'bytes'`);
|
|
33
|
+
}
|
|
34
|
+
const bytes = spec.bytes;
|
|
35
|
+
const factor = spec.factor ?? 1;
|
|
36
|
+
const raw = spec.signed ? buf.readIntLE(offset, bytes) : buf.readUIntLE(offset, bytes);
|
|
37
|
+
value = parseFloat((raw * factor).toFixed(Math.log10(1 / factor)));
|
|
38
|
+
offset += bytes;
|
|
39
|
+
}
|
|
40
|
+
const count = ids.filter((existingId) => existingId === id).length;
|
|
41
|
+
let name = spec.name;
|
|
42
|
+
if (count > 1) {
|
|
43
|
+
name = spec.name + `:${count}`;
|
|
44
|
+
if (spec.name in readings) {
|
|
45
|
+
readings[spec.name + ':1'] = readings[spec.name];
|
|
46
|
+
delete readings[spec.name];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
readings[name] = value;
|
|
50
|
+
}
|
|
51
|
+
return { version, encrypted, trigger, readings, unknown };
|
|
52
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const SHELLY_MODEL_LONG_NAMES = {
|
|
2
|
+
0x0001: 'Shelly BLU Button1',
|
|
3
|
+
0x0002: 'Shelly BLU DoorWindow',
|
|
4
|
+
0x0003: 'Shelly BLU HT',
|
|
5
|
+
0x0005: 'Shelly BLU Motion',
|
|
6
|
+
0x0006: 'Shelly BLU Wall Switch 4',
|
|
7
|
+
0x0007: 'Shelly BLU RC Button 4',
|
|
8
|
+
0x0008: 'Shelly BLU TRV',
|
|
9
|
+
};
|
|
10
|
+
const SHELLY_MODEL_SHORT_NAMES = {
|
|
11
|
+
0x0001: 'SBBT-002C',
|
|
12
|
+
0x0002: 'SBDW-002C',
|
|
13
|
+
0x0003: 'SBHT-003C',
|
|
14
|
+
0x0005: 'SBMO-003Z',
|
|
15
|
+
0x0006: 'SBBT-004CEU',
|
|
16
|
+
0x0007: 'SBBT-004CUS',
|
|
17
|
+
0x0008: 'SBTR-001AEU',
|
|
18
|
+
};
|
|
19
|
+
export function getShellyBluLongName(id) {
|
|
20
|
+
return SHELLY_MODEL_LONG_NAMES[id];
|
|
21
|
+
}
|
|
22
|
+
export function getShellyBluShortName(id) {
|
|
23
|
+
return SHELLY_MODEL_SHORT_NAMES[id];
|
|
24
|
+
}
|
|
25
|
+
export function decodeShellyManufacturerData(input) {
|
|
26
|
+
if (input.length < 10)
|
|
27
|
+
return null;
|
|
28
|
+
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input.replace(/\s+/g, ''), 'hex');
|
|
29
|
+
let offset = 0;
|
|
30
|
+
const companyId = buf.readUInt16LE(offset);
|
|
31
|
+
if (companyId !== 0x0ba9)
|
|
32
|
+
return null;
|
|
33
|
+
offset += 2;
|
|
34
|
+
const result = { companyId };
|
|
35
|
+
while (offset < buf.length) {
|
|
36
|
+
const blockType = buf.readUInt8(offset++);
|
|
37
|
+
switch (blockType) {
|
|
38
|
+
case 0x01: {
|
|
39
|
+
const flagsRaw = buf.readUInt16LE(offset);
|
|
40
|
+
offset += 2;
|
|
41
|
+
result.flags = {
|
|
42
|
+
discoverable: Boolean(flagsRaw & (1 << 0)),
|
|
43
|
+
authEnabled: Boolean(flagsRaw & (1 << 1)),
|
|
44
|
+
rpcEnabled: Boolean(flagsRaw & (1 << 2)),
|
|
45
|
+
buzzerEnabled: Boolean(flagsRaw & (1 << 3)),
|
|
46
|
+
inPairingMode: Boolean(flagsRaw & (1 << 4)),
|
|
47
|
+
};
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 0x0b: {
|
|
51
|
+
const modelId = buf.readUInt16LE(offset);
|
|
52
|
+
offset += 2;
|
|
53
|
+
result.modelId = modelId;
|
|
54
|
+
result.modelIdShortName = getShellyBluShortName(modelId);
|
|
55
|
+
result.modelIdLongName = getShellyBluLongName(modelId);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case 0x0a: {
|
|
59
|
+
const macBytes = buf.slice(offset, offset + 6);
|
|
60
|
+
offset += 6;
|
|
61
|
+
result.mac = macBytes.toString('hex').match(/.{2}/g)?.join(':');
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
default:
|
|
65
|
+
offset = buf.length;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export const BTHOME_SPEC = {
|
|
2
|
+
0x00: { name: 'packetId', bytes: 1, signed: false, factor: 1 },
|
|
3
|
+
0x01: { name: 'battery', bytes: 1, signed: false, factor: 1 },
|
|
4
|
+
0x02: { name: 'temperature', bytes: 2, signed: true, factor: 0.01 },
|
|
5
|
+
0x03: { name: 'humidity', bytes: 2, signed: false, factor: 0.01 },
|
|
6
|
+
0x04: { name: 'pressure', bytes: 3, signed: false, factor: 0.01 },
|
|
7
|
+
0x05: { name: 'illuminance', bytes: 3, signed: false, factor: 0.01 },
|
|
8
|
+
0x06: { name: 'massKilograms', bytes: 2, signed: false, factor: 0.01 },
|
|
9
|
+
0x07: { name: 'massPounds', bytes: 2, signed: false, factor: 0.01 },
|
|
10
|
+
0x08: { name: 'dewPoint', bytes: 2, signed: true, factor: 0.01 },
|
|
11
|
+
0x09: { name: 'countSmall', bytes: 1, signed: false, factor: 1 },
|
|
12
|
+
0x0a: { name: 'energy_kWh', bytes: 3, signed: false, factor: 0.001 },
|
|
13
|
+
0x0b: { name: 'power_W', bytes: 3, signed: false, factor: 0.01 },
|
|
14
|
+
0x0c: { name: 'voltage_V', bytes: 2, signed: false, factor: 0.001 },
|
|
15
|
+
0x0d: { name: 'pm2_5_ugm3', bytes: 2, signed: false, factor: 1 },
|
|
16
|
+
0x0e: { name: 'pm10_ugm3', bytes: 2, signed: false, factor: 1 },
|
|
17
|
+
0x13: { name: 'tvoc_ugm3', bytes: 2, signed: false, factor: 1 },
|
|
18
|
+
0x14: { name: 'moisture', bytes: 2, signed: false, factor: 0.01 },
|
|
19
|
+
0x2e: { name: 'humidity', bytes: 1, signed: false, factor: 1 },
|
|
20
|
+
0x2f: { name: 'moisture', bytes: 1, signed: false, factor: 1 },
|
|
21
|
+
0x40: { name: 'distance_mm', bytes: 2, signed: false, factor: 1 },
|
|
22
|
+
0x41: { name: 'distance_m', bytes: 2, signed: false, factor: 0.1 },
|
|
23
|
+
0x42: { name: 'duration_s', bytes: 3, signed: false, factor: 0.001 },
|
|
24
|
+
0x43: { name: 'current_A', bytes: 2, signed: false, factor: 0.001 },
|
|
25
|
+
0x44: { name: 'speed_ms', bytes: 2, signed: false, factor: 0.01 },
|
|
26
|
+
0x45: { name: 'temperature', bytes: 2, signed: true, factor: 0.1 },
|
|
27
|
+
0x46: { name: 'uvIndex', bytes: 1, signed: false, factor: 0.1 },
|
|
28
|
+
0x47: { name: 'volume_L', bytes: 2, signed: false, factor: 0.1 },
|
|
29
|
+
0x48: { name: 'volume_mL', bytes: 2, signed: false, factor: 1 },
|
|
30
|
+
0x49: { name: 'flowRate_m3ph', bytes: 2, signed: false, factor: 0.001 },
|
|
31
|
+
0x4a: { name: 'voltage_alt_V', bytes: 2, signed: false, factor: 0.1 },
|
|
32
|
+
0x4b: { name: 'gas_m3', bytes: 3, signed: false, factor: 0.001 },
|
|
33
|
+
0x4c: { name: 'gas_alt_m3', bytes: 4, signed: false, factor: 0.001 },
|
|
34
|
+
0x4d: { name: 'energy_alt_kWh', bytes: 4, signed: false, factor: 0.001 },
|
|
35
|
+
0x4e: { name: 'volume_alt_L', bytes: 4, signed: false, factor: 0.001 },
|
|
36
|
+
0x4f: { name: 'water_L', bytes: 4, signed: false, factor: 0.001 },
|
|
37
|
+
0x52: { name: 'gyroscope_dps', bytes: 2, signed: false, factor: 0.001 },
|
|
38
|
+
0x53: {
|
|
39
|
+
name: 'text',
|
|
40
|
+
parser(buf, off) {
|
|
41
|
+
return buf.slice(off + 1, off + 1 + buf[off]).toString('utf8');
|
|
42
|
+
},
|
|
43
|
+
bytes: null,
|
|
44
|
+
},
|
|
45
|
+
0x54: {
|
|
46
|
+
name: 'raw',
|
|
47
|
+
parser(buf, off) {
|
|
48
|
+
return buf.slice(off + 1, off + 1 + buf[off]).toString('hex');
|
|
49
|
+
},
|
|
50
|
+
bytes: null,
|
|
51
|
+
},
|
|
52
|
+
0x55: { name: 'volumeStorage_L', bytes: 4, signed: false, factor: 0.001 },
|
|
53
|
+
0x57: { name: 'temperature', bytes: 1, signed: true, factor: 1 },
|
|
54
|
+
0x58: { name: 'temperature', bytes: 1, signed: true, factor: 0.35 },
|
|
55
|
+
0x59: { name: 'count8', bytes: 1, signed: true, factor: 1 },
|
|
56
|
+
0x5a: { name: 'count16', bytes: 2, signed: true, factor: 1 },
|
|
57
|
+
0x5b: { name: 'count32', bytes: 4, signed: true, factor: 1 },
|
|
58
|
+
0x5c: { name: 'power_alt_W', bytes: 4, signed: true, factor: 0.01 },
|
|
59
|
+
0x5d: { name: 'current_alt_A', bytes: 2, signed: true, factor: 0.001 },
|
|
60
|
+
0x5e: { name: 'direction_deg', bytes: 2, signed: false, factor: 0.01 },
|
|
61
|
+
0x5f: { name: 'precipitation_mm', bytes: 2, signed: false, factor: 1 },
|
|
62
|
+
0x0f: { name: 'genericBoolean', bytes: 1, signed: false, factor: 1 },
|
|
63
|
+
0x10: { name: 'powerState', bytes: 1, signed: false, factor: 1 },
|
|
64
|
+
0x11: { name: 'openingState', bytes: 1, signed: false, factor: 1 },
|
|
65
|
+
0x15: { name: 'batteryState', bytes: 1, signed: false, factor: 1 },
|
|
66
|
+
0x16: { name: 'batteryChargingState', bytes: 1, signed: false, factor: 1 },
|
|
67
|
+
0x17: { name: 'carbonMonoxideState', bytes: 1, signed: false, factor: 1 },
|
|
68
|
+
0x18: { name: 'coldState', bytes: 1, signed: false, factor: 1 },
|
|
69
|
+
0x19: { name: 'connectivityState', bytes: 1, signed: false, factor: 1 },
|
|
70
|
+
0x1a: { name: 'doorState', bytes: 1, signed: false, factor: 1 },
|
|
71
|
+
0x1b: { name: 'garageDoorState', bytes: 1, signed: false, factor: 1 },
|
|
72
|
+
0x1c: { name: 'gasState', bytes: 1, signed: false, factor: 1 },
|
|
73
|
+
0x1d: { name: 'heatState', bytes: 1, signed: false, factor: 1 },
|
|
74
|
+
0x1e: { name: 'lightState', bytes: 1, signed: false, factor: 1 },
|
|
75
|
+
0x1f: { name: 'lockState', bytes: 1, signed: false, factor: 1 },
|
|
76
|
+
0x20: { name: 'moistureState', bytes: 1, signed: false, factor: 1 },
|
|
77
|
+
0x21: { name: 'motionState', bytes: 1, signed: false, factor: 1 },
|
|
78
|
+
0x22: { name: 'movingState', bytes: 1, signed: false, factor: 1 },
|
|
79
|
+
0x23: { name: 'occupancyState', bytes: 1, signed: false, factor: 1 },
|
|
80
|
+
0x24: { name: 'plugState', bytes: 1, signed: false, factor: 1 },
|
|
81
|
+
0x25: { name: 'presenceState', bytes: 1, signed: false, factor: 1 },
|
|
82
|
+
0x26: { name: 'problemState', bytes: 1, signed: false, factor: 1 },
|
|
83
|
+
0x27: { name: 'runningState', bytes: 1, signed: false, factor: 1 },
|
|
84
|
+
0x28: { name: 'safetyState', bytes: 1, signed: false, factor: 1 },
|
|
85
|
+
0x29: { name: 'smokeState', bytes: 1, signed: false, factor: 1 },
|
|
86
|
+
0x2a: { name: 'soundState', bytes: 1, signed: false, factor: 1 },
|
|
87
|
+
0x2b: { name: 'tamperState', bytes: 1, signed: false, factor: 1 },
|
|
88
|
+
0x2c: { name: 'vibrationState', bytes: 1, signed: false, factor: 1 },
|
|
89
|
+
0x2d: { name: 'windowState', bytes: 1, signed: false, factor: 1 },
|
|
90
|
+
0x3a: {
|
|
91
|
+
name: 'button',
|
|
92
|
+
bytes: 1,
|
|
93
|
+
signed: false,
|
|
94
|
+
factor: 1,
|
|
95
|
+
parser(buf, off) {
|
|
96
|
+
const code = buf.readUInt8(off);
|
|
97
|
+
const EVENT_MAP = {
|
|
98
|
+
0x00: 'none',
|
|
99
|
+
0x01: 'single_press',
|
|
100
|
+
0x02: 'double_press',
|
|
101
|
+
0x03: 'triple_press',
|
|
102
|
+
0x04: 'long_press',
|
|
103
|
+
0x05: 'long_double_press',
|
|
104
|
+
0x06: 'long_triple_press',
|
|
105
|
+
0x80: 'hold_press',
|
|
106
|
+
0xfe: 'hold_press',
|
|
107
|
+
};
|
|
108
|
+
return EVENT_MAP[code] ?? 'unknown';
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
0x3c: {
|
|
112
|
+
name: 'dimmerEvent',
|
|
113
|
+
bytes: 2,
|
|
114
|
+
signed: false,
|
|
115
|
+
factor: 1,
|
|
116
|
+
parser(buf, off) {
|
|
117
|
+
const evt = buf.readUInt8(off);
|
|
118
|
+
const steps = buf.readUInt8(off + 1);
|
|
119
|
+
const map = { 0x00: 'none', 0x01: 'rotateLeft', 0x02: 'rotateRight' };
|
|
120
|
+
return { event: map[evt] || `evt0x${evt.toString(16)}`, steps };
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
0x3f: { name: 'rotation_deg', bytes: 2, signed: true, factor: 0.1 },
|
|
124
|
+
0x50: {
|
|
125
|
+
name: 'timestamp',
|
|
126
|
+
bytes: 4,
|
|
127
|
+
signed: false,
|
|
128
|
+
factor: 1,
|
|
129
|
+
parser(buf, off) {
|
|
130
|
+
return new Date(buf.readUInt32LE(off) * 1000).toISOString();
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
0xf0: { name: 'deviceTypeId', bytes: 2, signed: false, factor: 1 },
|
|
134
|
+
0xf1: {
|
|
135
|
+
name: 'firmwareVersion',
|
|
136
|
+
bytes: 4,
|
|
137
|
+
signed: false,
|
|
138
|
+
factor: 1,
|
|
139
|
+
parser(buf, off) {
|
|
140
|
+
const [rc, patch, minor, major] = buf.slice(off, off + 4);
|
|
141
|
+
return `${major}.${minor}.${patch}.${rc}`;
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
0xf2: {
|
|
145
|
+
name: 'firmwareVersionShort',
|
|
146
|
+
bytes: 3,
|
|
147
|
+
signed: false,
|
|
148
|
+
factor: 1,
|
|
149
|
+
parser(buf, off) {
|
|
150
|
+
const [minor, patch, major] = buf.slice(off, off + 3);
|
|
151
|
+
return `${major}.${minor}.${patch}`;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
package/dist/index.js
ADDED