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.linky.internal.api;
15 import java.net.HttpCookie;
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;
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;
46 import com.google.gson.Gson;
47 import com.google.gson.JsonSyntaxException;
50 * {@link EnedisHttpApi} wraps the Enedis Webservice.
52 * @author Gaƫl L'hopital - Initial contribution
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");
70 private final Logger logger = LoggerFactory.getLogger(EnedisHttpApi.class);
71 private final Gson gson;
72 private final HttpClient httpClient;
73 private final LinkyConfiguration config;
75 private boolean connected = false;
77 public EnedisHttpApi(LinkyConfiguration config, Gson gson, HttpClient httpClient) {
79 this.httpClient = httpClient;
83 public void initialize() throws LinkyException {
84 logger.debug("Starting login process for user : {}", config.username);
87 addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, config.internalAuthId);
88 logger.debug("Step 1 : getting authentification");
89 String data = getData(URL_ENEDIS_AUTHENTICATE);
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();
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");
103 logger.debug("Get the location and the ReqID");
104 Matcher m = REQ_PATTERN.matcher(getLocation(result));
106 throw new LinkyException("Unable to locate ReqId in header");
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=";
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());
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");
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());
140 AuthResult authResult = gson.fromJson(result.getContentAsString(), AuthResult.class);
141 if (authResult == null) {
142 throw new LinkyException("Invalid authentication result data");
145 logger.debug("Add the tokenId cookie");
146 addCookie("enedisExt", authResult.tokenId);
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();
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")))
157 if (result.getStatus() != 302) {
158 throw new LinkyException("Connection failed step 6");
161 } catch (InterruptedException | TimeoutException | ExecutionException | JsonSyntaxException e) {
162 throw new LinkyException(e, "Error opening connection with Enedis webservice");
166 private String getLocation(ContentResponse response) {
167 return response.getHeaders().get(HttpHeader.LOCATION);
170 private void disconnect() throws LinkyException {
172 logger.debug("Logout process");
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");
185 public boolean isConnected() {
189 public void dispose() throws LinkyException {
193 private void addCookie(String key, String value) {
194 HttpCookie cookie = new HttpCookie(key, value);
195 cookie.setDomain(ENEDIS_DOMAIN);
197 httpClient.getCookieStore().add(COOKIE_URI, cookie);
200 private FormContentProvider getFormContent(String fieldName, String fieldValue) {
201 Fields fields = new Fields();
202 fields.put(fieldName, fieldValue);
203 return new FormContentProvider(fields);
206 private String getData(String url) throws LinkyException {
208 ContentResponse result = httpClient.GET(url);
209 if (result.getStatus() != 200) {
210 throw new LinkyException("Error requesting '%s' : %s", url, result.getContentAsString());
212 return result.getContentAsString();
213 } catch (InterruptedException | ExecutionException | TimeoutException e) {
214 throw new LinkyException(e, "Error getting url : '%s'", url);
218 public PrmInfo getPrmInfo() throws LinkyException {
222 String data = getData(PRM_INFO_URL);
223 if (data.isEmpty()) {
224 throw new LinkyException("Requesting '%s' returned an empty response", PRM_INFO_URL);
227 PrmInfo[] prms = gson.fromJson(data, PrmInfo[].class);
228 if (prms == null || prms.length < 1) {
229 throw new LinkyException("Invalid prms data received");
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);
238 public UserInfo getUserInfo() throws LinkyException {
242 String data = getData(USER_INFO_URL);
243 if (data.isEmpty()) {
244 throw new LinkyException("Requesting '%s' returned an empty response", USER_INFO_URL);
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);
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));
261 String data = getData(url);
262 if (data.isEmpty()) {
263 throw new LinkyException("Requesting '%s' returned an empty response", url);
265 logger.trace("getData returned {}", data);
267 ConsumptionReport report = gson.fromJson(data, ConsumptionReport.class);
268 if (report == null) {
269 throw new LinkyException("No report data received");
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);
278 public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
279 return getMeasures(userId, prmId, from, to, "energie");
282 public Consumption getPowerData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
283 return getMeasures(userId, prmId, from, to, "pmax");