Skip to content
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] Fixes for change in Enedis API on 2024 December 20 #17945

Merged
merged 13 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions bundles/org.openhab.binding.linky/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ The binding has no configuration options, all configuration is done at Thing lev

The thing has the following configuration parameters:

| Parameter | Description |
|----------------|--------------------------------|
| username | Your Enedis platform username. |
| password | Your Enedis platform password. |
| internalAuthId | The internal authID |
| Parameter | Description |
|----------------|--------------------------------------------|
| username | Your Enedis platform username. |
| password | Your Enedis platform password. |
| internalAuthId | The internal authID |
| timezone | The timezone at the location of your linky |

This version is now compatible with the new API of Enedis (deployed from june 2020).
To avoid the captcha login, it is necessary to log before on a classical browser (e.g Chrome, Firefox) and to retrieve the user cookies (internalAuthId).
Expand All @@ -43,6 +44,8 @@ Instructions given for Firefox :
1. Disconnect from your Enedis account
1. Repeat steps 1, 2. You should arrive directly on step 5, then open the developer tool window (F12) and select "Stockage" tab. In the "Cookies" entry, select "https://mon-compte-enedis.fr". You'll find an entry named "internalAuthId", copy this value in your openHAB configuration.

A new timezone parameter has been introduced. If you don't put a value, it will default to the timezone of your openHAB installation. This parameter can be useful if you read data from a Linky in a different timezone.

## Channels

