2 * Copyright (c) 2010-2024 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);
72 private static final int REQUEST_TIMEOUT_MS = 10_000;
74 private static final String API_BASE = "https://app.mobilelinkgen.com/api";
75 private static final String LOGIN_BASE = "https://generacconnectivity.b2clogin.com/generacconnectivity.onmicrosoft.com/B2C_1A_MobileLink_SignIn";
76 private static final Pattern SETTINGS_PATTERN = Pattern.compile("^var SETTINGS = (.*);$", Pattern.MULTILINE);
77 private static final Gson GSON = new GsonBuilder()
78 .registerTypeAdapter(ZonedDateTime.class, (JsonDeserializer<ZonedDateTime>) (json, type,
79 jsonDeserializationContext) -> ZonedDateTime.parse(json.getAsJsonPrimitive().getAsString()))
81 private HttpClient httpClient;
82 private GeneracMobileLinkDiscoveryService discoveryService;
83 private Map<String, Apparatus> apparatusesCache = new HashMap<String, Apparatus>();
84 private int refreshIntervalSeconds = 60;
85 private boolean loggedIn;
87 private @Nullable Future<?> pollFuture;
89 public GeneracMobileLinkAccountHandler(Bridge bridge, HttpClientFactory httpClientFactory,
90 GeneracMobileLinkDiscoveryService discoveryService) {
92 this.discoveryService = discoveryService;
93 httpClient = httpClientFactory.createHttpClient(GeneracMobileLinkBindingConstants.BINDING_ID);
94 httpClient.setFollowRedirects(true);
95 // We have to send a very large amount of cookies which exceeds the default buffer size
96 httpClient.setRequestBufferSize(16348);
99 } catch (Exception e) {
100 throw new IllegalStateException("Error starting custom HttpClient", e);
105 public void initialize() {
106 updateStatus(ThingStatus.UNKNOWN);
107 stopOrRestartPoll(true);
111 public void dispose() {
112 stopOrRestartPoll(false);
115 } catch (Exception e) {
116 logger.debug("Could not stop HttpClient", e);
121 public void handleCommand(ChannelUID channelUID, Command command) {
122 if (command instanceof RefreshType) {
124 updateGeneratorThings();
125 } catch (IOException | SessionExpiredException e) {
126 logger.debug("Could refresh things", e);
132 public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
133 logger.debug("childHandlerInitialized {}", childThing.getUID());
134 String id = childThing.getConfiguration().as(GeneracMobileLinkGeneratorConfiguration.class).generatorId;
135 Apparatus apparatus = apparatusesCache.get(id);
136 if (apparatus == null) {
137 logger.debug("No device for id {}", id);
141 updateGeneratorThing(childHandler, apparatus);
142 } catch (IOException | SessionExpiredException e) {
143 logger.debug("Could not initialize child", e);
147 private synchronized void stopOrRestartPoll(boolean restart) {
148 Future<?> pollFuture = this.pollFuture;
149 if (pollFuture != null) {
150 pollFuture.cancel(true);
151 this.pollFuture = null;
154 this.pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 1, refreshIntervalSeconds, TimeUnit.SECONDS);
158 private void poll() {
164 updateGeneratorThings();
165 } catch (IOException e) {
166 logger.debug("Could not update devices", e);
167 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
168 "@text/thing.generacmobilelink.account.offline.communication-error.io-exception");
169 } catch (SessionExpiredException e) {
170 logger.debug("Session expired", e);
171 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
172 "@text/thing.generacmobilelink.account.offline.communication-error.session-expired");
174 } catch (InvalidCredentialsException e) {
175 logger.debug("Credentials Invalid", e);
176 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
177 "@text/thing.generacmobilelink.account.offline.configuration-error.invalid-credentials");
179 // we don't want to continue polling with bad credentials
180 stopOrRestartPoll(false);
184 private void updateGeneratorThings() throws IOException, SessionExpiredException {
185 Apparatus[] apparatuses = getEndpoint(Apparatus[].class, "/v2/Apparatus/list");
186 if (apparatuses == null) {
187 logger.debug("Could not decode apparatuses response");
190 if (getThing().getStatus() != ThingStatus.ONLINE) {
191 updateStatus(ThingStatus.ONLINE);
193 for (Apparatus apparatus : apparatuses) {
194 if (apparatus.type != 0) {
195 logger.debug("Unknown apparatus type {} {}", apparatus.type, apparatus.name);
199 String id = String.valueOf(apparatus.apparatusId);
200 apparatusesCache.put(id, apparatus);
202 Optional<Thing> thing = getThing().getThings().stream().filter(
203 t -> t.getConfiguration().as(GeneracMobileLinkGeneratorConfiguration.class).generatorId.equals(id))
205 if (thing.isEmpty()) {
206 discoveryService.generatorDiscovered(apparatus, getThing().getUID());
208 ThingHandler handler = thing.get().getHandler();
209 if (handler != null) {
210 updateGeneratorThing(handler, apparatus);
216 private void updateGeneratorThing(ThingHandler handler, Apparatus apparatus)
217 throws IOException, SessionExpiredException {
218 ApparatusDetail detail = getEndpoint(ApparatusDetail.class, "/v1/Apparatus/details/" + apparatus.apparatusId);
219 if (detail != null) {
220 ((GeneracMobileLinkGeneratorHandler) handler).updateGeneratorStatus(apparatus, detail);
222 logger.debug("Could not decode apparatuses detail response");
226 private @Nullable <T> T getEndpoint(Class<T> clazz, String endpoint) throws IOException, SessionExpiredException {
228 ContentResponse response = httpClient.newRequest(API_BASE + endpoint).send();
229 if (response.getStatus() == 204) {
233 if (response.getStatus() != 200) {
234 throw new SessionExpiredException("API returned status code: " + response.getStatus());
236 String data = response.getContentAsString();
237 logger.debug("getEndpoint {}", data);
238 return GSON.fromJson(data, clazz);
239 } catch (InterruptedException e) {
240 Thread.currentThread().interrupt();
241 throw new IOException(e);
242 } catch (TimeoutException | ExecutionException | JsonSyntaxException e) {
243 throw new IOException(e);
248 * Attempts to login through a Microsoft Azure implicit grant oauth flow
250 * @throws IOException if there is a problem communicating or parsing the responses
251 * @throws InvalidCredentialsException If Azure rejects the login credentials.
253 private synchronized void login() throws IOException, InvalidCredentialsException {
254 logger.debug("Attempting login");
255 GeneracMobileLinkAccountConfiguration config = getConfigAs(GeneracMobileLinkAccountConfiguration.class);
256 refreshIntervalSeconds = config.refreshInterval;
258 ContentResponse signInResponse = httpClient.newRequest(API_BASE + "/Auth/SignIn?email=" + config.username)
261 String responseData = signInResponse.getContentAsString();
262 logger.trace("response data: {}", responseData);
264 // If we are immediately returned a submit form, it means our cookies are still valid with the identity
265 // provider and we can just try and submit to the API service
266 if (submitPage(responseData)) {
270 // Azure wants us to login again, look for the SETTINGS javascript in the page
271 Matcher matcher = SETTINGS_PATTERN.matcher(responseData);
272 if (!matcher.find()) {
273 throw new IOException("Could not find settings string");
276 String parseSettings = matcher.group(1);
277 logger.debug("parseSettings: {}", parseSettings);
278 SignInConfig signInConfig = GSON.fromJson(parseSettings, SignInConfig.class);
280 if (signInConfig == null) {
281 throw new IOException("Could not parse settings string");
284 Fields fields = new Fields();
285 fields.put("request_type", "RESPONSE");
286 fields.put("signInName", config.username);
287 fields.put("password", config.password);
289 Request selfAssertedRequest = httpClient.POST(LOGIN_BASE + "/SelfAsserted")
290 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).header("X-Csrf-Token", signInConfig.csrf)
291 .param("tx", "StateProperties=" + signInConfig.transId).param("p", "B2C_1A_SignUpOrSigninOnline")
292 .content(new FormContentProvider(fields));
294 ContentResponse selfAssertedResponse = selfAssertedRequest.send();
296 logger.debug("selfAssertedRequest response {}", selfAssertedResponse.getStatus());
298 if (selfAssertedResponse.getStatus() != 200) {
299 throw new IOException("SelfAsserted: Bad response status: " + selfAssertedResponse.getStatus());
302 SelfAssertedResponse sa = GSON.fromJson(selfAssertedResponse.getContentAsString(),
303 SelfAssertedResponse.class);
306 throw new IOException("SelfAsserted Could not parse response JSON");
309 if (!"200".equals(sa.status)) {
310 throw new InvalidCredentialsException("Invalid Credentials: " + sa.message);
313 Request confirmedRequest = httpClient.newRequest(LOGIN_BASE + "/api/CombinedSigninAndSignup/confirmed")
314 .timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS).param("csrf_token", signInConfig.csrf)
315 .param("tx", "StateProperties=" + signInConfig.transId).param("p", "B2C_1A_SignUpOrSigninOnline");
317 ContentResponse confirmedResponse = confirmedRequest.send();
319 if (confirmedResponse.getStatus() != 200) {
320 throw new IOException("CombinedSigninAndSignup bad response: " + confirmedResponse.getStatus());
323 String loginString = confirmedResponse.getContentAsString();
324 logger.trace("confirmedResponse: {}", loginString);
325 if (!submitPage(loginString)) {
326 throw new IOException("Error parsing HTML submit form");
328 } catch (InterruptedException e) {
329 Thread.currentThread().interrupt();
330 throw new IOException(e);
331 } catch (ExecutionException | TimeoutException | JsonSyntaxException e) {
332 throw new IOException(e);
337 * Attempts to submit a HTML form from Azure to the Generac API, returns false if the HTML does not match the
341 * @return false if the HTML is not a form, true if submission is successful
342 * @throws ExecutionException
343 * @throws TimeoutException
344 * @throws InterruptedException
345 * @throws JsonSyntaxException
346 * @throws IOException
348 private boolean submitPage(String loginString)
349 throws ExecutionException, TimeoutException, InterruptedException, JsonSyntaxException, IOException {
350 Document loginPage = Jsoup.parse(loginString);
351 Element form = loginPage.select("form").first();
352 Element loginState = loginPage.select("input[name=state]").first();
353 Element loginCode = loginPage.select("input[name=code]").first();
355 if (form == null || loginState == null || loginCode == null) {
356 logger.debug("Could not load login page");
360 // url that the form will submit to
361 String action = form.attr("action");
363 Fields fields = new Fields();
364 fields.put("state", loginState.attr("value"));
365 fields.put("code", loginCode.attr("value"));
367 Request loginRequest = httpClient.POST(action).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
368 .content(new FormContentProvider(fields));
370 ContentResponse loginResponse = loginRequest.send();
371 if (logger.isTraceEnabled()) {
372 logger.trace("login response {} {}", loginResponse.getStatus(), loginResponse.getContentAsString());
374 logger.debug("login response status {}", loginResponse.getStatus());
376 if (loginResponse.getStatus() != 200) {
377 throw new IOException("Bad api login resposne: " + loginResponse.getStatus());
382 private class InvalidCredentialsException extends Exception {
383 private static final long serialVersionUID = 1L;
385 public InvalidCredentialsException(String message) {
390 private class SessionExpiredException extends Exception {
391 private static final long serialVersionUID = 1L;
393 public SessionExpiredException(String message) {