Skip to content

Commit

Permalink
Filter exposes information before calling service handlers.
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 570e941
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 570e941

Please sign in to comment.