2 * Copyright (c) 2010-2020 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.*;
16 import static org.openhab.binding.linky.internal.model.LinkyTimeScale.*;
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;
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;
51 import com.google.gson.Gson;
52 import com.google.gson.JsonSyntaxException;
54 import okhttp3.FormBody;
55 import okhttp3.FormBody.Builder;
56 import okhttp3.OkHttpClient;
57 import okhttp3.Request;
58 import okhttp3.Response;
61 * The {@link LinkyHandler} is responsible for handling commands, which are
62 * sent to one of the channels.
64 * @author Gaƫl L'hopital - Initial contribution
68 public class LinkyHandler extends BaseThingHandler {
69 private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class);
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;
77 private final OkHttpClient client = new OkHttpClient.Builder().followRedirects(false)
78 .cookieJar(new LinkyCookieJar()).build();
79 private final Gson gson = new Gson();
81 private @NonNullByDefault({}) ScheduledFuture<?> refreshJob;
82 private final WeekFields weekFields;
84 private final ExpiringDayCache<LinkyConsumptionData> cachedDaylyData;
85 private final ExpiringDayCache<LinkyConsumptionData> cachedMonthlyData;
86 private final ExpiringDayCache<LinkyConsumptionData> cachedYearlyData;
88 public LinkyHandler(Thing thing, LocaleProvider localeProvider) {
90 this.weekFields = WeekFields.of(localeProvider.getLocale());
91 this.cachedDaylyData = new ExpiringDayCache<LinkyConsumptionData>("daily cache", REFRESH_FIRST_HOUR_OF_DAY,
93 final LocalDate today = LocalDate.now();
94 return getConsumptionData(DAILY, today.minusDays(13), today, true);
96 this.cachedMonthlyData = new ExpiringDayCache<LinkyConsumptionData>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY,
98 final LocalDate today = LocalDate.now();
99 return getConsumptionData(MONTHLY, today.withDayOfMonth(1).minusMonths(1), today, true);
101 this.cachedYearlyData = new ExpiringDayCache<LinkyConsumptionData>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY,
103 final LocalDate today = LocalDate.now();
104 return getConsumptionData(YEARLY, LocalDate.of(today.getYear() - 1, 1, 1), today, true);
109 public void initialize() {
110 logger.debug("Initializing Linky handler.");
111 updateStatus(ThingStatus.UNKNOWN);
112 scheduler.submit(this::login);
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);
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)));
127 private synchronized boolean login() {
128 logger.debug("login");
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())
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"));
139 logger.debug("Response status {} {}", response.code(), response.message());
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);
145 } catch (IOException e) {
146 logger.debug("Exception while trying to login: {}", e.getMessage(), e);
147 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
153 * Request new data and updates channels
155 private void updateData() {
162 * Request new dayly/weekly data and updates channels
164 private synchronized void updateDailyData() {
165 if (!isLinked(YESTERDAY) && !isLinked(LAST_WEEK) && !isLinked(THIS_WEEK)) {
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);
181 int lastWeekNumber = rangeStart.get(weekFields.weekOfWeekBasedYear());
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);
193 thisWeek += consumption;
194 logger.trace("Consumption at index {} added to current week: {}", jump, consumption);
196 yesterday = consumption;
199 rangeStart = rangeStart.plusDays(1);
202 cachedDaylyData.invalidateValue();
204 updateKwhChannel(YESTERDAY, yesterday);
205 updateKwhChannel(THIS_WEEK, thisWeek);
206 updateKwhChannel(LAST_WEEK, lastWeek);
210 * Request new monthly data and updates channels
212 private synchronized void updateMonthlyData() {
213 if (!isLinked(LAST_MONTH) && !isLinked(THIS_MONTH)) {
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;
228 cachedMonthlyData.invalidateValue();
230 updateKwhChannel(LAST_MONTH, lastMonth);
231 updateKwhChannel(THIS_MONTH, thisMonth);
235 * Request new yearly data and updates channels
237 private synchronized void updateYearlyData() {
238 if (!isLinked(LAST_YEAR) && !isLinked(THIS_YEAR)) {
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;
250 cachedYearlyData.invalidateValue();
252 updateKwhChannel(LAST_YEAR, lastYear);
253 updateKwhChannel(THIS_YEAR, thisYear);
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)
264 * Produce a report of all daily values between two dates
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
270 * @return the report as a string
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);
288 currentDay = currentDay.plusDays(1);
291 LocalDate currentDay = startDay;
292 while (!currentDay.isAfter(endDay)) {
293 report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator);
294 currentDay = currentDay.plusDays(1);
298 // Concatenate the report produced for each month between the two dates
299 LocalDate first = startDay;
301 LocalDate last = first.withDayOfMonth(first.lengthOfMonth());
302 if (last.isAfter(endDay)) {
305 report.addAll(reportValues(first, last, separator));
306 first = last.plusDays(1);
307 } while (!first.isAfter(endDay));
312 private @Nullable LinkyConsumptionData getConsumptionData(LinkyTimeScale timeScale, LocalDate from, LocalDate to,
314 logger.debug("getConsumptionData {}", timeScale);
316 LinkyConsumptionData result = null;
317 boolean tryRelog = false;
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();
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)) {
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);
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());
346 if (tryRelog && login()) {
347 result = getConsumptionData(timeScale, from, to, false);
353 public void dispose() {
354 logger.debug("Disposing the Linky handler.");
356 if (refreshJob != null && !refreshJob.isCancelled()) {
357 refreshJob.cancel(true);
363 public void handleCommand(ChannelUID channelUID, Command command) {
364 if (command instanceof RefreshType) {
365 logger.debug("Refreshing channel {}", channelUID.getId());
366 switch (channelUID.getId()) {
384 logger.debug("The Linky binding is read-only and can not handle command {}", command);