]> git.basschouten.com Git - openhab-addons.git/blob
a18c0a2183f853df2005e6043e9fc3144fff782d
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.generacmobilelink.internal.handler;
14
15 import java.io.IOException;
16 import java.time.ZonedDateTime;
17 import java.util.HashMap;
18 import java.util.Map;
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;
26
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;
57
58 import com.google.gson.Gson;
59 import com.google.gson.GsonBuilder;
60 import com.google.gson.JsonDeserializer;
61 import com.google.gson.JsonSyntaxException;
62
63 /**
64  * The {@link GeneracMobileLinkAccountHandler} is responsible for connecting to the MobileLink cloud service and
65  * discovering generator things
66  *
67  * @author Dan Cunningham - Initial contribution
68  */
69 @NonNullByDefault
70 public class GeneracMobileLinkAccountHandler extends BaseBridgeHandler {
71     private final Logger logger = LoggerFactory.getLogger(GeneracMobileLinkAccountHandler.class);
72
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().registerTypeAdapter(ZonedDateTime.class,
77             (JsonDeserializer<ZonedDateTime>) (json, type, jsonDeserializationContext) -> {
78                 return ZonedDateTime.parse(json.getAsJsonPrimitive().getAsString());
79             }).create();
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;
85
86     private @Nullable Future<?> pollFuture;
87
88     public GeneracMobileLinkAccountHandler(Bridge bridge, HttpClientFactory httpClientFactory,
89             GeneracMobileLinkDiscoveryService discoveryService) {
90         super(bridge);
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);
96         try {
97             httpClient.start();
98         } catch (Exception e) {
99             throw new IllegalStateException("Error starting custom HttpClient", e);
100         }
101     }
102
103     @Override
104     public void initialize() {
105         updateStatus(ThingStatus.UNKNOWN);
106         stopOrRestartPoll(true);
107     }
108
109     @Override
110     public void dispose() {
111         stopOrRestartPoll(false);
112         try {
113             httpClient.stop();
114         } catch (Exception e) {
115             logger.debug("Could not stop HttpClient", e);
116         }
117     }
118
119     @Override
120     public void handleCommand(ChannelUID channelUID, Command command) {
121         if (command instanceof RefreshType) {
122             try {
123                 updateGeneratorThings();
124             } catch (IOException | SessionExpiredException e) {
125                 logger.debug("Could refresh things", e);
126             }
127         }
128     }
129
130     @Override
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);
137             return;
138         }
139         try {
140             updateGeneratorThing(childHandler, apparatus);
141         } catch (IOException | SessionExpiredException e) {
142             logger.debug("Could not initialize child", e);
143         }
144     }
145
146     private synchronized void stopOrRestartPoll(boolean restart) {
147         Future<?> pollFuture = this.pollFuture;
148         if (pollFuture != null) {
149             pollFuture.cancel(true);
150             this.pollFuture = null;
151         }
152         if (restart) {
153             this.pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 1, refreshIntervalSeconds, TimeUnit.SECONDS);
154         }
155     }
156
157     private void poll() {
158         try {
159             if (!loggedIn) {
160                 login();
161             }
162             loggedIn = true;
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");
172             loggedIn = false;
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");
177             loggedIn = false;
178             // we don't want to continue polling with bad credentials
179             stopOrRestartPoll(false);
180         }
181     }
182
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");
187             return;
188         }
189         if (getThing().getStatus() != ThingStatus.ONLINE) {
190             updateStatus(ThingStatus.ONLINE);
191         }
192         for (Apparatus apparatus : apparatuses) {
193             if (apparatus.type != 0) {
194                 logger.debug("Unknown apparatus type {} {}", apparatus.type, apparatus.name);
195                 continue;
196             }
197
198             String id = String.valueOf(apparatus.apparatusId);
199             apparatusesCache.put(id, apparatus);
200
201             Optional<Thing> thing = getThing().getThings().stream().filter(
202                     t -> t.getConfiguration().as(GeneracMobileLinkGeneratorConfiguration.class).generatorId.equals(id))
203                     .findFirst();
204             if (!thing.isPresent()) {
205                 discoveryService.generatorDiscovered(apparatus, getThing().getUID());
206             } else {
207                 ThingHandler handler = thing.get().getHandler();
208                 if (handler != null) {
209                     updateGeneratorThing(handler, apparatus);
210                 }
211             }
212         }
213     }
214
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);
220         } else {
221             logger.debug("Could not decode apparatuses detail response");
222         }
223     }
224
225     private @Nullable <T> T getEndpoint(Class<T> clazz, String endpoint) throws IOException, SessionExpiredException {
226         try {
227             ContentResponse response = httpClient.newRequest(API_BASE + endpoint).send();
228             if (response.getStatus() == 204) {
229                 // no data
230                 return null;
231             }
232             if (response.getStatus() != 200) {
233                 throw new SessionExpiredException("API returned status code: " + response.getStatus());
234             }
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);
243         }
244     }
245
246     /**
247      * Attempts to login through a Microsoft Azure implicit grant oauth flow
248      *
249      * @throws IOException if there is a problem communicating or parsing the responses
250      * @throws InvalidCredentialsException If Azure rejects the login credentials.
251      */
252     private synchronized void login() throws IOException, InvalidCredentialsException {
253         logger.debug("Attempting login");
254         GeneracMobileLinkAccountConfiguration config = getConfigAs(GeneracMobileLinkAccountConfiguration.class);
255         refreshIntervalSeconds = config.refreshInterval;
256         try {
257             ContentResponse signInResponse = httpClient.newRequest(API_BASE + "/Auth/SignIn?email=" + config.username)
258                     .send();
259
260             String responseData = signInResponse.getContentAsString();
261             logger.trace("response data: {}", responseData);
262
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)) {
266                 return;
267             }
268
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");
273             }
274
275             String parseSettings = matcher.group(1);
276             logger.debug("parseSettings: {}", parseSettings);
277             SignInConfig signInConfig = GSON.fromJson(parseSettings, SignInConfig.class);
278
279             if (signInConfig == null) {
280                 throw new IOException("Could not parse settings string");
281             }
282
283             Fields fields = new Fields();
284             fields.put("request_type", "RESPONSE");
285             fields.put("signInName", config.username);
286             fields.put("password", config.password);
287
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));
291
292             ContentResponse selfAssertedResponse = selfAssertedRequest.send();
293
294             logger.debug("selfAssertedRequest response {}", selfAssertedResponse.getStatus());
295
296             if (selfAssertedResponse.getStatus() != 200) {
297                 throw new IOException("SelfAsserted: Bad response status: " + selfAssertedResponse.getStatus());
298             }
299
300             SelfAssertedResponse sa = GSON.fromJson(selfAssertedResponse.getContentAsString(),
301                     SelfAssertedResponse.class);
302
303             if (sa == null) {
304                 throw new IOException("SelfAsserted Could not parse response JSON");
305             }
306
307             if (!"200".equals(sa.status)) {
308                 throw new InvalidCredentialsException("Invalid Credentials: " + sa.message);
309             }
310
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");
314
315             ContentResponse confirmedResponse = confirmedRequest.send();
316
317             if (confirmedResponse.getStatus() != 200) {
318                 throw new IOException("CombinedSigninAndSignup bad response: " + confirmedResponse.getStatus());
319             }
320
321             String loginString = confirmedResponse.getContentAsString();
322             logger.trace("confirmedResponse: {}", loginString);
323             if (!submitPage(loginString)) {
324                 throw new IOException("Error parsing HTML submit form");
325             }
326         } catch (InterruptedException e) {
327             Thread.currentThread().interrupt();
328             throw new IOException(e);
329         } catch (ExecutionException | TimeoutException | JsonSyntaxException e) {
330             throw new IOException(e);
331         }
332     }
333
334     /**
335      * Attempts to submit a HTML form from Azure to the Generac API, returns false if the HTML does not match the
336      * required form
337      *
338      * @param loginString
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
345      */
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();
352
353         if (form == null || loginState == null || loginCode == null) {
354             logger.debug("Could not load login page");
355             return false;
356         }
357
358         // url that the form will submit to
359         String action = form.attr("action");
360
361         Fields fields = new Fields();
362         fields.put("state", loginState.attr("value"));
363         fields.put("code", loginCode.attr("value"));
364
365         Request loginRequest = httpClient.POST(action).content(new FormContentProvider(fields));
366
367         ContentResponse loginResponse = loginRequest.send();
368         if (logger.isTraceEnabled()) {
369             logger.trace("login response {} {}", loginResponse.getStatus(), loginResponse.getContentAsString());
370         } else {
371             logger.debug("login response status {}", loginResponse.getStatus());
372         }
373         if (loginResponse.getStatus() != 200) {
374             throw new IOException("Bad api login resposne: " + loginResponse.getStatus());
375         }
376         return true;
377     }
378
379     private class InvalidCredentialsException extends Exception {
380         private static final long serialVersionUID = 1L;
381
382         public InvalidCredentialsException(String message) {
383             super(message);
384         }
385     }
386
387     private class SessionExpiredException extends Exception {
388         private static final long serialVersionUID = 1L;
389
390         public SessionExpiredException(String message) {
391             super(message);
392         }
393     }
394 }