]> git.basschouten.com Git - openhab-addons.git/blob
4872c7b8c6c4e991f90bf5ec34f6b90367dcf989
[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.HashMap;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29
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;
55
56 import com.google.gson.Gson;
57
58 /**
59  * The {@link LinkyHandler} is responsible for handling commands, which are
60  * sent to one of the channels.
61  *
62  * @author GaĆ«l L'hopital - Initial contribution
63  */
64
65 @NonNullByDefault
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;
69
70     private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class);
71
72     private final HttpClient httpClient;
73     private final Gson gson;
74     private final WeekFields weekFields;
75
76     private @Nullable ScheduledFuture<?> refreshJob;
77     private @Nullable EnedisHttpApi enedisApi;
78
79     private final ExpiringDayCache<Consumption> cachedDailyData;
80     private final ExpiringDayCache<Consumption> cachedPowerData;
81     private final ExpiringDayCache<Consumption> cachedMonthlyData;
82     private final ExpiringDayCache<Consumption> cachedYearlyData;
83
84     private @NonNullByDefault({}) String prmId;
85     private @NonNullByDefault({}) String userId;
86
87     private enum Target {
88         FIRST,
89         LAST,
90         ALL
91     }
92
93     public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) {
94         super(thing);
95         this.gson = gson;
96         this.httpClient = httpClient;
97         this.weekFields = WeekFields.of(localeProvider.getLocale());
98
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);
106             }
107             return consumption;
108         });
109
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,
116                         Target.ALL);
117                 consumption = getConsumptionAfterChecks(consumption, Target.FIRST);
118             }
119             return consumption;
120         });
121
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);
128             }
129             return consumption;
130         });
131
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);
138             }
139             return consumption;
140         });
141     }
142
143     @Override
144     public void initialize() {
145         logger.debug("Initializing Linky handler.");
146         updateStatus(ThingStatus.UNKNOWN);
147
148         LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
149         if (config.seemsValid()) {
150             enedisApi = new EnedisHttpApi(config, gson, httpClient);
151             scheduler.submit(() -> {
152                 try {
153                     EnedisHttpApi api = this.enedisApi;
154                     api.initialize();
155                     updateStatus(ThingStatus.ONLINE);
156
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);
165                     }
166
167                     prmId = thing.getProperties().get(PRM_ID);
168                     userId = thing.getProperties().get(USER_ID);
169
170                     updateData();
171
172                     disconnect();
173
174                     final LocalDateTime now = LocalDateTime.now();
175                     final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
176                             .truncatedTo(ChronoUnit.HOURS);
177
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());
183                 }
184             });
185         } else {
186             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
187                     "@text/offline.config-error-mandatory-settings");
188         }
189     }
190
191     /**
192      * Request new data and updates channels
193      */
194     private synchronized void updateData() {
195         boolean connectedBefore = isConnected();
196         updatePowerData();
197         updateDailyWeeklyData();
198         updateMonthlyData();
199         updateYearlyData();
200         if (!connectedBefore && isConnected()) {
201             disconnect();
202         }
203     }
204
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));
211             }, () -> {
212                 updateKwhChannel(PEAK_POWER, Double.NaN);
213                 updateState(PEAK_TIMESTAMP, UnDefType.UNDEF);
214             });
215         }
216     }
217
218     /**
219      * Request new dayly/weekly data and updates channels
220      */
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
227                         : 1;
228                 Aggregate weeks = values.aggregats.weeks;
229                 if (weeks.datas.size() > idxLast) {
230                     updateKwhChannel(LAST_WEEK, weeks.datas.get(idxLast));
231                 }
232                 if (weeks.datas.size() > (idxLast + 1)) {
233                     updateKwhChannel(THIS_WEEK, weeks.datas.get(idxLast + 1));
234                 } else {
235                     updateKwhChannel(THIS_WEEK, 0.0);
236                 }
237             }, () -> {
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);
242                 } else {
243                     updateKwhChannel(THIS_WEEK, Double.NaN);
244                 }
245             });
246         }
247     }
248
249     /**
250      * Request new monthly data and updates channels
251      */
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));
259                 } else {
260                     updateKwhChannel(THIS_MONTH, 0.0);
261                 }
262             }, () -> {
263                 if (ZonedDateTime.now().getDayOfMonth() == 1) {
264                     updateKwhChannel(THIS_MONTH, 0.0);
265                     updateKwhChannel(LAST_MONTH, Double.NaN);
266                 } else {
267                     updateKwhChannel(THIS_MONTH, Double.NaN);
268                 }
269             });
270         }
271     }
272
273     /**
274      * Request new yearly data and updates channels
275      */
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));
283                 } else {
284                     updateKwhChannel(THIS_YEAR, 0.0);
285                 }
286             }, () -> {
287                 if (ZonedDateTime.now().getDayOfYear() == 1) {
288                     updateKwhChannel(THIS_YEAR, 0.0);
289                     updateKwhChannel(LAST_YEAR, Double.NaN);
290                 } else {
291                     updateKwhChannel(THIS_YEAR, Double.NaN);
292                 }
293             });
294         }
295     }
296
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));
301     }
302
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));
306     }
307
308     /**
309      * Produce a report of all daily values between two dates
310      *
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
314      *
315      * @return the report as a list of string
316      */
317     public synchronized List<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
318         List<String> report = buildReport(startDay, endDay, separator);
319         disconnect();
320         return report;
321     }
322
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);
337                     }
338                     report.add(line);
339                 }
340             } else {
341                 LocalDate currentDay = startDay;
342                 while (!currentDay.isAfter(endDay)) {
343                     report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator);
344                     currentDay = currentDay.plusDays(1);
345                 }
346             }
347         } else {
348             // Concatenate the report produced for each month between the two dates
349             LocalDate first = startDay;
350             do {
351                 LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
352                 if (last.isAfter(endDay)) {
353                     last = endDay;
354                 }
355                 report.addAll(buildReport(first, last, separator));
356                 first = last.plusDays(1);
357             } while (!first.isAfter(endDay));
358         }
359         return report;
360     }
361
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;
366         if (api != null) {
367             try {
368                 Consumption consumption = api.getEnergyData(userId, prmId, from, to);
369                 updateStatus(ThingStatus.ONLINE);
370                 return consumption;
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());
374             }
375         }
376         return null;
377     }
378
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;
383         if (api != null) {
384             try {
385                 Consumption consumption = api.getPowerData(userId, prmId, from, to);
386                 updateStatus(ThingStatus.ONLINE);
387                 return consumption;
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());
391             }
392         }
393         return null;
394     }
395
396     private boolean isConnected() {
397         EnedisHttpApi api = this.enedisApi;
398         return api == null ? false : api.isConnected();
399     }
400
401     private void disconnect() {
402         EnedisHttpApi api = this.enedisApi;
403         if (api != null) {
404             try {
405                 api.dispose();
406             } catch (LinkyException e) {
407                 logger.debug("disconnect: {}", e.getMessage());
408             }
409         }
410     }
411
412     @Override
413     public void dispose() {
414         logger.debug("Disposing the Linky handler.");
415         ScheduledFuture<?> job = this.refreshJob;
416         if (job != null && !job.isCancelled()) {
417             job.cancel(true);
418             refreshJob = null;
419         }
420         disconnect();
421         enedisApi = null;
422     }
423
424     @Override
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()) {
430                 case YESTERDAY:
431                 case LAST_WEEK:
432                 case THIS_WEEK:
433                     updateDailyWeeklyData();
434                     break;
435                 case LAST_MONTH:
436                 case THIS_MONTH:
437                     updateMonthlyData();
438                     break;
439                 case LAST_YEAR:
440                 case THIS_YEAR:
441                     updateYearlyData();
442                     break;
443                 case PEAK_POWER:
444                 case PEAK_TIMESTAMP:
445                     updatePowerData();
446                     break;
447                 default:
448                     break;
449             }
450             if (!connectedBefore && isConnected()) {
451                 disconnect();
452             }
453         } else {
454             logger.debug("The Linky binding is read-only and can not handle command {}", command);
455         }
456     }
457
458     private @Nullable Consumption getConsumptionAfterChecks(Consumption consumption, Target target) {
459         try {
460             checkData(consumption);
461         } catch (LinkyException e) {
462             logger.debug("Consumption data: {}", e.getMessage());
463             return null;
464         }
465         if (target == Target.FIRST && !isDataFirstDayAvailable(consumption)) {
466             logger.debug("Data including yesterday are not yet available");
467             return null;
468         }
469         if (target == Target.LAST && !isDataLastDayAvailable(consumption)) {
470             logger.debug("Data including yesterday are not yet available");
471             return null;
472         }
473         return consumption;
474     }
475
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");
479         }
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");
482         }
483         if (consumption.aggregats.weeks.periodes.size() == 0) {
484             throw new LinkyException("invalid consumptions data: no week period");
485         }
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");
488         }
489         if (consumption.aggregats.months.periodes.size() == 0) {
490             throw new LinkyException("invalid consumptions data: no month period");
491         }
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");
494         }
495         if (consumption.aggregats.years.periodes.size() == 0) {
496             throw new LinkyException("invalid consumptions data: no year period");
497         }
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");
500         }
501     }
502
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();
507     }
508
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();
513     }
514
515     private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter,
516             Target target) {
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) {
522                 if (size > 0) {
523                     logData(aggregate, 0, title, withDateFin, dateTimeFormatter);
524                 }
525             } else if (target == Target.LAST) {
526                 if (size > 0) {
527                     logData(aggregate, size - 1, title, withDateFin, dateTimeFormatter);
528                 }
529             } else {
530                 for (int i = 0; i < size; i++) {
531                     logData(aggregate, i, title, withDateFin, dateTimeFormatter);
532                 }
533             }
534         }
535     }
536
537     private void logData(Aggregate aggregate, int index, String title, boolean withDateFin,
538             DateTimeFormatter dateTimeFormatter) {
539         if (withDateFin) {
540             logger.debug("{} {} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
541                     aggregate.periodes.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index));
542         } else {
543             logger.debug("{} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
544                     aggregate.datas.get(index));
545         }
546     }
547 }