Skip to content

Commit

Permalink
[opengarage] Add door transition status support (openhab#14028)
Browse files Browse the repository at this point in the history
* Add support for garage door transition status

Homekit requires a status for the garage door of OPEN, CLOSED, CLOSING,
OPENING. In order to report that, we must provide state transition
information. State transition information is inferred when the garage
door state is changed. For door_transition_time_seconds since the last
open/close command was issued, the binding reports the state as either
"closing" or "opening".

---------

Signed-off-by: Tim Harper <[email protected]>
Co-authored-by: Laurent Garnier <[email protected]>
Signed-off-by: Jørgen Austvik <[email protected]>
  • Loading branch information
2 people authored and austvik committed Mar 27, 2024
1 parent 559a7cf commit 39069fc
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 103 deletions.
34 changes: 33 additions & 1 deletion bundles/org.openhab.binding.opengarage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The OpenGarage binding allows you to control an OpenGarage controller (<https://

## Supported Things

Opengarage controllers from <https://opensprinkler.com/product/opengarage/> are supported.
OpenGarage controllers from <https://opensprinkler.com/product/opengarage/> are supported.

## Discovery

Expand All @@ -19,13 +19,21 @@ As a minimum, the IP address is needed:
- `port` - the port the OpenGarage is listening on. Defaults to port 80
- `refresh` - The frequency with which to refresh information from the OpenGarage controller specified in seconds. Defaults to 10 seconds.
- `password` - The password to send commands to the OpenGarage. Defaults to "opendoor"
- `doorTransitionTimeSeconds` - Specifies how long it takes the garage door
to fully open / close after triggering it from OpenGarage, including auditory
beeps. Recommend to round up or pad by a second or two.
- `doorOpeningState` - Text state to report when garage is opening. Defaults to "OPENING".
- `doorOpenState` - Text state to report when garage is open (and not in transition). Defaults to "OPEN".
- `doorClosingState` - Text state to report when garage is closing. Defaults to "CLOSING".
- `doorClosedState` - Text state to report when garage is closed (and not in transition). Defaults to "CLOSED".

## Channels

| channel | type | description |
|----------------------|---------------|---------------------------------------------------------------------------------------|
| distance | Number:Length | Distance reading from the OpenGarage controller (default in cm) |
| status-switch | Switch | Door status (OFF = Closed, ON = Open), set "invert=true" on channel to invert switch |
| status-text | String | Text status of the current door state, including transition, using values from configuration: doorOpeningState, doorOpenState, doorClosingState, doorClosedState. |
| status-contact | Contact | Door status (Open or Closed) |
| status-rollershutter | Rollershutter | Door status (DOWN = Closed, UP = Open) |
| vehicle-status | Number | Report vehicle presence (0=Not Detected, 1=Detected, 2=Unknown) |
Expand All @@ -46,16 +54,40 @@ Contact OpenGarage_Status_Contact { channel="opengarage:opengarage:OpenGarage:st
Rollershutter OpenGarage_Status_Rollershutter { channel="opengarage:opengarage:OpenGarage:status-rollershutter" }
Number:Length OpenGarage_Distance { channel="opengarage:opengarage:OpenGarage:setpoint" }
String OpenGarage_Vehicle { channel="opengarage:opengarage:OpenGarage:vehicle" }
String OpenGarage_StatusText { channel="opengarage:opengarage:OpenGarage:status-text" }
```

opengarage.sitemap:

```perl
Text item=OpenGarage_StatusText label="Status"
Switch item=OpenGarage_Status icon="garagedoorclosed" mappings=[ON=Open] visibility=[OpenGarage_Status == OFF]
Switch item=OpenGarage_Status icon="garagedooropen" mappings=[OFF=Close] visibility=[OpenGarage_Status == ON]
Switch item=OpenGarage_Status icon="garage"
Contact item=OpenGarage_Status_Contact icon="garage"
Rollershutter item=OpenGarage_Status_Rollershutter icon="garage"
Text item=OpenGarage_Distance label="OG distance"
Text item=OpenGarage_Vehicle label="Vehicle Presence"

```

## Adding to HomeKit

If you have the HomeKit extension installed, you can control your OpenGarage instance via your iPhone.
To wire it up to HomeKit, you might specify the following:

opengarage.items

```
Group gOpenGarage "OpenGarage Door" {homekit="GarageDoorOpener"}
Switch OpenGarage_TargetState "Target state" (gOpenGarage) {homekit="GarageDoorOpener.TargetDoorState", channel="opengarage:opengarage:deadbeef:status-switch"}
String OpenGarage_CurrentState "Current state" (gOpenGarage) {homekit="GarageDoorOpener.CurrentDoorState", channel="opengarage:opengarage:deadbeef:status-text"}
Switch OpenGarage_xxObstruction "Obstruction (do not use)" (gOpenGarage) {homekit="GarageDoorOpener.ObstructionStatus"}
```

The obstruction channel is not bound to any channel.
It's needed because HomeKit requires it, and OpenGarage does not provide it.
HomeKit requires a status for the garage door of `OPEN`, `CLOSED`, `CLOSING`, `OPENING`.
In order to report that, we must provide state transition information.
State transition information is inferred when the garage door state is changed.
For `doorTransitionTimeSeconds` since the last open/close command was issued, the binding reports the state as either "closing" or "opening".
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class OpenGarageBindingConstants {
// List of all Channel ids
public static final String CHANNEL_OG_DISTANCE = "distance";
public static final String CHANNEL_OG_STATUS = "status"; // now deprecated
public static final String CHANNEL_OG_STATUS_TEXT = "status-text";
public static final String CHANNEL_OG_STATUS_SWITCH = "status-switch";
public static final String CHANNEL_OG_STATUS_CONTACT = "status-contact";
public static final String CHANNEL_OG_STATUS_ROLLERSHUTTER = "status-rollershutter";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,23 @@
*/
package org.openhab.binding.opengarage.internal;

import org.eclipse.jdt.annotation.NonNullByDefault;

/**
* The OpenGarageConfiguration class contains fields mapping thing configuration parameters.
*
* @author Paul Smedley - Initial contribution
*/
@NonNullByDefault
public class OpenGarageConfiguration {
public String hostname;
public long port = 80;
public String hostname = "";
public int port = 80;
public String password = "opendoor";
public long refresh = 10;
public int refresh = 10;

public String doorOpeningState = "OPENING";
public String doorOpenState = "OPEN";
public String doorClosedState = "CLOSED";
public String doorClosingState = "CLOSING";
public int doorTransitionTimeSeconds = 17;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
package org.openhab.binding.opengarage.internal;

import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.opengarage.internal.api.ControllerVariables;
import org.openhab.binding.opengarage.internal.api.Enums.OpenGarageCommand;
import org.openhab.core.library.types.DecimalType;
Expand Down Expand Up @@ -52,34 +54,47 @@ public class OpenGarageHandler extends BaseThingHandler {

private final Logger logger = LoggerFactory.getLogger(OpenGarageHandler.class);

private long refreshInterval;

private @NonNullByDefault({}) OpenGarageWebTargets webTargets;
private @Nullable ScheduledFuture<?> pollFuture;

// reference to periodically scheduled poll task
private Future<?> pollScheduledFuture = CompletableFuture.completedFuture(null);

// reference to one-shot poll task which gets scheduled after a garage state change command
private Future<?> pollScheduledFutureTransition = CompletableFuture.completedFuture(null);
private Instant lastTransition;
private String lastTransitionText;

private OpenGarageConfiguration config = new OpenGarageConfiguration();

public OpenGarageHandler(Thing thing) {
super(thing);
this.lastTransition = Instant.MIN;
this.lastTransitionText = "";
}

@Override
public void handleCommand(ChannelUID channelUID, Command command) {
public synchronized void handleCommand(ChannelUID channelUID, Command command) {
try {
logger.debug("Received command {} for thing '{}' on channel {}", command, thing.getUID().getAsString(),
channelUID.getId());
boolean invert = isChannelInverted(channelUID.getId());
Function<Boolean, Boolean> maybeInvert = getInverter(channelUID.getId());
switch (channelUID.getId()) {
case OpenGarageBindingConstants.CHANNEL_OG_STATUS:
case OpenGarageBindingConstants.CHANNEL_OG_STATUS_SWITCH:
case OpenGarageBindingConstants.CHANNEL_OG_STATUS_ROLLERSHUTTER:
if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)) {
changeStatus(invert ? OpenGarageCommand.CLOSE : OpenGarageCommand.OPEN);
return;
} else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)) {
changeStatus(invert ? OpenGarageCommand.OPEN : OpenGarageCommand.CLOSE);
return;
} else if (command.equals(StopMoveType.STOP) || command.equals(StopMoveType.MOVE)) {
if (command.equals(StopMoveType.STOP) || command.equals(StopMoveType.MOVE)) {
changeStatus(OpenGarageCommand.CLICK);
return;
} else {
boolean doorOpen = command.equals(OnOffType.ON) || command.equals(UpDownType.UP);
changeStatus(maybeInvert.apply(doorOpen) ? OpenGarageCommand.OPEN : OpenGarageCommand.CLOSE);
this.lastTransition = Instant.now();
this.lastTransitionText = doorOpen ? this.config.doorOpeningState
: this.config.doorClosingState;

this.poll(); // invoke poll directly to communicate the door transition state
this.pollScheduledFutureTransition.cancel(false);
this.pollScheduledFutureTransition = this.scheduler.schedule(this::poll,
this.config.doorTransitionTimeSeconds, TimeUnit.SECONDS);
}
break;
default:
Expand All @@ -91,107 +106,111 @@ public void handleCommand(ChannelUID channelUID, Command command) {

@Override
public void initialize() {
OpenGarageConfiguration config = getConfigAs(OpenGarageConfiguration.class);
this.config = getConfigAs(OpenGarageConfiguration.class);
logger.debug("config.hostname = {}, refresh = {}, port = {}", config.hostname, config.refresh, config.port);
if (config.hostname == null) {
if (config.hostname.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Hostname/IP address must be set");
} else {
webTargets = new OpenGarageWebTargets(config.hostname, config.port, config.password);
refreshInterval = config.refresh;

schedulePoll();
updateStatus(ThingStatus.UNKNOWN);
int requestTimeout = Math.max(OpenGarageWebTargets.DEFAULT_TIMEOUT_MS, config.refresh * 1000);
webTargets = new OpenGarageWebTargets(config.hostname, config.port, config.password, requestTimeout);
this.pollScheduledFuture = this.scheduler.scheduleWithFixedDelay(this::poll, 1, config.refresh,
TimeUnit.SECONDS);
}
}

@Override
public void dispose() {
this.pollScheduledFuture.cancel(true);
this.pollScheduledFutureTransition.cancel(true);
super.dispose();
stopPoll();
}

private void schedulePoll() {
if (pollFuture != null) {
pollFuture.cancel(false);
}
logger.debug("Scheduling poll for 1 second out, then every {} s", refreshInterval);
pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 1, refreshInterval, TimeUnit.SECONDS);
}

private void poll() {
/**
* Update the state of the controller.
*
*
*/
private synchronized void poll() {
try {
logger.debug("Polling for state");
pollStatus();
ControllerVariables controllerVariables = webTargets.getControllerVariables();
long lastTransitionAgoSecs = Duration.between(lastTransition, Instant.now()).getSeconds();
boolean inTransition = lastTransitionAgoSecs < this.config.doorTransitionTimeSeconds;
if (controllerVariables != null) {
updateStatus(ThingStatus.ONLINE);
updateState(OpenGarageBindingConstants.CHANNEL_OG_DISTANCE,
new QuantityType<>(controllerVariables.dist, MetricPrefix.CENTI(SIUnits.METRE)));
Function<Boolean, Boolean> maybeInvert = getInverter(
OpenGarageBindingConstants.CHANNEL_OG_STATUS_SWITCH);

if ((controllerVariables.door != 0) && (controllerVariables.door != 1)) {
logger.debug("Received unknown door value: {}", controllerVariables.door);
} else {
boolean doorOpen = controllerVariables.door == 1;
OnOffType onOff = maybeInvert.apply(doorOpen) ? OnOffType.ON : OnOffType.OFF;
UpDownType upDown = doorOpen ? UpDownType.UP : UpDownType.DOWN;
OpenClosedType contact = doorOpen ? OpenClosedType.OPEN : OpenClosedType.CLOSED;

String transitionText;
if (inTransition) {
transitionText = this.lastTransitionText;
} else {
transitionText = doorOpen ? this.config.doorOpenState : this.config.doorClosedState;
}
if (!inTransition) {
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS, onOff); // deprecated channel
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_SWITCH, onOff);
}
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_ROLLERSHUTTER, upDown);
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_CONTACT, contact);
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_TEXT, new StringType(transitionText));
}

switch (controllerVariables.vehicle) {
case 0:
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE,
new StringType("No vehicle detected"));
break;
case 1:
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE, new StringType("Vehicle detected"));
break;
case 2:
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE,
new StringType("Vehicle status unknown"));
break;
case 3:
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE,
new StringType("Vehicle status not available"));
break;

default:
logger.debug("Received unknown vehicle value: {}", controllerVariables.vehicle);
}
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE_STATUS,
new DecimalType(controllerVariables.vehicle));
}
} catch (IOException e) {
logger.debug("Could not connect to OpenGarage controller", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Could not connect to OpenGarage controller");
} catch (RuntimeException e) {
logger.warn("Unexpected error connecting to OpenGarage controller", e);
logger.debug("Unexpected error connecting to OpenGarage controller", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}

private void stopPoll() {
final Future<?> future = pollFuture;
if (future != null && !future.isCancelled()) {
future.cancel(true);
pollFuture = null;
}
}

private void pollStatus() throws IOException {
ControllerVariables controllerVariables = webTargets.getControllerVariables();
updateStatus(ThingStatus.ONLINE);
if (controllerVariables != null) {
updateState(OpenGarageBindingConstants.CHANNEL_OG_DISTANCE,
new QuantityType<>(controllerVariables.dist, MetricPrefix.CENTI(SIUnits.METRE)));
boolean invert = isChannelInverted(OpenGarageBindingConstants.CHANNEL_OG_STATUS_SWITCH);
switch (controllerVariables.door) {
case 0:
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS, invert ? OnOffType.ON : OnOffType.OFF);
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_SWITCH,
invert ? OnOffType.ON : OnOffType.OFF);
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_ROLLERSHUTTER, UpDownType.DOWN);
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_CONTACT, OpenClosedType.CLOSED);
break;
case 1:
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS, invert ? OnOffType.OFF : OnOffType.ON);
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_SWITCH,
invert ? OnOffType.OFF : OnOffType.ON);
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_ROLLERSHUTTER, UpDownType.UP);
updateState(OpenGarageBindingConstants.CHANNEL_OG_STATUS_CONTACT, OpenClosedType.OPEN);
break;
default:
logger.warn("Received unknown door value: {}", controllerVariables.door);
}
switch (controllerVariables.vehicle) {
case 0:
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE, new StringType("No vehicle detected"));
break;
case 1:
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE, new StringType("Vehicle detected"));
break;
case 2:
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE,
new StringType("Vehicle status unknown"));
break;
case 3:
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE,
new StringType("Vehicle status not available"));
break;
default:
logger.warn("Received unknown vehicle value: {}", controllerVariables.vehicle);
}
updateState(OpenGarageBindingConstants.CHANNEL_OG_VEHICLE_STATUS,
new DecimalType(controllerVariables.vehicle));
}
}

private void changeStatus(OpenGarageCommand status) throws OpenGarageCommunicationException {
webTargets.setControllerVariables(status);
}

private boolean isChannelInverted(String channelUID) {
private Function<Boolean, Boolean> getInverter(String channelUID) {
Channel channel = getThing().getChannel(channelUID);
return channel != null && channel.getConfiguration().as(OpenGarageChannelConfiguration.class).invert;
boolean invert = channel != null && channel.getConfiguration().as(OpenGarageChannelConfiguration.class).invert;
if (invert) {
return onOff -> !onOff;
} else {
return Function.identity();
}
}
}
Loading

0 comments on commit 39069fc

Please sign in to comment.