]> git.basschouten.com Git - openhab-addons.git/blob
53b7ebce7efa36abdb2ac4278db0044decdab84f
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.gardena.internal;
14
15 import java.util.Collection;
16 import java.util.Date;
17 import java.util.HashMap;
18 import java.util.HashSet;
19 import java.util.Map;
20 import java.util.Set;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.ScheduledExecutorService;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.stream.Collectors;
26
27 import org.apache.commons.lang.ObjectUtils;
28 import org.apache.commons.lang.StringUtils;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.client.HttpResponseException;
31 import org.eclipse.jetty.client.api.ContentResponse;
32 import org.eclipse.jetty.client.api.Request;
33 import org.eclipse.jetty.client.util.StringContentProvider;
34 import org.eclipse.jetty.http.HttpHeader;
35 import org.eclipse.jetty.http.HttpMethod;
36 import org.eclipse.jetty.util.ssl.SslContextFactory;
37 import org.openhab.binding.gardena.internal.config.GardenaConfig;
38 import org.openhab.binding.gardena.internal.config.GardenaConfigWrapper;
39 import org.openhab.binding.gardena.internal.exception.GardenaDeviceNotFoundException;
40 import org.openhab.binding.gardena.internal.exception.GardenaException;
41 import org.openhab.binding.gardena.internal.exception.GardenaUnauthorizedException;
42 import org.openhab.binding.gardena.internal.model.Ability;
43 import org.openhab.binding.gardena.internal.model.Device;
44 import org.openhab.binding.gardena.internal.model.Devices;
45 import org.openhab.binding.gardena.internal.model.Errors;
46 import org.openhab.binding.gardena.internal.model.Location;
47 import org.openhab.binding.gardena.internal.model.Locations;
48 import org.openhab.binding.gardena.internal.model.NoResult;
49 import org.openhab.binding.gardena.internal.model.Property;
50 import org.openhab.binding.gardena.internal.model.PropertyValue;
51 import org.openhab.binding.gardena.internal.model.Session;
52 import org.openhab.binding.gardena.internal.model.SessionWrapper;
53 import org.openhab.binding.gardena.internal.model.Setting;
54 import org.openhab.binding.gardena.internal.model.command.Command;
55 import org.openhab.binding.gardena.internal.model.command.MowerParkUntilFurtherNoticeCommand;
56 import org.openhab.binding.gardena.internal.model.command.MowerParkUntilNextTimerCommand;
57 import org.openhab.binding.gardena.internal.model.command.MowerStartOverrideTimerCommand;
58 import org.openhab.binding.gardena.internal.model.command.MowerStartResumeScheduleCommand;
59 import org.openhab.binding.gardena.internal.model.command.SensorMeasureAmbientTemperatureCommand;
60 import org.openhab.binding.gardena.internal.model.command.SensorMeasureLightCommand;
61 import org.openhab.binding.gardena.internal.model.command.SensorMeasureSoilHumidityCommand;
62 import org.openhab.binding.gardena.internal.model.command.SensorMeasureSoilTemperatureCommand;
63 import org.openhab.binding.gardena.internal.model.command.SettingCommand;
64 import org.openhab.binding.gardena.internal.model.command.SettingCommandWrapper;
65 import org.openhab.binding.gardena.internal.model.command.WateringCancelOverrideCommand;
66 import org.openhab.binding.gardena.internal.model.command.WateringManualOverrideCommand;
67 import org.openhab.binding.gardena.internal.model.deser.DateDeserializer;
68 import org.openhab.binding.gardena.internal.model.deser.PropertyValueDeserializer;
69 import org.openhab.binding.gardena.internal.model.property.BaseProperty;
70 import org.openhab.binding.gardena.internal.model.property.IrrigationControlWateringProperty;
71 import org.openhab.binding.gardena.internal.model.property.PropertyWrapper;
72 import org.openhab.binding.gardena.internal.model.property.StringProperty;
73 import org.slf4j.Logger;
74 import org.slf4j.LoggerFactory;
75
76 import com.google.gson.Gson;
77 import com.google.gson.GsonBuilder;
78
79 /**
80  * {@link GardenaSmart} implementation to access Gardena Smart Home.
81  *
82  * @author Gerhard Riegler - Initial contribution
83  */
84 public class GardenaSmartImpl implements GardenaSmart {
85     private final Logger logger = LoggerFactory.getLogger(GardenaSmartImpl.class);
86
87     public static final String DEVICE_CATEGORY_PUMP = "electronic_pressure_pump";
88     private static final String ABILITY_MOWER = "mower";
89     private static final String ABILITY_OUTLET = "outlet";
90     private static final String ABILITY_HUMIDITY = "humidity";
91     private static final String ABILITY_LIGHT = "light";
92     private static final String ABILITY_AMBIENT_TEMPERATURE = "ambient_temperature";
93     private static final String ABILITY_SOIL_TEMPERATURE = "soil_temperature";
94     private static final String ABILITY_POWER = "power";
95     private static final String ABILITY_WATERING = "watering";
96     private static final String ABILITY_MANUAL_WATERING = "manual_watering";
97
98     private static final String PROPERTY_BUTTON_MANUAL_OVERRIDE_TIME = "button_manual_override_time";
99     private static final String PROPERTY_POWER_TIMER = "power_timer";
100     private static final String PROPERTY_WATERING_TIMER = "watering_timer_";
101     private static final String PROPERTY_MANUAL_WATERING_TIMER = "manual_watering_timer";
102
103     private static final String DEVICE_CATEGORY_MOWER = "mower";
104     private static final String DEVICE_CATEGORY_GATEWAY = "gateway";
105
106     private static final String DEFAULT_MOWER_DURATION = "180";
107
108     private static final String URL = "https://smart.gardena.com";
109     private static final String URL_LOGIN = URL + "/v1/auth/token";
110     private static final String URL_LOCATIONS = URL + "/v1/locations/?user_id=";
111     private static final String URL_DEVICES = URL + "/v1/devices/?locationId=";
112     private static final String URL_COMMAND = URL + "/v1/devices/%s/abilities/%s/command?locationId=%s";
113     private static final String URL_PROPERTY = URL + "/v1/devices/%s/abilities/%s/properties/%s?locationId=%s";
114     private static final String URL_SETTING = URL + "/v1/devices/%s/settings/%s?locationId=%s";
115
116     private Gson gson = new GsonBuilder().registerTypeAdapter(Date.class, new DateDeserializer())
117             .registerTypeAdapter(PropertyValue.class, new PropertyValueDeserializer()).create();
118     private HttpClient httpClient;
119
120     private String mowerDuration = DEFAULT_MOWER_DURATION;
121     private Session session;
122     private GardenaConfig config;
123     private String id;
124
125     private ScheduledExecutorService scheduler;
126     private ScheduledFuture<?> refreshThreadFuture;
127     private RefreshDevicesThread refreshDevicesThread = new RefreshDevicesThread();
128
129     private GardenaSmartEventListener eventListener;
130
131     private Map<String, Device> allDevicesById = new HashMap<>();
132     private Set<Location> allLocations = new HashSet<>();
133
134     @Override
135     public void init(String id, GardenaConfig config, GardenaSmartEventListener eventListener,
136             ScheduledExecutorService scheduler) throws GardenaException {
137         this.id = id;
138         this.config = config;
139         this.eventListener = eventListener;
140         this.scheduler = scheduler;
141
142         if (!config.isValid()) {
143             throw new GardenaException("Invalid config, no email or password specified");
144         }
145
146         httpClient = new HttpClient(new SslContextFactory(true));
147         httpClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
148
149         try {
150             httpClient.start();
151         } catch (Exception ex) {
152             throw new GardenaException(ex.getMessage(), ex);
153         }
154
155         loadAllDevices();
156     }
157
158     @Override
159     public void dispose() {
160         stopRefreshThread(true);
161         if (httpClient != null) {
162             try {
163                 httpClient.stop();
164             } catch (Exception e) {
165                 // ignore
166             }
167             httpClient.destroy();
168         }
169         allLocations.clear();
170         allDevicesById.clear();
171     }
172
173     /**
174      * Schedules the device refresh thread.
175      */
176     private void startRefreshThread() {
177         refreshThreadFuture = scheduler.scheduleWithFixedDelay(refreshDevicesThread, 6, config.getRefresh(),
178                 TimeUnit.SECONDS);
179     }
180
181     /**
182      * Stops the device refresh thread.
183      */
184     private void stopRefreshThread(boolean force) {
185         if (refreshThreadFuture != null) {
186             refreshThreadFuture.cancel(force);
187         }
188     }
189
190     @Override
191     public String getId() {
192         return id;
193     }
194
195     @Override
196     public Set<Location> getLocations() {
197         return allLocations;
198     }
199
200     @Override
201     public Device getDevice(String deviceId) throws GardenaException {
202         Device device = allDevicesById.get(deviceId);
203         if (device == null) {
204             throw new GardenaDeviceNotFoundException(
205                     String.format("Device with id '%s' not found on gateway '%s'", deviceId, id));
206         }
207         return device;
208     }
209
210     @Override
211     public void loadAllDevices() throws GardenaException {
212         stopRefreshThread(false);
213         try {
214             allLocations.clear();
215             allDevicesById.clear();
216
217             verifySession();
218             Locations locations = executeRequest(HttpMethod.GET,
219                     URL_LOCATIONS + session.getSessionAttributes().getUserId(), null, Locations.class);
220
221             for (Location location : locations.getLocations()) {
222                 allLocations.add(location);
223                 Devices devices = loadDevices(location);
224                 for (Device device : devices.getDevices()) {
225                     if (DEVICE_CATEGORY_GATEWAY.equals(device.getCategory())) {
226                         location.getDeviceIds().remove(device.getId());
227                     } else {
228                         allDevicesById.put(device.getId(), device);
229                     }
230                 }
231             }
232         } finally {
233             startRefreshThread();
234         }
235     }
236
237     /**
238      * Loads all devices for the location, adds virtual properties for commands.
239      */
240     private Devices loadDevices(Location location) throws GardenaException {
241         Devices devices = executeRequest(HttpMethod.GET, URL_DEVICES + location.getId(), null, Devices.class);
242         for (Device device : devices.getDevices()) {
243             device.setLocation(location);
244             for (Ability ability : device.getAbilities()) {
245                 ability.setDevice(device);
246                 for (Property property : ability.getProperties()) {
247                     property.setAbility(ability);
248
249                     if (device.getCategory().equals(DEVICE_CATEGORY_PUMP)) {
250                         if (property.getName().equals(PROPERTY_MANUAL_WATERING_TIMER)) {
251                             Integer duration = getIntegerValue(property.getValueAsString());
252                             if (duration == null) {
253                                 duration = 0;
254                             }
255                             property.setValue(new PropertyValue(String.valueOf(duration / 60)));
256                         }
257                     }
258                 }
259             }
260             for (Setting setting : device.getSettings()) {
261                 setting.setDevice(device);
262             }
263
264             if (DEVICE_CATEGORY_MOWER.equals(device.getCategory())) {
265                 Ability mower = device.getAbility(ABILITY_MOWER);
266                 mower.addProperty(new Property(GardenaSmartCommandName.PARK_UNTIL_NEXT_TIMER, "false"));
267                 mower.addProperty(new Property(GardenaSmartCommandName.PARK_UNTIL_FURTHER_NOTICE, "false"));
268                 mower.addProperty(new Property(GardenaSmartCommandName.START_RESUME_SCHEDULE, "false"));
269                 mower.addProperty(new Property(GardenaSmartCommandName.START_OVERRIDE_TIMER, "false"));
270
271                 mower.addProperty(new Property(GardenaSmartCommandName.DURATION_PROPERTY, mowerDuration));
272             }
273         }
274         return devices;
275     }
276
277     @Override
278     public void sendCommand(Device device, GardenaSmartCommandName commandName, Object value) throws GardenaException {
279         Ability ability = null;
280         Command command = null;
281
282         switch (commandName) {
283             case PARK_UNTIL_NEXT_TIMER:
284                 ability = device.getAbility(ABILITY_MOWER);
285                 command = new MowerParkUntilNextTimerCommand();
286                 break;
287             case PARK_UNTIL_FURTHER_NOTICE:
288                 ability = device.getAbility(ABILITY_MOWER);
289                 command = new MowerParkUntilFurtherNoticeCommand();
290                 break;
291             case START_RESUME_SCHEDULE:
292                 ability = device.getAbility(ABILITY_MOWER);
293                 command = new MowerStartResumeScheduleCommand();
294                 break;
295             case START_OVERRIDE_TIMER:
296                 ability = device.getAbility(ABILITY_MOWER);
297                 command = new MowerStartOverrideTimerCommand(mowerDuration);
298                 break;
299             case DURATION_PROPERTY:
300                 if (value == null) {
301                     throw new GardenaException("Command '" + commandName + "' requires a value");
302                 }
303                 mowerDuration = ObjectUtils.toString(value);
304                 return;
305             case MEASURE_AMBIENT_TEMPERATURE:
306                 ability = device.getAbility(ABILITY_AMBIENT_TEMPERATURE);
307                 command = new SensorMeasureAmbientTemperatureCommand();
308                 break;
309             case MEASURE_LIGHT:
310                 ability = device.getAbility(ABILITY_LIGHT);
311                 command = new SensorMeasureLightCommand();
312                 break;
313             case MEASURE_SOIL_HUMIDITY:
314                 ability = device.getAbility(ABILITY_HUMIDITY);
315                 command = new SensorMeasureSoilHumidityCommand();
316                 break;
317             case MEASURE_SOIL_TEMPERATURE:
318                 ability = device.getAbility(ABILITY_SOIL_TEMPERATURE);
319                 command = new SensorMeasureSoilTemperatureCommand();
320                 break;
321             case OUTLET_MANUAL_OVERRIDE_TIME:
322                 if (value == null) {
323                     throw new GardenaException("Command '" + commandName + "' requires a value");
324                 }
325                 StringProperty prop = new StringProperty(PROPERTY_BUTTON_MANUAL_OVERRIDE_TIME,
326                         ObjectUtils.toString(value));
327
328                 executeSetProperty(device, ABILITY_OUTLET, PROPERTY_BUTTON_MANUAL_OVERRIDE_TIME, prop);
329                 break;
330             case OUTLET_VALVE:
331                 ability = device.getAbility(ABILITY_OUTLET);
332                 if (value != null && value == Boolean.TRUE) {
333                     String wateringDuration = device.getAbility(ABILITY_OUTLET)
334                             .getProperty(PROPERTY_BUTTON_MANUAL_OVERRIDE_TIME).getValueAsString();
335                     command = new WateringManualOverrideCommand(wateringDuration);
336                 } else {
337                     command = new WateringCancelOverrideCommand();
338                 }
339                 break;
340             case POWER_TIMER:
341                 if (value == null) {
342                     throw new GardenaException("Command '" + commandName + "' requires a value");
343                 }
344                 prop = new StringProperty(PROPERTY_POWER_TIMER, ObjectUtils.toString(value));
345                 executeSetProperty(device, ABILITY_POWER, PROPERTY_POWER_TIMER, prop);
346                 break;
347             case WATERING_TIMER_VALVE_1:
348             case WATERING_TIMER_VALVE_2:
349             case WATERING_TIMER_VALVE_3:
350             case WATERING_TIMER_VALVE_4:
351             case WATERING_TIMER_VALVE_5:
352             case WATERING_TIMER_VALVE_6:
353                 if (value == null) {
354                     throw new GardenaException("Command '" + commandName + "' requires a value");
355                 } else if (!(value instanceof Integer)) {
356                     throw new GardenaException("Watering duration value '" + value + "' not a number");
357                 }
358                 int valveId = Integer.parseInt(StringUtils.right(commandName.toString(), 1));
359                 String wateringTimerProperty = PROPERTY_WATERING_TIMER + valveId;
360                 IrrigationControlWateringProperty irrigationProp = new IrrigationControlWateringProperty(
361                         wateringTimerProperty, (Integer) value, valveId);
362                 executeSetProperty(device, ABILITY_WATERING, wateringTimerProperty, irrigationProp);
363                 break;
364             case PUMP_MANUAL_WATERING_TIMER:
365                 Integer duration = getIntegerValue(value);
366                 if (duration == null) {
367                     throw new GardenaException("Command '" + commandName + "' requires a number value");
368                 }
369                 prop = new StringProperty(PROPERTY_MANUAL_WATERING_TIMER, String.valueOf(duration * 60));
370
371                 executeSetProperty(device, ABILITY_MANUAL_WATERING, PROPERTY_MANUAL_WATERING_TIMER, prop);
372                 break;
373             default:
374                 throw new GardenaException("Unknown command " + commandName);
375         }
376
377         if (command != null) {
378             stopRefreshThread(false);
379             executeRequest(HttpMethod.POST, getCommandUrl(device, ability), command, NoResult.class);
380             startRefreshThread();
381         }
382     }
383
384     private Integer getIntegerValue(Object value) {
385         try {
386             return Integer.valueOf(ObjectUtils.toString(value));
387         } catch (NumberFormatException ex) {
388             return null;
389         }
390     }
391
392     /**
393      * Sends the new property value for the ability.
394      */
395     private void executeSetProperty(Device device, String ability, String property, BaseProperty value)
396             throws GardenaException {
397         String propertyUrl = String.format(URL_PROPERTY, device.getId(), ability, property,
398                 device.getLocation().getId());
399         stopRefreshThread(false);
400         executeRequest(HttpMethod.PUT, propertyUrl, new PropertyWrapper(value), NoResult.class);
401         device.getAbility(ability).getProperty(property).setValue(new PropertyValue(value.getValue()));
402         startRefreshThread();
403     }
404
405     @Override
406     public void sendSetting(Setting setting, Object value) throws GardenaException {
407         SettingCommand settingCommand = new SettingCommand(setting.getName());
408         settingCommand.setDeviceId(setting.getDevice().getId());
409         settingCommand.setValue(value);
410
411         stopRefreshThread(false);
412         executeRequest(HttpMethod.PUT, getSettingUrl(setting), new SettingCommandWrapper(settingCommand),
413                 NoResult.class);
414         startRefreshThread();
415     }
416
417     /**
418      * Returns the command url.
419      */
420     private String getCommandUrl(Device device, Ability ability) throws GardenaException {
421         return String.format(URL_COMMAND, device.getId(), ability.getName(), device.getLocation().getId());
422     }
423
424     /**
425      * Returns the settings url.
426      */
427     private String getSettingUrl(Setting setting) {
428         Device device = setting.getDevice();
429         return String.format(URL_SETTING, device.getId(), setting.getId(), device.getLocation().getId());
430     }
431
432     /**
433      * Communicates with Gardena Smart Home and parses the result.
434      */
435     private synchronized <T> T executeRequest(HttpMethod method, String url, Object contentObject, Class<T> result)
436             throws GardenaException {
437         try {
438             if (logger.isTraceEnabled()) {
439                 logger.trace("{} request:  {}", method, url);
440                 if (contentObject != null) {
441                     logger.trace("{} data   :  {}", method, gson.toJson(contentObject));
442                 }
443             }
444
445             Request request = httpClient.newRequest(url).method(method)
446                     .timeout(config.getConnectionTimeout(), TimeUnit.SECONDS)
447                     .idleTimeout(config.getConnectionTimeout(), TimeUnit.SECONDS)
448                     .header(HttpHeader.CONTENT_TYPE, "application/json").header(HttpHeader.ACCEPT, "application/json")
449                     .header(HttpHeader.ACCEPT_ENCODING, "gzip");
450
451             if (contentObject != null) {
452                 StringContentProvider content = new StringContentProvider(gson.toJson(contentObject));
453                 request.content(content);
454             }
455
456             if (!result.equals(SessionWrapper.class)) {
457                 verifySession();
458                 request.header("authorization", "Bearer " + session.getToken());
459                 request.header("authorization-provider", session.getSessionAttributes().getProvider());
460             }
461
462             ContentResponse contentResponse = request.send();
463             int status = contentResponse.getStatus();
464             if (logger.isTraceEnabled()) {
465                 logger.trace("Status  : {}", status);
466                 logger.trace("Response: {}", contentResponse.getContentAsString());
467             }
468
469             if (status == 500) {
470                 throw new GardenaException(
471                         gson.fromJson(contentResponse.getContentAsString(), Errors.class).toString());
472             } else if (status != 200 && status != 204 && status != 201) {
473                 throw new GardenaException(String.format("Error %s %s", status, contentResponse.getReason()));
474             }
475
476             if (result == NoResult.class) {
477                 return null;
478             }
479
480             return gson.fromJson(contentResponse.getContentAsString(), result);
481         } catch (ExecutionException ex) {
482             Throwable cause = ex.getCause();
483             if (cause instanceof HttpResponseException) {
484                 HttpResponseException responseException = (HttpResponseException) ex.getCause();
485                 int status = responseException.getResponse().getStatus();
486                 if (status == 401) {
487                     throw new GardenaUnauthorizedException(ex.getCause());
488                 }
489             }
490             throw new GardenaException(ex.getMessage(), ex);
491         } catch (Exception ex) {
492             throw new GardenaException(ex.getMessage(), ex);
493         }
494     }
495
496     /**
497      * Verifies the Gardena Smart Home session and reconnects if necessary.
498      */
499     private void verifySession() throws GardenaException {
500         if (session == null
501                 || session.getCreated() + (config.getSessionTimeout() * 60000) <= System.currentTimeMillis()) {
502             logger.trace("(Re)logging in to Gardena Smart Home");
503             session = executeRequest(HttpMethod.POST, URL_LOGIN, new GardenaConfigWrapper(config), SessionWrapper.class)
504                     .getSession();
505         }
506     }
507
508     /**
509      * Thread which refreshes the data from Gardena Smart Home.
510      */
511     private class RefreshDevicesThread implements Runnable {
512         private boolean connectionLost = false;
513
514         @Override
515         public void run() {
516             try {
517                 logger.debug("Refreshing gardena device data");
518                 final Map<String, Device> newDevicesById = new HashMap<>();
519
520                 for (Location location : allLocations) {
521                     Devices devices = loadDevices(location);
522                     for (Device device : devices.getDevices()) {
523                         if (DEVICE_CATEGORY_GATEWAY.equals(device.getCategory())) {
524                             location.getDeviceIds().remove(device.getId());
525                         } else {
526                             newDevicesById.put(device.getId(), device);
527                         }
528                     }
529                 }
530
531                 if (connectionLost) {
532                     connectionLost = false;
533                     logger.info("Connection resumed to Gardena Smart Home with id '{}'", id);
534                     eventListener.onConnectionResumed();
535                 }
536
537                 // determine deleted devices
538                 Collection<Device> deletedDevices = allDevicesById.values().stream()
539                         .filter(d -> !newDevicesById.values().contains(d)).collect(Collectors.toSet());
540
541                 // determine new devices
542                 Collection<Device> newDevices = newDevicesById.values().stream()
543                         .filter(d -> !allDevicesById.values().contains(d)).collect(Collectors.toSet());
544
545                 // determine updated devices
546                 Collection<Device> updatedDevices = allDevicesById.values().stream().distinct()
547                         .filter(newDevicesById.values()::contains).collect(Collectors.toSet());
548
549                 allDevicesById = newDevicesById;
550
551                 for (Device deletedDevice : deletedDevices) {
552                     eventListener.onDeviceDeleted(deletedDevice);
553                 }
554
555                 for (Device newDevice : newDevices) {
556                     eventListener.onNewDevice(newDevice);
557                 }
558
559                 for (Device updatedDevice : updatedDevices) {
560                     eventListener.onDeviceUpdated(updatedDevice);
561                 }
562
563             } catch (GardenaException ex) {
564                 if (!connectionLost) {
565                     connectionLost = true;
566                     logger.warn("Connection lost to Gardena Smart Home with id '{}'", id);
567                     logger.trace("{}", ex.getMessage(), ex);
568                     eventListener.onConnectionLost();
569                 }
570             }
571         }
572     }
573 }