[gardena] Improve API rate limit handling (#13016)

* [gardena] eliminate dangling references on dispose
* [gardena] add fixes for 429 errors
* [gardena] apply rate limiting to binding restarts
* [gardena] eliminate NPE if startup fails with exception

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
This commit is contained in:
Andrew Fiddian-Green
2022-08-04 19:20:40 +01:00
committed by GitHub
parent 9f3a23c55f
commit ac12e5bfed
8 changed files with 280 additions and 113 deletions

View File

@@ -37,4 +37,6 @@ public class GardenaBindingConstants {
public static final String DEVICE_TYPE_WATER_CONTROL = "water_control";
public static final String DEVICE_TYPE_SENSOR = "sensor";
public static final String DEVICE_TYPE_POWER = "power";
public static final String API_CALL_SUPPRESSION_UNTIL = "apiCallSuppressionUntil";
}

View File

@@ -12,9 +12,11 @@
*/
package org.openhab.binding.gardena.internal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -79,23 +81,25 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
private static final String URL_API_LOCATIONS = URL_API_GARDENA + "/locations";
private static final String URL_API_COMMAND = URL_API_GARDENA + "/command";
private String id;
private GardenaConfig config;
private ScheduledExecutorService scheduler;
private final String id;
private final GardenaConfig config;
private final ScheduledExecutorService scheduler;
private Map<String, Device> allDevicesById = new HashMap<>();
private LocationsResponse locationsResponse;
private GardenaSmartEventListener eventListener;
private final Map<String, Device> allDevicesById = new HashMap<>();
private @Nullable LocationsResponse locationsResponse = null;
private final GardenaSmartEventListener eventListener;
private HttpClient httpClient;
private Map<String, GardenaSmartWebSocket> webSockets = new HashMap<>();
private final HttpClient httpClient;
private final Map<String, GardenaSmartWebSocket> webSockets = new HashMap<>();
private @Nullable PostOAuth2Response token;
private boolean initialized = false;
private WebSocketClient webSocketClient;
private final WebSocketClient webSocketClient;
private Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet();
private @Nullable ScheduledFuture<?> deviceToNotifyFuture;
private @Nullable ScheduledFuture<?> newDeviceFuture;
private final Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet();
private final Object deviceUpdateTaskLock = new Object();
private @Nullable ScheduledFuture<?> deviceUpdateTask;
private final Object newDeviceTasksLock = new Object();
private final List<ScheduledFuture<?>> newDeviceTasks = new ArrayList<>();
public GardenaSmartImpl(String id, GardenaConfig config, GardenaSmartEventListener eventListener,
ScheduledExecutorService scheduler, HttpClientFactory httpClientFactory, WebSocketFactory webSocketFactory)
@@ -121,14 +125,17 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
// initially load access token
verifyToken();
locationsResponse = loadLocations();
LocationsResponse locationsResponse = loadLocations();
this.locationsResponse = locationsResponse;
// assemble devices
for (LocationDataItem location : locationsResponse.data) {
LocationResponse locationResponse = loadLocation(location.id);
if (locationResponse.included != null) {
for (DataItem<?> dataItem : locationResponse.included) {
handleDataItem(dataItem);
if (locationsResponse.data != null) {
for (LocationDataItem location : locationsResponse.data) {
LocationResponse locationResponse = loadLocation(location.id);
if (locationResponse.included != null) {
for (DataItem<?> dataItem : locationResponse.included) {
handleDataItem(dataItem);
}
}
}
}
@@ -153,16 +160,19 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
* Starts the websockets for each location.
*/
private void startWebsockets() throws Exception {
for (LocationDataItem location : locationsResponse.data) {
WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
Location locationAttributes = location.attributes;
WebSocket webSocketAttributes = webSocketCreatedResponse.data.attributes;
if (locationAttributes == null || webSocketAttributes == null) {
continue;
LocationsResponse locationsResponse = this.locationsResponse;
if (locationsResponse != null) {
for (LocationDataItem location : locationsResponse.data) {
WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
Location locationAttributes = location.attributes;
WebSocket webSocketAttributes = webSocketCreatedResponse.data.attributes;
if (locationAttributes == null || webSocketAttributes == null) {
continue;
}
String socketId = id + "-" + locationAttributes.name;
webSockets.put(location.id, new GardenaSmartWebSocket(this, webSocketClient, scheduler,
webSocketAttributes.url, token, socketId, location.id));
}
String socketId = id + "-" + locationAttributes.name;
webSockets.put(location.id, new GardenaSmartWebSocket(this, webSocketClient, scheduler,
webSocketAttributes.url, token, socketId, location.id));
}
}
@@ -299,15 +309,22 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
@Override
public void dispose() {
logger.debug("Disposing GardenaSmart");
final ScheduledFuture<?> newDeviceFuture = this.newDeviceFuture;
if (newDeviceFuture != null) {
newDeviceFuture.cancel(true);
initialized = false;
synchronized (newDeviceTasksLock) {
for (ScheduledFuture<?> task : newDeviceTasks) {
if (!task.isDone()) {
task.cancel(true);
}
}
newDeviceTasks.clear();
}
final ScheduledFuture<?> deviceToNotifyFuture = this.deviceToNotifyFuture;
if (deviceToNotifyFuture != null) {
deviceToNotifyFuture.cancel(true);
synchronized (deviceUpdateTaskLock) {
devicesToNotify.clear();
ScheduledFuture<?> task = deviceUpdateTask;
if (task != null) {
task.cancel(true);
}
deviceUpdateTask = null;
}
stopWebsockets();
try {
@@ -318,9 +335,8 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
}
httpClient.destroy();
webSocketClient.destroy();
locationsResponse = new LocationsResponse();
allDevicesById.clear();
initialized = false;
locationsResponse = null;
}
/**
@@ -353,16 +369,21 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
device = new Device(deviceId);
allDevicesById.put(device.id, device);
if (initialized) {
newDeviceFuture = scheduler.schedule(() -> {
Device newDevice = allDevicesById.get(deviceId);
if (newDevice != null) {
newDevice.evaluateDeviceType();
if (newDevice.deviceType != null) {
eventListener.onNewDevice(newDevice);
synchronized (newDeviceTasksLock) {
// remove prior completed tasks from the list
newDeviceTasks.removeIf(task -> task.isDone());
// add a new scheduled task to the list
newDeviceTasks.add(scheduler.schedule(() -> {
if (initialized) {
Device newDevice = allDevicesById.get(deviceId);
if (newDevice != null) {
newDevice.evaluateDeviceType();
if (newDevice.deviceType != null) {
eventListener.onNewDevice(newDevice);
}
}
}
}, 3, TimeUnit.SECONDS);
}, 3, TimeUnit.SECONDS));
}
}
@@ -417,18 +438,14 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
handleDataItem(dataItem);
Device device = allDevicesById.get(dataItem.getDeviceId());
if (device != null && device.active) {
devicesToNotify.add(device);
synchronized (deviceUpdateTaskLock) {
devicesToNotify.add(device);
// delay the deviceUpdated event to filter multiple events for the same device dataItem property
if (deviceToNotifyFuture == null) {
deviceToNotifyFuture = scheduler.schedule(() -> {
deviceToNotifyFuture = null;
Iterator<Device> notifyIterator = devicesToNotify.iterator();
while (notifyIterator.hasNext()) {
eventListener.onDeviceUpdated(notifyIterator.next());
notifyIterator.remove();
}
}, 1, TimeUnit.SECONDS);
// delay the deviceUpdated event to filter multiple events for the same device dataItem property
ScheduledFuture<?> task = this.deviceUpdateTask;
if (task == null || task.isDone()) {
deviceUpdateTask = scheduler.schedule(() -> notifyDevicesUpdated(), 1, TimeUnit.SECONDS);
}
}
}
}
@@ -437,6 +454,21 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
}
}
/**
* Helper scheduler task to update devices
*/
private void notifyDevicesUpdated() {
synchronized (deviceUpdateTaskLock) {
if (initialized) {
Iterator<Device> notifyIterator = devicesToNotify.iterator();
while (notifyIterator.hasNext()) {
eventListener.onDeviceUpdated(notifyIterator.next());
notifyIterator.remove();
}
}
}
}
@Override
public Device getDevice(String deviceId) throws GardenaDeviceNotFoundException {
Device device = allDevicesById.get(deviceId);

View File

@@ -12,12 +12,24 @@
*/
package org.openhab.binding.gardena.internal.handler;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.FormatStyle;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.gardena.internal.GardenaBindingConstants;
import org.openhab.binding.gardena.internal.GardenaSmart;
import org.openhab.binding.gardena.internal.GardenaSmartEventListener;
import org.openhab.binding.gardena.internal.GardenaSmartImpl;
@@ -26,6 +38,7 @@ import org.openhab.binding.gardena.internal.discovery.GardenaDeviceDiscoveryServ
import org.openhab.binding.gardena.internal.exception.GardenaException;
import org.openhab.binding.gardena.internal.model.dto.Device;
import org.openhab.binding.gardena.internal.util.UidUtils;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.io.net.http.WebSocketFactory;
import org.openhab.core.thing.Bridge;
@@ -39,7 +52,6 @@ import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -51,26 +63,83 @@ import org.slf4j.LoggerFactory;
@NonNullByDefault
public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaSmartEventListener {
private final Logger logger = LoggerFactory.getLogger(GardenaAccountHandler.class);
private static final long REINITIALIZE_DELAY_SECONDS = 120;
private static final long REINITIALIZE_DELAY_HOURS_LIMIT_EXCEEDED = 24;
// timing constants
private static final Duration REINITIALIZE_DELAY_SECONDS = Duration.ofSeconds(120);
private static final Duration REINITIALIZE_DELAY_MINUTES_BACK_OFF = Duration.ofMinutes(15).plusSeconds(10);
private static final Duration REINITIALIZE_DELAY_HOURS_LIMIT_EXCEEDED = Duration.ofHours(24).plusSeconds(10);
// assets
private @Nullable GardenaDeviceDiscoveryService discoveryService;
private @Nullable GardenaSmart gardenaSmart;
private HttpClientFactory httpClientFactory;
private WebSocketFactory webSocketFactory;
private final HttpClientFactory httpClientFactory;
private final WebSocketFactory webSocketFactory;
private final TimeZoneProvider timeZoneProvider;
public GardenaAccountHandler(Bridge bridge, HttpClientFactory httpClientFactory,
WebSocketFactory webSocketFactory) {
// re- initialisation stuff
private final Object reInitializationCodeLock = new Object();
private @Nullable ScheduledFuture<?> reInitializationTask;
private @Nullable Instant apiCallSuppressionUntil;
public GardenaAccountHandler(Bridge bridge, HttpClientFactory httpClientFactory, WebSocketFactory webSocketFactory,
TimeZoneProvider timeZoneProvider) {
super(bridge);
this.httpClientFactory = httpClientFactory;
this.webSocketFactory = webSocketFactory;
this.timeZoneProvider = timeZoneProvider;
}
/**
* Load the api call suppression until property.
*/
private void loadApiCallSuppressionUntil() {
try {
Map<String, String> properties = getThing().getProperties();
apiCallSuppressionUntil = Instant
.parse(properties.getOrDefault(GardenaBindingConstants.API_CALL_SUPPRESSION_UNTIL, ""));
} catch (DateTimeParseException e) {
apiCallSuppressionUntil = null;
}
}
/**
* Get the duration remaining until the end of the api call suppression window, or Duration.ZERO if we are outside
* the call suppression window.
*
* @return the duration until the end of the suppression window, or zero.
*/
private Duration apiCallSuppressionDelay() {
Instant now = Instant.now();
Instant until = apiCallSuppressionUntil;
return (until != null) && now.isBefore(until) ? Duration.between(now, until) : Duration.ZERO;
}
/**
* Updates the time when api call suppression ends to now() plus the given delay. If delay is zero or negative, the
* suppression time is nulled. Saves the value as a property to ensure consistent behaviour across restarts.
*
* @param delay the delay until the end of the suppression window.
*/
private void apiCallSuppressionUpdate(Duration delay) {
Instant until = (delay.isZero() || delay.isNegative()) ? null : Instant.now().plus(delay);
getThing().setProperty(GardenaBindingConstants.API_CALL_SUPPRESSION_UNTIL,
until == null ? null : until.toString());
apiCallSuppressionUntil = until;
}
@Override
public void initialize() {
logger.debug("Initializing Gardena account '{}'", getThing().getUID().getId());
initializeGardena();
loadApiCallSuppressionUntil();
Duration delay = apiCallSuppressionDelay();
if (delay.isZero()) {
// do immediate initialisation
scheduler.submit(() -> initializeGardena());
} else {
// delay the initialisation
scheduleReinitialize(delay);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, getUiText());
}
}
public void setDiscoveryService(GardenaDeviceDiscoveryService discoveryService) {
@@ -78,53 +147,94 @@ public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaS
}
/**
* Initializes the GardenaSmart account.
* Format a localized explanatory description regarding active call suppression.
*
* @return the localized description text, or null if call suppression is not active.
*/
private void initializeGardena() {
final GardenaAccountHandler instance = this;
scheduler.execute(() -> {
try {
GardenaConfig gardenaConfig = getThing().getConfiguration().as(GardenaConfig.class);
logger.debug("{}", gardenaConfig);
private @Nullable String getUiText() {
Instant until = apiCallSuppressionUntil;
if (until != null) {
ZoneId zone = timeZoneProvider.getTimeZone();
boolean isToday = LocalDate.now(zone).equals(LocalDate.ofInstant(until, zone));
DateTimeFormatter formatter = isToday ? DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM)
: DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
return "@text/accounthandler.waiting-until-to-reconnect [\""
+ formatter.format(ZonedDateTime.ofInstant(until, zone)) + "\"]";
}
return null;
}
String id = getThing().getUID().getId();
gardenaSmart = new GardenaSmartImpl(id, gardenaConfig, instance, scheduler, httpClientFactory,
webSocketFactory);
final GardenaDeviceDiscoveryService discoveryService = this.discoveryService;
if (discoveryService != null) {
discoveryService.startScan(null);
discoveryService.waitForScanFinishing();
}
updateStatus(ThingStatus.ONLINE);
} catch (GardenaException ex) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, ex.getMessage());
disposeGardena();
if (ex.getStatus() == 429) {
// if there was an error 429 (Too Many Requests), wait for 24 hours before trying again
scheduleReinitialize(REINITIALIZE_DELAY_HOURS_LIMIT_EXCEEDED, TimeUnit.HOURS);
} else {
// otherwise reinitialize after 120 seconds
scheduleReinitialize(REINITIALIZE_DELAY_SECONDS, TimeUnit.SECONDS);
}
logger.warn("{}", ex.getMessage());
/**
* Initializes the GardenaSmart account.
* This method is called on a background thread.
*/
private synchronized void initializeGardena() {
try {
GardenaConfig gardenaConfig = getThing().getConfiguration().as(GardenaConfig.class);
logger.debug("{}", gardenaConfig);
String id = getThing().getUID().getId();
gardenaSmart = new GardenaSmartImpl(id, gardenaConfig, this, scheduler, httpClientFactory,
webSocketFactory);
final GardenaDeviceDiscoveryService discoveryService = this.discoveryService;
if (discoveryService != null) {
discoveryService.startScan(null);
discoveryService.waitForScanFinishing();
}
});
apiCallSuppressionUpdate(Duration.ZERO);
updateStatus(ThingStatus.ONLINE);
} catch (GardenaException ex) {
logger.warn("{}", ex.getMessage());
synchronized (reInitializationCodeLock) {
Duration delay;
int status = ex.getStatus();
if (status <= 0) {
delay = REINITIALIZE_DELAY_SECONDS;
} else if (status == HttpStatus.TOO_MANY_REQUESTS_429) {
delay = REINITIALIZE_DELAY_HOURS_LIMIT_EXCEEDED;
} else {
delay = apiCallSuppressionDelay().plus(REINITIALIZE_DELAY_MINUTES_BACK_OFF);
}
scheduleReinitialize(delay);
apiCallSuppressionUpdate(delay);
}
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, getUiText());
disposeGardena();
}
}
/**
* Re-initializes the GardenaSmart account.
* This method is called on a background thread.
*/
private synchronized void reIninitializeGardena() {
if (getThing().getStatus() != ThingStatus.UNINITIALIZED) {
initializeGardena();
}
}
/**
* Schedules a reinitialization, if Gardena smart system account is not reachable.
*/
private void scheduleReinitialize(long delay, TimeUnit unit) {
scheduler.schedule(() -> {
if (getThing().getStatus() != ThingStatus.UNINITIALIZED) {
initializeGardena();
}
}, delay, unit);
private void scheduleReinitialize(Duration delay) {
ScheduledFuture<?> reInitializationTask = this.reInitializationTask;
if (reInitializationTask != null) {
reInitializationTask.cancel(false);
}
this.reInitializationTask = scheduler.schedule(() -> reIninitializeGardena(), delay.getSeconds(),
TimeUnit.SECONDS);
}
@Override
public void dispose() {
super.dispose();
synchronized (reInitializationCodeLock) {
ScheduledFuture<?> reInitializeTask = this.reInitializationTask;
if (reInitializeTask != null) {
reInitializeTask.cancel(true);
}
this.reInitializationTask = null;
}
disposeGardena();
}
@@ -141,6 +251,7 @@ public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaS
if (gardenaSmart != null) {
gardenaSmart.dispose();
}
this.gardenaSmart = null;
}
/**
@@ -157,11 +268,7 @@ public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaS
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (RefreshType.REFRESH == command) {
logger.debug("Refreshing Gardena account '{}'", getThing().getUID().getId());
disposeGardena();
initializeGardena();
}
// nothing to do here because the thing has no channels
}
@Override
@@ -202,8 +309,12 @@ public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaS
@Override
public void onError() {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Connection lost");
Duration delay = REINITIALIZE_DELAY_SECONDS;
synchronized (reInitializationCodeLock) {
scheduleReinitialize(delay);
}
apiCallSuppressionUpdate(delay);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, getUiText());
disposeGardena();
scheduleReinitialize(REINITIALIZE_DELAY_SECONDS, TimeUnit.SECONDS);
}
}

View File

@@ -12,8 +12,7 @@
*/
package org.openhab.binding.gardena.internal.handler;
import static org.openhab.binding.gardena.internal.GardenaBindingConstants.BINDING_ID;
import static org.openhab.binding.gardena.internal.GardenaBindingConstants.THING_TYPE_ACCOUNT;
import static org.openhab.binding.gardena.internal.GardenaBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@@ -58,7 +57,7 @@ public class GardenaHandlerFactory extends BaseThingHandlerFactory {
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
if (THING_TYPE_ACCOUNT.equals(thing.getThingTypeUID())) {
return new GardenaAccountHandler((Bridge) thing, httpClientFactory, webSocketFactory);
return new GardenaAccountHandler((Bridge) thing, httpClientFactory, webSocketFactory, timeZoneProvider);
} else {
return new GardenaThingHandler(thing, timeZoneProvider);
}

View File

@@ -152,8 +152,12 @@ public class Device {
throw new GardenaException("Unknown dataItem with id: " + dataItem.id);
}
if (common != null && common.attributes != null) {
common.attributes.lastUpdate.timestamp = new Date();
if (common != null) {
CommonService attributes = common.attributes;
if (attributes != null) {
attributes.lastUpdate.timestamp = new Date();
}
common.attributes = attributes;
}
}

View File

@@ -28,7 +28,8 @@ public class CreateWebSocketRequest {
data = new CreateWebSocketDataItem();
data.id = "wsreq-" + locationId;
data.type = "WEBSOCKET";
data.attributes = new CreateWebSocket();
data.attributes.locationId = locationId;
CreateWebSocket attributes = new CreateWebSocket();
attributes.locationId = locationId;
data.attributes = attributes;
}
}

View File

@@ -260,3 +260,7 @@ channel-type.gardena.timestampRefresh.label = Timestamp
channel-type.gardena.timestampRefresh.description = Timestamp
channel-type.gardena.valveCommandDuration.label = Command Duration
channel-type.gardena.valveCommandDuration.description = A duration in minutes for a command
# other messages
accounthandler.waiting-until-to-reconnect = Waiting until {0} to make automatic reconnection attempt