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.io.IOException;
18 import java.io.UnsupportedEncodingException;
19 import java.net.HttpCookie;
21 import java.net.URISyntaxException;
22 import java.security.MessageDigest;
23 import java.security.NoSuchAlgorithmException;
24 import java.security.SecureRandom;
25 import java.util.Arrays;
26 import java.util.Base64;
27 import java.util.Collection;
28 import java.util.Collections;
30 import java.util.Random;
31 import java.util.concurrent.CompletableFuture;
32 import java.util.concurrent.ExecutionException;
33 import java.util.concurrent.Future;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.TimeoutException;
36 import java.util.stream.Collectors;
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.eclipse.jetty.client.HttpClient;
41 import org.eclipse.jetty.client.HttpContentResponse;
42 import org.eclipse.jetty.client.api.ContentProvider;
43 import org.eclipse.jetty.client.api.ContentResponse;
44 import org.eclipse.jetty.client.api.Request;
45 import org.eclipse.jetty.client.api.Response;
46 import org.eclipse.jetty.client.api.Result;
47 import org.eclipse.jetty.client.util.BufferingResponseListener;
48 import org.eclipse.jetty.client.util.FormContentProvider;
49 import org.eclipse.jetty.client.util.StringContentProvider;
50 import org.eclipse.jetty.http.HttpMethod;
51 import org.eclipse.jetty.http.HttpStatus;
52 import org.eclipse.jetty.util.Fields;
53 import org.jsoup.Jsoup;
54 import org.jsoup.nodes.Document;
55 import org.jsoup.nodes.Element;
56 import org.openhab.binding.myq.internal.MyQDiscoveryService;
57 import org.openhab.binding.myq.internal.config.MyQAccountConfiguration;
58 import org.openhab.binding.myq.internal.dto.AccountDTO;
59 import org.openhab.binding.myq.internal.dto.ActionDTO;
60 import org.openhab.binding.myq.internal.dto.DevicesDTO;
61 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
62 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
63 import org.openhab.core.auth.client.oauth2.OAuthClientService;
64 import org.openhab.core.auth.client.oauth2.OAuthException;
65 import org.openhab.core.auth.client.oauth2.OAuthFactory;
66 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
67 import org.openhab.core.thing.Bridge;
68 import org.openhab.core.thing.ChannelUID;
69 import org.openhab.core.thing.Thing;
70 import org.openhab.core.thing.ThingStatus;
71 import org.openhab.core.thing.ThingStatusDetail;
72 import org.openhab.core.thing.ThingTypeUID;
73 import org.openhab.core.thing.binding.BaseBridgeHandler;
74 import org.openhab.core.thing.binding.ThingHandler;
75 import org.openhab.core.thing.binding.ThingHandlerService;
76 import org.openhab.core.types.Command;
77 import org.slf4j.Logger;
78 import org.slf4j.LoggerFactory;
80 import com.google.gson.FieldNamingPolicy;
81 import com.google.gson.Gson;
82 import com.google.gson.GsonBuilder;
83 import com.google.gson.JsonSyntaxException;
86 * The {@link MyQAccountHandler} is responsible for communicating with the MyQ API based on an account.
88 * @author Dan Cunningham - Initial contribution
91 public class MyQAccountHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
93 * MyQ oAuth relate fields
95 private static final String CLIENT_SECRET = "VUQ0RFhuS3lQV3EyNUJTdw==";
96 private static final String CLIENT_ID = "IOS_CGI_MYQ";
97 private static final String REDIRECT_URI = "com.myqops://ios";
98 private static final String SCOPE = "MyQ_Residential offline_access";
100 * MyQ authentication API endpoints
102 private static final String LOGIN_BASE_URL = "https://partner-identity.myq-cloud.com";
103 private static final String LOGIN_AUTHORIZE_URL = LOGIN_BASE_URL + "/connect/authorize";
104 private static final String LOGIN_TOKEN_URL = LOGIN_BASE_URL + "/connect/token";
105 // this should never happen, but lets be safe and give up after so many redirects
106 private static final int LOGIN_MAX_REDIRECTS = 30;
108 * MyQ device and account API endpoint
110 private static final String BASE_URL = "https://api.myqdevice.com/api";
111 private static final Integer RAPID_REFRESH_SECONDS = 5;
112 private final Logger logger = LoggerFactory.getLogger(MyQAccountHandler.class);
113 private final Gson gsonUpperCase = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)
115 private final Gson gsonLowerCase = new GsonBuilder()
116 .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
117 private final OAuthFactory oAuthFactory;
118 private @Nullable Future<?> normalPollFuture;
119 private @Nullable Future<?> rapidPollFuture;
120 private @Nullable AccountDTO account;
121 private @Nullable DevicesDTO devicesCache;
122 private @Nullable OAuthClientService oAuthService;
123 private Integer normalRefreshSeconds = 60;
124 private HttpClient httpClient;
125 private String username = "";
126 private String password = "";
127 private String userAgent = "";
128 // force login, even if we have a token
129 private boolean needsLogin = false;
131 public MyQAccountHandler(Bridge bridge, HttpClient httpClient, final OAuthFactory oAuthFactory) {
133 this.httpClient = httpClient;
134 this.oAuthFactory = oAuthFactory;
138 public void handleCommand(ChannelUID channelUID, Command command) {
142 public void initialize() {
143 MyQAccountConfiguration config = getConfigAs(MyQAccountConfiguration.class);
144 normalRefreshSeconds = config.refreshInterval;
145 username = config.username;
146 password = config.password;
147 // MyQ can get picky about blocking user agents apparently
148 userAgent = MyQAccountHandler.randomString(20);
150 updateStatus(ThingStatus.UNKNOWN);
155 public void dispose() {
157 if (oAuthService != null) {
158 oAuthService.close();
163 public Collection<Class<? extends ThingHandlerService>> getServices() {
164 return Collections.singleton(MyQDiscoveryService.class);
168 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
169 DevicesDTO localDeviceCaches = devicesCache;
170 if (localDeviceCaches != null && childHandler instanceof MyQDeviceHandler) {
171 MyQDeviceHandler handler = (MyQDeviceHandler) childHandler;
172 localDeviceCaches.items.stream()
173 .filter(d -> ((MyQDeviceHandler) childHandler).getSerialNumber().equalsIgnoreCase(d.serialNumber))
174 .findFirst().ifPresent(handler::handleDeviceUpdate);
179 public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
180 logger.debug("Auth Token Refreshed, expires in {}", tokenResponse.getExpiresIn());
184 * Sends an action to the MyQ API
186 * @param serialNumber
189 public void sendAction(String serialNumber, String action) {
190 if (getThing().getStatus() != ThingStatus.ONLINE) {
191 logger.debug("Account offline, ignoring action {}", action);
195 AccountDTO localAccount = account;
196 if (localAccount != null) {
198 ContentResponse response = sendRequest(
199 String.format("%s/v5.1/Accounts/%s/Devices/%s/actions", BASE_URL, localAccount.account.id,
201 HttpMethod.PUT, new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))),
203 if (HttpStatus.isSuccess(response.getStatus())) {
206 logger.debug("Failed to send action {} : {}", action, response.getContentAsString());
208 } catch (InterruptedException | MyQCommunicationException | MyQAuthenticationException e) {
209 logger.debug("Could not send action", e);
215 * Last known state of MyQ Devices
217 * @return cached MyQ devices
219 public @Nullable DevicesDTO devicesCache() {
223 private void stopPolls() {
228 private synchronized void stopNormalPoll() {
229 stopFuture(normalPollFuture);
230 normalPollFuture = null;
233 private synchronized void stopRapidPoll() {
234 stopFuture(rapidPollFuture);
235 rapidPollFuture = null;
238 private void stopFuture(@Nullable Future<?> future) {
239 if (future != null) {
244 private synchronized void restartPolls(boolean rapid) {
247 normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 35, normalRefreshSeconds,
249 rapidPollFuture = scheduler.scheduleWithFixedDelay(this::rapidPoll, 3, RAPID_REFRESH_SECONDS,
252 normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 0, normalRefreshSeconds,
257 private void normalPoll() {
262 private void rapidPoll() {
266 private synchronized void fetchData() {
268 if (account == null) {
272 } catch (MyQCommunicationException e) {
273 logger.debug("MyQ communication error", e);
274 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
275 } catch (MyQAuthenticationException e) {
276 logger.debug("MyQ authentication error", e);
277 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
279 } catch (InterruptedException e) {
280 // we were shut down, ignore
285 * This attempts to navigate the MyQ oAuth login flow in order to obtain a @AccessTokenResponse
287 * @return AccessTokenResponse token
288 * @throws InterruptedException
289 * @throws MyQCommunicationException
290 * @throws MyQAuthenticationException
292 private AccessTokenResponse login()
293 throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
294 // make sure we have a fresh session
295 httpClient.getCookieStore().removeAll();
298 String codeVerifier = generateCodeVerifier();
300 ContentResponse loginPageResponse = getLoginPage(codeVerifier);
302 // load the login page to get cookies and form parameters
303 Document loginPage = Jsoup.parse(loginPageResponse.getContentAsString());
304 Element form = loginPage.select("form").first();
305 Element requestToken = loginPage.select("input[name=__RequestVerificationToken]").first();
306 Element returnURL = loginPage.select("input[name=ReturnUrl]").first();
308 if (form == null || requestToken == null) {
309 throw new MyQCommunicationException("Could not load login page");
312 // url that the form will submit to
313 String action = LOGIN_BASE_URL + form.attr("action");
315 // post our user name and password along with elements from the scraped form
316 String location = postLogin(action, requestToken.attr("value"), returnURL.attr("value"));
317 if (location == null) {
318 throw new MyQAuthenticationException("Could not login with credentials");
321 // finally complete the oAuth flow and retrieve a JSON oAuth token response
322 ContentResponse tokenResponse = getLoginToken(location, codeVerifier);
323 String loginToken = tokenResponse.getContentAsString();
325 AccessTokenResponse accessTokenResponse = gsonLowerCase.fromJson(loginToken, AccessTokenResponse.class);
326 if (accessTokenResponse == null) {
327 throw new MyQAuthenticationException("Could not parse token response");
329 getOAuthService().importAccessTokenResponse(accessTokenResponse);
330 return accessTokenResponse;
331 } catch (IOException | ExecutionException | TimeoutException | OAuthException e) {
332 throw new MyQCommunicationException(e.getMessage());
336 private void getAccount() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
337 ContentResponse response = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, null, null);
338 account = parseResultAndUpdateStatus(response, gsonUpperCase, AccountDTO.class);
341 private void getDevices() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
342 AccountDTO localAccount = account;
343 if (localAccount == null) {
346 ContentResponse response = sendRequest(
347 String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id), HttpMethod.GET, null,
349 DevicesDTO devices = parseResultAndUpdateStatus(response, gsonLowerCase, DevicesDTO.class);
350 devicesCache = devices;
351 devices.items.forEach(device -> {
352 ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
353 if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
354 for (Thing thing : getThing().getThings()) {
355 ThingHandler handler = thing.getHandler();
357 && ((MyQDeviceHandler) handler).getSerialNumber().equalsIgnoreCase(device.serialNumber)) {
358 ((MyQDeviceHandler) handler).handleDeviceUpdate(device);
365 private synchronized ContentResponse sendRequest(String url, HttpMethod method, @Nullable ContentProvider content,
366 @Nullable String contentType)
367 throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
368 AccessTokenResponse tokenResponse = null;
369 // if we don't need to force a login, attempt to use the token we have
372 tokenResponse = getOAuthService().getAccessTokenResponse();
373 } catch (OAuthException | IOException | OAuthResponseException e) {
374 // ignore error, will try to login below
375 logger.debug("Error accessing token, will attempt to login again", e);
379 // if no token, or we need to login, do so now
380 if (tokenResponse == null) {
381 tokenResponse = login();
385 Request request = httpClient.newRequest(url).method(method).agent(userAgent).timeout(10, TimeUnit.SECONDS)
386 .header("Authorization", authTokenHeader(tokenResponse));
387 if (content != null & contentType != null) {
388 request = request.content(content, contentType);
391 // use asyc jetty as the API service will response with a 401 error when credentials are wrong,
392 // but not a WWW-Authenticate header which causes Jetty to throw a generic execution exception which
393 // prevents us from knowing the response code
394 logger.trace("Sending {} to {}", request.getMethod(), request.getURI());
395 final CompletableFuture<ContentResponse> futureResult = new CompletableFuture<>();
396 request.send(new BufferingResponseListener() {
397 @NonNullByDefault({})
399 public void onComplete(Result result) {
400 Response response = result.getResponse();
401 futureResult.complete(new HttpContentResponse(response, getContent(), getMediaType(), getEncoding()));
406 ContentResponse result = futureResult.get();
407 logger.trace("Account Response - status: {} content: {}", result.getStatus(), result.getContentAsString());
409 } catch (ExecutionException e) {
410 throw new MyQCommunicationException(e.getMessage());
414 private <T> T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class<T> classOfT)
415 throws MyQCommunicationException {
416 if (HttpStatus.isSuccess(response.getStatus())) {
418 T responseObject = parser.fromJson(response.getContentAsString(), classOfT);
419 if (responseObject != null) {
420 if (getThing().getStatus() != ThingStatus.ONLINE) {
421 updateStatus(ThingStatus.ONLINE);
423 return responseObject;
425 throw new MyQCommunicationException("Bad response from server");
427 } catch (JsonSyntaxException e) {
428 throw new MyQCommunicationException("Invalid JSON Response " + response.getContentAsString());
430 } else if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
431 // our tokens no longer work, will need to login again
433 throw new MyQCommunicationException("Token was rejected for request");
435 throw new MyQCommunicationException(
436 "Invalid Response Code " + response.getStatus() + " : " + response.getContentAsString());
441 * Returns the MyQ login page which contains form elements and cookies needed to login
443 * @param codeVerifier
445 * @throws InterruptedException
446 * @throws ExecutionException
447 * @throws TimeoutException
449 private ContentResponse getLoginPage(String codeVerifier)
450 throws InterruptedException, ExecutionException, TimeoutException {
452 Request request = httpClient.newRequest(LOGIN_AUTHORIZE_URL) //
453 .param("client_id", CLIENT_ID) //
454 .param("code_challenge", generateCodeChallange(codeVerifier)) //
455 .param("code_challenge_method", "S256") //
456 .param("redirect_uri", REDIRECT_URI) //
457 .param("response_type", "code") //
458 .param("scope", SCOPE) //
459 .agent(userAgent).followRedirects(true);
460 logger.debug("Sending {} to {}", request.getMethod(), request.getURI());
461 ContentResponse response = request.send();
462 logger.debug("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
464 } catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
465 throw new ExecutionException(e.getCause());
470 * Sends configured credentials and elements from the login page in order to obtain a redirect location header value
473 * @param requestToken
475 * @return The location header value
476 * @throws InterruptedException
477 * @throws ExecutionException
478 * @throws TimeoutException
481 private String postLogin(String url, String requestToken, String returnURL)
482 throws InterruptedException, ExecutionException, TimeoutException {
484 * on a successful post to this page we will get several redirects, and a final 301 to:
485 * com.myqops://ios?code=0123456789&scope=MyQ_Residential%20offline_access&iss=https%3A%2F%2Fpartner-identity.
488 * We can then take the parameters out of this location and continue the process
490 Fields fields = new Fields();
491 fields.add("Email", username);
492 fields.add("Password", password);
493 fields.add("__RequestVerificationToken", requestToken);
494 fields.add("ReturnUrl", returnURL);
496 Request request = httpClient.newRequest(url).method(HttpMethod.POST) //
497 .content(new FormContentProvider(fields)) //
499 .followRedirects(false);
502 logger.debug("Posting Login to {}", url);
503 ContentResponse response = request.send();
505 String location = null;
507 // follow redirects until we match our REDIRECT_URI or hit a redirect safety limit
508 for (int i = 0; i < LOGIN_MAX_REDIRECTS && HttpStatus.isRedirection(response.getStatus()); i++) {
510 String loc = response.getHeaders().get("location");
511 if (logger.isTraceEnabled()) {
512 logger.trace("Redirect Login: Code {} Location Header: {} Response {}", response.getStatus(), loc,
513 response.getContentAsString());
516 logger.debug("No location value");
519 if (loc.indexOf(REDIRECT_URI) == 0) {
523 request = httpClient.newRequest(LOGIN_BASE_URL + loc).agent(userAgent).followRedirects(false);
525 response = request.send();
531 * Final step of the login process to get a oAuth access response token
533 * @param redirectLocation
534 * @param codeVerifier
536 * @throws InterruptedException
537 * @throws ExecutionException
538 * @throws TimeoutException
540 private ContentResponse getLoginToken(String redirectLocation, String codeVerifier)
541 throws InterruptedException, ExecutionException, TimeoutException {
543 Map<String, String> params = parseLocationQuery(redirectLocation);
545 Fields fields = new Fields();
546 fields.add("client_id", CLIENT_ID);
547 fields.add("client_secret", Base64.getEncoder().encodeToString(CLIENT_SECRET.getBytes()));
548 fields.add("code", params.get("code"));
549 fields.add("code_verifier", codeVerifier);
550 fields.add("grant_type", "authorization_code");
551 fields.add("redirect_uri", REDIRECT_URI);
552 fields.add("scope", params.get("scope"));
554 Request request = httpClient.newRequest(LOGIN_TOKEN_URL) //
555 .content(new FormContentProvider(fields)) //
556 .method(HttpMethod.POST) //
557 .agent(userAgent).followRedirects(true);
560 ContentResponse response = request.send();
561 if (logger.isTraceEnabled()) {
562 logger.trace("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
565 } catch (URISyntaxException e) {
566 throw new ExecutionException(e.getCause());
570 private OAuthClientService getOAuthService() {
571 OAuthClientService oAuthService = this.oAuthService;
572 if (oAuthService == null || oAuthService.isClosed()) {
573 oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL,
574 LOGIN_AUTHORIZE_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false);
575 oAuthService.addAccessTokenRefreshListener(this);
576 this.oAuthService = oAuthService;
581 private static String randomString(int length) {
583 int high = 122; // A-Z
584 StringBuilder sb = new StringBuilder(length);
585 Random random = new Random();
586 for (int i = 0; i < length; i++) {
587 sb.append((char) (low + (int) (random.nextFloat() * (high - low + 1))));
589 return sb.toString();
592 private String generateCodeVerifier() throws UnsupportedEncodingException {
593 SecureRandom secureRandom = new SecureRandom();
594 byte[] codeVerifier = new byte[32];
595 secureRandom.nextBytes(codeVerifier);
596 return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier);
599 private String generateCodeChallange(String codeVerifier)
600 throws UnsupportedEncodingException, NoSuchAlgorithmException {
601 byte[] bytes = codeVerifier.getBytes("US-ASCII");
602 MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
603 messageDigest.update(bytes, 0, bytes.length);
604 byte[] digest = messageDigest.digest();
605 return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
608 private Map<String, String> parseLocationQuery(String location) throws URISyntaxException {
609 URI uri = new URI(location);
610 return Arrays.stream(uri.getQuery().split("&")).map(str -> str.split("="))
611 .collect(Collectors.toMap(str -> str[0], str -> str[1]));
614 private void setCookies(Request request) {
615 for (HttpCookie c : httpClient.getCookieStore().getCookies()) {
620 private String authTokenHeader(AccessTokenResponse tokenResponse) {
621 return tokenResponse.getTokenType() + " " + tokenResponse.getAccessToken();
625 * Exception for authenticated related errors
627 class MyQAuthenticationException extends Exception {
628 private static final long serialVersionUID = 1L;
630 public MyQAuthenticationException(String message) {
636 * Generic exception for non authentication related errors when communicating with the MyQ service.
638 class MyQCommunicationException extends IOException {
639 private static final long serialVersionUID = 1L;
641 public MyQCommunicationException(@Nullable String message) {