Skip to content

Commit

Permalink
Filter exposes information before calling service handlers. (#534)
Browse files Browse the repository at this point in the history
Add option to exclude based on endpoint (relates to #517).
  • Loading branch information
itavero committed Oct 1, 2022
1 parent d19f77e commit 79eec06
Show file tree
Hide file tree
Showing 26 changed files with 520 additions and 125 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ Since version 1.0.0, we try to follow the [Semantic Versioning](https://semver.o

## [Unreleased]

### Added

- Properties/exposes information can now be excluded based on the `endpoint`, using the `excluded_endpoints` configuration option. (relates to [#517](https://github.com/itavero/homebridge-z2m/issues/517))

### Changed

- Exposes information is now filtered before passing it to the service handlers. This should make the behavior more consistent and reduce complexity of the service handlers for improved maintainability.

## [1.9.2] - 2022-10-01

### Fixed
Expand Down
18 changes: 18 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
"minLength": 1
}
},
"excluded_endpoints": {
"title": "Excluded endpoints",
"type": "array",
"required": false,
"items": {
"type": "string",
"minLength": 0
}
},
"values": {
"title": "Include/exclude values",
"type": "array",
Expand Down Expand Up @@ -199,6 +208,9 @@
"excluded_keys": {
"$ref": "#/definitions/excluded_keys"
},
"excluded_endpoints": {
"$ref": "#/definitions/excluded_endpoints"
},
"values": {
"$ref": "#/definitions/values"
},
Expand Down Expand Up @@ -237,6 +249,12 @@
"functionBody": "return !model.devices[arrayIndices].exclude;"
}
},
"excluded_endpoints": {
"$ref": "#/definitions/excluded_endpoints",
"condition": {
"functionBody": "return !model.devices[arrayIndices].exclude;"
}
},
"included_keys": {
"title": "Included properties (keys)",
"type": "array",
Expand Down
6 changes: 5 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ A possible configuration looks like this:
},
{
"id": "0xabcd1234abcd1234",
"excluded_endpoints": [
"l2"
],
"converters": {
"switch": {
"type": "outlet"
Expand Down Expand Up @@ -82,7 +85,8 @@ Currently the following options are available:
* `exclude`: if set to `true` this device will not be fully ignored.
* `excluded_keys`: an array of properties/keys (known as the `property` in the exposes information) that should be ignored/excluded for this device.
* `included_keys`: an array of properties/keys (known as the `property` in the exposes information) that should be included for this device, even if they are excluded in the global default device configuration (see below).
* `values`: Per property, you can specify an include and/or exclude list to ignore certain values. The values may start or end with an asterisk (`*`) as a wildcard. This is currently only applied in the [Stateless Programmable Switch](action.md).
* `excluded_endpoints`: an array of endpoints that should be ignored/excluded for this device. To ignore properties without an endpoint, add `''` (empty string) to the array.
* `values`: Per property, you can specify an include and/or exclude list to ignore certain values. The values may start or end with an asterisk (`*`) as a wildcard.
* `exposes`: An array of exposes information, using the [structures defined by Zigbee2MQTT](https://www.zigbee2mqtt.io/guide/usage/exposes.html).
* `converters`: An object to optionally provide additional configuration for specific converters. More information can be found in the documentation of the [converters](converters.md), if applicable.

Expand Down
6 changes: 6 additions & 0 deletions src/configModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export const isMqttConfiguration = (x: any): x is MqttConfiguration => (
export interface BaseDeviceConfiguration extends Record<string, unknown> {
exclude?: boolean;
excluded_keys?: string[];
excluded_endpoints?: string[];
values?: PropertyValueConfiguration[];
converters?: object;
experimental?: string[];
Expand All @@ -114,6 +115,11 @@ export const isBaseDeviceConfiguration = (x: any): x is BaseDeviceConfiguration
return false;
}

// Optional excluded_endpoints which must be an array of strings if present
if (x.excluded_endpoints !== undefined && !isStringArray(x.excluded_endpoints)) {
return false;
}

// Optional 'experimental' which must be an array of strings if present
if (x.experimental !== undefined && !isStringArray(x.experimental)) {
return false;
Expand Down
6 changes: 2 additions & 4 deletions src/converters/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@ import { SwitchActionHelper, SwitchActionMapping } from './action_helper';

export class StatelessProgrammableSwitchCreator implements ServiceCreator {
createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
const actionExposes = exposes.filter(e => exposesIsPublished(e) && exposesHasEnumProperty(e) && e.name === 'action'
&& !accessory.isPropertyExcluded(e.property))
const actionExposes = exposes.filter(e => exposesIsPublished(e) && exposesHasEnumProperty(e) && e.name === 'action')
.map(e => e as ExposesEntryWithEnumProperty);

for (const expose of actionExposes) {
// Each action expose can map to multiple instances of a Stateless Programmable Switch,
// depending on the values provided.
try {
const allowedValues = expose.values.filter(v => accessory.isValueAllowedForProperty(expose.property, v));
const mappings = SwitchActionHelper.getInstance().valuesToNumberedMappings(allowedValues).filter(m => m.isValidMapping());
const mappings = SwitchActionHelper.getInstance().valuesToNumberedMappings(expose.values).filter(m => m.isValidMapping());
const logEntries: string[] = [`Mapping of property '${expose.property}' of device '${accessory.displayName}':`];
for (const mapping of mappings) {
try {
Expand Down
5 changes: 2 additions & 3 deletions src/converters/air_quality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import { Characteristic, CharacteristicValue, Service, WithUUID } from 'homebrid

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,
const endpointMap = groupByEndpoint(exposes.filter(e => exposesHasProperty(e) && exposesIsPublished(e)
&& AirQualitySensorHandler.propertyFactories.find((f) => f.canUseExposesEntry(e)) !== undefined,
).map(e => e as ExposesEntryWithProperty));
endpointMap.forEach((value, key) => {
if (!accessory.isServiceHandlerIdKnown(AirQualitySensorHandler.generateIdentifier(key))) {
Expand Down
2 changes: 1 addition & 1 deletion src/converters/basic_sensors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class BasicSensorCreator implements ServiceCreator {
}

createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
const endpointMap = groupByEndpoint(exposes.filter(e => exposesHasProperty(e) && !accessory.isPropertyExcluded(e.property)
const endpointMap = groupByEndpoint(exposes.filter(e => exposesHasProperty(e)
&& exposesIsPublished(e)).map(e => e as ExposesEntryWithProperty));

endpointMap.forEach((value, key) => {
Expand Down
2 changes: 1 addition & 1 deletion src/converters/battery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
export class BatteryCreator implements ServiceCreator {
createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
const endpointMap = groupByEndpoint(exposes.filter(e =>
exposesHasProperty(e) && exposesIsPublished(e) && !accessory.isPropertyExcluded(e.property) && (
exposesHasProperty(e) && exposesIsPublished(e) && (
(e.name === 'battery' && exposesHasNumericRangeProperty(e))
|| (e.name === 'battery_low' && exposesHasBinaryProperty(e))
)).map(e => e as ExposesEntryWithProperty));
Expand Down
16 changes: 4 additions & 12 deletions src/converters/climate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,12 @@ class ThermostatHandler implements ServiceHandler {
}

public static hasRequiredFeatures(accessory: BasicAccessory, e: ExposesEntryWithFeatures): boolean {
if (e.features.findIndex(f => f.name === 'occupied_cooling_setpoint' && !accessory.isPropertyExcluded(f.property)) >= 0) {
if (e.features.findIndex(f => f.name === 'occupied_cooling_setpoint') >= 0) {
// For now ignore devices that have a cooling setpoint as I haven't figured our how to handle this correctly in HomeKit.
return false;
}

return exposesHasAllRequiredFeatures(e,
[ThermostatHandler.PREDICATE_SETPOINT, ThermostatHandler.PREDICATE_LOCAL_TEMPERATURE],
accessory.isPropertyExcluded.bind(accessory));
return exposesHasAllRequiredFeatures(e, [ThermostatHandler.PREDICATE_SETPOINT, ThermostatHandler.PREDICATE_LOCAL_TEMPERATURE]);
}

private monitors: CharacteristicMonitor[] = [];
Expand All @@ -110,25 +108,19 @@ class ThermostatHandler implements ServiceHandler {

// Store all required features
const possibleLocalTemp = expose.features.find(ThermostatHandler.PREDICATE_LOCAL_TEMPERATURE);
if (possibleLocalTemp === undefined || accessory.isPropertyExcluded(possibleLocalTemp.property)) {
if (possibleLocalTemp === undefined) {
throw new Error('Local temperature feature not found.');
}
this.localTemperatureExpose = possibleLocalTemp as ExposesEntryWithProperty;

const possibleSetpoint = expose.features.find(ThermostatHandler.PREDICATE_SETPOINT);
if (possibleSetpoint === undefined || accessory.isPropertyExcluded(possibleSetpoint.property)) {
if (possibleSetpoint === undefined) {
throw new Error('Setpoint feature not found.');
}
this.setpointExpose = possibleSetpoint as ExposesEntryWithProperty;

this.targetModeExpose = expose.features.find(ThermostatHandler.PREDICATE_TARGET_MODE) as ExposesEntryWithEnumProperty;
if (this.targetModeExpose !== undefined && accessory.isPropertyExcluded(this.targetModeExpose.property)) {
this.targetModeExpose = undefined;
}
this.currentStateExpose = expose.features.find(ThermostatHandler.PREDICATE_CURRENT_STATE) as ExposesEntryWithEnumProperty;
if (this.currentStateExpose !== undefined && accessory.isPropertyExcluded(this.currentStateExpose.property)) {
this.currentStateExpose = undefined;
}
if (this.targetModeExpose === undefined || this.currentStateExpose === undefined) {
if (this.targetModeExpose !== undefined) {
this.accessory.log.debug(`${accessory.displayName}: ignore ${this.targetModeExpose.property}; no current state exposed.`);
Expand Down
4 changes: 2 additions & 2 deletions src/converters/cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ class CoverHandler implements ServiceHandler {
const endpoint = expose.endpoint;
this.identifier = CoverHandler.generateIdentifier(endpoint);

let positionExpose = expose.features.find(e => exposesHasNumericRangeProperty(e) && !accessory.isPropertyExcluded(e.property)
let positionExpose = expose.features.find(e => exposesHasNumericRangeProperty(e)
&& e.name === 'position' && exposesCanBeSet(e) && exposesIsPublished(e)) as ExposesEntryWithNumericRangeProperty;
this.tiltExpose = expose.features.find(e => exposesHasNumericRangeProperty(e) && !accessory.isPropertyExcluded(e.property)
this.tiltExpose = expose.features.find(e => exposesHasNumericRangeProperty(e)
&& e.name === 'tilt' && exposesCanBeSet(e) && exposesIsPublished(e)) as ExposesEntryWithNumericRangeProperty | undefined;

if (positionExpose === undefined) {
Expand Down
4 changes: 0 additions & 4 deletions src/converters/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@ export interface BasicAccessory {

queueKeyForGetAction(key: string | string[]): void;

isPropertyExcluded(property: string | undefined): boolean;

isValueAllowedForProperty(property: string, value: string): boolean;

registerServiceHandler(handler: ServiceHandler): void;

isServiceHandlerIdKnown(identifier: string): boolean;
Expand Down
14 changes: 7 additions & 7 deletions src/converters/light.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { EXP_COLOR_MODE } from '../experimental';
export class LightCreator implements ServiceCreator {
createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
exposes.filter(e => e.type === ExposesKnownTypes.LIGHT && exposesHasFeatures(e)
&& exposesHasAllRequiredFeatures(e, [LightHandler.PREDICATE_STATE], accessory.isPropertyExcluded.bind(accessory))
&& exposesHasAllRequiredFeatures(e, [LightHandler.PREDICATE_STATE])
&& !accessory.isServiceHandlerIdKnown(LightHandler.generateIdentifier(e.endpoint)))
.forEach(e => this.createService(e as ExposesEntryWithFeatures, accessory));
}
Expand Down Expand Up @@ -58,11 +58,11 @@ class LightHandler implements ServiceHandler {
const endpoint = expose.endpoint;
this.identifier = LightHandler.generateIdentifier(endpoint);

const features = expose.features.filter(e => exposesHasProperty(e) && !accessory.isPropertyExcluded(e.property))
const features = expose.features.filter(e => exposesHasProperty(e))
.map(e => e as ExposesEntryWithProperty);

// On/off characteristic (required by HomeKit)
const potentialStateExpose = features.find(e => LightHandler.PREDICATE_STATE(e) && !accessory.isPropertyExcluded(e.property));
const potentialStateExpose = features.find(e => LightHandler.PREDICATE_STATE(e));
if (potentialStateExpose === undefined) {
throw new Error('Required "state" property not found for Light.');
}
Expand All @@ -84,7 +84,7 @@ class LightHandler implements ServiceHandler {
this.tryCreateBrightness(features, service);

// Color: Hue/Saturation or X/Y
this.tryCreateColor(expose, service, accessory);
this.tryCreateColor(expose, service);

// Color temperature
this.tryCreateColorTemperature(features, service);
Expand Down Expand Up @@ -137,17 +137,17 @@ class LightHandler implements ServiceHandler {
this.monitors.forEach(m => m.callback(state));
}

private tryCreateColor(expose: ExposesEntryWithFeatures, service: Service, accessory: BasicAccessory) {
private tryCreateColor(expose: ExposesEntryWithFeatures, service: Service) {
// First see if color_hs is present
this.colorExpose = expose.features.find(e => exposesHasFeatures(e)
&& e.type === ExposesKnownTypes.COMPOSITE && e.name === 'color_hs'
&& e.property !== undefined && !accessory.isPropertyExcluded(e.property)) as ExposesEntryWithFeatures | undefined;
&& e.property !== undefined) as ExposesEntryWithFeatures | undefined;

// Otherwise check for color_xy
if (this.colorExpose === undefined) {
this.colorExpose = expose.features.find(e => exposesHasFeatures(e)
&& e.type === ExposesKnownTypes.COMPOSITE && e.name === 'color_xy'
&& e.property !== undefined && !accessory.isPropertyExcluded(e.property)) as ExposesEntryWithFeatures | undefined;
&& e.property !== undefined) as ExposesEntryWithFeatures | undefined;
}

if (this.colorExpose !== undefined && this.colorExpose.property !== undefined) {
Expand Down
9 changes: 3 additions & 6 deletions src/converters/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import {
export class LockCreator implements ServiceCreator {
createServicesFromExposes(accessory: BasicAccessory, exposes: ExposesEntry[]): void {
exposes.filter(e => e.type === ExposesKnownTypes.LOCK && exposesHasFeatures(e)
&& exposesHasAllRequiredFeatures(e, [LockHandler.PREDICATE_LOCK_STATE, LockHandler.PREDICATE_STATE],
accessory.isPropertyExcluded.bind(accessory))
&& exposesHasAllRequiredFeatures(e, [LockHandler.PREDICATE_LOCK_STATE, LockHandler.PREDICATE_STATE])
&& !accessory.isServiceHandlerIdKnown(LockHandler.generateIdentifier(e.endpoint)))
.forEach(e => this.createService(e as ExposesEntryWithFeatures, accessory));
}
Expand Down Expand Up @@ -55,15 +54,13 @@ class LockHandler implements ServiceHandler {
const endpoint = expose.endpoint;
this.identifier = LockHandler.generateIdentifier(endpoint);

const potentialStateExpose = expose.features.find(e => LockHandler.PREDICATE_STATE(e)
&& !accessory.isPropertyExcluded(e.property)) as ExposesEntryWithBinaryProperty;
const potentialStateExpose = expose.features.find(e => LockHandler.PREDICATE_STATE(e)) as ExposesEntryWithBinaryProperty;
if (potentialStateExpose === undefined) {
throw new Error(`Required "${LockHandler.NAME_STATE}" property not found for Lock.`);
}
this.stateExpose = potentialStateExpose;

const potentialLockStateExpose = expose.features.find(e => LockHandler.PREDICATE_LOCK_STATE(e)
&& !accessory.isPropertyExcluded(e.property)) as ExposesEntryWithEnumProperty;
const potentialLockStateExpose = expose.features.find(e => LockHandler.PREDICATE_LOCK_STATE(e)) as ExposesEntryWithEnumProperty;
if (potentialLockStateExpose === undefined) {
throw new Error(`Required "${LockHandler.NAME_LOCK_STATE}" property not found for Lock.`);
}
Expand Down
5 changes: 2 additions & 3 deletions src/converters/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class SwitchCreator implements ServiceCreator {
exposeAsOutlet = true;
}
exposes.filter(e => e.type === ExposesKnownTypes.SWITCH && exposesHasFeatures(e)
&& exposesHasAllRequiredFeatures(e, [SwitchHandler.PREDICATE_STATE], accessory.isPropertyExcluded.bind(accessory))
&& exposesHasAllRequiredFeatures(e, [SwitchHandler.PREDICATE_STATE])
&& !accessory.isServiceHandlerIdKnown(SwitchHandler.generateIdentifier(exposeAsOutlet, e.endpoint)))
.forEach(e => this.createService(e as ExposesEntryWithFeatures, accessory, exposeAsOutlet));
}
Expand Down Expand Up @@ -69,8 +69,7 @@ class SwitchHandler implements ServiceHandler {

this.identifier = SwitchHandler.generateIdentifier(exposeAsOutlet, endpoint);

const potentialStateExpose = expose.features.find(e => SwitchHandler.PREDICATE_STATE(e)
&& !accessory.isPropertyExcluded(e.property)) as ExposesEntryWithBinaryProperty;
const potentialStateExpose = expose.features.find(e => SwitchHandler.PREDICATE_STATE(e)) as ExposesEntryWithBinaryProperty;
if (potentialStateExpose === undefined) {
throw new Error(`Required "state" property not found for ${serviceTypeName}.`);
}
Expand Down
10 changes: 0 additions & 10 deletions src/docgen/docs_accessory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,6 @@ export class DocsAccessory implements BasicAccessory {
// Do nothing
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
isPropertyExcluded(property: string | undefined): boolean {
return false;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
isValueAllowedForProperty(property: string, value: string): boolean {
return true;
}

registerServiceHandler(handler: ServiceHandler): void {
this.handlerIds.add(handler.identifier);
}
Expand Down

0 comments on commit 79eec06

Please sign in to comment.