]> git.basschouten.com Git - openhab-addons.git/blob
20a158543b4831cf42094a625c8a489ea72c2eeb
[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.linky.internal.api;
14
15 import java.net.HttpCookie;
16 import java.net.URI;
17 import java.time.LocalDate;
18 import java.time.format.DateTimeFormatter;
19 import java.util.Objects;
20 import java.util.concurrent.ExecutionException;
21 import java.util.concurrent.TimeoutException;
22 import java.util.regex.Matcher;
23 import java.util.regex.Pattern;
24
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jetty.client.HttpClient;
27 import org.eclipse.jetty.client.api.ContentResponse;
28 import org.eclipse.jetty.client.util.FormContentProvider;
29 import org.eclipse.jetty.client.util.StringContentProvider;
30 import org.eclipse.jetty.http.HttpHeader;
31 import org.eclipse.jetty.util.Fields;
32 import org.jsoup.Jsoup;
33 import org.jsoup.nodes.Document;
34 import org.jsoup.nodes.Element;
35 import org.openhab.binding.linky.internal.LinkyConfiguration;
36 import org.openhab.binding.linky.internal.LinkyException;
37 import org.openhab.binding.linky.internal.dto.AuthData;
38 import org.openhab.binding.linky.internal.dto.AuthResult;
39 import org.openhab.binding.linky.internal.dto.ConsumptionReport;
40 import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption;
41 import org.openhab.binding.linky.internal.dto.PrmInfo;
42 import org.openhab.binding.linky.internal.dto.UserInfo;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
45
46 import com.google.gson.Gson;
47 import com.google.gson.JsonSyntaxException;
48
49 /**
50  * {@link EnedisHttpApi} wraps the Enedis Webservice.
51  *
52  * @author GaĆ«l L'hopital - Initial contribution
53  */
54 @NonNullByDefault
55 public class EnedisHttpApi {
56     private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
57     private static final String ENEDIS_DOMAIN = ".enedis.fr";
58     private static final String URL_APPS_LINCS = "https://apps.lincs" + ENEDIS_DOMAIN;
59     private static final String URL_MON_COMPTE = "https://mon-compte" + ENEDIS_DOMAIN;
60     private static final String URL_COMPTE_PART = URL_MON_COMPTE.replace("compte", "compte-particulier");
61     private static final String URL_ENEDIS_AUTHENTICATE = URL_APPS_LINCS + "/authenticate?target=" + URL_COMPTE_PART;
62     private static final String USER_INFO_URL = URL_APPS_LINCS + "/userinfos";
63     private static final String PRM_INFO_BASE_URL = URL_APPS_LINCS + "/mes-mesures/api/private/v1/personnes/";
64     private static final String PRM_INFO_URL = PRM_INFO_BASE_URL + "null/prms";
65     private static final String MEASURE_URL = PRM_INFO_BASE_URL
66             + "%s/prms/%s/donnees-%s?dateDebut=%s&dateFin=%s&mesuretypecode=CONS";
67     private static final URI COOKIE_URI = URI.create(URL_COMPTE_PART);
68     private static final Pattern REQ_PATTERN = Pattern.compile("ReqID%(.*?)%26");
69
70     private final Logger logger = LoggerFactory.getLogger(EnedisHttpApi.class);
71     private final Gson gson;
72     private final HttpClient httpClient;
73     private final LinkyConfiguration config;
74
75     private boolean connected = false;
76
77     public EnedisHttpApi(LinkyConfiguration config, Gson gson, HttpClient httpClient) {
78         this.gson = gson;
79         this.httpClient = httpClient;
80         this.config = config;
81     }
82
83     public void initialize() throws LinkyException {
84         logger.debug("Starting login process for user : {}", config.username);
85
86         try {
87             addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, config.internalAuthId);
88             logger.debug("Step 1 : getting authentification");
89             String data = getData(URL_ENEDIS_AUTHENTICATE);
90
91             logger.debug("Reception request SAML");
92             Document htmlDocument = Jsoup.parse(data);
93             Element el = htmlDocument.select("form").first();
94             Element samlInput = el.select("input[name=SAMLRequest]").first();
95
96             logger.debug("Step 2 : send SSO SAMLRequest");
97             ContentResponse result = httpClient.POST(el.attr("action"))
98                     .content(getFormContent("SAMLRequest", samlInput.attr("value"))).send();
99             if (result.getStatus() != 302) {
100                 throw new LinkyException("Connection failed step 2");
101             }
102
103             logger.debug("Get the location and the ReqID");
104             Matcher m = REQ_PATTERN.matcher(getLocation(result));
105             if (!m.find()) {
106                 throw new LinkyException("Unable to locate ReqId in header");
107             }
108
109             String reqId = m.group(1);
110             String authenticateUrl = URL_MON_COMPTE
111                     + "/auth/json/authenticate?realm=/enedis&forward=true&spEntityID=SP-ODW-PROD&goto=/auth/SSOPOST/metaAlias/enedis/providerIDP?ReqID%"
112                     + reqId + "%26index%3Dnull%26acsURL%3D" + URL_APPS_LINCS
113                     + "/saml/SSO%26spEntityID%3DSP-ODW-PROD%26binding%3Durn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST&AMAuthCookie=";
114
115             logger.debug("Step 3 : auth1 - retrieve the template, thanks to cookie internalAuthId user is already set");
116             result = httpClient.POST(authenticateUrl).header("X-NoSession", "true").header("X-Password", "anonymous")
117                     .header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous").send();
118             if (result.getStatus() != 200) {
119                 throw new LinkyException("Connection failed step 3 - auth1 : %s", result.getContentAsString());
120             }
121
122             AuthData authData = gson.fromJson(result.getContentAsString(), AuthData.class);
123             if (authData == null || authData.callbacks.size() < 2 || authData.callbacks.get(0).input.isEmpty()
124                     || authData.callbacks.get(1).input.isEmpty() || !config.username
125                             .equals(Objects.requireNonNull(authData.callbacks.get(0).input.get(0)).valueAsString())) {
126                 logger.debug("auth1 - invalid template for auth data: {}", result.getContentAsString());
127                 throw new LinkyException("Authentication error, the authentication_cookie is probably wrong");
128             }
129
130             authData.callbacks.get(1).input.get(0).value = config.password;
131             logger.debug("Step 4 : auth2 - send the auth data");
132             result = httpClient.POST(authenticateUrl).header(HttpHeader.CONTENT_TYPE, "application/json")
133                     .header("X-NoSession", "true").header("X-Password", "anonymous")
134                     .header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous")
135                     .content(new StringContentProvider(gson.toJson(authData))).send();
136             if (result.getStatus() != 200) {
137                 throw new LinkyException("Connection failed step 3 - auth2 : %s", result.getContentAsString());
138             }
139
140             AuthResult authResult = gson.fromJson(result.getContentAsString(), AuthResult.class);
141             if (authResult == null) {
142                 throw new LinkyException("Invalid authentication result data");
143             }
144
145             logger.debug("Add the tokenId cookie");
146             addCookie("enedisExt", authResult.tokenId);
147
148             logger.debug("Step 5 : retrieve the SAMLresponse");
149             data = getData(URL_MON_COMPTE + "/" + authResult.successUrl);
150             htmlDocument = Jsoup.parse(data);
151             el = htmlDocument.select("form").first();
152             samlInput = el.select("input[name=SAMLResponse]").first();
153
154             logger.debug("Step 6 : post the SAMLresponse to finish the authentication");
155             result = httpClient.POST(el.attr("action")).content(getFormContent("SAMLResponse", samlInput.attr("value")))
156                     .send();
157             if (result.getStatus() != 302) {
158                 throw new LinkyException("Connection failed step 6");
159             }
160             connected = true;
161         } catch (InterruptedException | TimeoutException | ExecutionException | JsonSyntaxException e) {
162             throw new LinkyException(e, "Error opening connection with Enedis webservice");
163         }
164     }
165
166     private String getLocation(ContentResponse response) {
167         return response.getHeaders().get(HttpHeader.LOCATION);
168     }
169
170     private void disconnect() throws LinkyException {
171         if (connected) {
172             logger.debug("Logout process");
173             connected = false;
174             try { // Three times in a row to get disconnected
175                 String location = getLocation(httpClient.GET(URL_APPS_LINCS + "/logout"));
176                 location = getLocation(httpClient.GET(location));
177                 getLocation(httpClient.GET(location));
178                 httpClient.getCookieStore().removeAll();
179             } catch (InterruptedException | ExecutionException | TimeoutException e) {
180                 throw new LinkyException(e, "Error while disconnecting from Enedis webservice");
181             }
182         }
183     }
184
185     public boolean isConnected() {
186         return connected;
187     }
188
189     public void dispose() throws LinkyException {
190         disconnect();
191     }
192
193     private void addCookie(String key, String value) {
194         HttpCookie cookie = new HttpCookie(key, value);
195         cookie.setDomain(ENEDIS_DOMAIN);
196         cookie.setPath("/");
197         httpClient.getCookieStore().add(COOKIE_URI, cookie);
198     }
199
200     private FormContentProvider getFormContent(String fieldName, String fieldValue) {
201         Fields fields = new Fields();
202         fields.put(fieldName, fieldValue);
203         return new FormContentProvider(fields);
204     }
205
206     private String getData(String url) throws LinkyException {
207         try {
208             ContentResponse result = httpClient.GET(url);
209             if (result.getStatus() != 200) {
210                 throw new LinkyException("Error requesting '%s' : %s", url, result.getContentAsString());
211             }
212             return result.getContentAsString();
213         } catch (InterruptedException | ExecutionException | TimeoutException e) {
214             throw new LinkyException(e, "Error getting url : '%s'", url);
215         }
216     }
217
218     public PrmInfo getPrmInfo() throws LinkyException {
219         if (!connected) {
220             initialize();
221         }
222         String data = getData(PRM_INFO_URL);
223         if (data.isEmpty()) {
224             throw new LinkyException("Requesting '%s' returned an empty response", PRM_INFO_URL);
225         }
226         try {
227             PrmInfo[] prms = gson.fromJson(data, PrmInfo[].class);
228             if (prms == null || prms.length < 1) {
229                 throw new LinkyException("Invalid prms data received");
230             }
231             return prms[0];
232         } catch (JsonSyntaxException e) {
233             logger.debug("invalid JSON response not matching PrmInfo[].class: {}", data);
234             throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", PRM_INFO_URL);
235         }
236     }
237
238     public UserInfo getUserInfo() throws LinkyException {
239         if (!connected) {
240             initialize();
241         }
242         String data = getData(USER_INFO_URL);
243         if (data.isEmpty()) {
244             throw new LinkyException("Requesting '%s' returned an empty response", USER_INFO_URL);
245         }
246         try {
247             return Objects.requireNonNull(gson.fromJson(data, UserInfo.class));
248         } catch (JsonSyntaxException e) {
249             logger.debug("invalid JSON response not matching UserInfo.class: {}", data);
250             throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", USER_INFO_URL);
251         }
252     }
253
254     private Consumption getMeasures(String userId, String prmId, LocalDate from, LocalDate to, String request)
255             throws LinkyException {
256         String url = String.format(MEASURE_URL, userId, prmId, request, from.format(API_DATE_FORMAT),
257                 to.format(API_DATE_FORMAT));
258         if (!connected) {
259             initialize();
260         }
261         String data = getData(url);
262         if (data.isEmpty()) {
263             throw new LinkyException("Requesting '%s' returned an empty response", url);
264         }
265         logger.trace("getData returned {}", data);
266         try {
267             ConsumptionReport report = gson.fromJson(data, ConsumptionReport.class);
268             if (report == null) {
269                 throw new LinkyException("No report data received");
270             }
271             return report.firstLevel.consumptions;
272         } catch (JsonSyntaxException e) {
273             logger.debug("invalid JSON response not matching ConsumptionReport.class: {}", data);
274             throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url);
275         }
276     }
277
278     public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
279         return getMeasures(userId, prmId, from, to, "energie");
280     }
281
282     public Consumption getPowerData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
283         return getMeasures(userId, prmId, from, to, "pmax");
284     }
285 }