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.generacmobilelink.internal.handler;
15 import java.io.IOException;
16 import java.time.ZonedDateTime;
17 import java.util.HashMap;
19 import java.util.Optional;
20 import java.util.concurrent.ExecutionException;
21 import java.util.concurrent.Future;
22 import java.util.concurrent.TimeUnit;
23 import java.util.concurrent.TimeoutException;
24 import java.util.regex.Matcher;
25 import java.util.regex.Pattern;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.client.api.ContentResponse;
31 import org.eclipse.jetty.client.api.Request;
32 import org.eclipse.jetty.client.util.FormContentProvider;
33 import org.eclipse.jetty.util.Fields;
34 import org.jsoup.Jsoup;
35 import org.jsoup.nodes.Document;
36 import org.jsoup.nodes.Element;
37 import org.openhab.binding.generacmobilelink.internal.GeneracMobileLinkBindingConstants;
38 import org.openhab.binding.generacmobilelink.internal.config.GeneracMobileLinkAccountConfiguration;
39 import org.openhab.binding.generacmobilelink.internal.config.GeneracMobileLinkGeneratorConfiguration;
40 import org.openhab.binding.generacmobilelink.internal.discovery.GeneracMobileLinkDiscoveryService;
41 import org.openhab.binding.generacmobilelink.internal.dto.Apparatus;
42 import org.openhab.binding.generacmobilelink.internal.dto.ApparatusDetail;
43 import org.openhab.binding.generacmobilelink.internal.dto.SelfAssertedResponse;
44 import org.openhab.binding.generacmobilelink.internal.dto.SignInConfig;
45 import org.openhab.core.io.net.http.HttpClientFactory;
46 import org.openhab.core.thing.Bridge;
47 import org.openhab.core.thing.ChannelUID;
48 import org.openhab.core.thing.Thing;
49 import org.openhab.core.thing.ThingStatus;
50 import org.openhab.core.thing.ThingStatusDetail;
51 import org.openhab.core.thing.binding.BaseBridgeHandler;
52 import org.openhab.core.thing.binding.ThingHandler;
53 import org.openhab.core.types.Command;
54 import org.openhab.core.types.RefreshType;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
58 import com.google.gson.Gson;
59 import com.google.gson.GsonBuilder;
60 import com.google.gson.JsonDeserializer;
61 import com.google.gson.JsonSyntaxException;
64 * The {@link GeneracMobileLinkAccountHandler} is responsible for connecting to the MobileLink cloud service and
65 * discovering generator things
67 * @author Dan Cunningham - Initial contribution
70 public class GeneracMobileLinkAccountHandler extends BaseBridgeHandler {
71 private final Logger logger = LoggerFactory.getLogger(GeneracMobileLinkAccountHandler.class);
73 private static final String API_BASE = "https://app.mobilelinkgen.com/api";
74 private static final String LOGIN_BASE = "https://generacconnectivity.b2clogin.com/generacconnectivity.onmicrosoft.com/B2C_1A_MobileLink_SignIn";
75 private static final Pattern SETTINGS_PATTERN = Pattern.compile("^var SETTINGS = (.*);$", Pattern.MULTILINE);
76 private static final Gson GSON = new GsonBuilder()
77 .registerTypeAdapter(ZonedDateTime.class, (JsonDeserializer<ZonedDateTime>) (json, type,
78 jsonDeserializationContext) -> ZonedDateTime.parse(json.getAsJsonPrimitive().getAsString()))
80 private HttpClient httpClient;
81 private GeneracMobileLinkDiscoveryService discoveryService;
82 private Map<String, Apparatus> apparatusesCache = new HashMap<String, Apparatus>();
83 private int refreshIntervalSeconds = 60;
84 private boolean loggedIn;
86 private @Nullable Future<?> pollFuture;
88 public GeneracMobileLinkAccountHandler(Bridge bridge, HttpClientFactory httpClientFactory,
89 GeneracMobileLinkDiscoveryService discoveryService) {
91 this.discoveryService = discoveryService;
92 httpClient = httpClientFactory.createHttpClient(GeneracMobileLinkBindingConstants.BINDING_ID);
93 httpClient.setFollowRedirects(true);
94 // We have to send a very large amount of cookies which exceeds the default buffer size
95 httpClient.setRequestBufferSize(16348);
98 } catch (Exception e) {
99 throw new IllegalStateException("Error starting custom HttpClient", e);
104 public void initialize() {
105 updateStatus(ThingStatus.UNKNOWN);
106 stopOrRestartPoll(true);
110 public void dispose() {
111 stopOrRestartPoll(false);
114 } catch (Exception e) {
115 logger.debug("Could not stop HttpClient", e);
120 public void handleCommand(ChannelUID channelUID, Command command) {
121 if (command instanceof RefreshType) {
123 updateGeneratorThings();
124 } catch (IOException | SessionExpiredException e) {
125 logger.debug("Could refresh things", e);
131 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
132 logger.debug("childHandlerInitialized {}", childThing.getUID());
133 String id = childThing.getConfiguration().as(GeneracMobileLinkGeneratorConfiguration.class).generatorId;
134 Apparatus apparatus = apparatusesCache.get(id);
135 if (apparatus == null) {
136 logger.debug("No device for id {}", id);
140 updateGeneratorThing(childHandler, apparatus);
141 } catch (IOException | SessionExpiredException e) {
142 logger.debug("Could not initialize child", e);
146 private synchronized void stopOrRestartPoll(boolean restart) {
147 Future<?> pollFuture = this.pollFuture;
148 if (pollFuture != null) {
149 pollFuture.cancel(true);
150 this.pollFuture = null;
153 this.pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 1, refreshIntervalSeconds, TimeUnit.SECONDS);
157 private void poll() {
163 updateGeneratorThings();
164 } catch (IOException e) {
165 logger.debug("Could not update devices", e);
166 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
167 "@text/thing.generacmobilelink.account.offline.communication-error.io-exception");
168 } catch (SessionExpiredException e) {
169 logger.debug("Session expired", e);
170 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
171 "@text/thing.generacmobilelink.account.offline.communication-error.session-expired");
173 } catch (InvalidCredentialsException e) {
174 logger.debug("Credentials Invalid", e);
175 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
176 "@text/thing.generacmobilelink.account.offline.configuration-error.invalid-credentials");
178 // we don't want to continue polling with bad credentials
179 stopOrRestartPoll(false);
183 private void updateGeneratorThings() throws IOException, SessionExpiredException {
184 Apparatus[] apparatuses = getEndpoint(Apparatus[].class, "/v2/Apparatus/list");
185 if (apparatuses == null) {
186 logger.debug("Could not decode apparatuses response");
189 if (getThing().getStatus() != ThingStatus.ONLINE) {
190 updateStatus(ThingStatus.ONLINE);
192 for (Apparatus apparatus : apparatuses) {
193 if (apparatus.type != 0) {
194 logger.debug("Unknown apparatus type {} {}", apparatus.type, apparatus.name);
198 String id = String.valueOf(apparatus.apparatusId);
199 apparatusesCache.put(id, apparatus);
201 Optional<Thing> thing = getThing().getThings().stream().filter(
202 t -> t.getConfiguration().as(GeneracMobileLinkGeneratorConfiguration.class).generatorId.equals(id))
204 if (thing.isEmpty()) {
205 discoveryService.generatorDiscovered(apparatus, getThing().getUID());
207 ThingHandler handler = thing.get().getHandler();
208 if (handler != null) {
209 updateGeneratorThing(handler, apparatus);
215 private void updateGeneratorThing(ThingHandler handler, Apparatus apparatus)
216 throws IOException, SessionExpiredException {
217 ApparatusDetail detail = getEndpoint(ApparatusDetail.class, "/v1/Apparatus/details/" + apparatus.apparatusId);
218 if (detail != null) {
219 ((GeneracMobileLinkGeneratorHandler) handler).updateGeneratorStatus(apparatus, detail);
221 logger.debug("Could not decode apparatuses detail response");
225 private @Nullable <T> T getEndpoint(Class<T> clazz, String endpoint) throws IOException, SessionExpiredException {
227 ContentResponse response = httpClient.newRequest(API_BASE + endpoint).send();
228 if (response.getStatus() == 204) {
232 if (response.getStatus() != 200) {
233 throw new SessionExpiredException("API returned status code: " + response.getStatus());
235 String data = response.getContentAsString();
236 logger.debug("getEndpoint {}", data);
237 return GSON.fromJson(data, clazz);
238 } catch (InterruptedException e) {
239 Thread.currentThread().interrupt();
240 throw new IOException(e);
241 } catch (TimeoutException | ExecutionException | JsonSyntaxException e) {
242 throw new IOException(e);
247 * Attempts to login through a Microsoft Azure implicit grant oauth flow
249 * @throws IOException if there is a problem communicating or parsing the responses
250 * @throws InvalidCredentialsException If Azure rejects the login credentials.
252 private synchronized void login() throws IOException, InvalidCredentialsException {
253 logger.debug("Attempting login");
254 GeneracMobileLinkAccountConfiguration config = getConfigAs(GeneracMobileLinkAccountConfiguration.class);
255 refreshIntervalSeconds = config.refreshInterval;
257 ContentResponse signInResponse = httpClient.newRequest(API_BASE + "/Auth/SignIn?email=" + config.username)
260 String responseData = signInResponse.getContentAsString();
261 logger.trace("response data: {}", responseData);
263 // If we are immediately returned a submit form, it means our cookies are still valid with the identity
264 // provider and we can just try and submit to the API service
265 if (submitPage(responseData)) {
269 // Azure wants us to login again, look for the SETTINGS javascript in the page
270 Matcher matcher = SETTINGS_PATTERN.matcher(responseData);
271 if (!matcher.find()) {
272 throw new IOException("Could not find settings string");
275 String parseSettings = matcher.group(1);
276 logger.debug("parseSettings: {}", parseSettings);
277 SignInConfig signInConfig = GSON.fromJson(parseSettings, SignInConfig.class);
279 if (signInConfig == null) {
280 throw new IOException("Could not parse settings string");
283 Fields fields = new Fields();
284 fields.put("request_type", "RESPONSE");
285 fields.put("signInName", config.username);
286 fields.put("password", config.password);
288 Request selfAssertedRequest = httpClient.POST(LOGIN_BASE + "/SelfAsserted")
289 .header("X-Csrf-Token", signInConfig.csrf).param("tx", "StateProperties=" + signInConfig.transId)
290 .param("p", "B2C_1A_SignUpOrSigninOnline").content(new FormContentProvider(fields));
292 ContentResponse selfAssertedResponse = selfAssertedRequest.send();
294 logger.debug("selfAssertedRequest response {}", selfAssertedResponse.getStatus());
296 if (selfAssertedResponse.getStatus() != 200) {
297 throw new IOException("SelfAsserted: Bad response status: " + selfAssertedResponse.getStatus());
300 SelfAssertedResponse sa = GSON.fromJson(selfAssertedResponse.getContentAsString(),
301 SelfAssertedResponse.class);
304 throw new IOException("SelfAsserted Could not parse response JSON");
307 if (!"200".equals(sa.status)) {
308 throw new InvalidCredentialsException("Invalid Credentials: " + sa.message);
311 Request confirmedRequest = httpClient.newRequest(LOGIN_BASE + "/api/CombinedSigninAndSignup/confirmed")
312 .param("csrf_token", signInConfig.csrf).param("tx", "StateProperties=" + signInConfig.transId)
313 .param("p", "B2C_1A_SignUpOrSigninOnline");
315 ContentResponse confirmedResponse = confirmedRequest.send();
317 if (confirmedResponse.getStatus() != 200) {
318 throw new IOException("CombinedSigninAndSignup bad response: " + confirmedResponse.getStatus());
321 String loginString = confirmedResponse.getContentAsString();
322 logger.trace("confirmedResponse: {}", loginString);
323 if (!submitPage(loginString)) {
324 throw new IOException("Error parsing HTML submit form");
326 } catch (InterruptedException e) {
327 Thread.currentThread().interrupt();
328 throw new IOException(e);
329 } catch (ExecutionException | TimeoutException | JsonSyntaxException e) {
330 throw new IOException(e);
335 * Attempts to submit a HTML form from Azure to the Generac API, returns false if the HTML does not match the
339 * @return false if the HTML is not a form, true if submission is successful
340 * @throws ExecutionException
341 * @throws TimeoutException
342 * @throws InterruptedException
343 * @throws JsonSyntaxException
344 * @throws IOException
346 private boolean submitPage(String loginString)
347 throws ExecutionException, TimeoutException, InterruptedException, JsonSyntaxException, IOException {
348 Document loginPage = Jsoup.parse(loginString);
349 Element form = loginPage.select("form").first();
350 Element loginState = loginPage.select("input[name=state]").first();
351 Element loginCode = loginPage.select("input[name=code]").first();
353 if (form == null || loginState == null || loginCode == null) {
354 logger.debug("Could not load login page");
358 // url that the form will submit to
359 String action = form.attr("action");
361 Fields fields = new Fields();
362 fields.put("state", loginState.attr("value"));
363 fields.put("code", loginCode.attr("value"));
365 Request loginRequest = httpClient.POST(action).content(new FormContentProvider(fields));
367 ContentResponse loginResponse = loginRequest.send();
368 if (logger.isTraceEnabled()) {
369 logger.trace("login response {} {}", loginResponse.getStatus(), loginResponse.getContentAsString());
371 logger.debug("login response status {}", loginResponse.getStatus());
373 if (loginResponse.getStatus() != 200) {
374 throw new IOException("Bad api login resposne: " + loginResponse.getStatus());
379 private class InvalidCredentialsException extends Exception {
380 private static final long serialVersionUID = 1L;
382 public InvalidCredentialsException(String message) {
387 private class SessionExpiredException extends Exception {
388 private static final long serialVersionUID = 1L;
390 public SessionExpiredException(String message) {