[netatmo] Avoid rushing server APIs (#20314)

* Stabilize activity peaks

Signed-off-by: clinique <gael@lhopital.org>
Signed-off-by: gael@lhopital.org <gael@lhopital.org>
This commit is contained in:
Gaël L'hopital
2026-03-07 09:07:09 +01:00
committed by GitHub
parent 6ab35524a7
commit 7ae9e39c72
5 changed files with 166 additions and 34 deletions
@@ -12,6 +12,7 @@
*/
package org.openhab.binding.netatmo.internal.api.dto;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;
@@ -37,15 +38,15 @@ public class HomeEvent extends Event {
public class NAEventsDataResponse extends ApiResponse<BodyResponse<Home>> {
}
private record Snapshot(@Nullable String url, @Nullable ZonedDateTime expiresAt) {
private record Snapshot(@Nullable String url, @Nullable Instant expiresAt) {
public @Nullable String url() {
ZonedDateTime expires = expiresAt;
// If no expiration data provided, lets consider it is available
if (expires == null) {
Instant expires = expiresAt;
// If no expiration data provided or later than now: it is available
if (expires == null || expires.isAfter(Instant.now())) {
return url;
}
// If the snapshot is expired we consider it as not available, so do not provide the url
return expires.isAfter(ZonedDateTime.now().withZoneSameInstant(expires.getZone())) ? url : null;
// Consider it as not available, so do not provide the url
return null;
}
}
@@ -119,4 +120,9 @@ public class HomeEvent extends Event {
private @Nullable String internalGetUrl(@Nullable Snapshot image) {
return image == null ? null : image.url();
}
@Override
public boolean isIgnoredForThingUpdate() {
return true;
}
}
@@ -54,7 +54,10 @@ public class NADeserializer {
(JsonDeserializer<HomeData>) (json, type, context) -> context.deserialize(json,
json.getAsJsonObject().has("therm_mode") ? HomeData.Energy.class
: HomeData.Security.class))
.registerTypeAdapter(ZonedDateTime.class, (JsonDeserializer<ZonedDateTime>) (json, type, context) -> {
.registerTypeAdapter(Instant.class, (JsonDeserializer<Instant>) (json, type, context) -> {
long netatmoTS = json.getAsJsonPrimitive().getAsLong();
return Instant.ofEpochSecond(netatmoTS);
}).registerTypeAdapter(ZonedDateTime.class, (JsonDeserializer<ZonedDateTime>) (json, type, context) -> {
long netatmoTS = json.getAsJsonPrimitive().getAsLong();
Instant i = Instant.ofEpochSecond(netatmoTS);
return ZonedDateTime.ofInstant(i, timeZoneProvider.getTimeZone());
@@ -104,11 +104,14 @@ import com.google.gson.GsonBuilder;
public class ApiBridgeHandler extends BaseBridgeHandler {
private static final int TIMEOUT_S = 20;
private static final int API_LIMIT_INTERVAL_S = 3600;
private static final int MAX_REQUESTS_PER_SECOND = 5;
private static final long ONE_SECOND_IN_NANOS = TimeUnit.SECONDS.toNanos(1);
private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class);
private final AuthenticationApi connectApi = new AuthenticationApi(this);
private final Map<Class<? extends RestManager>, RestManager> managers = new HashMap<>();
private final Deque<Instant> requestsTimestamps = new ArrayDeque<>(200);
private final Deque<Long> requestsWindowInNanos = new ArrayDeque<>(MAX_REQUESTS_PER_SECOND);
private final BindingConfiguration bindingConf;
private final HttpClient httpClient;
private final OAuthFactory oAuthFactory;
@@ -328,28 +331,13 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
}
connectApi.getAuthorization().ifPresent(auth -> request.header(HttpHeader.AUTHORIZATION, auth));
if (payload != null && contentType != null
&& (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))) {
InputStream stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
try (InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(stream)) {
request.content(inputStreamContentProvider, contentType);
request.header(HttpHeader.ACCEPT, MediaType.APPLICATION_JSON);
}
logger.trace(" -with payload: {} ", payload);
}
if (isLinked(requestCountChannelUID)) {
Instant now = Instant.now();
requestsTimestamps.addLast(now);
Instant oneHourAgo = now.minus(1, ChronoUnit.HOURS);
while (requestsTimestamps.getFirst().isBefore(oneHourAgo)) {
requestsTimestamps.removeFirst();
}
updateState(requestCountChannelUID, new DecimalType(requestsTimestamps.size()));
}
handlePayload(method, payload, contentType, request);
handleRequestCounter();
logger.trace(" -with headers: {} ",
String.join(", ", request.getHeaders().stream().map(HttpField::toString).toList()));
throttleApiRequestRate();
ContentResponse response = request.send();
Code statusCode = HttpStatus.getCode(response.getStatus());
@@ -377,8 +365,13 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
String delayStr = response.getHeaders().get(HttpHeader.RETRY_AFTER);
int delay = delayStr != null ? Integer.valueOf(delayStr) : Integer.MAX_VALUE;
if (exception.getStatusCode() == ServiceError.CONCURRENCY_LIMIT_TIMED_OUT) {
if (retryCount > 0) {
logger.debug("Concurrency limited section, retry counter: {}", retryCount);
return executeUri(uri, method, clazz, payload, contentType, retryCount - 1);
} else {
delay = Math.min(delay, TIMEOUT_S);
message = "@text/concurrency-limit-timed-out [ \"%d\" ]";
}
} else { // ServiceError.MAXIMUM_USAGE_REACHED
delay = Math.min(delay, API_LIMIT_INTERVAL_S);
message = "@text/maximum-usage-reached [ \"%d\" ]";
@@ -400,6 +393,56 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
}
}
private void handleRequestCounter() {
if (!isLinked(requestCountChannelUID)) {
return;
}
Instant now = Instant.now();
requestsTimestamps.addLast(now);
Instant oneHourAgo = now.minus(1, ChronoUnit.HOURS);
requestsTimestamps.removeIf(t -> t.isBefore(oneHourAgo));
updateState(requestCountChannelUID, new DecimalType(requestsTimestamps.size()));
}
private void handlePayload(HttpMethod method, @Nullable String payload, @Nullable String contentType,
Request request) {
if (payload == null || contentType == null
|| !(HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))) {
return;
}
InputStream stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8));
try (InputStreamContentProvider inputStreamContentProvider = new InputStreamContentProvider(stream)) {
request.content(inputStreamContentProvider, contentType);
request.header(HttpHeader.ACCEPT, MediaType.APPLICATION_JSON);
}
logger.trace(" -with payload: {} ", payload);
}
private void throttleApiRequestRate() throws InterruptedException {
while (true) {
long now = System.nanoTime();
long threshold = now - ONE_SECOND_IN_NANOS;
while (!requestsWindowInNanos.isEmpty() && requestsWindowInNanos.getFirst() <= threshold) {
requestsWindowInNanos.removeFirst();
}
if (requestsWindowInNanos.size() < MAX_REQUESTS_PER_SECOND) {
requestsWindowInNanos.addLast(now);
return;
}
long waitNanos = requestsWindowInNanos.getFirst() + ONE_SECOND_IN_NANOS - now;
if (waitNanos > 0) {
logger.trace("Rate limit reached ({} req/s), waiting {} ms", MAX_REQUESTS_PER_SECOND,
TimeUnit.NANOSECONDS.toMillis(waitNanos));
TimeUnit.NANOSECONDS.sleep(waitNanos);
}
}
}
public void identifyAllModulesAndApplyAction(BiFunction<NAModule, ThingUID, Optional<ThingUID>> action) {
ThingUID accountUID = getThing().getUID();
try {
@@ -184,17 +184,22 @@ public interface CommonInterface {
return;
}
}
String finalReason = null;
for (Capability cap : getCapabilities().values()) {
String thingStatusReason = cap.setNewData(newData);
if (thingStatusReason != null) {
finalReason = thingStatusReason;
String statusReason = cap.setNewData(newData);
if (statusReason != null) {
finalReason = statusReason;
}
}
if (newData.isIgnoredForThingUpdate()) {
return;
}
// Prevent turning ONLINE myself if in the meantime something turned account OFFLINE
ApiBridgeHandler accountHandler = getAccountHandler();
if (accountHandler != null && accountHandler.isConnected() && !newData.isIgnoredForThingUpdate()) {
setThingStatus(finalReason == null ? ThingStatus.ONLINE : ThingStatus.OFFLINE, ThingStatusDetail.NONE,
if (getAccountHandler() instanceof ApiBridgeHandler accountHandler && accountHandler.isConnected()) {
setThingStatus(finalReason != null ? ThingStatus.OFFLINE : ThingStatus.ONLINE, ThingStatusDetail.NONE,
finalReason);
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2010-2026 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.netatmo.internal.api.dto;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.ZoneId;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.openhab.binding.netatmo.internal.api.NetatmoException;
import org.openhab.binding.netatmo.internal.deserialization.NADeserializer;
import org.openhab.core.i18n.TimeZoneProvider;
/**
* @author Gaël L'hopital - Initial contribution
*/
public class HomeEventTest {
private static final long PAST_EPOCH_SECONDS = 946684800L; // 2000-01-01T00:00:00Z
private static final long FUTURE_EPOCH_SECONDS = 4102444800L; // 2100-01-01T00:00:00Z
private static NADeserializer gson;
@BeforeAll
public static void init() {
TimeZoneProvider timeZoneProvider = mock(TimeZoneProvider.class);
when(timeZoneProvider.getTimeZone()).thenReturn(ZoneId.systemDefault());
gson = new NADeserializer(timeZoneProvider);
}
@Test
public void testSnapshotUrlDeserializationWithFutureExpiration() throws NetatmoException {
String event = """
{\
"snapshot": {\
"url": "https://example.netatmo/snapshot.jpg",\
"expires_at": %d\
}\
}\
""".formatted(FUTURE_EPOCH_SECONDS);
HomeEvent object = gson.deserialize(HomeEvent.class, event);
assertEquals("https://example.netatmo/snapshot.jpg", object.getSnapshotUrl());
}
@Test
public void testVignetteUrlDeserializationWithPastExpiration() throws NetatmoException {
String event = """
{\
"vignette": {\
"url": "https://example.netatmo/vignette.jpg",\
"expires_at": %d\
}\
}\
""".formatted(PAST_EPOCH_SECONDS);
HomeEvent object = gson.deserialize(HomeEvent.class, event);
assertNull(object.getVignetteUrl());
}
}