2 * Copyright (c) 2010-2023 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.net.CookieStore;
19 import java.net.HttpCookie;
21 import java.net.URISyntaxException;
22 import java.nio.charset.StandardCharsets;
23 import java.security.MessageDigest;
24 import java.security.NoSuchAlgorithmException;
25 import java.security.SecureRandom;
26 import java.util.ArrayList;
27 import java.util.Arrays;
28 import java.util.Base64;
29 import java.util.Collection;
30 import java.util.List;
32 import java.util.Random;
34 import java.util.concurrent.CompletableFuture;
35 import java.util.concurrent.ExecutionException;
36 import java.util.concurrent.Future;
37 import java.util.concurrent.TimeUnit;
38 import java.util.concurrent.TimeoutException;
39 import java.util.stream.Collectors;
41 import org.eclipse.jdt.annotation.NonNullByDefault;
42 import org.eclipse.jdt.annotation.Nullable;
43 import org.eclipse.jetty.client.HttpClient;
44 import org.eclipse.jetty.client.HttpContentResponse;
45 import org.eclipse.jetty.client.api.ContentProvider;
46 import org.eclipse.jetty.client.api.ContentResponse;
47 import org.eclipse.jetty.client.api.Request;
48 import org.eclipse.jetty.client.api.Response;
49 import org.eclipse.jetty.client.api.Result;
50 import org.eclipse.jetty.client.util.BufferingResponseListener;
51 import org.eclipse.jetty.client.util.FormContentProvider;
52 import org.eclipse.jetty.http.HttpMethod;
53 import org.eclipse.jetty.http.HttpStatus;
54 import org.eclipse.jetty.util.Fields;
55 import org.jsoup.Jsoup;
56 import org.jsoup.nodes.Document;
57 import org.jsoup.nodes.Element;
58 import org.openhab.binding.myq.internal.MyQDiscoveryService;
59 import org.openhab.binding.myq.internal.config.MyQAccountConfiguration;
60 import org.openhab.binding.myq.internal.dto.AccountDTO;
61 import org.openhab.binding.myq.internal.dto.AccountsDTO;
62 import org.openhab.binding.myq.internal.dto.DeviceDTO;
63 import org.openhab.binding.myq.internal.dto.DevicesDTO;
64 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
65 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
66 import org.openhab.core.auth.client.oauth2.OAuthClientService;
67 import org.openhab.core.auth.client.oauth2.OAuthException;
68 import org.openhab.core.auth.client.oauth2.OAuthFactory;
69 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
70 import org.openhab.core.thing.Bridge;
71 import org.openhab.core.thing.ChannelUID;
72 import org.openhab.core.thing.Thing;
73 import org.openhab.core.thing.ThingStatus;
74 import org.openhab.core.thing.ThingStatusDetail;
75 import org.openhab.core.thing.ThingTypeUID;
76 import org.openhab.core.thing.binding.BaseBridgeHandler;
77 import org.openhab.core.thing.binding.ThingHandler;
78 import org.openhab.core.thing.binding.ThingHandlerService;
79 import org.openhab.core.types.Command;
80 import org.slf4j.Logger;
81 import org.slf4j.LoggerFactory;
83 import com.google.gson.FieldNamingPolicy;
84 import com.google.gson.Gson;
85 import com.google.gson.GsonBuilder;
86 import com.google.gson.JsonSyntaxException;
89 * The {@link MyQAccountHandler} is responsible for communicating with the MyQ API based on an account.
91 * @author Dan Cunningham - Initial contribution
94 public class MyQAccountHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
95 private static final int REQUEST_TIMEOUT_MS = 10_000;
98 * MyQ oAuth relate fields
100 private static final String CLIENT_SECRET = "VUQ0RFhuS3lQV3EyNUJTdw==";
101 private static final String CLIENT_ID = "ANDROID_CGI_MYQ";
102 private static final String REDIRECT_URI = "com.myqops://android";
103 private static final String SCOPE = "MyQ_Residential offline_access";
105 * MyQ authentication API endpoints
107 private static final String LOGIN_BASE_URL = "https://partner-identity.myq-cloud.com";
108 private static final String LOGIN_AUTHORIZE_URL = LOGIN_BASE_URL + "/connect/authorize";
109 private static final String LOGIN_TOKEN_URL = LOGIN_BASE_URL + "/connect/token";
110 // this should never happen, but lets be safe and give up after so many redirects
111 private static final int LOGIN_MAX_REDIRECTS = 30;
113 * MyQ device and account API endpoints
115 private static final String ACCOUNTS_URL = "https://accounts.myq-cloud.com/api/v6.0/accounts";
116 private static final String DEVICES_URL = "https://devices.myq-cloud.com/api/v5.2/Accounts/%s/Devices";
117 private static final String CMD_LAMP_URL = "https://account-devices-lamp.myq-cloud.com/api/v5.2/Accounts/%s/lamps/%s/%s";
118 private static final String CMD_DOOR_URL = "https://account-devices-gdo.myq-cloud.com/api/v5.2/Accounts/%s/door_openers/%s/%s";
120 private static final Integer RAPID_REFRESH_SECONDS = 5;
121 private final Logger logger = LoggerFactory.getLogger(MyQAccountHandler.class);
122 private final Gson gsonLowerCase = new GsonBuilder()
123 .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
124 private final OAuthFactory oAuthFactory;
125 private @Nullable Future<?> normalPollFuture;
126 private @Nullable Future<?> rapidPollFuture;
127 private @Nullable AccountsDTO accounts;
129 private List<DeviceDTO> devicesCache = new ArrayList<DeviceDTO>();
130 private @Nullable OAuthClientService oAuthService;
131 private Integer normalRefreshSeconds = 60;
132 private HttpClient httpClient;
133 private String username = "";
134 private String password = "";
135 private String userAgent = "";
136 // force login, even if we have a token
137 private boolean needsLogin = false;
139 public MyQAccountHandler(Bridge bridge, HttpClient httpClient, final OAuthFactory oAuthFactory) {
141 this.httpClient = httpClient;
142 this.oAuthFactory = oAuthFactory;
146 public void handleCommand(ChannelUID channelUID, Command command) {
150 public void initialize() {
151 MyQAccountConfiguration config = getConfigAs(MyQAccountConfiguration.class);
152 normalRefreshSeconds = config.refreshInterval;
153 username = config.username;
154 password = config.password;
155 // MyQ can get picky about blocking user agents apparently
156 userAgent = ""; // no agent string
158 updateStatus(ThingStatus.UNKNOWN);
163 public void dispose() {
165 OAuthClientService oAuthService = this.oAuthService;
166 if (oAuthService != null) {
167 oAuthService.removeAccessTokenRefreshListener(this);
168 oAuthFactory.ungetOAuthService(getThing().toString());
169 this.oAuthService = null;
174 public void handleRemoval() {
175 oAuthFactory.deleteServiceAndAccessToken(getThing().toString());
176 super.handleRemoval();
180 public Collection<Class<? extends ThingHandlerService>> getServices() {
181 return Set.of(MyQDiscoveryService.class);
185 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
186 List<DeviceDTO> localDeviceCaches = devicesCache;
187 if (childHandler instanceof MyQDeviceHandler deviceHandler) {
188 localDeviceCaches.stream().filter(d -> deviceHandler.getSerialNumber().equalsIgnoreCase(d.serialNumber))
189 .findFirst().ifPresent(deviceHandler::handleDeviceUpdate);
194 public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
195 logger.debug("Auth Token Refreshed, expires in {}", tokenResponse.getExpiresIn());
199 * Sends a door action to the MyQ API
204 public void sendDoorAction(DeviceDTO device, String action) {
205 sendAction(device, action, CMD_DOOR_URL);
209 * Sends a lamp action to the MyQ API
214 public void sendLampAction(DeviceDTO device, String action) {
215 sendAction(device, action, CMD_LAMP_URL);
218 private void sendAction(DeviceDTO device, String action, String urlFormat) {
219 if (getThing().getStatus() != ThingStatus.ONLINE) {
220 logger.debug("Account offline, ignoring action {}", action);
225 ContentResponse response = sendRequest(
226 String.format(urlFormat, device.accountId, device.serialNumber, action), HttpMethod.PUT, null,
228 if (HttpStatus.isSuccess(response.getStatus())) {
231 logger.debug("Failed to send action {} : {}", action, response.getContentAsString());
233 } catch (InterruptedException | MyQCommunicationException | MyQAuthenticationException e) {
234 logger.debug("Could not send action", e);
239 * Last known state of MyQ Devices
241 * @return cached MyQ devices
243 public @Nullable List<DeviceDTO> devicesCache() {
247 private void stopPolls() {
252 private synchronized void stopNormalPoll() {
253 stopFuture(normalPollFuture);
254 normalPollFuture = null;
257 private synchronized void stopRapidPoll() {
258 stopFuture(rapidPollFuture);
259 rapidPollFuture = null;
262 private void stopFuture(@Nullable Future<?> future) {
263 if (future != null) {
268 private synchronized void restartPolls(boolean rapid) {
271 normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 35, normalRefreshSeconds,
273 rapidPollFuture = scheduler.scheduleWithFixedDelay(this::rapidPoll, 3, RAPID_REFRESH_SECONDS,
276 normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 0, normalRefreshSeconds,
281 private void normalPoll() {
286 private void rapidPoll() {
290 private synchronized void fetchData() {
292 if (accounts == null) {
296 } catch (MyQCommunicationException e) {
297 logger.debug("MyQ communication error", e);
298 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
299 } catch (MyQAuthenticationException e) {
300 logger.debug("MyQ authentication error", e);
301 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
303 } catch (InterruptedException e) {
304 // we were shut down, ignore
309 * This attempts to navigate the MyQ oAuth login flow in order to obtain a @AccessTokenResponse
311 * @return AccessTokenResponse token
312 * @throws InterruptedException
313 * @throws MyQCommunicationException
314 * @throws MyQAuthenticationException
316 private AccessTokenResponse login()
317 throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
319 // make sure we have a fresh session
320 URI authUri = new URI(LOGIN_BASE_URL);
321 CookieStore store = httpClient.getCookieStore();
322 store.get(authUri).forEach(cookie -> {
323 store.remove(authUri, cookie);
326 String codeVerifier = generateCodeVerifier();
328 ContentResponse loginPageResponse = getLoginPage(codeVerifier);
330 // load the login page to get cookies and form parameters
331 Document loginPage = Jsoup.parse(loginPageResponse.getContentAsString());
332 Element form = loginPage.select("form").first();
333 Element requestToken = loginPage.select("input[name=__RequestVerificationToken]").first();
334 Element returnURL = loginPage.select("input[name=ReturnUrl]").first();
336 if (form == null || requestToken == null) {
337 throw new MyQCommunicationException("Could not load login page");
340 // url that the form will submit to
341 String action = LOGIN_BASE_URL + form.attr("action");
343 // post our user name and password along with elements from the scraped form
344 String location = postLogin(action, requestToken.attr("value"), returnURL.attr("value"));
345 if (location == null) {
346 throw new MyQAuthenticationException("Could not login with credentials");
349 // finally complete the oAuth flow and retrieve a JSON oAuth token response
350 ContentResponse tokenResponse = getLoginToken(location, codeVerifier);
351 String loginToken = tokenResponse.getContentAsString();
354 AccessTokenResponse accessTokenResponse = gsonLowerCase.fromJson(loginToken, AccessTokenResponse.class);
355 if (accessTokenResponse == null) {
356 throw new MyQAuthenticationException("Could not parse token response");
358 getOAuthService().importAccessTokenResponse(accessTokenResponse);
359 return accessTokenResponse;
360 } catch (JsonSyntaxException e) {
361 throw new MyQCommunicationException("Invalid Token Response " + loginToken);
363 } catch (IOException | ExecutionException | TimeoutException | OAuthException | URISyntaxException e) {
364 throw new MyQCommunicationException(e.getMessage());
368 private void getAccounts() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
369 ContentResponse response = sendRequest(ACCOUNTS_URL, HttpMethod.GET, null, null);
370 accounts = parseResultAndUpdateStatus(response, gsonLowerCase, AccountsDTO.class);
373 private void getDevices() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
374 AccountsDTO localAccounts = accounts;
375 if (localAccounts == null) {
379 List<DeviceDTO> currentDevices = new ArrayList<DeviceDTO>();
381 for (AccountDTO account : localAccounts.accounts) {
382 ContentResponse response = sendRequest(String.format(DEVICES_URL, account.id), HttpMethod.GET, null, null);
383 DevicesDTO devices = parseResultAndUpdateStatus(response, gsonLowerCase, DevicesDTO.class);
384 currentDevices.addAll(devices.items);
385 devices.items.forEach(device -> {
386 ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
387 if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
388 for (Thing thing : getThing().getThings()) {
389 ThingHandler handler = thing.getHandler();
390 if (handler != null && ((MyQDeviceHandler) handler).getSerialNumber()
391 .equalsIgnoreCase(device.serialNumber)) {
392 ((MyQDeviceHandler) handler).handleDeviceUpdate(device);
398 devicesCache = currentDevices;
401 private synchronized ContentResponse sendRequest(String url, HttpMethod method, @Nullable ContentProvider content,
402 @Nullable String contentType)
403 throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
404 AccessTokenResponse tokenResponse = null;
405 // if we don't need to force a login, attempt to use the token we have
408 tokenResponse = getOAuthService().getAccessTokenResponse();
409 } catch (OAuthException | IOException | OAuthResponseException e) {
410 // ignore error, will try to login below
411 logger.debug("Error accessing token, will attempt to login again", e);
415 // if no token, or we need to login, do so now
416 if (tokenResponse == null) {
417 tokenResponse = login();
421 Request request = httpClient.newRequest(url).method(method).agent(userAgent)
422 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
423 .header("Authorization", authTokenHeader(tokenResponse));
424 if (content != null & contentType != null) {
425 request = request.content(content, contentType);
428 // use asyc jetty as the API service will response with a 401 error when credentials are wrong,
429 // but not a WWW-Authenticate header which causes Jetty to throw a generic execution exception which
430 // prevents us from knowing the response code
431 logger.trace("Sending {} to {}", request.getMethod(), request.getURI());
432 final CompletableFuture<ContentResponse> futureResult = new CompletableFuture<>();
433 request.send(new BufferingResponseListener() {
434 @NonNullByDefault({})
436 public void onComplete(Result result) {
437 Response response = result.getResponse();
438 futureResult.complete(new HttpContentResponse(response, getContent(), getMediaType(), getEncoding()));
443 ContentResponse result = futureResult.get();
444 logger.trace("Account Response - status: {} content: {}", result.getStatus(), result.getContentAsString());
446 } catch (ExecutionException e) {
447 throw new MyQCommunicationException(e.getMessage());
451 private <T> T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class<T> classOfT)
452 throws MyQCommunicationException {
453 if (HttpStatus.isSuccess(response.getStatus())) {
455 T responseObject = parser.fromJson(response.getContentAsString(), classOfT);
456 if (responseObject != null) {
457 if (getThing().getStatus() != ThingStatus.ONLINE) {
458 updateStatus(ThingStatus.ONLINE);
460 return responseObject;
462 throw new MyQCommunicationException("Bad response from server");
464 } catch (JsonSyntaxException e) {
465 throw new MyQCommunicationException("Invalid JSON Response " + response.getContentAsString());
467 } else if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
468 // our tokens no longer work, will need to login again
470 throw new MyQCommunicationException("Token was rejected for request");
472 throw new MyQCommunicationException(
473 "Invalid Response Code " + response.getStatus() + " : " + response.getContentAsString());
478 * Returns the MyQ login page which contains form elements and cookies needed to login
480 * @param codeVerifier
482 * @throws InterruptedException
483 * @throws ExecutionException
484 * @throws TimeoutException
486 private ContentResponse getLoginPage(String codeVerifier)
487 throws InterruptedException, ExecutionException, TimeoutException {
489 Request request = httpClient.newRequest(LOGIN_AUTHORIZE_URL) //
490 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) //
491 .param("client_id", CLIENT_ID) //
492 .param("code_challenge", generateCodeChallange(codeVerifier)) //
493 .param("code_challenge_method", "S256") //
494 .param("redirect_uri", REDIRECT_URI) //
495 .param("response_type", "code") //
496 .param("scope", SCOPE) //
497 .agent(userAgent).followRedirects(true);
498 request.header("Accept", "\"*/*\"");
499 request.header("Authorization",
500 "Basic " + Base64.getEncoder().encodeToString((CLIENT_ID + ":").getBytes()));
501 logger.debug("Sending {} to {}", request.getMethod(), request.getURI());
502 ContentResponse response = request.send();
503 logger.debug("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
505 } catch (NoSuchAlgorithmException e) {
506 throw new ExecutionException(e.getCause());
511 * Sends configured credentials and elements from the login page in order to obtain a redirect location header value
514 * @param requestToken
516 * @return The location header value
517 * @throws InterruptedException
518 * @throws ExecutionException
519 * @throws TimeoutException
522 private String postLogin(String url, String requestToken, String returnURL)
523 throws InterruptedException, ExecutionException, TimeoutException {
525 * on a successful post to this page we will get several redirects, and a final 301 to:
526 * com.myqops://ios?code=0123456789&scope=MyQ_Residential%20offline_access&iss=https%3A%2F%2Fpartner-identity.
529 * We can then take the parameters out of this location and continue the process
531 Fields fields = new Fields();
532 fields.add("Email", username);
533 fields.add("Password", password);
534 fields.add("__RequestVerificationToken", requestToken);
535 fields.add("ReturnUrl", returnURL);
537 Request request = httpClient.newRequest(url).method(HttpMethod.POST) //
538 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) //
539 .content(new FormContentProvider(fields)) //
541 .followRedirects(false);
544 logger.debug("Posting Login to {}", url);
545 ContentResponse response = request.send();
547 String location = null;
549 // follow redirects until we match our REDIRECT_URI or hit a redirect safety limit
550 for (int i = 0; i < LOGIN_MAX_REDIRECTS && HttpStatus.isRedirection(response.getStatus()); i++) {
552 String loc = response.getHeaders().get("location");
553 if (logger.isTraceEnabled()) {
554 logger.trace("Redirect Login: Code {} Location Header: {} Response {}", response.getStatus(), loc,
555 response.getContentAsString());
558 logger.debug("No location value");
561 if (loc.indexOf(REDIRECT_URI) == 0) {
565 request = httpClient.newRequest(LOGIN_BASE_URL + loc).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
566 .agent(userAgent).followRedirects(false);
568 response = request.send();
574 * Final step of the login process to get an oAuth access response token
576 * @param redirectLocation
577 * @param codeVerifier
579 * @throws InterruptedException
580 * @throws ExecutionException
581 * @throws TimeoutException
583 private ContentResponse getLoginToken(String redirectLocation, String codeVerifier)
584 throws InterruptedException, ExecutionException, TimeoutException {
586 Map<String, String> params = parseLocationQuery(redirectLocation);
588 Fields fields = new Fields();
589 fields.add("client_id", CLIENT_ID);
590 fields.add("client_secret", Base64.getEncoder().encodeToString(CLIENT_SECRET.getBytes()));
591 fields.add("code", params.get("code"));
592 fields.add("code_verifier", codeVerifier);
593 fields.add("grant_type", "authorization_code");
594 fields.add("redirect_uri", REDIRECT_URI);
595 fields.add("scope", params.get("scope"));
597 Request request = httpClient.newRequest(LOGIN_TOKEN_URL) //
598 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) //
599 .content(new FormContentProvider(fields)) //
600 .method(HttpMethod.POST) //
601 .agent(userAgent).followRedirects(true);
603 ContentResponse response = request.send();
604 if (logger.isTraceEnabled()) {
605 logger.trace("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
608 } catch (URISyntaxException e) {
609 throw new ExecutionException(e.getCause());
613 private OAuthClientService getOAuthService() {
614 OAuthClientService oAuthService = this.oAuthService;
615 if (oAuthService == null || oAuthService.isClosed()) {
616 oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL,
617 LOGIN_AUTHORIZE_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false);
618 oAuthService.addAccessTokenRefreshListener(this);
619 this.oAuthService = oAuthService;
624 private static String randomString(int length) {
626 int high = 122; // A-Z
627 StringBuilder sb = new StringBuilder(length);
628 Random random = new Random();
629 for (int i = 0; i < length; i++) {
630 sb.append((char) (low + (int) (random.nextFloat() * (high - low + 1))));
632 return sb.toString();
635 private String generateCodeVerifier() {
636 SecureRandom secureRandom = new SecureRandom();
637 byte[] codeVerifier = new byte[32];
638 secureRandom.nextBytes(codeVerifier);
639 return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier);
642 private String generateCodeChallange(String codeVerifier) throws NoSuchAlgorithmException {
643 byte[] bytes = codeVerifier.getBytes(StandardCharsets.US_ASCII);
644 MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
645 messageDigest.update(bytes, 0, bytes.length);
646 byte[] digest = messageDigest.digest();
647 return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
650 private Map<String, String> parseLocationQuery(String location) throws URISyntaxException {
651 URI uri = new URI(location);
652 return Arrays.stream(uri.getQuery().split("&")).map(str -> str.split("="))
653 .collect(Collectors.toMap(str -> str[0], str -> str[1]));
656 private void setCookies(Request request) {
657 for (HttpCookie c : httpClient.getCookieStore().getCookies()) {
662 private String authTokenHeader(AccessTokenResponse tokenResponse) {
663 return tokenResponse.getTokenType() + " " + tokenResponse.getAccessToken();
667 * Exception for authenticated related errors
669 class MyQAuthenticationException extends Exception {
670 private static final long serialVersionUID = 1L;
672 public MyQAuthenticationException(String message) {
678 * Generic exception for non authentication related errors when communicating with the MyQ service.
680 class MyQCommunicationException extends IOException {
681 private static final long serialVersionUID = 1L;
683 public MyQCommunicationException(@Nullable String message) {