Skip to content

Commit

Permalink
[netatmo] API limit reached handling (#16489)
Browse files Browse the repository at this point in the history
Signed-off-by: Gaël L'hopital <[email protected]>
  • Loading branch information
clinique authored Dec 28, 2024
1 parent 7b986e0 commit df1ee5f
Show file tree
Hide file tree
Showing 7 changed files with 57 additions and 52 deletions.
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 @@ -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 @@ -23,7 +23,6 @@
public enum ConfigurationLevel {
EMPTY_CLIENT_ID("@text/conf-error-no-client-id"),
EMPTY_CLIENT_SECRET("@text/conf-error-no-client-secret"),
REFRESH_TOKEN_NEEDED("@text/conf-error-grant-needed [ \"%s\" ]"),
COMPLETED("");

public String message;
Expand Down
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.Instant;
Expand Down Expand Up @@ -103,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 @@ -128,7 +128,6 @@ 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);
}

Expand All @@ -138,15 +137,9 @@ 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;
}

Expand All @@ -170,15 +163,13 @@ public void openConnection(@Nullable String code, @Nullable String redirectUri)
logger.debug("Connected 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 @@ -197,8 +188,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 @@ -207,8 +197,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 All @@ -227,35 +216,43 @@ private void startAuthorizationFlow() {
servlet.startListening();
grantServlet = Optional.of(servlet);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
ConfigurationLevel.REFRESH_TOKEN_NEEDED.message.formatted(servlet.getPath()));
"@text/conf-error-grant-needed [ \"%s\" ]".formatted(servlet.getPath()));
connectApi.dispose();
}

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 @@ -280,13 +277,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 @@ -303,7 +299,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 Down Expand Up @@ -345,29 +341,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);
} else if (e.getStatusCode() == ServiceError.INVALID_TOKEN_MISSING) {
startAuthorizationFlow();
if (statusCode == Code.TOO_MANY_REQUESTS
|| exception.getStatusCode() == ServiceError.MAXIMUM_USAGE_REACHED) {
prepareReconnection(API_LIMIT_INTERVAL_S,
"@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

0 comments on commit df1ee5f

Please sign in to comment.