]> git.basschouten.com Git - openhab-addons.git/blob
2b408e199862bc8575fa90f6e249587e282ecf23
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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             LocalDate to = LocalDate.now();
109             LocalDate from = to.minusDays(1);
110             Consumption consumption = getPowerData(from, to);
111             if (consumption != null) {
112                 logData(consumption.aggregats.days, "Day (peak)", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME,
113                         Target.ALL);
114                 consumption = getConsumptionAfterChecks(consumption, Target.FIRST);
115             }
116             return consumption;
117         });
118
119         this.cachedMonthlyData = new ExpiringDayCache<>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
120             LocalDate today = LocalDate.now();
121             Consumption consumption = getConsumptionData(today.withDayOfMonth(1).minusMonths(1), today);
122             if (consumption != null) {
123                 logData(consumption.aggregats.months, "Month", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL);
124                 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
125             }
126             return consumption;
127         });
128
129         this.cachedYearlyData = new ExpiringDayCache<>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
130             LocalDate today = LocalDate.now();
131             Consumption consumption = getConsumptionData(LocalDate.of(today.getYear() - 1, 1, 1), today);
132             if (consumption != null) {
133                 logData(consumption.aggregats.years, "Year", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL);
134                 consumption = getConsumptionAfterChecks(consumption, Target.LAST);
135             }
136             return consumption;
137         });
138     }
139
140     @Override
141     public void initialize() {
142         logger.debug("Initializing Linky handler.");
143         updateStatus(ThingStatus.UNKNOWN);
144
145         LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
146         if (config.seemsValid()) {
147             enedisApi = new EnedisHttpApi(config, gson, httpClient);
148             scheduler.submit(() -> {
149                 try {
150                     EnedisHttpApi api = this.enedisApi;
151                     api.initialize();
152                     updateStatus(ThingStatus.ONLINE);
153
154                     if (thing.getProperties().isEmpty()) {
155                         PrmInfo prmInfo = api.getPrmInfo();
156                         updateProperties(Map.of(USER_ID, api.getUserInfo().userProperties.internId, PUISSANCE,
157                                 prmInfo.puissanceSouscrite + " kVA", PRM_ID, prmInfo.prmId));
158                     }
159
160                     prmId = thing.getProperties().get(PRM_ID);
161                     userId = thing.getProperties().get(USER_ID);
162
163                     updateData();
164
165                     disconnect();
166
167                     final LocalDateTime now = LocalDateTime.now();
168                     final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
169                             .truncatedTo(ChronoUnit.HOURS);
170
171                     refreshJob = scheduler.scheduleWithFixedDelay(this::updateData,
172                             ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1,
173                             REFRESH_INTERVAL_IN_MIN, TimeUnit.MINUTES);
174                 } catch (LinkyException e) {
175                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
176                 }
177             });
178         } else {
179             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
180                     "@text/offline.config-error-mandatory-settings");
181         }
182     }
183
184     /**
185      * Request new data and updates channels
186      */
187     private synchronized void updateData() {
188         boolean connectedBefore = isConnected();
189         updatePowerData();
190         updateDailyWeeklyData();
191         updateMonthlyData();
192         updateYearlyData();
193         if (!connectedBefore && isConnected()) {
194             disconnect();
195         }
196     }
197
198     private synchronized void updatePowerData() {
199         if (isLinked(PEAK_POWER) || isLinked(PEAK_TIMESTAMP)) {
200             cachedPowerData.getValue().ifPresentOrElse(values -> {
201                 Aggregate days = values.aggregats.days;
202                 updateVAChannel(PEAK_POWER, days.datas.get(0));
203                 updateState(PEAK_TIMESTAMP, new DateTimeType(days.periodes.get(0).dateDebut));
204             }, () -> {
205                 updateKwhChannel(PEAK_POWER, Double.NaN);
206                 updateState(PEAK_TIMESTAMP, UnDefType.UNDEF);
207             });
208         }
209     }
210
211     /**
212      * Request new dayly/weekly data and updates channels
213      */
214     private synchronized void updateDailyWeeklyData() {
215         if (isLinked(YESTERDAY) || isLinked(LAST_WEEK) || isLinked(THIS_WEEK)) {
216             cachedDailyData.getValue().ifPresentOrElse(values -> {
217                 Aggregate days = values.aggregats.days;
218                 updateKwhChannel(YESTERDAY, days.datas.get(days.datas.size() - 1));
219                 int idxLast = days.periodes.get(days.periodes.size() - 1).dateDebut.get(weekFields.dayOfWeek()) == 7 ? 2
220                         : 1;
221                 Aggregate weeks = values.aggregats.weeks;
222                 if (weeks.datas.size() > idxLast) {
223                     updateKwhChannel(LAST_WEEK, weeks.datas.get(idxLast));
224                 }
225                 if (weeks.datas.size() > (idxLast + 1)) {
226                     updateKwhChannel(THIS_WEEK, weeks.datas.get(idxLast + 1));
227                 } else {
228                     updateKwhChannel(THIS_WEEK, 0.0);
229                 }
230             }, () -> {
231                 updateKwhChannel(YESTERDAY, Double.NaN);
232                 if (ZonedDateTime.now().get(weekFields.dayOfWeek()) == 1) {
233                     updateKwhChannel(THIS_WEEK, 0.0);
234                     updateKwhChannel(LAST_WEEK, Double.NaN);
235                 } else {
236                     updateKwhChannel(THIS_WEEK, Double.NaN);
237                 }
238             });
239         }
240     }
241
242     /**
243      * Request new monthly data and updates channels
244      */
245     private synchronized void updateMonthlyData() {
246         if (isLinked(LAST_MONTH) || isLinked(THIS_MONTH)) {
247             cachedMonthlyData.getValue().ifPresentOrElse(values -> {
248                 Aggregate months = values.aggregats.months;
249                 updateKwhChannel(LAST_MONTH, months.datas.get(0));
250                 if (months.datas.size() > 1) {
251                     updateKwhChannel(THIS_MONTH, months.datas.get(1));
252                 } else {
253                     updateKwhChannel(THIS_MONTH, 0.0);
254                 }
255             }, () -> {
256                 if (ZonedDateTime.now().getDayOfMonth() == 1) {
257                     updateKwhChannel(THIS_MONTH, 0.0);
258                     updateKwhChannel(LAST_MONTH, Double.NaN);
259                 } else {
260                     updateKwhChannel(THIS_MONTH, Double.NaN);
261                 }
262             });
263         }
264     }
265
266     /**
267      * Request new yearly data and updates channels
268      */
269     private synchronized void updateYearlyData() {
270         if (isLinked(LAST_YEAR) || isLinked(THIS_YEAR)) {
271             cachedYearlyData.getValue().ifPresentOrElse(values -> {
272                 Aggregate years = values.aggregats.years;
273                 updateKwhChannel(LAST_YEAR, years.datas.get(0));
274                 if (years.datas.size() > 1) {
275                     updateKwhChannel(THIS_YEAR, years.datas.get(1));
276                 } else {
277                     updateKwhChannel(THIS_YEAR, 0.0);
278                 }
279             }, () -> {
280                 if (ZonedDateTime.now().getDayOfYear() == 1) {
281                     updateKwhChannel(THIS_YEAR, 0.0);
282                     updateKwhChannel(LAST_YEAR, Double.NaN);
283                 } else {
284                     updateKwhChannel(THIS_YEAR, Double.NaN);
285                 }
286             });
287         }
288     }
289
290     private void updateKwhChannel(String channelId, double consumption) {
291         logger.debug("Update channel {} with {}", channelId, consumption);
292         updateState(channelId,
293                 Double.isNaN(consumption) ? UnDefType.UNDEF : new QuantityType<>(consumption, Units.KILOWATT_HOUR));
294     }
295
296     private void updateVAChannel(String channelId, double power) {
297         logger.debug("Update channel {} with {}", channelId, power);
298         updateState(channelId, Double.isNaN(power) ? UnDefType.UNDEF : new QuantityType<>(power, Units.VOLT_AMPERE));
299     }
300
301     /**
302      * Produce a report of all daily values between two dates
303      *
304      * @param startDay the start day of the report
305      * @param endDay the end day of the report
306      * @param separator the separator to be used betwwen the date and the value
307      *
308      * @return the report as a list of string
309      */
310     public synchronized List<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
311         List<String> report = buildReport(startDay, endDay, separator);
312         disconnect();
313         return report;
314     }
315
316     private List<String> buildReport(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
317         List<String> report = new ArrayList<>();
318         if (startDay.getYear() == endDay.getYear() && startDay.getMonthValue() == endDay.getMonthValue()) {
319             // All values in the same month
320             Consumption result = getConsumptionData(startDay, endDay.plusDays(1));
321             if (result != null) {
322                 Aggregate days = result.aggregats.days;
323                 int size = (days.datas == null || days.periodes == null) ? 0
324                         : (days.datas.size() <= days.periodes.size() ? days.datas.size() : days.periodes.size());
325                 for (int i = 0; i < size; i++) {
326                     double consumption = days.datas.get(i);
327                     String line = days.periodes.get(i).dateDebut.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator;
328                     if (consumption >= 0) {
329                         line += String.valueOf(consumption);
330                     }
331                     report.add(line);
332                 }
333             } else {
334                 LocalDate currentDay = startDay;
335                 while (!currentDay.isAfter(endDay)) {
336                     report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator);
337                     currentDay = currentDay.plusDays(1);
338                 }
339             }
340         } else {
341             // Concatenate the report produced for each month between the two dates
342             LocalDate first = startDay;
343             do {
344                 LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
345                 if (last.isAfter(endDay)) {
346                     last = endDay;
347                 }
348                 report.addAll(buildReport(first, last, separator));
349                 first = last.plusDays(1);
350             } while (!first.isAfter(endDay));
351         }
352         return report;
353     }
354
355     private @Nullable Consumption getConsumptionData(LocalDate from, LocalDate to) {
356         logger.debug("getConsumptionData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE),
357                 to.format(DateTimeFormatter.ISO_LOCAL_DATE));
358         EnedisHttpApi api = this.enedisApi;
359         if (api != null) {
360             try {
361                 Consumption consumption = api.getEnergyData(userId, prmId, from, to);
362                 updateStatus(ThingStatus.ONLINE);
363                 return consumption;
364             } catch (LinkyException e) {
365                 logger.debug("Exception when getting consumption data: {}", e.getMessage(), e);
366                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
367             }
368         }
369         return null;
370     }
371
372     private @Nullable Consumption getPowerData(LocalDate from, LocalDate to) {
373         logger.debug("getPowerData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE),
374                 to.format(DateTimeFormatter.ISO_LOCAL_DATE));
375         EnedisHttpApi api = this.enedisApi;
376         if (api != null) {
377             try {
378                 Consumption consumption = api.getPowerData(userId, prmId, from, to);
379                 updateStatus(ThingStatus.ONLINE);
380                 return consumption;
381             } catch (LinkyException e) {
382                 logger.debug("Exception when getting power data: {}", e.getMessage(), e);
383                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
384             }
385         }
386         return null;
387     }
388
389     private boolean isConnected() {
390         EnedisHttpApi api = this.enedisApi;
391         return api == null ? false : api.isConnected();
392     }
393
394     private void disconnect() {
395         EnedisHttpApi api = this.enedisApi;
396         if (api != null) {
397             try {
398                 api.dispose();
399             } catch (LinkyException e) {
400                 logger.debug("disconnect: {}", e.getMessage());
401             }
402         }
403     }
404
405     @Override
406     public void dispose() {
407         logger.debug("Disposing the Linky handler.");
408         ScheduledFuture<?> job = this.refreshJob;
409         if (job != null && !job.isCancelled()) {
410             job.cancel(true);
411             refreshJob = null;
412         }
413         disconnect();
414         enedisApi = null;
415     }
416
417     @Override
418     public synchronized void handleCommand(ChannelUID channelUID, Command command) {
419         if (command instanceof RefreshType) {
420             logger.debug("Refreshing channel {}", channelUID.getId());
421             boolean connectedBefore = isConnected();
422             switch (channelUID.getId()) {
423                 case YESTERDAY:
424                 case LAST_WEEK:
425                 case THIS_WEEK:
426                     updateDailyWeeklyData();
427                     break;
428                 case LAST_MONTH:
429                 case THIS_MONTH:
430                     updateMonthlyData();
431                     break;
432                 case LAST_YEAR:
433                 case THIS_YEAR:
434                     updateYearlyData();
435                     break;
436                 case PEAK_POWER:
437                 case PEAK_TIMESTAMP:
438                     updatePowerData();
439                     break;
440                 default:
441                     break;
442             }
443             if (!connectedBefore && isConnected()) {
444                 disconnect();
445             }
446         } else {
447             logger.debug("The Linky binding is read-only and can not handle command {}", command);
448         }
449     }
450
451     private @Nullable Consumption getConsumptionAfterChecks(Consumption consumption, Target target) {
452         try {
453             checkData(consumption);
454         } catch (LinkyException e) {
455             logger.debug("Consumption data: {}", e.getMessage());
456             return null;
457         }
458         if (target == Target.FIRST && !isDataFirstDayAvailable(consumption)) {
459             logger.debug("Data including yesterday are not yet available");
460             return null;
461         }
462         if (target == Target.LAST && !isDataLastDayAvailable(consumption)) {
463             logger.debug("Data including yesterday are not yet available");
464             return null;
465         }
466         return consumption;
467     }
468
469     private void checkData(Consumption consumption) throws LinkyException {
470         if (consumption.aggregats.days.periodes.size() == 0) {
471             throw new LinkyException("Invalid consumptions data: no day period");
472         }
473         if (consumption.aggregats.days.periodes.size() != consumption.aggregats.days.datas.size()) {
474             throw new LinkyException("Invalid consumptions data: not any data for each day period");
475         }
476         if (consumption.aggregats.weeks.periodes.size() == 0) {
477             throw new LinkyException("Invalid consumptions data: no week period");
478         }
479         if (consumption.aggregats.weeks.periodes.size() != consumption.aggregats.weeks.datas.size()) {
480             throw new LinkyException("Invalid consumptions data: not any data for each week period");
481         }
482         if (consumption.aggregats.months.periodes.size() == 0) {
483             throw new LinkyException("Invalid consumptions data: no month period");
484         }
485         if (consumption.aggregats.months.periodes.size() != consumption.aggregats.months.datas.size()) {
486             throw new LinkyException("Invalid consumptions data: not any data for each month period");
487         }
488         if (consumption.aggregats.years.periodes.size() == 0) {
489             throw new LinkyException("Invalid consumptions data: no year period");
490         }
491         if (consumption.aggregats.years.periodes.size() != consumption.aggregats.years.datas.size()) {
492             throw new LinkyException("Invalid consumptions data: not any data for each year period");
493         }
494     }
495
496     private boolean isDataFirstDayAvailable(Consumption consumption) {
497         Aggregate days = consumption.aggregats.days;
498         logData(days, "First day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.FIRST);
499         return days.datas != null && days.datas.size() > 0 && !days.datas.get(0).isNaN();
500     }
501
502     private boolean isDataLastDayAvailable(Consumption consumption) {
503         Aggregate days = consumption.aggregats.days;
504         logData(days, "Last day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.LAST);
505         return days.datas != null && days.datas.size() > 0 && !days.datas.get(days.datas.size() - 1).isNaN();
506     }
507
508     private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter,
509             Target target) {
510         if (logger.isDebugEnabled()) {
511             int size = (aggregate.datas == null || aggregate.periodes == null) ? 0
512                     : (aggregate.datas.size() <= aggregate.periodes.size() ? aggregate.datas.size()
513                             : aggregate.periodes.size());
514             if (target == Target.FIRST) {
515                 if (size > 0) {
516                     logData(aggregate, 0, title, withDateFin, dateTimeFormatter);
517                 }
518             } else if (target == Target.LAST) {
519                 if (size > 0) {
520                     logData(aggregate, size - 1, title, withDateFin, dateTimeFormatter);
521                 }
522             } else {
523                 for (int i = 0; i < size; i++) {
524                     logData(aggregate, i, title, withDateFin, dateTimeFormatter);
525                 }
526             }
527         }
528     }
529
530     private void logData(Aggregate aggregate, int index, String title, boolean withDateFin,
531             DateTimeFormatter dateTimeFormatter) {
532         if (withDateFin) {
533             logger.debug("{} {} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
534                     aggregate.periodes.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index));
535         } else {
536             logger.debug("{} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
537                     aggregate.datas.get(index));
538         }
539     }
540 }