mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[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:
+12
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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());
|
||||
|
||||
+64
-21
@@ -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) {
|
||||
delay = Math.min(delay, TIMEOUT_S);
|
||||
message = "@text/concurrency-limit-timed-out [ \"%d\" ]";
|
||||
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 {
|
||||
|
||||
+11
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user