Skip to content

Commit

Permalink
feat(xiaomi-mi): add device information (#362)
Browse files Browse the repository at this point in the history
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!
  • Loading branch information
PeteBa committed Dec 6, 2020
1 parent 6fed59f commit 67427d5
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 30 deletions.
15 changes: 15 additions & 0 deletions src/integrations/xiaomi-mi/parser.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -69,6 +76,7 @@ export class ServiceData {
frameControl: FrameControl;
version: number;
productId: number;
productName: string;
frameCounter: number;
macAddress: string;
capabilities: Capabilities;
Expand Down Expand Up @@ -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();
Expand All @@ -117,6 +126,7 @@ export class Parser {
frameControl: this.frameControl,
version,
productId,
productName,
frameCounter,
macAddress,
capabilities,
Expand All @@ -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);
}
Expand Down
59 changes: 45 additions & 14 deletions src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts
Expand Up @@ -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();
Expand Down Expand Up @@ -134,99 +141,117 @@ 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',
},
});
});

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: '%',
},
});
});

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: '%',
},
});
});

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: '%',
},
});
});

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));
Expand All @@ -235,39 +260,45 @@ 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',
},
});
});

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));
Expand All @@ -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));
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 39 additions & 16 deletions src/integrations/xiaomi-mi/xiaomi-mi.service.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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<EntityCustomization<any>> = [
{
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;
}
Expand Down Expand Up @@ -135,50 +139,69 @@ 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
);
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
);
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: {
Expand Down

0 comments on commit 67427d5

Please sign in to comment.