xmihome
Version:
The core library for interacting with Xiaomi Mi Home devices via Cloud, MiIO, and Bluetooth.
317 lines (290 loc) • 11.9 kB
JavaScript
import crypto from 'crypto';
import { devices } from 'xmihome-devices/mibeacon.js';
/** @import Bluetooth from './bluetooth.js' */
/**
* @typedef {Object} FrameControl
* @property {boolean} isEncrypted Зашифрован ли пакет.
* @property {boolean} hasMac Содержит ли пакет MAC-адрес.
* @property {boolean} hasCapabilities Содержит ли пакет байт возможностей.
* @property {boolean} hasObject Содержит ли пакет полезную нагрузку (данные).
* @property {boolean} isMesh Является ли устройство Mesh-узлом.
* @property {boolean} isRegistered Зарегистрировано ли устройство.
* @property {boolean} isSolicited Требует ли устройство привязки (solicited).
* @property {number} authMode Режим аутентификации (0, 1, 2).
* @property {number} version Версия протокола MiBeacon (2-5).
*/
/**
* @typedef {Object} MiBeaconResult
* @property {number} ts Метка времени получения пакета.
* @property {string} id Device ID в hex формате (например, '0x03bc').
* @property {string} mac MAC-адрес устройства.
* @property {string} uuid UUID сервиса.
* @property {string} type Название модели устройства.
* @property {FrameControl} frameControl Флаги заголовка пакета.
* @property {string} firmware Версия протокола.
* @property {Object} [payload] Распаршенные данные (температура, влажность и т.д.).
* @property {string[]} [objectIds] Список ID объектов, найденных в пакете (например, ['0x1004', '0x1006']).
*/
/**
* Класс-парсер для рекламных пакетов Xiaomi MiBeacon.
* Порт: https://github.com/custom-components/ble_monitor/blob/master/custom_components/ble_monitor/ble_parser/xiaomi.py
*/
export default class MiBeacon {
/** @type {Bluetooth} */ #bluetooth;
/** @type {string} */ #mac;
/** @type {Buffer|undefined} */ #key;
/** @type {string} */ #uuid;
/** @type {Buffer} */ #data;
/**
* Конструктор класса MiBeacon.
* @param {Bluetooth} bluetooth Экземпляр класса Bluetooth.
* @param {string} device Имя устройства из D-Bus (dev_XX_XX...).
* @param {Buffer} data Сырые данные рекламного пакета (Service Data).
* @param {string} uuid UUID сервиса.
*/
constructor(bluetooth, device, data, uuid) {
this.#bluetooth = bluetooth;
this.#mac = device.replace('dev_', '').replace(/_/g, ':');
this.#key = bluetooth.bindKeys.get(this.#mac);
this.#uuid = uuid;
this.#data = data;
};
get client() {
return this.#bluetooth.client;
};
/**
* Выполняет разбор пакета.
* @returns {MiBeaconResult|null} Результат разбора или null, если пакет некорректен.
*/
parse() {
if (this.#data.length < 5)
return null;
const frameCounter = this.#data[4];
const frameControl = this.#parseFrameControl();
if (frameControl.version < 2) {
this.client?.log('debug', `MiBeacon: version ${frameControl.version} not supported.`);
return null;
}
const id = this.#data.readUInt16LE(2);
const type = devices[id];
const result = {
ts: Date.now(),
id: `0x${id.toString(16).padStart(4, '0')}`,
mac: this.#mac,
uuid: this.#uuid,
type, frameControl,
firmware: `MiBeacon v${frameControl.version}`
};
let payloadOffset = 5;
if (frameControl.hasMac) {
if (this.#data.length < payloadOffset + 6)
return null;
payloadOffset += 6;
}
if (frameControl.hasCapabilities) {
payloadOffset += 1;
if (this.#data.length > payloadOffset && (this.#data[payloadOffset - 1] & 0x20))
payloadOffset += 1;
}
if (!frameControl.hasObject)
return null;
let payload = this.#data.subarray(payloadOffset);
if (frameControl.isEncrypted) {
result.firmware += ' encrypted';
if (!this.#key)
return null;
payload = this.#decrypt(payload, id, frameCounter);
}
if (!payload)
return null;
const { objectIds, data } = this.#parseObjects(payload, type) || {};
if (!data)
return null;
return {
...result,
objectIds,
payload: data
};
};
/**
* @returns {FrameControl}
*/
#parseFrameControl() {
const fc = this.#data.readUInt16LE(0);
return {
isEncrypted: !!(fc & (1 << 3)),
hasMac: !!(fc & (1 << 4)),
hasCapabilities: !!(fc & (1 << 5)),
hasObject: !!(fc & (1 << 6)),
isMesh: !!(fc & (1 << 7)),
isRegistered: !!(fc & (1 << 8)),
isSolicited: !!(fc & (1 << 9)),
authMode: (fc >> 10) & 3,
version: fc >> 12,
};
};
/**
* Выполняет расшифровку зашифрованных данных пакета (V4/V5) через AES-128-CCM.
* @param {Buffer} encryptedData Данные для расшифровки.
* @param {number} id Product ID устройства.
* @param {number} frameCounter Счетчик кадров из пакета.
* @returns {Buffer|null} Расшифрованный Buffer или null при ошибке.
*/
#decrypt(encryptedData, id, frameCounter) {
if (encryptedData.length < 7)
return null;
const nonce = Buffer.alloc(12);
const macBuffer = Buffer.from(this.#mac.replace(/:/g, ''), 'hex').reverse();
macBuffer.copy(nonce, 0);
nonce.writeUInt16LE(id, 6);
nonce.writeUInt8(frameCounter, 8);
const counterExt = encryptedData.subarray(encryptedData.length - 7, encryptedData.length - 4);
counterExt.copy(nonce, 9);
const mic = encryptedData.subarray(-4);
const ciphertext = encryptedData.subarray(0, -7);
try {
const decipher = crypto.createDecipheriv('aes-128-ccm', this.#key, nonce, {
authTagLength: 4
});
decipher.setAuthTag(mic);
decipher.setAAD(Buffer.from([0x11]), { plaintextLength: ciphertext.length });
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
} catch (error) {
this.client?.log?.('debug', `Decryption error: ${error.message}`);
return null;
}
};
/**
* Разбирает полезную нагрузку пакета на объекты.
* @param {Buffer} payload Буфер с объектами данных.
* @param {string} type Тип устройства (название модели).
* @returns {{ data: Object, objectIds: string[] } | null}
*/
#parseObjects(payload, type) {
const data = {};
const objectIds = [];
let offset = 0;
while (offset < payload.length) {
if (offset + 3 > payload.length)
break;
const typeId = payload.readUInt16LE(offset);
const len = payload[offset + 2];
if (offset + 3 + len > payload.length)
break;
const objectData = payload.subarray(offset + 3, offset + 3 + len);
offset += 3 + len;
const hexId = `0x${typeId.toString(16).padStart(4, '0')}`;
const parser = MiBeacon.#objectParsers[typeId];
if (parser) {
objectIds.push(hexId);
Object.assign(data, parser(objectData, type));
} else {
this.client?.log('debug', `MiBeacon: Unknown object ${hexId}: ${objectData.toString('hex')}`);
}
}
if (objectIds.length === 0)
return null;
return { objectIds, data };
};
/**
* Парсеры значений.
* @type {Object.<number, (d: Buffer, devType?: string) => Object>}
*/
static #objectParsers = {
0x0003: (d) => ({ motion: d[0], motion_timer: d[0] }),
0x0006: (d) => {
if (d.length !== 5)
return {};
const keyId = d.readUInt32LE(0);
const match = d[4];
return {
fingerprint: match === 0 ? 1 : 0,
key_id: keyId === 0 ? 'admin' : (keyId === 0xFFFFFFFF ? 'unknown' : keyId),
result: match === 0 ? 'match' : 'failed'
};
},
0x0007: (d) => ({ door: d[0] === 0 || d[0] === 2 || d[0] === 4 ? 1 : 0, door_action_id: d[0] }),
0x0008: (d) => ({ armed_away: d[0] ^ 1 }),
0x0010: (d) => ({ toothbrush: d[0] === 0 ? 1 : 0, counter: d.length > 1 ? d[1] : undefined }),
0x000A: (d) => d.length === 2 ? ({ temperature: d.readInt16LE(0) / 100 }) : {},
0x000B: (d) => ({ lock_event: d[0], key_id: d.readUInt32LE(1) }),
0x000F: (d) => {
if (d.length !== 3)
return {};
const val = d.readUInt32LE(0) & 0xFFFFFF;
return { motion: 1, illuminance: val, light: val >= 100 ? 1 : 0 };
},
0x1001: (d) => d.length === 3 ? { button_type: d[0], value: d[1], press_type: d[2] } : {},
0x1004: (d) => ({ temperature: d.readInt16LE(0) / 10 }),
0x1005: (d) => ({ switch: d[0], temperature: d[1] }),
0x1006: (d) => ({ humidity: d.readUInt16LE(0) / 10 }),
0x1007: (d) => {
const illum = d.readUIntLE(0, 3);
return { illuminance: illum, light: illum === 100 ? 1 : 0 };
},
0x1008: (d) => ({ moisture: d[0] }),
0x1009: (d) => ({ conductivity: d.readUInt16LE(0) }),
0x1010: (d) => ({ formaldehyde: d.readUInt16LE(0) / 100 }),
0x1012: (d) => ({ switch: d[0] }),
0x1013: (d) => ({ consumable: d[0] }),
0x1014: (d) => ({ moisture: d[0] }),
0x1015: (d) => ({ smoke: d[0] }),
0x1017: (d) => ({ motion: d.readUInt32LE(0) === 0 ? 1 : 0, no_motion_time: d.readUInt32LE(0) }),
0x1018: (d) => ({ light: d[0] }),
0x1019: (d) => ({ opening: d[0] === 0 ? 1 : 0, status: d[0] }),
0x101B: (d) => ({ motion: d.readUInt32LE(0) === 0 ? 1 : 0 }),
0x100A: (d) => ({ battery: d[0], voltage: 2.2 + (3.1 - 2.2) * (d[0] / 100) }),
0x100D: (d) => ({ temperature: d.readInt16LE(0) / 10, humidity: d.readUInt16LE(2) / 10 }),
0x100E: (d) => ({ lock: (d[0] & 0x01) ^ 1 }),
0x2000: (d) => {
if (d.length !== 5)
return {};
const t1 = d.readInt16LE(0);
const t2 = d.readInt16LE(2);
const body = (3.71934 * Math.pow(10, -11) * Math.exp(0.69314 * t1 / 100) - (1.02801 * Math.pow(10, -8) * Math.exp(0.53871 * t2 / 100)) + 36.413);
return { temperature: body, battery: d[4] };
},
0x3003: (d) => ({ toothbrush: d[0] === 0 ? 1 : 0 }),
0x4801: (d) => ({ temperature: d.readFloatLE(0) }),
0x4802: (d) => ({ humidity: d[0] }),
0x4803: (d) => ({ battery: d[0] }),
0x4804: (d) => ({ opening: d[0] === 1 ? 1 : 0 }),
0x4805: (d) => ({ illuminance: d.readFloatLE(0) }),
0x4806: (d) => ({ moisture: d[0] }),
0x4808: (d) => ({ humidity: d.readFloatLE(0) }),
0x4810: (d) => ({ sleeping: d[0] }),
0x4811: (d) => ({ snoring: d[0] }),
0x4818: (d) => ({ motion: d.readUInt16LE(0) === 0 ? 1 : 0 }),
0x483C: (d) => ({ pressure_state: d[0] }),
0x483D: (d) => ({ pressure_duration: d.readUInt32LE(0) }),
0x484E: (d) => ({ motion: d[0] === 1 ? 1 : 0 }),
0x4A01: (d) => ({ low_battery: d[0] }),
0x4A08: (d) => ({ motion: 1, illuminance: d.readFloatLE(0) }),
0x4A0C: () => ({ button: 'single', switch: 'toggle' }),
0x4A0D: () => ({ button: 'double', switch: 'toggle' }),
0x4A0E: () => ({ button: 'long', switch: 'toggle' }),
0x4A0F: () => ({ opening: 1, status: 'forced' }),
0x4A12: (d) => ({ opening: d[0] === 1 ? 1 : 0 }),
0x4A13: () => ({ button: 'toggle' }),
0x4A1A: () => ({ opening: 1, status: 'not_closed' }),
0x4A1C: (d) => ({ reset: d[0] }),
0x4C01: (d) => ({ temperature: d.readFloatLE(0) }),
0x4C02: (d) => ({ humidity: d[0] }),
0x4C03: (d) => ({ battery: d[0] }),
0x4C08: (d) => ({ humidity: d.readFloatLE(0) }),
0x4C14: (d) => ({ mode: d[0] }),
0x4E01: (d) => ({ low_battery: d[0] }),
0x4E0C: (d) => ({ click: d[0] }),
0x4E16: (d) => ({ bed_occupancy: d[0] === 1 ? 1 : 0 }),
0x5003: (d) => ({ battery: d[0] }),
0x5403: (d) => ({ battery: d[0] }),
0x5601: (d) => ({ low_battery: d[0] }),
0x5A16: (d) => ({ bed_occupancy: d[0] === 1 ? 1 : 0 }),
0x6E16: (d) => {
const data = d.readUInt32LE(1);
const mass = data & 0x7FF;
const impedance = data >> 18;
return { weight: mass / 10, impedance: impedance / 10 };
}
};
};