-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[linky] Yet another website underlaying API modification #17538
Changes from all commits
94cba60
5c0c9f9
d8b7b5d
4fa8450
1d1cc21
952616a
fe8ff60
2b4e55c
46c6a66
8f59eb2
98f9317
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,18 +16,26 @@ | |
import java.net.URI; | ||
import java.time.LocalDate; | ||
import java.time.format.DateTimeFormatter; | ||
import java.util.HashMap; | ||
import java.util.List; | ||
import java.util.Objects; | ||
import java.util.Optional; | ||
import java.util.concurrent.ExecutionException; | ||
import java.util.concurrent.TimeoutException; | ||
import java.util.regex.Matcher; | ||
import java.util.regex.Pattern; | ||
|
||
import javax.ws.rs.core.MediaType; | ||
|
||
import org.eclipse.jdt.annotation.NonNullByDefault; | ||
import org.eclipse.jetty.client.HttpClient; | ||
import org.eclipse.jetty.client.api.ContentResponse; | ||
import org.eclipse.jetty.client.api.Request; | ||
import org.eclipse.jetty.client.util.FormContentProvider; | ||
import org.eclipse.jetty.client.util.StringContentProvider; | ||
import org.eclipse.jetty.http.HttpHeader; | ||
import org.eclipse.jetty.http.HttpMethod; | ||
import org.eclipse.jetty.http.HttpStatus; | ||
import org.eclipse.jetty.util.Fields; | ||
import org.jsoup.Jsoup; | ||
import org.jsoup.nodes.Document; | ||
|
@@ -38,6 +46,7 @@ | |
import org.openhab.binding.linky.internal.dto.AuthResult; | ||
import org.openhab.binding.linky.internal.dto.ConsumptionReport; | ||
import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption; | ||
import org.openhab.binding.linky.internal.dto.PrmDetail; | ||
import org.openhab.binding.linky.internal.dto.PrmInfo; | ||
import org.openhab.binding.linky.internal.dto.UserInfo; | ||
import org.slf4j.Logger; | ||
|
@@ -59,9 +68,10 @@ public class EnedisHttpApi { | |
private static final String URL_MON_COMPTE = "https://mon-compte" + ENEDIS_DOMAIN; | ||
private static final String URL_COMPTE_PART = URL_MON_COMPTE.replace("compte", "compte-particulier"); | ||
private static final String URL_ENEDIS_AUTHENTICATE = URL_APPS_LINCS + "/authenticate?target=" + URL_COMPTE_PART; | ||
private static final String USER_INFO_CONTRACT_URL = URL_APPS_LINCS + "/mon-compte-client/api/private/v1/userinfos"; | ||
private static final String USER_INFO_URL = URL_APPS_LINCS + "/userinfos"; | ||
private static final String PRM_INFO_BASE_URL = URL_APPS_LINCS + "/mes-mesures/api/private/v1/personnes/"; | ||
private static final String PRM_INFO_URL = PRM_INFO_BASE_URL + "null/prms"; | ||
private static final String PRM_INFO_URL = URL_APPS_LINCS + "/mes-prms/api/private/v2/personnes/%s/prms"; | ||
private static final String MEASURE_URL = PRM_INFO_BASE_URL | ||
+ "%s/prms/%s/donnees-%s?dateDebut=%s&dateFin=%s&mesuretypecode=CONS"; | ||
lolodomo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
private static final URI COOKIE_URI = URI.create(URL_COMPTE_PART); | ||
|
@@ -81,22 +91,22 @@ public EnedisHttpApi(LinkyConfiguration config, Gson gson, HttpClient httpClient | |
} | ||
|
||
public void initialize() throws LinkyException { | ||
logger.debug("Starting login process for user : {}", config.username); | ||
logger.debug("Starting login process for user: {}", config.username); | ||
|
||
try { | ||
addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, config.internalAuthId); | ||
logger.debug("Step 1 : getting authentification"); | ||
String data = getData(URL_ENEDIS_AUTHENTICATE); | ||
logger.debug("Step 1: getting authentification"); | ||
String data = getContent(URL_ENEDIS_AUTHENTICATE); | ||
|
||
logger.debug("Reception request SAML"); | ||
Document htmlDocument = Jsoup.parse(data); | ||
Element el = htmlDocument.select("form").first(); | ||
Element samlInput = el.select("input[name=SAMLRequest]").first(); | ||
|
||
logger.debug("Step 2 : send SSO SAMLRequest"); | ||
logger.debug("Step 2: send SSO SAMLRequest"); | ||
ContentResponse result = httpClient.POST(el.attr("action")) | ||
.content(getFormContent("SAMLRequest", samlInput.attr("value"))).send(); | ||
if (result.getStatus() != 302) { | ||
if (result.getStatus() != HttpStatus.FOUND_302) { | ||
throw new LinkyException("Connection failed step 2"); | ||
} | ||
|
||
|
@@ -112,11 +122,11 @@ public void initialize() throws LinkyException { | |
+ reqId + "%26index%3Dnull%26acsURL%3D" + URL_APPS_LINCS | ||
+ "/saml/SSO%26spEntityID%3DSP-ODW-PROD%26binding%3Durn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST&AMAuthCookie="; | ||
|
||
logger.debug("Step 3 : auth1 - retrieve the template, thanks to cookie internalAuthId user is already set"); | ||
logger.debug("Step 3: auth1 - retrieve the template, thanks to cookie internalAuthId user is already set"); | ||
result = httpClient.POST(authenticateUrl).header("X-NoSession", "true").header("X-Password", "anonymous") | ||
.header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous").send(); | ||
if (result.getStatus() != 200) { | ||
throw new LinkyException("Connection failed step 3 - auth1 : %s", result.getContentAsString()); | ||
if (result.getStatus() != HttpStatus.OK_200) { | ||
throw new LinkyException("Connection failed step 3 - auth1: %s", result.getContentAsString()); | ||
} | ||
|
||
AuthData authData = gson.fromJson(result.getContentAsString(), AuthData.class); | ||
|
@@ -128,13 +138,13 @@ public void initialize() throws LinkyException { | |
} | ||
|
||
authData.callbacks.get(1).input.get(0).value = config.password; | ||
logger.debug("Step 4 : auth2 - send the auth data"); | ||
result = httpClient.POST(authenticateUrl).header(HttpHeader.CONTENT_TYPE, "application/json") | ||
logger.debug("Step 4: auth2 - send the auth data"); | ||
result = httpClient.POST(authenticateUrl).header(HttpHeader.CONTENT_TYPE, MediaType.APPLICATION_JSON) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if this could cause more than one .content(new StringContentProvider(gson.toJson(authData), MediaType.APPLICATION_JSON)) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for your suggestion but the binding does not break at this step. |
||
.header("X-NoSession", "true").header("X-Password", "anonymous") | ||
.header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous") | ||
.content(new StringContentProvider(gson.toJson(authData))).send(); | ||
if (result.getStatus() != 200) { | ||
throw new LinkyException("Connection failed step 3 - auth2 : %s", result.getContentAsString()); | ||
if (result.getStatus() != HttpStatus.OK_200) { | ||
throw new LinkyException("Connection failed step 3 - auth2: %s", result.getContentAsString()); | ||
} | ||
|
||
AuthResult authResult = gson.fromJson(result.getContentAsString(), AuthResult.class); | ||
|
@@ -145,18 +155,40 @@ public void initialize() throws LinkyException { | |
logger.debug("Add the tokenId cookie"); | ||
addCookie("enedisExt", authResult.tokenId); | ||
|
||
logger.debug("Step 5 : retrieve the SAMLresponse"); | ||
data = getData(URL_MON_COMPTE + "/" + authResult.successUrl); | ||
logger.debug("Step 5: retrieve the SAMLresponse"); | ||
data = getContent(URL_MON_COMPTE + "/" + authResult.successUrl); | ||
htmlDocument = Jsoup.parse(data); | ||
el = htmlDocument.select("form").first(); | ||
samlInput = el.select("input[name=SAMLResponse]").first(); | ||
|
||
logger.debug("Step 6 : post the SAMLresponse to finish the authentication"); | ||
logger.debug("Step 6: post the SAMLresponse to finish the authentication"); | ||
result = httpClient.POST(el.attr("action")).content(getFormContent("SAMLResponse", samlInput.attr("value"))) | ||
.send(); | ||
if (result.getStatus() != 302) { | ||
if (result.getStatus() != HttpStatus.FOUND_302) { | ||
throw new LinkyException("Connection failed step 6"); | ||
} | ||
|
||
logger.debug("Step 7: retrieve cookieKey"); | ||
result = httpClient.GET(USER_INFO_CONTRACT_URL); | ||
|
||
@SuppressWarnings("unchecked") | ||
HashMap<String, String> hashRes = gson.fromJson(result.getContentAsString(), HashMap.class); | ||
|
||
String cookieKey; | ||
if (hashRes != null && hashRes.containsKey("cnAlex")) { | ||
cookieKey = "personne_for_" + hashRes.get("cnAlex"); | ||
} else { | ||
throw new LinkyException("Connection failed step 7, missing cookieKey"); | ||
} | ||
|
||
List<HttpCookie> lCookie = httpClient.getCookieStore().getCookies(); | ||
Optional<HttpCookie> cookie = lCookie.stream().filter(it -> it.getName().contains(cookieKey)).findFirst(); | ||
|
||
String cookieVal = cookie.map(HttpCookie::getValue) | ||
.orElseThrow(() -> new LinkyException("Connection failed step 7, missing cookieVal")); | ||
|
||
addCookie(cookieKey, cookieVal); | ||
|
||
connected = true; | ||
} catch (InterruptedException | TimeoutException | ExecutionException | JsonSyntaxException e) { | ||
throw new LinkyException(e, "Error opening connection with Enedis webservice"); | ||
|
@@ -203,76 +235,64 @@ private FormContentProvider getFormContent(String fieldName, String fieldValue) | |
return new FormContentProvider(fields); | ||
} | ||
|
||
private String getData(String url) throws LinkyException { | ||
private String getContent(String url) throws LinkyException { | ||
try { | ||
ContentResponse result = httpClient.GET(url); | ||
if (result.getStatus() != 200) { | ||
throw new LinkyException("Error requesting '%s' : %s", url, result.getContentAsString()); | ||
Request request = httpClient.newRequest(url) | ||
.agent("Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"); | ||
request = request.method(HttpMethod.GET); | ||
ContentResponse result = request.send(); | ||
if (result.getStatus() != HttpStatus.OK_200) { | ||
throw new LinkyException("Error requesting '%s': %s", url, result.getContentAsString()); | ||
} | ||
return result.getContentAsString(); | ||
String content = result.getContentAsString(); | ||
logger.trace("getContent returned {}", content); | ||
return content; | ||
} catch (InterruptedException | ExecutionException | TimeoutException e) { | ||
throw new LinkyException(e, "Error getting url : '%s'", url); | ||
throw new LinkyException(e, "Error getting url: '%s'", url); | ||
} | ||
} | ||
|
||
public PrmInfo getPrmInfo() throws LinkyException { | ||
private <T> T getData(String url, Class<T> clazz) throws LinkyException { | ||
if (!connected) { | ||
initialize(); | ||
} | ||
String data = getData(PRM_INFO_URL); | ||
String data = getContent(url); | ||
if (data.isEmpty()) { | ||
throw new LinkyException("Requesting '%s' returned an empty response", PRM_INFO_URL); | ||
throw new LinkyException("Requesting '%s' returned an empty response", url); | ||
} | ||
try { | ||
PrmInfo[] prms = gson.fromJson(data, PrmInfo[].class); | ||
if (prms == null || prms.length < 1) { | ||
throw new LinkyException("Invalid prms data received"); | ||
} | ||
return prms[0]; | ||
return Objects.requireNonNull(gson.fromJson(data, clazz)); | ||
} catch (JsonSyntaxException e) { | ||
logger.debug("invalid JSON response not matching PrmInfo[].class: {}", data); | ||
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", PRM_INFO_URL); | ||
logger.debug("Invalid JSON response not matching {}: {}", clazz.getName(), data); | ||
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url); | ||
} | ||
} | ||
|
||
public UserInfo getUserInfo() throws LinkyException { | ||
if (!connected) { | ||
initialize(); | ||
} | ||
String data = getData(USER_INFO_URL); | ||
if (data.isEmpty()) { | ||
throw new LinkyException("Requesting '%s' returned an empty response", USER_INFO_URL); | ||
} | ||
try { | ||
return Objects.requireNonNull(gson.fromJson(data, UserInfo.class)); | ||
} catch (JsonSyntaxException e) { | ||
logger.debug("invalid JSON response not matching UserInfo.class: {}", data); | ||
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", USER_INFO_URL); | ||
public PrmInfo getPrmInfo(String internId) throws LinkyException { | ||
String url = PRM_INFO_URL.formatted(internId); | ||
PrmInfo[] prms = getData(url, PrmInfo[].class); | ||
if (prms.length < 1) { | ||
throw new LinkyException("Invalid prms data received"); | ||
} | ||
return prms[0]; | ||
} | ||
|
||
public PrmDetail getPrmDetails(String internId, String prmId) throws LinkyException { | ||
String url = PRM_INFO_URL.formatted(internId) + "/" + prmId | ||
+ "?embed=SITALI&embed=SITCOM&embed=SITCON&embed=SYNCON"; | ||
return getData(url, PrmDetail.class); | ||
} | ||
|
||
public UserInfo getUserInfo() throws LinkyException { | ||
return getData(USER_INFO_URL, UserInfo.class); | ||
} | ||
|
||
private Consumption getMeasures(String userId, String prmId, LocalDate from, LocalDate to, String request) | ||
throws LinkyException { | ||
String url = String.format(MEASURE_URL, userId, prmId, request, from.format(API_DATE_FORMAT), | ||
to.format(API_DATE_FORMAT)); | ||
if (!connected) { | ||
initialize(); | ||
} | ||
String data = getData(url); | ||
if (data.isEmpty()) { | ||
throw new LinkyException("Requesting '%s' returned an empty response", url); | ||
} | ||
logger.trace("getData returned {}", data); | ||
try { | ||
ConsumptionReport report = gson.fromJson(data, ConsumptionReport.class); | ||
if (report == null) { | ||
throw new LinkyException("No report data received"); | ||
} | ||
return report.firstLevel.consumptions; | ||
} catch (JsonSyntaxException e) { | ||
logger.debug("invalid JSON response not matching ConsumptionReport.class: {}", data); | ||
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url); | ||
} | ||
ConsumptionReport report = getData(url, ConsumptionReport.class); | ||
return report.firstLevel.consumptions; | ||
} | ||
|
||
public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
/** | ||
* Copyright (c) 2010-2024 Contributors to the openHAB project | ||
* | ||
* See the NOTICE file(s) distributed with this work for additional | ||
* information. | ||
* | ||
* This program and the accompanying materials are made available under the | ||
* terms of the Eclipse Public License 2.0 which is available at | ||
* http://www.eclipse.org/legal/epl-2.0 | ||
* | ||
* SPDX-License-Identifier: EPL-2.0 | ||
*/ | ||
package org.openhab.binding.linky.internal.dto; | ||
|
||
import java.util.ArrayList; | ||
|
||
/** | ||
* The {@link PrmDetail} holds detailed informations about prm configuration | ||
* | ||
* @author Gaël L'hopital - Initial contribution | ||
*/ | ||
public class PrmDetail { | ||
public record Adresse(String ligne2, String ligne3, String ligne4, String ligne5, String ligne6) { | ||
lolodomo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
public record DicEntry(String code, String libelle) { | ||
} | ||
|
||
public record Measure(String unite, String valeur) { | ||
} | ||
|
||
public record AlimentationPrincipale(Object puissanceRaccordementInjection, | ||
Measure puissanceRaccordementSoutirage) { | ||
} | ||
|
||
public record Compteur(boolean accessibilite, boolean ticActivee, boolean ticStandard) { | ||
} | ||
|
||
public record Contrat(DicEntry typeContrat, String referenceContrat) { | ||
} | ||
|
||
public record Disjoncteur(DicEntry calibre) { | ||
} | ||
|
||
public record DispositifComptage(DicEntry typeComptage) { | ||
} | ||
|
||
public record GrilleFournisseur(DicEntry calendrier, Object classeTemporelle) { | ||
} | ||
|
||
public record InformationsContractuelles(Contrat contrat, DicEntry etatContractuel, SiContractuel siContractuel) { | ||
} | ||
|
||
public record SiContractuel(DicEntry application) { | ||
} | ||
|
||
public record SituationAlimentationDto(AlimentationPrincipale alimentationPrincipale) { | ||
} | ||
|
||
public record SituationComptageDto(ArrayList<Compteur> compteurs, Disjoncteur disjoncteur, | ||
DispositifComptage dispositifComptage) { | ||
} | ||
|
||
public record SituationContractuelleDto(InformationsContractuelles informationsContractuelles, | ||
StructureTarifaire structureTarifaire, String fournisseur, DicEntry segment) { | ||
} | ||
|
||
public record StructureTarifaire(Measure puissanceSouscrite, GrilleFournisseur grilleFournisseur) { | ||
} | ||
|
||
public record SyntheseContractuelleDto(DicEntry niveauOuvertureServices) { | ||
} | ||
|
||
public Adresse adresse; | ||
public String segment; | ||
public SyntheseContractuelleDto syntheseContractuelleDto; | ||
public SituationContractuelleDto[] situationContractuelleDtos; | ||
public SituationAlimentationDto situationAlimentationDto; | ||
public SituationComptageDto situationComptageDto; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did you add that? What's the purpose?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's the way I found to solve the dependency resolution issue I faced.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was building fine without that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@wborn @lsiepel @jlaur : can you help me to understand why this new property needs to be added ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will check later if this is something really necessary and if something similar is already done in other bindings but now our priority is to merge to have this fix in the coming milestone.