The information that is retrieved is available as these channels:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public class LinkyConfiguration {
public String username = "";
public String password = "";
public String internalAuthId = "";
public String timezone = "";

public boolean seemsValid() {
return !username.isBlank() && !password.isBlank() && !internalAuthId.isBlank();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@
*/
package org.openhab.binding.linky.internal;

import static java.time.temporal.ChronoField.*;
import static org.openhab.binding.linky.internal.LinkyBindingConstants.THING_TYPE_LINKY;

import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
Expand All @@ -27,7 +32,9 @@
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.openhab.binding.linky.internal.handler.LinkyHandler;
import org.openhab.binding.linky.internal.utils.DoubleTypeAdapter;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.io.net.http.TrustAllTrustManager;
import org.openhab.core.thing.Thing;
Expand Down Expand Up @@ -55,21 +62,42 @@
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.linky")
public class LinkyHandlerFactory extends BaseThingHandlerFactory {
private static final DateTimeFormatter LINKY_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX");
private static final DateTimeFormatter LINKY_LOCALDATE_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd");
private static final DateTimeFormatter LINKY_LOCALDATETIME_FORMATTER = new DateTimeFormatterBuilder()
.appendPattern("uuuu-MM-dd'T'HH:mm").optionalStart().appendLiteral(':').appendValue(SECOND_OF_MINUTE, 2)
.optionalStart().appendFraction(NANO_OF_SECOND, 0, 9, true).toFormatter();

private static final int REQUEST_BUFFER_SIZE = 8000;
private static final int RESPONSE_BUFFER_SIZE = 200000;

private final Logger logger = LoggerFactory.getLogger(LinkyHandlerFactory.class);
private final Gson gson = new GsonBuilder().registerTypeAdapter(ZonedDateTime.class,
(JsonDeserializer<ZonedDateTime>) (json, type, jsonDeserializationContext) -> ZonedDateTime
.parse(json.getAsJsonPrimitive().getAsString(), LINKY_FORMATTER))
.create();
private final Gson gson = new GsonBuilder()
.registerTypeAdapter(ZonedDateTime.class,
(JsonDeserializer<ZonedDateTime>) (json, type, jsonDeserializationContext) -> ZonedDateTime
.parse(json.getAsJsonPrimitive().getAsString(), LINKY_FORMATTER))
.registerTypeAdapter(LocalDate.class,
(JsonDeserializer<LocalDate>) (json, type, jsonDeserializationContext) -> LocalDate
.parse(json.getAsJsonPrimitive().getAsString(), LINKY_LOCALDATE_FORMATTER))
.registerTypeAdapter(LocalDateTime.class,
(JsonDeserializer<LocalDateTime>) (json, type, jsonDeserializationContext) -> {
try {
return LocalDateTime.parse(json.getAsJsonPrimitive().getAsString(),
LINKY_LOCALDATETIME_FORMATTER);
} catch (DateTimeParseException ex) {
return LocalDate.parse(json.getAsJsonPrimitive().getAsString(), LINKY_LOCALDATE_FORMATTER)
.atStartOfDay();
}
})
.registerTypeAdapter(Double.class, new DoubleTypeAdapter()).serializeNulls().create();
private final LocaleProvider localeProvider;
private final HttpClient httpClient;
private final TimeZoneProvider timeZoneProvider;

@Activate
public LinkyHandlerFactory(final @Reference LocaleProvider localeProvider,
final @Reference HttpClientFactory httpClientFactory) {
final @Reference HttpClientFactory httpClientFactory, final @Reference TimeZoneProvider timeZoneProvider) {
this.localeProvider = localeProvider;
this.timeZoneProvider = timeZoneProvider;
SslContextFactory sslContextFactory = new SslContextFactory.Client();
try {
SSLContext sslContext = SSLContext.getInstance("SSL");
Expand Down Expand Up @@ -114,7 +142,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {

@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
return supportsThingType(thing.getThingTypeUID()) ? new LinkyHandler(thing, localeProvider, gson, httpClient)
return supportsThingType(thing.getThingTypeUID())
? new LinkyHandler(thing, localeProvider, gson, httpClient, timeZoneProvider)
: null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,18 @@
*/
@NonNullByDefault
public class EnedisHttpApi {
private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final String ENEDIS_DOMAIN = ".enedis.fr";
private static final String URL_APPS_LINCS = "https://alex.microapplications" + ENEDIS_DOMAIN;
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_BASE_URL = URL_APPS_LINCS + "/mes-mesures-prm/api/private/v1/personnes/";
private static final String PRM_INFO_URL = URL_APPS_LINCS + "/mes-prms-part/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";
+ "%s/prms/%s/donnees-energetiques?mesuresTypeCode=%s&mesuresCorrigees=false&typeDonnees=CONS&dateDebut=%s";
private static final URI COOKIE_URI = URI.create(URL_COMPTE_PART);
private static final Pattern REQ_PATTERN = Pattern.compile("ReqID%(.*?)%26");

Expand All @@ -90,10 +90,16 @@ public EnedisHttpApi(LinkyConfiguration config, Gson gson, HttpClient httpClient
this.config = config;
}

public void removeAllCookie() {
httpClient.getCookieStore().removeAll();
}

public void initialize() throws LinkyException {
logger.debug("Starting login process for user: {}", config.username);

try {
removeAllCookie();

addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, config.internalAuthId);
logger.debug("Step 1: getting authentification");
String data = getContent(URL_ENEDIS_AUTHENTICATE);
Expand Down Expand Up @@ -237,10 +243,37 @@ private FormContentProvider getFormContent(String fieldName, String fieldValue)

private String getContent(String url) throws LinkyException {
try {
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 = httpClient.newRequest(url);

request = request.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.TEMPORARY_REDIRECT_307
|| result.getStatus() == HttpStatus.MOVED_TEMPORARILY_302) {
String loc = result.getHeaders().get("Location");
String newUrl = "";

if (loc.startsWith("http://") || loc.startsWith("https://")) {
newUrl = loc;
} else {
newUrl = URL_APPS_LINCS + loc;
}

request = httpClient.newRequest(newUrl);
request = request.method(HttpMethod.GET);
result = request.send();

if (result.getStatus() == HttpStatus.TEMPORARY_REDIRECT_307
|| result.getStatus() == HttpStatus.MOVED_TEMPORARILY_302) {
loc = result.getHeaders().get("Location");
String[] urlParts = loc.split("/");
if (urlParts.length < 4) {
throw new LinkyException("malformed url : %s", loc);
}
return urlParts[3];
}
}

if (result.getStatus() != HttpStatus.OK_200) {
throw new LinkyException("Error requesting '%s': %s", url, result.getContentAsString());
}
Expand All @@ -261,7 +294,9 @@ private <T> T getData(String url, Class<T> clazz) throws LinkyException {
throw new LinkyException("Requesting '%s' returned an empty response", url);
}
try {
return Objects.requireNonNull(gson.fromJson(data, clazz));
T result = Objects.requireNonNull(gson.fromJson(data, clazz));
logger.trace("getData success {}: {}", clazz.getName(), url);
return result;
} catch (JsonSyntaxException e) {
logger.debug("Invalid JSON response not matching {}: {}", clazz.getName(), data);
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url);
Expand Down Expand Up @@ -289,17 +324,16 @@ public UserInfo getUserInfo() throws LinkyException {

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));
String url = String.format(MEASURE_URL, userId, prmId, request, from.format(API_DATE_FORMAT));
ConsumptionReport report = getData(url, ConsumptionReport.class);
return report.firstLevel.consumptions;
return report.consumptions;
}

public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
return getMeasures(userId, prmId, from, to, "energie");
return getMeasures(userId, prmId, from, to, "ENERGIE");
}

public Consumption getPowerData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
return getMeasures(userId, prmId, from, to, "pmax");
return getMeasures(userId, prmId, from, to, "PMAX");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ public class ExpiringDayCache<V> {

private final String name;
private final int beginningHour;
private final Supplier<@Nullable V> action;
private final int beginningMinute;

private Supplier<@Nullable V> action;

private @Nullable V value;
private LocalDateTime expiresAt;
Expand All @@ -55,9 +57,10 @@ public class ExpiringDayCache<V> {
* @param beginningHour the hour in the day at which the validity period is starting
* @param action the action to retrieve/calculate the value
*/
public ExpiringDayCache(String name, int beginningHour, Supplier<@Nullable V> action) {
public ExpiringDayCache(String name, int beginningHour, int beginningMinute, Supplier<@Nullable V> action) {
this.name = name;
this.beginningHour = beginningHour;
this.beginningMinute = beginningMinute;
this.expiresAt = calcAlreadyExpired();
this.action = action;
}
Expand Down Expand Up @@ -99,7 +102,7 @@ public boolean isExpired() {

private LocalDateTime calcNextExpiresAt() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime limit = now.withHour(beginningHour).truncatedTo(ChronoUnit.HOURS);
LocalDateTime limit = now.withHour(beginningHour).withMinute(beginningMinute).truncatedTo(ChronoUnit.MINUTES);
LocalDateTime result = now.isBefore(limit) ? limit : limit.plusDays(1);
logger.debug("calcNextExpiresAt result = {}", result.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
*/
package org.openhab.binding.linky.internal.dto;

import java.time.ZonedDateTime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

import com.google.gson.annotations.SerializedName;
Expand All @@ -22,43 +23,41 @@
* returned by API calls
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - fix to handle new Dto format after enedis site modifications
*/
public class ConsumptionReport {
public class Period {
public String grandeurPhysiqueEnum;
public ZonedDateTime dateDebut;
public ZonedDateTime dateFin;

public class Data {
public LocalDateTime dateDebut;
public LocalDateTime dateFin;
public Double valeur;
}

public class Aggregate {
public List<String> labels;
public List<Period> periodes;
public List<Double> datas;
@SerializedName("donnees")
public List<Data> datas;
public String unite;
}

public class ChronoData {
@SerializedName("JOUR")
@SerializedName("jour")
public Aggregate days;
@SerializedName("SEMAINE")
@SerializedName("semaine")
public Aggregate weeks;
@SerializedName("MOIS")
@SerializedName("mois")
public Aggregate months;
@SerializedName("ANNEE")
@SerializedName("annee")
public Aggregate years;
}

public class Consumption {
public ChronoData aggregats;
public String grandeurMetier;
public String grandeurPhysique;
public String unite;
}

public class FirstLevel {
@SerializedName("CONS")
public Consumption consumptions;
public LocalDate dateDebut;
public LocalDate dateFin;
}

@SerializedName("1")
public FirstLevel firstLevel;
@SerializedName("cons")
public Consumption consumptions;
}
Loading