Skip to content

Commit 7cab153

Browse files
authored
[linky] Yet another website underlaying API modification (openhab#17538)
* Yet another website underlaying API modification * Correction for current and previous week, month, year * Added unitHing * Switch peek power to kVA * Adding new cookie and user agent Signed-off-by: Gaël L'hopital <[email protected]>
1 parent f0d4a0a commit 7cab153

File tree

9 files changed

+223
-144
lines changed

9 files changed

+223
-144
lines changed

bundles/org.openhab.binding.linky/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ In case you are running openHAB inside Docker, the binding will work only if you
8383
### Thing
8484

8585
```java
86-
Thing linky:linky:local "Compteur Linky" [ username="[email protected]", password="******" ]
86+
Thing linky:linky:local "Compteur Linky" [ username="[email protected]", password="******", internalAuthId="******" ]
8787
```
8888

8989
### Items

bundles/org.openhab.binding.linky/pom.xml

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
<name>openHAB Add-ons :: Bundles :: Linky Binding</name>
1616

17+
<properties>
18+
<bnd.importpackage>javax.annotation.meta;resolution:=optional</bnd.importpackage>
19+
</properties>
20+
1721
<dependencies>
1822
<dependency>
1923
<groupId>org.jsoup</groupId>

bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyException.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ public LinkyException(Exception e, String message) {
3636
}
3737

3838
public LinkyException(String message, Object... params) {
39-
this(String.format(message, params));
39+
this(message.formatted(params));
4040
}
4141

4242
public LinkyException(Exception e, String message, Object... params) {
43-
this(e, String.format(message, params));
43+
this(e, message.formatted(params));
4444
}
4545
}

bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java

