Skip to content

Commit

Permalink
Add support for motor_state
Browse files Browse the repository at this point in the history
  • Loading branch information
burmistrzak committed May 6, 2024
1 parent b18c387 commit 6c9e4b7
Show file tree
Hide file tree
Showing 3 changed files with 286 additions and 17 deletions.
97 changes: 80 additions & 17 deletions src/converters/cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[] = [];

Expand Down Expand Up @@ -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;
Expand All @@ -173,13 +185,35 @@ class CoverHandler implements ServiceHandler {
updateState(state: Record<string, unknown>): 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;
}
Expand All @@ -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 {
Expand All @@ -210,7 +244,7 @@ class CoverHandler implements ServiceHandler {
}

private startOrRestartUpdateTimer(): void {
if (this.updateTimer === undefined) {
if (this.updateTimer === undefined || this.hasMotorState) {
return;
}

Expand Down Expand Up @@ -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);
}
}
}

Expand All @@ -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;

Expand Down
103 changes: 103 additions & 0 deletions test/cover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
103 changes: 103 additions & 0 deletions test/exposes/bosch/bmct-slz.json
Original file line number Diff line number Diff line change
@@ -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
}
]

0 comments on commit 6c9e4b7

Please sign in to comment.