]> git.basschouten.com Git - openhab-addons.git/blob
f9dc5f11bcb0f1394fd179c98698578efa41ddd5
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.myq.internal.handler;
14
15 import static org.openhab.binding.myq.internal.MyQBindingConstants.*;
16
17 import java.util.Collection;
18 import java.util.Collections;
19 import java.util.Random;
20 import java.util.concurrent.CompletableFuture;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.Future;
23 import java.util.concurrent.TimeUnit;
24
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.eclipse.jetty.client.api.ContentProvider;
29 import org.eclipse.jetty.client.api.Request;
30 import org.eclipse.jetty.client.api.Result;
31 import org.eclipse.jetty.client.util.BufferingResponseListener;
32 import org.eclipse.jetty.client.util.StringContentProvider;
33 import org.eclipse.jetty.http.HttpMethod;
34 import org.eclipse.jetty.http.HttpStatus;
35 import org.openhab.binding.myq.internal.MyQDiscoveryService;
36 import org.openhab.binding.myq.internal.config.MyQAccountConfiguration;
37 import org.openhab.binding.myq.internal.dto.AccountDTO;
38 import org.openhab.binding.myq.internal.dto.ActionDTO;
39 import org.openhab.binding.myq.internal.dto.DevicesDTO;
40 import org.openhab.binding.myq.internal.dto.LoginRequestDTO;
41 import org.openhab.binding.myq.internal.dto.LoginResponseDTO;
42 import org.openhab.core.thing.Bridge;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.ThingTypeUID;
48 import org.openhab.core.thing.binding.BaseBridgeHandler;
49 import org.openhab.core.thing.binding.ThingHandler;
50 import org.openhab.core.thing.binding.ThingHandlerService;
51 import org.openhab.core.types.Command;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
54
55 import com.google.gson.FieldNamingPolicy;
56 import com.google.gson.Gson;
57 import com.google.gson.GsonBuilder;
58 import com.google.gson.JsonSyntaxException;
59
60 /**
61  * The {@link MyQAccountHandler} is responsible for communicating with the MyQ API based on an account.
62  *
63  * @author Dan Cunningham - Initial contribution
64  */
65 @NonNullByDefault
66 public class MyQAccountHandler extends BaseBridgeHandler {
67     private static final String BASE_URL = "https://api.myqdevice.com/api";
68     private static final Integer RAPID_REFRESH_SECONDS = 5;
69     private final Logger logger = LoggerFactory.getLogger(MyQAccountHandler.class);
70     private final Gson gsonUpperCase = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)
71             .create();
72     private final Gson gsonLowerCase = new GsonBuilder()
73             .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
74     private @Nullable Future<?> normalPollFuture;
75     private @Nullable Future<?> rapidPollFuture;
76     private @Nullable String securityToken;
77     private @Nullable AccountDTO account;
78     private @Nullable DevicesDTO devicesCache;
79     private Integer normalRefreshSeconds = 60;
80     private HttpClient httpClient;
81     private String username = "";
82     private String password = "";
83     private String userAgent = "";
84
85     public MyQAccountHandler(Bridge bridge, HttpClient httpClient) {
86         super(bridge);
87         this.httpClient = httpClient;
88     }
89
90     @Override
91     public void handleCommand(ChannelUID channelUID, Command command) {
92     }
93
94     @Override
95     public void initialize() {
96         MyQAccountConfiguration config = getConfigAs(MyQAccountConfiguration.class);
97         normalRefreshSeconds = config.refreshInterval;
98         username = config.username;
99         password = config.password;
100         // MyQ can get picky about blocking user agents apparently
101         userAgent = MyQAccountHandler.randomString(40);
102         securityToken = null;
103         updateStatus(ThingStatus.UNKNOWN);
104         restartPolls(false);
105     }
106
107     @Override
108     public void dispose() {
109         stopPolls();
110     }
111
112     @Override
113     public Collection<Class<? extends ThingHandlerService>> getServices() {
114         return Collections.singleton(MyQDiscoveryService.class);
115     }
116
117     @Override
118     public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
119         DevicesDTO localDeviceCaches = devicesCache;
120         if (localDeviceCaches != null && childHandler instanceof MyQDeviceHandler) {
121             MyQDeviceHandler handler = (MyQDeviceHandler) childHandler;
122             localDeviceCaches.items.stream()
123                     .filter(d -> ((MyQDeviceHandler) childHandler).getSerialNumber().equalsIgnoreCase(d.serialNumber))
124                     .findFirst().ifPresent(handler::handleDeviceUpdate);
125         }
126     }
127
128     /**
129      * Sends an action to the MyQ API
130      *
131      * @param serialNumber
132      * @param action
133      */
134     public void sendAction(String serialNumber, String action) {
135         AccountDTO localAccount = account;
136         if (localAccount != null) {
137             try {
138                 HttpResult result = sendRequest(
139                         String.format("%s/v5.1/Accounts/%s/Devices/%s/actions", BASE_URL, localAccount.account.id,
140                                 serialNumber),
141                         HttpMethod.PUT, securityToken,
142                         new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))), "application/json");
143                 if (HttpStatus.isSuccess(result.responseCode)) {
144                     restartPolls(true);
145                 } else {
146                     logger.debug("Failed to send action {} : {}", action, result.content);
147                 }
148             } catch (InterruptedException e) {
149             }
150         }
151     }
152
153     /**
154      * Last known state of MyQ Devices
155      *
156      * @return cached MyQ devices
157      */
158     public @Nullable DevicesDTO devicesCache() {
159         return devicesCache;
160     }
161
162     private void stopPolls() {
163         stopNormalPoll();
164         stopRapidPoll();
165     }
166
167     private synchronized void stopNormalPoll() {
168         stopFuture(normalPollFuture);
169         normalPollFuture = null;
170     }
171
172     private synchronized void stopRapidPoll() {
173         stopFuture(rapidPollFuture);
174         rapidPollFuture = null;
175     }
176
177     private void stopFuture(@Nullable Future<?> future) {
178         if (future != null) {
179             future.cancel(true);
180         }
181     }
182
183     private synchronized void restartPolls(boolean rapid) {
184         stopPolls();
185         if (rapid) {
186             normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 35, normalRefreshSeconds,
187                     TimeUnit.SECONDS);
188             rapidPollFuture = scheduler.scheduleWithFixedDelay(this::rapidPoll, 3, RAPID_REFRESH_SECONDS,
189                     TimeUnit.SECONDS);
190         } else {
191             normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 0, normalRefreshSeconds,
192                     TimeUnit.SECONDS);
193         }
194     }
195
196     private void normalPoll() {
197         stopRapidPoll();
198         fetchData();
199     }
200
201     private void rapidPoll() {
202         fetchData();
203     }
204
205     private synchronized void fetchData() {
206         try {
207             if (securityToken == null) {
208                 login();
209                 if (securityToken != null) {
210                     getAccount();
211                 }
212             }
213             if (securityToken != null) {
214                 getDevices();
215             }
216         } catch (InterruptedException e) {
217         }
218     }
219
220     private void login() throws InterruptedException {
221         HttpResult result = sendRequest(BASE_URL + "/v5/Login", HttpMethod.POST, null,
222                 new StringContentProvider(gsonUpperCase.toJson(new LoginRequestDTO(username, password))),
223                 "application/json");
224         LoginResponseDTO loginResponse = parseResultAndUpdateStatus(result, gsonUpperCase, LoginResponseDTO.class);
225         if (loginResponse != null) {
226             securityToken = loginResponse.securityToken;
227         } else {
228             securityToken = null;
229             if (thing.getStatusInfo().getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR) {
230                 // bad credentials, stop trying to login
231                 stopPolls();
232             }
233         }
234     }
235
236     private void getAccount() throws InterruptedException {
237         HttpResult result = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, securityToken, null, null);
238         account = parseResultAndUpdateStatus(result, gsonUpperCase, AccountDTO.class);
239     }
240
241     private void getDevices() throws InterruptedException {
242         AccountDTO localAccount = account;
243         if (localAccount == null) {
244             return;
245         }
246         HttpResult result = sendRequest(String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id),
247                 HttpMethod.GET, securityToken, null, null);
248         DevicesDTO devices = parseResultAndUpdateStatus(result, gsonLowerCase, DevicesDTO.class);
249         if (devices != null) {
250             devicesCache = devices;
251             devices.items.forEach(device -> {
252                 ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
253                 if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
254                     for (Thing thing : getThing().getThings()) {
255                         ThingHandler handler = thing.getHandler();
256                         if (handler != null && ((MyQDeviceHandler) handler).getSerialNumber()
257                                 .equalsIgnoreCase(device.serialNumber)) {
258                             ((MyQDeviceHandler) handler).handleDeviceUpdate(device);
259                         }
260                     }
261                 }
262             });
263         }
264     }
265
266     private synchronized HttpResult sendRequest(String url, HttpMethod method, @Nullable String token,
267             @Nullable ContentProvider content, @Nullable String contentType) throws InterruptedException {
268         try {
269             Request request = httpClient.newRequest(url).method(method)
270                     .header("MyQApplicationId", "JVM/G9Nwih5BwKgNCjLxiFUQxQijAebyyg8QUHr7JOrP+tuPb8iHfRHKwTmDzHOu")
271                     .header("ApiVersion", "5.1").header("BrandId", "2").header("Culture", "en").agent(userAgent)
272                     .timeout(10, TimeUnit.SECONDS);
273             if (token != null) {
274                 request = request.header("SecurityToken", token);
275             }
276             if (content != null & contentType != null) {
277                 request = request.content(content, contentType);
278             }
279             // use asyc jetty as the API service will response with a 401 error when credentials are wrong,
280             // but not a WWW-Authenticate header which causes Jetty to throw a generic execution exception which
281             // prevents us from knowing the response code
282             logger.trace("Sending {} to {}", request.getMethod(), request.getURI());
283             final CompletableFuture<HttpResult> futureResult = new CompletableFuture<>();
284             request.send(new BufferingResponseListener() {
285                 @NonNullByDefault({})
286                 @Override
287                 public void onComplete(Result result) {
288                     futureResult.complete(new HttpResult(result.getResponse().getStatus(), getContentAsString()));
289                 }
290             });
291             HttpResult result = futureResult.get();
292             logger.trace("Account Response - status: {} content: {}", result.responseCode, result.content);
293             return result;
294         } catch (ExecutionException e) {
295             return new HttpResult(0, e.getMessage());
296         }
297     }
298
299     @Nullable
300     private <T> T parseResultAndUpdateStatus(HttpResult result, Gson parser, Class<T> classOfT) {
301         if (HttpStatus.isSuccess(result.responseCode)) {
302             try {
303                 T responseObject = parser.fromJson(result.content, classOfT);
304                 if (responseObject != null) {
305                     if (getThing().getStatus() != ThingStatus.ONLINE) {
306                         updateStatus(ThingStatus.ONLINE);
307                     }
308                     return responseObject;
309                 }
310             } catch (JsonSyntaxException e) {
311                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
312                         "Invalid JSON Response " + result.content);
313             }
314         } else if (result.responseCode == HttpStatus.UNAUTHORIZED_401) {
315             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
316                     "Unauthorized - Check Credentials");
317         } else {
318             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
319                     "Invalid Response Code " + result.responseCode + " : " + result.content);
320         }
321         return null;
322     }
323
324     private class HttpResult {
325         public final int responseCode;
326         public @Nullable String content;
327
328         public HttpResult(int responseCode, @Nullable String content) {
329             this.responseCode = responseCode;
330             this.content = content;
331         }
332     }
333
334     private static String randomString(int length) {
335         int low = 97; // a-z
336         int high = 122; // A-Z
337         StringBuilder sb = new StringBuilder(length);
338         Random random = new Random();
339         for (int i = 0; i < length; i++) {
340             sb.append((char) (low + (int) (random.nextFloat() * (high - low + 1))));
341         }
342         return sb.toString();
343     }
344 }