From 0be912b319b3571183a71417a27bd33c0f3627a8 Mon Sep 17 00:00:00 2001 From: Alan Strohm Date: Thu, 7 May 2020 23:58:42 -0700 Subject: [PATCH] feat: add new Xiaomi Mi integration (#184) Kudos to the upstream projects that provided the code for the parser. Closes #176. --- docs/.vuepress/config.js | 3 +- docs/integrations/README.md | 1 + docs/integrations/xiaomi-mi.md | 60 ++++ src/config/definitions/default.ts | 2 + src/integrations/xiaomi-mi/env.ts | 3 + src/integrations/xiaomi-mi/parser.ts | 329 +++++++++++++++++ .../xiaomi-mi/xiaomi-mi.config.ts | 10 + .../xiaomi-mi/xiaomi-mi.module.ts | 16 + .../xiaomi-mi/xiaomi-mi.service.spec.ts | 338 ++++++++++++++++++ .../xiaomi-mi/xiaomi-mi.service.ts | 249 +++++++++++++ 10 files changed, 1010 insertions(+), 1 deletion(-) create mode 100644 docs/integrations/xiaomi-mi.md create mode 100644 src/integrations/xiaomi-mi/env.ts create mode 100644 src/integrations/xiaomi-mi/parser.ts create mode 100644 src/integrations/xiaomi-mi/xiaomi-mi.config.ts create mode 100644 src/integrations/xiaomi-mi/xiaomi-mi.module.ts create mode 100644 src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts create mode 100644 src/integrations/xiaomi-mi/xiaomi-mi.service.ts diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index f55f8d8..7b9e4b7 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -82,7 +82,8 @@ module.exports = { '/integrations/omron-d6t', '/integrations/grid-eye', '/integrations/gpio', - '/integrations/shell' + '/integrations/shell', + '/integrations/xiaomi-mi' ] } ] diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 21eddb1..a1b3cf7 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -29,4 +29,5 @@ Looking for a way to integrate OpenHAB? You can use the [Home Assistant Core](ho | ------------------- | ------------------ | | [GPIO](./gpio.md) | Binary sensors | | [Shell](./shell.md) | Binary sensors | +| [Xiaomi Mi](./xiaomi-mi.md) | Temperature, humidity, etc sensors | diff --git a/docs/integrations/xiaomi-mi.md b/docs/integrations/xiaomi-mi.md new file mode 100644 index 0000000..8b02d6e --- /dev/null +++ b/docs/integrations/xiaomi-mi.md @@ -0,0 +1,60 @@ +# Xiaomi Mi Sensors + +**Integration Key:** `xiaomiMi` + +::: warning + +Using this together with [Bluetooth Classic](./bluetooth-classic) requires multiple Bluetooth adapters. +Using this together with [Bluetooth Low Energy](./bluetooth-low-energy) +requires that the hciDeviceId settings of both integrations are the same value. + +::: + +The Xiaomi Mi integration scans for Bluetooth Low Engery (BLE) advertisements from a variety of Xiaomi sensors. +Sensor readings can then be published to MQTT using the [Home Assistant integration](./home-assistant). + +## Requirements + +This integration has all the same requirements as the [Bluetooth Low Energy](./bluetooth-low-energy) integration. + +## Supported devices + +This integration has been tested with these devices: + +- LYWSD02 + + (rectangular body, E-Ink, broadcasts temperature and humidity, about 20 readings per minute, no battery info) + +- LYWSD03MMC + + (small square body, segment LCD, broadcasts temperature and humidity once in about 10 minutes and battery level once in an hour, advertisements are encrypted, therefore you need to set the key in your configuration, see for instructions the [bindKey](#sensor-options) option) + +## Settings + +| Name | Type | Default | Description | +| ----------------- | --------------------------------- | -------- | ------------------------------- | +| `sensors` | [Sensor options](#sensor-options) | | An array of sensor definitions. | +| `hciDeviceId` | Number | `0` | Bluetooth Device ID (e.g. `0` to use `hci0`). | + +### Sensor Options + +| Name | Type | Default | Description | +| ----------------- | ------ | -------- | ---------------------------------------------------------------------- | +| `name` | string | | A human readable name for the sensor. Will be used in MQTT topics. | +| `address` | string | | MAC address of the device ([Format](#address-format)). | +| `bindKey` | string | | A decryption key for sensors which send [encrypted data](#encryption). | + +### Address Format + +The `address` field is a lowercase MAC address without `:`. This is the same format as a [tag ID](./bluetooth-low-energy#determining-the-ids) in the BLE integration. The BLE integration can also be used to log device IDs to the console. + +## Encryption + +Some Xiaomi sensors encrypted their data (e.g. LYWSD03MMC). To be able to read the data from this sensor one needs to get a hold of the encryption key. For ways to get this key please read this [this FAQ entry](https://github.com/custom-components/sensor.mitemp_bt/blob/master/faq.md#my-sensors-ble-advertisements-are-encrypted-how-can-i-get-the-key) from the [custom-components/sensor.mitemp_bt](https://github.com/custom-components/sensor.mitemp_bt/) repository. Once found, it can be set with the [bindKey](#sensor-options) option. + +## See also + +There are many projects dedicated to these devices. This integration has particularly benefited from these two: + +- [Homebridge plugin](https://github.com/hannseman/homebridge-mi-hygrothermograph). Much of the parser code came from this project. +- [mitemp_bt](https://github.com/custom-components/sensor.mitemp_bt/). One of the better documented projects with extensive device support. diff --git a/src/config/definitions/default.ts b/src/config/definitions/default.ts index 9f72ac5..1adbd0a 100644 --- a/src/config/definitions/default.ts +++ b/src/config/definitions/default.ts @@ -7,6 +7,7 @@ import { GridEyeConfig } from '../../integrations/grid-eye/grid-eye.config'; import { BluetoothClassicConfig } from '../../integrations/bluetooth-classic/bluetooth-classic.config'; import { GpioConfig } from '../../integrations/gpio/gpio.config'; import { ShellConfig } from '../../integrations/shell/shell.config'; +import { XiaomiMiConfig } from '../../integrations/xiaomi-mi/xiaomi-mi.config'; export class AppConfig { global: GlobalConfig = new GlobalConfig(); @@ -17,6 +18,7 @@ export class AppConfig { gridEye: GridEyeConfig = new GridEyeConfig(); gpio: GpioConfig = new GpioConfig(); shell: ShellConfig = new ShellConfig(); + xiaomiMi: XiaomiMiConfig = new XiaomiMiConfig(); homeAssistant: HomeAssistantConfig = new HomeAssistantConfig(); } diff --git a/src/integrations/xiaomi-mi/env.ts b/src/integrations/xiaomi-mi/env.ts new file mode 100644 index 0000000..88a562b --- /dev/null +++ b/src/integrations/xiaomi-mi/env.ts @@ -0,0 +1,3 @@ +import c from 'config'; + +process.env.NOBLE_HCI_DEVICE_ID = c.get('xiaomiMi.hciDeviceId'); diff --git a/src/integrations/xiaomi-mi/parser.ts b/src/integrations/xiaomi-mi/parser.ts new file mode 100644 index 0000000..02e99ce --- /dev/null +++ b/src/integrations/xiaomi-mi/parser.ts @@ -0,0 +1,329 @@ +/** + * This parser was originally ported from: + * + * https://github.com/hannseman/homebridge-mi-hygrothermograph/blob/master/lib/parser.js + */ +import * as crypto from 'crypto'; +export 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 +}; + +class FrameControl { + isFactoryNew: boolean; + isConnected: boolean; + isCentral: boolean; + isEncrypted: boolean; + hasMacAddress: boolean; + hasCapabilities: boolean; + hasEvent: boolean; + hasCustomData: boolean; + hasSubtitle: boolean; + hasBinding: boolean; +} + +const CapabilityFlags = { + connectable: 1 << 0, + central: 1 << 1, + secure: 1 << 2, + io: (1 << 3) | (1 << 4) +}; + +class Capabilities { + connectable: boolean; + central: boolean; + secure: boolean; + io: boolean; +} + +export const EventTypes = { + temperature: 4100, + humidity: 4102, + illuminance: 4103, + moisture: 4104, + fertility: 4105, + battery: 4106, + temperatureAndHumidity: 4109 +}; + +export class Event { + temperature?: number; + humidity?: number; + illuminance?: number; + moisture?: number; + fertility?: number; + battery?: number; +} + +export class ServiceData { + frameControl: FrameControl; + version: number; + productId: number; + frameCounter: number; + macAddress: string; + capabilities: Capabilities; + eventType?: number; + eventLength?: number; + event: Event; +} + +export class Parser { + private buffer: Buffer; + private readonly bindKey?: string; + + private baseByteLength = 5; + private frameControl: FrameControl; + private eventType: number; + + constructor(buffer: Buffer, bindKey: string | null = null) { + 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(): ServiceData { + this.frameControl = this.parseFrameControl(); + const version = this.parseVersion(); + const productId = this.parseProductId(); + const frameCounter = this.parseFrameCounter(); + const macAddress = this.parseMacAddress(); + const capabilities = this.parseCapabilities(); + + if (this.frameControl.isEncrypted) { + this.decryptPayload(); + } + + this.eventType = this.parseEventType(); + const eventLength = this.parseEventLength(); + const event = this.parseEventData(); + return { + frameControl: this.frameControl, + version, + productId, + frameCounter, + macAddress, + capabilities, + eventType: this.eventType, + eventLength, + event + }; + } + + parseFrameControl(): FrameControl { + const frameControl = this.buffer.readUInt16LE(0); + return Object.keys(FrameControlFlags).reduce((map, flag) => { + map[flag] = (frameControl & FrameControlFlags[flag]) !== 0; + return map; + }, new FrameControl()); + } + + parseVersion(): number { + return this.buffer.readUInt8(1) >> 4; + } + + parseProductId(): number { + return this.buffer.readUInt16LE(2); + } + + parseFrameCounter(): number { + return this.buffer.readUInt8(4); + } + + parseMacAddress(): string { + 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(): number { + if (!this.frameControl.hasMacAddress) { + return this.baseByteLength; + } + return 11; + } + + parseCapabilities(): Capabilities { + 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; + }, new Capabilities()); + } + + get eventOffset(): number { + let offset = this.baseByteLength; + if (this.frameControl.hasMacAddress) { + offset = 11; + } + if (this.frameControl.hasCapabilities) { + offset += 1; + } + + return offset; + } + + parseEventType(): number | null { + if (!this.frameControl.hasEvent) { + return null; + } + return this.buffer.readUInt16LE(this.eventOffset); + } + + parseEventLength(): number | null { + if (!this.frameControl.hasEvent) { + return null; + } + return this.buffer.readUInt8(this.eventOffset + 2); + } + + decryptPayload(): void { + 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(): Event | null { + 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(): Event { + return { + temperature: this.buffer.readInt16LE(this.eventOffset + 3) / 10 + }; + } + + parseHumidityEvent(): Event { + return { + humidity: this.buffer.readUInt16LE(this.eventOffset + 3) / 10 + }; + } + + parseBatteryEvent(): Event { + return { + battery: this.buffer.readUInt8(this.eventOffset + 3) + }; + } + + parseTemperatureAndHumidityEvent(): Event { + const temperature = this.buffer.readInt16LE(this.eventOffset + 3) / 10; + const humidity = this.buffer.readUInt16LE(this.eventOffset + 5) / 10; + return { temperature, humidity }; + } + + parseIlluminanceEvent(): Event { + return { + illuminance: this.buffer.readUIntLE(this.eventOffset + 3, 3) + }; + } + + parseFertilityEvent(): Event { + return { + fertility: this.buffer.readInt16LE(this.eventOffset + 3) + }; + } + + parseMoistureEvent(): Event { + return { + moisture: this.buffer.readInt8(this.eventOffset + 3) + }; + } + + toString(): string { + return this.buffer.toString('hex'); + } +} diff --git a/src/integrations/xiaomi-mi/xiaomi-mi.config.ts b/src/integrations/xiaomi-mi/xiaomi-mi.config.ts new file mode 100644 index 0000000..651332b --- /dev/null +++ b/src/integrations/xiaomi-mi/xiaomi-mi.config.ts @@ -0,0 +1,10 @@ +export class XiaomiMiConfig { + hciDeviceId = 0; + sensors: XiaomiMiSensorOptions[] = []; +} + +export class XiaomiMiSensorOptions { + name: string; + address: string; + bindKey?: string; +} diff --git a/src/integrations/xiaomi-mi/xiaomi-mi.module.ts b/src/integrations/xiaomi-mi/xiaomi-mi.module.ts new file mode 100644 index 0000000..fca5af1 --- /dev/null +++ b/src/integrations/xiaomi-mi/xiaomi-mi.module.ts @@ -0,0 +1,16 @@ +import './env'; +import { DynamicModule, Module } from '@nestjs/common'; +import { XiaomiMiService } from './xiaomi-mi.service'; +import { EntitiesModule } from '../../entities/entities.module'; +import { ConfigModule } from '../../config/config.module'; + +@Module({}) +export default class XiaomiMiModule { + static forRoot(): DynamicModule { + return { + module: XiaomiMiModule, + imports: [EntitiesModule, ConfigModule], + providers: [XiaomiMiService] + }; + } +} diff --git a/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts b/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts new file mode 100644 index 0000000..b8b9f02 --- /dev/null +++ b/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts @@ -0,0 +1,338 @@ +const mockNoble = { + on: jest.fn() +}; +jest.mock( + '@abandonware/noble', + () => { + return mockNoble; + }, + { virtual: true } +); + +import { Peripheral } from '@abandonware/noble'; +import { ConfigService } from '../../config/config.service'; +import { Test, TestingModule } from '@nestjs/testing'; +import { XiaomiMiService } from './xiaomi-mi.service'; +import { EntitiesModule } from '../../entities/entities.module'; +import { ConfigModule } from '../../config/config.module'; +import { ClusterService } from '../../cluster/cluster.service'; +import { EntitiesService } from '../../entities/entities.service'; +import { XiaomiMiConfig } from './xiaomi-mi.config'; +import { Sensor } from '../../entities/sensor'; +import { SensorConfig } from '../home-assistant/sensor-config'; +import c from 'config'; + +describe('XiaomiMiService', () => { + let service: XiaomiMiService; + const entitiesService = { + get: jest.fn(), + add: jest.fn() + }; + const mockConfig: Partial = { + sensors: [] + }; + const configService = { + get: jest.fn().mockImplementation((key: string) => { + return key === 'xiaomiMi' ? mockConfig : c.get(key); + }) + }; + const loggerService = { + log: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn() + }; + + function advert(address: string, serviceData: string): Peripheral { + return { + id: address, + advertisement: { + serviceData: [{ uuid: 'fe95', data: Buffer.from(serviceData, 'hex') }] + } + } as Peripheral; + } + + // Some of this test data was ported from + // https://github.com/hannseman/homebridge-mi-hygrothermograph/blob/master/test/parser.test.js + const testAddress = '4c65a8d0ae64'; + const serviceData = { + temperature: '70205b044c64aed0a8654c09041002cc00', + humidity: '70205b044964aed0a8654c09061002ea01', + temperatureAndHumidity: '5020aa01b064aed0a8654c0d1004d9006001', + negativeTemperature: '5020aa01a664aed0a8654c04100285ff', + battery: '5020aa014e64aed0a8654c0a10015d', + moisture: '71209800a864aed0a8654c0d08100112', + moistureNoMac: '60209800a80d08100112', + illuminance: '71209800a764aed0a8654c0d0710030e0000', + fertility: '71209800a564aed0a8654c0d091002b800', + encrypted: '58585b05db184bf838c1a472c3fa42cd050000ce7b8a28' + }; + const bindKey = 'b2d46f0cd168c18b247c0c79e9ad5b8d'; + + beforeEach(async () => { + jest.clearAllMocks(); + mockConfig.sensors = [{ name: 'test', address: testAddress }]; + + const module: TestingModule = await Test.createTestingModule({ + imports: [EntitiesModule, ConfigModule], + providers: [XiaomiMiService] + }) + .overrideProvider(EntitiesService) + .useValue(entitiesService) + .overrideProvider(ConfigService) + .useValue(configService) + .overrideProvider(ClusterService) + .useValue({}) + .compile(); + module.useLogger(loggerService); + + service = module.get(XiaomiMiService); + service.onModuleInit(); + }); + + it('should setup noble listeners on bootstrap', () => { + service.onApplicationBootstrap(); + expect(mockNoble.on).toHaveBeenCalledWith( + 'stateChange', + expect.any(Function) + ); + expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); + expect(mockNoble.on).toHaveBeenCalledWith('warning', expect.any(Function)); + }); + + it('should warn if no sensors have been configured', () => { + expect(loggerService.warn).not.toHaveBeenCalled(); + + mockConfig.sensors = []; + service.onModuleInit(); + expect(loggerService.warn).toHaveBeenCalled(); + }); + + it('should not publish from unknown devices', () => { + mockConfig.sensors = [{ name: 'test', address: 'cba987654321' }]; + service.onModuleInit(); + service.handleDiscovery(advert(testAddress, serviceData.temperature)); + expect(entitiesService.get).not.toHaveBeenCalled(); + expect(entitiesService.add).not.toHaveBeenCalled(); + }); + + it('should warn if device not Xiaomi', () => { + service.handleDiscovery({ + id: testAddress, + advertisement: { + serviceData: [ + { + uuid: 'bad', + data: Buffer.from(serviceData.temperature, 'hex') + } + ] + } + } as Peripheral); + expect(loggerService.warn).toHaveBeenCalled(); + }); + + it('should publish temperature', () => { + const sensor = new Sensor('testid', 'Test'); + entitiesService.add.mockReturnValue(sensor); + + service.handleDiscovery(advert(testAddress, serviceData.temperature)); + + expect(sensor.state).toBe(20.4); + expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ + for: SensorConfig, + overrides: { + deviceClass: 'temperature', + unitOfMeasurement: '°C' + } + }); + }); + + it('should publish humidity', () => { + const sensor = new Sensor('testid', 'Test'); + entitiesService.add.mockReturnValue(sensor); + + service.handleDiscovery(advert(testAddress, serviceData.humidity)); + + expect(sensor.state).toBe(49); + expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ + for: SensorConfig, + overrides: { + deviceClass: 'humidity', + unitOfMeasurement: '%' + } + }); + }); + + it('should publish temperature and humidity', () => { + const temp = new Sensor('temp', 'temp'); + const humidity = new Sensor('humidity', 'humidity'); + entitiesService.add.mockReturnValueOnce(temp).mockReturnValueOnce(humidity); + + service.handleDiscovery( + advert(testAddress, serviceData.temperatureAndHumidity) + ); + + expect(temp.state).toBe(21.7); + expect(humidity.state).toBe(35.2); + expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ + for: SensorConfig, + overrides: { + deviceClass: 'temperature', + unitOfMeasurement: '°C' + } + }); + + expect(entitiesService.add.mock.calls[1][1]).toContainEqual({ + for: SensorConfig, + overrides: { + deviceClass: 'humidity', + unitOfMeasurement: '%' + } + }); + }); + + it('should publish battery', () => { + const sensor = new Sensor('testid', 'Test'); + entitiesService.add.mockReturnValue(sensor); + + service.handleDiscovery(advert(testAddress, serviceData.battery)); + + expect(sensor.state).toBe(93); + expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ + for: SensorConfig, + overrides: { + deviceClass: 'battery', + unitOfMeasurement: '%' + } + }); + }); + + it('should publish moisture', () => { + const sensor = new Sensor('testid', 'Test'); + entitiesService.add.mockReturnValue(sensor); + + service.handleDiscovery(advert(testAddress, serviceData.moisture)); + + expect(sensor.state).toBe(18); + expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ + for: SensorConfig, + overrides: { + deviceClass: 'moisture', + unitOfMeasurement: '%' + } + }); + }); + + it('should publish even if missing mac address', () => { + const sensor = new Sensor('testid', 'Test'); + entitiesService.add.mockReturnValue(sensor); + + service.handleDiscovery(advert(testAddress, serviceData.moistureNoMac)); + + expect(sensor.state).toBe(18); + }); + + it('should publish illuminance', () => { + const sensor = new Sensor('testid', 'Test'); + entitiesService.add.mockReturnValue(sensor); + + service.handleDiscovery(advert(testAddress, serviceData.illuminance)); + + expect(sensor.state).toBe(14); + expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ + for: SensorConfig, + overrides: { + deviceClass: 'illuminance', + unitOfMeasurement: 'lx' + } + }); + }); + + it('should publish fertility', () => { + const sensor = new Sensor('testid', 'Test'); + entitiesService.add.mockReturnValue(sensor); + + service.handleDiscovery(advert(testAddress, serviceData.fertility)); + + expect(sensor.state).toBe(184); + expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ + for: SensorConfig, + overrides: { + deviceClass: 'fertility', + unitOfMeasurement: '' + } + }); + }); + + it('should reuse existing entities', () => { + const sensor = new Sensor('testid', 'Test'); + entitiesService.get.mockReturnValueOnce(sensor); + + service.handleDiscovery(advert(testAddress, serviceData.humidity)); + + expect(sensor.state).toBe(49); + expect(entitiesService.add).not.toHaveBeenCalled(); + }); + + it('should ignore advertisements with no event', () => { + service.handleDiscovery(advert(testAddress, '30585b05a064aed0a8654c08')); + + expect(entitiesService.add).not.toHaveBeenCalled(); + expect(entitiesService.get).not.toHaveBeenCalled(); + }); + + it('should decrypt advertisements', () => { + mockConfig.sensors = [ + { + name: 'test', + address: testAddress, + bindKey: bindKey + } + ]; + service.onModuleInit(); + const sensor = new Sensor('testid', 'Test'); + entitiesService.get.mockReturnValueOnce(sensor); + + service.handleDiscovery(advert(testAddress, serviceData.encrypted)); + + expect(sensor.state).toBe(43.9); + }); + + it('should warn on missing bindKey for encrypted payloads', () => { + service.handleDiscovery(advert(testAddress, serviceData.encrypted)); + + expect(entitiesService.get).not.toHaveBeenCalled(); + expect(loggerService.error).toHaveBeenCalled(); + }); + + it('should report an error on short advertisements', () => { + service.handleDiscovery(advert(testAddress, '5020')); + expect(loggerService.error).toHaveBeenCalled(); + }); + + it('should warn on empty buffers', () => { + service.handleDiscovery({ + id: testAddress, + advertisement: { + serviceData: [{ uuid: 'fe95', data: null }] + } + } as Peripheral); + expect(loggerService.warn).toHaveBeenCalled(); + }); + + it('should report an error on invalid event types', () => { + service.handleDiscovery( + advert(testAddress, '5020aa014e64aed0a8654c0a11015d') + ); + expect(loggerService.error).toHaveBeenCalled(); + }); + + it('should publish negative temperatures', () => { + const sensor = new Sensor('testid', 'Test'); + entitiesService.add.mockReturnValue(sensor); + service.handleDiscovery( + advert(testAddress, serviceData.negativeTemperature) + ); + expect(sensor.state).toBe(-12.3); + }); +}); diff --git a/src/integrations/xiaomi-mi/xiaomi-mi.service.ts b/src/integrations/xiaomi-mi/xiaomi-mi.service.ts new file mode 100644 index 0000000..d29c76c --- /dev/null +++ b/src/integrations/xiaomi-mi/xiaomi-mi.service.ts @@ -0,0 +1,249 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnModuleInit +} from '@nestjs/common'; +import noble, { Peripheral, Advertisement } from '@abandonware/noble'; +import { EntitiesService } from '../../entities/entities.service'; +import { ConfigService } from '../../config/config.service'; +import { XiaomiMiSensorOptions } from './xiaomi-mi.config'; +import { makeId } from '../../util/id'; +import { SERVICE_DATA_UUID, ServiceData, Parser, EventTypes } from './parser'; +import { Sensor } from '../../entities/sensor'; +import { EntityCustomization } from '../../entities/entity-customization.interface'; +import { SensorConfig } from '../home-assistant/sensor-config'; + +@Injectable() +export class XiaomiMiService implements OnModuleInit, OnApplicationBootstrap { + private config: { [address: string]: XiaomiMiSensorOptions } = {}; + private readonly logger: Logger; + + constructor( + private readonly entitiesService: EntitiesService, + private readonly configService: ConfigService + ) { + this.logger = new Logger(XiaomiMiService.name); + } + + /** + * Lifecycle hook, called once the host module has been initialized. + */ + onModuleInit(): void { + this.config = {}; + this.configService.get('xiaomiMi').sensors.forEach(options => { + this.config[options.address] = options; + }); + if (!this.hasSensors()) { + this.logger.warn( + 'No sensors entries in the config, so no sensors will be created! ' + + 'Enable the bluetooth-low-energy integration to log discovered IDs.' + ); + } + } + + /** + * Lifecycle hook, called once the application has started. + */ + onApplicationBootstrap(): void { + noble.on('stateChange', XiaomiMiService.handleStateChange); + noble.on('discover', this.handleDiscovery.bind(this)); + noble.on('warning', this.onWarning.bind(this)); + } + + /** + * Log warnings from noble. + */ + private onWarning(message: string): void { + this.logger.warn('Warning: ', message); + } + + /** + * Determines whether we have any sensors. + * + * @returns Sensors status + */ + private hasSensors(): boolean { + return Object.keys(this.config).length > 0; + } + + /** + * Record a measurement. + * + * @param devName - The name of the device that took the measurement. + * @param kind - The kind of measurement. + * @param units - The units of the measurement. + * @param state - The current measurement. + */ + private recordMeasure( + devName: string, + kind: string, + units: string, + state: number | string + ): void { + this.logger.debug(`${devName}: ${kind}: ${state}${units}`); + const sensorName = `${devName} ${kind}`; + const id = makeId(sensorName); + let entity = this.entitiesService.get(id); + if (!entity) { + const customizations: Array> = [ + { + for: SensorConfig, + overrides: { + deviceClass: kind, + unitOfMeasurement: units + } + } + ]; + entity = this.entitiesService.add( + new Sensor(id, sensorName), + customizations + ) as Sensor; + } + entity.state = state; + } + + /** + * Filters found BLE peripherals and publishes new readings to sensors, depending on configuration. + * + * @param peripheral - BLE peripheral + */ + handleDiscovery(peripheral: Peripheral): void { + const { advertisement, id } = peripheral || {}; + const options = this.config[id]; + if (!options) { + return; + } + const buffer = XiaomiMiService.getValidServiceData(advertisement); + if (!buffer) { + this.logger.warn( + `${ + options.name + } does not appear to be a Xiaomi device. Got advertisement ${JSON.stringify( + advertisement + )}` + ); + return; + } + let serviceData: ServiceData | null = null; + try { + serviceData = XiaomiMiService.parseServiceData(buffer, options.bindKey); + } catch (error) { + this.logger.error( + `${options.name}: couldn't parse service data: ${error}` + ); + return; + } + if (!serviceData.frameControl.hasEvent) { + this.logger.debug( + `${options.name}: advertisement with no event: ${buffer.toString( + 'hex' + )}` + ); + return; + } + const event = serviceData.event; + switch (serviceData.eventType) { + case EventTypes.temperature: { + this.recordMeasure( + options.name, + 'temperature', + '°C', + event.temperature + ); + break; + } + case EventTypes.humidity: { + this.recordMeasure(options.name, 'humidity', '%', event.humidity); + break; + } + case EventTypes.battery: { + this.recordMeasure(options.name, 'battery', '%', event.battery); + break; + } + case EventTypes.temperatureAndHumidity: { + this.recordMeasure( + options.name, + 'temperature', + '°C', + event.temperature + ); + this.recordMeasure(options.name, 'humidity', '%', event.humidity); + break; + } + case EventTypes.illuminance: { + this.recordMeasure( + options.name, + 'illuminance', + 'lx', + event.illuminance + ); + break; + } + case EventTypes.moisture: { + this.recordMeasure(options.name, 'moisture', '%', event.moisture); + break; + } + case EventTypes.fertility: { + this.recordMeasure(options.name, 'fertility', '', event.fertility); + break; + } + default: { + this.logger.error( + `${options.name}: unknown event type: ${serviceData.eventType}, ` + + `raw data: ${buffer.toString('hex')}` + ); + break; + } + } + } + + /** + * Extract service data. + * + * @returns The service data buffer for the Xiamoi service data UUID or null + * if it doesn't exist. + */ + private static getValidServiceData( + advertisement: Advertisement + ): Buffer | null { + if (!advertisement || !advertisement.serviceData) { + return null; + } + const uuidPair = advertisement.serviceData.find( + data => data.uuid.toLowerCase() === SERVICE_DATA_UUID + ); + if (!uuidPair) { + return null; + } + return uuidPair.data; + } + + /** + * Parses the service data buffer. + * + * @param buffer - The servie data buffer. + * @param bindKey - An optional bindKey for use in decripting the payload. + * + * @returns A ServiceData object. + */ + private static parseServiceData( + buffer: Buffer, + bindKey: string | null + ): ServiceData { + return new Parser(buffer, bindKey).parse(); + } + + /** + * Stops or starts BLE scans based on the adapter state. + * + * @param state - Noble adapter state string + */ + private static handleStateChange(state: string): void { + if (state === 'poweredOn') { + noble.startScanning([], true); + } else { + noble.stopScanning(); + } + } +}