2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.linky.internal.handler;
15 import static org.openhab.binding.linky.internal.LinkyBindingConstants.*;
17 import java.time.LocalDate;
18 import java.time.LocalDateTime;
19 import java.time.ZonedDateTime;
20 import java.time.format.DateTimeFormatter;
21 import java.time.temporal.ChronoUnit;
22 import java.time.temporal.WeekFields;
23 import java.util.ArrayList;
24 import java.util.HashMap;
25 import java.util.List;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.openhab.binding.linky.internal.LinkyConfiguration;
34 import org.openhab.binding.linky.internal.LinkyException;
35 import org.openhab.binding.linky.internal.api.EnedisHttpApi;
36 import org.openhab.binding.linky.internal.api.ExpiringDayCache;
37 import org.openhab.binding.linky.internal.dto.ConsumptionReport.Aggregate;
38 import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption;
39 import org.openhab.binding.linky.internal.dto.PrmInfo;
40 import org.openhab.binding.linky.internal.dto.UserInfo;
41 import org.openhab.core.i18n.LocaleProvider;
42 import org.openhab.core.library.types.DateTimeType;
43 import org.openhab.core.library.types.QuantityType;
44 import org.openhab.core.library.unit.Units;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.binding.BaseThingHandler;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.openhab.core.types.UnDefType;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
56 import com.google.gson.Gson;
59 * The {@link LinkyHandler} is responsible for handling commands, which are
60 * sent to one of the channels.
62 * @author Gaƫl L'hopital - Initial contribution
66 public class LinkyHandler extends BaseThingHandler {
67 private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class);
69 private static final int REFRESH_FIRST_HOUR_OF_DAY = 1;
70 private static final int REFRESH_INTERVAL_IN_MIN = 120;
72 private final HttpClient httpClient;
73 private final Gson gson;
74 private final WeekFields weekFields;
76 private @Nullable ScheduledFuture<?> refreshJob;
77 private @Nullable EnedisHttpApi enedisApi;
79 private final ExpiringDayCache<Consumption> cachedDailyData;
80 private final ExpiringDayCache<Consumption> cachedPowerData;
81 private final ExpiringDayCache<Consumption> cachedMonthlyData;
82 private final ExpiringDayCache<Consumption> cachedYearlyData;
84 private @NonNullByDefault({}) String prmId;
85 private @NonNullByDefault({}) String userId;
87 public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) {
90 this.httpClient = httpClient;
91 this.weekFields = WeekFields.of(localeProvider.getLocale());
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);
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) {
110 checkData(consumption);
111 } catch (LinkyException e) {
112 logger.debug("Power data: {}", e.getMessage());
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);
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);
141 public void initialize() {
142 logger.debug("Initializing Linky handler.");
143 updateStatus(ThingStatus.UNKNOWN);
145 LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
146 enedisApi = new EnedisHttpApi(config, gson, httpClient);
148 scheduler.submit(() -> {
150 EnedisHttpApi api = this.enedisApi;
153 updateStatus(ThingStatus.ONLINE);
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);
165 prmId = thing.getProperties().get(PRM_ID);
166 userId = thing.getProperties().get(USER_ID);
172 final LocalDateTime now = LocalDateTime.now();
173 final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
174 .truncatedTo(ChronoUnit.HOURS);
176 refreshJob = scheduler.scheduleWithFixedDelay(this::updateData,
177 ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1,
178 REFRESH_INTERVAL_IN_MIN, TimeUnit.MINUTES);
180 throw new LinkyException("Enedis Api is not initialized");
182 } catch (LinkyException e) {
183 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
189 * Request new data and updates channels
191 private synchronized void updateData() {
192 boolean connectedBefore = isConnected();
198 if (!connectedBefore && isConnected()) {
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));
214 * Request new dayly/weekly data and updates channels
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));
222 updateKwhChannel(YESTERDAY, Double.NaN);
228 * Request new weekly data and updates channels
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
236 Aggregate weeks = values.aggregats.weeks;
237 if (weeks.datas.size() > idxLast) {
238 updateKwhChannel(LAST_WEEK, weeks.datas.get(idxLast));
240 if (weeks.datas.size() > (idxLast + 1)) {
241 updateKwhChannel(THIS_WEEK, weeks.datas.get(idxLast + 1));
243 updateKwhChannel(THIS_WEEK, 0.0);
246 if (ZonedDateTime.now().get(weekFields.dayOfWeek()) == 1) {
247 updateKwhChannel(THIS_WEEK, 0.0);
248 updateKwhChannel(LAST_WEEK, Double.NaN);
250 updateKwhChannel(THIS_WEEK, Double.NaN);
257 * Request new monthly data and updates channels
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));
267 updateKwhChannel(THIS_MONTH, 0.0);
270 if (ZonedDateTime.now().getDayOfMonth() == 1) {
271 updateKwhChannel(THIS_MONTH, 0.0);
272 updateKwhChannel(LAST_MONTH, Double.NaN);
274 updateKwhChannel(THIS_MONTH, Double.NaN);
281 * Request new yearly data and updates channels
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));
291 updateKwhChannel(THIS_YEAR, 0.0);
294 if (ZonedDateTime.now().getDayOfYear() == 1) {
295 updateKwhChannel(THIS_YEAR, 0.0);
296 updateKwhChannel(LAST_YEAR, Double.NaN);
298 updateKwhChannel(THIS_YEAR, Double.NaN);
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));
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));
316 * Produce a report of all daily values between two dates
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
322 * @return the report as a list of string
324 public synchronized List<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
325 List<String> report = buildReport(startDay, endDay, separator);
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);
348 LocalDate currentDay = startDay;
349 while (!currentDay.isAfter(endDay)) {
350 report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator);
351 currentDay = currentDay.plusDays(1);
355 // Concatenate the report produced for each month between the two dates
356 LocalDate first = startDay;
358 LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
359 if (last.isAfter(endDay)) {
362 report.addAll(buildReport(first, last, separator));
363 first = last.plusDays(1);
364 } while (!first.isAfter(endDay));
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;
375 Consumption consumption = api.getEnergyData(userId, prmId, from, to);
376 updateStatus(ThingStatus.ONLINE);
378 } catch (LinkyException e) {
379 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
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;
391 Consumption consumption = api.getPowerData(userId, prmId, from, to);
392 updateStatus(ThingStatus.ONLINE);
394 } catch (LinkyException e) {
395 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
401 private boolean isConnected() {
402 EnedisHttpApi api = this.enedisApi;
403 return api == null ? false : api.isConnected();
406 private void disconnect() {
407 EnedisHttpApi api = this.enedisApi;
411 } catch (LinkyException ignore) {
417 public void dispose() {
418 logger.debug("Disposing the Linky handler.");
419 ScheduledFuture<?> job = this.refreshJob;
420 if (job != null && !job.isCancelled()) {
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()) {
456 if (!connectedBefore && isConnected()) {
460 logger.debug("The Linky binding is read-only and can not handle command {}", command);
464 private @Nullable Consumption getConsumptionAfterChecks(Consumption consumption) {
466 checkData(consumption);
467 } catch (LinkyException e) {
468 logger.debug("Consumption data: {}", e.getMessage());
471 if (!isDataLastDayAvailable(consumption)) {
472 logger.debug("Data including yesterday are not yet available");
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");
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");
485 if (consumption.aggregats.weeks.periodes.size() == 0) {
486 throw new LinkyException("invalid consumptions data: no week period");
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");
491 if (consumption.aggregats.months.periodes.size() == 0) {
492 throw new LinkyException("invalid consumptions data: no month period");
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");
497 if (consumption.aggregats.years.periodes.size() == 0) {
498 throw new LinkyException("invalid consumptions data: no year period");
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");
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();
511 private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter,
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());
519 logData(aggregate, size - 1, title, withDateFin, dateTimeFormatter);
522 for (int i = 0; i < size; i++) {
523 logData(aggregate, i, title, withDateFin, dateTimeFormatter);
529 private void logData(Aggregate aggregate, int index, String title, boolean withDateFin,
530 DateTimeFormatter dateTimeFormatter) {
532 logger.debug("{} {} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
533 aggregate.periodes.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index));
535 logger.debug("{} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
536 aggregate.datas.get(index));