This commit is contained in:
lo92fr 2025-01-08 22:48:27 +01:00 committed by GitHub
commit 9788bb2f18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 116 additions and 70 deletions

View File

@ -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();

View File

@ -12,12 +12,16 @@
*/
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 javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
@ -28,6 +32,7 @@ import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.openhab.binding.linky.internal.handler.LinkyHandler;
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 +60,43 @@ import com.google.gson.JsonDeserializer;
@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))
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 (Exception ex) {
return LocalDate.parse(json.getAsJsonPrimitive().getAsString(), LINKY_LOCALDATE_FORMATTER)
.atStartOfDay();
}
})
.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 +141,8 @@ public class LinkyHandlerFactory extends BaseThingHandlerFactory {
@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;
}
}

View File

@ -62,7 +62,7 @@ import com.google.gson.JsonSyntaxException;
*/
@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");
@ -289,17 +289,16 @@ public class EnedisHttpApi {
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");
}
}

View File

@ -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 @@ import com.google.gson.annotations.SerializedName;
* 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;
}
@ -51,14 +54,10 @@ public class ConsumptionReport {
public ChronoData aggregats;
public String grandeurMetier;
public String grandeurPhysique;
public String unite;
public LocalDate dateDebut;
public LocalDate dateFin;
}
public class FirstLevel {
@SerializedName("CONS")
public Consumption consumptions;
}
@SerializedName("1")
public FirstLevel firstLevel;
@SerializedName("cons")
public Consumption consumptions;
}

View File

@ -16,6 +16,7 @@ import static org.openhab.binding.linky.internal.LinkyBindingConstants.*;
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;
@ -38,7 +39,9 @@ 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.openhab.core.config.core.Configuration;
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,6 +68,9 @@ import com.google.gson.Gson;
@NonNullByDefault
public class LinkyHandler extends BaseThingHandler {
private final TimeZoneProvider timeZoneProvider;
private ZoneId zoneId = ZoneId.systemDefault();
private static final int REFRESH_FIRST_HOUR_OF_DAY = 1;
private static final int REFRESH_INTERVAL_IN_MIN = 120;
@ -90,11 +96,13 @@ public class LinkyHandler extends BaseThingHandler {
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, () -> {
LocalDate today = LocalDate.now();
@ -150,6 +158,19 @@ public class LinkyHandler extends BaseThingHandler {
logger.debug("Initializing Linky handler.");
updateStatus(ThingStatus.UNKNOWN);
// update the timezone if not set to default to openhab default timezone
Configuration thingConfig = getConfig();
String val = (String) thingConfig.get("timezone");
if (val == null || val.isBlank()) {
zoneId = this.timeZoneProvider.getTimeZone();
thingConfig.put("timezone", zoneId.getId());
} else {
zoneId = ZoneId.of(val);
}
updateConfiguration(thingConfig);
LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
if (config.seemsValid()) {
enedisApi = new EnedisHttpApi(config, gson, httpClient);
@ -211,8 +232,9 @@ public class LinkyHandler extends BaseThingHandler {
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 +246,9 @@ public class LinkyHandler extends BaseThingHandler {
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 +262,7 @@ public class LinkyHandler extends BaseThingHandler {
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 +344,15 @@ public class LinkyHandler extends BaseThingHandler {
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);
}
@ -474,50 +495,36 @@ public class LinkyHandler extends BaseThingHandler {
}
private void checkData(Consumption consumption) throws LinkyException {
if (consumption.aggregats.days.periodes.isEmpty()) {
if (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 +544,11 @@ public class LinkyHandler extends BaseThingHandler {
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);
}
}
}

View File

@ -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.

View File

@ -34,6 +34,16 @@
<label>Auth ID</label>
<description>Authentication ID delivered after the captcha (see documentation).</description>
</parameter>
<parameter name="timezone" type="text" required="false">
<label>timezone</label>
<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</description>
</parameter>
</config-description>
</thing-type>