[huesync] Fix lost api-token when device goes offline (#18100)

* fix(18062): [huesync] Configuration (API Token) lost if device goes offline

Signed-off-by: Patrik Gfeller <patrik.gfeller@proton.me>
pull/17817/merge
Patrik Gfeller 2025-02-18 13:18:57 +01:00 committed by GitHub
parent 51df8f302f
commit a41c6d4b4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 329 additions and 222 deletions

View File

@ -23,6 +23,14 @@ import org.openhab.core.thing.ThingTypeUID;
*/ */
@NonNullByDefault @NonNullByDefault
public class HueSyncConstants { public class HueSyncConstants {
public static class EXCEPTION_TYPES {
public static class CONNECTION {
public static final String UNAUTHORIZED_401 = "invalidLogin";
public static final String NOT_FOUND_404 = "notFound";
public static final String INTERNAL_SERVER_ERROR_500 = "deviceError";
}
}
public static class ENDPOINTS { public static class ENDPOINTS {
public static final String DEVICE = "device"; public static final String DEVICE = "device";
public static final String REGISTRATIONS = "registrations"; public static final String REGISTRATIONS = "registrations";
@ -81,9 +89,11 @@ public class HueSyncConstants {
public static final String PARAMETER_HOST = "host"; public static final String PARAMETER_HOST = "host";
public static final String PARAMETER_PORT = "port"; public static final String PARAMETER_PORT = "port";
public static final Integer REGISTRATION_INITIAL_DELAY = 3; public static final Integer REGISTRATION_INITIAL_DELAY = 5;
public static final Integer REGISTRATION_INTERVAL = 1; public static final Integer REGISTRATION_INTERVAL = 1;
public static final Integer POLL_INITIAL_DELAY = 10;
public static final String REGISTRATION_ID = "registrationId"; public static final String REGISTRATION_ID = "registrationId";
public static final String API_TOKEN = "apiAccessToken"; public static final String API_TOKEN = "apiAccessToken";
} }

View File

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

View File

@ -34,6 +34,7 @@ import org.openhab.binding.huesync.internal.api.dto.registration.HueSyncRegistra
import org.openhab.binding.huesync.internal.api.dto.registration.HueSyncRegistrationRequest; import org.openhab.binding.huesync.internal.api.dto.registration.HueSyncRegistrationRequest;
import org.openhab.binding.huesync.internal.config.HueSyncConfiguration; import org.openhab.binding.huesync.internal.config.HueSyncConfiguration;
import org.openhab.binding.huesync.internal.exceptions.HueSyncConnectionException; 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.OnOffType;
import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType; import org.openhab.core.library.types.StringType;
@ -55,11 +56,14 @@ public class HueSyncDeviceConnection {
private final Logger logger = LoggerFactory.getLogger(HueSyncDeviceConnection.class); private final Logger logger = LoggerFactory.getLogger(HueSyncDeviceConnection.class);
private final HueSyncConnection connection; private final HueSyncConnection connection;
private final HueSyncExceptionHandler exceptionHandler;
private final Map<String, Consumer<Command>> deviceCommandExecutors = new HashMap<>(); private final Map<String, Consumer<Command>> deviceCommandExecutors = new HashMap<>();
public HueSyncDeviceConnection(HttpClient httpClient, HueSyncConfiguration configuration) public HueSyncDeviceConnection(HttpClient httpClient, HueSyncConfiguration configuration,
throws CertificateException, IOException, URISyntaxException { HueSyncExceptionHandler exceptionHandler) throws CertificateException, IOException, URISyntaxException {
this.exceptionHandler = exceptionHandler;
this.connection = new HueSyncConnection(httpClient, configuration.host, configuration.port); this.connection = new HueSyncConnection(httpClient, configuration.host, configuration.port);
registerCommandHandlers(); registerCommandHandlers();
@ -109,7 +113,11 @@ public class HueSyncDeviceConnection {
String json = String.format("{ \"%s\": %s }", key, value); String json = String.format("{ \"%s\": %s }", key, value);
try {
this.connection.executeRequest(HttpMethod.PUT, ENDPOINTS.EXECUTION, json, null); this.connection.executeRequest(HttpMethod.PUT, ENDPOINTS.EXECUTION, json, null);
} catch (HueSyncConnectionException exception) {
exceptionHandler.handle(exception);
}
} }
// #endregion // #endregion
@ -131,29 +139,29 @@ public class HueSyncDeviceConnection {
} }
} }
public @Nullable HueSyncDevice getDeviceInfo() { public @Nullable HueSyncDevice getDeviceInfo() throws Exception {
return this.connection.executeGetRequest(ENDPOINTS.DEVICE, HueSyncDevice.class); return this.connection.executeGetRequest(ENDPOINTS.DEVICE, HueSyncDevice.class);
} }
public @Nullable HueSyncDeviceDetailed getDetailedDeviceInfo() { public @Nullable HueSyncDeviceDetailed getDetailedDeviceInfo() throws Exception {
return this.connection.isRegistered() return this.connection.isRegistered()
? this.connection.executeRequest(HttpMethod.GET, ENDPOINTS.DEVICE, "", HueSyncDeviceDetailed.class) ? this.connection.executeRequest(HttpMethod.GET, ENDPOINTS.DEVICE, "", HueSyncDeviceDetailed.class)
: null; : null;
} }
public @Nullable HueSyncHdmi getHdmiInfo() { public @Nullable HueSyncHdmi getHdmiInfo() throws Exception {
return this.connection.isRegistered() return this.connection.isRegistered()
? this.connection.executeRequest(HttpMethod.GET, ENDPOINTS.HDMI, "", HueSyncHdmi.class) ? this.connection.executeRequest(HttpMethod.GET, ENDPOINTS.HDMI, "", HueSyncHdmi.class)
: null; : null;
} }
public @Nullable HueSyncExecution getExecutionInfo() { public @Nullable HueSyncExecution getExecutionInfo() throws Exception {
return this.connection.isRegistered() return this.connection.isRegistered()
? this.connection.executeRequest(HttpMethod.GET, ENDPOINTS.EXECUTION, "", HueSyncExecution.class) ? this.connection.executeRequest(HttpMethod.GET, ENDPOINTS.EXECUTION, "", HueSyncExecution.class)
: null; : null;
} }
public @Nullable HueSyncRegistration registerDevice(String id) throws HueSyncConnectionException { public @Nullable HueSyncRegistration registerDevice(String id) throws Exception {
if (!id.isBlank()) { if (!id.isBlank()) {
try { try {
HueSyncRegistrationRequest dto = new HueSyncRegistrationRequest(); HueSyncRegistrationRequest dto = new HueSyncRegistrationRequest();
@ -181,7 +189,11 @@ public class HueSyncDeviceConnection {
} }
public void unregisterDevice() { public void unregisterDevice() {
try {
this.connection.unregisterDevice(); this.connection.unregisterDevice();
} catch (HueSyncConnectionException e) {
this.logger.warn("{}", e.getMessage());
}
} }
public void dispose() { public void dispose() {

View File

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

View File

@ -13,7 +13,7 @@
package org.openhab.binding.huesync.internal.exceptions; package org.openhab.binding.huesync.internal.exceptions;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.huesync.internal.i18n.HueSyncLocalizer; import org.openhab.binding.huesync.internal.i18n.ResourceHelper;
/** /**
* Base class for all HueSyncExceptions * Base class for all HueSyncExceptions
@ -25,6 +25,6 @@ public abstract class HueSyncException extends Exception {
private static final long serialVersionUID = 0L; private static final long serialVersionUID = 0L;
public HueSyncException(String message) { public HueSyncException(String message) {
super(message.startsWith("@text") ? HueSyncLocalizer.getResourceString(message) : message); super(message.startsWith("@text") ? ResourceHelper.getResourceString(message) : message);
} }
} }

View File

@ -12,9 +12,6 @@
*/ */
package org.openhab.binding.huesync.internal.factory; package org.openhab.binding.huesync.internal.factory;
import java.io.IOException;
import java.net.URISyntaxException;
import java.security.cert.CertificateException;
import java.util.Collections; import java.util.Collections;
import java.util.Set; import java.util.Set;
@ -31,8 +28,6 @@ import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference; import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** /**
* The {@link HueSyncHandlerFactory} is responsible for creating things and * The {@link HueSyncHandlerFactory} is responsible for creating things and
@ -44,9 +39,7 @@ import org.slf4j.LoggerFactory;
@NonNullByDefault @NonNullByDefault
@Component(configurationPid = "binding.huesync", service = ThingHandlerFactory.class) @Component(configurationPid = "binding.huesync", service = ThingHandlerFactory.class)
public class HueSyncHandlerFactory extends BaseThingHandlerFactory { public class HueSyncHandlerFactory extends BaseThingHandlerFactory {
private final HttpClientFactory httpClientFactory; private final HttpClientFactory httpClientFactory;
private final Logger logger = LoggerFactory.getLogger(HueSyncHandlerFactory.class);
@Activate @Activate
public HueSyncHandlerFactory(@Reference final HttpClientFactory httpClientFactory) throws Exception { public HueSyncHandlerFactory(@Reference final HttpClientFactory httpClientFactory) throws Exception {
@ -66,12 +59,7 @@ public class HueSyncHandlerFactory extends BaseThingHandlerFactory {
ThingTypeUID thingTypeUID = thing.getThingTypeUID(); ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (HueSyncConstants.THING_TYPE_UID.equals(thingTypeUID)) { if (HueSyncConstants.THING_TYPE_UID.equals(thingTypeUID)) {
try {
return new HueSyncHandler(thing, this.httpClientFactory); return new HueSyncHandler(thing, this.httpClientFactory);
} catch (IOException | URISyntaxException | CertificateException e) {
this.logger.warn("It was not possible to create a handler for {}: {}", thingTypeUID.getId(),
e.getMessage());
}
} }
return null; return null;

View File

@ -12,9 +12,6 @@
*/ */
package org.openhab.binding.huesync.internal.handler; package org.openhab.binding.huesync.internal.handler;
import java.io.IOException;
import java.net.URISyntaxException;
import java.security.cert.CertificateException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
@ -25,6 +22,8 @@ import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient; 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.HdmiChannels;
import org.openhab.binding.huesync.internal.HueSyncConstants; import org.openhab.binding.huesync.internal.HueSyncConstants;
import org.openhab.binding.huesync.internal.api.dto.device.HueSyncDevice; import org.openhab.binding.huesync.internal.api.dto.device.HueSyncDevice;
@ -36,9 +35,11 @@ import org.openhab.binding.huesync.internal.api.dto.registration.HueSyncRegistra
import org.openhab.binding.huesync.internal.config.HueSyncConfiguration; import org.openhab.binding.huesync.internal.config.HueSyncConfiguration;
import org.openhab.binding.huesync.internal.connection.HueSyncDeviceConnection; import org.openhab.binding.huesync.internal.connection.HueSyncDeviceConnection;
import org.openhab.binding.huesync.internal.exceptions.HueSyncApiException; 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.HueSyncRegistrationTask;
import org.openhab.binding.huesync.internal.handler.tasks.HueSyncUpdateTask; import org.openhab.binding.huesync.internal.handler.tasks.HueSyncUpdateTask;
import org.openhab.binding.huesync.internal.handler.tasks.HueSyncUpdateTaskResult; import org.openhab.binding.huesync.internal.handler.tasks.HueSyncUpdateTaskResult;
import org.openhab.binding.huesync.internal.types.HueSyncExceptionHandler;
import org.openhab.core.config.core.Configuration; import org.openhab.core.config.core.Configuration;
import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.DecimalType;
@ -49,6 +50,7 @@ import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing; import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.binding.BaseThingHandler; import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command; import org.openhab.core.types.Command;
import org.openhab.core.types.State; import org.openhab.core.types.State;
@ -63,34 +65,104 @@ import org.slf4j.LoggerFactory;
*/ */
@NonNullByDefault @NonNullByDefault
public class HueSyncHandler extends BaseThingHandler { public class HueSyncHandler extends BaseThingHandler {
/**
* Exception handler implementation
*
* @author Patrik Gfeller - Initial contribution
* @author Patrik Gfeller - Issue #18062, improve connection exception handling.
*/
private class ExceptionHandler implements HueSyncExceptionHandler {
private final HueSyncHandler handler;
private ExceptionHandler(HueSyncHandler handler) {
this.handler = handler;
}
@Override
public void handle(Exception exception) {
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);
}
}
private static final String REGISTER = "Registration"; private static final String REGISTER = "Registration";
private static final String POLL = "Update"; private static final String POLL = "Update";
private static final String PROPERTY_API_VERSION = "apiVersion"; private static final String PROPERTY_API_VERSION = "apiVersion";
private final ExceptionHandler exceptionHandler;
private final Logger logger = LoggerFactory.getLogger(HueSyncHandler.class); private final Logger logger = LoggerFactory.getLogger(HueSyncHandler.class);
Map<String, @Nullable ScheduledFuture<?>> tasks = new HashMap<>(); Map<String, @Nullable ScheduledFuture<?>> tasks = new HashMap<>();
private Optional<HueSyncDevice> deviceInfo = Optional.empty(); private Optional<HueSyncDevice> deviceInfo = Optional.empty();
private Optional<HueSyncDeviceConnection> connection = Optional.empty();
private final HueSyncDeviceConnection connection;
private final HttpClient httpClient; private final HttpClient httpClient;
public HueSyncHandler(Thing thing, HttpClientFactory httpClientFactory) public HueSyncHandler(Thing thing, HttpClientFactory httpClientFactory) {
throws CertificateException, IOException, URISyntaxException {
super(thing); super(thing);
this.httpClient = httpClientFactory.getCommonHttpClient(); this.updateStatus(ThingStatus.UNKNOWN);
this.connection = new HueSyncDeviceConnection(this.httpClient, this.getConfigAs(HueSyncConfiguration.class)); this.exceptionHandler = new ExceptionHandler(this);
this.httpClient = httpClientFactory.getCommonHttpClient();
} }
// #region override
@Override
protected Configuration editConfiguration() {
this.logger.debug("Configuration change detected.");
return new Configuration(this.thing.getConfiguration().getProperties());
}
// #endregion
// #region private // #region private
private Runnable initializeConnection() { private Runnable initializeConnection() {
return () -> { return () -> {
this.deviceInfo = Optional.ofNullable(this.connection.getDeviceInfo()); try {
var connectionInstance = new HueSyncDeviceConnection(this.httpClient,
this.getConfigAs(HueSyncConfiguration.class), this.exceptionHandler);
this.connection = Optional.of(connectionInstance);
this.deviceInfo = Optional.ofNullable(connectionInstance.getDeviceInfo());
this.deviceInfo.ifPresent(info -> { this.deviceInfo.ifPresent(info -> {
connect(connectionInstance, info);
});
} 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_SERIAL_NUMBER, info.uniqueId != null ? info.uniqueId : "");
setProperty(Thing.PROPERTY_MODEL_ID, info.deviceType); setProperty(Thing.PROPERTY_MODEL_ID, info.deviceType);
setProperty(Thing.PROPERTY_FIRMWARE_VERSION, info.firmwareVersion); setProperty(Thing.PROPERTY_FIRMWARE_VERSION, info.firmwareVersion);
@ -100,33 +172,23 @@ public class HueSyncHandler extends BaseThingHandler {
try { try {
this.checkCompatibility(); this.checkCompatibility();
} catch (HueSyncApiException e) { } catch (HueSyncApiException e) {
this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); this.exceptionHandler.handle(e);
} finally { } finally {
this.startTasks(); this.startTasks(connectionInstance);
} }
});
};
}
private void stopTask(@Nullable ScheduledFuture<?> task) {
if (task == null || task.isCancelled() || task.isDone()) {
return;
}
task.cancel(true);
} }
private @Nullable ScheduledFuture<?> executeTask(Runnable task, long initialDelay, long interval) { private @Nullable ScheduledFuture<?> executeTask(Runnable task, long initialDelay, long interval) {
return scheduler.scheduleWithFixedDelay(task, initialDelay, interval, TimeUnit.SECONDS); return scheduler.scheduleWithFixedDelay(task, initialDelay, interval, TimeUnit.SECONDS);
} }
private void startTasks() { private synchronized void startTasks(HueSyncDeviceConnection connection) {
this.stopTasks(); this.stopTasks();
this.connection.updateConfiguration(this.getConfigAs(HueSyncConfiguration.class)); connection.updateConfiguration(this.getConfigAs(HueSyncConfiguration.class));
Runnable task = null; Runnable task = null;
String id = this.connection.isRegistered() ? POLL : REGISTER; String id = connection.isRegistered() ? POLL : REGISTER;
this.logger.debug("startTasks - [{}]", id); this.logger.debug("startTasks - [{}]", id);
@ -135,13 +197,13 @@ public class HueSyncHandler extends BaseThingHandler {
switch (id) { switch (id) {
case POLL -> { case POLL -> {
initialDelay = 0;
interval = this.getConfigAs(HueSyncConfiguration.class).statusUpdateInterval;
this.updateStatus(ThingStatus.ONLINE); this.updateStatus(ThingStatus.ONLINE);
task = new HueSyncUpdateTask(this.connection, this.deviceInfo.get(), initialDelay = HueSyncConstants.POLL_INITIAL_DELAY;
deviceStatus -> this.handleUpdate(deviceStatus));
interval = this.getConfigAs(HueSyncConfiguration.class).statusUpdateInterval;
task = new HueSyncUpdateTask(connection, this.deviceInfo.get(),
deviceStatus -> this.handleUpdate(deviceStatus), this.exceptionHandler);
} }
case REGISTER -> { case REGISTER -> {
initialDelay = HueSyncConstants.REGISTRATION_INITIAL_DELAY; initialDelay = HueSyncConstants.REGISTRATION_INITIAL_DELAY;
@ -150,8 +212,8 @@ public class HueSyncHandler extends BaseThingHandler {
this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
"@text/thing.config.huesync.box.registration"); "@text/thing.config.huesync.box.registration");
task = new HueSyncRegistrationTask(this.connection, this.deviceInfo.get(), task = new HueSyncRegistrationTask(connection, this.deviceInfo.get(),
registration -> this.handleRegistration(registration)); registration -> this.handleRegistration(registration, connection), this.exceptionHandler);
} }
} }
@ -161,7 +223,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())); logger.debug("Stopping {} task(s): {}", this.tasks.values().size(), String.join(",", this.tasks.keySet()));
this.tasks.values().forEach(task -> this.stopTask(task)); this.tasks.values().forEach(task -> this.stopTask(task));
@ -171,32 +233,42 @@ public class HueSyncHandler extends BaseThingHandler {
"@text/thing.config.huesync.box.registration"); "@text/thing.config.huesync.box.registration");
} }
private synchronized void stopTask(@Nullable ScheduledFuture<?> task) {
if (task == null || task.isCancelled() || task.isDone()) {
return;
}
task.cancel(true);
}
private void handleUpdate(@Nullable HueSyncUpdateTaskResult dto) { private void handleUpdate(@Nullable HueSyncUpdateTaskResult dto) {
try { synchronized (this) {
HueSyncUpdateTaskResult update = Optional.ofNullable(dto).get(); ThingStatus status = this.thing.getStatus();
try { switch (status) {
this.updateFirmwareInformation(Optional.ofNullable(update.deviceStatus).get()); case ONLINE:
} catch (NoSuchElementException e) { Optional.ofNullable(dto).ifPresent(taskResult -> {
this.logMissingUpdateInformation("device"); 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));
});
break;
case OFFLINE:
this.stopTasks();
this.updateHdmiInformation(Optional.ofNullable(update.hdmiStatus).get()); this.connection.ifPresent(connectionInstance -> {
this.updateExecutionInformation(Optional.ofNullable(update.execution).get()); this.deviceInfo.ifPresent(deviceInfoInstance -> {
} catch (NoSuchElementException e) { this.connect(connectionInstance, deviceInfoInstance);
Configuration configuration = this.editConfiguration(); });
});
configuration.put(HueSyncConstants.REGISTRATION_ID, ""); break;
configuration.put(HueSyncConstants.API_TOKEN, ""); default:
this.logger.debug("Unable to execute update - Status: [{}]", status);
this.updateConfiguration(configuration);
this.startTasks();
} }
} }
private void logMissingUpdateInformation(String api) {
this.logger.warn("Device information - {} status missing", api);
} }
private void updateHdmiInformation(HueSyncHdmi hdmiStatus) { private void updateHdmiInformation(HueSyncHdmi hdmiStatus) {
@ -240,7 +312,7 @@ public class HueSyncHandler extends BaseThingHandler {
this.updateState(HueSyncConstants.CHANNELS.COMMANDS.BRIGHTNESS, new DecimalType(executionStatus.brightness)); this.updateState(HueSyncConstants.CHANNELS.COMMANDS.BRIGHTNESS, new DecimalType(executionStatus.brightness));
} }
private void handleRegistration(HueSyncRegistration registration) { private void handleRegistration(HueSyncRegistration registration, HueSyncDeviceConnection connection) {
this.stopTasks(); this.stopTasks();
setProperty(HueSyncConstants.REGISTRATION_ID, registration.registrationId); setProperty(HueSyncConstants.REGISTRATION_ID, registration.registrationId);
@ -252,7 +324,7 @@ public class HueSyncHandler extends BaseThingHandler {
this.updateConfiguration(configuration); this.updateConfiguration(configuration);
this.startTasks(); this.startTasks(connection);
} }
private void checkCompatibility() throws HueSyncApiException { private void checkCompatibility() throws HueSyncApiException {
@ -291,25 +363,25 @@ public class HueSyncHandler extends BaseThingHandler {
// #endregion // #endregion
// #region Override // #region Override
@Override @Override
public void initialize() { public void initialize() {
try { try {
updateStatus(ThingStatus.UNKNOWN);
this.stopTasks(); this.stopTasks();
this.updateStatus(ThingStatus.OFFLINE);
scheduler.execute(initializeConnection()); scheduler.execute(initializeConnection());
} catch (Exception e) { } catch (Exception e) {
this.stopTasks();
this.logger.warn("{}", e.getMessage()); this.logger.warn("{}", e.getMessage());
this.exceptionHandler.handle(e);
this.updateStatus(ThingStatus.OFFLINE);
} }
} }
@Override @Override
public void handleCommand(ChannelUID channelUID, Command command) { public void handleCommand(ChannelUID channelUID, Command command) {
if (thing.getStatus() != ThingStatus.ONLINE) { if (thing.getStatus() != ThingStatus.ONLINE || this.connection.isEmpty()) {
this.logger.warn("Device status: {} - Command {} for chanel {} will be ignored", this.logger.warn("Device status: {} - Command {} for channel {} will be ignored",
thing.getStatus().toString(), command.toFullString(), channelUID.toString()); thing.getStatus().toString(), command.toFullString(), channelUID.toString());
return; return;
} }
@ -321,28 +393,32 @@ public class HueSyncHandler extends BaseThingHandler {
return; return;
} }
this.connection.executeCommand(channel, command); this.connection.get().executeCommand(channel, command);
} }
@Override @Override
public void dispose() { public void dispose() {
synchronized (this) {
super.dispose(); super.dispose();
try { try {
this.stopTasks(); this.stopTasks();
this.connection.dispose(); this.connection.orElseThrow().dispose();
} catch (Exception e) { } catch (Exception e) {
this.logger.warn("{}", e.getMessage()); this.logger.warn("{}", e.getMessage());
} finally { } finally {
this.logger.debug("Thing {} ({}) disposed.", this.thing.getLabel(), this.thing.getUID()); this.logger.debug("Thing {} ({}) disposed.", this.thing.getLabel(), this.thing.getUID());
} }
} }
}
@Override @Override
public void handleRemoval() { public void handleRemoval() {
super.handleRemoval(); super.handleRemoval();
this.connection.unregisterDevice(); if (this.connection.isPresent()) {
this.connection.get().unregisterDevice();
}
} }
// #endregion // #endregion

View File

@ -18,7 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.huesync.internal.api.dto.device.HueSyncDevice; import org.openhab.binding.huesync.internal.api.dto.device.HueSyncDevice;
import org.openhab.binding.huesync.internal.api.dto.registration.HueSyncRegistration; import org.openhab.binding.huesync.internal.api.dto.registration.HueSyncRegistration;
import org.openhab.binding.huesync.internal.connection.HueSyncDeviceConnection; import org.openhab.binding.huesync.internal.connection.HueSyncDeviceConnection;
import org.openhab.binding.huesync.internal.exceptions.HueSyncConnectionException; import org.openhab.binding.huesync.internal.types.HueSyncExceptionHandler;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -33,10 +33,13 @@ public class HueSyncRegistrationTask implements Runnable {
private final HueSyncDeviceConnection connection; private final HueSyncDeviceConnection connection;
private final HueSyncDevice deviceInfo; private final HueSyncDevice deviceInfo;
private final HueSyncExceptionHandler exceptionHandler;
private final Consumer<HueSyncRegistration> action; private final Consumer<HueSyncRegistration> action;
public HueSyncRegistrationTask(HueSyncDeviceConnection connection, HueSyncDevice deviceInfo, public HueSyncRegistrationTask(HueSyncDeviceConnection connection, HueSyncDevice deviceInfo,
Consumer<HueSyncRegistration> action) { Consumer<HueSyncRegistration> action, HueSyncExceptionHandler exceptionHandler) {
this.exceptionHandler = exceptionHandler;
this.connection = connection; this.connection = connection;
this.deviceInfo = deviceInfo; this.deviceInfo = deviceInfo;
this.action = action; this.action = action;
@ -61,8 +64,8 @@ public class HueSyncRegistrationTask implements Runnable {
this.action.accept(registration); this.action.accept(registration);
} }
} catch (HueSyncConnectionException e) { } catch (Exception e) {
this.logger.warn("{}", e.getMessage()); this.exceptionHandler.handle(e);
} }
} }
} }

View File

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

View File

@ -26,9 +26,9 @@ import org.osgi.framework.ServiceReference;
* @author Patrik Gfeller - Initial Contribution * @author Patrik Gfeller - Initial Contribution
*/ */
@NonNullByDefault @NonNullByDefault
public class HueSyncLocalizer { public class ResourceHelper {
private static final Locale LOCALE = Locale.ENGLISH; private static final Locale LOCALE = Locale.ENGLISH;
private static final BundleContext BUNDLE_CONTEXT = FrameworkUtil.getBundle(HueSyncLocalizer.class) private static final BundleContext BUNDLE_CONTEXT = FrameworkUtil.getBundle(ResourceHelper.class)
.getBundleContext(); .getBundleContext();
private static final ServiceReference<TranslationProvider> SERVICE_REFERENCE = BUNDLE_CONTEXT private static final ServiceReference<TranslationProvider> SERVICE_REFERENCE = BUNDLE_CONTEXT
.getServiceReference(TranslationProvider.class); .getServiceReference(TranslationProvider.class);

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.huesync.internal.types;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Marker interface for exception handler implementations
*
* @author Patrik Gfeller - Initial contribution
*/
@NonNullByDefault
public interface HueSyncExceptionHandler {
void handle(Exception exception);
}

View File

@ -91,15 +91,13 @@ channel-type.huesync.execution-mode.command.option.music = Music
channel-type.huesync.execution-sync-active.label = Synchronization Active channel-type.huesync.execution-sync-active.label = Synchronization Active
channel-type.huesync.execution-sync-active.description = <p> <b>OFF</b> in case of <i>powersave</i> or <i>passthrough</i> mode, and <b>ON</b> in case of <i>video</i>, <i>game</i> or <i>music</i> mode. </p> <p> When changed from <b>OFF</b> to <b>ON</b>, it will start syncing in last used mode for current source. When changed from <b>ON</b> to <b>OFF</b>, will set <i>passthrough</i> mode. </p> channel-type.huesync.execution-sync-active.description = <p> <b>OFF</b> in case of <i>powersave</i> or <i>passthrough</i> mode, and <b>ON</b> in case of <i>video</i>, <i>game</i> or <i>music</i> mode. </p> <p> When changed from <b>OFF</b> to <b>ON</b>, it will start syncing in last used mode for current source. When changed from <b>ON</b> to <b>OFF</b>, will set <i>passthrough</i> mode. </p>
# *** exceptions ***
exception.generic.connection = "Unable to connect to device."
# api & connection exceptions # api & connection exceptions
api.minimal-version = Only devices with API level >= 7 are supported api.minimal-version = Only devices with API level >= 7 are supported
api.communication-problem = Communication problem with the device api.communication-problem = Unable to communicate with the device (API)
connection.invalid-login = Invalid or missing credentials connection.invalid-login = Invalid or missing credentials
connection.generic-error = Unable to communicate with the device (Generic)
connection.server-error = Device was not able to process the request.
# registration # registration