2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.linky.internal.handler;
15 import static org.openhab.binding.linky.internal.LinkyBindingConstants.*;
17 import java.time.LocalDate;
18 import java.time.LocalDateTime;
19 import java.time.ZonedDateTime;
20 import java.time.format.DateTimeFormatter;
21 import java.time.temporal.ChronoUnit;
22 import java.time.temporal.WeekFields;
23 import java.util.ArrayList;
24 import java.util.List;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.HttpClient;
32 import org.openhab.binding.linky.internal.LinkyConfiguration;
33 import org.openhab.binding.linky.internal.LinkyException;
34 import org.openhab.binding.linky.internal.api.EnedisHttpApi;
35 import org.openhab.binding.linky.internal.api.ExpiringDayCache;
36 import org.openhab.binding.linky.internal.dto.ConsumptionReport.Aggregate;
37 import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption;
38 import org.openhab.binding.linky.internal.dto.PrmInfo;
39 import org.openhab.core.i18n.LocaleProvider;
40 import org.openhab.core.library.types.DateTimeType;
41 import org.openhab.core.library.types.QuantityType;
42 import org.openhab.core.library.unit.Units;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.binding.BaseThingHandler;
48 import org.openhab.core.types.Command;
49 import org.openhab.core.types.RefreshType;
50 import org.openhab.core.types.UnDefType;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
54 import com.google.gson.Gson;
57 * The {@link LinkyHandler} is responsible for handling commands, which are
58 * sent to one of the channels.
60 * @author Gaƫl L'hopital - Initial contribution
64 public class LinkyHandler extends BaseThingHandler {
65 private static final int REFRESH_FIRST_HOUR_OF_DAY = 1;
66 private static final int REFRESH_INTERVAL_IN_MIN = 120;
68 private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class);
69 private final HttpClient httpClient;
70 private final Gson gson;
71 private final WeekFields weekFields;
73 private final ExpiringDayCache<Consumption> cachedDailyData;
74 private final ExpiringDayCache<Consumption> cachedPowerData;
75 private final ExpiringDayCache<Consumption> cachedMonthlyData;
76 private final ExpiringDayCache<Consumption> cachedYearlyData;
78 private @Nullable ScheduledFuture<?> refreshJob;
79 private @Nullable EnedisHttpApi enedisApi;
81 private @NonNullByDefault({}) String prmId;
82 private @NonNullByDefault({}) String userId;
90 public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) {
93 this.httpClient = httpClient;
94 this.weekFields = WeekFields.of(localeProvider.getLocale());
96 this.cachedDailyData = new ExpiringDayCache<>("daily cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
97 LocalDate today = LocalDate.now();
98 Consumption consumption = getConsumptionData(today.minusDays(15), today);
99 if (consumption != null) {
100 logData(consumption.aggregats.days, "Day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.ALL);
101 logData(consumption.aggregats.weeks, "Week", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL);
102 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
107 this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
108 // We request data for yesterday and the day before yesterday, even if the data for the day before yesterday
109 // is not needed by the binding. This is only a workaround to an API bug that will return
110 // INTERNAL_SERVER_ERROR rather than the expected data with a NaN value when the data for yesterday is not
112 // By requesting two days, the API is not failing and you get the expected NaN value for yesterday when the
113 // data is not yet available.
114 LocalDate today = LocalDate.now();
115 Consumption consumption = getPowerData(today.minusDays(2), today);
116 if (consumption != null) {
117 logData(consumption.aggregats.days, "Day (peak)", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME,
119 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
124 this.cachedMonthlyData = new ExpiringDayCache<>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
125 LocalDate today = LocalDate.now();
126 Consumption consumption = getConsumptionData(today.withDayOfMonth(1).minusMonths(1), today);
127 if (consumption != null) {
128 logData(consumption.aggregats.months, "Month", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL);
129 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
134 this.cachedYearlyData = new ExpiringDayCache<>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
135 LocalDate today = LocalDate.now();
136 Consumption consumption = getConsumptionData(LocalDate.of(today.getYear() - 1, 1, 1), today);
137 if (consumption != null) {
138 logData(consumption.aggregats.years, "Year", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL);
139 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
146 public void initialize() {
147 logger.debug("Initializing Linky handler.");
148 updateStatus(ThingStatus.UNKNOWN);
150 LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
151 if (config.seemsValid()) {
152 enedisApi = new EnedisHttpApi(config, gson, httpClient);
153 scheduler.submit(() -> {
155 EnedisHttpApi api = this.enedisApi;
157 updateStatus(ThingStatus.ONLINE);
159 if (thing.getProperties().isEmpty()) {
160 PrmInfo prmInfo = api.getPrmInfo();
161 updateProperties(Map.of(USER_ID, api.getUserInfo().userProperties.internId, PUISSANCE,
162 prmInfo.puissanceSouscrite + " kVA", PRM_ID, prmInfo.prmId));
165 prmId = thing.getProperties().get(PRM_ID);
166 userId = thing.getProperties().get(USER_ID);
172 final LocalDateTime now = LocalDateTime.now();
173 final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
174 .truncatedTo(ChronoUnit.HOURS);
176 refreshJob = scheduler.scheduleWithFixedDelay(this::updateData,
177 ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1,
178 REFRESH_INTERVAL_IN_MIN, TimeUnit.MINUTES);
179 } catch (LinkyException e) {
180 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
184 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
185 "@text/offline.config-error-mandatory-settings");
190 * Request new data and updates channels
192 private synchronized void updateData() {
193 boolean connectedBefore = isConnected();
195 updateDailyWeeklyData();
198 if (!connectedBefore && isConnected()) {
203 private synchronized void updatePowerData() {
204 if (isLinked(PEAK_POWER) || isLinked(PEAK_TIMESTAMP)) {
205 cachedPowerData.getValue().ifPresentOrElse(values -> {
206 Aggregate days = values.aggregats.days;
207 updateVAChannel(PEAK_POWER, days.datas.get(days.datas.size() - 1));
208 updateState(PEAK_TIMESTAMP, new DateTimeType(days.periodes.get(days.datas.size() - 1).dateDebut));
210 updateKwhChannel(PEAK_POWER, Double.NaN);
211 updateState(PEAK_TIMESTAMP, UnDefType.UNDEF);
217 * Request new dayly/weekly data and updates channels
219 private synchronized void updateDailyWeeklyData() {
220 if (isLinked(YESTERDAY) || isLinked(LAST_WEEK) || isLinked(THIS_WEEK)) {
221 cachedDailyData.getValue().ifPresentOrElse(values -> {
222 Aggregate days = values.aggregats.days;
223 updateKwhChannel(YESTERDAY, days.datas.get(days.datas.size() - 1));
224 int idxLast = days.periodes.get(days.periodes.size() - 1).dateDebut.get(weekFields.dayOfWeek()) == 7 ? 2
226 Aggregate weeks = values.aggregats.weeks;
227 if (weeks.datas.size() > idxLast) {
228 updateKwhChannel(LAST_WEEK, weeks.datas.get(idxLast));
230 if (weeks.datas.size() > (idxLast + 1)) {
231 updateKwhChannel(THIS_WEEK, weeks.datas.get(idxLast + 1));
233 updateKwhChannel(THIS_WEEK, 0.0);
236 updateKwhChannel(YESTERDAY, Double.NaN);
237 if (ZonedDateTime.now().get(weekFields.dayOfWeek()) == 1) {
238 updateKwhChannel(THIS_WEEK, 0.0);
239 updateKwhChannel(LAST_WEEK, Double.NaN);
241 updateKwhChannel(THIS_WEEK, Double.NaN);
248 * Request new monthly data and updates channels
250 private synchronized void updateMonthlyData() {
251 if (isLinked(LAST_MONTH) || isLinked(THIS_MONTH)) {
252 cachedMonthlyData.getValue().ifPresentOrElse(values -> {
253 Aggregate months = values.aggregats.months;
254 updateKwhChannel(LAST_MONTH, months.datas.get(0));
255 if (months.datas.size() > 1) {
256 updateKwhChannel(THIS_MONTH, months.datas.get(1));
258 updateKwhChannel(THIS_MONTH, 0.0);
261 if (ZonedDateTime.now().getDayOfMonth() == 1) {
262 updateKwhChannel(THIS_MONTH, 0.0);
263 updateKwhChannel(LAST_MONTH, Double.NaN);
265 updateKwhChannel(THIS_MONTH, Double.NaN);
272 * Request new yearly data and updates channels
274 private synchronized void updateYearlyData() {
275 if (isLinked(LAST_YEAR) || isLinked(THIS_YEAR)) {
276 cachedYearlyData.getValue().ifPresentOrElse(values -> {
277 Aggregate years = values.aggregats.years;
278 updateKwhChannel(LAST_YEAR, years.datas.get(0));
279 if (years.datas.size() > 1) {
280 updateKwhChannel(THIS_YEAR, years.datas.get(1));
282 updateKwhChannel(THIS_YEAR, 0.0);
285 if (ZonedDateTime.now().getDayOfYear() == 1) {
286 updateKwhChannel(THIS_YEAR, 0.0);
287 updateKwhChannel(LAST_YEAR, Double.NaN);
289 updateKwhChannel(THIS_YEAR, Double.NaN);
295 private void updateKwhChannel(String channelId, double consumption) {
296 logger.debug("Update channel {} with {}", channelId, consumption);
297 updateState(channelId,
298 Double.isNaN(consumption) ? UnDefType.UNDEF : new QuantityType<>(consumption, Units.KILOWATT_HOUR));
301 private void updateVAChannel(String channelId, double power) {
302 logger.debug("Update channel {} with {}", channelId, power);
303 updateState(channelId, Double.isNaN(power) ? UnDefType.UNDEF : new QuantityType<>(power, Units.VOLT_AMPERE));
307 * Produce a report of all daily values between two dates
309 * @param startDay the start day of the report
310 * @param endDay the end day of the report
311 * @param separator the separator to be used betwwen the date and the value
313 * @return the report as a list of string
315 public synchronized List<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
316 List<String> report = buildReport(startDay, endDay, separator);
321 private List<String> buildReport(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
322 List<String> report = new ArrayList<>();
323 if (startDay.getYear() == endDay.getYear() && startDay.getMonthValue() == endDay.getMonthValue()) {
324 // All values in the same month
325 Consumption result = getConsumptionData(startDay, endDay.plusDays(1));
326 if (result != null) {
327 Aggregate days = result.aggregats.days;
328 int size = (days.datas == null || days.periodes == null) ? 0
329 : (days.datas.size() <= days.periodes.size() ? days.datas.size() : days.periodes.size());
330 for (int i = 0; i < size; i++) {
331 double consumption = days.datas.get(i);
332 String line = days.periodes.get(i).dateDebut.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator;
333 if (consumption >= 0) {
334 line += String.valueOf(consumption);
339 LocalDate currentDay = startDay;
340 while (!currentDay.isAfter(endDay)) {
341 report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator);
342 currentDay = currentDay.plusDays(1);
346 // Concatenate the report produced for each month between the two dates
347 LocalDate first = startDay;
349 LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
350 if (last.isAfter(endDay)) {
353 report.addAll(buildReport(first, last, separator));
354 first = last.plusDays(1);
355 } while (!first.isAfter(endDay));
360 private @Nullable Consumption getConsumptionData(LocalDate from, LocalDate to) {
361 logger.debug("getConsumptionData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE),
362 to.format(DateTimeFormatter.ISO_LOCAL_DATE));
363 EnedisHttpApi api = this.enedisApi;
366 Consumption consumption = api.getEnergyData(userId, prmId, from, to);
367 updateStatus(ThingStatus.ONLINE);
369 } catch (LinkyException e) {
370 logger.debug("Exception when getting consumption data: {}", e.getMessage(), e);
371 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
377 private @Nullable Consumption getPowerData(LocalDate from, LocalDate to) {
378 logger.debug("getPowerData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE),
379 to.format(DateTimeFormatter.ISO_LOCAL_DATE));
380 EnedisHttpApi api = this.enedisApi;
383 Consumption consumption = api.getPowerData(userId, prmId, from, to);
384 updateStatus(ThingStatus.ONLINE);
386 } catch (LinkyException e) {
387 logger.debug("Exception when getting power data: {}", e.getMessage(), e);
388 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
394 private boolean isConnected() {
395 EnedisHttpApi api = this.enedisApi;
396 return api == null ? false : api.isConnected();
399 private void disconnect() {
400 EnedisHttpApi api = this.enedisApi;
404 } catch (LinkyException e) {
405 logger.debug("disconnect: {}", e.getMessage());
411 public void dispose() {
412 logger.debug("Disposing the Linky handler.");
413 ScheduledFuture<?> job = this.refreshJob;
414 if (job != null && !job.isCancelled()) {
423 public synchronized void handleCommand(ChannelUID channelUID, Command command) {
424 if (command instanceof RefreshType) {
425 logger.debug("Refreshing channel {}", channelUID.getId());
426 boolean connectedBefore = isConnected();
427 switch (channelUID.getId()) {
431 updateDailyWeeklyData();
448 if (!connectedBefore && isConnected()) {
452 logger.debug("The Linky binding is read-only and can not handle command {}", command);
456 private @Nullable Consumption getConsumptionAfterChecks(Consumption consumption, Target target) {
458 checkData(consumption);
459 } catch (LinkyException e) {
460 logger.debug("Consumption data: {}", e.getMessage());
463 if (target == Target.FIRST && !isDataFirstDayAvailable(consumption)) {
464 logger.debug("Data including yesterday are not yet available");
467 if (target == Target.LAST && !isDataLastDayAvailable(consumption)) {
468 logger.debug("Data including yesterday are not yet available");
474 private void checkData(Consumption consumption) throws LinkyException {
475 if (consumption.aggregats.days.periodes.isEmpty()) {
476 throw new LinkyException("Invalid consumptions data: no day period");
478 if (consumption.aggregats.days.periodes.size() != consumption.aggregats.days.datas.size()) {
479 throw new LinkyException("Invalid consumptions data: not any data for each day period");
481 if (consumption.aggregats.weeks.periodes.isEmpty()) {
482 throw new LinkyException("Invalid consumptions data: no week period");
484 if (consumption.aggregats.weeks.periodes.size() != consumption.aggregats.weeks.datas.size()) {
485 throw new LinkyException("Invalid consumptions data: not any data for each week period");
487 if (consumption.aggregats.months.periodes.isEmpty()) {
488 throw new LinkyException("Invalid consumptions data: no month period");
490 if (consumption.aggregats.months.periodes.size() != consumption.aggregats.months.datas.size()) {
491 throw new LinkyException("Invalid consumptions data: not any data for each month period");
493 if (consumption.aggregats.years.periodes.isEmpty()) {
494 throw new LinkyException("Invalid consumptions data: no year period");
496 if (consumption.aggregats.years.periodes.size() != consumption.aggregats.years.datas.size()) {
497 throw new LinkyException("Invalid consumptions data: not any data for each year period");
501 private boolean isDataFirstDayAvailable(Consumption consumption) {
502 Aggregate days = consumption.aggregats.days;
503 logData(days, "First day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.FIRST);
504 return days.datas != null && !days.datas.isEmpty() && !days.datas.get(0).isNaN();
507 private boolean isDataLastDayAvailable(Consumption consumption) {
508 Aggregate days = consumption.aggregats.days;
509 logData(days, "Last day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.LAST);
510 return days.datas != null && !days.datas.isEmpty() && !days.datas.get(days.datas.size() - 1).isNaN();
513 private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter,
515 if (logger.isDebugEnabled()) {
516 int size = (aggregate.datas == null || aggregate.periodes == null) ? 0
517 : (aggregate.datas.size() <= aggregate.periodes.size() ? aggregate.datas.size()
518 : aggregate.periodes.size());
519 if (target == Target.FIRST) {
521 logData(aggregate, 0, title, withDateFin, dateTimeFormatter);
523 } else if (target == Target.LAST) {
525 logData(aggregate, size - 1, title, withDateFin, dateTimeFormatter);
528 for (int i = 0; i < size; i++) {
529 logData(aggregate, i, title, withDateFin, dateTimeFormatter);
535 private void logData(Aggregate aggregate, int index, String title, boolean withDateFin,
536 DateTimeFormatter dateTimeFormatter) {
538 logger.debug("{} {} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
539 aggregate.periodes.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index));
541 logger.debug("{} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
542 aggregate.datas.get(index));