]> git.basschouten.com Git - openhab-addons.git/blob
aaee211d5db7d9a78f1757437703192286a28df9
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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 import static org.openhab.binding.linky.internal.model.LinkyTimeScale.*;
17
18 import java.io.IOException;
19 import java.nio.charset.StandardCharsets;
20 import java.time.LocalDate;
21 import java.time.LocalDateTime;
22 import java.time.format.DateTimeFormatter;
23 import java.time.temporal.ChronoUnit;
24 import java.time.temporal.WeekFields;
25 import java.util.ArrayList;
26 import java.util.Base64;
27 import java.util.List;
28 import java.util.concurrent.ScheduledFuture;
29 import java.util.concurrent.TimeUnit;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.linky.internal.ExpiringDayCache;
34 import org.openhab.binding.linky.internal.LinkyConfiguration;
35 import org.openhab.binding.linky.internal.model.LinkyConsumptionData;
36 import org.openhab.binding.linky.internal.model.LinkyTimeScale;
37 import org.openhab.core.i18n.LocaleProvider;
38 import org.openhab.core.library.types.QuantityType;
39 import org.openhab.core.library.unit.SmartHomeUnits;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.Thing;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.thing.binding.BaseThingHandler;
45 import org.openhab.core.types.Command;
46 import org.openhab.core.types.RefreshType;
47 import org.openhab.core.types.UnDefType;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
50
51 import com.google.gson.Gson;
52 import com.google.gson.JsonSyntaxException;
53
54 import okhttp3.FormBody;
55 import okhttp3.FormBody.Builder;
56 import okhttp3.OkHttpClient;
57 import okhttp3.Request;
58 import okhttp3.Response;
59
60 /**
61  * The {@link LinkyHandler} is responsible for handling commands, which are
62  * sent to one of the channels.
63  *
64  * @author GaĆ«l L'hopital - Initial contribution
65  */
66
67 @NonNullByDefault
68 public class LinkyHandler extends BaseThingHandler {
69     private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class);
70
71     private static final String LOGIN_BASE_URI = "https://espace-client-connexion.enedis.fr/auth/UI/Login";
72     private static final String API_BASE_URI = "https://espace-client-particuliers.enedis.fr/group/espace-particuliers/suivi-de-consommation";
73     private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
74     private static final int REFRESH_FIRST_HOUR_OF_DAY = 5;
75     private static final int REFRESH_INTERVAL_IN_MIN = 360;
76
77     private final OkHttpClient client = new OkHttpClient.Builder().followRedirects(false)
78             .cookieJar(new LinkyCookieJar()).build();
79     private final Gson gson = new Gson();
80
81     private @NonNullByDefault({}) ScheduledFuture<?> refreshJob;
82     private final WeekFields weekFields;
83
84     private final ExpiringDayCache<LinkyConsumptionData> cachedDaylyData;
85     private final ExpiringDayCache<LinkyConsumptionData> cachedMonthlyData;
86     private final ExpiringDayCache<LinkyConsumptionData> cachedYearlyData;
87
88     public LinkyHandler(Thing thing, LocaleProvider localeProvider) {
89         super(thing);
90         this.weekFields = WeekFields.of(localeProvider.getLocale());
91         this.cachedDaylyData = new ExpiringDayCache<LinkyConsumptionData>("daily cache", REFRESH_FIRST_HOUR_OF_DAY,
92                 () -> {
93                     final LocalDate today = LocalDate.now();
94                     return getConsumptionData(DAILY, today.minusDays(13), today, true);
95                 });
96         this.cachedMonthlyData = new ExpiringDayCache<LinkyConsumptionData>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY,
97                 () -> {
98                     final LocalDate today = LocalDate.now();
99                     return getConsumptionData(MONTHLY, today.withDayOfMonth(1).minusMonths(1), today, true);
100                 });
101         this.cachedYearlyData = new ExpiringDayCache<LinkyConsumptionData>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY,
102                 () -> {
103                     final LocalDate today = LocalDate.now();
104                     return getConsumptionData(YEARLY, LocalDate.of(today.getYear() - 1, 1, 1), today, true);
105                 });
106     }
107
108     @Override
109     public void initialize() {
110         logger.debug("Initializing Linky handler.");
111         updateStatus(ThingStatus.UNKNOWN);
112         scheduler.submit(this::login);
113
114         final LocalDateTime now = LocalDateTime.now();
115         final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
116                 .truncatedTo(ChronoUnit.HOURS);
117         refreshJob = scheduler.scheduleWithFixedDelay(this::updateData,
118                 ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1,
119                 REFRESH_INTERVAL_IN_MIN, TimeUnit.MINUTES);
120     }
121
122     private static Builder getLoginBodyBuilder() {
123         return new FormBody.Builder().add("encoded", "true").add("gx_charset", "UTF-8").add("SunQueryParamsString",
124                 Base64.getEncoder().encodeToString("realm=particuliers".getBytes(StandardCharsets.UTF_8)));
125     }
126
127     private synchronized boolean login() {
128         logger.debug("login");
129
130         LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
131         Request requestLogin = new Request.Builder().url(LOGIN_BASE_URI)
132                 .post(getLoginBodyBuilder().add("IDToken1", config.username).add("IDToken2", config.password).build())
133                 .build();
134         try (Response response = client.newCall(requestLogin).execute()) {
135             if (response.isRedirect()) {
136                 logger.debug("Response status {} {} redirects to {}", response.code(), response.message(),
137                         response.header("Location"));
138             } else {
139                 logger.debug("Response status {} {}", response.code(), response.message());
140             }
141             // Do a first call to get data; this first call will fail with code 302
142             getConsumptionData(DAILY, LocalDate.now(), LocalDate.now(), false);
143             updateStatus(ThingStatus.ONLINE);
144             return true;
145         } catch (IOException e) {
146             logger.debug("Exception while trying to login: {}", e.getMessage(), e);
147             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
148             return false;
149         }
150     }
151
152     /**
153      * Request new data and updates channels
154      */
155     private void updateData() {
156         updateDailyData();
157         updateMonthlyData();
158         updateYearlyData();
159     }
160
161     /**
162      * Request new dayly/weekly data and updates channels
163      */
164     private synchronized void updateDailyData() {
165         if (!isLinked(YESTERDAY) && !isLinked(LAST_WEEK) && !isLinked(THIS_WEEK)) {
166             return;
167         }
168
169         double lastWeek = Double.NaN;
170         double thisWeek = Double.NaN;
171         double yesterday = Double.NaN;
172         LinkyConsumptionData result = cachedDaylyData.getValue();
173         if (result != null && result.success()) {
174             LocalDate rangeStart = LocalDate.now().minusDays(13);
175             int jump = result.getDecalage();
176             while (rangeStart.getDayOfWeek() != weekFields.getFirstDayOfWeek()) {
177                 rangeStart = rangeStart.plusDays(1);
178                 jump++;
179             }
180
181             int lastWeekNumber = rangeStart.get(weekFields.weekOfWeekBasedYear());
182
183             lastWeek = 0.0;
184             thisWeek = 0.0;
185             yesterday = Double.NaN;
186             while (jump < result.getData().size()) {
187                 double consumption = result.getData().get(jump).valeur;
188                 if (consumption > 0) {
189                     if (rangeStart.get(weekFields.weekOfWeekBasedYear()) == lastWeekNumber) {
190                         lastWeek += consumption;
191                         logger.trace("Consumption at index {} added to last week: {}", jump, consumption);
192                     } else {
193                         thisWeek += consumption;
194                         logger.trace("Consumption at index {} added to current week: {}", jump, consumption);
195                     }
196                     yesterday = consumption;
197                 }
198                 jump++;
199                 rangeStart = rangeStart.plusDays(1);
200             }
201         } else {
202             cachedDaylyData.invalidateValue();
203         }
204         updateKwhChannel(YESTERDAY, yesterday);
205         updateKwhChannel(THIS_WEEK, thisWeek);
206         updateKwhChannel(LAST_WEEK, lastWeek);
207     }
208
209     /**
210      * Request new monthly data and updates channels
211      */
212     private synchronized void updateMonthlyData() {
213         if (!isLinked(LAST_MONTH) && !isLinked(THIS_MONTH)) {
214             return;
215         }
216
217         double lastMonth = Double.NaN;
218         double thisMonth = Double.NaN;
219         LinkyConsumptionData result = cachedMonthlyData.getValue();
220         if (result != null && result.success()) {
221             int jump = result.getDecalage();
222             lastMonth = result.getData().get(jump).valeur;
223             thisMonth = result.getData().get(jump + 1).valeur;
224             if (thisMonth < 0) {
225                 thisMonth = 0.0;
226             }
227         } else {
228             cachedMonthlyData.invalidateValue();
229         }
230         updateKwhChannel(LAST_MONTH, lastMonth);
231         updateKwhChannel(THIS_MONTH, thisMonth);
232     }
233
234     /**
235      * Request new yearly data and updates channels
236      */
237     private synchronized void updateYearlyData() {
238         if (!isLinked(LAST_YEAR) && !isLinked(THIS_YEAR)) {
239             return;
240         }
241
242         double thisYear = Double.NaN;
243         double lastYear = Double.NaN;
244         LinkyConsumptionData result = cachedYearlyData.getValue();
245         if (result != null && result.success()) {
246             int elementQuantity = result.getData().size();
247             thisYear = elementQuantity > 0 ? result.getData().get(elementQuantity - 1).valeur : Double.NaN;
248             lastYear = elementQuantity > 1 ? result.getData().get(elementQuantity - 2).valeur : Double.NaN;
249         } else {
250             cachedYearlyData.invalidateValue();
251         }
252         updateKwhChannel(LAST_YEAR, lastYear);
253         updateKwhChannel(THIS_YEAR, thisYear);
254     }
255
256     private void updateKwhChannel(String channelId, double consumption) {
257         logger.debug("Update channel {} with {}", channelId, consumption);
258         updateState(channelId,
259                 !Double.isNaN(consumption) ? new QuantityType<>(consumption, SmartHomeUnits.KILOWATT_HOUR)
260                         : UnDefType.UNDEF);
261     }
262
263     /**
264      * Produce a report of all daily values between two dates
265      *
266      * @param startDay the start day of the report
267      * @param endDay the end day of the report
268      * @param separator the separator to be used betwwen the date and the value
269      *
270      * @return the report as a string
271      */
272     public List<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
273         List<String> report = new ArrayList<>();
274         if (startDay.getYear() == endDay.getYear() && startDay.getMonthValue() == endDay.getMonthValue()) {
275             // All values in the same month
276             LinkyConsumptionData result = getConsumptionData(DAILY, startDay, endDay, true);
277             if (result != null && result.success()) {
278                 LocalDate currentDay = startDay;
279                 int jump = result.getDecalage();
280                 while (jump < result.getData().size() && !currentDay.isAfter(endDay)) {
281                     double consumption = result.getData().get(jump).valeur;
282                     String line = currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator;
283                     if (consumption >= 0) {
284                         line += String.valueOf(consumption);
285                     }
286                     report.add(line);
287                     jump++;
288                     currentDay = currentDay.plusDays(1);
289                 }
290             } else {
291                 LocalDate currentDay = startDay;
292                 while (!currentDay.isAfter(endDay)) {
293                     report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator);
294                     currentDay = currentDay.plusDays(1);
295                 }
296             }
297         } else {
298             // Concatenate the report produced for each month between the two dates
299             LocalDate first = startDay;
300             do {
301                 LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
302                 if (last.isAfter(endDay)) {
303                     last = endDay;
304                 }
305                 report.addAll(reportValues(first, last, separator));
306                 first = last.plusDays(1);
307             } while (!first.isAfter(endDay));
308         }
309         return report;
310     }
311
312     private @Nullable LinkyConsumptionData getConsumptionData(LinkyTimeScale timeScale, LocalDate from, LocalDate to,
313             boolean reLog) {
314         logger.debug("getConsumptionData {}", timeScale);
315
316         LinkyConsumptionData result = null;
317         boolean tryRelog = false;
318
319         FormBody formBody = new FormBody.Builder().add("p_p_id", "lincspartdisplaycdc_WAR_lincspartcdcportlet")
320                 .add("p_p_lifecycle", "2").add("p_p_resource_id", timeScale.getId())
321                 .add("_lincspartdisplaycdc_WAR_lincspartcdcportlet_dateDebut", from.format(API_DATE_FORMAT))
322                 .add("_lincspartdisplaycdc_WAR_lincspartcdcportlet_dateFin", to.format(API_DATE_FORMAT)).build();
323
324         Request requestData = new Request.Builder().url(API_BASE_URI).post(formBody).build();
325         try (Response response = client.newCall(requestData).execute()) {
326             if (response.isRedirect()) {
327                 String location = response.header("Location");
328                 logger.debug("Response status {} {} redirects to {}", response.code(), response.message(), location);
329                 if (reLog && location != null && location.startsWith(LOGIN_BASE_URI)) {
330                     tryRelog = true;
331                 }
332             } else {
333                 String body = (response.body() != null) ? response.body().string() : null;
334                 logger.debug("Response status {} {} : {}", response.code(), response.message(), body);
335                 if (body != null && !body.isEmpty()) {
336                     result = gson.fromJson(body, LinkyConsumptionData.class);
337                 }
338             }
339         } catch (IOException e) {
340             logger.debug("Exception calling API : {} - {}", e.getClass().getCanonicalName(), e.getMessage());
341             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
342         } catch (JsonSyntaxException e) {
343             logger.debug("Exception while converting JSON response : {}", e.getMessage());
344             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.NONE, e.getMessage());
345         }
346         if (tryRelog && login()) {
347             result = getConsumptionData(timeScale, from, to, false);
348         }
349         return result;
350     }
351
352     @Override
353     public void dispose() {
354         logger.debug("Disposing the Linky handler.");
355
356         if (refreshJob != null && !refreshJob.isCancelled()) {
357             refreshJob.cancel(true);
358             refreshJob = null;
359         }
360     }
361
362     @Override
363     public void handleCommand(ChannelUID channelUID, Command command) {
364         if (command instanceof RefreshType) {
365             logger.debug("Refreshing channel {}", channelUID.getId());
366             switch (channelUID.getId()) {
367                 case YESTERDAY:
368                 case LAST_WEEK:
369                 case THIS_WEEK:
370                     updateDailyData();
371                     break;
372                 case LAST_MONTH:
373                 case THIS_MONTH:
374                     updateMonthlyData();
375                     break;
376                 case LAST_YEAR:
377                 case THIS_YEAR:
378                     updateYearlyData();
379                     break;
380                 default:
381                     break;
382             }
383         } else {
384             logger.debug("The Linky binding is read-only and can not handle command {}", command);
385         }
386     }
387 }