Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[huesync] - Fix for issue #18062 - Configuration (API Token) lost if device goes offline #18100

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
*/
@NonNullByDefault
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 final String DEVICE = "device";
public static final String REGISTRATIONS = "registrations";
Expand Down Expand Up @@ -81,9 +89,11 @@ public static class HDMI {
public static final String PARAMETER_HOST = "host";
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 POLL_INITIAL_DELAY = 10;

public static final String REGISTRATION_ID = "registrationId";
public static final String API_TOKEN = "apiAccessToken";
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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;
Expand All @@ -26,8 +27,6 @@
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;
Expand Down Expand Up @@ -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";
Expand All @@ -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)
Expand Down Expand Up @@ -102,46 +138,30 @@ public void updateAuthentication(String id, String token) {

// #region protected
protected @Nullable <T> T executeRequest(HttpMethod method, String endpoint, String payload,
@Nullable Class<T> type) {
try {
return this.processedResponse(this.executeRequest(method, endpoint, payload), type);
} catch (ExecutionException e) {
this.handleExecutionException(e);
} catch (InterruptedException | TimeoutException e) {
this.logger.warn("{}", e.getMessage());
}
@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) {
try {
return this.processedResponse(this.executeGetRequest(endpoint), type);
} catch (ExecutionException e) {
this.handleExecutionException(e);
} catch (InterruptedException | TimeoutException e) {
this.logger.warn("{}", e.getMessage());
}
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();
}
}

Expand All @@ -151,93 +171,51 @@ protected void dispose() {
// #endregion

// #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 {
ContentResponse response = request.execute();

/*
* 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
* 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) {
switch (response.getStatus()) {
case HttpStatus.OK_200 -> {
return (type != null && (response instanceof ContentResponse))
? this.deserialize(((ContentResponse) response).getContentAsString(), type)
: null;
return this.deserialize(response.getContentAsString(), type);
}
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.BAD_REQUEST_400 -> {
logger.debug("registration in progress: no token received yet");
return null;
}
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);
case HttpStatus.UNAUTHORIZED_401 -> message = "@text/connection.invalid-login";
case HttpStatus.NOT_FOUND_404 -> message = "@text/connection.generic-error";
}
} catch (HueSyncConnectionException e) {
this.logger.warn("{}", e.getMessage());
}
return null;
}

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());

return null;
}
}

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));
throw new HueSyncConnectionException(message, new HttpResponseException(message, response));
} catch (JsonProcessingException | InterruptedException | ExecutionException | TimeoutException e) {
throw new HueSyncConnectionException(message, e);
}

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 @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 = "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
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;
import org.openhab.core.library.types.StringType;
Expand All @@ -55,11 +56,14 @@ public class HueSyncDeviceConnection {
private final Logger logger = LoggerFactory.getLogger(HueSyncDeviceConnection.class);

private final HueSyncConnection connection;
private final HueSyncExceptionHandler exceptionHandler;

private final Map<String, Consumer<Command>> deviceCommandExecutors = new HashMap<>();

public HueSyncDeviceConnection(HttpClient httpClient, HueSyncConfiguration configuration)
throws CertificateException, IOException, URISyntaxException {
public HueSyncDeviceConnection(HttpClient httpClient, HueSyncConfiguration configuration,
HueSyncExceptionHandler exceptionHandler) throws CertificateException, IOException, URISyntaxException {

this.exceptionHandler = exceptionHandler;
this.connection = new HueSyncConnection(httpClient, configuration.host, configuration.port);

registerCommandHandlers();
Expand Down Expand Up @@ -109,7 +113,11 @@ private void execute(String key, Command command) {

String json = String.format("{ \"%s\": %s }", key, value);

this.connection.executeRequest(HttpMethod.PUT, ENDPOINTS.EXECUTION, json, null);
try {
this.connection.executeRequest(HttpMethod.PUT, ENDPOINTS.EXECUTION, json, null);
} catch (HueSyncConnectionException exception) {
exceptionHandler.handle(exception);
}
}

// #endregion
Expand All @@ -131,29 +139,29 @@ public void executeCommand(Channel channel, Command command) {
}
}

public @Nullable HueSyncDevice getDeviceInfo() {
public @Nullable HueSyncDevice getDeviceInfo() throws Exception {
return this.connection.executeGetRequest(ENDPOINTS.DEVICE, HueSyncDevice.class);
}

public @Nullable HueSyncDeviceDetailed getDetailedDeviceInfo() {
public @Nullable HueSyncDeviceDetailed getDetailedDeviceInfo() throws Exception {
return this.connection.isRegistered()
? this.connection.executeRequest(HttpMethod.GET, ENDPOINTS.DEVICE, "", HueSyncDeviceDetailed.class)
: null;
}

public @Nullable HueSyncHdmi getHdmiInfo() {
public @Nullable HueSyncHdmi getHdmiInfo() throws Exception {
return this.connection.isRegistered()
? this.connection.executeRequest(HttpMethod.GET, ENDPOINTS.HDMI, "", HueSyncHdmi.class)
: null;
}

public @Nullable HueSyncExecution getExecutionInfo() {
public @Nullable HueSyncExecution getExecutionInfo() throws Exception {
return this.connection.isRegistered()
? this.connection.executeRequest(HttpMethod.GET, ENDPOINTS.EXECUTION, "", HueSyncExecution.class)
: null;
}

public @Nullable HueSyncRegistration registerDevice(String id) throws HueSyncConnectionException {
public @Nullable HueSyncRegistration registerDevice(String id) throws Exception {
if (!id.isBlank()) {
try {
HueSyncRegistrationRequest dto = new HueSyncRegistrationRequest();
Expand Down Expand Up @@ -181,7 +189,11 @@ public boolean isRegistered() {
}

public void unregisterDevice() {
this.connection.unregisterDevice();
try {
this.connection.unregisterDevice();
} catch (HueSyncConnectionException e) {
this.logger.warn("{}", e.getMessage());
}
}

public void dispose() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
package org.openhab.binding.huesync.internal.exceptions;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;

/**
*
Expand All @@ -21,8 +22,18 @@
@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;
}
}
Loading