Skip to content

Commit

Permalink
feat(xiaomi-mi): publish miflora battery state as sensors (#852)
Browse files Browse the repository at this point in the history
Polls Xiaomi MiFlora devices regularly to grab battery information, which required some additional implementation since the data isn't in the advertisements. Also refactors the Xiaomi and BLE code a bit.
  • Loading branch information
PeteBa committed Aug 29, 2021
1 parent 05a86bb commit 1cdfcd2
Show file tree
Hide file tree
Showing 11 changed files with 825 additions and 532 deletions.
1 change: 1 addition & 0 deletions src/config/config.service.spec.fail.yml
Expand Up @@ -177,6 +177,7 @@ shell:
offCommand: echo off
xiaomiMi:
hciDeviceId: 0
enableMifloraBattery: 5 # FORMAT ERROR: -> Bool Required
sensors:
- address: a4:c1:38:32:12:f9 # FORMAT ERROR: -> Hex Required / Length Exceeded Errors
name: Xiaomi 1
Expand Down
2 changes: 2 additions & 0 deletions src/config/config.service.spec.pass.yml
Expand Up @@ -168,9 +168,11 @@ shell:
offCommand: echo off
xiaomiMi:
hciDeviceId: 0
enableMifloraBattery: false
sensors:
- address: a4c1383212f9
name: Xiaomi 1
enableMifloraBattery: true
- address: a4c138131f9e
name: Xiaomi 2
bindKey: 2456789ABCDEF
Expand Down
1 change: 1 addition & 0 deletions src/config/config.service.spec.ts
Expand Up @@ -100,6 +100,7 @@ describe('ConfigService', () => {
`gpio.binarySensors[1].deviceClass`,
`shell.sensors[1].switches`,
`shell.switches`,
`xiaomiMi.enableMifloraBattery`,
`xiaomiMi.sensors[0].address`,
`xiaomiMi.sensors[0].address`,
`homeAssistant.discoveryPrefix`,
Expand Down
173 changes: 173 additions & 0 deletions src/integration-support/bluetooth/bluetooth.service.spec.ts
Expand Up @@ -246,6 +246,9 @@ Requesting information ...
});

describe('Bluetooth Low Energy', () => {
const SERVICE_UUID = '5403c8a75c9647e99ab859e373d875a7';
const CHARACTERISTIC_UUID = '21c46f33e813440786012ad281030052';

it('should setup noble listeners on the first subscriber', () => {
const callback = () => undefined;
service.onLowEnergyDiscovery(callback);
Expand Down Expand Up @@ -534,9 +537,179 @@ Requesting information ...
expect(peripheral.disconnectAsync).not.toHaveBeenCalled();
});

it('should reset the adapter when query attempts time out', async () => {
const peripheral = {
id: 'abcd1234',
connectable: true,
connectAsync: jest.fn().mockImplementation(() => {
peripheral.state = 'connected';
return Promise.resolve();
}),
discoverServicesAsync: jest
.fn()
.mockRejectedValue(new Error('timed out')),
disconnectAsync: jest.fn().mockResolvedValue(undefined),
once: jest.fn(),
} as unknown as Peripheral;
mockExec.mockReturnValue(new Promise((resolve) => setTimeout(resolve)));
const resetSpy = jest.spyOn(service, 'resetHciDevice');

await service.queryLowEnergyDevice(
peripheral,
SERVICE_UUID,
CHARACTERISTIC_UUID
);
expect(resetSpy).toHaveBeenCalled();
});

it('should return the value of a query from the device', async () => {
const gattCharacteristic = {
readAsync: jest.fn().mockResolvedValue(Buffer.from('app-id', 'utf-8')),
};
const gattService = {
discoverCharacteristicsAsync: jest
.fn()
.mockResolvedValue([gattCharacteristic]),
};
const peripheral = {
id: 'abcd1234',
connectable: true,
connectAsync: jest.fn().mockImplementation(() => {
peripheral.state = 'connected';
return Promise.resolve();
}),
discoverServicesAsync: jest.fn().mockResolvedValue([gattService]),
disconnectAsync: jest.fn().mockResolvedValue(undefined),
once: jest.fn(),
} as unknown as Peripheral;

const result = await service.queryLowEnergyDevice(
peripheral,
SERVICE_UUID,
CHARACTERISTIC_UUID
);

expect(peripheral.disconnectAsync).toHaveBeenCalled();
expect(result).toStrictEqual(Buffer.from('app-id', 'utf-8'));
});

it('should return null if device does not have the requested characteristic', async () => {
const gattService = {
discoverCharacteristicsAsync: jest.fn().mockResolvedValue([]),
};
const peripheral = {
id: 'abcd1234',
connectable: true,
connectAsync: jest.fn().mockImplementation(() => {
peripheral.state = 'connected';
return Promise.resolve();
}),
discoverServicesAsync: jest.fn().mockResolvedValue([gattService]),
disconnectAsync: jest.fn().mockResolvedValue(undefined),
once: jest.fn(),
} as unknown as Peripheral;

const result = await service.queryLowEnergyDevice(
peripheral,
SERVICE_UUID,
CHARACTERISTIC_UUID
);

expect(peripheral.disconnectAsync).toHaveBeenCalled();
expect(result).toBeNull();
});

it('should return null if device does not have the requested service', async () => {
const peripheral = {
id: 'abcd1234',
connectable: true,
connectAsync: jest.fn().mockImplementation(() => {
peripheral.state = 'connected';
return Promise.resolve();
}),
discoverServicesAsync: jest.fn().mockResolvedValue([]),
disconnectAsync: jest.fn().mockResolvedValue(undefined),
once: jest.fn(),
} as unknown as Peripheral;

const result = await service.queryLowEnergyDevice(
peripheral,
SERVICE_UUID,
CHARACTERISTIC_UUID
);

expect(peripheral.disconnectAsync).toHaveBeenCalled();
expect(result).toBeNull();
});

it('should return null if there is an error while discovering GATT information', async () => {
const gattService = {
discoverCharacteristicsAsync: jest
.fn()
.mockRejectedValue(new Error('expected for this test')),
};
const peripheral = {
id: 'abcd1234',
connectable: true,
connectAsync: jest.fn().mockImplementation(() => {
peripheral.state = 'connected';
return Promise.resolve();
}),
discoverServicesAsync: jest.fn().mockResolvedValue([gattService]),
disconnectAsync: jest.fn().mockResolvedValue(undefined),
once: jest.fn(),
} as unknown as Peripheral;

const result = await service.queryLowEnergyDevice(
peripheral,
SERVICE_UUID,
CHARACTERISTIC_UUID
);

expect(peripheral.disconnectAsync).toHaveBeenCalled();
expect(result).toBeNull();
});

it('should not disconnect from an already disconnecting peripheral', async () => {
const gattCharacteristic = {
readAsync: jest.fn().mockImplementation(() => {
peripheral.state = 'disconnecting';
return Promise.resolve(Buffer.from('app-id', 'utf-8'));
}),
};
const gattService = {
discoverCharacteristicsAsync: jest
.fn()
.mockResolvedValue([gattCharacteristic]),
};
const peripheral = {
id: 'abcd1234',
connectable: true,
connectAsync: jest.fn().mockImplementation(() => {
peripheral.state = 'connected';
return Promise.resolve();
}),
discoverServicesAsync: jest.fn().mockResolvedValue([gattService]),
disconnectAsync: jest.fn().mockResolvedValue(undefined),
once: jest.fn(),
} as unknown as Peripheral;
service.disconnectLowEnergyDevice = jest.fn();

const result = await service.queryLowEnergyDevice(
peripheral,
SERVICE_UUID,
CHARACTERISTIC_UUID
);

expect(service.disconnectLowEnergyDevice).not.toHaveBeenCalled();
expect(result).toStrictEqual(Buffer.from('app-id', 'utf-8'));
});

it('should reset adapter if nothing has been detected for a while', async () => {
jest.spyOn(Promises, 'sleep').mockResolvedValue();
jest.useFakeTimers('modern');
const execPromise = Promise.resolve({ stdout: '-1' });
mockExec.mockReturnValue(execPromise);

// eslint-disable-next-line @typescript-eslint/no-empty-function
service.onLowEnergyDiscovery(() => {});
Expand Down
75 changes: 75 additions & 0 deletions src/integration-support/bluetooth/bluetooth.service.ts
Expand Up @@ -181,6 +181,81 @@ export class BluetoothService implements OnApplicationShutdown {
}
}

/**
* Connects to the given peripheral and queries the service/characteristic value.
* If the service and characteristic are not found it will return null.
*
* @param peripheral - Peripheral to connect to
* @param serviceUuid - Service UUID to query
* @param characteristicUuid - Characteristic UUID to query
*/
async queryLowEnergyDevice(
target: Peripheral,
serviceUuid: string,
characteristicUuid: string
): Promise<Buffer | null> {
const disconnectPromise = util.promisify(target.once).bind(target)(
'disconnect'
);

const peripheral = await this.connectLowEnergyDevice(target);

try {
return await promiseWithTimeout<Buffer | null>(
Promise.race([
this.readLowEnergyCharacteristic(
peripheral,
serviceUuid,
characteristicUuid
),
disconnectPromise,
]),
15 * 1000
);
} catch (e) {
this.logger.error(
`Failed to query value from ${target.id}: ${e.message}`,
e.trace
);

if (e.message === 'timed out') {
this.resetHciDevice(this.lowEnergyAdapterId);
}

return null;
} finally {
if (!['disconnecting', 'disconnected'].includes(target.state)) {
this.disconnectLowEnergyDevice(target);
}
}
}

/**
* Reads a characteristic value of a BLE peripheral as Buffer.
*
* @param peripheral - peripheral to read from
* @param serviceUuid - UUID of the service that contains the characteristic
* @param characteristicUuid - UUID of the characteristic to read
*/
private async readLowEnergyCharacteristic(
peripheral: Peripheral,
serviceUuid: string,
characteristicUuid: string
): Promise<Buffer | null> {
const services = await peripheral.discoverServicesAsync([serviceUuid]);

if (services.length > 0) {
const characteristics = await services[0].discoverCharacteristicsAsync([
characteristicUuid,
]);

if (characteristics.length > 0) {
return await characteristics[0].readAsync();
}
}
return null;
}

/**
* Queries for the RSSI of a Bluetooth device using the hcitool shell command.
*
Expand Down

0 comments on commit 1cdfcd2

Please sign in to comment.