Skip to content

Commit

Permalink
Support for cover devices that only expose tilt (#257)
Browse files Browse the repository at this point in the history
* Support for cover devices that only expose tilt. (see #254)

* Refactor the changes for tilt only covers slightly.
  • Loading branch information
itavero authored Aug 16, 2021
1 parent 2a6e5f5 commit 597ef28
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Since version 1.0.0, we try to follow the [Semantic Versioning](https://semver.o
### Added

- The plugin will now log an error if the output format of Zigbee2MQTT (`experimental.output`) appears to have been configured incorrectly.
- Support for `cover` devices that only expose `tilt` and no `position`. (see [#254](https://github.com/itavero/homebridge-z2m/issues/254))

### Changed

Expand Down
4 changes: 2 additions & 2 deletions docs/cover.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ The table below shows how the different features within this `exposes` entry are

| Name | Required access | Characteristic | Remarks |
|-|-|-|-|
| `position` | published, set | [Current Position](https://developers.homebridge.io/#/characteristic/CurrentPosition) (for the value from MQTT),<br>[Target Position](https://developers.homebridge.io/#/characteristic/TargetPosition) (for the value set from HomeKit) | Required |
| `tilt` | published, set | [Current Horizontal Tilt Angle](https://developers.homebridge.io/#/characteristic/CurrentHorizontalTiltAngle) (for the value from MQTT),<br>[Target Horizontal Tilt Angle](https://developers.homebridge.io/#/characteristic/TargetHorizontalTiltAngle) (for the value set from HomeKit)| Optional |
| `position` | published, set | [Current Position](https://developers.homebridge.io/#/characteristic/CurrentPosition) (for the value from MQTT),<br>[Target Position](https://developers.homebridge.io/#/characteristic/TargetPosition) (for the value set from HomeKit) | Required (unless `tilt` is present) |
| `tilt` | published, set | [Current Horizontal Tilt Angle](https://developers.homebridge.io/#/characteristic/CurrentHorizontalTiltAngle) (for the value from MQTT),<br>[Target Horizontal Tilt Angle](https://developers.homebridge.io/#/characteristic/TargetHorizontalTiltAngle) (for the value set from HomeKit)| Optional. Will be used as _Current Position_ if `position` is not available. |

The required [Position State](https://developers.homebridge.io/#/characteristic/PositionState) characteristic is set when the _Target Position_ is changed or when an `position` is received from MQTT (and the movement is assumed to be stopped).

Expand Down
4 changes: 4 additions & 0 deletions docs/devices/current_products_corp/cp180335e-01.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ the Current Products Corp CP180335E-01
* BatteryLevel
* ChargingState
* StatusLowBattery
* [WindowCovering](../../cover.md)
* CurrentPosition
* PositionState
* TargetPosition


# Related
Expand Down
4 changes: 2 additions & 2 deletions docs/devices/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ This page lists the devices currently supported by Zigbee2MQTT v1.21.0 (which de
Using an automated script, we have checked which HomeKit Services (and Characteristics) would be created for each of these devices.
That way you have some kind of idea of what kind of devices are supported.

Currently there are **1541 supported devices** for which homebridge-z2m will expose at least one HomeKit service.
Unfortunately there are still 66 devices that are not yet supported by this plugin, but are supported by Zigbee2MQTT.
Currently there are **1542 supported devices** for which homebridge-z2m will expose at least one HomeKit service.
Unfortunately there are still 65 devices that are not yet supported by this plugin, but are supported by Zigbee2MQTT.

## A
<div style="clear:both" />
Expand Down
15 changes: 11 additions & 4 deletions src/converters/cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,19 @@ class CoverHandler implements ServiceHandler {
const endpoint = expose.endpoint;
this.identifier = CoverHandler.generateIdentifier(endpoint);

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

if (positionExpose === undefined) {
throw new Error('Required "position" property not found for WindowCovering.');
if (this.tiltExpose !== undefined) {
// Tilt only device
positionExpose = this.tiltExpose;
this.tiltExpose = undefined;
} else {
throw new Error('Required "position" property not found for WindowCovering and no "tilt" as backup.');
}
}
this.positionExpose = positionExpose;

Expand Down Expand Up @@ -81,8 +90,6 @@ class CoverHandler implements ServiceHandler {
}

// Tilt
this.tiltExpose = expose.features.find(e => exposesHasNumericRangeProperty(e) && !accessory.isPropertyExcluded(e.property)
&& e.name === 'tilt' && exposesCanBeSet(e) && exposesIsPublished(e)) as ExposesEntryWithNumericRangeProperty | undefined;
if (this.tiltExpose !== undefined) {
getOrAddCharacteristic(this.service, hap.Characteristic.CurrentHorizontalTiltAngle);
this.monitors.push(new NumericCharacteristicMonitor(this.tiltExpose.property, this.service,
Expand Down
247 changes: 247 additions & 0 deletions test/cover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,4 +512,251 @@ describe('Cover', () => {
harness.clearMocks();
});
});

describe('Current Products Corp CP180335E-01', () => {
const deviceModelJson = `{
"date_code":"",
"definition":{
"description":"Gen. 2 hybrid E-Wand",
"exposes":[
{
"access":1,
"description":"Remaining battery in %",
"name":"battery",
"property":"battery",
"type":"numeric",
"unit":"%",
"value_max":100,
"value_min":0
},
{
"features":[
{
"access":3,
"name":"state",
"property":"state",
"type":"enum",
"values":[
"OPEN",
"CLOSE",
"STOP"
]
},
{
"access":7,
"description":"Tilt of this cover",
"name":"tilt",
"property":"tilt",
"type":"numeric",
"value_max":100,
"value_min":0
}
],
"type":"cover"
},
{
"access":1,
"description":"Link quality (signal strength)",
"name":"linkquality",
"property":"linkquality",
"type":"numeric",
"unit":"lqi",
"value_max":255,
"value_min":0
}
],
"model":"CP180335E-01",
"supports_ota":false,
"vendor":"Current Products Corp"
},
"endpoints":{
"1":{
"bindings":[
{
"cluster":"genPowerCfg",
"target":{
"endpoint":1,
"ieee_address":"0x00212effff074469",
"type":"endpoint"
}
},
{
"cluster":"closuresWindowCovering",
"target":{
"endpoint":1,
"ieee_address":"0x00212effff074469",
"type":"endpoint"
}
}
],
"clusters":{
"input":[
"genBasic",
"genPowerCfg",
"genIdentify",
"genGroups",
"genScenes",
"genOnOff",
"genLevelCtrl",
"genPollCtrl",
"closuresWindowCovering",
"haDiagnostic"
],
"output":[
"genIdentify",
"genOta"
]
},
"configured_reportings":[
{
"attribute":"batteryPercentageRemaining",
"cluster":"genPowerCfg",
"maximum_report_interval":62000,
"minimum_report_interval":3600,
"reportable_change":0
},
{
"attribute":"currentPositionTiltPercentage",
"cluster":"closuresWindowCovering",
"maximum_report_interval":62000,
"minimum_report_interval":1,
"reportable_change":1
}
]
}
},
"friendly_name":"0x847127fffe4cf99c",
"ieee_address":"0x847127fffe4cf99c",
"interview_completed":true,
"interviewing":false,
"manufacturer":"Current Products Corp",
"model_id":"E-Wand",
"network_address":57023,
"power_source":"Battery",
"supported":true,
"type":"EndDevice"
}`;

// Shared "state"
let deviceExposes: ExposesEntry[] = [];
let harness: ServiceHandlersTestHarness;

beforeEach(() => {
// Only test service creation for first test case and reuse harness afterwards
if (deviceExposes.length === 0 && harness === undefined) {
// Test JSON Device List entry
const device = testJsonDeviceListEntry(deviceModelJson);
deviceExposes = device?.definition?.exposes ?? [];
expect(deviceExposes?.length).toBeGreaterThan(0);
const newHarness = new ServiceHandlersTestHarness();

// Check service creation
const windowCovering = newHarness.getOrAddHandler(hap.Service.WindowCovering)
.addExpectedCharacteristic('position', hap.Characteristic.CurrentPosition, false, 'tilt')
.addExpectedCharacteristic('target_position', hap.Characteristic.TargetPosition, true, undefined, false)
.addExpectedCharacteristic('position_state', hap.Characteristic.PositionState, false, undefined, false);
newHarness.prepareCreationMocks();

const positionCharacteristicMock = windowCovering.getCharacteristicMock('position');
if (positionCharacteristicMock !== undefined) {
positionCharacteristicMock.props.minValue = 0;
positionCharacteristicMock.props.maxValue = 100;
}

const targetPositionCharacteristicMock = windowCovering.getCharacteristicMock('target_position');
if (targetPositionCharacteristicMock !== undefined) {
targetPositionCharacteristicMock.props.minValue = 0;
targetPositionCharacteristicMock.props.maxValue = 100;
}

newHarness.callCreators(deviceExposes);

newHarness.checkCreationExpectations();
harness = newHarness;
}
harness?.clearMocks();
});

afterEach(() => {
verifyAllWhenMocksCalled();
resetAllWhenMocks();
});

test('Status update is handled: Position changes', () => {
expect(harness).toBeDefined();

// First update (previous state is unknown, so)
harness.checkUpdateState('{"tilt":100}', hap.Service.WindowCovering, new Map([
[hap.Characteristic.CurrentPosition, 100],
[hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED],
[hap.Characteristic.TargetPosition, 100],
]));
harness.clearMocks();
});

test('HomeKit: Change target position', () => {
expect(harness).toBeDefined();

// Set current position to a known value, to check assumed position state
harness.checkUpdateState('{"tilt":50}', hap.Service.WindowCovering, new Map([
[hap.Characteristic.CurrentPosition, 50],
[hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED],
[hap.Characteristic.TargetPosition, 50],
]));
harness.clearMocks();

// Check changing the position to a higher value
harness.checkHomeKitUpdate(hap.Service.WindowCovering, 'target_position', 51, { tilt: 51 });
const windowCovering = harness.getOrAddHandler(hap.Service.WindowCovering).checkCharacteristicUpdates(new Map([
[hap.Characteristic.PositionState, hap.Characteristic.PositionState.INCREASING],
]));
harness.clearMocks();

// Receive status update with target position that was previously send.
// This should be ignored.
harness.checkUpdateStateIsIgnored('{"tilt":51}');
harness.clearMocks();

// Check changing the position to a lower value
harness.checkHomeKitUpdate(hap.Service.WindowCovering, 'target_position', 49, { tilt: 49 });
windowCovering.checkCharacteristicUpdates(new Map([
[hap.Characteristic.PositionState, hap.Characteristic.PositionState.DECREASING],
]));
harness.clearMocks();

// Send two updates - should stop timer
harness.checkUpdateState('{"tilt":51}', hap.Service.WindowCovering, new Map([
[hap.Characteristic.CurrentPosition, 51],
]));
harness.clearMocks();
harness.checkUpdateState('{"tilt":49}', hap.Service.WindowCovering, new Map([
[hap.Characteristic.CurrentPosition, 49],
]));
harness.clearMocks();
harness.checkUpdateState('{"tilt":49}', hap.Service.WindowCovering, new Map([
[hap.Characteristic.CurrentPosition, 49],
[hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED],
[hap.Characteristic.TargetPosition, 49],
]));
harness.clearMocks();

// Check timer - should request position
jest.runOnlyPendingTimers();
windowCovering.checkNoCharacteristicUpdates();
harness.checkNoGetKeysQueued();

// Check changing the position to the same value as was last received
harness.checkHomeKitUpdate(hap.Service.WindowCovering, 'target_position', 49, { tilt: 49 });
windowCovering.checkCharacteristicUpdates(new Map([
[hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED],
]));
harness.clearMocks();

// Check timer - should request position
jest.runOnlyPendingTimers();
harness.checkGetKeysQueued('tilt');
harness.clearMocks();
});
});

});

0 comments on commit 597ef28

Please sign in to comment.