Skip to content

Commit

Permalink
Support for Air Quality sensor (see #241)
Browse files Browse the repository at this point in the history
  • Loading branch information
itavero committed Aug 23, 2021
1 parent 2172199 commit bd2c063
Show file tree
Hide file tree
Showing 12 changed files with 433 additions and 6 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
}
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
231 changes: 231 additions & 0 deletions src/converters/air_quality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { BasicAccessory, ServiceCreator, ServiceHandler } from './interfaces';
import {
exposesCanBeGet, ExposesEntry, ExposesEntryWithProperty, exposesHasNumericProperty, exposesHasProperty, exposesIsPublished,
} from '../z2mModels';
import { hap } from '../hap';
import { copyExposesRangeToCharacteristic, getOrAddCharacteristic } from '../helpers';
import { Characteristic, CharacteristicValue, Service, WithUUID } from 'homebridge';

export class AirQualitySensorCreator implements ServiceCreator {
createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
const endpointMap = new Map<string | undefined, ExposesEntryWithProperty[]>();
exposes.filter(e =>
exposesHasProperty(e) && exposesIsPublished(e) && !accessory.isPropertyExcluded(e.property) &&
AirQualitySensorHandler.propertyFactories.find((f) => f.canUseExposesEntry(e)) !== undefined,
).map(e => e as ExposesEntryWithProperty).forEach((item) => {
const collection = endpointMap.get(item.endpoint);
if (!collection) {
endpointMap.set(item.endpoint, [item]);
} else {
collection.push(item);
}
});
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 ParticulateMatter2_5Property extends PassthroughAirQualityProperty {
private static readonly NAME = 'pm25';
static canUseExposesEntry(entry: ExposesEntry): boolean {
return exposesHasNumericProperty(entry) && entry.name === ParticulateMatter2_5Property.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,
ParticulateMatter2_5Property,
];

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;
}
}
8 changes: 7 additions & 1 deletion src/converters/creators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { LockCreator } from './lock';
import { SwitchCreator } from './switch';
import { StatelessProgrammableSwitchCreator } from './action';
import { ThermostatCreator } from './climate';
import { AirQualitySensorCreator } from './air_quality';

export interface ServiceCreatorManager {
createHomeKitEntitiesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void;
Expand All @@ -24,6 +25,7 @@ export class BasicServiceCreatorManager implements ServiceCreatorManager {
CoverCreator,
LockCreator,
BasicSensorCreator,
AirQualitySensorCreator,
StatelessProgrammableSwitchCreator,
ThermostatCreator,
BatteryCreator,
Expand All @@ -46,7 +48,11 @@ export class BasicServiceCreatorManager implements ServiceCreatorManager {

createHomeKitEntitiesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
for (const c of this.creators) {
c.createServicesFromExposes(accessory, exposes);
try {
c.createServicesFromExposes(accessory, exposes);
} catch (e) {
accessory.log.error(`Exception occurred when creating services for ${accessory.displayName}: ${e}`);
}
}
}
}
1 change: 1 addition & 0 deletions src/docgen/docgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const serviceNameMapping = new Map<string, ServiceInfo>([
addServiceMapping(hapNodeJs.Service.CarbonMonoxideSensor, 'sensors.md'),
addServiceMapping(hapNodeJs.Service.LeakSensor, 'sensors.md'),
['E863F00A-079E-48FF-8F27-9C2605A29F52', new ServiceInfo('Air Pressure Sensor', 'sensors.md')],
addServiceMapping(hapNodeJs.Service.AirQualitySensor, 'air_quality.md'),
]);


Expand Down
10 changes: 6 additions & 4 deletions src/z2mModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,14 @@ export interface ExposesEntryWithEnumProperty extends ExposesEntryWithProperty {
export const exposesHasFeatures = (x: ExposesEntry): x is ExposesEntryWithFeatures => ('features' in x);
export const exposesHasProperty = (x: ExposesEntry): x is ExposesEntryWithProperty => (x.name !== undefined
&& x.property !== undefined && x.access !== undefined);
export const exposesHasNumericRangeProperty = (x: ExposesEntry): x is ExposesEntryWithNumericRangeProperty => (exposesHasProperty(x)
&& x.type === ExposesKnownTypes.NUMERIC && x.value_min !== undefined && x.value_max !== undefined);
export const exposesHasNumericProperty = (x: ExposesEntry): x is ExposesEntryWithProperty => (exposesHasProperty(x)
&& x.type === ExposesKnownTypes.NUMERIC);
export const exposesHasNumericRangeProperty = (x: ExposesEntry): x is ExposesEntryWithNumericRangeProperty => (exposesHasNumericProperty(x)
&& x.value_min !== undefined && x.value_max !== undefined);
export const exposesHasBinaryProperty = (x: ExposesEntry): x is ExposesEntryWithBinaryProperty => (exposesHasProperty(x)
&& x.type === ExposesKnownTypes.BINARY && x.value_on !== undefined && x.value_off !== undefined);
&& x.type === ExposesKnownTypes.BINARY && x.value_on !== undefined && x.value_off !== undefined);
export const exposesHasEnumProperty = (x: ExposesEntry): x is ExposesEntryWithEnumProperty => (exposesHasProperty(x)
&& x.type === ExposesKnownTypes.ENUM && x.values !== undefined && x.values.length > 0);
&& x.type === ExposesKnownTypes.ENUM && x.values !== undefined && x.values.length > 0);

export function exposesCanBeSet(entry: ExposesEntry): boolean {
return (entry.access !== undefined) && ((entry.access & ExposesAccessLevel.SET) !== 0);
Expand Down
Loading

0 comments on commit bd2c063

Please sign in to comment.