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

[netatmo] API limit reached handling #16489

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
*/
@NonNullByDefault
public class NetatmoBindingConstants {

public static final String BINDING_ID = "netatmo";
public static final String VENDOR = "Netatmo";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import static org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.*;
import static org.openhab.core.auth.oauth2client.internal.Keyword.*;

import java.net.URI;
import java.util.List;
import java.util.Optional;
import java.util.Set;
Expand All @@ -37,8 +36,8 @@
*/
@NonNullByDefault
public class AuthenticationApi extends RestManager {
public static final URI TOKEN_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_TOKEN).build();
public static final URI AUTH_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_AUTHORIZE).build();
public static final String TOKEN_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_TOKEN).build().toString();
public static final String AUTH_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_AUTHORIZE).build().toString();

private List<Scope> grantedScope = List.of();
private @Nullable String authorization;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,13 @@ public class ApiHandlerConfiguration {
public String webHookUrl = "";
public String webHookPostfix = "";
public int reconnectInterval = 300;

public ConfigurationLevel check() {
if (clientId.isBlank()) {
return ConfigurationLevel.EMPTY_CLIENT_ID;
} else if (clientSecret.isBlank()) {
return ConfigurationLevel.EMPTY_CLIENT_SECRET;
}
return ConfigurationLevel.COMPLETED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
Expand All @@ -35,6 +34,7 @@
import java.util.concurrent.TimeoutException;
import java.util.function.BiFunction;

import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.UriBuilder;

import org.eclipse.jdt.annotation.NonNullByDefault;
Expand Down Expand Up @@ -102,6 +102,7 @@
@NonNullByDefault
public class ApiBridgeHandler extends BaseBridgeHandler {
private static final int TIMEOUT_S = 20;
private static final int API_LIMIT_INTERVAL_S = 3600;

private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class);
private final AuthenticationApi connectApi = new AuthenticationApi(this);
Expand All @@ -127,8 +128,7 @@ public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer des
this.deserializer = deserializer;
this.httpService = httpService;
this.oAuthFactory = oAuthFactory;

requestCountChannelUID = new ChannelUID(thing.getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
this.requestCountChannelUID = new ChannelUID(thing.getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
}

@Override
Expand All @@ -137,22 +137,16 @@ public void initialize() {

ApiHandlerConfiguration configuration = getConfiguration();

if (configuration.clientId.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
ConfigurationLevel.EMPTY_CLIENT_ID.message);
return;
}

if (configuration.clientSecret.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
ConfigurationLevel.EMPTY_CLIENT_SECRET.message);
ConfigurationLevel confLevel = configuration.check();
if (!ConfigurationLevel.COMPLETED.equals(confLevel)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, confLevel.message);
return;
}

oAuthClientService = oAuthFactory
.createOAuthClientService(this.getThing().getUID().getAsString(),
AuthenticationApi.TOKEN_URI.toString(), AuthenticationApi.AUTH_URI.toString(),
configuration.clientId, configuration.clientSecret, FeatureArea.ALL_SCOPES, false)
.createOAuthClientService(this.getThing().getUID().getAsString(), AuthenticationApi.TOKEN_URI,
AuthenticationApi.AUTH_URI, configuration.clientId, configuration.clientSecret,
FeatureArea.ALL_SCOPES, false)
.withGsonBuilder(new GsonBuilder().registerTypeAdapter(AccessTokenResponse.class,
new AccessTokenResponseDeserializer()));

Expand All @@ -169,15 +163,13 @@ public void openConnection(@Nullable String code, @Nullable String redirectUri)
logger.debug("Connecting to Netatmo API.");

ApiHandlerConfiguration configuration = getConfiguration();
if (!configuration.webHookUrl.isBlank()) {
SecurityApi securityApi = getRestManager(SecurityApi.class);
if (securityApi != null) {
webHookServlet.ifPresent(servlet -> servlet.dispose());
WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi,
configuration.webHookUrl, configuration.webHookPostfix);
servlet.startListening();
this.webHookServlet = Optional.of(servlet);
}
if (!configuration.webHookUrl.isBlank()
&& getRestManager(SecurityApi.class) instanceof SecurityApi securityApi) {
webHookServlet.ifPresent(servlet -> servlet.dispose());
WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi,
configuration.webHookUrl, configuration.webHookPostfix);
servlet.startListening();
this.webHookServlet = Optional.of(servlet);
}

updateStatus(ThingStatus.ONLINE);
Expand All @@ -200,8 +192,7 @@ private boolean authenticate(@Nullable String code, @Nullable String redirectUri
accessTokenResponse = oAuthClientService.getAccessTokenResponseByAuthorizationCode(code, redirectUri);

// Dispose grant servlet upon completion of authorization flow.
grantServlet.ifPresent(servlet -> servlet.dispose());
grantServlet = Optional.empty();
freeGrantServlet();
} else {
accessTokenResponse = oAuthClientService.getAccessTokenResponse();
}
Expand All @@ -210,8 +201,7 @@ private boolean authenticate(@Nullable String code, @Nullable String redirectUri
startAuthorizationFlow();
return false;
} catch (IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
prepareReconnection(code, redirectUri);
prepareReconnection(getConfiguration().reconnectInterval, e.getMessage(), code, redirectUri);
return false;
}

Expand Down Expand Up @@ -239,27 +229,35 @@ public ApiHandlerConfiguration getConfiguration() {
return getConfigAs(ApiHandlerConfiguration.class);
}

private void prepareReconnection(@Nullable String code, @Nullable String redirectUri) {
private void prepareReconnection(int delay, @Nullable String message, @Nullable String code,
@Nullable String redirectUri) {
if (!ThingStatus.OFFLINE.equals(thing.getStatus())) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
}
connectApi.dispose();
freeConnectJob();
connectJob = Optional.of(scheduler.schedule(() -> openConnection(code, redirectUri),
getConfiguration().reconnectInterval, TimeUnit.SECONDS));
connectJob = Optional.of(scheduler.schedule(() -> openConnection(code, redirectUri), delay, TimeUnit.SECONDS));
logger.debug("Reconnection scheduled in {} seconds", delay);
}

private void freeConnectJob() {
connectJob.ifPresent(j -> j.cancel(true));
connectJob = Optional.empty();
}

private void freeGrantServlet() {
grantServlet.ifPresent(servlet -> servlet.dispose());
grantServlet = Optional.empty();
}

@Override
public void dispose() {
logger.debug("Shutting down Netatmo API bridge handler.");

webHookServlet.ifPresent(servlet -> servlet.dispose());
webHookServlet = Optional.empty();

grantServlet.ifPresent(servlet -> servlet.dispose());
grantServlet = Optional.empty();
freeGrantServlet();

connectApi.dispose();
freeConnectJob();
Expand All @@ -284,13 +282,12 @@ public void handleCommand(ChannelUID channelUID, Command command) {
public <T extends RestManager> @Nullable T getRestManager(Class<T> clazz) {
if (!managers.containsKey(clazz)) {
try {
Constructor<T> constructor = clazz.getConstructor(getClass());
T instance = constructor.newInstance(this);
T instance = clazz.getConstructor(getClass()).newInstance(this);
Set<Scope> expected = instance.getRequiredScopes();
if (connectApi.matchesScopes(expected)) {
managers.put(clazz, instance);
} else {
logger.info("Unable to instantiate {}, expected scope {} is not active", clazz, expected);
logger.warn("Unable to instantiate {}, expected scope {} is not active", clazz, expected);
}
} catch (SecurityException | ReflectiveOperationException e) {
logger.warn("Error invoking RestManager constructor for class {}: {}", clazz, e.getMessage());
Expand All @@ -307,7 +304,7 @@ public synchronized <T> T executeUri(URI uri, HttpMethod method, Class<T> clazz,
Request request = httpClient.newRequest(uri).method(method).timeout(TIMEOUT_S, TimeUnit.SECONDS);

if (!authenticate(null, null)) {
prepareReconnection(null, null);
prepareReconnection(getConfiguration().reconnectInterval, "@text/status-bridge-offline", null, null);
throw new NetatmoException("Not authenticated");
}
connectApi.getAuthorization().ifPresent(auth -> request.header(HttpHeader.AUTHORIZATION, auth));
Expand All @@ -317,7 +314,7 @@ public synchronized <T> T executeUri(URI uri, HttpMethod method, Class<T> clazz,
InputStream stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
try (InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(stream)) {
request.content(inputStreamContentProvider, contentType);
request.header(HttpHeader.ACCEPT, "application/json");
request.header(HttpHeader.ACCEPT, MediaType.APPLICATION_JSON);
}
logger.trace(" -with payload: {} ", payload);
}
Expand Down Expand Up @@ -348,27 +345,30 @@ public synchronized <T> T executeUri(URI uri, HttpMethod method, Class<T> clazz,
try {
exception = new NetatmoException(deserializer.deserialize(ApiError.class, responseBody));
} catch (NetatmoException e) {
exception = new NetatmoException("Error deserializing error: %s".formatted(statusCode.getMessage()));
if (statusCode == Code.TOO_MANY_REQUESTS) {
exception = new NetatmoException(statusCode.getMessage());
} else {
exception = new NetatmoException(
"Error deserializing error: %s".formatted(statusCode.getMessage()));
}
}
throw exception;
} catch (NetatmoException e) {
if (e.getStatusCode() == ServiceError.MAXIMUM_USAGE_REACHED) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/maximum-usage-reached");
prepareReconnection(null, null);
if (statusCode == Code.TOO_MANY_REQUESTS
|| exception.getStatusCode() == ServiceError.MAXIMUM_USAGE_REACHED) {
prepareReconnection(API_LIMIT_INTERVAL_S,
clinique marked this conversation as resolved.
Show resolved Hide resolved
"@text/maximum-usage-reached [ \"%d\" ]".formatted(API_LIMIT_INTERVAL_S), null, null);
}
throw e;
throw exception;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
throw new NetatmoException("Request interrupted");
throw new NetatmoException(e, "Request interrupted");
} catch (TimeoutException | ExecutionException e) {
if (retryCount > 0) {
logger.debug("Request timedout, retry counter: {}", retryCount);
logger.debug("Request error, retry counter: {}", retryCount);
return executeUri(uri, method, clazz, payload, contentType, retryCount - 1);
}
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/request-time-out");
prepareReconnection(null, null);
throw new NetatmoException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
prepareReconnection(getConfiguration().reconnectInterval, "@text/request-time-out", null, e.getMessage());
throw new NetatmoException("%s: \"%s\"".formatted(e.getClass().getName(), e.getMessage()));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ protected void updateWebhookEvent(WebhookEvent event) {
handler.updateState(GROUP_LAST_EVENT, CHANNEL_EVENT_TIME, toDateTimeType(event.getTime()));
handler.updateState(GROUP_LAST_EVENT, CHANNEL_EVENT_SUBTYPE, Objects.requireNonNull(
event.getSubTypeDescription().map(ChannelTypeUtils::toStringType).orElse(UnDefType.NULL)));

final String message = event.getName();
handler.updateState(GROUP_LAST_EVENT, CHANNEL_EVENT_MESSAGE,
message == null || message.isBlank() ? UnDefType.NULL : toStringType(message));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public CameraCapability(CommonInterface handler, NetatmoDescriptionProvider desc
List<ChannelHelper> channelHelpers) {
super(handler, descriptionProvider, channelHelpers);
this.personChannelUID = new ChannelUID(thingUID, GROUP_LAST_EVENT, CHANNEL_EVENT_PERSON_ID);
this.cameraHelper = (CameraChannelHelper) channelHelpers.stream().filter(c -> c instanceof CameraChannelHelper)
this.cameraHelper = (CameraChannelHelper) channelHelpers.stream().filter(CameraChannelHelper.class::isInstance)
.findFirst().orElseThrow(() -> new IllegalArgumentException(
"CameraCapability must find a CameraChannelHelper, please file a bug report."));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ device-not-connected = Thing is not reachable
data-over-limit = Data seems quite old
request-time-out = Request timed out - will attempt reconnection later
deserialization-unknown = Deserialization lead to an unknown code
maximum-usage-reached = Maximum usage reached. Will try reconnection after `reconnectInterval` seconds.
maximum-usage-reached = Maximum usage reached, will reconnect in {0} seconds.

homestatus-unknown-error = Unknown error
homestatus-internal-error = Internal error
Expand Down