From 6c9e4b79bb709f32185e09b18856a097d1e57e86 Mon Sep 17 00:00:00 2001 From: burmistrzak <61958704+burmistrzak@users.noreply.github.com> Date: Mon, 6 May 2024 20:37:27 +0200 Subject: [PATCH] Add support for `motor_state` --- src/converters/cover.ts | 97 ++++++++++++++++++++++++----- test/cover.spec.ts | 103 +++++++++++++++++++++++++++++++ test/exposes/bosch/bmct-slz.json | 103 +++++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 test/exposes/bosch/bmct-slz.json diff --git a/src/converters/cover.ts b/src/converters/cover.ts index c24166c9..390df74c 100644 --- a/src/converters/cover.ts +++ b/src/converters/cover.ts @@ -42,6 +42,10 @@ export class CoverCreator implements ServiceCreator { class CoverHandler implements ServiceHandler { private static readonly STATE_HOLD_POSITION = 'STOP'; + private static readonly MOTOR_STATE_DEFAULT = 'none'; + private static readonly MOTOR_STATE_OPENING = 'opening'; + private static readonly MOTOR_STATE_CLOSING = 'closing'; + private static readonly MOTOR_STATE_STOPPED = 'stopped'; private readonly positionExpose: ExposesEntryWithNumericRangeProperty; private readonly tiltExpose: ExposesEntryWithNumericRangeProperty | undefined; private readonly stateExpose: ExposesEntryWithEnumProperty | undefined; @@ -58,6 +62,10 @@ class CoverHandler implements ServiceHandler { private ignoreNextUpdateIfEqualToTarget: boolean; private lastPositionSet = -1; private positionCurrent = -1; + private motorState: string; + private motorStatePrevious: string; + private hasMotorState: boolean; + private setTargetPositionHandled: boolean; public readonly mainCharacteristics: Characteristic[] = []; @@ -156,6 +164,10 @@ class CoverHandler implements ServiceHandler { } this.waitingForUpdate = false; this.ignoreNextUpdateIfEqualToTarget = false; + this.hasMotorState = false; + this.setTargetPositionHandled = false; + this.motorState = CoverHandler.MOTOR_STATE_DEFAULT; + this.motorStatePrevious = CoverHandler.MOTOR_STATE_DEFAULT; } identifier: string; @@ -173,13 +185,35 @@ class CoverHandler implements ServiceHandler { updateState(state: Record): void { this.monitors.forEach((m) => m.callback(state)); + if ('motor_state' in state) { + const latestMotorState = state['motor_state'] as string; + switch (latestMotorState) { + case 'opening': + this.motorState = CoverHandler.MOTOR_STATE_OPENING; + break; + case 'closing': + this.motorState = CoverHandler.MOTOR_STATE_CLOSING; + break; + case 'stopped': + case 'pause': + default: + this.motorState = CoverHandler.MOTOR_STATE_STOPPED; + break; + } + this.hasMotorState = true; + if (this.updateTimer !== undefined) { + this.accessory.log.debug(`${this.accessory.displayName}: cover: motor_state detected, stopping updateTimer`); + this.updateTimer.stop(); + } + } + if (this.positionExpose.property in state) { const latestPosition = state[this.positionExpose.property] as number; // Ignore "first" update? const doIgnoreIfEqual = this.ignoreNextUpdateIfEqualToTarget; + this.ignoreNextUpdateIfEqualToTarget = false; if (latestPosition === this.lastPositionSet && doIgnoreIfEqual) { - this.ignoreNextUpdateIfEqualToTarget = false; this.accessory.log.debug(`${this.accessory.displayName}: cover: ignore position update (equal to last target)`); return; } @@ -192,8 +226,8 @@ class CoverHandler implements ServiceHandler { let didStop = true; // As long as the update timer is running, we are expecting updates. - if (this.updateTimer !== undefined && this.updateTimer.isActive) { - if (latestPosition === this.positionCurrent && !doIgnoreIfEqual) { + if (this.updateTimer !== undefined && this.updateTimer.isActive && !this.hasMotorState) { + if (latestPosition === this.positionCurrent) { // Stop requesting frequent updates if no change is detected. this.updateTimer.stop(); } else { @@ -210,7 +244,7 @@ class CoverHandler implements ServiceHandler { } private startOrRestartUpdateTimer(): void { - if (this.updateTimer === undefined) { + if (this.updateTimer === undefined || this.hasMotorState) { return; } @@ -252,14 +286,39 @@ class CoverHandler implements ServiceHandler { this.current_max ); - this.service.updateCharacteristic(hap.Characteristic.CurrentPosition, characteristicValue); - - if (isStopped) { - // Update target position and position state - // This should improve the UX in the Home.app - this.accessory.log.debug(`${this.accessory.displayName}: cover: assume movement stopped`); + if (this.motorState === CoverHandler.MOTOR_STATE_CLOSING && this.motorStatePrevious !== CoverHandler.MOTOR_STATE_CLOSING) { + this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.DECREASING); + this.accessory.log.debug(`${this.accessory.displayName}: cover: closing via motor_state`); + if (!this.setTargetPositionHandled) { + this.service.updateCharacteristic(hap.Characteristic.TargetPosition, 0); + } + this.motorStatePrevious = CoverHandler.MOTOR_STATE_CLOSING; + this.setTargetPositionHandled = false; + } else if (this.motorState === CoverHandler.MOTOR_STATE_OPENING && this.motorStatePrevious !== CoverHandler.MOTOR_STATE_OPENING) { + this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.INCREASING); + this.accessory.log.debug(`${this.accessory.displayName}: cover: opening via motor_state`); + if (!this.setTargetPositionHandled) { + this.service.updateCharacteristic(hap.Characteristic.TargetPosition, 100); + } + this.motorStatePrevious = CoverHandler.MOTOR_STATE_OPENING; + this.setTargetPositionHandled = false; + } else if (this.motorState === CoverHandler.MOTOR_STATE_STOPPED && this.motorStatePrevious !== CoverHandler.MOTOR_STATE_STOPPED) { this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED); + this.service.updateCharacteristic(hap.Characteristic.CurrentPosition, characteristicValue); this.service.updateCharacteristic(hap.Characteristic.TargetPosition, characteristicValue); + this.accessory.log.debug(`${this.accessory.displayName}: cover: stopped via motor_state`); + this.motorStatePrevious = CoverHandler.MOTOR_STATE_STOPPED; + this.setTargetPositionHandled = false; + } else if (this.motorState === CoverHandler.MOTOR_STATE_DEFAULT && !this.hasMotorState) { + this.service.updateCharacteristic(hap.Characteristic.CurrentPosition, characteristicValue); + + if (isStopped) { + // Update target position and position state + // This should improve the UX in the Home.app + this.accessory.log.debug(`${this.accessory.displayName}: cover: assume movement stopped`); + this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED); + this.service.updateCharacteristic(hap.Characteristic.TargetPosition, characteristicValue); + } } } @@ -276,15 +335,19 @@ class CoverHandler implements ServiceHandler { data[this.positionExpose.property] = target; this.accessory.queueDataForSetAction(data); - // Assume position state based on new target - if (target > this.positionCurrent) { - this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.INCREASING); - } else if (target < this.positionCurrent) { - this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.DECREASING); + if (!this.hasMotorState) { + // Assume position state based on new target + if (target > this.positionCurrent) { + this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.INCREASING); + } else if (target < this.positionCurrent) { + this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.DECREASING); + } else { + this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED); + } } else { - this.service.updateCharacteristic(hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED); + this.service.updateCharacteristic(hap.Characteristic.TargetPosition, target); + this.setTargetPositionHandled = true; } - // Store last sent position for future reference this.lastPositionSet = target; diff --git a/test/cover.spec.ts b/test/cover.spec.ts index 1f17df2a..bfa7e9d7 100644 --- a/test/cover.spec.ts +++ b/test/cover.spec.ts @@ -413,4 +413,107 @@ describe('Cover', () => { harness.checkHomeKitUpdate(hap.Service.WindowCovering, 'state', true, { state: 'STOP' }); }); }); + + describe('Bosch Light/shutter control unit II', () => { + // 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) { + // Load exposes from JSON + deviceExposes = loadExposesFromFile('bosch/bmct-slz.json'); + 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) + .addExpectedCharacteristic('target_position', hap.Characteristic.TargetPosition, true) + .addExpectedCharacteristic('position_state', hap.Characteristic.PositionState, false) + .addExpectedCharacteristic('state', hap.Characteristic.HoldPosition, true); + 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(); + newHarness.checkHasMainCharacteristics(); + newHarness.checkExpectedGetableKeys(['position']); + 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( + '{"position":100, "motor_state":"stopped"}', + hap.Service.WindowCovering, + new Map([ + [hap.Characteristic.CurrentPosition, 100], + [hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED], + [hap.Characteristic.TargetPosition, 100], + ]) + ); + harness.clearMocks(); + }); + + test('HomeKit: Hold position', () => { + expect(harness).toBeDefined(); + + harness.checkHomeKitUpdate(hap.Service.WindowCovering, 'state', true, { state: 'STOP' }); + }); + + test('HomeKit: Change target position', () => { + expect(harness).toBeDefined(); + + // Ignore known stopped position + harness.checkUpdateStateIsIgnored('{"position":100, "motor_state":"stopped"}'); + harness.clearMocks(); + + // Check changing the position to a lower value + harness.checkHomeKitUpdate(hap.Service.WindowCovering, 'target_position', 51, { position: 51 }); + harness.getOrAddHandler(hap.Service.WindowCovering).checkCharacteristicUpdates(new Map([[hap.Characteristic.TargetPosition, 51]])); + harness.clearMocks(); + + harness.checkUpdateState( + '{"position":41, "motor_state":"closing"}', + hap.Service.WindowCovering, + new Map([[hap.Characteristic.PositionState, hap.Characteristic.PositionState.DECREASING]]) + ); + harness.clearMocks(); + + harness.checkUpdateState( + '{"position":51, "motor_state":"stopped"}', + hap.Service.WindowCovering, + new Map([ + [hap.Characteristic.CurrentPosition, 51], + [hap.Characteristic.PositionState, hap.Characteristic.PositionState.STOPPED], + [hap.Characteristic.TargetPosition, 51], + ]) + ); + harness.clearMocks(); + }); + }); }); diff --git a/test/exposes/bosch/bmct-slz.json b/test/exposes/bosch/bmct-slz.json new file mode 100644 index 00000000..b5d15e27 --- /dev/null +++ b/test/exposes/bosch/bmct-slz.json @@ -0,0 +1,103 @@ +[ + { + "access": 7, + "description": "Module controlled by a rocker switch or a button", + "label": "Switch type", + "name": "switch_type", + "property": "switch_type", + "type": "enum", + "values": [ + "button", + "button_key_change", + "rocker_switch", + "rocker_switch_key_change" + ] + }, + { + "access": 1, + "category": "diagnostic", + "description": "Link quality (signal strength)", + "label": "Linkquality", + "name": "linkquality", + "property": "linkquality", + "type": "numeric", + "unit": "lqi", + "value_max": 255, + "value_min": 0 + }, + { + "features": [ + { + "access": 3, + "label": "State", + "name": "state", + "property": "state", + "type": "enum", + "values": [ + "OPEN", + "CLOSE", + "STOP" + ] + }, + { + "access": 7, + "description": "Position of this cover", + "label": "Position", + "name": "position", + "property": "position", + "type": "numeric", + "unit": "%", + "value_max": 100, + "value_min": 0 + } + ], + "type": "cover" + }, + { + "access": 1, + "description": "Shutter motor actual state ", + "label": "Motor state", + "name": "motor_state", + "property": "motor_state", + "type": "enum", + "values": [ + "stopped", + "opening", + "closing" + ] + }, + { + "access": 7, + "description": "Enable/Disable child lock", + "label": "Child lock", + "name": "child_lock", + "property": "child_lock", + "type": "binary", + "value_off": "OFF", + "value_on": "ON" + }, + { + "access": 7, + "description": "Calibration closing time", + "endpoint": "closing_time", + "label": "Calibration", + "name": "calibration", + "property": "calibration_closing_time", + "type": "numeric", + "unit": "s", + "value_max": 90, + "value_min": 1 + }, + { + "access": 7, + "description": "Calibration opening time", + "endpoint": "opening_time", + "label": "Calibration", + "name": "calibration", + "property": "calibration_opening_time", + "type": "numeric", + "unit": "s", + "value_max": 90, + "value_min": 1 + } +] \ No newline at end of file