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