From 6a840f75a070cd53b7af658a3251b76b5d5aa755 Mon Sep 17 00:00:00 2001 From: lo92fr Date: Mon, 10 Feb 2025 19:21:52 +0100 Subject: [PATCH] [linky] Fixes for change in Enedis API on 2024 December 20 (#17945) * fixes for change in Enedis API on 2024 December 20 ! - URL for data is now mes-mesures-prm and not mes-mesures. - Dto format have changed : mainly merge data & date, field renaming, and moving. - Some changes on date format. * add timezone to thing config to allow overriding default timezone * add possible fix for 500 Internal Server Error * backport some fixes from linkyv2 branch to handle enedis website errors * remove condition so we can always get metadata to fix 500 error * remove the missingData stuff from previous commit, realize if was duplicate with check already done in getConsumptionAfterChecks * remove also the refreshinterval on ExpiringDayCache constructor, we don't need it anymore * remove nullable on action field Signed-off-by: Laurent ARNAL --- bundles/org.openhab.binding.linky/README.md | 13 +- .../linky/internal/LinkyConfiguration.java | 1 + .../linky/internal/LinkyHandlerFactory.java | 41 +++- .../linky/internal/api/EnedisHttpApi.java | 56 ++++- .../linky/internal/api/ExpiringDayCache.java | 9 +- .../linky/internal/dto/ConsumptionReport.java | 39 ++- .../linky/internal/handler/LinkyHandler.java | 232 ++++++++++-------- .../internal/utils/DoubleTypeAdapter.java | 56 +++++ .../resources/OH-INF/i18n/linky.properties | 4 +- .../resources/OH-INF/thing/thing-types.xml | 10 + 10 files changed, 310 insertions(+), 151 deletions(-) create mode 100644 bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/utils/DoubleTypeAdapter.java diff --git a/bundles/org.openhab.binding.linky/README.md b/bundles/org.openhab.binding.linky/README.md index 1f7aa92d1ee15..5c563a412c1d9 100644 --- a/bundles/org.openhab.binding.linky/README.md +++ b/bundles/org.openhab.binding.linky/README.md @@ -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). @@ -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: diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyConfiguration.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyConfiguration.java index 8b471ac673d9e..6021cae5ceab7 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyConfiguration.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyConfiguration.java @@ -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(); diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java index ca60b18dd1da2..36ccf55c3c6eb 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java @@ -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; @@ -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; @@ -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) (json, type, jsonDeserializationContext) -> ZonedDateTime - .parse(json.getAsJsonPrimitive().getAsString(), LINKY_FORMATTER)) - .create(); + private final Gson gson = new GsonBuilder() + .registerTypeAdapter(ZonedDateTime.class, + (JsonDeserializer) (json, type, jsonDeserializationContext) -> ZonedDateTime + .parse(json.getAsJsonPrimitive().getAsString(), LINKY_FORMATTER)) + .registerTypeAdapter(LocalDate.class, + (JsonDeserializer) (json, type, jsonDeserializationContext) -> LocalDate + .parse(json.getAsJsonPrimitive().getAsString(), LINKY_LOCALDATE_FORMATTER)) + .registerTypeAdapter(LocalDateTime.class, + (JsonDeserializer) (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"); @@ -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; } } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/EnedisHttpApi.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/EnedisHttpApi.java index a2d2102ba912d..bce596ffd6966 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/EnedisHttpApi.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/EnedisHttpApi.java @@ -62,7 +62,7 @@ */ @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; @@ -70,10 +70,10 @@ public class EnedisHttpApi { 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"); @@ -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); @@ -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()); } @@ -261,7 +294,9 @@ private T getData(String url, Class 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); @@ -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"); } } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/ExpiringDayCache.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/ExpiringDayCache.java index 6c5f6e9713f5d..5621534a75dc5 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/ExpiringDayCache.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/ExpiringDayCache.java @@ -43,7 +43,9 @@ public class ExpiringDayCache { 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; @@ -55,9 +57,10 @@ public class ExpiringDayCache { * @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; } @@ -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; diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ConsumptionReport.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ConsumptionReport.java index 71e324f9f4cf9..6f38eee866395 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ConsumptionReport.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ConsumptionReport.java @@ -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; @@ -22,28 +23,30 @@ * 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 labels; - public List periodes; - public List datas; + @SerializedName("donnees") + public List 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; } @@ -51,14 +54,10 @@ 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; } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyHandler.java index 4ad4815373a22..20f502a957290 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyHandler.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyHandler.java @@ -16,6 +16,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; @@ -23,6 +24,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Random; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -39,6 +41,7 @@ import org.openhab.binding.linky.internal.dto.PrmInfo; import org.openhab.binding.linky.internal.dto.UserInfo; import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.unit.MetricPrefix; @@ -65,9 +68,12 @@ @NonNullByDefault public class LinkyHandler extends BaseThingHandler { - private static final int REFRESH_FIRST_HOUR_OF_DAY = 1; + private static final Random randomNumbers = new Random(); + private static final int REFRESH_HOUR_OF_DAY = 1; + private static final int REFRESH_MINUTE_OF_DAY = randomNumbers.nextInt(60); private static final int REFRESH_INTERVAL_IN_MIN = 120; + private final TimeZoneProvider timeZoneProvider; private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class); private final HttpClient httpClient; private final Gson gson; @@ -78,6 +84,8 @@ public class LinkyHandler extends BaseThingHandler { private final ExpiringDayCache cachedMonthlyData; private final ExpiringDayCache cachedYearlyData; + private ZoneId zoneId = ZoneId.systemDefault(); + private @Nullable ScheduledFuture refreshJob; private @Nullable EnedisHttpApi enedisApi; @@ -90,15 +98,18 @@ private enum Target { ALL } - public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) { + public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient, + TimeZoneProvider timeZoneProvider) { super(thing); this.gson = gson; this.httpClient = httpClient; this.weekFields = WeekFields.of(localeProvider.getLocale()); + this.timeZoneProvider = timeZoneProvider; - this.cachedDailyData = new ExpiringDayCache<>("daily cache", REFRESH_FIRST_HOUR_OF_DAY, () -> { + this.cachedDailyData = new ExpiringDayCache<>("daily cache", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_OF_DAY, () -> { LocalDate today = LocalDate.now(); Consumption consumption = getConsumptionData(today.minusDays(15), today); + if (consumption != null) { logData(consumption.aggregats.days, "Day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.ALL); logData(consumption.aggregats.weeks, "Week", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL); @@ -107,13 +118,13 @@ public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpC return consumption; }); - this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_FIRST_HOUR_OF_DAY, () -> { - // We request data for yesterday and the day before yesterday, even if the data for the day before yesterday - // is not needed by the binding. This is only a workaround to an API bug that will return - // INTERNAL_SERVER_ERROR rather than the expected data with a NaN value when the data for yesterday is not - // yet available. - // By requesting two days, the API is not failing and you get the expected NaN value for yesterday when the - // data is not yet available. + this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_OF_DAY, () -> { + // We request data for yesterday and the day before yesterday, + // even if the data for the day before yesterday is not needed by the binding. + // This is only a workaround to an API bug that will return INTERNAL_SERVER_ERROR rather + // than the expected data with a NaN value when the data for yesterday is not yet available. + // By requesting two days, the API is not failing and you get the expected NaN value for yesterday + // when the data is not yet available. LocalDate today = LocalDate.now(); Consumption consumption = getPowerData(today.minusDays(2), today); if (consumption != null) { @@ -124,25 +135,29 @@ public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpC return consumption; }); - this.cachedMonthlyData = new ExpiringDayCache<>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> { - LocalDate today = LocalDate.now(); - Consumption consumption = getConsumptionData(today.withDayOfMonth(1).minusMonths(1), today); - if (consumption != null) { - logData(consumption.aggregats.months, "Month", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL); - consumption = getConsumptionAfterChecks(consumption, Target.LAST); - } - return consumption; - }); - - this.cachedYearlyData = new ExpiringDayCache<>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> { - LocalDate today = LocalDate.now(); - Consumption consumption = getConsumptionData(LocalDate.of(today.getYear() - 1, 1, 1), today); - if (consumption != null) { - logData(consumption.aggregats.years, "Year", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL); - consumption = getConsumptionAfterChecks(consumption, Target.LAST); - } - return consumption; - }); + this.cachedMonthlyData = new ExpiringDayCache<>("monthly cache", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_OF_DAY, + () -> { + LocalDate today = LocalDate.now(); + Consumption consumption = getConsumptionData(today.withDayOfMonth(1).minusMonths(1), today); + if (consumption != null) { + logData(consumption.aggregats.months, "Month", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, + Target.ALL); + consumption = getConsumptionAfterChecks(consumption, Target.LAST); + } + return consumption; + }); + + this.cachedYearlyData = new ExpiringDayCache<>("yearly cache", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_OF_DAY, + () -> { + LocalDate today = LocalDate.now(); + Consumption consumption = getConsumptionData(LocalDate.of(today.getYear() - 1, 1, 1), today); + if (consumption != null) { + logData(consumption.aggregats.years, "Year", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, + Target.ALL); + consumption = getConsumptionAfterChecks(consumption, Target.LAST); + } + return consumption; + }); } @Override @@ -152,6 +167,13 @@ public void initialize() { LinkyConfiguration config = getConfigAs(LinkyConfiguration.class); if (config.seemsValid()) { + + if (config.timezone.isBlank()) { + zoneId = this.timeZoneProvider.getTimeZone(); + } else { + zoneId = ZoneId.of(config.timezone); + } + enedisApi = new EnedisHttpApi(config, gson, httpClient); scheduler.submit(() -> { try { @@ -159,26 +181,13 @@ public void initialize() { api.initialize(); updateStatus(ThingStatus.ONLINE); - if (thing.getProperties().isEmpty()) { - UserInfo userInfo = api.getUserInfo(); - PrmInfo prmInfo = api.getPrmInfo(userInfo.userProperties.internId); - PrmDetail details = api.getPrmDetails(userInfo.userProperties.internId, prmInfo.idPrm); - updateProperties(Map.of(USER_ID, userInfo.userProperties.internId, PUISSANCE, - details.situationContractuelleDtos[0].structureTarifaire().puissanceSouscrite().valeur() - + " kVA", - PRM_ID, prmInfo.idPrm)); - } - - prmId = thing.getProperties().get(PRM_ID); - userId = thing.getProperties().get(USER_ID); - updateData(); disconnect(); final LocalDateTime now = LocalDateTime.now(); - final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY) - .truncatedTo(ChronoUnit.HOURS); + final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_HOUR_OF_DAY) + .withMinute(REFRESH_MINUTE_OF_DAY).truncatedTo(ChronoUnit.MINUTES); refreshJob = scheduler.scheduleWithFixedDelay(this::updateData, ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1, @@ -193,17 +202,38 @@ public void initialize() { } } + private synchronized void updateMetaData() throws LinkyException { + EnedisHttpApi api = this.enedisApi; + if (api != null) { + UserInfo userInfo = api.getUserInfo(); + PrmInfo prmInfo = api.getPrmInfo(userInfo.userProperties.internId); + PrmDetail details = api.getPrmDetails(userInfo.userProperties.internId, prmInfo.idPrm); + updateProperties(Map.of(USER_ID, userInfo.userProperties.internId, PUISSANCE, + details.situationContractuelleDtos[0].structureTarifaire().puissanceSouscrite().valeur() + " kVA", + PRM_ID, prmInfo.idPrm)); + + prmId = thing.getProperties().get(PRM_ID); + userId = thing.getProperties().get(USER_ID); + } + } + /** * Request new data and updates channels */ private synchronized void updateData() { boolean connectedBefore = isConnected(); - updatePowerData(); - updateDailyWeeklyData(); - updateMonthlyData(); - updateYearlyData(); - if (!connectedBefore && isConnected()) { - disconnect(); + try { + updateMetaData(); + updatePowerData(); + updateDailyWeeklyData(); + updateMonthlyData(); + updateYearlyData(); + if (!connectedBefore && isConnected()) { + disconnect(); + } + } catch (LinkyException e) { + logger.debug("Exception occurs during data update {}", e.getMessage(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); } } @@ -211,8 +241,9 @@ private synchronized void updatePowerData() { if (isLinked(PEAK_POWER) || isLinked(PEAK_TIMESTAMP)) { cachedPowerData.getValue().ifPresentOrElse(values -> { Aggregate days = values.aggregats.days; - updatekVAChannel(PEAK_POWER, days.datas.get(days.datas.size() - 1)); - updateState(PEAK_TIMESTAMP, new DateTimeType(days.periodes.get(days.datas.size() - 1).dateDebut)); + updatekVAChannel(PEAK_POWER, days.datas.get(days.datas.size() - 1).valeur); + updateState(PEAK_TIMESTAMP, + new DateTimeType(days.datas.get(days.datas.size() - 1).dateDebut.atZone(zoneId))); }, () -> { updateKwhChannel(PEAK_POWER, Double.NaN); updateState(PEAK_TIMESTAMP, UnDefType.UNDEF); @@ -224,9 +255,9 @@ private void setCurrentAndPrevious(Aggregate periods, String currentChannel, Str double currentValue = 0.0; double previousValue = 0.0; if (!periods.datas.isEmpty()) { - currentValue = periods.datas.get(periods.datas.size() - 1); + currentValue = periods.datas.get(periods.datas.size() - 1).valeur; if (periods.datas.size() > 1) { - previousValue = periods.datas.get(periods.datas.size() - 2); + previousValue = periods.datas.get(periods.datas.size() - 2).valeur; } } updateKwhChannel(currentChannel, currentValue); @@ -240,7 +271,7 @@ private synchronized void updateDailyWeeklyData() { if (isLinked(YESTERDAY) || isLinked(LAST_WEEK) || isLinked(THIS_WEEK)) { cachedDailyData.getValue().ifPresentOrElse(values -> { Aggregate days = values.aggregats.days; - updateKwhChannel(YESTERDAY, days.datas.get(days.datas.size() - 1)); + updateKwhChannel(YESTERDAY, days.datas.get(days.datas.size() - 1).valeur); setCurrentAndPrevious(values.aggregats.weeks, THIS_WEEK, LAST_WEEK); }, () -> { updateKwhChannel(YESTERDAY, Double.NaN); @@ -322,16 +353,15 @@ private List buildReport(LocalDate startDay, LocalDate endDay, @Nullable Consumption result = getConsumptionData(startDay, endDay.plusDays(1)); if (result != null) { Aggregate days = result.aggregats.days; - int size = (days.datas == null || days.periodes == null) ? 0 - : (days.datas.size() <= days.periodes.size() ? days.datas.size() : days.periodes.size()); + int size = (days.datas == null) ? 0 : days.datas.size(); for (int i = 0; i < size; i++) { - double consumption = days.datas.get(i); - LocalDate day = days.periodes.get(i).dateDebut.toLocalDate(); + double consumption = days.datas.get(i).valeur; + LocalDate day = days.datas.get(i).dateDebut.toLocalDate(); // Filter data in case it contains data from dates outside the requested period if (day.isBefore(startDay) || day.isAfter(endDay)) { continue; } - String line = days.periodes.get(i).dateDebut.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator; + String line = days.datas.get(i).dateDebut.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator; if (consumption >= 0) { line += String.valueOf(consumption); } @@ -426,26 +456,32 @@ public synchronized void handleCommand(ChannelUID channelUID, Command command) { if (command instanceof RefreshType) { logger.debug("Refreshing channel {}", channelUID.getId()); boolean connectedBefore = isConnected(); - switch (channelUID.getId()) { - case YESTERDAY: - case LAST_WEEK: - case THIS_WEEK: - updateDailyWeeklyData(); - break; - case LAST_MONTH: - case THIS_MONTH: - updateMonthlyData(); - break; - case LAST_YEAR: - case THIS_YEAR: - updateYearlyData(); - break; - case PEAK_POWER: - case PEAK_TIMESTAMP: - updatePowerData(); - break; - default: - break; + + try { + updateMetaData(); + switch (channelUID.getId()) { + case YESTERDAY: + case LAST_WEEK: + case THIS_WEEK: + updateDailyWeeklyData(); + break; + case LAST_MONTH: + case THIS_MONTH: + updateMonthlyData(); + break; + case LAST_YEAR: + case THIS_YEAR: + updateYearlyData(); + break; + case PEAK_POWER: + case PEAK_TIMESTAMP: + updatePowerData(); + break; + default: + break; + } + } catch (LinkyException ex) { + logger.debug("Unable to handleCommand refresh", ex); } if (!connectedBefore && isConnected()) { disconnect(); @@ -474,50 +510,36 @@ public synchronized void handleCommand(ChannelUID channelUID, Command command) { } private void checkData(Consumption consumption) throws LinkyException { - if (consumption.aggregats.days.periodes.isEmpty()) { + if (consumption.aggregats.days != null && consumption.aggregats.days.datas.isEmpty()) { throw new LinkyException("Invalid consumptions data: no day period"); } - if (consumption.aggregats.days.periodes.size() != consumption.aggregats.days.datas.size()) { - throw new LinkyException("Invalid consumptions data: not any data for each day period"); - } - if (consumption.aggregats.weeks.periodes.isEmpty()) { + if (consumption.aggregats.weeks != null && consumption.aggregats.weeks.datas.isEmpty()) { throw new LinkyException("Invalid consumptions data: no week period"); } - if (consumption.aggregats.weeks.periodes.size() != consumption.aggregats.weeks.datas.size()) { - throw new LinkyException("Invalid consumptions data: not any data for each week period"); - } - if (consumption.aggregats.months.periodes.isEmpty()) { + if (consumption.aggregats.months != null && consumption.aggregats.months.datas.isEmpty()) { throw new LinkyException("Invalid consumptions data: no month period"); } - if (consumption.aggregats.months.periodes.size() != consumption.aggregats.months.datas.size()) { - throw new LinkyException("Invalid consumptions data: not any data for each month period"); - } - if (consumption.aggregats.years.periodes.isEmpty()) { + if (consumption.aggregats.years != null && consumption.aggregats.years.datas.isEmpty()) { throw new LinkyException("Invalid consumptions data: no year period"); } - if (consumption.aggregats.years.periodes.size() != consumption.aggregats.years.datas.size()) { - throw new LinkyException("Invalid consumptions data: not any data for each year period"); - } } private boolean isDataFirstDayAvailable(Consumption consumption) { Aggregate days = consumption.aggregats.days; logData(days, "First day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.FIRST); - return days.datas != null && !days.datas.isEmpty() && !days.datas.get(0).isNaN(); + return days.datas != null && !days.datas.isEmpty() && !days.datas.get(0).valeur.isNaN(); } private boolean isDataLastDayAvailable(Consumption consumption) { Aggregate days = consumption.aggregats.days; logData(days, "Last day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.LAST); - return days.datas != null && !days.datas.isEmpty() && !days.datas.get(days.datas.size() - 1).isNaN(); + return days.datas != null && !days.datas.isEmpty() && !days.datas.get(days.datas.size() - 1).valeur.isNaN(); } private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter, Target target) { if (logger.isDebugEnabled()) { - int size = (aggregate.datas == null || aggregate.periodes == null) ? 0 - : (aggregate.datas.size() <= aggregate.periodes.size() ? aggregate.datas.size() - : aggregate.periodes.size()); + int size = (aggregate.datas == null) ? 0 : aggregate.datas.size(); if (target == Target.FIRST) { if (size > 0) { logData(aggregate, 0, title, withDateFin, dateTimeFormatter); @@ -537,11 +559,11 @@ private void logData(Aggregate aggregate, String title, boolean withDateFin, Dat private void logData(Aggregate aggregate, int index, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter) { if (withDateFin) { - logger.debug("{} {} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter), - aggregate.periodes.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index)); + logger.debug("{} {} {} value {}", title, aggregate.datas.get(index).dateDebut.format(dateTimeFormatter), + aggregate.datas.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index).valeur); } else { - logger.debug("{} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter), - aggregate.datas.get(index)); + logger.debug("{} {} value {}", title, aggregate.datas.get(index).dateDebut.format(dateTimeFormatter), + aggregate.datas.get(index).valeur); } } } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/utils/DoubleTypeAdapter.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/utils/DoubleTypeAdapter.java new file mode 100644 index 0000000000000..c4ebf74997146 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/utils/DoubleTypeAdapter.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2010-2025 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.utils; + +import java.io.IOException; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * {@link DoubleTypeAdapter} A type adapter for gson / double. + * + * Will prevent Null exception error when api return incomplete value. + * We can have this scenario when we ask to today consumption after midnight, but before enedis update the value. + * In this case, we don't want to failed all the data just because of one missing value. + * + * @author Laurent Arnal - Initial contribution + */ +public class DoubleTypeAdapter extends TypeAdapter { + + @Override + public Double read(JsonReader reader) throws IOException { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + return Double.NaN; + } + String stringValue = reader.nextString(); + try { + Double value = Double.valueOf(stringValue); + return value; + } catch (NumberFormatException e) { + return Double.NaN; + } + } + + @Override + public void write(JsonWriter writer, Double value) throws IOException { + if (value == null) { + writer.nullValue(); + return; + } + writer.value(value.doubleValue()); + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky.properties b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky.properties index 9eeaf4460fdba..fa77fc096ce40 100644 --- a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky.properties +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky.properties @@ -14,6 +14,8 @@ thing-type.config.linky.linky.internalAuthId.label = Auth ID thing-type.config.linky.linky.internalAuthId.description = Authentication ID delivered after the captcha (see documentation). thing-type.config.linky.linky.password.label = Password thing-type.config.linky.linky.password.description = Your Enedis Password +thing-type.config.linky.linky.timezone.label = Timezone +thing-type.config.linky.linky.timezone.description = The timezone associated with your Point of delivery. Will default to openHAB default timezone. You will need to change this if your Linky is located in a different timezone that your openHAB location. You can use an offset, or a label like Europe/Paris thing-type.config.linky.linky.username.label = Username thing-type.config.linky.linky.username.description = Your Enedis Username @@ -41,6 +43,6 @@ channel-type.linky.power.label = Yesterday Peak Power channel-type.linky.power.description = Maximum power usage yesterday channel-type.linky.timestamp.label = Timestamp -# Thing status descriptions +# thing status descriptions offline.config-error-mandatory-settings = Username, password and authId are mandatory. diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-types.xml index fbcdbb509e376..ccc678058c05c 100644 --- a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-types.xml @@ -34,6 +34,16 @@ Authentication ID delivered after the captcha (see documentation). + + + The timezone associated with your Point of delivery. + Will default to openHAB default timezone. + You will + need to change this if your Linky is located in a different timezone that your openHAB location. + You can use an + offset, or a label like Europe/Paris + +