Skip to content

Commit

Permalink
Support for Air Quality sensor (#259)
Browse files Browse the repository at this point in the history
* Support for Air Quality sensor (see #241)
* Reduce duplicate code for grouping ExposesEntries by their endpoint.
  • Loading branch information
itavero committed Aug 23, 2021
1 parent 2172199 commit 20f05e9
Show file tree
Hide file tree
Showing 16 changed files with 451 additions and 32 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"source.fixAll.eslint": true
},
"editor.rulers": [ 140 ],
"eslint.enable": true
"eslint.enable": true,
"editor.tabSize": 2
}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Since version 1.0.0, we try to follow the [Semantic Versioning](https://semver.o

## [Unreleased]

### Added

- Support for Air Quality Sensors (`voc`, `pm10`, `pm25`). (see [#241](https://github.com/itavero/homebridge-z2m/issues/241))

## [1.4.0] - 2021-08-16
### Added

Expand Down
15 changes: 15 additions & 0 deletions docs/air_quality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Air Quality Sensor
If the device contains any of the `exposes` mentioned in the following table, an [Air Quality Sensor](https://developers.homebridge.io/#/service/AirQualitySensor) service will be created.

Besides the characteristic mentioned in the table, the plugin will also add the required [Air Quality](https://developers.homebridge.io/#/characteristic/AirQuality) characteristic.
The table below contains the threshold values for the different properties.
If a single sensor supports multiple of the characteristics mentioned in the table, the worst air quality indication will be used for the _Air Quality_ characteristic.

| Name | Characteristic | Excellent | Good | Fair | Inferior | Poor |
|-|-|-|-|-|-|-|
| `voc` | [VOC Density](https://developers.homebridge.io/#/characteristic/VOCDensity) | <= 333 | <= 1000 | <= 3333 | <= 8332 | > 8332 |
| `pm10` | [PM10 Density](https://developers.homebridge.io/#/characteristic/PM10Density) | <= 25 | <= 50 | <= 100 | <= 300 | > 300 |
| `pm25` | [PM2.5](https://developers.homebridge.io/#/characteristic/PM2_5Density) | <= 15 | <= 35 | <= 55 | <= 75 | > 75 |

Note that these values have been selected based on several graphs found on different online resources.
There might be room from improvement, but then again, the _Air Quality_ is just an indication.
3 changes: 3 additions & 0 deletions docs/devices/develco/aqszb-110.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ DO NOT EDIT THIS FILE MANUALLY!
The following HomeKit Services and Characteristics are exposed by
the Develco AQSZB-110

* [AirQualitySensor](../../air_quality.md)
* AirQuality
* VOCDensity
* [Battery](../../battery.md)
* BatteryLevel
* ChargingState
Expand Down
4 changes: 4 additions & 0 deletions docs/devices/heiman/hs2aq-em.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ DO NOT EDIT THIS FILE MANUALLY!
The following HomeKit Services and Characteristics are exposed by
the HEIMAN HS2AQ-EM

* [AirQualitySensor](../../air_quality.md)
* AirQuality
* PM10Density
* VOCDensity
* [Battery](../../battery.md)
* BatteryLevel
* ChargingState
Expand Down
3 changes: 3 additions & 0 deletions docs/devices/lifecontrol/mclh-08.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ DO NOT EDIT THIS FILE MANUALLY!
The following HomeKit Services and Characteristics are exposed by
the LifeControl MCLH-08

* [AirQualitySensor](../../air_quality.md)
* AirQuality
* VOCDensity
* [HumiditySensor](../../sensors.md)
* CurrentRelativeHumidity
* [TemperatureSensor](../../sensors.md)
Expand Down
3 changes: 3 additions & 0 deletions docs/devices/tuya/ts0601_air_quality_sensor.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ DO NOT EDIT THIS FILE MANUALLY!
The following HomeKit Services and Characteristics are exposed by
the Tuya TS0601_air_quality_sensor

* [AirQualitySensor](../../air_quality.md)
* AirQuality
* VOCDensity
* [HumiditySensor](../../sensors.md)
* CurrentRelativeHumidity
* [TemperatureSensor](../../sensors.md)
Expand Down
3 changes: 3 additions & 0 deletions docs/devices/xiaomi/vockqjk11lm.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ DO NOT EDIT THIS FILE MANUALLY!
The following HomeKit Services and Characteristics are exposed by
these devices

* [AirQualitySensor](../../air_quality.md)
* AirQuality
* VOCDensity
* [Battery](../../battery.md)
* BatteryLevel
* ChargingState
Expand Down
223 changes: 223 additions & 0 deletions src/converters/air_quality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { BasicAccessory, ServiceCreator, ServiceHandler } from './interfaces';
import {
exposesCanBeGet, ExposesEntry, ExposesEntryWithProperty, exposesHasNumericProperty, exposesHasProperty, exposesIsPublished,
} from '../z2mModels';
import { hap } from '../hap';
import { copyExposesRangeToCharacteristic, getOrAddCharacteristic, groupByEndpoint } from '../helpers';
import { Characteristic, CharacteristicValue, Service, WithUUID } from 'homebridge';

export class AirQualitySensorCreator implements ServiceCreator {
createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
const endpointMap = groupByEndpoint(exposes.filter(e =>
exposesHasProperty(e) && exposesIsPublished(e) && !accessory.isPropertyExcluded(e.property) &&
AirQualitySensorHandler.propertyFactories.find((f) => f.canUseExposesEntry(e)) !== undefined,
).map(e => e as ExposesEntryWithProperty));
endpointMap.forEach((value, key) => {
if (!accessory.isServiceHandlerIdKnown(AirQualitySensorHandler.generateIdentifier(key))) {
this.createService(key, value, accessory);
}
});
}

private createService(endpoint: string | undefined, exposes: ExposesEntryWithProperty[], accessory: BasicAccessory): void {
try {
const handler = new AirQualitySensorHandler(endpoint, exposes, accessory);
accessory.registerServiceHandler(handler);
} catch (error) {
accessory.log.warn('Failed to setup Air Quality Sensor service ' +
`for accessory ${accessory.displayName} for endpoint ${endpoint}: ${error}`);
}
}
}

export declare type WithExposesValidator<T> = T & {
canUseExposesEntry(entry: ExposesEntry): boolean;
};

interface AirQualityProperty {
readonly expose: ExposesEntryWithProperty;
readonly latestAirQuality: number;
updateState(state: Record<string, unknown>): void;
}

abstract class PassthroughAirQualityProperty implements AirQualityProperty {
public latestAirQuality: number;

constructor(public expose: ExposesEntryWithProperty, protected service: Service,
protected characteristic: WithUUID<{ new(): Characteristic }>) {
this.latestAirQuality = hap.Characteristic.AirQuality.UNKNOWN;
const c = getOrAddCharacteristic(service, characteristic);
copyExposesRangeToCharacteristic(expose, c);
}

updateState(state: Record<string, unknown>): void {
if (this.expose.property in state) {
const sensorValue = state[this.expose.property] as CharacteristicValue;
if (sensorValue !== null && sensorValue !== undefined) {
this.service.updateCharacteristic(this.characteristic, sensorValue);
this.latestAirQuality = this.convertToAirQuality(sensorValue) ?? hap.Characteristic.AirQuality.UNKNOWN;
}
}
}

abstract convertToAirQuality(sensorValue: CharacteristicValue): number | undefined;
}

class VolatileOrganicCompoundsProperty extends PassthroughAirQualityProperty {
private static readonly NAME = 'voc';
static canUseExposesEntry(entry: ExposesEntry): boolean {
return exposesHasNumericProperty(entry) && entry.name === VolatileOrganicCompoundsProperty.NAME;
}

constructor(expose: ExposesEntryWithProperty, service: Service) {
super(expose, service, hap.Characteristic.VOCDensity);
}

convertToAirQuality(sensorValue: CharacteristicValue): number | undefined {
if (sensorValue <= 333) {
return hap.Characteristic.AirQuality.EXCELLENT;
}

if (sensorValue <= 1000) {
return hap.Characteristic.AirQuality.GOOD;
}

if (sensorValue <= 3333) {
return hap.Characteristic.AirQuality.FAIR;
}

if (sensorValue <= 8332) {
return hap.Characteristic.AirQuality.INFERIOR;
}

return hap.Characteristic.AirQuality.POOR;
}
}

class ParticulateMatter10Property extends PassthroughAirQualityProperty {
private static readonly NAME = 'pm10';
static canUseExposesEntry(entry: ExposesEntry): boolean {
return exposesHasNumericProperty(entry) && entry.name === ParticulateMatter10Property.NAME;
}

constructor(expose: ExposesEntryWithProperty, service: Service) {
super(expose, service, hap.Characteristic.PM10Density);
}

convertToAirQuality(sensorValue: CharacteristicValue): number | undefined {
if (sensorValue <= 25) {
return hap.Characteristic.AirQuality.EXCELLENT;
}

if (sensorValue <= 50) {
return hap.Characteristic.AirQuality.GOOD;
}

if (sensorValue <= 100) {
return hap.Characteristic.AirQuality.FAIR;
}

if (sensorValue <= 300) {
return hap.Characteristic.AirQuality.INFERIOR;
}

return hap.Characteristic.AirQuality.POOR;
}
}

class ParticulateMatter2Dot5Property extends PassthroughAirQualityProperty {
private static readonly NAME = 'pm25';
static canUseExposesEntry(entry: ExposesEntry): boolean {
return exposesHasNumericProperty(entry) && entry.name === ParticulateMatter2Dot5Property.NAME;
}

constructor(expose: ExposesEntryWithProperty, service: Service) {
super(expose, service, hap.Characteristic.PM10Density);
}

convertToAirQuality(sensorValue: CharacteristicValue): number | undefined {
if (sensorValue <= 15) {
return hap.Characteristic.AirQuality.EXCELLENT;
}

if (sensorValue <= 35) {
return hap.Characteristic.AirQuality.GOOD;
}

if (sensorValue <= 55) {
return hap.Characteristic.AirQuality.FAIR;
}

if (sensorValue <= 75) {
return hap.Characteristic.AirQuality.INFERIOR;
}

return hap.Characteristic.AirQuality.POOR;
}
}

class AirQualitySensorHandler implements ServiceHandler {
public static readonly propertyFactories:
WithExposesValidator<{ new(expose: ExposesEntryWithProperty, service: Service): AirQualityProperty }>[] = [
VolatileOrganicCompoundsProperty,
ParticulateMatter10Property,
ParticulateMatter2Dot5Property,
];

private readonly properties: AirQualityProperty[] = [];
private readonly service: Service;

constructor(endpoint: string | undefined, exposes: ExposesEntryWithProperty[], private readonly accessory: BasicAccessory) {
this.identifier = AirQualitySensorHandler.generateIdentifier(endpoint);

const serviceName = accessory.getDefaultServiceDisplayName(endpoint);
accessory.log.debug(`Configuring Air Quality Sensor for ${serviceName}`);
this.service = accessory.getOrAddService(new hap.Service.AirQualitySensor(serviceName, endpoint));
getOrAddCharacteristic(this.service, hap.Characteristic.AirQuality);

for (const e of exposes) {
const factory = AirQualitySensorHandler.propertyFactories.find((f) => f.canUseExposesEntry(e));
if (factory === undefined) {
accessory.log.warn(`Air Quality Sensor does not know how to handle ${e.property} (on ${serviceName})`);
continue;
}
this.properties.push(new factory(e, this.service));
}

if (this.properties.length === 0) {
throw new Error(`Air Quality Sensor (${serviceName}) did not receive any suitable exposes entries.`);
}
}

identifier: string;
get getableKeys(): string[] {
const keys: string[] = [];
for (const property of this.properties) {
if (exposesCanBeGet(property.expose)) {
keys.push(property.expose.property);
}
}
return keys;
}

updateState(state: Record<string, unknown>): void {
let airQuality: CharacteristicValue = hap.Characteristic.AirQuality.UNKNOWN;
for (const p of this.properties) {
p.updateState(state);
airQuality = AirQualitySensorHandler.getWorstAirQuality(airQuality, p.latestAirQuality);
}
this.service.updateCharacteristic(hap.Characteristic.AirQuality, airQuality);
}

static getWorstAirQuality(a: number, b: number): number {
return (a > b) ? a : b;
}

static generateIdentifier(endpoint: string | undefined) {
let identifier = hap.Service.AirQualitySensor.UUID;
if (endpoint !== undefined) {
identifier += '_' + endpoint.trim();
}
return identifier;
}
}
15 changes: 3 additions & 12 deletions src/converters/basic_sensors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
CharacteristicMonitor, MappingCharacteristicMonitor, PassthroughCharacteristicMonitor,
} from './monitor';
import { Characteristic, CharacteristicValue, Service, WithUUID } from 'homebridge';
import { copyExposesRangeToCharacteristic, getOrAddCharacteristic } from '../helpers';
import { copyExposesRangeToCharacteristic, getOrAddCharacteristic, groupByEndpoint } from '../helpers';
import { hap } from '../hap';

interface ExposeToHandlerFunction {
Expand Down Expand Up @@ -362,17 +362,8 @@ export class BasicSensorCreator implements ServiceCreator {
];

createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
const endpointMap = new Map<string | undefined, ExposesEntryWithProperty[]>();
exposes.filter(e => exposesHasProperty(e) && !accessory.isPropertyExcluded(e.property)
&& exposesIsPublished(e)).map(e => e as ExposesEntryWithProperty)
.forEach((item) => {
const collection = endpointMap.get(item.endpoint);
if (!collection) {
endpointMap.set(item.endpoint, [item]);
} else {
collection.push(item);
}
});
const endpointMap = groupByEndpoint(exposes.filter(e => exposesHasProperty(e) && !accessory.isPropertyExcluded(e.property)
&& exposesIsPublished(e)).map(e => e as ExposesEntryWithProperty));

endpointMap.forEach((value, key) => {
const optionalProperties = value.filter(e => exposesHasBinaryProperty(e) && (e.name === 'battery_low' || e.name === 'tamper'))
Expand Down
14 changes: 3 additions & 11 deletions src/converters/battery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
exposesHasBinaryProperty, exposesHasNumericRangeProperty, exposesHasProperty, exposesIsPublished,
} from '../z2mModels';
import { hap } from '../hap';
import { getOrAddCharacteristic } from '../helpers';
import { getOrAddCharacteristic, groupByEndpoint } from '../helpers';
import { CharacteristicValue } from 'homebridge';
import {
BinaryConditionCharacteristicMonitor,
Expand All @@ -13,19 +13,11 @@ import {

export class BatteryCreator implements ServiceCreator {
createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
const endpointMap = new Map<string | undefined, ExposesEntryWithProperty[]>();
exposes.filter(e =>
const endpointMap = groupByEndpoint(exposes.filter(e =>
exposesHasProperty(e) && exposesIsPublished(e) && !accessory.isPropertyExcluded(e.property) && (
(e.name === 'battery' && exposesHasNumericRangeProperty(e))
|| (e.name === 'battery_low' && exposesHasBinaryProperty(e))
)).map(e => e as ExposesEntryWithProperty).forEach((item) => {
const collection = endpointMap.get(item.endpoint);
if (!collection) {
endpointMap.set(item.endpoint, [item]);
} else {
collection.push(item);
}
});
)).map(e => e as ExposesEntryWithProperty));
endpointMap.forEach((value, key) => {
if (!accessory.isServiceHandlerIdKnown(BatteryHandler.generateIdentifier(key))) {
this.createService(key, value, accessory);
Expand Down
Loading

0 comments on commit 20f05e9

Please sign in to comment.