fix(18062): 🛠️ work in progress ...

Signed-off-by: Patrik Gfeller <patrik.gfeller@proton.me>
pull/18100/head
Patrik Gfeller 2025-01-31 18:41:20 +01:00
parent 116e8ce37f
commit 6e66e114cc
No known key found for this signature in database
GPG Key ID: 5EBA8954A87B9AEF
6 changed files with 202 additions and 179 deletions

View File

@ -18,6 +18,7 @@ import java.net.URISyntaxException;
import java.security.cert.CertificateException;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
@ -26,8 +27,6 @@ import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.HttpResponseException;
import org.eclipse.jetty.client.api.AuthenticationStore;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Response;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
@ -55,8 +54,10 @@ public class HueSyncConnection {
public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
/**
* Request format: The Sync Box API can be accessed locally via HTTPS on root level (port 443,
* /api/v1), resource level /api/v1/<resource> and in some cases sub-resource level
* Request format: The Sync Box API can be accessed locally via HTTPS on root
* level (port 443,
* /api/v1), resource level /api/v1/<resource> and in some cases sub-resource
* level
* /api/v1/<resource>/<sub-resource>.
*/
private static final String REQUEST_FORMAT = "https://%s:%s/%s/%s";
@ -72,6 +73,41 @@ public class HueSyncConnection {
private Optional<HueSyncAuthenticationResult> authentication = Optional.empty();
private class Request {
private final String endpoint;
private HttpMethod method = HttpMethod.GET;
private String payload = "";
private Request(HttpMethod httpMethod, String endpoint, String payload) {
this.method = httpMethod;
this.endpoint = endpoint;
this.payload = payload;
}
protected Request(String endpoint) {
this.endpoint = endpoint;
}
private Request(HttpMethod httpMethod, String endpoint) {
this.method = httpMethod;
this.endpoint = endpoint;
}
protected ContentResponse execute() throws InterruptedException, ExecutionException, TimeoutException {
String uri = String.format(REQUEST_FORMAT, host, port, API, endpoint);
var request = httpClient.newRequest(uri).method(method).timeout(1, TimeUnit.SECONDS);
if (!payload.isBlank()) {
request.header(HttpHeader.CONTENT_TYPE, MimeTypes.Type.APPLICATION_JSON_UTF_8.toString())
.content(new StringContentProvider(payload));
}
return request.send();
}
}
protected String registrationId = "";
public HueSyncConnection(HttpClient httpClient, String host, Integer port)
@ -102,42 +138,30 @@ public class HueSyncConnection {
// #region protected
protected @Nullable <T> T executeRequest(HttpMethod method, String endpoint, String payload,
@Nullable Class<T> type) throws Exception {
try {
return this.processedResponse(this.executeRequest(method, endpoint, payload), type);
} catch (ExecutionException | InterruptedException | TimeoutException e) {
this.handleException(e);
}
@Nullable Class<T> type) throws HueSyncConnectionException {
return null;
return this.executeRequest(new Request(method, endpoint, payload), type);
}
protected @Nullable <T> T executeGetRequest(String endpoint, Class<T> type) throws Exception {
try {
return this.processedResponse(this.executeGetRequest(endpoint), type);
} catch (ExecutionException | InterruptedException | TimeoutException e) {
this.handleException(e);
}
protected @Nullable <T> T executeRequest(HttpMethod httpMethod, String endpoint, @Nullable Class<T> type)
throws HueSyncConnectionException {
return this.executeRequest(new Request(httpMethod, endpoint), type);
}
return null;
protected @Nullable <T> T executeGetRequest(String endpoint, Class<T> type) throws HueSyncConnectionException {
return this.executeRequest(new Request(endpoint), type);
}
protected boolean isRegistered() {
return this.authentication.isPresent();
}
protected void unregisterDevice() {
protected void unregisterDevice() throws HueSyncConnectionException {
if (this.isRegistered()) {
try {
String endpoint = ENDPOINTS.REGISTRATIONS + "/" + this.registrationId;
ContentResponse response = this.executeRequest(HttpMethod.DELETE, endpoint);
String endpoint = ENDPOINTS.REGISTRATIONS + "/" + this.registrationId;
if (response.getStatus() == HttpStatus.OK_200) {
this.removeAuthentication();
}
} catch (InterruptedException | TimeoutException | ExecutionException e) {
this.logger.warn("{}", e.getMessage());
}
this.executeRequest(HttpMethod.DELETE, endpoint, null);
this.removeAuthentication();
}
}
@ -147,96 +171,51 @@ public class HueSyncConnection {
// #endregion
// #region private
private @Nullable <T> T processedResponse(Response response, @Nullable Class<T> type)
throws HueSyncConnectionException {
int status = response.getStatus();
String exceptionMessage;
/*
* 400 Invalid State: Registration in progress
*
* 401 Authentication failed: If credentials are missing or invalid, errors out. If
* credentials are missing, continues on to GET only the Configuration state when
* unauthenticated, to allow for device identification.
*
* 404 Invalid URI Path: Accessing URI path which is not supported
*
* 500 Internal: Internal errors like out of memory
*/
switch (status) {
case HttpStatus.OK_200 -> {
return (type != null && (response instanceof ContentResponse))
? this.deserialize(((ContentResponse) response).getContentAsString(), type)
: null;
}
case HttpStatus.BAD_REQUEST_400 -> {
this.logger.debug("registration in progress: no token received yet");
return null;
}
case HttpStatus.UNAUTHORIZED_401 -> exceptionMessage = "@text/connection.invalid-login";
case HttpStatus.NOT_FOUND_404 -> exceptionMessage = "@text/connection.generic-error";
default -> exceptionMessage = "@text/connection.generic-error";
}
var exception = new HueSyncConnectionException(exceptionMessage);
private @Nullable <T> T executeRequest(Request request, @Nullable Class<T> type) throws HueSyncConnectionException {
String message = "@text/connection.generic-error";
this.logger.warn("{}", exception.getMessage());
throw (exception);
}
private @Nullable <T> T deserialize(String json, Class<T> type) {
try {
return OBJECT_MAPPER.readValue(json, type);
} catch (JsonProcessingException | NoClassDefFoundError e) {
this.logger.error("{}", e.getMessage());
ContentResponse response = request.execute();
return null;
/*
* 400 Invalid State: Registration in progress
*
* 401 Authentication failed: If credentials are missing or invalid, errors out.
* If
* credentials are missing, continues on to GET only the Configuration state
* when
* unauthenticated, to allow for device identification.
*
* 404 Invalid URI Path: Accessing URI path which is not supported
*
* 500 Internal: Internal errors like out of memory
*/
switch (response.getStatus()) {
case HttpStatus.OK_200 -> {
return this.deserialize(response.getContentAsString(), type);
}
case HttpStatus.BAD_REQUEST_400 -> {
logger.debug("registration in progress: no token received yet");
return null;
}
case HttpStatus.UNAUTHORIZED_401 -> message = "@text/connection.invalid-login";
case HttpStatus.NOT_FOUND_404 -> message = "@text/connection.generic-error";
}
throw new HueSyncConnectionException(message, new HttpResponseException(message, response));
} catch (JsonProcessingException | InterruptedException | ExecutionException | TimeoutException e) {
throw new HueSyncConnectionException(message, e);
}
}
private ContentResponse executeRequest(HttpMethod method, String endpoint)
throws InterruptedException, TimeoutException, ExecutionException {
return this.executeRequest(method, endpoint, "");
}
private ContentResponse executeGetRequest(String endpoint)
throws InterruptedException, ExecutionException, TimeoutException {
String uri = String.format(REQUEST_FORMAT, this.host, this.port, API, endpoint);
return httpClient.GET(uri);
}
private ContentResponse executeRequest(HttpMethod method, String endpoint, String payload)
throws InterruptedException, TimeoutException, ExecutionException {
String uri = String.format(REQUEST_FORMAT, this.host, this.port, API, endpoint);
Request request = this.httpClient.newRequest(uri).method(method);
this.logger.trace("uri: {}", uri);
this.logger.trace("method: {}", method);
this.logger.trace("payload: {}", payload);
if (!payload.isBlank()) {
request.header(HttpHeader.CONTENT_TYPE, MimeTypes.Type.APPLICATION_JSON_UTF_8.toString())
.content(new StringContentProvider(payload));
}
return request.send();
}
private void handleException(Exception e) throws Exception {
this.logger.warn("Exception: {}, Client State: {}", e.getMessage(), this.httpClient.getState());
Throwable cause = e.getCause();
if (cause != null && cause instanceof HttpResponseException) {
this.processedResponse(((HttpResponseException) cause).getResponse(), null);
} else {
throw e;
}
private @Nullable <T> T deserialize(String json, @Nullable Class<T> type) throws JsonProcessingException {
return type == null ? null : OBJECT_MAPPER.readValue(json, type);
}
private void removeAuthentication() {
AuthenticationStore store = this.httpClient.getAuthenticationStore();
store.clearAuthenticationResults();
this.httpClient.setAuthenticationStore(store);
this.registrationId = "";

View File

@ -33,6 +33,7 @@ import org.openhab.binding.huesync.internal.api.dto.hdmi.HueSyncHdmi;
import org.openhab.binding.huesync.internal.api.dto.registration.HueSyncRegistration;
import org.openhab.binding.huesync.internal.api.dto.registration.HueSyncRegistrationRequest;
import org.openhab.binding.huesync.internal.config.HueSyncConfiguration;
import org.openhab.binding.huesync.internal.exceptions.HueSyncConnectionException;
import org.openhab.binding.huesync.internal.types.HueSyncExceptionHandler;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
@ -114,7 +115,7 @@ public class HueSyncDeviceConnection {
try {
this.connection.executeRequest(HttpMethod.PUT, ENDPOINTS.EXECUTION, json, null);
} catch (Exception exception) {
} catch (HueSyncConnectionException exception) {
exceptionHandler.handle(exception);
}
}
@ -188,7 +189,11 @@ public class HueSyncDeviceConnection {
}
public void unregisterDevice() {
this.connection.unregisterDevice();
try {
this.connection.unregisterDevice();
} catch (HueSyncConnectionException e) {
this.logger.warn("{}", e.getMessage());
}
}
public void dispose() {

View File

@ -13,6 +13,7 @@
package org.openhab.binding.huesync.internal.exceptions;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
*
@ -21,8 +22,18 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
@NonNullByDefault
public class HueSyncConnectionException extends HueSyncException {
private static final long serialVersionUID = 0L;
private @Nullable Exception innerException = null;
public HueSyncConnectionException(String message, Exception exception) {
super(message);
this.innerException = exception;
}
public HueSyncConnectionException(String message) {
super(message);
}
public @Nullable Exception getInnerException() {
return this.innerException;
}
}

View File

@ -22,6 +22,8 @@ import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.HttpResponseException;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.huesync.internal.HdmiChannels;
import org.openhab.binding.huesync.internal.HueSyncConstants;
import org.openhab.binding.huesync.internal.api.dto.device.HueSyncDevice;
@ -33,6 +35,7 @@ import org.openhab.binding.huesync.internal.api.dto.registration.HueSyncRegistra
import org.openhab.binding.huesync.internal.config.HueSyncConfiguration;
import org.openhab.binding.huesync.internal.connection.HueSyncDeviceConnection;
import org.openhab.binding.huesync.internal.exceptions.HueSyncApiException;
import org.openhab.binding.huesync.internal.exceptions.HueSyncConnectionException;
import org.openhab.binding.huesync.internal.handler.tasks.HueSyncRegistrationTask;
import org.openhab.binding.huesync.internal.handler.tasks.HueSyncUpdateTask;
import org.openhab.binding.huesync.internal.handler.tasks.HueSyncUpdateTaskResult;
@ -69,17 +72,39 @@ public class HueSyncHandler extends BaseThingHandler {
* @author Patrik Gfeller - Issue #18062, improve connection exception handling.
*/
private class ExceptionHandler implements HueSyncExceptionHandler {
private final Thing thing;
private final HueSyncHandler handler;
private ExceptionHandler(Thing thing) {
this.thing = thing;
private ExceptionHandler(HueSyncHandler handler) {
this.handler = handler;
}
@Override
public void handle(Exception exception) {
ThingStatusInfo status = new ThingStatusInfo(ThingStatus.INITIALIZING,
ThingStatusDetail.COMMUNICATION_ERROR, exception.getLocalizedMessage());
this.thing.setStatusInfo(status);
ThingStatusDetail detail = ThingStatusDetail.COMMUNICATION_ERROR;
String description;
if (exception instanceof HueSyncConnectionException connectionException) {
if (connectionException.getInnerException() instanceof HttpResponseException innerException) {
switch (innerException.getResponse().getStatus()) {
case HttpStatus.BAD_REQUEST_400 -> {
detail = ThingStatusDetail.CONFIGURATION_PENDING;
}
case HttpStatus.UNAUTHORIZED_401 -> {
detail = ThingStatusDetail.CONFIGURATION_ERROR;
}
default -> {
detail = ThingStatusDetail.COMMUNICATION_ERROR;
}
}
}
description = connectionException.getLocalizedMessage();
} else {
detail = ThingStatusDetail.COMMUNICATION_ERROR;
description = exception.getLocalizedMessage();
}
ThingStatusInfo statusInfo = new ThingStatusInfo(ThingStatus.OFFLINE, detail, description);
this.handler.thing.setStatusInfo(statusInfo);
}
}
@ -101,7 +126,9 @@ public class HueSyncHandler extends BaseThingHandler {
public HueSyncHandler(Thing thing, HttpClientFactory httpClientFactory) {
super(thing);
this.exceptionHandler = new ExceptionHandler(thing);
this.updateStatus(ThingStatus.UNKNOWN);
this.exceptionHandler = new ExceptionHandler(this);
this.httpClient = httpClientFactory.getCommonHttpClient();
}
@ -122,34 +149,39 @@ public class HueSyncHandler extends BaseThingHandler {
this.getConfigAs(HueSyncConfiguration.class), this.exceptionHandler);
this.connection = Optional.of(connectionInstance);
this.deviceInfo = Optional.ofNullable(connectionInstance.getDeviceInfo());
this.deviceInfo.ifPresent(info -> {
setProperty(Thing.PROPERTY_SERIAL_NUMBER, info.uniqueId != null ? info.uniqueId : "");
setProperty(Thing.PROPERTY_MODEL_ID, info.deviceType);
setProperty(Thing.PROPERTY_FIRMWARE_VERSION, info.firmwareVersion);
setProperty(HueSyncHandler.PROPERTY_API_VERSION, String.format("%d", info.apiLevel));
try {
this.checkCompatibility();
} catch (HueSyncApiException e) {
this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
} finally {
this.startTasks(connectionInstance);
}
connect(connectionInstance, info);
});
} catch (Exception ex) {
this.exceptionHandler.handle(ex);
} catch (Exception e) {
this.exceptionHandler.handle(e);
}
};
}
private void connect(HueSyncDeviceConnection connectionInstance, HueSyncDevice info) {
setProperty(Thing.PROPERTY_SERIAL_NUMBER, info.uniqueId != null ? info.uniqueId : "");
setProperty(Thing.PROPERTY_MODEL_ID, info.deviceType);
setProperty(Thing.PROPERTY_FIRMWARE_VERSION, info.firmwareVersion);
setProperty(HueSyncHandler.PROPERTY_API_VERSION, String.format("%d", info.apiLevel));
try {
this.checkCompatibility();
} catch (HueSyncApiException e) {
this.exceptionHandler.handle(e);
} finally {
this.startTasks(connectionInstance);
}
}
private @Nullable ScheduledFuture<?> executeTask(Runnable task, long initialDelay, long interval) {
return scheduler.scheduleWithFixedDelay(task, initialDelay, interval, TimeUnit.SECONDS);
}
private void startTasks(HueSyncDeviceConnection connection) {
private synchronized void startTasks(HueSyncDeviceConnection connection) {
this.stopTasks();
connection.updateConfiguration(this.getConfigAs(HueSyncConfiguration.class));
@ -164,13 +196,13 @@ public class HueSyncHandler extends BaseThingHandler {
switch (id) {
case POLL -> {
initialDelay = 0;
interval = this.getConfigAs(HueSyncConfiguration.class).statusUpdateInterval;
this.updateStatus(ThingStatus.ONLINE);
initialDelay = 5;
interval = this.getConfigAs(HueSyncConfiguration.class).statusUpdateInterval;
task = new HueSyncUpdateTask(connection, this.deviceInfo.get(),
deviceStatus -> this.handleUpdate(deviceStatus, connection));
deviceStatus -> this.handleUpdate(deviceStatus), this.exceptionHandler);
}
case REGISTER -> {
initialDelay = HueSyncConstants.REGISTRATION_INITIAL_DELAY;
@ -190,7 +222,7 @@ public class HueSyncHandler extends BaseThingHandler {
}
}
private void stopTasks() {
private synchronized void stopTasks() {
logger.debug("Stopping {} task(s): {}", this.tasks.values().size(), String.join(",", this.tasks.keySet()));
this.tasks.values().forEach(task -> this.stopTask(task));
@ -200,7 +232,7 @@ public class HueSyncHandler extends BaseThingHandler {
"@text/thing.config.huesync.box.registration");
}
private void stopTask(@Nullable ScheduledFuture<?> task) {
private synchronized void stopTask(@Nullable ScheduledFuture<?> task) {
if (task == null || task.isCancelled() || task.isDone()) {
return;
}
@ -208,28 +240,26 @@ public class HueSyncHandler extends BaseThingHandler {
task.cancel(true);
}
private void handleUpdate(@Nullable HueSyncUpdateTaskResult dto, HueSyncDeviceConnection connection) {
try {
HueSyncUpdateTaskResult update = Optional.ofNullable(dto).get();
private void handleUpdate(@Nullable HueSyncUpdateTaskResult dto) {
synchronized (this) {
ThingStatus status = this.thing.getStatus();
this.updateFirmwareInformation(Optional.ofNullable(update.deviceStatus).get());
this.updateHdmiInformation(Optional.ofNullable(update.hdmiStatus).get());
this.updateExecutionInformation(Optional.ofNullable(update.execution).get());
if (this.getThing().getStatus() != ThingStatus.ONLINE) {
this.updateStatus(ThingStatus.ONLINE);
switch (status) {
case ONLINE -> {
Optional.ofNullable(dto).ifPresent(taskResult -> {
Optional.ofNullable(taskResult.deviceStatus)
.ifPresent(payload -> this.updateFirmwareInformation(payload));
Optional.ofNullable(taskResult.hdmiStatus)
.ifPresent(payload -> this.updateHdmiInformation(payload));
Optional.ofNullable(taskResult.execution)
.ifPresent(payload -> this.updateExecutionInformation(payload));
});
}
default -> this.logger.debug("Unable to execute update - Status: [{}]", status);
}
} catch (NoSuchElementException e) {
this.logMissingUpdateInformation("device");
this.startTasks(connection);
}
}
private void logMissingUpdateInformation(String api) {
this.logger.warn("Device information - {} status missing", api);
}
private void updateHdmiInformation(HueSyncHdmi hdmiStatus) {
updateHdmiStatus(HueSyncConstants.CHANNELS.HDMI.IN_1, hdmiStatus.input1);
updateHdmiStatus(HueSyncConstants.CHANNELS.HDMI.IN_2, hdmiStatus.input2);
@ -326,9 +356,8 @@ public class HueSyncHandler extends BaseThingHandler {
@Override
public void initialize() {
try {
this.updateStatus(ThingStatus.UNKNOWN);
this.stopTasks();
this.updateStatus(ThingStatus.OFFLINE);
scheduler.execute(initializeConnection());
} catch (Exception e) {
@ -358,15 +387,17 @@ public class HueSyncHandler extends BaseThingHandler {
@Override
public void dispose() {
super.dispose();
synchronized (this) {
super.dispose();
try {
this.stopTasks();
this.connection.get().dispose();
} catch (Exception e) {
this.logger.warn("{}", e.getMessage());
} finally {
this.logger.debug("Thing {} ({}) disposed.", this.thing.getLabel(), this.thing.getUID());
try {
this.stopTasks();
this.connection.orElseThrow().dispose();
} catch (Exception e) {
this.logger.warn("{}", e.getMessage());
} finally {
this.logger.debug("Thing {} ({}) disposed.", this.thing.getLabel(), this.thing.getUID());
}
}
}
@ -374,10 +405,8 @@ public class HueSyncHandler extends BaseThingHandler {
public void handleRemoval() {
super.handleRemoval();
try {
this.connection.orElseThrow().unregisterDevice();
} catch (NoSuchElementException e) {
if (this.connection.isPresent()) {
this.connection.get().unregisterDevice();
}
}

View File

@ -18,6 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.huesync.internal.api.dto.device.HueSyncDevice;
import org.openhab.binding.huesync.internal.connection.HueSyncDeviceConnection;
import org.openhab.binding.huesync.internal.types.HueSyncExceptionHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -34,10 +35,13 @@ public class HueSyncUpdateTask implements Runnable {
private final HueSyncDeviceConnection connection;
private final HueSyncDevice deviceInfo;
private final HueSyncExceptionHandler exceptionHandler;
private final Consumer<@Nullable HueSyncUpdateTaskResult> action;
public HueSyncUpdateTask(HueSyncDeviceConnection connection, HueSyncDevice deviceInfo,
Consumer<@Nullable HueSyncUpdateTaskResult> action) {
Consumer<@Nullable HueSyncUpdateTaskResult> action, HueSyncExceptionHandler exceptionHandler) {
this.exceptionHandler = exceptionHandler;
this.connection = connection;
this.deviceInfo = deviceInfo;
@ -46,24 +50,19 @@ public class HueSyncUpdateTask implements Runnable {
@Override
public void run() {
HueSyncUpdateTaskResult updateInfo = new HueSyncUpdateTaskResult();
try {
this.logger.debug("Status update query for {} {}:{}", this.deviceInfo.name, this.deviceInfo.deviceType,
this.deviceInfo.uniqueId);
if (!this.connection.isRegistered()) {
this.action.accept(null);
}
HueSyncUpdateTaskResult updateInfo = new HueSyncUpdateTaskResult();
updateInfo.deviceStatus = this.connection.getDetailedDeviceInfo();
updateInfo.hdmiStatus = this.connection.getHdmiInfo();
updateInfo.execution = this.connection.getExecutionInfo();
this.action.accept(updateInfo);
} catch (Exception e) {
this.logger.debug("{}", e.getMessage());
this.action.accept(null);
this.exceptionHandler.handle(e);
}
}
}

View File

@ -96,7 +96,7 @@ channel-type.huesync.execution-sync-active.description = <p> <b>OFF</b> in case
api.minimal-version = Only devices with API level >= 7 are supported
api.communication-problem = Communication problem with the device
connection.invalid-login = Invalid or missing credentials
connection.generic-error = Unable to communicate with to reach API endpoint.
connection.generic-error = Unable to communicate the device.
connection.server-error = Device was not able to process the request.
# registration