2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.myq.internal.handler;
15 import static org.openhab.binding.myq.internal.MyQBindingConstants.*;
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;
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;
55 import com.google.gson.FieldNamingPolicy;
56 import com.google.gson.Gson;
57 import com.google.gson.GsonBuilder;
58 import com.google.gson.JsonSyntaxException;
61 * The {@link MyQAccountHandler} is responsible for communicating with the MyQ API based on an account.
63 * @author Dan Cunningham - Initial contribution
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)
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 = "";
85 public MyQAccountHandler(Bridge bridge, HttpClient httpClient) {
87 this.httpClient = httpClient;
91 public void handleCommand(ChannelUID channelUID, Command command) {
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);
108 public void dispose() {
113 public Collection<Class<? extends ThingHandlerService>> getServices() {
114 return Collections.singleton(MyQDiscoveryService.class);
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);
129 * Sends an action to the MyQ API
131 * @param serialNumber
134 public void sendAction(String serialNumber, String action) {
135 AccountDTO localAccount = account;
136 if (localAccount != null) {
138 HttpResult result = sendRequest(
139 String.format("%s/v5.1/Accounts/%s/Devices/%s/actions", BASE_URL, localAccount.account.id,
141 HttpMethod.PUT, securityToken,
142 new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))), "application/json");
143 if (HttpStatus.isSuccess(result.responseCode)) {
146 logger.debug("Failed to send action {} : {}", action, result.content);
148 } catch (InterruptedException e) {
154 * Last known state of MyQ Devices
156 * @return cached MyQ devices
158 public @Nullable DevicesDTO devicesCache() {
162 private void stopPolls() {
167 private synchronized void stopNormalPoll() {
168 stopFuture(normalPollFuture);
169 normalPollFuture = null;
172 private synchronized void stopRapidPoll() {
173 stopFuture(rapidPollFuture);
174 rapidPollFuture = null;
177 private void stopFuture(@Nullable Future<?> future) {
178 if (future != null) {
183 private synchronized void restartPolls(boolean rapid) {
186 normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 35, normalRefreshSeconds,
188 rapidPollFuture = scheduler.scheduleWithFixedDelay(this::rapidPoll, 3, RAPID_REFRESH_SECONDS,
191 normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 0, normalRefreshSeconds,
196 private void normalPoll() {
201 private void rapidPoll() {
205 private synchronized void fetchData() {
207 if (securityToken == null) {
209 if (securityToken != null) {
213 if (securityToken != null) {
216 } catch (InterruptedException e) {
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))),
224 LoginResponseDTO loginResponse = parseResultAndUpdateStatus(result, gsonUpperCase, LoginResponseDTO.class);
225 if (loginResponse != null) {
226 securityToken = loginResponse.securityToken;
228 securityToken = null;
229 if (thing.getStatusInfo().getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR) {
230 // bad credentials, stop trying to login
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);
241 private void getDevices() throws InterruptedException {
242 AccountDTO localAccount = account;
243 if (localAccount == null) {
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);
266 private synchronized HttpResult sendRequest(String url, HttpMethod method, @Nullable String token,
267 @Nullable ContentProvider content, @Nullable String contentType) throws InterruptedException {
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);
274 request = request.header("SecurityToken", token);
276 if (content != null & contentType != null) {
277 request = request.content(content, contentType);
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({})
287 public void onComplete(Result result) {
288 futureResult.complete(new HttpResult(result.getResponse().getStatus(), getContentAsString()));
291 HttpResult result = futureResult.get();
292 logger.trace("Account Response - status: {} content: {}", result.responseCode, result.content);
294 } catch (ExecutionException e) {
295 return new HttpResult(0, e.getMessage());
300 private <T> T parseResultAndUpdateStatus(HttpResult result, Gson parser, Class<T> classOfT) {
301 if (HttpStatus.isSuccess(result.responseCode)) {
303 T responseObject = parser.fromJson(result.content, classOfT);
304 if (responseObject != null) {
305 if (getThing().getStatus() != ThingStatus.ONLINE) {
306 updateStatus(ThingStatus.ONLINE);
308 return responseObject;
310 } catch (JsonSyntaxException e) {
311 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
312 "Invalid JSON Response " + result.content);
314 } else if (result.responseCode == HttpStatus.UNAUTHORIZED_401) {
315 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
316 "Unauthorized - Check Credentials");
318 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
319 "Invalid Response Code " + result.responseCode + " : " + result.content);
324 private class HttpResult {
325 public final int responseCode;
326 public @Nullable String content;
328 public HttpResult(int responseCode, @Nullable String content) {
329 this.responseCode = responseCode;
330 this.content = content;
334 private static String randomString(int length) {
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))));
342 return sb.toString();