[hue] Fix reconnection, parallel commands, trigger channels, and light level formula (#15169)

* [hue] post merge tweaks

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>

* [hue] abandon internal restart

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>

* [hue] remove externalRestartScheduled flag

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>

* [hue] serialize PUT calls

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>

* [hue] GET requests shall not activate trigger channels

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>

* [hue] fix LightLevel formula

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>

* [hue] fix Button DTO null error

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>

---------

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
This commit is contained in:
Andrew Fiddian-Green 2023-07-27 13:55:17 +01:00 committed by GitHub
parent 4eaba31f57
commit ae36108cbd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 50 additions and 124 deletions

View File

@ -69,8 +69,8 @@ Device things support some of the following channels:
| dynamics | Number:Time | Sets the duration of dynamic transitions between light states. (Advanced) | | dynamics | Number:Time | Sets the duration of dynamic transitions between light states. (Advanced) |
| alert | String | Allows setting an alert on a light e.g. flashing them. (Advanced) | | alert | String | Allows setting an alert on a light e.g. flashing them. (Advanced) |
| effect | String | Allows setting an effect on a light e.g. 'candle' effect. (Advanced) | | effect | String | Allows setting an effect on a light e.g. 'candle' effect. (Advanced) |
| button-last-event | Number | Informs which button was last pressed in the device. (Trigger Channel) | | button-last-event | (String) | Informs which button was last pressed in the device. (Trigger Channel) |
| rotary-steps | Number | Informs about the number of rotary steps of the last rotary dial movement. (Trigger Channel) | | rotary-steps | (String) | Informs about the number of rotary steps of the last rotary dial movement. (Trigger Channel) |
| motion | Switch | Shows if motion has been detected by the sensor. (Read Only) | | motion | Switch | Shows if motion has been detected by the sensor. (Read Only) |
| motion-enabled | Switch | Supports enabling / disabling the motion sensor. (Advanced) | | motion-enabled | Switch | Supports enabling / disabling the motion sensor. (Advanced) |
| light-level | Number:Illuminance | Shows the current light level measured by the sensor. (Read Only) | | light-level | Number:Illuminance | Shows the current light level measured by the sensor. (Read Only) |

View File

@ -416,7 +416,6 @@ public class Clip2Bridge implements Closeable {
private static final int CHECK_ALIVE_SECONDS = 300; private static final int CHECK_ALIVE_SECONDS = 300;
private static final int REQUEST_INTERVAL_MILLISECS = 50; private static final int REQUEST_INTERVAL_MILLISECS = 50;
private static final int MAX_CONCURRENT_STREAMS = 3; private static final int MAX_CONCURRENT_STREAMS = 3;
private static final int RESTART_AFTER_SECONDS = 5;
private static final ResourceReference BRIDGE = new ResourceReference().setType(ResourceType.BRIDGE); private static final ResourceReference BRIDGE = new ResourceReference().setType(ResourceType.BRIDGE);
@ -463,15 +462,12 @@ public class Clip2Bridge implements Closeable {
private final Semaphore streamMutex = new Semaphore(MAX_CONCURRENT_STREAMS, true); private final Semaphore streamMutex = new Semaphore(MAX_CONCURRENT_STREAMS, true);
private boolean closing; private boolean closing;
private boolean internalRestartScheduled;
private boolean externalRestartScheduled;
private State onlineState = State.CLOSED; private State onlineState = State.CLOSED;
private Optional<Instant> lastRequestTime = Optional.empty(); private Optional<Instant> lastRequestTime = Optional.empty();
private Instant sessionExpireTime = Instant.MAX; private Instant sessionExpireTime = Instant.MAX;
private @Nullable Session http2Session; private @Nullable Session http2Session;
private @Nullable Future<?> checkAliveTask; private @Nullable Future<?> checkAliveTask;
private @Nullable Future<?> internalRestartTask;
private Map<Integer, Future<?>> fatalErrorTasks = new ConcurrentHashMap<>(); private Map<Integer, Future<?>> fatalErrorTasks = new ConcurrentHashMap<>();
/** /**
@ -481,14 +477,20 @@ public class Clip2Bridge implements Closeable {
* @param bridgeHandler the bridge handler. * @param bridgeHandler the bridge handler.
* @param hostName the host name (ip address) of the Hue bridge * @param hostName the host name (ip address) of the Hue bridge
* @param applicationKey the application key. * @param applicationKey the application key.
* @throws ApiException if unable to open Jetty HTTP/2 client.
*/ */
public Clip2Bridge(HttpClientFactory httpClientFactory, Clip2BridgeHandler bridgeHandler, String hostName, public Clip2Bridge(HttpClientFactory httpClientFactory, Clip2BridgeHandler bridgeHandler, String hostName,
String applicationKey) { String applicationKey) throws ApiException {
LOGGER.debug("Clip2Bridge()"); LOGGER.debug("Clip2Bridge()");
httpClient = httpClientFactory.getCommonHttpClient(); httpClient = httpClientFactory.getCommonHttpClient();
http2Client = httpClientFactory.createHttp2Client("hue-clip2", httpClient.getSslContextFactory()); http2Client = httpClientFactory.createHttp2Client("hue-clip2", httpClient.getSslContextFactory());
http2Client.setConnectTimeout(Clip2Bridge.TIMEOUT_SECONDS * 1000); http2Client.setConnectTimeout(Clip2Bridge.TIMEOUT_SECONDS * 1000);
http2Client.setIdleTimeout(-1); http2Client.setIdleTimeout(-1);
try {
http2Client.start();
} catch (Exception e) {
throw new ApiException("Error starting HTTP/2 client", e);
}
this.bridgeHandler = bridgeHandler; this.bridgeHandler = bridgeHandler;
this.hostName = hostName; this.hostName = hostName;
this.applicationKey = applicationKey; this.applicationKey = applicationKey;
@ -541,9 +543,11 @@ public class Clip2Bridge implements Closeable {
@Override @Override
public void close() { public void close() {
closing = true; closing = true;
externalRestartScheduled = false;
internalRestartScheduled = false;
close2(); close2();
try {
http2Client.stop();
} catch (Exception e) {
}
} }
/** /**
@ -552,26 +556,15 @@ public class Clip2Bridge implements Closeable {
private void close2() { private void close2() {
synchronized (this) { synchronized (this) {
LOGGER.debug("close2()"); LOGGER.debug("close2()");
boolean notifyHandler = onlineState == State.ACTIVE && !internalRestartScheduled boolean notifyHandler = onlineState == State.ACTIVE && !closing;
&& !externalRestartScheduled && !closing;
onlineState = State.CLOSED; onlineState = State.CLOSED;
synchronized (fatalErrorTasks) { synchronized (fatalErrorTasks) {
fatalErrorTasks.values().forEach(task -> cancelTask(task, true)); fatalErrorTasks.values().forEach(task -> cancelTask(task, true));
fatalErrorTasks.clear(); fatalErrorTasks.clear();
} }
if (!internalRestartScheduled) {
// don't close the task if a restart is current
cancelTask(internalRestartTask, true);
internalRestartTask = null;
}
cancelTask(checkAliveTask, true); cancelTask(checkAliveTask, true);
checkAliveTask = null; checkAliveTask = null;
closeSession(); closeSession();
try {
http2Client.stop();
} catch (Exception e) {
// ignore
}
if (notifyHandler) { if (notifyHandler) {
bridgeHandler.onConnectionOffline(); bridgeHandler.onConnectionOffline();
} }
@ -584,7 +577,7 @@ public class Clip2Bridge implements Closeable {
private void closeSession() { private void closeSession() {
LOGGER.debug("closeSession()"); LOGGER.debug("closeSession()");
Session session = http2Session; Session session = http2Session;
if (Objects.nonNull(session)) { if (Objects.nonNull(session) && !session.isClosed()) {
session.close(0, null, Callback.NOOP); session.close(0, null, Callback.NOOP);
} }
http2Session = null; http2Session = null;
@ -599,24 +592,13 @@ public class Clip2Bridge implements Closeable {
* @param cause the exception that caused the error. * @param cause the exception that caused the error.
*/ */
private synchronized void fatalError(Object listener, Http2Exception cause) { private synchronized void fatalError(Object listener, Http2Exception cause) {
if (externalRestartScheduled || internalRestartScheduled || onlineState == State.CLOSED || closing) { if (onlineState == State.CLOSED || closing) {
return; return;
} }
String causeId = listener.getClass().getSimpleName(); String causeId = listener.getClass().getSimpleName();
if (listener instanceof ContentStreamListenerAdapter) { if (listener instanceof ContentStreamListenerAdapter) {
// on GET / PUT requests the caller handles errors and closes the stream; the session is still OK // on GET / PUT requests the caller handles errors and closes the stream; the session is still OK
LOGGER.debug("fatalError() {} {} ignoring", causeId, cause.error); LOGGER.debug("fatalError() {} {} ignoring", causeId, cause.error);
} else if (cause.error == Http2Error.GO_AWAY) {
LOGGER.debug("fatalError() {} {} scheduling reconnect", causeId, cause.error);
// schedule task to open again
internalRestartScheduled = true;
cancelTask(internalRestartTask, false);
internalRestartTask = bridgeHandler.getScheduler().schedule(
() -> internalRestart(onlineState == State.ACTIVE), RESTART_AFTER_SECONDS, TimeUnit.SECONDS);
// force close immediately to be clean when internalRestart() starts
close2();
} else { } else {
if (LOGGER.isDebugEnabled()) { if (LOGGER.isDebugEnabled()) {
LOGGER.debug("fatalError() {} {} closing", causeId, cause.error, cause); LOGGER.debug("fatalError() {} {} closing", causeId, cause.error, cause);
@ -659,7 +641,6 @@ public class Clip2Bridge implements Closeable {
* @throws InterruptedException * @throws InterruptedException
*/ */
public Resources getResources(ResourceReference reference) throws ApiException, InterruptedException { public Resources getResources(ResourceReference reference) throws ApiException, InterruptedException {
sleepDuringRestart();
if (onlineState == State.CLOSED) { if (onlineState == State.CLOSED) {
throw new ApiException("getResources() offline"); throw new ApiException("getResources() offline");
} }
@ -735,30 +716,6 @@ public class Clip2Bridge implements Closeable {
return Objects.isNull(id) || id.isEmpty() ? url : url + "/" + id; return Objects.isNull(id) || id.isEmpty() ? url : url + "/" + id;
} }
/**
* Restart the session.
*
* @param active boolean that selects whether to restart in active or passive mode.
*/
private void internalRestart(boolean active) {
try {
openPassive();
if (active) {
openActive();
}
internalRestartScheduled = false;
} catch (ApiException e) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("internalRestart() failed", e);
} else {
LOGGER.warn("Scheduled reconnection task failed.");
}
internalRestartScheduled = false;
close2();
} catch (InterruptedException e) {
}
}
/** /**
* The event stream calls this method when it has received text data. It parses the text as JSON into a list of * The event stream calls this method when it has received text data. It parses the text as JSON into a list of
* Event entries, converts the list of events to a list of resources, and forwards that list to the bridge * Event entries, converts the list of events to a list of resources, and forwards that list to the bridge
@ -892,11 +849,6 @@ public class Clip2Bridge implements Closeable {
synchronized (this) { synchronized (this) {
LOGGER.debug("openPassive()"); LOGGER.debug("openPassive()");
onlineState = State.CLOSED; onlineState = State.CLOSED;
try {
http2Client.start();
} catch (Exception e) {
throw new ApiException("Error starting HTTP/2 client", e);
}
openSession(); openSession();
openCheckAliveTask(); openCheckAliveTask();
onlineState = State.PASSIVE; onlineState = State.PASSIVE;
@ -965,14 +917,14 @@ public class Clip2Bridge implements Closeable {
} }
/** /**
* Use an HTTP/2 PUT command to send a resource to the server. * Use an HTTP/2 PUT command to send a resource to the server. Note: the Hue bridge server can sometimes get
* confused by parallel PUT commands, so use 'synchronized' to prevent that.
* *
* @param resource the resource to put. * @param resource the resource to put.
* @throws ApiException if something fails. * @throws ApiException if something fails.
* @throws InterruptedException * @throws InterruptedException
*/ */
public void putResource(Resource resource) throws ApiException, InterruptedException { public synchronized void putResource(Resource resource) throws ApiException, InterruptedException {
sleepDuringRestart();
if (onlineState == State.CLOSED) { if (onlineState == State.CLOSED) {
return; return;
} }
@ -1067,32 +1019,6 @@ public class Clip2Bridge implements Closeable {
throw new HttpUnauthorizedException("Application key registration failed"); throw new HttpUnauthorizedException("Application key registration failed");
} }
public void setExternalRestartScheduled() {
externalRestartScheduled = true;
internalRestartScheduled = false;
cancelTask(internalRestartTask, false);
internalRestartTask = null;
close2();
}
/**
* Sleep the caller during any period when the connection is restarting.
*
* @throws ApiException if anything failed.
* @throws InterruptedException
*/
private void sleepDuringRestart() throws ApiException, InterruptedException {
Future<?> restartTask = this.internalRestartTask;
if (Objects.nonNull(restartTask)) {
try {
restartTask.get(RESTART_AFTER_SECONDS * 2, TimeUnit.SECONDS);
} catch (ExecutionException | TimeoutException e) {
throw new ApiException("sleepDuringRestart() error", e);
}
}
internalRestartScheduled = false;
}
/** /**
* Test the Hue Bridge connection state by attempting to connect and trying to execute a basic command that requires * Test the Hue Bridge connection state by attempting to connect and trying to execute a basic command that requires
* authentication. * authentication.

View File

@ -12,10 +12,11 @@
*/ */
package org.openhab.binding.hue.internal.dto.clip2; package org.openhab.binding.hue.internal.dto.clip2;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.hue.internal.dto.clip2.enums.ButtonEventType; import org.openhab.binding.hue.internal.dto.clip2.enums.ButtonEventType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.State;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
@ -26,17 +27,17 @@ import com.google.gson.annotations.SerializedName;
*/ */
@NonNullByDefault @NonNullByDefault
public class Button { public class Button {
private @NonNullByDefault({}) @SerializedName("last_event") String lastEvent; private @Nullable @SerializedName("last_event") String lastEvent;
/** /**
* @return the last button event as an enum. * @return the last button event as an enum.
* @throws IllegalArgumentException if lastEvent is bad. * @throws IllegalArgumentException if lastEvent is null or bad.
*/ */
public ButtonEventType getLastEvent() throws IllegalArgumentException { public ButtonEventType getLastEvent() throws IllegalArgumentException {
return ButtonEventType.valueOf(lastEvent.toUpperCase()); String lastEvent = this.lastEvent;
} if (Objects.nonNull(lastEvent)) {
return ButtonEventType.valueOf(lastEvent.toUpperCase());
public State getLastEventState() { }
return new StringType(getLastEvent().name()); throw new IllegalArgumentException("lastEvent field is null");
} }
} }

View File

@ -40,16 +40,15 @@ public class LightLevel {
} }
/** /**
* Raw sensor light level is '10000 * log10(lux) + 1' so apply the inverse formula to convert to Lux. * Raw sensor light level formula is '10000 * log10(lux + 1)' so apply the inverse formula to convert back to Lux.
* NOTE: the Philips/Signify API documentation quotes the formula as '10000 * log10(lux) + 1', however this code
* author thinks that that formula is wrong since zero Lux would cause a log10(0) negative infinity overflow!
* *
* @return a QuantityType with light level in Lux, or UNDEF. * @return a QuantityType with light level in Lux, or UNDEF.
*/ */
public State getLightLevelState() { public State getLightLevelState() {
if (lightLevelValid) { if (lightLevelValid) {
double rawLightLevel = lightLevel; return new QuantityType<>(Math.pow(10f, (double) lightLevel / 10000f) - 1f, Units.LUX);
if (rawLightLevel > 1f) {
return new QuantityType<>(Math.pow(10f, (rawLightLevel - 1f) / 10000f), Units.LUX);
}
} }
return UnDefType.UNDEF; return UnDefType.UNDEF;
} }

View File

@ -196,11 +196,6 @@ public class Resource {
return UnDefType.NULL; return UnDefType.NULL;
} }
public State getButtonLastEventState() {
Button button = this.button;
return Objects.nonNull(button) ? button.getLastEventState() : UnDefType.NULL;
}
public List<ResourceReference> getChildren() { public List<ResourceReference> getChildren() {
List<ResourceReference> children = this.children; List<ResourceReference> children = this.children;
return Objects.nonNull(children) ? children : List.of(); return Objects.nonNull(children) ? children : List.of();

View File

@ -486,7 +486,15 @@ public class Clip2BridgeHandler extends BaseBridgeHandler {
String applicationKey = config.applicationKey; String applicationKey = config.applicationKey;
applicationKey = Objects.nonNull(applicationKey) ? applicationKey : ""; applicationKey = Objects.nonNull(applicationKey) ? applicationKey : "";
clip2Bridge = new Clip2Bridge(httpClientFactory, this, ipAddress, applicationKey);
try {
clip2Bridge = new Clip2Bridge(httpClientFactory, this, ipAddress, applicationKey);
} catch (ApiException e) {
logger.trace("initializeAssets() communication error on '{}'", ipAddress, e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.api2.comm-error.exception [\"" + e.getMessage() + "\"]");
return;
}
assetsLoaded = true; assetsLoaded = true;
} }
@ -499,14 +507,9 @@ public class Clip2BridgeHandler extends BaseBridgeHandler {
*/ */
public void onConnectionOffline() { public void onConnectionOffline() {
if (assetsLoaded) { if (assetsLoaded) {
try { cancelTask(checkConnectionTask, false);
getClip2Bridge().setExternalRestartScheduled(); checkConnectionTask = scheduler.schedule(() -> checkConnection(), RECONNECT_DELAY_SECONDS,
cancelTask(checkConnectionTask, false); TimeUnit.SECONDS);
checkConnectionTask = scheduler.schedule(() -> checkConnection(), RECONNECT_DELAY_SECONDS,
TimeUnit.SECONDS);
} catch (AssetNotLoadedException e) {
// should never occur
}
} }
} }

View File

@ -783,9 +783,10 @@ public class Clip2ThingHandler extends BaseThingHandler {
if (fullUpdate) { if (fullUpdate) {
addSupportedChannel(CHANNEL_2_BUTTON_LAST_EVENT); addSupportedChannel(CHANNEL_2_BUTTON_LAST_EVENT);
controlIds.put(resource.getId(), resource.getControlId()); controlIds.put(resource.getId(), resource.getControlId());
} else {
State buttonState = resource.getButtonEventState(controlIds);
updateState(CHANNEL_2_BUTTON_LAST_EVENT, buttonState, fullUpdate);
} }
State buttonState = resource.getButtonEventState(controlIds);
updateState(CHANNEL_2_BUTTON_LAST_EVENT, buttonState, fullUpdate);
break; break;
case DEVICE_POWER: case DEVICE_POWER:
@ -828,8 +829,9 @@ public class Clip2ThingHandler extends BaseThingHandler {
case RELATIVE_ROTARY: case RELATIVE_ROTARY:
if (fullUpdate) { if (fullUpdate) {
addSupportedChannel(CHANNEL_2_ROTARY_STEPS); addSupportedChannel(CHANNEL_2_ROTARY_STEPS);
} else {
updateState(CHANNEL_2_ROTARY_STEPS, resource.getRotaryStepsState(), fullUpdate);
} }
updateState(CHANNEL_2_ROTARY_STEPS, resource.getRotaryStepsState(), fullUpdate);
break; break;
case TEMPERATURE: case TEMPERATURE: