]> git.basschouten.com Git - openhab-addons.git/blob
23b2d05686ba00fa1f747a0ce138f649330ba5e0
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.linky.internal.handler;
14
15 import static org.openhab.binding.linky.internal.LinkyBindingConstants.*;
16
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;
25 import java.util.Map;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28
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;
53
54 import com.google.gson.Gson;
55
56 /**
57  * The {@link LinkyHandler} is responsible for handling commands, which are
58  * sent to one of the channels.
59  *
60  * @author GaĆ«l L'hopital - Initial contribution
61  */
62
63 @NonNullByDefault
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;
67
68     private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class);
69     private final HttpClient httpClient;
70     private final Gson gson;
71     private final WeekFields weekFields;
72
73     private final ExpiringDayCache<Consumption> cachedDailyData;
74     private final ExpiringDayCache<Consumption> cachedPowerData;
75     private final ExpiringDayCache<Consumption> cachedMonthlyData;
76     private final ExpiringDayCache<Consumption> cachedYearlyData;
77
78     private @Nullable ScheduledFuture<?> refreshJob;
79     private @Nullable EnedisHttpApi enedisApi;
80
81     private @NonNullByDefault({}) String prmId;
82     private @NonNullByDefault({}) String userId;
83
84     private enum Target {
85         FIRST,
86         LAST,
87         ALL
88     }
89
90     public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) {
91         super(thing);
92         this.gson = gson;
93         this.httpClient = httpClient;
94         this.weekFields = WeekFields.of(localeProvider.getLocale());
95
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);
103             }
104             return consumption;
105         });
106
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
111             // yet available.
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,
118                         Target.ALL);
119                 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
120             }
121             return consumption;
122         });
123
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);
130             }
131             return consumption;
132         });
133
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);
140             }
141             return consumption;
142         });
143     }
144
145     @Override
146     public void initialize() {
147         logger.debug("Initializing Linky handler.");
148         updateStatus(ThingStatus.UNKNOWN);
149
150         LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
151         if (config.seemsValid()) {
152             enedisApi = new EnedisHttpApi(config, gson, httpClient);
153             scheduler.submit(() -> {
154                 try {
155                     EnedisHttpApi api = this.enedisApi;
156                     api.initialize();
157                     updateStatus(ThingStatus.ONLINE);
158
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));
163                     }
164
165                     prmId = thing.getProperties().get(PRM_ID);
166                     userId = thing.getProperties().get(USER_ID);
167
168                     updateData();
169
170                     disconnect();
171
172                     final LocalDateTime now = LocalDateTime.now();
173                     final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
174                             .truncatedTo(ChronoUnit.HOURS);
175
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());
181                 }
182             });
183         } else {
184             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
185                     "@text/offline.config-error-mandatory-settings");
186         }
187     }
188
189     /**
190      * Request new data and updates channels
191      */
192     private synchronized void updateData() {
193         boolean connectedBefore = isConnected();
194         updatePowerData();
195         updateDailyWeeklyData();
196         updateMonthlyData();
197         updateYearlyData();
198         if (!connectedBefore && isConnected()) {
199             disconnect();
200         }
201     }
202
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));
209             }, () -> {
210                 updateKwhChannel(PEAK_POWER, Double.NaN);
211                 updateState(PEAK_TIMESTAMP, UnDefType.UNDEF);
212             });
213         }
214     }
215
216     /**
217      * Request new dayly/weekly data and updates channels
218      */
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
225                         : 1;
226                 Aggregate weeks = values.aggregats.weeks;
227                 if (weeks.datas.size() > idxLast) {
228                     updateKwhChannel(LAST_WEEK, weeks.datas.get(idxLast));
229                 }
230                 if (weeks.datas.size() > (idxLast + 1)) {
231                     updateKwhChannel(THIS_WEEK, weeks.datas.get(idxLast + 1));
232                 } else {
233                     updateKwhChannel(THIS_WEEK, 0.0);
234                 }
235             }, () -> {
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);
240                 } else {
241                     updateKwhChannel(THIS_WEEK, Double.NaN);
242                 }
243             });
244         }
245     }
246
247     /**
248      * Request new monthly data and updates channels
249      */
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));
257                 } else {
258                     updateKwhChannel(THIS_MONTH, 0.0);
259                 }
260             }, () -> {
261                 if (ZonedDateTime.now().getDayOfMonth() == 1) {
262                     updateKwhChannel(THIS_MONTH, 0.0);
263                     updateKwhChannel(LAST_MONTH, Double.NaN);
264                 } else {
265                     updateKwhChannel(THIS_MONTH, Double.NaN);
266                 }
267             });
268         }
269     }
270
271     /**
272      * Request new yearly data and updates channels
273      */
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));
281                 } else {
282                     updateKwhChannel(THIS_YEAR, 0.0);
283                 }
284             }, () -> {
285                 if (ZonedDateTime.now().getDayOfYear() == 1) {
286                     updateKwhChannel(THIS_YEAR, 0.0);
287                     updateKwhChannel(LAST_YEAR, Double.NaN);
288                 } else {
289                     updateKwhChannel(THIS_YEAR, Double.NaN);
290                 }
291             });
292         }
293     }
294
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));
299     }
300
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));
304     }
305
306     /**
307      * Produce a report of all daily values between two dates
308      *
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
312      *
313      * @return the report as a list of string
314      */
315     public synchronized List<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
316         List<String> report = buildReport(startDay, endDay, separator);
317         disconnect();
318         return report;
319     }
320
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);
335                     }
336                     report.add(line);
337                 }
338             } else {
339                 LocalDate currentDay = startDay;
340                 while (!currentDay.isAfter(endDay)) {
341                     report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator);
342                     currentDay = currentDay.plusDays(1);
343                 }
344             }
345         } else {
346             // Concatenate the report produced for each month between the two dates
347             LocalDate first = startDay;
348             do {
349                 LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
350                 if (last.isAfter(endDay)) {
351                     last = endDay;
352                 }
353                 report.addAll(buildReport(first, last, separator));
354                 first = last.plusDays(1);
355             } while (!first.isAfter(endDay));
356         }
357         return report;
358     }
359
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;
364         if (api != null) {
365             try {
366                 Consumption consumption = api.getEnergyData(userId, prmId, from, to);
367                 updateStatus(ThingStatus.ONLINE);
368                 return consumption;
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());
372             }
373         }
374         return null;
375     }
376
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;
381         if (api != null) {
382             try {
383                 Consumption consumption = api.getPowerData(userId, prmId, from, to);
384                 updateStatus(ThingStatus.ONLINE);
385                 return consumption;
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());
389             }
390         }
391         return null;
392     }
393
394     private boolean isConnected() {
395         EnedisHttpApi api = this.enedisApi;
396         return api == null ? false : api.isConnected();
397     }
398
399     private void disconnect() {
400         EnedisHttpApi api = this.enedisApi;
401         if (api != null) {
402             try {
403                 api.dispose();
404             } catch (LinkyException e) {
405                 logger.debug("disconnect: {}", e.getMessage());
406             }
407         }
408     }
409
410     @Override
411     public void dispose() {
412         logger.debug("Disposing the Linky handler.");
413         ScheduledFuture<?> job = this.refreshJob;
414         if (job != null && !job.isCancelled()) {
415             job.cancel(true);
416             refreshJob = null;
417         }
418         disconnect();
419         enedisApi = null;
420     }
421
422     @Override
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()) {
428                 case YESTERDAY:
429                 case LAST_WEEK:
430                 case THIS_WEEK:
431                     updateDailyWeeklyData();
432                     break;
433                 case LAST_MONTH:
434                 case THIS_MONTH:
435                     updateMonthlyData();
436                     break;
437                 case LAST_YEAR:
438                 case THIS_YEAR:
439                     updateYearlyData();
440                     break;
441                 case PEAK_POWER:
442                 case PEAK_TIMESTAMP:
443                     updatePowerData();
444                     break;
445                 default:
446                     break;
447             }
448             if (!connectedBefore && isConnected()) {
449                 disconnect();
450             }
451         } else {
452             logger.debug("The Linky binding is read-only and can not handle command {}", command);
453         }
454     }
455
456     private @Nullable Consumption getConsumptionAfterChecks(Consumption consumption, Target target) {
457         try {
458             checkData(consumption);
459         } catch (LinkyException e) {
460             logger.debug("Consumption data: {}", e.getMessage());
461             return null;
462         }
463         if (target == Target.FIRST && !isDataFirstDayAvailable(consumption)) {
464             logger.debug("Data including yesterday are not yet available");
465             return null;
466         }
467         if (target == Target.LAST && !isDataLastDayAvailable(consumption)) {
468             logger.debug("Data including yesterday are not yet available");
469             return null;
470         }
471         return consumption;
472     }
473
474     private void checkData(Consumption consumption) throws LinkyException {
475         if (consumption.aggregats.days.periodes.size() == 0) {
476             throw new LinkyException("Invalid consumptions data: no day period");
477         }
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");
480         }
481         if (consumption.aggregats.weeks.periodes.size() == 0) {
482             throw new LinkyException("Invalid consumptions data: no week period");
483         }
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");
486         }
487         if (consumption.aggregats.months.periodes.size() == 0) {
488             throw new LinkyException("Invalid consumptions data: no month period");
489         }
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");
492         }
493         if (consumption.aggregats.years.periodes.size() == 0) {
494             throw new LinkyException("Invalid consumptions data: no year period");
495         }
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");
498         }
499     }
500
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.size() > 0 && !days.datas.get(0).isNaN();
505     }
506
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.size() > 0 && !days.datas.get(days.datas.size() - 1).isNaN();
511     }
512
513     private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter,
514             Target target) {
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) {
520                 if (size > 0) {
521                     logData(aggregate, 0, title, withDateFin, dateTimeFormatter);
522                 }
523             } else if (target == Target.LAST) {
524                 if (size > 0) {
525                     logData(aggregate, size - 1, title, withDateFin, dateTimeFormatter);
526                 }
527             } else {
528                 for (int i = 0; i < size; i++) {
529                     logData(aggregate, i, title, withDateFin, dateTimeFormatter);
530                 }
531             }
532         }
533     }
534
535     private void logData(Aggregate aggregate, int index, String title, boolean withDateFin,
536             DateTimeFormatter dateTimeFormatter) {
537         if (withDateFin) {
538             logger.debug("{} {} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
539                     aggregate.periodes.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index));
540         } else {
541             logger.debug("{} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
542                     aggregate.datas.get(index));
543         }
544     }
545 }