]> git.basschouten.com Git - openhab-addons.git/blob
5e93e7f3e1fbf7404a1ffaa0979e8744c80069fa
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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     private static final int REQUEST_TIMEOUT_MS = 10_000;
73
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()))
80             .create();
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;
86
87     private @Nullable Future<?> pollFuture;
88
89     public GeneracMobileLinkAccountHandler(Bridge bridge, HttpClientFactory httpClientFactory,
90             GeneracMobileLinkDiscoveryService discoveryService) {
91         super(bridge);
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);
97         try {
98             httpClient.start();
99         } catch (Exception e) {
100             throw new IllegalStateException("Error starting custom HttpClient", e);
101         }
102     }
103
104     @Override
105     public void initialize() {
106         updateStatus(ThingStatus.UNKNOWN);
107         stopOrRestartPoll(true);
108     }
109
110     @Override
111     public void dispose() {
112         stopOrRestartPoll(false);
113         try {
114             httpClient.stop();
115         } catch (Exception e) {
116             logger.debug("Could not stop HttpClient", e);
117         }
118     }
119
120     @Override
121     public void handleCommand(ChannelUID channelUID, Command command) {
122         if (command instanceof RefreshType) {
123             try {
124                 updateGeneratorThings();
125             } catch (IOException | SessionExpiredException e) {
126                 logger.debug("Could refresh things", e);
127             }
128         }
129     }
130
131     @Override
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);
138             return;
139         }
140         try {
141             updateGeneratorThing(childHandler, apparatus);
142         } catch (IOException | SessionExpiredException e) {
143             logger.debug("Could not initialize child", e);
144         }
145     }
146
147     private synchronized void stopOrRestartPoll(boolean restart) {
148         Future<?> pollFuture = this.pollFuture;
149         if (pollFuture != null) {
150             pollFuture.cancel(true);
151             this.pollFuture = null;
152         }
153         if (restart) {
154             this.pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 1, refreshIntervalSeconds, TimeUnit.SECONDS);
155         }
156     }
157
158     private void poll() {
159         try {
160             if (!loggedIn) {
161                 login();
162             }
163             loggedIn = true;
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");
173             loggedIn = false;
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");
178             loggedIn = false;
179             // we don't want to continue polling with bad credentials
180             stopOrRestartPoll(false);
181         }
182     }
183
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");
188             return;
189         }
190         if (getThing().getStatus() != ThingStatus.ONLINE) {
191             updateStatus(ThingStatus.ONLINE);
192         }
193         for (Apparatus apparatus : apparatuses) {
194             if (apparatus.type != 0) {
195                 logger.debug("Unknown apparatus type {} {}", apparatus.type, apparatus.name);
196                 continue;
197             }
198
199             String id = String.valueOf(apparatus.apparatusId);
200             apparatusesCache.put(id, apparatus);
201
202             Optional<Thing> thing = getThing().getThings().stream().filter(
203                     t -> t.getConfiguration().as(GeneracMobileLinkGeneratorConfiguration.class).generatorId.equals(id))
204                     .findFirst();
205             if (thing.isEmpty()) {
206                 discoveryService.generatorDiscovered(apparatus, getThing().getUID());
207             } else {
208                 ThingHandler handler = thing.get().getHandler();
209                 if (handler != null) {
210                     updateGeneratorThing(handler, apparatus);
211                 }
212             }
213         }
214     }
215
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);
221         } else {
222             logger.debug("Could not decode apparatuses detail response");
223         }
224     }
225
226     private @Nullable <T> T getEndpoint(Class<T> clazz, String endpoint) throws IOException, SessionExpiredException {
227         try {
228             ContentResponse response = httpClient.newRequest(API_BASE + endpoint).send();
229             if (response.getStatus() == 204) {
230                 // no data
231                 return null;
232             }
233             if (response.getStatus() != 200) {
234                 throw new SessionExpiredException("API returned status code: " + response.getStatus());
235             }
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);
244         }
245     }
246
247     /**
248      * Attempts to login through a Microsoft Azure implicit grant oauth flow
249      *
250      * @throws IOException if there is a problem communicating or parsing the responses
251      * @throws InvalidCredentialsException If Azure rejects the login credentials.
252      */
253     private synchronized void login() throws IOException, InvalidCredentialsException {
254         logger.debug("Attempting login");
255         GeneracMobileLinkAccountConfiguration config = getConfigAs(GeneracMobileLinkAccountConfiguration.class);
256         refreshIntervalSeconds = config.refreshInterval;
257         try {
258             ContentResponse signInResponse = httpClient.newRequest(API_BASE + "/Auth/SignIn?email=" + config.username)
259                     .send();
260
261             String responseData = signInResponse.getContentAsString();
262             logger.trace("response data: {}", responseData);
263
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)) {
267                 return;
268             }
269
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");
274             }
275
276             String parseSettings = matcher.group(1);
277             logger.debug("parseSettings: {}", parseSettings);
278             SignInConfig signInConfig = GSON.fromJson(parseSettings, SignInConfig.class);
279
280             if (signInConfig == null) {
281                 throw new IOException("Could not parse settings string");
282             }
283
284             Fields fields = new Fields();
285             fields.put("request_type", "RESPONSE");
286             fields.put("signInName", config.username);
287             fields.put("password", config.password);
288
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));
293
294             ContentResponse selfAssertedResponse = selfAssertedRequest.send();
295
296             logger.debug("selfAssertedRequest response {}", selfAssertedResponse.getStatus());
297
298             if (selfAssertedResponse.getStatus() != 200) {
299                 throw new IOException("SelfAsserted: Bad response status: " + selfAssertedResponse.getStatus());
300             }
301
302             SelfAssertedResponse sa = GSON.fromJson(selfAssertedResponse.getContentAsString(),
303                     SelfAssertedResponse.class);
304
305             if (sa == null) {
306                 throw new IOException("SelfAsserted Could not parse response JSON");
307             }
308
309             if (!"200".equals(sa.status)) {
310                 throw new InvalidCredentialsException("Invalid Credentials: " + sa.message);
311             }
312
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");
316
317             ContentResponse confirmedResponse = confirmedRequest.send();
318
319             if (confirmedResponse.getStatus() != 200) {
320                 throw new IOException("CombinedSigninAndSignup bad response: " + confirmedResponse.getStatus());
321             }
322
323             String loginString = confirmedResponse.getContentAsString();
324             logger.trace("confirmedResponse: {}", loginString);
325             if (!submitPage(loginString)) {
326                 throw new IOException("Error parsing HTML submit form");
327             }
328         } catch (InterruptedException e) {
329             Thread.currentThread().interrupt();
330             throw new IOException(e);
331         } catch (ExecutionException | TimeoutException | JsonSyntaxException e) {
332             throw new IOException(e);
333         }
334     }
335
336     /**
337      * Attempts to submit a HTML form from Azure to the Generac API, returns false if the HTML does not match the
338      * required form
339      *
340      * @param loginString
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
347      */
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();
354
355         if (form == null || loginState == null || loginCode == null) {
356             logger.debug("Could not load login page");
357             return false;
358         }
359
360         // url that the form will submit to
361         String action = form.attr("action");
362
363         Fields fields = new Fields();
364         fields.put("state", loginState.attr("value"));
365         fields.put("code", loginCode.attr("value"));
366
367         Request loginRequest = httpClient.POST(action).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
368                 .content(new FormContentProvider(fields));
369
370         ContentResponse loginResponse = loginRequest.send();
371         if (logger.isTraceEnabled()) {
372             logger.trace("login response {} {}", loginResponse.getStatus(), loginResponse.getContentAsString());
373         } else {
374             logger.debug("login response status {}", loginResponse.getStatus());
375         }
376         if (loginResponse.getStatus() != 200) {
377             throw new IOException("Bad api login resposne: " + loginResponse.getStatus());
378         }
379         return true;
380     }
381
382     private class InvalidCredentialsException extends Exception {
383         private static final long serialVersionUID = 1L;
384
385         public InvalidCredentialsException(String message) {
386             super(message);
387         }
388     }
389
390     private class SessionExpiredException extends Exception {
391         private static final long serialVersionUID = 1L;
392
393         public SessionExpiredException(String message) {
394             super(message);
395         }
396     }
397 }