2 * Copyright (c) 2010-2022 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.HashMap;
25 import java.util.List;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.openhab.binding.linky.internal.LinkyConfiguration;
34 import org.openhab.binding.linky.internal.LinkyException;
35 import org.openhab.binding.linky.internal.api.EnedisHttpApi;
36 import org.openhab.binding.linky.internal.api.ExpiringDayCache;
37 import org.openhab.binding.linky.internal.dto.ConsumptionReport.Aggregate;
38 import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption;
39 import org.openhab.binding.linky.internal.dto.PrmInfo;
40 import org.openhab.binding.linky.internal.dto.UserInfo;
41 import org.openhab.core.i18n.LocaleProvider;
42 import org.openhab.core.library.types.DateTimeType;
43 import org.openhab.core.library.types.QuantityType;
44 import org.openhab.core.library.unit.Units;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.binding.BaseThingHandler;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.openhab.core.types.UnDefType;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
56 import com.google.gson.Gson;
59 * The {@link LinkyHandler} is responsible for handling commands, which are
60 * sent to one of the channels.
62 * @author Gaƫl L'hopital - Initial contribution
66 public class LinkyHandler extends BaseThingHandler {
67 private static final int REFRESH_FIRST_HOUR_OF_DAY = 1;
68 private static final int REFRESH_INTERVAL_IN_MIN = 120;
70 private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class);
72 private final HttpClient httpClient;
73 private final Gson gson;
74 private final WeekFields weekFields;
76 private @Nullable ScheduledFuture<?> refreshJob;
77 private @Nullable EnedisHttpApi enedisApi;
79 private final ExpiringDayCache<Consumption> cachedDailyData;
80 private final ExpiringDayCache<Consumption> cachedPowerData;
81 private final ExpiringDayCache<Consumption> cachedMonthlyData;
82 private final ExpiringDayCache<Consumption> cachedYearlyData;
84 private @NonNullByDefault({}) String prmId;
85 private @NonNullByDefault({}) String userId;
93 public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) {
96 this.httpClient = httpClient;
97 this.weekFields = WeekFields.of(localeProvider.getLocale());
99 this.cachedDailyData = new ExpiringDayCache<>("daily cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
100 LocalDate today = LocalDate.now();
101 Consumption consumption = getConsumptionData(today.minusDays(15), today);
102 if (consumption != null) {
103 logData(consumption.aggregats.days, "Day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.ALL);
104 logData(consumption.aggregats.weeks, "Week", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL);
105 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
110 this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
111 LocalDate to = LocalDate.now().plusDays(1);
112 LocalDate from = to.minusDays(2);
113 Consumption consumption = getPowerData(from, to);
114 if (consumption != null) {
115 logData(consumption.aggregats.days, "Day (peak)", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME,
117 consumption = getConsumptionAfterChecks(consumption, Target.FIRST);
122 this.cachedMonthlyData = new ExpiringDayCache<>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
123 LocalDate today = LocalDate.now();
124 Consumption consumption = getConsumptionData(today.withDayOfMonth(1).minusMonths(1), today);
125 if (consumption != null) {
126 logData(consumption.aggregats.months, "Month", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL);
127 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
132 this.cachedYearlyData = new ExpiringDayCache<>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
133 LocalDate today = LocalDate.now();
134 Consumption consumption = getConsumptionData(LocalDate.of(today.getYear() - 1, 1, 1), today);
135 if (consumption != null) {
136 logData(consumption.aggregats.years, "Year", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL);
137 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
144 public void initialize() {
145 logger.debug("Initializing Linky handler.");
146 updateStatus(ThingStatus.UNKNOWN);
148 LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
149 if (config.seemsValid()) {
150 enedisApi = new EnedisHttpApi(config, gson, httpClient);
151 scheduler.submit(() -> {
153 EnedisHttpApi api = this.enedisApi;
155 updateStatus(ThingStatus.ONLINE);
157 if (thing.getProperties().isEmpty()) {
158 Map<String, String> properties = new HashMap<>();
159 PrmInfo prmInfo = api.getPrmInfo();
160 UserInfo userInfo = api.getUserInfo();
161 properties.put(USER_ID, userInfo.userProperties.internId);
162 properties.put(PUISSANCE, prmInfo.puissanceSouscrite + " kVA");
163 properties.put(PRM_ID, prmInfo.prmId);
164 updateProperties(properties);
167 prmId = thing.getProperties().get(PRM_ID);
168 userId = thing.getProperties().get(USER_ID);
174 final LocalDateTime now = LocalDateTime.now();
175 final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
176 .truncatedTo(ChronoUnit.HOURS);
178 refreshJob = scheduler.scheduleWithFixedDelay(this::updateData,
179 ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1,
180 REFRESH_INTERVAL_IN_MIN, TimeUnit.MINUTES);
181 } catch (LinkyException e) {
182 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
186 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
187 "@text/offline.config-error-mandatory-settings");
192 * Request new data and updates channels
194 private synchronized void updateData() {
195 boolean connectedBefore = isConnected();
197 updateDailyWeeklyData();
200 if (!connectedBefore && isConnected()) {
205 private synchronized void updatePowerData() {
206 if (isLinked(PEAK_POWER) || isLinked(PEAK_TIMESTAMP)) {
207 cachedPowerData.getValue().ifPresentOrElse(values -> {
208 Aggregate days = values.aggregats.days;
209 updateVAChannel(PEAK_POWER, days.datas.get(0));
210 updateState(PEAK_TIMESTAMP, new DateTimeType(days.periodes.get(0).dateDebut));
212 updateKwhChannel(PEAK_POWER, Double.NaN);
213 updateState(PEAK_TIMESTAMP, UnDefType.UNDEF);
219 * Request new dayly/weekly data and updates channels
221 private synchronized void updateDailyWeeklyData() {
222 if (isLinked(YESTERDAY) || isLinked(LAST_WEEK) || isLinked(THIS_WEEK)) {
223 cachedDailyData.getValue().ifPresentOrElse(values -> {
224 Aggregate days = values.aggregats.days;
225 updateKwhChannel(YESTERDAY, days.datas.get(days.datas.size() - 1));
226 int idxLast = days.periodes.get(days.periodes.size() - 1).dateDebut.get(weekFields.dayOfWeek()) == 7 ? 2
228 Aggregate weeks = values.aggregats.weeks;
229 if (weeks.datas.size() > idxLast) {
230 updateKwhChannel(LAST_WEEK, weeks.datas.get(idxLast));
232 if (weeks.datas.size() > (idxLast + 1)) {
233 updateKwhChannel(THIS_WEEK, weeks.datas.get(idxLast + 1));
235 updateKwhChannel(THIS_WEEK, 0.0);
238 updateKwhChannel(YESTERDAY, Double.NaN);
239 if (ZonedDateTime.now().get(weekFields.dayOfWeek()) == 1) {
240 updateKwhChannel(THIS_WEEK, 0.0);
241 updateKwhChannel(LAST_WEEK, Double.NaN);
243 updateKwhChannel(THIS_WEEK, Double.NaN);
250 * Request new monthly data and updates channels
252 private synchronized void updateMonthlyData() {
253 if (isLinked(LAST_MONTH) || isLinked(THIS_MONTH)) {
254 cachedMonthlyData.getValue().ifPresentOrElse(values -> {
255 Aggregate months = values.aggregats.months;
256 updateKwhChannel(LAST_MONTH, months.datas.get(0));
257 if (months.datas.size() > 1) {
258 updateKwhChannel(THIS_MONTH, months.datas.get(1));
260 updateKwhChannel(THIS_MONTH, 0.0);
263 if (ZonedDateTime.now().getDayOfMonth() == 1) {
264 updateKwhChannel(THIS_MONTH, 0.0);
265 updateKwhChannel(LAST_MONTH, Double.NaN);
267 updateKwhChannel(THIS_MONTH, Double.NaN);
274 * Request new yearly data and updates channels
276 private synchronized void updateYearlyData() {
277 if (isLinked(LAST_YEAR) || isLinked(THIS_YEAR)) {
278 cachedYearlyData.getValue().ifPresentOrElse(values -> {
279 Aggregate years = values.aggregats.years;
280 updateKwhChannel(LAST_YEAR, years.datas.get(0));
281 if (years.datas.size() > 1) {
282 updateKwhChannel(THIS_YEAR, years.datas.get(1));
284 updateKwhChannel(THIS_YEAR, 0.0);
287 if (ZonedDateTime.now().getDayOfYear() == 1) {
288 updateKwhChannel(THIS_YEAR, 0.0);
289 updateKwhChannel(LAST_YEAR, Double.NaN);
291 updateKwhChannel(THIS_YEAR, Double.NaN);
297 private void updateKwhChannel(String channelId, double consumption) {
298 logger.debug("Update channel {} with {}", channelId, consumption);
299 updateState(channelId,
300 Double.isNaN(consumption) ? UnDefType.UNDEF : new QuantityType<>(consumption, Units.KILOWATT_HOUR));
303 private void updateVAChannel(String channelId, double power) {
304 logger.debug("Update channel {} with {}", channelId, power);
305 updateState(channelId, Double.isNaN(power) ? UnDefType.UNDEF : new QuantityType<>(power, Units.VOLT_AMPERE));
309 * Produce a report of all daily values between two dates
311 * @param startDay the start day of the report
312 * @param endDay the end day of the report
313 * @param separator the separator to be used betwwen the date and the value
315 * @return the report as a list of string
317 public synchronized List<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
318 List<String> report = buildReport(startDay, endDay, separator);
323 private List<String> buildReport(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
324 List<String> report = new ArrayList<>();
325 if (startDay.getYear() == endDay.getYear() && startDay.getMonthValue() == endDay.getMonthValue()) {
326 // All values in the same month
327 Consumption result = getConsumptionData(startDay, endDay.plusDays(1));
328 if (result != null) {
329 Aggregate days = result.aggregats.days;
330 int size = (days.datas == null || days.periodes == null) ? 0
331 : (days.datas.size() <= days.periodes.size() ? days.datas.size() : days.periodes.size());
332 for (int i = 0; i < size; i++) {
333 double consumption = days.datas.get(i);
334 String line = days.periodes.get(i).dateDebut.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator;
335 if (consumption >= 0) {
336 line += String.valueOf(consumption);
341 LocalDate currentDay = startDay;
342 while (!currentDay.isAfter(endDay)) {
343 report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator);
344 currentDay = currentDay.plusDays(1);
348 // Concatenate the report produced for each month between the two dates
349 LocalDate first = startDay;
351 LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
352 if (last.isAfter(endDay)) {
355 report.addAll(buildReport(first, last, separator));
356 first = last.plusDays(1);
357 } while (!first.isAfter(endDay));
362 private @Nullable Consumption getConsumptionData(LocalDate from, LocalDate to) {
363 logger.debug("getConsumptionData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE),
364 to.format(DateTimeFormatter.ISO_LOCAL_DATE));
365 EnedisHttpApi api = this.enedisApi;
368 Consumption consumption = api.getEnergyData(userId, prmId, from, to);
369 updateStatus(ThingStatus.ONLINE);
371 } catch (LinkyException e) {
372 logger.debug("Exception when getting consumption data: {}", e.getMessage(), e);
373 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
379 private @Nullable Consumption getPowerData(LocalDate from, LocalDate to) {
380 logger.debug("getPowerData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE),
381 to.format(DateTimeFormatter.ISO_LOCAL_DATE));
382 EnedisHttpApi api = this.enedisApi;
385 Consumption consumption = api.getPowerData(userId, prmId, from, to);
386 updateStatus(ThingStatus.ONLINE);
388 } catch (LinkyException e) {
389 logger.debug("Exception when getting power data: {}", e.getMessage(), e);
390 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
396 private boolean isConnected() {
397 EnedisHttpApi api = this.enedisApi;
398 return api == null ? false : api.isConnected();
401 private void disconnect() {
402 EnedisHttpApi api = this.enedisApi;
406 } catch (LinkyException e) {
407 logger.debug("disconnect: {}", e.getMessage());
413 public void dispose() {
414 logger.debug("Disposing the Linky handler.");
415 ScheduledFuture<?> job = this.refreshJob;
416 if (job != null && !job.isCancelled()) {
425 public synchronized void handleCommand(ChannelUID channelUID, Command command) {
426 if (command instanceof RefreshType) {
427 logger.debug("Refreshing channel {}", channelUID.getId());
428 boolean connectedBefore = isConnected();
429 switch (channelUID.getId()) {
433 updateDailyWeeklyData();
450 if (!connectedBefore && isConnected()) {
454 logger.debug("The Linky binding is read-only and can not handle command {}", command);
458 private @Nullable Consumption getConsumptionAfterChecks(Consumption consumption, Target target) {
460 checkData(consumption);
461 } catch (LinkyException e) {
462 logger.debug("Consumption data: {}", e.getMessage());
465 if (target == Target.FIRST && !isDataFirstDayAvailable(consumption)) {
466 logger.debug("Data including yesterday are not yet available");
469 if (target == Target.LAST && !isDataLastDayAvailable(consumption)) {
470 logger.debug("Data including yesterday are not yet available");
476 private void checkData(Consumption consumption) throws LinkyException {
477 if (consumption.aggregats.days.periodes.size() == 0) {
478 throw new LinkyException("invalid consumptions data: no day period");
480 if (consumption.aggregats.days.periodes.size() != consumption.aggregats.days.datas.size()) {
481 throw new LinkyException("invalid consumptions data: not one data for each day period");
483 if (consumption.aggregats.weeks.periodes.size() == 0) {
484 throw new LinkyException("invalid consumptions data: no week period");
486 if (consumption.aggregats.weeks.periodes.size() != consumption.aggregats.weeks.datas.size()) {
487 throw new LinkyException("invalid consumptions data: not one data for each week period");
489 if (consumption.aggregats.months.periodes.size() == 0) {
490 throw new LinkyException("invalid consumptions data: no month period");
492 if (consumption.aggregats.months.periodes.size() != consumption.aggregats.months.datas.size()) {
493 throw new LinkyException("invalid consumptions data: not one data for each month period");
495 if (consumption.aggregats.years.periodes.size() == 0) {
496 throw new LinkyException("invalid consumptions data: no year period");
498 if (consumption.aggregats.years.periodes.size() != consumption.aggregats.years.datas.size()) {
499 throw new LinkyException("invalid consumptions data: not one data for each year period");
503 private boolean isDataFirstDayAvailable(Consumption consumption) {
504 Aggregate days = consumption.aggregats.days;
505 logData(days, "First day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.FIRST);
506 return days.datas != null && days.datas.size() > 0 && !days.datas.get(0).isNaN();
509 private boolean isDataLastDayAvailable(Consumption consumption) {
510 Aggregate days = consumption.aggregats.days;
511 logData(days, "Last day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.LAST);
512 return days.datas != null && days.datas.size() > 0 && !days.datas.get(days.datas.size() - 1).isNaN();
515 private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter,
517 if (logger.isDebugEnabled()) {
518 int size = (aggregate.datas == null || aggregate.periodes == null) ? 0
519 : (aggregate.datas.size() <= aggregate.periodes.size() ? aggregate.datas.size()
520 : aggregate.periodes.size());
521 if (target == Target.FIRST) {
523 logData(aggregate, 0, title, withDateFin, dateTimeFormatter);
525 } else if (target == Target.LAST) {
527 logData(aggregate, size - 1, title, withDateFin, dateTimeFormatter);
530 for (int i = 0; i < size; i++) {
531 logData(aggregate, i, title, withDateFin, dateTimeFormatter);
537 private void logData(Aggregate aggregate, int index, String title, boolean withDateFin,
538 DateTimeFormatter dateTimeFormatter) {
540 logger.debug("{} {} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
541 aggregate.periodes.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index));
543 logger.debug("{} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
544 aggregate.datas.get(index));