+2
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
public class LinkyHandlerFactory extends BaseThingHandlerFactory {
5757
private static final DateTimeFormatter LINKY_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX");
5858
private static final int REQUEST_BUFFER_SIZE = 8000;
59+
private static final int RESPONSE_BUFFER_SIZE = 200000;
5960

6061
private final Logger logger = LoggerFactory.getLogger(LinkyHandlerFactory.class);
6162
private final Gson gson = new GsonBuilder().registerTypeAdapter(ZonedDateTime.class,
@@ -83,6 +84,7 @@ public LinkyHandlerFactory(final @Reference LocaleProvider localeProvider,
8384
this.httpClient = httpClientFactory.createHttpClient(LinkyBindingConstants.BINDING_ID, sslContextFactory);
8485
httpClient.setFollowRedirects(false);
8586
httpClient.setRequestBufferSize(REQUEST_BUFFER_SIZE);
87+
httpClient.setResponseBufferSize(RESPONSE_BUFFER_SIZE);
8688
}
8789

8890
@Override

bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/EnedisHttpApi.java

+84-64
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,26 @@
1616
import java.net.URI;
1717
import java.time.LocalDate;
1818
import java.time.format.DateTimeFormatter;
19+
import java.util.HashMap;
20+
import java.util.List;
1921
import java.util.Objects;
22+
import java.util.Optional;
2023
import java.util.concurrent.ExecutionException;
2124
import java.util.concurrent.TimeoutException;
2225
import java.util.regex.Matcher;
2326
import java.util.regex.Pattern;
2427

28+
import javax.ws.rs.core.MediaType;
29+
2530
import org.eclipse.jdt.annotation.NonNullByDefault;
2631
import org.eclipse.jetty.client.HttpClient;
2732
import org.eclipse.jetty.client.api.ContentResponse;
33+
import org.eclipse.jetty.client.api.Request;
2834
import org.eclipse.jetty.client.util.FormContentProvider;
2935
import org.eclipse.jetty.client.util.StringContentProvider;
3036
import org.eclipse.jetty.http.HttpHeader;
37+
import org.eclipse.jetty.http.HttpMethod;
38+
import org.eclipse.jetty.http.HttpStatus;
3139
import org.eclipse.jetty.util.Fields;
3240
import org.jsoup.Jsoup;
3341
import org.jsoup.nodes.Document;
@@ -38,6 +46,7 @@
3846
import org.openhab.binding.linky.internal.dto.AuthResult;
3947
import org.openhab.binding.linky.internal.dto.ConsumptionReport;
4048
import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption;
49+
import org.openhab.binding.linky.internal.dto.PrmDetail;
4150
import org.openhab.binding.linky.internal.dto.PrmInfo;
4251
import org.openhab.binding.linky.internal.dto.UserInfo;
4352
import org.slf4j.Logger;
@@ -59,9 +68,10 @@ public class EnedisHttpApi {
5968
private static final String URL_MON_COMPTE = "https://mon-compte" + ENEDIS_DOMAIN;
6069
private static final String URL_COMPTE_PART = URL_MON_COMPTE.replace("compte", "compte-particulier");
6170
private static final String URL_ENEDIS_AUTHENTICATE = URL_APPS_LINCS + "/authenticate?target=" + URL_COMPTE_PART;
71+
private static final String USER_INFO_CONTRACT_URL = URL_APPS_LINCS + "/mon-compte-client/api/private/v1/userinfos";
6272
private static final String USER_INFO_URL = URL_APPS_LINCS + "/userinfos";
6373
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";
74+
private static final String PRM_INFO_URL = URL_APPS_LINCS + "/mes-prms/api/private/v2/personnes/%s/prms";
6575
private static final String MEASURE_URL = PRM_INFO_BASE_URL
6676
+ "%s/prms/%s/donnees-%s?dateDebut=%s&dateFin=%s&mesuretypecode=CONS";
6777
private static final URI COOKIE_URI = URI.create(URL_COMPTE_PART);
@@ -81,22 +91,22 @@ public EnedisHttpApi(LinkyConfiguration config, Gson gson, HttpClient httpClient
8191
}
8292

8393
public void initialize() throws LinkyException {
84-
logger.debug("Starting login process for user : {}", config.username);
94+
logger.debug("Starting login process for user: {}", config.username);
8595

8696
try {
8797
addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, config.internalAuthId);
88-
logger.debug("Step 1 : getting authentification");
89-
String data = getData(URL_ENEDIS_AUTHENTICATE);
98+
logger.debug("Step 1: getting authentification");
99+
String data = getContent(URL_ENEDIS_AUTHENTICATE);
90100

91101
logger.debug("Reception request SAML");
92102
Document htmlDocument = Jsoup.parse(data);
93103
Element el = htmlDocument.select("form").first();
94104
Element samlInput = el.select("input[name=SAMLRequest]").first();
95105

96-
logger.debug("Step 2 : send SSO SAMLRequest");
106+
logger.debug("Step 2: send SSO SAMLRequest");
97107
ContentResponse result = httpClient.POST(el.attr("action"))
98108
.content(getFormContent("SAMLRequest", samlInput.attr("value"))).send();
99-
if (result.getStatus() != 302) {
109+
if (result.getStatus() != HttpStatus.FOUND_302) {
100110
throw new LinkyException("Connection failed step 2");
101111
}
102112

@@ -112,11 +122,11 @@ public void initialize() throws LinkyException {
112122
+ reqId + "%26index%3Dnull%26acsURL%3D" + URL_APPS_LINCS
113123
+ "/saml/SSO%26spEntityID%3DSP-ODW-PROD%26binding%3Durn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST&AMAuthCookie=";
114124

115-
logger.debug("Step 3 : auth1 - retrieve the template, thanks to cookie internalAuthId user is already set");
125+
logger.debug("Step 3: auth1 - retrieve the template, thanks to cookie internalAuthId user is already set");
116126
result = httpClient.POST(authenticateUrl).header("X-NoSession", "true").header("X-Password", "anonymous")
117127
.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());
128+
if (result.getStatus() != HttpStatus.OK_200) {
129+
throw new LinkyException("Connection failed step 3 - auth1: %s", result.getContentAsString());
120130
}
121131

122132
AuthData authData = gson.fromJson(result.getContentAsString(), AuthData.class);
@@ -128,13 +138,13 @@ public void initialize() throws LinkyException {
128138
}
129139

130140
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")
141+
logger.debug("Step 4: auth2 - send the auth data");
142+
result = httpClient.POST(authenticateUrl).header(HttpHeader.CONTENT_TYPE, MediaType.APPLICATION_JSON)
133143
.header("X-NoSession", "true").header("X-Password", "anonymous")
134144
.header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous")
135145
.content(new StringContentProvider(gson.toJson(authData))).send();
136-
if (result.getStatus() != 200) {
137-
throw new LinkyException("Connection failed step 3 - auth2 : %s", result.getContentAsString());
146+
if (result.getStatus() != HttpStatus.OK_200) {
147+
throw new LinkyException("Connection failed step 3 - auth2: %s", result.getContentAsString());
138148
}
139149

140150
AuthResult authResult = gson.fromJson(result.getContentAsString(), AuthResult.class);
@@ -145,18 +155,40 @@ public void initialize() throws LinkyException {
145155
logger.debug("Add the tokenId cookie");
146156
addCookie("enedisExt", authResult.tokenId);
147157

148-
logger.debug("Step 5 : retrieve the SAMLresponse");
149-
data = getData(URL_MON_COMPTE + "/" + authResult.successUrl);
158+
logger.debug("Step 5: retrieve the SAMLresponse");
159+
data = getContent(URL_MON_COMPTE + "/" + authResult.successUrl);
150160
htmlDocument = Jsoup.parse(data);
151161
el = htmlDocument.select("form").first();
152162
samlInput = el.select("input[name=SAMLResponse]").first();
153163

154-
logger.debug("Step 6 : post the SAMLresponse to finish the authentication");
164+
logger.debug("Step 6: post the SAMLresponse to finish the authentication");
155165
result = httpClient.POST(el.attr("action")).content(getFormContent("SAMLResponse", samlInput.attr("value")))
156166
.send();
157-
if (result.getStatus() != 302) {
167+
if (result.getStatus() != HttpStatus.FOUND_302) {
158168
throw new LinkyException("Connection failed step 6");
159169
}
170+
171+
logger.debug("Step 7: retrieve cookieKey");
172+
result = httpClient.GET(USER_INFO_CONTRACT_URL);
173+
174+
@SuppressWarnings("unchecked")
175+
HashMap<String, String> hashRes = gson.fromJson(result.getContentAsString(), HashMap.class);
176+
177+
String cookieKey;
178+
if (hashRes != null && hashRes.containsKey("cnAlex")) {
179+
cookieKey = "personne_for_" + hashRes.get("cnAlex");
180+
} else {
181+
throw new LinkyException("Connection failed step 7, missing cookieKey");
182+
}
183+
184+
List<HttpCookie> lCookie = httpClient.getCookieStore().getCookies();
185+
Optional<HttpCookie> cookie = lCookie.stream().filter(it -> it.getName().contains(cookieKey)).findFirst();
186+
187+
String cookieVal = cookie.map(HttpCookie::getValue)
188+
.orElseThrow(() -> new LinkyException("Connection failed step 7, missing cookieVal"));
189+
190+
addCookie(cookieKey, cookieVal);
191+
160192
connected = true;
161193
} catch (InterruptedException | TimeoutException | ExecutionException | JsonSyntaxException e) {
162194
throw new LinkyException(e, "Error opening connection with Enedis webservice");
@@ -203,76 +235,64 @@ private FormContentProvider getFormContent(String fieldName, String fieldValue)
203235
return new FormContentProvider(fields);
204236
}
205237

206-
private String getData(String url) throws LinkyException {
238+
private String getContent(String url) throws LinkyException {
207239
try {
208-
ContentResponse result = httpClient.GET(url);
209-
if (result.getStatus() != 200) {
210-
throw new LinkyException("Error requesting '%s' : %s", url, result.getContentAsString());
240+
Request request = httpClient.newRequest(url)
241+
.agent("Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0");
242+
request = request.method(HttpMethod.GET);
243+
ContentResponse result = request.send();
244+
if (result.getStatus() != HttpStatus.OK_200) {
245+
throw new LinkyException("Error requesting '%s': %s", url, result.getContentAsString());
211246
}
212-
return result.getContentAsString();
247+
String content = result.getContentAsString();
248+
logger.trace("getContent returned {}", content);
249+
return content;
213250
} catch (InterruptedException | ExecutionException | TimeoutException e) {
214-
throw new LinkyException(e, "Error getting url : '%s'", url);
251+
throw new LinkyException(e, "Error getting url: '%s'", url);
215252
}
216253
}
217254

218-
public PrmInfo getPrmInfo() throws LinkyException {
255+
private <T> T getData(String url, Class<T> clazz) throws LinkyException {
219256
if (!connected) {
220257
initialize();
221258
}
222-
String data = getData(PRM_INFO_URL);
259+
String data = getContent(url);
223260
if (data.isEmpty()) {
224-
throw new LinkyException("Requesting '%s' returned an empty response", PRM_INFO_URL);
261+
throw new LinkyException("Requesting '%s' returned an empty response", url);
225262
}
226263
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];
264+
return Objects.requireNonNull(gson.fromJson(data, clazz));
232265
} 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);
266+
logger.debug("Invalid JSON response not matching {}: {}", clazz.getName(), data);
267+
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url);
235268
}
236269
}
237270

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);
271+
public PrmInfo getPrmInfo(String internId) throws LinkyException {
272+
String url = PRM_INFO_URL.formatted(internId);
273+
PrmInfo[] prms = getData(url, PrmInfo[].class);
274+
if (prms.length < 1) {
275+
throw new LinkyException("Invalid prms data received");
251276
}
277+
return prms[0];
278+
}
279+
280+
public PrmDetail getPrmDetails(String internId, String prmId) throws LinkyException {
281+
String url = PRM_INFO_URL.formatted(internId) + "/" + prmId
282+
+ "?embed=SITALI&embed=SITCOM&embed=SITCON&embed=SYNCON";
283+
return getData(url, PrmDetail.class);
284+
}
285+
286+
public UserInfo getUserInfo() throws LinkyException {
287+
return getData(USER_INFO_URL, UserInfo.class);
252288
}
253289

254290
private Consumption getMeasures(String userId, String prmId, LocalDate from, LocalDate to, String request)
255291
throws LinkyException {
256292
String url = String.format(MEASURE_URL, userId, prmId, request, from.format(API_DATE_FORMAT),
257293
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-
}
294+
ConsumptionReport report = getData(url, ConsumptionReport.class);
295+
return report.firstLevel.consumptions;
276296
}
277297

278298
public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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.dto;
14+
15+
import java.util.ArrayList;
16+
17+
/**
18+
* The {@link PrmDetail} holds detailed informations about prm configuration
19+
*
20+
* @author Gaël L'hopital - Initial contribution
21+
*/
22+
public class PrmDetail {
23+
public record Adresse(String ligne2, String ligne3, String ligne4, String ligne5, String ligne6) {
24+
}
25+
26+
public record DicEntry(String code, String libelle) {
27+
}
28+
29+
public record Measure(String unite, String valeur) {
30+
}
31+
32+
public record AlimentationPrincipale(Object puissanceRaccordementInjection,
33+
Measure puissanceRaccordementSoutirage) {
34+
}
35+
36+
public record Compteur(boolean accessibilite, boolean ticActivee, boolean ticStandard) {
37+
}
38+
39+
public record Contrat(DicEntry typeContrat, String referenceContrat) {
40+
}
41+
42+
public record Disjoncteur(DicEntry calibre) {
43+
}
44+
45+
public record DispositifComptage(DicEntry typeComptage) {
46+
}
47+
48+
public record GrilleFournisseur(DicEntry calendrier, Object classeTemporelle) {
49+
}
50+
51+
public record InformationsContractuelles(Contrat contrat, DicEntry etatContractuel, SiContractuel siContractuel) {
52+
}
53+
54+
public record SiContractuel(DicEntry application) {
55+
}
56+
57+
public record SituationAlimentationDto(AlimentationPrincipale alimentationPrincipale) {
58+
}
59+
60+
public record SituationComptageDto(ArrayList<Compteur> compteurs, Disjoncteur disjoncteur,
61+
DispositifComptage dispositifComptage) {
62+
}
63+
64+
public record SituationContractuelleDto(InformationsContractuelles informationsContractuelles,
65+
StructureTarifaire structureTarifaire, String fournisseur, DicEntry segment) {
66+
}
67+
68+
public record StructureTarifaire(Measure puissanceSouscrite, GrilleFournisseur grilleFournisseur) {
69+
}
70+
71+
public record SyntheseContractuelleDto(DicEntry niveauOuvertureServices) {
72+
}
73+
74+
public Adresse adresse;
75+
public String segment;
76+
public SyntheseContractuelleDto syntheseContractuelleDto;
77+
public SituationContractuelleDto[] situationContractuelleDtos;
78+
public SituationAlimentationDto situationAlimentationDto;
79+
public SituationComptageDto situationComptageDto;
80+
}

0 commit comments

Comments
 (0)