From 67427d5b718f76d06fd339b38d93c7bd740c51f5 Mon Sep 17 00:00:00 2001 From: PeteBa Date: Sun, 6 Dec 2020 13:48:43 +0000 Subject: [PATCH] feat(xiaomi-mi): add device information (#362) Also makes the entities distributed, so that they use shared entities. This causes the entity IDs to change and will therefore re-create the entities in Home Assistant! --- src/integrations/xiaomi-mi/parser.ts | 15 +++++ .../xiaomi-mi/xiaomi-mi.service.spec.ts | 59 ++++++++++++++----- .../xiaomi-mi/xiaomi-mi.service.ts | 55 ++++++++++++----- 3 files changed, 99 insertions(+), 30 deletions(-) diff --git a/src/integrations/xiaomi-mi/parser.ts b/src/integrations/xiaomi-mi/parser.ts index 3ac2a63..e783591 100644 --- a/src/integrations/xiaomi-mi/parser.ts +++ b/src/integrations/xiaomi-mi/parser.ts @@ -6,6 +6,13 @@ import * as crypto from 'crypto'; export const SERVICE_DATA_UUID = 'fe95'; +const ProductNames = { + 152: 'Mi Flora HHCCJCY01', + 1371: 'Mijia LYWSD03MMC', + 1115: 'Mijia LYWSD02', + 426: 'Miija LYWSDCGQ', +}; + const FrameControlFlags = { isFactoryNew: 1 << 0, isConnected: 1 << 1, @@ -69,6 +76,7 @@ export class ServiceData { frameControl: FrameControl; version: number; productId: number; + productName: string; frameCounter: number; macAddress: string; capabilities: Capabilities; @@ -102,6 +110,7 @@ export class Parser { this.frameControl = this.parseFrameControl(); const version = this.parseVersion(); const productId = this.parseProductId(); + const productName = this.parseProductName(); const frameCounter = this.parseFrameCounter(); const macAddress = this.parseMacAddress(); const capabilities = this.parseCapabilities(); @@ -117,6 +126,7 @@ export class Parser { frameControl: this.frameControl, version, productId, + productName, frameCounter, macAddress, capabilities, @@ -142,6 +152,11 @@ export class Parser { return this.buffer.readUInt16LE(2); } + parseProductName(): string { + const id = this.parseProductId(); + return id in ProductNames ? ProductNames[id] : 'Unknown'; + } + parseFrameCounter(): number { return this.buffer.readUInt8(4); } diff --git a/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts b/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts index 2a9d38c..e19371c 100644 --- a/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts +++ b/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts @@ -71,6 +71,13 @@ describe('XiaomiMiService', () => { encrypted: '58585b05db184bf838c1a472c3fa42cd050000ce7b8a28', }; const bindKey = 'b2d46f0cd168c18b247c0c79e9ad5b8d'; + const deviceInfo = { + identifiers: '4c65a8d0ae64', + manufacturer: 'Xiaomi', + name: 'test', + swVersion: '2', + viaDevice: 'room-assistant-distributed', + }; beforeEach(async () => { jest.clearAllMocks(); @@ -134,15 +141,18 @@ describe('XiaomiMiService', () => { }); it('should publish temperature', () => { - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.add.mockReturnValue(sensor); service.handleDiscovery(advert(testAddress, serviceData.temperature)); + deviceInfo['model'] = 'Mijia LYWSD02'; + expect(sensor.state).toBe(20.4); expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ for: SensorConfig, overrides: { + device: deviceInfo, deviceClass: 'temperature', unitOfMeasurement: '°C', }, @@ -150,15 +160,18 @@ describe('XiaomiMiService', () => { }); it('should publish humidity', () => { - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.add.mockReturnValue(sensor); service.handleDiscovery(advert(testAddress, serviceData.humidity)); + deviceInfo['model'] = 'Mijia LYWSD02'; + expect(sensor.state).toBe(49); expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ for: SensorConfig, overrides: { + device: deviceInfo, deviceClass: 'humidity', unitOfMeasurement: '%', }, @@ -166,27 +179,33 @@ describe('XiaomiMiService', () => { }); it('should publish temperature and humidity', () => { - const temp = new Sensor('temp', 'temp'); - const humidity = new Sensor('humidity', 'humidity'); + const temp = new Sensor('temp', 'temp', true); + const humidity = new Sensor('humidity', 'humidity', true); entitiesService.add.mockReturnValueOnce(temp).mockReturnValueOnce(humidity); service.handleDiscovery( advert(testAddress, serviceData.temperatureAndHumidity) ); + deviceInfo['model'] = 'Miija LYWSDCGQ'; + expect(temp.state).toBe(21.7); expect(humidity.state).toBe(35.2); expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ for: SensorConfig, overrides: { + device: deviceInfo, deviceClass: 'temperature', unitOfMeasurement: '°C', }, }); + deviceInfo['model'] = 'Miija LYWSDCGQ'; + expect(entitiesService.add.mock.calls[1][1]).toContainEqual({ for: SensorConfig, overrides: { + device: deviceInfo, deviceClass: 'humidity', unitOfMeasurement: '%', }, @@ -194,15 +213,18 @@ describe('XiaomiMiService', () => { }); it('should publish battery', () => { - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.add.mockReturnValue(sensor); service.handleDiscovery(advert(testAddress, serviceData.battery)); + deviceInfo['model'] = 'Miija LYWSDCGQ'; + expect(sensor.state).toBe(93); expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ for: SensorConfig, overrides: { + device: deviceInfo, deviceClass: 'battery', unitOfMeasurement: '%', }, @@ -210,23 +232,26 @@ describe('XiaomiMiService', () => { }); it('should publish moisture', () => { - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.add.mockReturnValue(sensor); service.handleDiscovery(advert(testAddress, serviceData.moisture)); + deviceInfo['model'] = 'Mi Flora HHCCJCY01'; + expect(sensor.state).toBe(18); expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ for: SensorConfig, overrides: { - deviceClass: 'humidity', + device: deviceInfo, + deviceClass: undefined, unitOfMeasurement: '%', }, }); }); it('should publish even if missing mac address', () => { - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.add.mockReturnValue(sensor); service.handleDiscovery(advert(testAddress, serviceData.moistureNoMac)); @@ -235,15 +260,18 @@ describe('XiaomiMiService', () => { }); it('should publish illuminance', () => { - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.add.mockReturnValue(sensor); service.handleDiscovery(advert(testAddress, serviceData.illuminance)); + deviceInfo['model'] = 'Mi Flora HHCCJCY01'; + expect(sensor.state).toBe(14); expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ for: SensorConfig, overrides: { + device: deviceInfo, deviceClass: 'illuminance', unitOfMeasurement: 'lx', }, @@ -251,23 +279,26 @@ describe('XiaomiMiService', () => { }); it('should publish fertility', () => { - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.add.mockReturnValue(sensor); service.handleDiscovery(advert(testAddress, serviceData.fertility)); + deviceInfo['model'] = 'Mi Flora HHCCJCY01'; + expect(sensor.state).toBe(184); expect(entitiesService.add.mock.calls[0][1]).toContainEqual({ for: SensorConfig, overrides: { - deviceClass: null, + device: deviceInfo, + deviceClass: undefined, unitOfMeasurement: 'µS/cm', }, }); }); it('should reuse existing entities', () => { - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.get.mockReturnValueOnce(sensor); service.handleDiscovery(advert(testAddress, serviceData.humidity)); @@ -292,7 +323,7 @@ describe('XiaomiMiService', () => { }, ]; service.onModuleInit(); - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.get.mockReturnValueOnce(sensor); service.handleDiscovery(advert(testAddress, serviceData.encrypted)); @@ -330,7 +361,7 @@ describe('XiaomiMiService', () => { }); it('should publish negative temperatures', () => { - const sensor = new Sensor('testid', 'Test'); + const sensor = new Sensor('testid', 'Test', true); entitiesService.add.mockReturnValue(sensor); service.handleDiscovery( advert(testAddress, serviceData.negativeTemperature) diff --git a/src/integrations/xiaomi-mi/xiaomi-mi.service.ts b/src/integrations/xiaomi-mi/xiaomi-mi.service.ts index 1d8862c..290a082 100644 --- a/src/integrations/xiaomi-mi/xiaomi-mi.service.ts +++ b/src/integrations/xiaomi-mi/xiaomi-mi.service.ts @@ -8,6 +8,8 @@ import { Peripheral, Advertisement } from '@mkerix/noble'; import { EntitiesService } from '../../entities/entities.service'; import { ConfigService } from '../../config/config.service'; import { XiaomiMiSensorOptions } from './xiaomi-mi.config'; +import { DISTRIBUTED_DEVICE_ID } from '../home-assistant/home-assistant.const'; +import { Device } from '../home-assistant/device'; import { makeId } from '../../util/id'; import { SERVICE_DATA_UUID, ServiceData, Parser, EventTypes } from './parser'; import { Sensor } from '../../entities/sensor'; @@ -63,33 +65,35 @@ export class XiaomiMiService implements OnModuleInit, OnApplicationBootstrap { /** * Record a measurement. * - * @param devName - The name of the device that took the measurement. - * @param kind - The kind of measurement. + * @param device - The device and associated information that took the measurement. + * @param kind - The kind of measurement (used to name the sensor e.g. "Temperature"). + * @param devClass - The class of measurement (related to Home-Assistant device class e.g. "temperature"). * @param units - The units of the measurement. * @param state - The current measurement. */ private recordMeasure( - devName: string, + device: Device, kind: string, + devClass: string, units: string, state: number | string ): void { - this.logger.debug(`${devName}: ${kind}: ${state}${units}`); - const sensorName = `${devName} ${kind}`; - const id = makeId(sensorName); + this.logger.debug(`${device.name}: ${kind}: ${state}${units}`); + const id = makeId(`xiaomi ${device.identifiers} ${kind}`); let entity = this.entitiesService.get(id); if (!entity) { const customizations: Array> = [ { for: SensorConfig, overrides: { - deviceClass: kind, + deviceClass: devClass, unitOfMeasurement: units, + device: device, }, }, ]; entity = this.entitiesService.add( - new Sensor(id, sensorName), + new Sensor(id, `${device.name} ${kind}`, true), customizations ) as Sensor; } @@ -135,11 +139,22 @@ export class XiaomiMiService implements OnModuleInit, OnApplicationBootstrap { ); return; } + + const device: Device = { + name: options.name, + manufacturer: 'Xiaomi', + model: serviceData.productName, + swVersion: serviceData.version.toString(), + identifiers: peripheral.id, + viaDevice: DISTRIBUTED_DEVICE_ID, + }; + const event = serviceData.event; switch (serviceData.eventType) { case EventTypes.temperature: { this.recordMeasure( - options.name, + device, + 'Temperature', 'temperature', '°C', event.temperature @@ -147,26 +162,28 @@ export class XiaomiMiService implements OnModuleInit, OnApplicationBootstrap { break; } case EventTypes.humidity: { - this.recordMeasure(options.name, 'humidity', '%', event.humidity); + this.recordMeasure(device, 'Humidity', 'humidity', '%', event.humidity); break; } case EventTypes.battery: { - this.recordMeasure(options.name, 'battery', '%', event.battery); + this.recordMeasure(device, 'Battery', 'battery', '%', event.battery); break; } case EventTypes.temperatureAndHumidity: { this.recordMeasure( - options.name, + device, + 'Temperature', 'temperature', '°C', event.temperature ); - this.recordMeasure(options.name, 'humidity', '%', event.humidity); + this.recordMeasure(device, 'Humidity', 'humidity', '%', event.humidity); break; } case EventTypes.illuminance: { this.recordMeasure( - options.name, + device, + 'Illuminance', 'illuminance', 'lx', event.illuminance @@ -174,11 +191,17 @@ export class XiaomiMiService implements OnModuleInit, OnApplicationBootstrap { break; } case EventTypes.moisture: { - this.recordMeasure(options.name, 'humidity', '%', event.moisture); + this.recordMeasure(device, 'Moisture', undefined, '%', event.moisture); break; } case EventTypes.fertility: { - this.recordMeasure(options.name, null, 'µS/cm', event.fertility); + this.recordMeasure( + device, + 'Conductivity', + undefined, + 'µS/cm', + event.fertility + ); break; } default: {