homebridge-mi-hygrothermograph
Version:
Homebridge plugin for Xiaomi Mi Bluetooth Temperature and Humidity Sensor
283 lines (243 loc) • 6.62 kB
JavaScript
const crypto = require("crypto");
const SERVICE_DATA_UUID = "fe95";
const FrameControlFlags = {
isFactoryNew: 1 << 0,
isConnected: 1 << 1,
isCentral: 1 << 2,
isEncrypted: 1 << 3,
hasMacAddress: 1 << 4,
hasCapabilities: 1 << 5,
hasEvent: 1 << 6,
hasCustomData: 1 << 7,
hasSubtitle: 1 << 8,
hasBinding: 1 << 9,
};
const CapabilityFlags = {
connectable: 1 << 0,
central: 1 << 1,
secure: 1 << 2,
io: (1 << 3) | (1 << 4),
};
const EventTypes = {
temperature: 4100,
humidity: 4102,
illuminance: 4103,
moisture: 4104,
fertility: 4105,
battery: 4106,
temperatureAndHumidity: 4109,
};
class Parser {
constructor(buffer, bindKey = null) {
this.baseByteLength = 5;
if (buffer == null) {
throw new Error("A buffer must be provided.");
}
this.buffer = buffer;
if (buffer.length < this.baseByteLength) {
throw new Error(
`Service data length must be >= 5 bytes. ${this.toString()}`
);
}
this.bindKey = bindKey;
}
parse() {
this.frameControl = this.parseFrameControl();
this.version = this.parseVersion();
this.productId = this.parseProductId();
this.frameCounter = this.parseFrameCounter();
this.macAddress = this.parseMacAddress();
this.capabilities = this.parseCapabilities();
if (this.frameControl.isEncrypted) {
this.decryptPayload();
}
this.eventType = this.parseEventType();
this.eventLength = this.parseEventLength();
this.event = this.parseEventData();
return {
frameControl: this.frameControl,
event: this.event,
productId: this.productId,
frameCounter: this.frameCounter,
macAddress: this.macAddress,
eventType: this.eventType,
capabilities: this.capabilities,
eventLength: this.eventLength,
version: this.version,
};
}
parseFrameControl() {
const frameControl = this.buffer.readUInt16LE(0);
return Object.keys(FrameControlFlags).reduce((map, flag) => {
map[flag] = (frameControl & FrameControlFlags[flag]) !== 0;
return map;
}, {});
}
parseVersion() {
return this.buffer.readUInt8(1) >> 4;
}
parseProductId() {
return this.buffer.readUInt16LE(2);
}
parseFrameCounter() {
return this.buffer.readUInt8(4);
}
parseMacAddress() {
if (!this.frameControl.hasMacAddress) {
return null;
}
const macBuffer = this.buffer.slice(
this.baseByteLength,
this.baseByteLength + 6
);
return Buffer.from(macBuffer).reverse().toString("hex");
}
get capabilityOffset() {
if (!this.frameControl.hasMacAddress) {
return this.baseByteLength;
}
return 11;
}
parseCapabilities() {
if (!this.frameControl.hasCapabilities) {
return null;
}
const capabilities = this.buffer.readUInt8(this.capabilityOffset);
return Object.keys(CapabilityFlags).reduce((map, flag) => {
map[flag] = (capabilities & CapabilityFlags[flag]) !== 0;
return map;
}, {});
}
get eventOffset() {
let offset = this.baseByteLength;
if (this.frameControl.hasMacAddress) {
offset = 11;
}
if (this.frameControl.hasCapabilities) {
offset += 1;
}
return offset;
}
parseEventType() {
if (!this.frameControl.hasEvent) {
return null;
}
return this.buffer.readUInt16LE(this.eventOffset);
}
parseEventLength() {
if (!this.frameControl.hasEvent) {
return null;
}
return this.buffer.readUInt8(this.eventOffset + 2);
}
decryptPayload() {
const msgLength = this.buffer.length;
const eventLength = msgLength - this.eventOffset;
if (eventLength < 3) {
return;
}
if (this.bindKey == null) {
throw Error("Sensor data is encrypted. Please configure a bindKey.");
}
const encryptedPayload = this.buffer.slice(this.eventOffset, msgLength);
const nonce = Buffer.concat([
this.buffer.slice(5, 11), //mac_reversed
this.buffer.slice(2, 4), //device_type
this.buffer.slice(4, 5), //frame_cnt
encryptedPayload.slice(-7, -4), //ext_cnt
]);
const decipher = crypto.createDecipheriv(
"aes-128-ccm",
Buffer.from(this.bindKey, "hex"), //key
nonce, //iv
{ authTagLength: 4 }
);
const ciphertext = encryptedPayload.slice(0, -7);
decipher.setAuthTag(encryptedPayload.slice(-4));
decipher.setAAD(Buffer.from("11", "hex"), {
plaintextLength: ciphertext.length,
});
const receivedPlaintext = decipher.update(ciphertext);
decipher.final();
this.buffer = Buffer.concat([
this.buffer.slice(0, this.eventOffset),
receivedPlaintext,
]);
}
parseEventData() {
if (!this.frameControl.hasEvent) {
return null;
}
switch (this.eventType) {
case EventTypes.temperature: {
return this.parseTemperatureEvent();
}
case EventTypes.humidity: {
return this.parseHumidityEvent();
}
case EventTypes.battery: {
return this.parseBatteryEvent();
}
case EventTypes.temperatureAndHumidity: {
return this.parseTemperatureAndHumidityEvent();
}
case EventTypes.illuminance: {
return this.parseIlluminanceEvent();
}
case EventTypes.fertility: {
return this.parseFertilityEvent();
}
case EventTypes.moisture: {
return this.parseMoistureEvent();
}
default: {
throw new Error(
`Unknown event type: ${this.eventType}. ${this.toString()}`
);
}
}
}
parseTemperatureEvent() {
return {
temperature: this.buffer.readInt16LE(this.eventOffset + 3) / 10,
};
}
parseHumidityEvent() {
return {
humidity: this.buffer.readUInt16LE(this.eventOffset + 3) / 10,
};
}
parseBatteryEvent() {
return {
battery: this.buffer.readUInt8(this.eventOffset + 3),
};
}
parseTemperatureAndHumidityEvent() {
const temperature = this.buffer.readInt16LE(this.eventOffset + 3) / 10;
const humidity = this.buffer.readUInt16LE(this.eventOffset + 5) / 10;
return { temperature, humidity };
}
parseIlluminanceEvent() {
return {
illuminance: this.buffer.readUIntLE(this.eventOffset + 3, 3),
};
}
parseFertilityEvent() {
return {
fertility: this.buffer.readInt16LE(this.eventOffset + 3),
};
}
parseMoistureEvent() {
return {
moisture: this.buffer.readInt8(this.eventOffset + 3),
};
}
toString() {
return this.buffer.toString("hex");
}
}
module.exports = {
Parser,
EventTypes,
SERVICE_DATA_UUID,
};