*/
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;
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)
// 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);
+ }
}
}
}
* 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));
}
}
@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 {
}
httpClient.destroy();
webSocketClient.destroy();
- locationsResponse = new LocationsResponse();
allDevicesById.clear();
- initialized = false;
+ locationsResponse = null;
}
/**
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));
}
}
handleDataItem(dataItem);
Device device = allDevicesById.get(dataItem.getDeviceId());
if (device != null && device.active) {
- 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);
+ synchronized (deviceUpdateTaskLock) {
+ devicesToNotify.add(device);
+
+ // 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);
+ }
}
}
}
}
}
+ /**
+ * 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);
*/
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;
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;
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;
@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;
- private @Nullable GardenaDeviceDiscoveryService discoveryService;
+ // 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;
+
+ // 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) {
+ 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) {
this.discoveryService = discoveryService;
}
+ /**
+ * Format a localized explanatory description regarding active call suppression.
+ *
+ * @return the localized description text, or null if call suppression is not active.
+ */
+ 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;
+ }
+
/**
* Initializes the GardenaSmart account.
+ * This method is called on a background thread.
*/
- private void initializeGardena() {
- final GardenaAccountHandler instance = this;
- scheduler.execute(() -> {
- try {
- GardenaConfig gardenaConfig = getThing().getConfiguration().as(GardenaConfig.class);
- logger.debug("{}", gardenaConfig);
-
- 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);
+ 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 {
- // otherwise reinitialize after 120 seconds
- scheduleReinitialize(REINITIALIZE_DELAY_SECONDS, TimeUnit.SECONDS);
+ delay = apiCallSuppressionDelay().plus(REINITIALIZE_DELAY_MINUTES_BACK_OFF);
}
- logger.warn("{}", ex.getMessage());
+ 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();
}
if (gardenaSmart != null) {
gardenaSmart.dispose();
}
+ this.gardenaSmart = null;
}
/**
@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
@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);
}
}