== Third-party Content
-okhttp
-* License: Apache License 2.0
-* Project: https://square.github.io/okhttp/
-* Source: https://github.com/square/okhttp
-
-okio
-* License: Apache 2.0 License
-* Project: https://square.github.io/okio/2.x/okio/jvm/okio
-* Source: https://github.com/square/okio
\ No newline at end of file
+jsoup
+* License: MIT License
+* Project: https://jsoup.org/
+* Source: https://github.com/jhy/jsoup
\ No newline at end of file
The thing has the following configuration parameters:
-| Parameter | Description |
-|-----------------|--------------------------------|
-| username | Your Enedis platform username. |
-| password | Your Enedis platform password. |
+| Parameter | Description |
+|----------------|--------------------------------|
+| username | Your Enedis platform username. |
+| password | Your Enedis platform password. |
+| internalAuthId | The internal authID |
+
+This version is now compatible with the new API of Enedis (deployed from june 2020).
+To avoid the captcha login, it is necessary to log before on a classical browser (e.g Chrome, Firefox) and to retrieve the user cookies (internalAuthId).
+
+Instructions given for Firefox :
+
+1. Go to https://mon-compte-client.enedis.fr/.
+2. Select "Particulier" in the drop down list and click on the "Connexion" button.
+3. You'll be redirected to a page where you'll have to enter you Enedis account email address and check the "Je ne suis pas un robot" checkbox.
+4. Clic on "Suivant".
+5. In the login page, prefilled with your mail address, enter your Enedis account password and click on "Connexion à Espace Client Enedis".
+6. You will be directed to your Enedis account environment. Get back to previous page in you browser.
+7. Open the developper tool window (F12) and select "Stockage" tab. In the "Cookies" entry, select "https://mon-compte-enedis.fr". You should see an entry named "internalAuthId", copy this value in your Openhab configuration.
## Channels
The information that is retrieved is available as these channels:
-| Channel ID | Item Type | Description |
-|-------------------|---------------|----------------------------|
-| daily#yesterday | Number:Energy | Yesterday energy usage |
-| weekly#thisWeek | Number:Energy | Current week energy usage |
-| weekly#lastWeek | Number:Energy | Last week energy usage |
-| monthly#thisMonth | Number:Energy | Current month energy usage |
-| monthly#lastMonth | Number:Energy | Last month energy usage |
-| yearly#thisYear | Number:Energy | Current year energy usage |
-| yearly#lastYear | Number:Energy | Last year energy usage |
+| Channel ID | Item Type | Description |
+|-------------------|---------------|------------------------------|
+| daily#yesterday | Number:Energy | Yesterday energy usage |
+| daily#power | Number:Power | Yesterday's peak power usage |
+| daily#timestamp | DateTime | Timestamp of the power peak |
+| weekly#thisWeek | Number:Energy | Current week energy usage |
+| weekly#lastWeek | Number:Energy | Last week energy usage |
+| monthly#thisMonth | Number:Energy | Current month energy usage |
+| monthly#lastMonth | Number:Energy | Last month energy usage |
+| yearly#thisYear | Number:Energy | Current year energy usage |
+| yearly#lastYear | Number:Energy | Last year energy usage |
## Console Commands
```
Number:Energy ConsoHier "Conso hier [%.0f %unit%]" <energy> { channel="linky:linky:local:daily#yesterday" }
-Number:Energy ConsoSemaineEnCours "Conso semaine en cours [%.0f %unit%]" <energy> { channel="linky:linky:local:weekly#thisWeek" }
+Number:Energy ConsoSemaineEnCours "Conso cette semaine [%.0f %unit%]" <energy> { channel="linky:linky:local:weekly#thisWeek" }
Number:Energy ConsoSemaineDerniere "Conso semaine dernière [%.0f %unit%]" <energy> { channel="linky:linky:local:weekly#lastWeek" }
-Number:Energy ConsoMoisEnCours "Conso mois en cours [%.0f %unit%]" <energy> { channel="linky:linky:local:monthly#thisMonth" }
+Number:Energy ConsoMoisEnCours "Conso ce mois [%.0f %unit%]" <energy> { channel="linky:linky:local:monthly#thisMonth" }
Number:Energy ConsoMoisDernier "Conso mois dernier [%.0f %unit%]" <energy> { channel="linky:linky:local:monthly#lastMonth" }
-Number:Energy ConsoAnneeEnCours "Conso année en cours [%.0f %unit%]" <energy> { channel="linky:linky:local:yearly#thisYear" }
+Number:Energy ConsoAnneeEnCours "Conso cette année [%.0f %unit%]" <energy> { channel="linky:linky:local:yearly#thisYear" }
Number:Energy ConsoAnneeDerniere "Conso année dernière [%.0f %unit%]" <energy> { channel="linky:linky:local:yearly#lastYear" }
```
<name>openHAB Add-ons :: Bundles :: Linky Binding</name>
- <properties>
- <bnd.importpackage>!android.*,!com.android.org.*,!dalvik.*,!javax.annotation.meta.*,!org.apache.harmony.*,!org.conscrypt.*,!sun.*</bnd.importpackage>
- </properties>
-
<dependencies>
<dependency>
- <groupId>com.squareup.okhttp3</groupId>
- <artifactId>okhttp</artifactId>
- <version>3.12.3</version>
- <scope>compile</scope>
- </dependency>
- <dependency>
- <groupId>com.squareup.okio</groupId>
- <artifactId>okio</artifactId>
- <version>1.15.0</version>
+ <groupId>org.jsoup</groupId>
+ <artifactId>jsoup</artifactId>
+ <version>1.8.3</version>
<scope>compile</scope>
</dependency>
</dependencies>
+
</project>
<feature name="openhab-binding-linky" description="Linky Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
+ <bundle dependency="true">mvn:org.jsoup/jsoup/1.8.3</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.linky/${project.version}</bundle>
</feature>
</features>
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.linky.internal;
-
-import java.time.LocalDateTime;
-import java.time.format.DateTimeFormatter;
-import java.time.temporal.ChronoUnit;
-import java.util.function.Supplier;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * This is a simple expiring and reloading cache implementation.
- *
- * There must be provided an action in order to retrieve/calculate the value. This action will be called only if the
- * answer from the last calculation is not valid anymore, i.e. if it is expired.
- *
- * The cache expires after the current day; it is possible to shift the beginning time of the day.
- *
- * Soft Reference is not used to store the cached value because JVM Garbage Collector is clearing it too much often.
- *
- * @author Laurent Garnier - Initial contribution
- *
- * @param <V> the type of the value
- */
-@NonNullByDefault
-public class ExpiringDayCache<V> {
- private final Logger logger = LoggerFactory.getLogger(ExpiringDayCache.class);
-
- private final String name;
- private final int beginningHour;
- private final Supplier<@Nullable V> action;
-
- private @Nullable V value;
- private LocalDateTime expiresAt;
-
- /**
- * Create a new instance.
- *
- * @param name the name of this cache
- * @param beginningHour the hour in the day at which the validity period is starting
- * @param action the action to retrieve/calculate the value
- */
- public ExpiringDayCache(String name, int beginningHour, Supplier<@Nullable V> action) {
- this.name = name;
- this.beginningHour = beginningHour;
- this.expiresAt = calcAlreadyExpired();
- this.action = action;
- }
-
- /**
- * Returns the value - possibly from the cache, if it is still valid.
- */
- public synchronized @Nullable V getValue() {
- @Nullable
- V cachedValue = value;
- if (cachedValue == null || isExpired()) {
- logger.debug("getValue from cache \"{}\" is requiring a fresh value", name);
- cachedValue = refreshValue();
- } else {
- logger.debug("getValue from cache \"{}\" is returing a cached value", name);
- }
- return cachedValue;
- }
-
- /**
- * Puts a new value into the cache.
- *
- * @param value the new value
- */
- public final synchronized void putValue(@Nullable V value) {
- this.value = value;
- expiresAt = calcNextExpiresAt();
- }
-
- /**
- * Invalidates the value in the cache.
- */
- public final synchronized void invalidateValue() {
- value = null;
- expiresAt = calcAlreadyExpired();
- }
-
- /**
- * Refreshes and returns the value in the cache.
- *
- * @return the new value
- */
- public synchronized @Nullable V refreshValue() {
- value = action.get();
- expiresAt = calcNextExpiresAt();
- return value;
- }
-
- /**
- * Checks if the value is expired.
- *
- * @return true if the value is expired
- */
- public boolean isExpired() {
- return !LocalDateTime.now().isBefore(expiresAt);
- }
-
- private LocalDateTime calcNextExpiresAt() {
- LocalDateTime now = LocalDateTime.now();
- LocalDateTime limit = now.withHour(beginningHour).truncatedTo(ChronoUnit.HOURS);
- LocalDateTime result = now.isBefore(limit) ? limit : limit.plusDays(1);
- logger.debug("calcNextExpiresAt result = {}", result.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
- return result;
- }
-
- private LocalDateTime calcAlreadyExpired() {
- return LocalDateTime.now().minusDays(1);
- }
-}
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_LINKY = new ThingTypeUID(BINDING_ID, "linky");
+ // Thing properties
+ public static final String PUISSANCE = "puissance";
+ public static final String PRM_ID = "prmId";
+ public static final String USER_ID = "av2_interne_id";
+
// List of all Channel id's
public static final String YESTERDAY = "daily#yesterday";
+ public static final String PEAK_POWER = "daily#power";
+ public static final String PEAK_TIMESTAMP = "daily#timestamp";
public static final String THIS_WEEK = "weekly#thisWeek";
public static final String LAST_WEEK = "weekly#lastWeek";
public static final String THIS_MONTH = "monthly#thisMonth";
* @author Gaël L'hopital - Initial contribution
*/
public class LinkyConfiguration {
+ public static final String INTERNAL_AUTH_ID = "internalAuthId";
public String username;
public String password;
+ public String internalAuthId;
}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.linky.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Will be thrown for cloud errors
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+@NonNullByDefault
+public class LinkyException extends Exception {
+
+ private static final long serialVersionUID = 3703839284673384018L;
+
+ public LinkyException() {
+ super();
+ }
+
+ public LinkyException(String message) {
+ super(message);
+ }
+
+ public LinkyException(String message, Exception e) {
+ super(message, e);
+ }
+}
import static org.openhab.binding.linky.internal.LinkyBindingConstants.THING_TYPE_LINKY;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.linky.internal.handler.LinkyHandler;
import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializer;
+
/**
* The {@link LinkyHandlerFactory} is responsible for creating things handlers.
*
@NonNullByDefault
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.linky")
public class LinkyHandlerFactory extends BaseThingHandlerFactory {
-
+ private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX");
private final LocaleProvider localeProvider;
+ private final Gson gson;
+ private final HttpClient httpClient;
@Activate
- public LinkyHandlerFactory(final @Reference LocaleProvider localeProvider) {
+ public LinkyHandlerFactory(final @Reference LocaleProvider localeProvider,
+ final @Reference HttpClientFactory httpClientFactory) {
this.localeProvider = localeProvider;
+ this.httpClient = httpClientFactory.createHttpClient(LinkyBindingConstants.BINDING_ID);
+ this.gson = new GsonBuilder().registerTypeAdapter(ZonedDateTime.class,
+ (JsonDeserializer<ZonedDateTime>) (json, type, jsonDeserializationContext) -> ZonedDateTime
+ .parse(json.getAsJsonPrimitive().getAsString(), formatter))
+ .create();
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
- return thingTypeUID.equals(THING_TYPE_LINKY);
+ return THING_TYPE_LINKY.equals(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
- if (thingTypeUID.equals(THING_TYPE_LINKY)) {
- return new LinkyHandler(thing, localeProvider);
+ if (supportsThingType(thingTypeUID)) {
+ return new LinkyHandler(thing, localeProvider, gson, httpClient);
}
return null;
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.linky.internal.api;
+
+import java.net.CookieStore;
+import java.net.HttpCookie;
+import java.net.URI;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.util.FormContentProvider;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.util.Fields;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.openhab.binding.linky.internal.LinkyConfiguration;
+import org.openhab.binding.linky.internal.LinkyException;
+import org.openhab.binding.linky.internal.dto.AuthData;
+import org.openhab.binding.linky.internal.dto.AuthResult;
+import org.openhab.binding.linky.internal.dto.ConsumptionReport;
+import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption;
+import org.openhab.binding.linky.internal.dto.PrmInfo;
+import org.openhab.binding.linky.internal.dto.UserInfo;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+
+/**
+ * {@link EnedisHttpApi} wraps the Enedis Webservice.
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+@NonNullByDefault
+public class EnedisHttpApi {
+ private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
+ private static final String URL_APPS_LINCS = "https://apps.lincs.enedis.fr";
+ private static final String URL_MON_COMPTE = "https://mon-compte.enedis.fr";
+ private static final String URL_ENEDIS_AUTHENTICATE = URL_APPS_LINCS
+ + "/authenticate?target=https://mon-compte-particulier.enedis.fr/suivi-de-mesure/";
+ private static final String URL_COOKIE = "https://mon-compte-particulier.enedis.fr";
+
+ private final Logger logger = LoggerFactory.getLogger(EnedisHttpApi.class);
+ private final Gson gson;
+ private final HttpClient httpClient;
+ private final LinkyConfiguration config;
+ private boolean connected = false;
+
+ public EnedisHttpApi(LinkyConfiguration config, Gson gson, HttpClient httpClient) {
+ this.gson = gson;
+ this.httpClient = httpClient;
+ this.config = config;
+ }
+
+ public void initialize() throws LinkyException {
+ httpClient.getSslContextFactory().setExcludeCipherSuites(new String[0]);
+ httpClient.setFollowRedirects(false);
+ try {
+ httpClient.start();
+ } catch (Exception e) {
+ throw new LinkyException("Unable to start Jetty HttpClient", e);
+ }
+ connect();
+ }
+
+ private void connect() throws LinkyException {
+ addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, config.internalAuthId);
+
+ logger.debug("Starting login process for user : {}", config.username);
+
+ try {
+ logger.debug("Step 1 : getting authentification");
+ String data = getData(URL_ENEDIS_AUTHENTICATE);
+
+ logger.debug("Reception request SAML");
+ Document htmlDocument = Jsoup.parse(data);
+ Element el = htmlDocument.select("form").first();
+ Element samlInput = el.select("input[name=SAMLRequest]").first();
+
+ logger.debug("Step 2 : send SSO SAMLRequest");
+ ContentResponse result = httpClient.POST(el.attr("action"))
+ .content(getFormContent("SAMLRequest", samlInput.attr("value"))).send();
+ if (result.getStatus() != 302) {
+ throw new LinkyException("Connection failed step 2");
+ }
+
+ logger.debug("Get the location and the ReqID");
+ Pattern p = Pattern.compile("ReqID%(.*?)%26");
+ Matcher m = p.matcher(getLocation(result));
+ if (!m.find()) {
+ throw new LinkyException("Unable to locate ReqId in header");
+ }
+
+ String reqId = m.group(1);
+ String url = URL_MON_COMPTE
+ + "/auth/json/authenticate?realm=/enedis&forward=true&spEntityID=SP-ODW-PROD&goto=/auth/SSOPOST/metaAlias/enedis/providerIDP?ReqID%"
+ + reqId
+ + "%26index%3Dnull%26acsURL%3Dhttps://apps.lincs.enedis.fr/saml/SSO%26spEntityID%3DSP-ODW-PROD%26binding%3Durn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST&AMAuthCookie=";
+
+ logger.debug(
+ "Step 3 : auth1 - retrieve the template, thanks to cookie internalAuthId, user is already set");
+ result = httpClient.POST(url).send();
+ if (result.getStatus() != 200) {
+ throw new LinkyException("Connection failed step 3 - auth1 : " + result.getContentAsString());
+ }
+
+ AuthData authData = gson.fromJson(result.getContentAsString(), AuthData.class);
+ if (authData.callbacks.size() < 2 || authData.callbacks.get(0).input.size() == 0
+ || authData.callbacks.get(1).input.size() == 0
+ || !config.username.contentEquals(authData.callbacks.get(0).input.get(0).valueAsString())) {
+ throw new LinkyException("Authentication error, the authentication_cookie is probably wrong");
+ }
+
+ authData.callbacks.get(1).input.get(0).value = config.password;
+ url = "https://mon-compte.enedis.fr/auth/json/authenticate?realm=/enedis&spEntityID=SP-ODW-PROD&goto=/auth/SSOPOST/metaAlias/enedis/providerIDP?ReqID%"
+ + reqId
+ + "%26index%3Dnull%26acsURL%3Dhttps://apps.lincs.enedis.fr/saml/SSO%26spEntityID%3DSP-ODW-PROD%26binding%3Durn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST&AMAuthCookie=";
+
+ logger.debug("Step 3 : auth2 - send the auth data");
+ result = httpClient.POST(url).header(HttpHeader.CONTENT_TYPE, "application/json")
+ .content(new StringContentProvider(gson.toJson(authData))).send();
+ if (result.getStatus() != 200) {
+ throw new LinkyException("Connection failed step 3 - auth2 : " + result.getContentAsString());
+ }
+
+ AuthResult authResult = gson.fromJson(result.getContentAsString(), AuthResult.class);
+ logger.debug("Add the tokenId cookie");
+ addCookie("enedisExt", authResult.tokenId);
+
+ logger.debug("Step 4 : retrieve the SAMLresponse");
+ data = getData(URL_MON_COMPTE + "/" + authResult.successUrl);
+ htmlDocument = Jsoup.parse(data);
+ el = htmlDocument.select("form").first();
+ samlInput = el.select("input[name=SAMLResponse]").first();
+
+ logger.debug("Step 5 : post the SAMLresponse to finish the authentication");
+ result = httpClient.POST(el.attr("action")).content(getFormContent("SAMLResponse", samlInput.attr("value")))
+ .send();
+ if (result.getStatus() != 302) {
+ throw new LinkyException("Connection failed step 5");
+ }
+ connected = true;
+ } catch (InterruptedException | TimeoutException | ExecutionException e) {
+ throw new LinkyException("Error opening connection with Enedis webservice", e);
+ }
+ }
+
+ public String getLocation(ContentResponse response) {
+ return response.getHeaders().get(HttpHeader.LOCATION);
+ }
+
+ public void disconnect() throws LinkyException {
+ if (connected) {
+ try { // Three times in a row to get disconnected
+ String location = getLocation(httpClient.GET(URL_APPS_LINCS + "/logout"));
+ location = getLocation(httpClient.GET(location));
+ location = getLocation(httpClient.GET(location));
+ CookieStore cookieStore = httpClient.getCookieStore();
+ cookieStore.removeAll();
+ connected = false;
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ throw new LinkyException("Error while disconnecting from Enedis webservice", e);
+ }
+ }
+ }
+
+ public void dispose() throws LinkyException {
+ try {
+ disconnect();
+ httpClient.stop();
+ } catch (Exception e) {
+ throw new LinkyException("Error stopping Jetty client", e);
+ }
+ }
+
+ private void addCookie(String key, String value) {
+ CookieStore cookieStore = httpClient.getCookieStore();
+ HttpCookie cookie = new HttpCookie(key, value);
+ cookie.setDomain(".enedis.fr");
+ cookie.setPath("/");
+ cookieStore.add(URI.create(URL_COOKIE), cookie);
+ }
+
+ private FormContentProvider getFormContent(String fieldName, String fieldValue) {
+ Fields fields = new Fields();
+ fields.put(fieldName, fieldValue);
+ return new FormContentProvider(fields);
+ }
+
+ private String getData(String url) throws LinkyException {
+ try {
+ ContentResponse result = httpClient.GET(url);
+ if (result.getStatus() != 200) {
+ throw new LinkyException(String.format("Error requesting '%s' : %s", url, result.getContentAsString()));
+ }
+ return result.getContentAsString();
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ throw new LinkyException(String.format("Error getting url : '%s'", url), e);
+ }
+ }
+
+ public PrmInfo getPrmInfo() throws LinkyException {
+ final String prm_info_url = URL_APPS_LINCS + "/mes-mesures/api/private/v1/personnes/null/prms";
+ String data = getData(prm_info_url);
+ PrmInfo[] prms = gson.fromJson(data, PrmInfo[].class);
+ return prms[0];
+ }
+
+ public UserInfo getUserInfo() throws LinkyException {
+ final String user_info_url = URL_APPS_LINCS + "/userinfos";
+ String data = getData(user_info_url);
+ return gson.fromJson(data, UserInfo.class);
+ }
+
+ private Consumption getMeasures(String userId, String prmId, LocalDate from, LocalDate to, String request)
+ throws LinkyException {
+ final String measure_url = URL_APPS_LINCS
+ + "/mes-mesures/api/private/v1/personnes/%s/prms/%s/donnees-%s?dateDebut=%s&dateFin=%s&mesuretypecode=CONS";
+ String url = String.format(measure_url, userId, prmId, request, from.format(API_DATE_FORMAT),
+ to.format(API_DATE_FORMAT));
+ String data = getData(url);
+ ConsumptionReport report = gson.fromJson(data, ConsumptionReport.class);
+ return report.firstLevel.consumptions;
+ }
+
+ public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
+ return getMeasures(userId, prmId, from, to, "energie");
+ }
+
+ public Consumption getPowerData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
+ return getMeasures(userId, prmId, from, to, "pmax");
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.linky.internal.api;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.function.Supplier;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is a simple expiring and reloading cache implementation.
+ *
+ * There must be provided an action in order to retrieve/calculate the value. This action will be called only if the
+ * answer from the last calculation is not valid anymore, i.e. if it is expired.
+ *
+ * The cache expires after the current day; it is possible to shift the beginning time of the day.
+ *
+ * Soft Reference is not used to store the cached value because JVM Garbage Collector is clearing it too much often.
+ *
+ * @author Laurent Garnier - Initial contribution
+ *
+ * @param <V> the type of the value
+ */
+@NonNullByDefault
+public class ExpiringDayCache<V> {
+ private final Logger logger = LoggerFactory.getLogger(ExpiringDayCache.class);
+
+ private final String name;
+ private final int beginningHour;
+ private final Supplier<@Nullable V> action;
+
+ private @Nullable V value;
+ private LocalDateTime expiresAt;
+
+ /**
+ * Create a new instance.
+ *
+ * @param name the name of this cache
+ * @param beginningHour the hour in the day at which the validity period is starting
+ * @param action the action to retrieve/calculate the value
+ */
+ public ExpiringDayCache(String name, int beginningHour, Supplier<@Nullable V> action) {
+ this.name = name;
+ this.beginningHour = beginningHour;
+ this.expiresAt = calcAlreadyExpired();
+ this.action = action;
+ }
+
+ /**
+ * Returns the value - possibly from the cache, if it is still valid.
+ */
+ public synchronized @Nullable V getValue() {
+ @Nullable
+ V cachedValue = value;
+ if (cachedValue == null || isExpired()) {
+ logger.debug("getValue from cache \"{}\" is requiring a fresh value", name);
+ cachedValue = refreshValue();
+ } else {
+ logger.debug("getValue from cache \"{}\" is returning a cached value", name);
+ }
+ return cachedValue;
+ }
+
+ /**
+ * Puts a new value into the cache.
+ *
+ * @param value the new value
+ */
+ public final synchronized void putValue(@Nullable V value) {
+ this.value = value;
+ expiresAt = calcNextExpiresAt();
+ }
+
+ /**
+ * Invalidates the value in the cache.
+ */
+ public final synchronized void invalidateValue() {
+ value = null;
+ expiresAt = calcAlreadyExpired();
+ }
+
+ /**
+ * Refreshes and returns the value in the cache.
+ *
+ * @return the new value
+ */
+ public synchronized @Nullable V refreshValue() {
+ value = action.get();
+ expiresAt = calcNextExpiresAt();
+ return value;
+ }
+
+ /**
+ * Checks if the value is expired.
+ *
+ * @return true if the value is expired
+ */
+ public boolean isExpired() {
+ return !LocalDateTime.now().isBefore(expiresAt);
+ }
+
+ private LocalDateTime calcNextExpiresAt() {
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime limit = now.withHour(beginningHour).truncatedTo(ChronoUnit.HOURS);
+ LocalDateTime result = now.isBefore(limit) ? limit : limit.plusDays(1);
+ logger.debug("calcNextExpiresAt result = {}", result.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
+ return result;
+ }
+
+ private LocalDateTime calcAlreadyExpired() {
+ return LocalDateTime.now().minusDays(1);
+ }
+}
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.linky.internal.LinkyBindingConstants;
import org.openhab.binding.linky.internal.handler.LinkyHandler;
import org.openhab.core.io.console.Console;
import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
@Activate
public LinkyCommandExtension(final @Reference ThingRegistry thingRegistry) {
- super("linky", "Interact with the Linky binding.");
+ super(LinkyBindingConstants.BINDING_ID, "Interact with the Linky binding.");
this.thingRegistry = thingRegistry;
}
}
}
if (thing == null) {
- console.println("Bad thing id '" + args[0] + "'");
+ console.println(String.format("Bad thing id '%s'", args[0]));
printUsage(console);
} else if (thingHandler == null) {
- console.println("No handler initialized for the thing id '" + args[0] + "'");
+ console.println(String.format("No handler initialized for the thing id '%s'", args[0]));
printUsage(console);
} else if (handler == null) {
- console.println("'" + args[0] + "' is not a Linky thing id");
+ console.println(String.format("'%s' is not a Linky thing id", args[0]));
printUsage(console);
} else if (REPORT.equals(args[1])) {
LocalDate now = LocalDate.now();
try {
start = LocalDate.parse(args[2], DateTimeFormatter.ISO_LOCAL_DATE);
} catch (DateTimeParseException e) {
- console.println(
- "Invalid format for start day '" + args[2] + "'; expected format is YYYY-MM-DD");
+ console.println(String
+ .format("Invalid format for start day '%s'; expected format is YYYY-MM-DD", args[2]));
printUsage(console);
return;
}
try {
end = LocalDate.parse(args[3], DateTimeFormatter.ISO_LOCAL_DATE);
} catch (DateTimeParseException e) {
- console.println("Invalid format for end day '" + args[3] + "'; expected format is YYYY-MM-DD");
+ console.println(String.format("Invalid format for end day '%s'; expected format is YYYY-MM-DD",
+ args[3]));
printUsage(console);
return;
}
@Override
public List<String> getUsages() {
- return Arrays.asList(buildCommandUsage("<thingUID> " + REPORT + " <start day> <end day> [<separator>]",
- "report daily consumptions between two dates"));
+ return Arrays
+ .asList(buildCommandUsage(String.format("<thingUID> %s <start day> <end day> [<separator>]", REPORT),
+ "report daily consumptions between two dates"));
}
}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.linky.internal.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link AuthData} holds authentication information
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+
+public class AuthData {
+ public class AuthDataCallBack {
+ public class NameValuePair {
+ public String name;
+ public Object value;
+
+ public @Nullable String valueAsString() {
+ if (value instanceof String) {
+ return (String) value;
+ }
+ return null;
+ }
+ }
+
+ public String type;
+
+ public List<NameValuePair> output = new ArrayList<>();
+ public List<NameValuePair> input = new ArrayList<>();
+ }
+
+ public String authId;
+ public String template;
+ public String stage;
+ public String header;
+ public List<AuthDataCallBack> callbacks = new ArrayList<>();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.linky.internal.dto;
+
+/**
+ * The {@link AuthResult} holds informations about the ongoing authentication process
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+
+public class AuthResult {
+ public String successUrl;
+ public String tokenId;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.linky.internal.dto;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link ConsumptionReport} is responsible for holding values
+ * returned by API calls
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+public class ConsumptionReport {
+ public class Period {
+ public String grandeurPhysiqueEnum;
+ public ZonedDateTime dateDebut;
+ public ZonedDateTime dateFin;
+ }
+
+ public class Aggregate {
+ public List<String> labels;
+ public List<Period> periodes;
+ public List<Double> datas;
+ }
+
+ public class ChronoData {
+ @SerializedName("JOUR")
+ public Aggregate days;
+ @SerializedName("SEMAINE")
+ public Aggregate weeks;
+ @SerializedName("MOIS")
+ public Aggregate months;
+ @SerializedName("ANNEE")
+ public Aggregate years;
+ }
+
+ public class Consumption {
+ public ChronoData aggregats;
+ public String grandeurMetier;
+ public String grandeurPhysique;
+ public String unite;
+ }
+
+ public class FirstLevel {
+ @SerializedName("CONS")
+ public Consumption consumptions;
+ }
+
+ @SerializedName("1")
+ public FirstLevel firstLevel;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.linky.internal.dto;
+
+/**
+ * The {@link UserInfo} holds informations about energy delivery point
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+
+public class PrmInfo {
+ public class Adresse {
+ public Object adresseLigneUn;
+ public String adresseLigneDeux;
+ public Object adresseLigneTrois;
+ public String adresseLigneQuatre;
+ public Object adresseLigneCinq;
+ public String adresseLigneSix;
+ public String adresseLigneSept;
+ }
+
+ public String prmId;
+ public String dateFinRole;
+ public String segment;
+ public Adresse adresse;
+ public String typeCompteur;
+ public String niveauOuvertureServices;
+ public String communiquant;
+ public long dateSoutirage;
+ public String dateInjection;
+ public int departement;
+ public int puissanceSouscrite;
+ public String codeCalendrier;
+ public String codeTitulaire;
+ public boolean collecteActivee;
+ public boolean multiTitulaire;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.linky.internal.dto;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link UserInfo} holds informations about the user account
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+
+public class UserInfo {
+ public class UserProperties {
+ @SerializedName("av2_interne_id")
+ public String internId;
+ @SerializedName("av2_prenom")
+ public String firstName;
+ @SerializedName("av2_mail")
+ public String mail;
+ @SerializedName("av2_nom")
+ public String name;
+ @SerializedName("av2_infos_personnalisees")
+ public String personalInfo;
+ }
+
+ public String username;
+ public boolean connected;
+ public UserProperties userProperties;
+}
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.linky.internal.handler;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import okhttp3.Cookie;
-import okhttp3.CookieJar;
-import okhttp3.HttpUrl;
-
-/**
- * The {@link LinkyCookieJar} is responsible to holds cookies
- * during API session
- *
- * @author Gaël L'hopital - Initial contribution
- */
-public class LinkyCookieJar implements CookieJar {
-
- private static final String LOGIN_URL_PATH = "/auth/UI/Login";
-
- private List<Cookie> cookies = new ArrayList<>();
-
- @Override
- public void saveFromResponse(final HttpUrl url, final List<Cookie> cookies) {
- this.cookies.addAll(cookies);
- }
-
- @Override
- public List<Cookie> loadForRequest(final HttpUrl url) {
- if (LOGIN_URL_PATH.equals(url.url().getPath())) {
- cookies = new ArrayList<>();
- }
- return cookies;
- }
-}
package org.openhab.binding.linky.internal.handler;
import static org.openhab.binding.linky.internal.LinkyBindingConstants.*;
-import static org.openhab.binding.linky.internal.model.LinkyTimeScale.*;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.time.temporal.WeekFields;
import java.util.ArrayList;
-import java.util.Base64;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.linky.internal.ExpiringDayCache;
+import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.linky.internal.LinkyConfiguration;
-import org.openhab.binding.linky.internal.model.LinkyConsumptionData;
-import org.openhab.binding.linky.internal.model.LinkyTimeScale;
+import org.openhab.binding.linky.internal.LinkyException;
+import org.openhab.binding.linky.internal.api.EnedisHttpApi;
+import org.openhab.binding.linky.internal.api.ExpiringDayCache;
+import org.openhab.binding.linky.internal.dto.ConsumptionReport.Aggregate;
+import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption;
+import org.openhab.binding.linky.internal.dto.PrmInfo;
+import org.openhab.binding.linky.internal.dto.UserInfo;
import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SmartHomeUnits;
import org.openhab.core.thing.ChannelUID;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
-import com.google.gson.JsonSyntaxException;
-
-import okhttp3.FormBody;
-import okhttp3.FormBody.Builder;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.Response;
/**
* The {@link LinkyHandler} is responsible for handling commands, which are
public class LinkyHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class);
- private static final String LOGIN_BASE_URI = "https://espace-client-connexion.enedis.fr/auth/UI/Login";
- private static final String API_BASE_URI = "https://espace-client-particuliers.enedis.fr/group/espace-particuliers/suivi-de-consommation";
- private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
private static final int REFRESH_FIRST_HOUR_OF_DAY = 5;
private static final int REFRESH_INTERVAL_IN_MIN = 360;
- private final OkHttpClient client = new OkHttpClient.Builder().followRedirects(false)
- .cookieJar(new LinkyCookieJar()).build();
- private final Gson gson = new Gson();
+ private final HttpClient httpClient;
+ private final Gson gson;
- private @NonNullByDefault({}) ScheduledFuture<?> refreshJob;
+ private @Nullable ScheduledFuture<?> refreshJob;
+ private @Nullable EnedisHttpApi enedisApi;
private final WeekFields weekFields;
- private final ExpiringDayCache<LinkyConsumptionData> cachedDaylyData;
- private final ExpiringDayCache<LinkyConsumptionData> cachedMonthlyData;
- private final ExpiringDayCache<LinkyConsumptionData> cachedYearlyData;
+ private final ExpiringDayCache<Consumption> cachedDaylyData;
+ private final ExpiringDayCache<Consumption> cachedPowerData;
+ private final ExpiringDayCache<Consumption> cachedMonthlyData;
+ private final ExpiringDayCache<Consumption> cachedYearlyData;
+
+ private @NonNullByDefault({}) String prmId;
+ private @NonNullByDefault({}) String userId;
- public LinkyHandler(Thing thing, LocaleProvider localeProvider) {
+ public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) {
super(thing);
+ this.gson = gson;
+ this.httpClient = httpClient;
+
this.weekFields = WeekFields.of(localeProvider.getLocale());
- this.cachedDaylyData = new ExpiringDayCache<LinkyConsumptionData>("daily cache", REFRESH_FIRST_HOUR_OF_DAY,
- () -> {
- final LocalDate today = LocalDate.now();
- return getConsumptionData(DAILY, today.minusDays(13), today, true);
- });
- this.cachedMonthlyData = new ExpiringDayCache<LinkyConsumptionData>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY,
- () -> {
- final LocalDate today = LocalDate.now();
- return getConsumptionData(MONTHLY, today.withDayOfMonth(1).minusMonths(1), today, true);
- });
- this.cachedYearlyData = new ExpiringDayCache<LinkyConsumptionData>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY,
- () -> {
- final LocalDate today = LocalDate.now();
- return getConsumptionData(YEARLY, LocalDate.of(today.getYear() - 1, 1, 1), today, true);
- });
+
+ this.cachedDaylyData = new ExpiringDayCache<>("daily cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
+ LocalDate today = LocalDate.now();
+ return getConsumptionData(today.minusDays(13), today);
+ });
+
+ this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
+ LocalDate to = LocalDate.now().plusDays(1);
+ LocalDate from = to.minusDays(2);
+ return getPowerData(from, to);
+ });
+
+ this.cachedMonthlyData = new ExpiringDayCache<>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
+ LocalDate today = LocalDate.now();
+ return getConsumptionData(today.withDayOfMonth(1).minusMonths(1), today);
+ });
+
+ this.cachedYearlyData = new ExpiringDayCache<>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
+ LocalDate today = LocalDate.now();
+ return getConsumptionData(LocalDate.of(today.getYear() - 1, 1, 1), today);
+ });
}
@Override
public void initialize() {
logger.debug("Initializing Linky handler.");
updateStatus(ThingStatus.UNKNOWN);
- scheduler.submit(this::login);
-
- final LocalDateTime now = LocalDateTime.now();
- final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
- .truncatedTo(ChronoUnit.HOURS);
- refreshJob = scheduler.scheduleWithFixedDelay(this::updateData,
- ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1,
- REFRESH_INTERVAL_IN_MIN, TimeUnit.MINUTES);
- }
- private static Builder getLoginBodyBuilder() {
- return new FormBody.Builder().add("encoded", "true").add("gx_charset", "UTF-8").add("SunQueryParamsString",
- Base64.getEncoder().encodeToString("realm=particuliers".getBytes(StandardCharsets.UTF_8)));
- }
+ LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
+ enedisApi = new EnedisHttpApi(config, gson, httpClient);
- private synchronized boolean login() {
- logger.debug("login");
+ try {
+ enedisApi.initialize();
+ updateStatus(ThingStatus.ONLINE);
- LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
- Request requestLogin = new Request.Builder().url(LOGIN_BASE_URI)
- .post(getLoginBodyBuilder().add("IDToken1", config.username).add("IDToken2", config.password).build())
- .build();
- try (Response response = client.newCall(requestLogin).execute()) {
- if (response.isRedirect()) {
- logger.debug("Response status {} {} redirects to {}", response.code(), response.message(),
- response.header("Location"));
- } else {
- logger.debug("Response status {} {}", response.code(), response.message());
+ if (thing.getProperties().isEmpty()) {
+ Map<String, String> properties = discoverAttributes();
+ updateProperties(properties);
}
- // Do a first call to get data; this first call will fail with code 302
- getConsumptionData(DAILY, LocalDate.now(), LocalDate.now(), false);
- updateStatus(ThingStatus.ONLINE);
- return true;
- } catch (IOException e) {
- logger.debug("Exception while trying to login: {}", e.getMessage(), e);
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
- return false;
+
+ prmId = thing.getProperties().get(PRM_ID);
+ userId = thing.getProperties().get(USER_ID);
+
+ final LocalDateTime now = LocalDateTime.now();
+ final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY)
+ .truncatedTo(ChronoUnit.HOURS);
+
+ updateData();
+
+ refreshJob = scheduler.scheduleWithFixedDelay(this::updateData,
+ ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1,
+ REFRESH_INTERVAL_IN_MIN, TimeUnit.MINUTES);
+
+ } catch (LinkyException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}
+ private Map<String, String> discoverAttributes() throws LinkyException {
+ Map<String, String> properties = new HashMap<>();
+ EnedisHttpApi api = this.enedisApi;
+ if (api != null) {
+ PrmInfo prmInfo = api.getPrmInfo();
+ UserInfo userInfo = api.getUserInfo();
+ properties.put(USER_ID, userInfo.userProperties.internId);
+ properties.put(PUISSANCE, prmInfo.puissanceSouscrite + " kVA");
+ properties.put(PRM_ID, prmInfo.prmId);
+ }
+
+ return properties;
+ }
+
/**
* Request new data and updates channels
*/
private void updateData() {
+ updatePowerData();
updateDailyData();
updateMonthlyData();
updateYearlyData();
}
+ private synchronized void updatePowerData() {
+ if (isLinked(PEAK_POWER) || isLinked(PEAK_TIMESTAMP)) {
+ Consumption result = cachedPowerData.getValue();
+ if (result != null) {
+ updateVAChannel(PEAK_POWER, result.aggregats.days.datas.get(0));
+ updateState(PEAK_TIMESTAMP, new DateTimeType(result.aggregats.days.periodes.get(0).dateDebut));
+ }
+ }
+ }
+
/**
* Request new dayly/weekly data and updates channels
*/
private synchronized void updateDailyData() {
- if (!isLinked(YESTERDAY) && !isLinked(LAST_WEEK) && !isLinked(THIS_WEEK)) {
- return;
- }
-
- double lastWeek = Double.NaN;
- double thisWeek = Double.NaN;
- double yesterday = Double.NaN;
- LinkyConsumptionData result = cachedDaylyData.getValue();
- if (result != null && result.success()) {
- LocalDate rangeStart = LocalDate.now().minusDays(13);
- int jump = result.getDecalage();
- while (rangeStart.getDayOfWeek() != weekFields.getFirstDayOfWeek()) {
- rangeStart = rangeStart.plusDays(1);
- jump++;
- }
-
- int lastWeekNumber = rangeStart.get(weekFields.weekOfWeekBasedYear());
-
- lastWeek = 0.0;
- thisWeek = 0.0;
- yesterday = Double.NaN;
- while (jump < result.getData().size()) {
- double consumption = result.getData().get(jump).valeur;
- if (consumption > 0) {
- if (rangeStart.get(weekFields.weekOfWeekBasedYear()) == lastWeekNumber) {
- lastWeek += consumption;
- logger.trace("Consumption at index {} added to last week: {}", jump, consumption);
+ if (isLinked(YESTERDAY) || isLinked(LAST_WEEK) || isLinked(THIS_WEEK)) {
+ Consumption result = cachedDaylyData.getValue();
+ if (result != null) {
+ Aggregate days = result.aggregats.days;
+
+ int maxValue = days.periodes.size() - 1;
+ int thisWeekNumber = days.periodes.get(maxValue).dateDebut.get(weekFields.weekOfWeekBasedYear());
+ double yesterday = days.datas.get(maxValue);
+ double lastWeek = 0.0;
+ double thisWeek = 0.0;
+
+ for (int i = maxValue; i >= 0; i--) {
+ int weekNumber = days.periodes.get(i).dateDebut.get(weekFields.weekOfWeekBasedYear());
+ if (weekNumber == thisWeekNumber) {
+ thisWeek += days.datas.get(i);
+ } else if (weekNumber == thisWeekNumber - 1) {
+ lastWeek += days.datas.get(i);
} else {
- thisWeek += consumption;
- logger.trace("Consumption at index {} added to current week: {}", jump, consumption);
+ break;
}
- yesterday = consumption;
}
- jump++;
- rangeStart = rangeStart.plusDays(1);
+
+ updateKwhChannel(YESTERDAY, yesterday);
+ updateKwhChannel(THIS_WEEK, thisWeek);
+ updateKwhChannel(LAST_WEEK, lastWeek);
}
- } else {
- cachedDaylyData.invalidateValue();
}
- updateKwhChannel(YESTERDAY, yesterday);
- updateKwhChannel(THIS_WEEK, thisWeek);
- updateKwhChannel(LAST_WEEK, lastWeek);
}
/**
* Request new monthly data and updates channels
*/
private synchronized void updateMonthlyData() {
- if (!isLinked(LAST_MONTH) && !isLinked(THIS_MONTH)) {
- return;
- }
-
- double lastMonth = Double.NaN;
- double thisMonth = Double.NaN;
- LinkyConsumptionData result = cachedMonthlyData.getValue();
- if (result != null && result.success()) {
- int jump = result.getDecalage();
- lastMonth = result.getData().get(jump).valeur;
- thisMonth = result.getData().get(jump + 1).valeur;
- if (thisMonth < 0) {
- thisMonth = 0.0;
+ if (isLinked(LAST_MONTH) || isLinked(THIS_MONTH)) {
+ Consumption result = cachedMonthlyData.getValue();
+ if (result != null) {
+ Aggregate months = result.aggregats.months;
+ updateKwhChannel(LAST_MONTH, months.datas.get(0));
+ updateKwhChannel(THIS_MONTH, months.datas.get(1));
}
- } else {
- cachedMonthlyData.invalidateValue();
}
- updateKwhChannel(LAST_MONTH, lastMonth);
- updateKwhChannel(THIS_MONTH, thisMonth);
}
/**
* Request new yearly data and updates channels
*/
private synchronized void updateYearlyData() {
- if (!isLinked(LAST_YEAR) && !isLinked(THIS_YEAR)) {
- return;
- }
-
- double thisYear = Double.NaN;
- double lastYear = Double.NaN;
- LinkyConsumptionData result = cachedYearlyData.getValue();
- if (result != null && result.success()) {
- int elementQuantity = result.getData().size();
- thisYear = elementQuantity > 0 ? result.getData().get(elementQuantity - 1).valeur : Double.NaN;
- lastYear = elementQuantity > 1 ? result.getData().get(elementQuantity - 2).valeur : Double.NaN;
- } else {
- cachedYearlyData.invalidateValue();
+ if (isLinked(LAST_YEAR) || isLinked(THIS_YEAR)) {
+ Consumption result = cachedYearlyData.getValue();
+ if (result != null) {
+ Aggregate years = result.aggregats.years;
+ updateKwhChannel(LAST_YEAR, years.datas.get(0));
+ updateKwhChannel(THIS_YEAR, years.datas.get(1));
+ }
}
- updateKwhChannel(LAST_YEAR, lastYear);
- updateKwhChannel(THIS_YEAR, thisYear);
}
private void updateKwhChannel(String channelId, double consumption) {
: UnDefType.UNDEF);
}
+ private void updateVAChannel(String channelId, double power) {
+ logger.debug("Update channel {} with {}", channelId, power);
+ updateState(channelId,
+ !Double.isNaN(power) ? new QuantityType<>(power, SmartHomeUnits.VOLT_AMPERE) : UnDefType.UNDEF);
+ }
+
/**
* Produce a report of all daily values between two dates
*
List<String> report = new ArrayList<>();
if (startDay.getYear() == endDay.getYear() && startDay.getMonthValue() == endDay.getMonthValue()) {
// All values in the same month
- LinkyConsumptionData result = getConsumptionData(DAILY, startDay, endDay, true);
- if (result != null && result.success()) {
- LocalDate currentDay = startDay;
- int jump = result.getDecalage();
- while (jump < result.getData().size() && !currentDay.isAfter(endDay)) {
- double consumption = result.getData().get(jump).valeur;
- String line = currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator;
+ Consumption result = getConsumptionData(startDay, endDay);
+ if (result != null) {
+ Aggregate days = result.aggregats.days;
+ for (int i = 0; i < days.datas.size(); i++) {
+ double consumption = days.datas.get(i);
+ String line = days.periodes.get(i).dateDebut.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator;
if (consumption >= 0) {
line += String.valueOf(consumption);
}
report.add(line);
- jump++;
- currentDay = currentDay.plusDays(1);
}
} else {
LocalDate currentDay = startDay;
return report;
}
- private @Nullable LinkyConsumptionData getConsumptionData(LinkyTimeScale timeScale, LocalDate from, LocalDate to,
- boolean reLog) {
- logger.debug("getConsumptionData {}", timeScale);
-
- LinkyConsumptionData result = null;
- boolean tryRelog = false;
-
- FormBody formBody = new FormBody.Builder().add("p_p_id", "lincspartdisplaycdc_WAR_lincspartcdcportlet")
- .add("p_p_lifecycle", "2").add("p_p_resource_id", timeScale.getId())
- .add("_lincspartdisplaycdc_WAR_lincspartcdcportlet_dateDebut", from.format(API_DATE_FORMAT))
- .add("_lincspartdisplaycdc_WAR_lincspartcdcportlet_dateFin", to.format(API_DATE_FORMAT)).build();
-
- Request requestData = new Request.Builder().url(API_BASE_URI).post(formBody).build();
- try (Response response = client.newCall(requestData).execute()) {
- if (response.isRedirect()) {
- String location = response.header("Location");
- logger.debug("Response status {} {} redirects to {}", response.code(), response.message(), location);
- if (reLog && location != null && location.startsWith(LOGIN_BASE_URI)) {
- tryRelog = true;
- }
- } else {
- String body = (response.body() != null) ? response.body().string() : null;
- logger.debug("Response status {} {} : {}", response.code(), response.message(), body);
- if (body != null && !body.isEmpty()) {
- result = gson.fromJson(body, LinkyConsumptionData.class);
- }
+ private @Nullable Consumption getConsumptionData(LocalDate from, LocalDate to) {
+ EnedisHttpApi api = this.enedisApi;
+ if (api != null) {
+ try {
+ return api.getEnergyData(userId, prmId, from, to);
+ } catch (LinkyException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
}
- } catch (IOException e) {
- logger.debug("Exception calling API : {} - {}", e.getClass().getCanonicalName(), e.getMessage());
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
- } catch (JsonSyntaxException e) {
- logger.debug("Exception while converting JSON response : {}", e.getMessage());
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.NONE, e.getMessage());
}
- if (tryRelog && login()) {
- result = getConsumptionData(timeScale, from, to, false);
+ return null;
+ }
+
+ private @Nullable Consumption getPowerData(LocalDate from, LocalDate to) {
+ EnedisHttpApi api = this.enedisApi;
+ if (api != null) {
+ try {
+ return api.getPowerData(userId, prmId, from, to);
+ } catch (LinkyException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
+ }
}
- return result;
+ return null;
}
@Override
public void dispose() {
logger.debug("Disposing the Linky handler.");
-
- if (refreshJob != null && !refreshJob.isCancelled()) {
- refreshJob.cancel(true);
+ ScheduledFuture<?> job = this.refreshJob;
+ if (job != null && !job.isCancelled()) {
+ job.cancel(true);
refreshJob = null;
}
+ EnedisHttpApi api = this.enedisApi;
+ if (api != null) {
+ try {
+ api.dispose();
+ enedisApi = null;
+ } catch (LinkyException ignore) {
+ }
+ }
}
@Override
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.linky.internal.model;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * The {@link LinkyConsumptionData} is responsible for holding values
- * returned by API calls
- *
- * @author Gaël L'hopital - Initial contribution
- */
-public class LinkyConsumptionData {
- private Etat etat;
- private Graphe graphe;
-
- public Etat getEtat() {
- return etat;
- }
-
- public boolean isInactive() {
- return "nonActive".equalsIgnoreCase(etat.valeur);
- }
-
- public boolean success() {
- return "termine".equalsIgnoreCase(etat.valeur);
- }
-
- public List<Data> getData() {
- return graphe.data;
- }
-
- public int getDecalage() {
- return graphe.decalage;
- }
-
- private static class Etat {
- public String valeur;
- }
-
- public static class Graphe {
- public int puissanceSouscrite;
- public int decalage;
- public Periode periode;
- public List<Data> data = new ArrayList<>();
- }
-
- private static class Periode {
- public String dateDebut;
- public String dateFin;
- }
-
- public static class Data {
- public double valeur;
- public int ordre;
-
- public boolean isPositive() {
- return valeur > 0;
- }
- }
-}
+++ /dev/null
-/**
- * Copyright (c) 2010-2020 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.linky.internal.model;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * The {@link LinkyTimeScale} enumerates all possible time scale
- * for API queries
- *
- * @author Gaël L'hopital - Initial contribution
- */
-@NonNullByDefault
-public enum LinkyTimeScale {
- HOURLY("urlCdcHeure"),
- DAILY("urlCdcJour"),
- MONTHLY("urlCdcMois"),
- YEARLY("urlCdcAn");
-
- private String id;
-
- private LinkyTimeScale(String id) {
- this.id = id;
- }
-
- public String getId() {
- return this.id;
- }
-}
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
- <!-- Linky Thing -->
<thing-type id="linky">
<label>Linky</label>
<description>
<context>password</context>
<description>Your Enedis Password</description>
</parameter>
+ <parameter name="internalAuthId" type="text" required="true">
+ <label>Auth ID</label>
+ <description>Authentication ID delivered after the captcha (see documentation).</description>
+ </parameter>
</config-description>
</thing-type>
<channel id="yesterday" typeId="consumption">
<label>Yesterday Consumption</label>
</channel>
+ <channel id="power" typeId="power"/>
+ <channel id="timestamp" typeId="timestamp"/>
</channels>
</channel-group-type>
<label>Weekly consumption</label>
<channels>
<channel id="thisWeek" typeId="consumption">
- <label>Current Week Consumption</label>
+ <label>This Week Consumption</label>
</channel>
<channel id="lastWeek" typeId="consumption">
- <label>Last Week Consumption</label>
+ <label>Maximum power usage yesterday</label>
</channel>
</channels>
</channel-group-type>
<label>Monthly consumption</label>
<channels>
<channel id="thisMonth" typeId="consumption">
- <label>Current Month Consumption</label>
+ <label>This Month Consumption</label>
</channel>
<channel id="lastMonth" typeId="consumption">
<label>Last Month Consumption</label>
<label>Yearly consumption</label>
<channels>
<channel id="thisYear" typeId="consumption">
- <label>Current Year Consumption</label>
+ <label>This Year Consumption</label>
</channel>
<channel id="lastYear" typeId="consumption">
<label>Last Year Consumption</label>
</channels>
</channel-group-type>
-
<channel-type id="consumption">
<item-type>Number:Energy</item-type>
<label>Total Consumption</label>
<state readOnly="true" pattern="%.3f %unit%"></state>
</channel-type>
+ <channel-type id="power">
+ <item-type>Number:Power</item-type>
+ <label>Peak Power</label>
+ <description>Maximum power usage yesterday</description>
+ <state readOnly="true" pattern="%.3f %unit%"></state>
+ </channel-type>
+
+ <channel-type id="timestamp">
+ <item-type>DateTime</item-type>
+ <label>Peak Timestamp</label>
+ <description>Maximum power usage timestamp</description>
+ <state readOnly="true">
+ </state>
+ </channel-type>
</thing:thing-descriptions>