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.format.DateTimeFormatter;
20 import java.time.temporal.ChronoUnit;
21 import java.time.temporal.WeekFields;
22 import java.util.ArrayList;
23 import java.util.HashMap;
24 import java.util.List;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
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.binding.linky.internal.dto.UserInfo;
40 import org.openhab.core.i18n.LocaleProvider;
41 import org.openhab.core.library.types.DateTimeType;
42 import org.openhab.core.library.types.QuantityType;
43 import org.openhab.core.library.unit.Units;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingStatusDetail;
48 import org.openhab.core.thing.binding.BaseThingHandler;
49 import org.openhab.core.types.Command;
50 import org.openhab.core.types.RefreshType;
51 import org.openhab.core.types.UnDefType;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
55 import com.google.gson.Gson;
58 * The {@link LinkyHandler} is responsible for handling commands, which are
59 * sent to one of the channels.
61 * @author Gaƫl L'hopital - Initial contribution
65 public class LinkyHandler extends BaseThingHandler {
66 private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class);
68 private static final int REFRESH_FIRST_HOUR_OF_DAY = 1;
69 private static final int REFRESH_INTERVAL_IN_MIN = 120;
71 private final HttpClient httpClient;
72 private final Gson gson;
73 private final WeekFields weekFields;
75 private @Nullable ScheduledFuture<?> refreshJob;
76 private @Nullable EnedisHttpApi enedisApi;
78 private final ExpiringDayCache<Consumption> cachedDailyData;
79 private final ExpiringDayCache<Consumption> cachedPowerData;
80 private final ExpiringDayCache<Consumption> cachedMonthlyData;
81 private final ExpiringDayCache<Consumption> cachedYearlyData;
83 private @NonNullByDefault({}) String prmId;
84 private @NonNullByDefault({}) String userId;
86 public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) {
89 this.httpClient = httpClient;
90 this.weekFields = WeekFields.of(localeProvider.getLocale());
92 this.cachedDailyData = new ExpiringDayCache<>("daily cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
93 LocalDate today = LocalDate.now();
94 Consumption consumption = getConsumptionData(today.minusDays(15), today);
95 if (consumption != null) {
96 logData(consumption.aggregats.days, "Day", false, DateTimeFormatter.ISO_LOCAL_DATE, false);
97 logData(consumption.aggregats.weeks, "Week", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, false);
98 consumption = getConsumptionAfterChecks(consumption);
103 this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
104 LocalDate to = LocalDate.now().plusDays(1);
105 LocalDate from = to.minusDays(2);
106 Consumption consumption = getPowerData(from, to);
107 if (consumption != null) {
109 checkData(consumption);
110 } catch (LinkyException e) {
111 logger.debug("Power data: {}", e.getMessage());
118 this.cachedMonthlyData = new ExpiringDayCache<>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
119 LocalDate today = LocalDate.now();
120 Consumption consumption = getConsumptionData(today.withDayOfMonth(1).minusMonths(1), today);
121 if (consumption != null) {
122 logData(consumption.aggregats.months, "Month", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, false);
123 consumption = getConsumptionAfterChecks(consumption);
128 this.cachedYearlyData = new ExpiringDayCache<>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
129 LocalDate today = LocalDate.now();
130 Consumption consumption = getConsumptionData(LocalDate.of(today.getYear() - 1, 1, 1), today);
131 if (consumption != null) {
132 logData(consumption.aggregats.years, "Year", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, false);
133 consumption = getConsumptionAfterChecks(consumption);
140 public void initialize() {
141 logger.debug("Initializing Linky handler.");
142 updateStatus(ThingStatus.UNKNOWN);
144 LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
145 enedisApi = new EnedisHttpApi(config, gson, httpClient);
147 scheduler.submit(() -> {
149 EnedisHttpApi api = this.enedisApi;
152 updateStatus(ThingStatus.ONLINE);
154 if (thing.getProperties().isEmpty()) {
155 Map<String, String> properties = new HashMap<>();
156 PrmInfo prmInfo = api.getPrmInfo();
157 UserInfo userInfo = api.getUserInfo();
158 properties.put(USER_ID, userInfo.userProperties.internId);
159 properties.put(PUISSANCE, prmInfo.puissanceSouscrite + " kVA");
160 properties.put(PRM_ID, prmInfo.prmId);
161 updateProperties(properties);
164 prmId = thing.getProperties().get(PRM_ID);
165 userId = thing.getProperties().get(USER_ID);
171 final LocalDateTime now = LocalDateTime.now();
172 final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
173 .truncatedTo(ChronoUnit.HOURS);
175 refreshJob = scheduler.scheduleWithFixedDelay(this::updateData,
176 ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1,
177 REFRESH_INTERVAL_IN_MIN, TimeUnit.MINUTES);
179 throw new LinkyException("Enedis Api is not initialized");
181 } catch (LinkyException e) {
182 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
188 * Request new data and updates channels
190 private synchronized void updateData() {
191 boolean connectedBefore = isConnected();
197 if (!connectedBefore && isConnected()) {
202 private synchronized void updatePowerData() {
203 if (isLinked(PEAK_POWER) || isLinked(PEAK_TIMESTAMP)) {
204 cachedPowerData.getValue().ifPresent(values -> {
205 Aggregate days = values.aggregats.days;
206 updateVAChannel(PEAK_POWER, days.datas.get(0));
207 updateState(PEAK_TIMESTAMP, new DateTimeType(days.periodes.get(0).dateDebut));
213 * Request new dayly/weekly data and updates channels
215 private synchronized void updateDailyData() {
216 if (isLinked(YESTERDAY)) {
217 cachedDailyData.getValue().ifPresent(values -> {
218 Aggregate days = values.aggregats.days;
219 updateKwhChannel(YESTERDAY, days.datas.get(days.datas.size() - 1));
225 * Request new weekly data and updates channels
227 private synchronized void updateWeeklyData() {
228 if (isLinked(LAST_WEEK) || isLinked(THIS_WEEK)) {
229 cachedDailyData.getValue().ifPresent(values -> {
230 Aggregate days = values.aggregats.days;
231 int idxLast = days.periodes.get(days.periodes.size() - 1).dateDebut.get(weekFields.dayOfWeek()) == 7 ? 2
233 Aggregate weeks = values.aggregats.weeks;
234 if (weeks.datas.size() > idxLast) {
235 updateKwhChannel(LAST_WEEK, weeks.datas.get(idxLast));
237 if (weeks.datas.size() > (idxLast + 1)) {
238 updateKwhChannel(THIS_WEEK, weeks.datas.get(idxLast + 1));
240 updateKwhChannel(THIS_WEEK, 0.0);
247 * Request new monthly data and updates channels
249 private synchronized void updateMonthlyData() {
250 if (isLinked(LAST_MONTH) || isLinked(THIS_MONTH)) {
251 cachedMonthlyData.getValue().ifPresent(values -> {
252 Aggregate months = values.aggregats.months;
253 updateKwhChannel(LAST_MONTH, months.datas.get(0));
254 if (months.datas.size() > 1) {
255 updateKwhChannel(THIS_MONTH, months.datas.get(1));
257 updateKwhChannel(THIS_MONTH, 0.0);
264 * Request new yearly data and updates channels
266 private synchronized void updateYearlyData() {
267 if (isLinked(LAST_YEAR) || isLinked(THIS_YEAR)) {
268 cachedYearlyData.getValue().ifPresent(values -> {
269 Aggregate years = values.aggregats.years;
270 updateKwhChannel(LAST_YEAR, years.datas.get(0));
271 if (years.datas.size() > 1) {
272 updateKwhChannel(THIS_YEAR, years.datas.get(1));
274 updateKwhChannel(THIS_YEAR, 0.0);
280 private void updateKwhChannel(String channelId, double consumption) {
281 logger.debug("Update channel {} with {}", channelId, consumption);
282 updateState(channelId,
283 Double.isNaN(consumption) ? UnDefType.UNDEF : new QuantityType<>(consumption, Units.KILOWATT_HOUR));
286 private void updateVAChannel(String channelId, double power) {
287 logger.debug("Update channel {} with {}", channelId, power);
288 updateState(channelId, Double.isNaN(power) ? UnDefType.UNDEF : new QuantityType<>(power, Units.VOLT_AMPERE));
292 * Produce a report of all daily values between two dates
294 * @param startDay the start day of the report
295 * @param endDay the end day of the report
296 * @param separator the separator to be used betwwen the date and the value
298 * @return the report as a list of string
300 public synchronized List<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
301 List<String> report = buildReport(startDay, endDay, separator);
306 private List<String> buildReport(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
307 List<String> report = new ArrayList<>();
308 if (startDay.getYear() == endDay.getYear() && startDay.getMonthValue() == endDay.getMonthValue()) {
309 // All values in the same month
310 Consumption result = getConsumptionData(startDay, endDay.plusDays(1));
311 if (result != null) {
312 Aggregate days = result.aggregats.days;
313 int size = (days.datas == null || days.periodes == null) ? 0
314 : (days.datas.size() <= days.periodes.size() ? days.datas.size() : days.periodes.size());
315 for (int i = 0; i < size; i++) {
316 double consumption = days.datas.get(i);
317 String line = days.periodes.get(i).dateDebut.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator;
318 if (consumption >= 0) {
319 line += String.valueOf(consumption);
324 LocalDate currentDay = startDay;
325 while (!currentDay.isAfter(endDay)) {
326 report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator);
327 currentDay = currentDay.plusDays(1);
331 // Concatenate the report produced for each month between the two dates
332 LocalDate first = startDay;
334 LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
335 if (last.isAfter(endDay)) {
338 report.addAll(buildReport(first, last, separator));
339 first = last.plusDays(1);
340 } while (!first.isAfter(endDay));
345 private @Nullable Consumption getConsumptionData(LocalDate from, LocalDate to) {
346 logger.debug("getConsumptionData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE),
347 to.format(DateTimeFormatter.ISO_LOCAL_DATE));
348 EnedisHttpApi api = this.enedisApi;
351 Consumption consumption = api.getEnergyData(userId, prmId, from, to);
352 updateStatus(ThingStatus.ONLINE);
354 } catch (LinkyException e) {
355 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
361 private @Nullable Consumption getPowerData(LocalDate from, LocalDate to) {
362 logger.debug("getPowerData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE),
363 to.format(DateTimeFormatter.ISO_LOCAL_DATE));
364 EnedisHttpApi api = this.enedisApi;
367 Consumption consumption = api.getPowerData(userId, prmId, from, to);
368 updateStatus(ThingStatus.ONLINE);
370 } catch (LinkyException e) {
371 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
377 private boolean isConnected() {
378 EnedisHttpApi api = this.enedisApi;
379 return api == null ? false : api.isConnected();
382 private void disconnect() {
383 EnedisHttpApi api = this.enedisApi;
387 } catch (LinkyException ignore) {
393 public void dispose() {
394 logger.debug("Disposing the Linky handler.");
395 ScheduledFuture<?> job = this.refreshJob;
396 if (job != null && !job.isCancelled()) {
405 public synchronized void handleCommand(ChannelUID channelUID, Command command) {
406 if (command instanceof RefreshType) {
407 logger.debug("Refreshing channel {}", channelUID.getId());
408 boolean connectedBefore = isConnected();
409 switch (channelUID.getId()) {
432 if (!connectedBefore && isConnected()) {
436 logger.debug("The Linky binding is read-only and can not handle command {}", command);
440 private @Nullable Consumption getConsumptionAfterChecks(Consumption consumption) {
442 checkData(consumption);
443 } catch (LinkyException e) {
444 logger.debug("Consumption data: {}", e.getMessage());
447 if (!isDataLastDayAvailable(consumption)) {
448 logger.debug("Data including yesterday are not yet available");
454 public void checkData(Consumption consumption) throws LinkyException {
455 if (consumption.aggregats.days.periodes.size() == 0) {
456 throw new LinkyException("invalid consumptions data: no day period");
458 if (consumption.aggregats.days.periodes.size() != consumption.aggregats.days.datas.size()) {
459 throw new LinkyException("invalid consumptions data: not one data for each day period");
461 if (consumption.aggregats.weeks.periodes.size() == 0) {
462 throw new LinkyException("invalid consumptions data: no week period");
464 if (consumption.aggregats.weeks.periodes.size() != consumption.aggregats.weeks.datas.size()) {
465 throw new LinkyException("invalid consumptions data: not one data for each week period");
467 if (consumption.aggregats.months.periodes.size() == 0) {
468 throw new LinkyException("invalid consumptions data: no month period");
470 if (consumption.aggregats.months.periodes.size() != consumption.aggregats.months.datas.size()) {
471 throw new LinkyException("invalid consumptions data: not one data for each month period");
473 if (consumption.aggregats.years.periodes.size() == 0) {
474 throw new LinkyException("invalid consumptions data: no year period");
476 if (consumption.aggregats.years.periodes.size() != consumption.aggregats.years.datas.size()) {
477 throw new LinkyException("invalid consumptions data: not one data for each year period");
481 private boolean isDataLastDayAvailable(Consumption consumption) {
482 Aggregate days = consumption.aggregats.days;
483 logData(days, "Last day", false, DateTimeFormatter.ISO_LOCAL_DATE, true);
484 return days.datas != null && days.datas.size() > 0 && !days.datas.get(days.datas.size() - 1).isNaN();
487 private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter,
489 if (logger.isDebugEnabled()) {
490 int size = (aggregate.datas == null || aggregate.periodes == null) ? 0
491 : (aggregate.datas.size() <= aggregate.periodes.size() ? aggregate.datas.size()
492 : aggregate.periodes.size());
495 logData(aggregate, size - 1, title, withDateFin, dateTimeFormatter);
498 for (int i = 0; i < size; i++) {
499 logData(aggregate, i, title, withDateFin, dateTimeFormatter);
505 private void logData(Aggregate aggregate, int index, String title, boolean withDateFin,
506 DateTimeFormatter dateTimeFormatter) {
508 logger.debug("{} {} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
509 aggregate.periodes.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index));
511 logger.debug("{} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter),
512 aggregate.datas.get(index));