diff --git a/bundles/org.openhab.binding.linky/README.md b/bundles/org.openhab.binding.linky/README.md index 1f7aa92d1ee..e0fba21babd 100644 --- a/bundles/org.openhab.binding.linky/README.md +++ b/bundles/org.openhab.binding.linky/README.md @@ -1,63 +1,341 @@ # Linky Binding This binding uses the API provided by Enedis to retrieve your energy consumption data. -You need to create an Enedis account [here](https://espace-client-connexion.enedis.fr/auth/UI/Login?realm=particuliers) if you don't have one already. - +You need to create an Enedis account [here](https://mon-compte-client.enedis.fr/) if you don't have one already. + Please ensure that you have accepted their conditions, and check that you can see graphs on the website. Especially, check hourly view/graph. Enedis may ask for permission the first time to start collecting hourly data. + The binding will not provide these informations unless this step is ok. +This new binding version is able to use multiple bridge to access the consumption data. +You can use : + +- The enedis-web bridge : this one will use the old Enedis API, base on the enedis web site to gather the data. +- The myelectricaldata bridge : this one will use the new Rest Enedis API. We will use the MyElectricalData proxy site to access the data. +- The enedis bridge : this one will also use the new Rest Enedis API, and will directly gather data from Enedis Site. + +There is advantage and disadvantage for each method. + +- Enedis-web bridge is the old way to go. +- MyelectricalData and enedis bridge both use new API format, less prone to change of the web site architecture. +- MyelectricalData bridge is handle by third party provider, but is stable. +- Enedis bridge use direct connection to Enedis, but currently required complex registration step with Enedis. + this limitation would certainly go away in near feature, that will make Enedis Bridge the preffered way to go. + ## Supported Things There is one supported thing : the `linky` thing is retrieving the consumption of your home from the [Linky electric meter](https://www.enedis.fr/linky-compteur-communicant). +You can have multiple linky thing in your setup if you have different house / linky linked to your account. +You can switch the thing from one bridge to another if you experience troubles with one bridge. +Data will be the quite the same ever bridge you use. +Only a few items from contract are not the same between web bridge and api bridge. + ## Discovery -This binding does not provide discovery service. +This binding currently does not provide discovery service. +Perhaps will add auto linky discovery in a future version. ## Binding Configuration -The binding has no configuration options, all configuration is done at Thing level. +To retrieve data, Linky thing will be need to be linked to a LinkyBridge. LinkyBridge can be today select between enedis-web, myelectricaldata and enedis. + +### enedis-web bridge + +If you select enedis-web bridge, you will need : + +- To create an Enedis account : https://mon-compte-client.enedis.fr/ +- To fill the bridge with you information : username, password, and also InternalAuthId. + + | Parameter | Description | + |----------------|--------------------------------| + | username | Your Enedis platform username. | + | password | Your Enedis platform password. | + | internalAuthId | The internal authID | + + This version is now compatible with the new version of Enedis WEB API (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 . + 1. Select "Particulier" in the drop down list and click on the "Connexion" button. + 1. 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. + 1. Clic on "Suivant". + 1. In the login page, prefilled with your mail address, enter your Enedis account password and click on "Connexion à Espace Client Enedis". + 1. You will be directed to your Enedis account environment. Get back to previous page in you browser. + 1. Disconnect from your Enedis account + 1. Repeat steps 1, 2. You should arrive directly on step 5, then open the developer tool window (F12) and select "Stockage" tab. In the "Cookies" entry, select "https://mon-compte-enedis.fr". You'll find an entry named "internalAuthId", copy this value in your openHAB configuration. + + +### myelectricaldata bridge + +If you select MyElectricalData bridge, you will need : + +- To create an Enedis account : https://mon-compte-client.enedis.fr/ + +Follow these steps to initialize the token. You can access the procedure from the connectlinky page available from your openhab: https://home.myopenhab.org/connectlinky/index. + +- to select your provider + + ![connectlinky-index](doc/connectlinky-index.png) +
+ +- To follow the two first step wizard, and click on the "access Enedis" button + + ![connectlinky-myelectricaldata-step1](doc/connectlinky-myelectricaldata-step1.png)
+ ![connectlinky-myelectricaldata-step2](doc/connectlinky-myelectricaldata-step2.png)
+ +- To login to your Enedis Account + + ![connectlinky-myelectricaldata-step2b](doc/connectlinky-myelectricaldata-step2b.png)
+ +- To authorize data collection for your prmId.
+ +If you have multiple linky on your account like me, you will have to repeat the procedure for each linky. +Don't select the two linky in the same procedure, it will not work ! + + ![connectlinky-myelectricaldata-step2c](doc/connectlinky-myelectricaldata-step2c.png)
+ +- You will then be redirect to a confirmation page on MyElectricalData web site + + ![connectlinky-myelectricaldata-step2d](doc/connectlinky-myelectricaldata-step2d.png)
+ +- Go back to your openhab with step3 : "connectlinky/myelectricaldata-step3", then select your prmId in combobox, and click "Retrieve Token" + + ![connectlinky-myelectricaldata-step3](doc/connectlinky-myelectricaldata-step3.png)
+ +- Last, you will see this confirmation page if everything is everything is ok + + ![connectlinky-myelectricaldata-step3b](doc/connectlinky-myelectricaldata-step3b.png)
+ +If you select enedis bridge, you will need : + +- To create an Enedis account : https://mon-compte-client.enedis.fr/ + +Follow these steps to initialize the token. you can access the procedure from the connectlinky page available from your openhab: https://home.myopenhab.org/connectlinky/index. + +- to select your provider + + ![enedis-index](doc/enedis-index.png)
+ +- To follow the two first step wizard, and click on the "access Enedis" button + + ![connectlinky-enedis-step1](doc/connectlinky-enedis-step1.png)
+ ![connectlinky-enedis-step2](doc/connectlinky-enedis-step2.png)
+ +- To login to your Enedis Account + + ![connectlinky-enedis-step2b](doc/connectlinky-enedis-step2b.png)
+ +- To authorize data collection for your prmId.
+ +If you have multiple linky on your account like me, you will have to repeat the procedure for each linky. +Don't select the two linky in the same procedure, it will not work ! + + ![connectlinky-enedis-step2c](doc/connectlinky-enedis-step2c.png)
+ +- Last, you will see this confirmation page if everything is everything is ok + + ![connectlinky-enedis-step3](doc/connectlinky-enedis-step3.png)
+ + ## Thing Configuration The thing has the following configuration parameters: -| Parameter | Description | -|----------------|--------------------------------| -| username | Your Enedis platform username. | -| password | Your Enedis platform password. | -| internalAuthId | The internal authID | +| Parameter | Description | +|----------------|---------------------------------------------------------------------------------------------| +| prmId | The prmId link to the linky Handler. | +| token | Optional : need if a token necessary to access this Linky thing (use for MyElectricaldata) | -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). +Thing linky:linky:local "Compteur Linky" [ prmId="xxxx", token="yyyyyyyyyyyyyyyyyyyyyyy" ] -Instructions given for Firefox : - -1. Go to . -1. Select "Particulier" in the drop down list and click on the "Connexion" button. -1. 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. -1. Clic on "Suivant". -1. In the login page, prefilled with your mail address, enter your Enedis account password and click on "Connexion à Espace Client Enedis". -1. You will be directed to your Enedis account environment. Get back to previous page in you browser. -1. Disconnect from your Enedis account -1. Repeat steps 1, 2. You should arrive directly on step 5, then open the developer tool window (F12) and select "Stockage" tab. In the "Cookies" entry, select "https://mon-compte-enedis.fr". You'll find an entry named "internalAuthId", copy this value in your openHAB configuration. ## Channels -The information that is retrieved is available as these channels: +The information that is retrieved is available as many different groups. -| 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 | +- The Main group will give information about the contract linked to this linky. + +You will find the following channel: + +| Channel ID | Item Type | Description | +|---------------------------------------------------|----------------|-----------------------------------------------| +| main#identitiy | String | The full name of the contract older | +| main#contractSubscribedPower | String | The subscribed max Power | +| main#contractLastActivationDate | String | | +| main#contractDistributionTariff | String | | +| main#contractOffpeakHours | String | The OffPeakHour link to your contract | +| main#contractLastDistributionTariffChangeDate | String | | +| main#contractSegment | String | | +| main#usagePointId | String | | +| main#usagePointStatus | String | | +| main#usagePointMeterType | String | | +| main#usagePointAddressCity | String | | +| main#usagePointAddressCountry | String | | +| main#usagePointAddressInseeCode | String | | +| main#usagePointAddressPostalCode | String | | +| main#usagePointAddressStreet | String | | +| main#contactMail | String | | +| main#contactPhone | String | | + + +- The tempo group will give information about the tempo day color link to a tempo contract + +| Channel ID | Item Type | Description | +|---------------------------------------------------|----------------|----------------------------------------------------------------------------| +| tempo#tempoInfoToday | String | The tempo color for the current day | +| tempo#tempoInfoTomorrow | String | The tempo color for the tomorrow | +| tempo#tempoInfoTimeSeries | String | A timeseries channel that will expose full tempo information for one year | + + +Using the timeseries channel, you will be able to esealy create a calendar graph to show the tempo calendar. +You will need for this to enable a timeseries persistence framework. +Graph definitions will look like this + +```java +config: + chartType: month + future: false + label: Tempo + period: M + sidebar: true +slots: + calendar: + - component: oh-calendar-axis + config: + cellSize: 10 + dayLabel: + firstDay: 1 + fontSize: 16 + margin: 20 + left: center + monthLabel: + color: "#c0c0ff" + fontSize: 30 + margin: 20 + orient: vertical + top: middle + yearLabel: + color: "#c0c0ff" + fontSize: 30 + margin: 50 + dataZoom: + - component: oh-chart-datazoom + config: + orient: horizontal + show: true + type: slider + grid: [] + legend: + - component: oh-chart-legend + config: + show: false + series: + - component: oh-calendar-series + config: + aggregationFunction: average + calendarIndex: 0 + coordinateSystem: calendar + item: Linky_Melody_Tempo + label: + formatter: =v=> JSON.stringify(v.data[0]).substring(1,11) + show: true + smartFormatter: false + name: Series 1 + service: inmemory + type: heatmap + title: + - component: oh-chart-title + config: + show: true + text: Calendrier Tempo + toolbox: + - component: oh-chart-toolbox + config: + presetFeatures: + - saveAsImage + - restore + - dataView + - dataZoom + - magicType + show: true + tooltip: + - component: oh-chart-tooltip + config: + formatter: "{c}" + show: true + visualMap: + - component: oh-chart-visualmap + config: + bottom: 0 + calculable: true + inRange: + color: + - "#0000ff" + - "#ffffff" + - "#ff0000" + left: center + max: 2 + min: 0 + orient: horizontal + presetPalette: "" + show: false + type: continuous + xAxis: [] + yAxis: [] + +``` + +The resulting graph will look like this: + +![TempoGraph](doc/TempoGraph.png) + + +| Channel ID | Item Type | Description | +|-----------------------|---------------|---------------------------------------| +| daily#yesterday | Number:Energy | Yesterday energy usage | +| daily#day-2 | Number:Energy | Day-2 energy usage | +| daily#day-3 | Number:Energy | Day-3 energy usage | +| daily#consumption | Number:Energy | timeseries for consumption | +| daily#power | Number:Power | Yesterday's peak power usage | +| daily#timestamp | DateTime | Timestamp of the power peak | +| daily#power-2 | Number:Power | Day-2's peak power usage | +| daily#timestamp-2 | DateTime | Timestamp Day-2's of the power peak | +| daily#power-3 | Number:Power | Day-3's peak power usage | +| daily#timestamp-3 | DateTime | Timestamp Day-3's of the power peak | +| daily#mawPower | Number:Power | timeseries for maxPower | + + +| Channel ID | Item Type | Description | +|-----------------------|---------------|------------------------------| +| weekly#thisWeek | Number:Energy | Current week energy usage | +| weekly#lastWeek | Number:Energy | Last week energy usage | +| weekly#week-2 | Number:Energy | Last week energy usage | +| weekly#consumption | Number:Energy | Last week energy usage | +| weekly#maxPower | Number:Energy | Last week energy usage | + + +| Channel ID | Item Type | Description | +|-----------------------|---------------|------------------------------| +| monthly#thisMonth | Number:Energy | Current month energy usage | +| monthly#lastMonth | Number:Energy | Last month energy usage | +| monthly#month-2 | Number:Energy | Last month energy usage | +| monthly#consumption | Number:Energy | Last month energy usage | +| monthly#maxPower | Number:Energy | Last month energy usage | + +| Channel ID | Item Type | Description | +|-----------------------|---------------|------------------------------| +| yearly#thisYear | Number:Energy | Current year energy usage | +| yearly#lastYear | Number:Energy | Last year energy usage | +| yearly#year-2 | Number:Energy | year-2 energy usage | +| yearly#consumption | Number:Energy | Last year energy usage | +| yearly#maxPower | Number:Energy | Last year energy usage | + +![TempoGraph](doc/GraphConso.png) ## Console Commands @@ -97,3 +375,4 @@ Number:Energy ConsoMoisDernier "Conso mois dernier [%.0f %unit%]" { cha Number:Energy ConsoAnneeEnCours "Conso cette année [%.0f %unit%]" { channel="linky:linky:local:yearly#thisYear" } Number:Energy ConsoAnneeDerniere "Conso année dernière [%.0f %unit%]" { channel="linky:linky:local:yearly#lastYear" } ``` + diff --git a/bundles/org.openhab.binding.linky/doc/GraphConso.png b/bundles/org.openhab.binding.linky/doc/GraphConso.png new file mode 100644 index 00000000000..686adaae6ee Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/GraphConso.png differ diff --git a/bundles/org.openhab.binding.linky/doc/TempoGraph.png b/bundles/org.openhab.binding.linky/doc/TempoGraph.png new file mode 100644 index 00000000000..f84f624cb69 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/TempoGraph.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step1.png b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step1.png new file mode 100644 index 00000000000..5ed80d443fc Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step1.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step2.png b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step2.png new file mode 100644 index 00000000000..6bae2af138d Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step2.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step2b.png b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step2b.png new file mode 100644 index 00000000000..bc48ef7f46d Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step2b.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step2c.png b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step2c.png new file mode 100644 index 00000000000..4811191dacb Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step2c.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step3.png b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step3.png new file mode 100644 index 00000000000..98e7ab471b1 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-enedis-step3.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-index.png b/bundles/org.openhab.binding.linky/doc/connectlinky-index.png new file mode 100644 index 00000000000..982108bbcbe Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-index.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step1.png b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step1.png new file mode 100644 index 00000000000..b9cc7f77f3d Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step1.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2.png b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2.png new file mode 100644 index 00000000000..57b205d0089 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2b.png b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2b.png new file mode 100644 index 00000000000..5e7ccdf61ba Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2b.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2c.png b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2c.png new file mode 100644 index 00000000000..5b334becfc2 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2c.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2d.png b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2d.png new file mode 100644 index 00000000000..4d0233b14db Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step2d.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step3.png b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step3.png new file mode 100644 index 00000000000..fbfa30e902c Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step3.png differ diff --git a/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step3b.png b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step3b.png new file mode 100644 index 00000000000..27ed064dabc Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/connectlinky-myelectricaldata-step3b.png differ diff --git a/bundles/org.openhab.binding.linky/src/main/feature/feature.xml b/bundles/org.openhab.binding.linky/src/main/feature/feature.xml index 41dad925440..68373fd3f5f 100644 --- a/bundles/org.openhab.binding.linky/src/main/feature/feature.xml +++ b/bundles/org.openhab.binding.linky/src/main/feature/feature.xml @@ -4,6 +4,7 @@ openhab-runtime-base + openhab-core-auth-oauth2client mvn:org.jsoup/jsoup/1.14.3 mvn:org.openhab.addons.bundles/org.openhab.binding.linky/${project.version} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyAuthServlet.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyAuthServlet.java new file mode 100644 index 00000000000..74700035bef --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyAuthServlet.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2010-2025 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.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.util.MultiMap; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.UrlEncoded; +import org.openhab.binding.linky.internal.handler.ApiBridgeHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The LinkyAuthServlet manages the authorization with the Linky Web API. The servlet implements the + * Authorization Code flow and saves the resulting refreshToken with the bridge. + * + * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ +@NonNullByDefault +public class LinkyAuthServlet extends HttpServlet { + + private static final long serialVersionUID = -4719613645562518231L; + + private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}"); + + private static final String CONTENT_TYPE = "text/html;charset=UTF-8"; + + private static final String HTML_USER_AUTHORIZED = "

Addon authorized for %s.

"; + private static final String HTML_ERROR = "

Call to Enedis failed with error: %s

"; + + // Keys present in the index.html + private static final String KEY_AUTHORIZE_URI = "authorize.uri"; + private static final String KEY_RETRIEVE_TOKEN_URI = "retrieveToken.uri"; + private static final String KEY_REDIRECT_URI = "redirectUri"; + private static final String KEY_CODE = "code.Value"; + private static final String KEY_PRMID = "prmId.Value"; + private static final String KEY_PRMID_OPTION = "prmId.Option"; + private static final String KEY_AUTHORIZED_USER = "authorizedUser"; + private static final String KEY_CB_DISPLAY_CONFIRMATION = "cb.displayConfirmation"; + private static final String KEY_CB_DISPLAY_ERROR = "cb.displayError"; + private static final String KEY_CB_DISPLAY_INSTRUCTION = "cb.displayInstruction"; + private static final String KEY_ERROR = "error"; + private static final String KEY_PAGE_REFRESH = "pageRefresh"; + private static final String TEMPLATE_PATH = "templates/"; + + private final Logger logger = LoggerFactory.getLogger(LinkyAuthServlet.class); + private final String index; + private final String enedisStep1; + private final String enedisStep2; + private final String enedisStep3; + private final String myelectricaldataStep1; + private final String myelectricaldataStep2; + private final String myelectricaldataStep3; + + private ApiBridgeHandler apiBridgeHandler; + + public LinkyAuthServlet(ApiBridgeHandler apiBridgeHandler) throws LinkyException { + this.apiBridgeHandler = apiBridgeHandler; + + try { + this.index = readTemplate("index.html"); + this.enedisStep1 = readTemplate("enedis-step1.html"); + this.enedisStep2 = readTemplate("enedis-step2.html"); + this.enedisStep3 = readTemplate("enedis-step3-cb.html"); + this.myelectricaldataStep1 = readTemplate("myelectricaldata-step1.html"); + this.myelectricaldataStep2 = readTemplate("myelectricaldata-step2.html"); + this.myelectricaldataStep3 = readTemplate("myelectricaldata-step3.html"); + } catch (IOException e) { + throw new LinkyException("unable to initialize auth servlet", e); + } + } + + /** + * Reads a template from file and returns the content as String. + * + * @param templateName name of the template file to read + * @return The content of the template file + * @throws IOException thrown when an HTML template could not be read + */ + private String readTemplate(String templateName) throws IOException { + final URL url = apiBridgeHandler.getBundleContext().getBundle().getEntry(TEMPLATE_PATH + templateName); + + if (url == null) { + throw new FileNotFoundException( + String.format("Cannot find {}' - failed to initialize Linky servlet".formatted(templateName))); + } else { + try (InputStream inputStream = url.openStream()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + logger.debug("Linky auth callback servlet received GET request {}.", req.getRequestURI()); + final Map replaceMap = new HashMap<>(); + + StringBuffer requestUrl = req.getRequestURL(); + String servletBaseUrl = requestUrl != null ? requestUrl.toString() : ""; + + String template = ""; + if (servletBaseUrl.contains("index")) { + template = index; + } else if (servletBaseUrl.contains("enedis-step1")) { + template = enedisStep1; + } else if (servletBaseUrl.contains("enedis-step2")) { + template = enedisStep2; + } else if (servletBaseUrl.contains("enedis-step3-cb")) { + template = enedisStep3; + } else if (servletBaseUrl.contains("myelectricaldata-step1")) { + template = myelectricaldataStep1; + } else if (servletBaseUrl.contains("myelectricaldata-step2")) { + template = myelectricaldataStep2; + } else if (servletBaseUrl.contains("myelectricaldata-step3")) { + template = myelectricaldataStep3; + } else if (servletBaseUrl.contains("enedis")) { + template = enedisStep1; + } else if (servletBaseUrl.contains("myelectricaldata")) { + template = myelectricaldataStep1; + } else { + template = index; + } + + // for some unknown reason, getRequestURL return a malformed URL mixing http:// and port 443 + if (servletBaseUrl.contains(":443")) { + servletBaseUrl = servletBaseUrl.replace("http://", "https://"); + servletBaseUrl = servletBaseUrl.replace(":443", ""); + } + + try { + handleLinkyRedirect(replaceMap, servletBaseUrl, req.getQueryString()); + + resp.setContentType(CONTENT_TYPE); + + StringBuffer optionBuffer = new StringBuffer(); + + List prmIds = apiBridgeHandler.getAllPrmId(); + for (String prmId : prmIds) { + optionBuffer.append(""); + } + + final MultiMap params = new MultiMap<>(); + String queryString = req.getQueryString(); + if (queryString != null) { + UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name()); + } + final String usagePointId = params.getString("usage_point_id"); + final String code = params.getString("code"); + + replaceMap.put(KEY_PRMID, usagePointId); + replaceMap.put(KEY_CODE, code); + + replaceMap.put(KEY_PRMID_OPTION, optionBuffer.toString()); + replaceMap.put(KEY_REDIRECT_URI, servletBaseUrl); + replaceMap.put(KEY_RETRIEVE_TOKEN_URI, servletBaseUrl + "?state=OK"); + + String authorizeUri = apiBridgeHandler.formatAuthorizationUrl(""); + replaceMap.put(KEY_AUTHORIZE_URI, authorizeUri); + resp.getWriter().append(replaceKeysFromMap(template, replaceMap)); + resp.getWriter().close(); + } catch (LinkyException ex) { + resp.setContentType(CONTENT_TYPE); + replaceMap.put(KEY_ERROR, "Error during request handling : " + ex.getMessage()); + resp.getWriter().append(replaceKeysFromMap(template, replaceMap)); + resp.getWriter().close(); + } + } + + /** + * Handles a possible call from Enedis to the redirect_uri. If that is the case Linky will pass the authorization + * codes via the url and these are processed. In case of an error this is shown to the user. If the user was + * authorized this is passed on to the handler. Based on all these different outcomes the HTML is generated to + * inform the user. + * + * @param replaceMap a map with key String values that will be mapped in the HTML templates. + * @param servletBaseURL the servlet base, which should be used as the Linky redirect_uri value + * @param queryString the query part of the GET request this servlet is processing + */ + private void handleLinkyRedirect(Map replaceMap, String servletBaseURL, + @Nullable String queryString) throws LinkyException { + replaceMap.put(KEY_AUTHORIZED_USER, ""); + replaceMap.put(KEY_ERROR, ""); + replaceMap.put(KEY_PAGE_REFRESH, ""); + replaceMap.put(KEY_CB_DISPLAY_CONFIRMATION, "none"); + replaceMap.put(KEY_CB_DISPLAY_ERROR, "none"); + replaceMap.put(KEY_CB_DISPLAY_INSTRUCTION, "true"); + + if (queryString != null) { + final MultiMap params = new MultiMap<>(); + UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name()); + final String reqCode = params.getString("code"); + final String reqState = params.getString("state"); + final String reqError = params.getString("error"); + + replaceMap.put(KEY_PAGE_REFRESH, ""); + + // params.isEmpty() ? "" : String.format(HTML_META_REFRESH_CONTENT, servletBaseURL) + + if (!StringUtil.isBlank(reqError)) { + logger.debug("Linky redirected with an error: {}", reqError); + replaceMap.put(KEY_CB_DISPLAY_ERROR, "true"); + replaceMap.put(KEY_CB_DISPLAY_CONFIRMATION, "none"); + replaceMap.put(KEY_CB_DISPLAY_INSTRUCTION, "none"); + replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError)); + } else if (!StringUtil.isBlank(reqState)) { + replaceMap.put(KEY_CB_DISPLAY_ERROR, "none"); + replaceMap.put(KEY_CB_DISPLAY_CONFIRMATION, "true"); + replaceMap.put(KEY_CB_DISPLAY_INSTRUCTION, "none"); + try { + replaceMap.put(KEY_AUTHORIZED_USER, String.format(HTML_USER_AUTHORIZED, + reqCode + " / " + apiBridgeHandler.authorize(servletBaseURL, reqState, reqCode))); + } catch (LinkyException e) { + logger.debug("Exception during authorizaton: ", e); + replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, e.getMessage())); + } + } + } + } + + /** + * Replaces all keys from the map found in the template with values from the map. If the key is not found the key + * will be kept in the template. + * + * @param template template to replace keys with values + * @param map map with key value pairs to replace in the template + * @return a template with keys replaced + */ + private String replaceKeysFromMap(String template, Map map) { + final Matcher m = MESSAGE_KEY_PATTERN.matcher(template); + final StringBuffer sb = new StringBuffer(); + + while (m.find()) { + try { + final String key = m.group(1); + m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}'))); + } catch (RuntimeException e) { + logger.debug("Error occurred during template filling, cause ", e); + } + } + m.appendTail(sb); + return sb.toString(); + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyBindingConstants.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyBindingConstants.java index 33975c9c66f..5deb1e6672f 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyBindingConstants.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyBindingConstants.java @@ -12,6 +12,8 @@ */ package org.openhab.binding.linky.internal; +import java.util.Set; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.ThingTypeUID; @@ -20,6 +22,7 @@ import org.openhab.core.thing.ThingTypeUID; * used across the whole binding. * * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API * */ @NonNullByDefault public class LinkyBindingConstants { @@ -27,21 +30,96 @@ public class LinkyBindingConstants { public static final String BINDING_ID = "linky"; // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_API_ENEDIS_BRIDGE = new ThingTypeUID(BINDING_ID, "enedis"); + public static final ThingTypeUID THING_TYPE_API_WEB_ENEDIS_BRIDGE = new ThingTypeUID(BINDING_ID, "enedis-web"); + public static final ThingTypeUID THING_TYPE_API_MYELECTRICALDATA_BRIDGE = new ThingTypeUID(BINDING_ID, + "my-electrical-data"); public static final ThingTypeUID THING_TYPE_LINKY = new ThingTypeUID(BINDING_ID, "linky"); + public static final Set SUPPORTED_DEVICE_THING_TYPES_UIDS = Set.of(THING_TYPE_API_ENEDIS_BRIDGE, + THING_TYPE_API_WEB_ENEDIS_BRIDGE, THING_TYPE_API_MYELECTRICALDATA_BRIDGE, THING_TYPE_LINKY); + // Thing properties + // List of all Channel groups id's public static final String PUISSANCE = "puissance"; public static final String PRM_ID = "prmId"; - public static final String USER_ID = "av2_interne_id"; + public static final String USER_ID = "customerId"; + public static final String AV2_ID = "av2_interne_id"; + + public static final String DAILY_GROUP = "daily"; + public static final String WEEKLY_GROUP = "weekly"; + public static final String MONTHLY_GROUP = "monthly"; + public static final String YEARLY_GROUP = "yearly"; + + public static final String MAIN_GROUP = "main"; + public static final String TEMPO_GROUP = "tempo"; + public static final String LOAD_CURVE_GROUP = "loadCurve"; // 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"; - public static final String LAST_MONTH = "monthly#lastMonth"; - public static final String THIS_YEAR = "yearly#thisYear"; - public static final String LAST_YEAR = "yearly#lastYear"; + public static final String CONSUMPTION_CHANNEL = "consumption"; + public static final String MAX_POWER_CHANNEL = "maxPower"; + public static final String POWER_CHANNEL = "power"; + public static final String TIMESTAMP_CHANNEL = "power"; + + public static final String DAY_MINUS_1 = "yesterday"; + public static final String DAY_MINUS_2 = "day-2"; + public static final String DAY_MINUS_3 = "day-3"; + + public static final String PEAK_POWER_DAY_MINUS_1 = "power"; + public static final String PEAK_POWER_TS_DAY_MINUS_1 = "timestamp"; + + public static final String PEAK_POWER_DAY_MINUS_2 = "power-2"; + public static final String PEAK_POWER_TS_DAY_MINUS_2 = "timestamp-2"; + + public static final String PEAK_POWER_DAY_MINUS_3 = "power-3"; + public static final String PEAK_POWER_TS_DAY_MINUS_3 = "timestamp-3"; + + public static final String WEEK_MINUS_0 = "thisWeek"; + public static final String WEEK_MINUS_1 = "lastWeek"; + public static final String WEEK_MINUS_2 = "week-2"; + + public static final String MONTH_MINUS_0 = "thisMonth"; + public static final String MONTH_MINUS_1 = "lastMonth"; + public static final String MONTH_MINUS_2 = "month-2"; + + public static final String YEAR_MINUS_0 = "thisYear"; + public static final String YEAR_MINUS_1 = "lastYear"; + public static final String YEAR_MINUS_2 = "year-2"; + + public static final String TEMPO_TODAY_INFO = "tempoInfoToday"; + public static final String TEMPO_TOMORROW_INFO = "tempoInfoTomorrow"; + public static final String TEMPO_TEMPO_INFO_TIME_SERIES = "tempoInfoTimeSeries"; + + public static final String MAIN_IDENTITY = "identity"; + + public static final String MAIN_CONTRACT_SUBSCRIBED_POWER = "contractSubscribedPower"; + public static final String MAIN_CONTRACT_LAST_ACTIVATION_DATE = "contractLastActivationDate"; + public static final String MAIN_CONTRACT_DISTRIBUTION_TARIFF = "contractDistributionTariff"; + public static final String MAIN_CONTRACT_OFF_PEAK_HOURS = "contractOffpeakHours"; + public static final String MAIN_CONTRACT_CONTRACT_STATUS = "contractStatus"; + public static final String MAIN_CONTRACT_CONTRACT_TYPE = "contractType"; + public static final String MAIN_CONTRACT_LAST_DISTRIBUTION_TARIFF_CHANGE_DATE = "contractLastDistributionTariffChangeDate"; + public static final String MAIN_CONTRACT_SEGMENT = "contractSegment"; + + public static final String MAIN_USAGEPOINT_ID = "usagePointId"; + public static final String MAIN_USAGEPOINT_STATUS = "usagePointStatus"; + public static final String MAIN_USAGEPOINT_METER_TYPE = "usagePointMeterType"; + + public static final String MAIN_USAGEPOINT_METER_ADDRESS_CITY = "usagePointAddressCity"; + public static final String MAIN_USAGEPOINT_METER_ADDRESS_COUNTRY = "usagePointAddressCountry"; + public static final String MAIN_USAGEPOINT_METER_ADDRESS_INSEE_CODE = "usagePointAddressInseeCode"; + public static final String MAIN_USAGEPOINT_METER_ADDRESS_POSTAL_CODE = "usagePointAddressPostalCode"; + public static final String MAIN_USAGEPOINT_METER_ADDRESS_STREET = "usagePointAddressStreet"; + + public static final String MAIN_CONTACT_MAIL = "contactMail"; + public static final String MAIN_CONTACT_PHONE = "contactPhone"; + + // Authorization related Servlet and resources aliases. + public static final String LINKY_ALIAS = "/connectlinky"; + public static final String LINKY_IMG_ALIAS = "/img"; + + /** + * Smartthings scopes needed by this binding to work. + */ + public static final String LINKY_SCOPES = "am_application_scope default"; } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyConfiguration.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyConfiguration.java index 8b471ac673d..fd6608864d9 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyConfiguration.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyConfiguration.java @@ -13,21 +13,31 @@ package org.openhab.binding.linky.internal; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.config.core.Configuration; /** * The {@link LinkyConfiguration} is the class used to match the * thing configuration. * * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API */ @NonNullByDefault -public class LinkyConfiguration { +public class LinkyConfiguration extends Configuration { public static final String INTERNAL_AUTH_ID = "internalAuthId"; + + public String token = ""; + public String timezone = ""; + public String prmId = ""; + public String clientId = ""; + public String clientSecret = ""; + public boolean isSandbox = false; + public String username = ""; public String password = ""; public String internalAuthId = ""; public boolean seemsValid() { - return !username.isBlank() && !password.isBlank() && !internalAuthId.isBlank(); + return !prmId.isBlank(); } } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java index ca60b18dd1d..ef1b2068640 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java @@ -12,25 +12,28 @@ */ package org.openhab.binding.linky.internal; -import static org.openhab.binding.linky.internal.LinkyBindingConstants.THING_TYPE_LINKY; +import static java.time.temporal.ChronoField.*; +import static org.openhab.binding.linky.internal.LinkyBindingConstants.*; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; +import java.time.format.DateTimeFormatterBuilder; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.openhab.binding.linky.internal.handler.EnedisBridgeHandler; +import org.openhab.binding.linky.internal.handler.EnedisWebBridgeHandler; import org.openhab.binding.linky.internal.handler.LinkyHandler; +import org.openhab.binding.linky.internal.handler.MyElectricalDataBridgeHandler; +import org.openhab.core.auth.client.oauth2.OAuthFactory; import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.io.net.http.HttpClientFactory; -import org.openhab.core.io.net.http.TrustAllTrustManager; +import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.ThingHandler; @@ -39,8 +42,7 @@ import org.osgi.service.component.ComponentContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.osgi.service.http.HttpService; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -50,71 +52,101 @@ import com.google.gson.JsonDeserializer; * The {@link LinkyHandlerFactory} is responsible for creating things handlers. * * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API */ @NonNullByDefault -@Component(service = ThingHandlerFactory.class, configurationPid = "binding.linky") +@Component(immediate = true, service = ThingHandlerFactory.class, configurationPid = "binding.linky") public class LinkyHandlerFactory extends BaseThingHandlerFactory { private static final DateTimeFormatter LINKY_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX"); - private static final int REQUEST_BUFFER_SIZE = 8000; - private static final int RESPONSE_BUFFER_SIZE = 200000; + private static final DateTimeFormatter LINKY_LOCALDATE_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd"); + private static final DateTimeFormatter LINKY_LOCALDATETIME_FORMATTER = new DateTimeFormatterBuilder() + .appendPattern("uuuu-MM-dd['T'][' ']HH:mm").optionalStart().appendLiteral(':') + .appendValue(SECOND_OF_MINUTE, 2).optionalStart().appendFraction(NANO_OF_SECOND, 0, 9, true).toFormatter(); - private final Logger logger = LoggerFactory.getLogger(LinkyHandlerFactory.class); - private final Gson gson = new GsonBuilder().registerTypeAdapter(ZonedDateTime.class, - (JsonDeserializer) (json, type, jsonDeserializationContext) -> ZonedDateTime - .parse(json.getAsJsonPrimitive().getAsString(), LINKY_FORMATTER)) + /* + * ; + * + * DateTimeFormatter formatter1 = new DateTimeFormatterBuilder() + * .appendPattern(DATE_TIME_FORMAT_PATTERN) + * // optional decimal point followed by 1 to 6 digits + * .optionalStart() + * .appendPattern(".") + * .appendFraction(ChronoField.MICRO_OF_SECOND, 1, 6, false) + * .optionalEnd() + * .toFormatter(); + */ + + private final HttpClientFactory httpClientFactory; + private final OAuthFactory oAuthFactory; + private final HttpService httpService; + private final ThingRegistry thingRegistry; + private final ComponentContext componentContext; + private final TimeZoneProvider timeZoneProvider; + + private final Gson gson = new GsonBuilder() + .registerTypeAdapter(ZonedDateTime.class, + (JsonDeserializer) (json, type, jsonDeserializationContext) -> ZonedDateTime + .parse(json.getAsJsonPrimitive().getAsString(), LINKY_FORMATTER)) + .registerTypeAdapter(LocalDate.class, + (JsonDeserializer) (json, type, jsonDeserializationContext) -> LocalDate + .parse(json.getAsJsonPrimitive().getAsString(), LINKY_LOCALDATE_FORMATTER)) + .registerTypeAdapter(LocalDateTime.class, + (JsonDeserializer) (json, type, jsonDeserializationContext) -> { + try { + return LocalDateTime.parse(json.getAsJsonPrimitive().getAsString(), + LINKY_LOCALDATETIME_FORMATTER); + } catch (Exception ex) { + return LocalDate.parse(json.getAsJsonPrimitive().getAsString(), LINKY_LOCALDATE_FORMATTER) + .atStartOfDay(); + } + }) .create(); + private final LocaleProvider localeProvider; - private final HttpClient httpClient; @Activate public LinkyHandlerFactory(final @Reference LocaleProvider localeProvider, - final @Reference HttpClientFactory httpClientFactory) { + final @Reference HttpClientFactory httpClientFactory, final @Reference OAuthFactory oAuthFactory, + final @Reference HttpService httpService, final @Reference ThingRegistry thingRegistry, + ComponentContext componentContext, final @Reference TimeZoneProvider timeZoneProvider) { this.localeProvider = localeProvider; - SslContextFactory sslContextFactory = new SslContextFactory.Client(); - try { - SSLContext sslContext = SSLContext.getInstance("SSL"); - sslContext.init(null, new TrustManager[] { TrustAllTrustManager.getInstance() }, null); - sslContextFactory.setSslContext(sslContext); - } catch (NoSuchAlgorithmException e) { - logger.warn("An exception occurred while requesting the SSL encryption algorithm : '{}'", e.getMessage(), - e); - } catch (KeyManagementException e) { - logger.warn("An exception occurred while initialising the SSL context : '{}'", e.getMessage(), e); - } - this.httpClient = httpClientFactory.createHttpClient(LinkyBindingConstants.BINDING_ID, sslContextFactory); - httpClient.setFollowRedirects(false); - httpClient.setRequestBufferSize(REQUEST_BUFFER_SIZE); - httpClient.setResponseBufferSize(RESPONSE_BUFFER_SIZE); + this.timeZoneProvider = timeZoneProvider; + this.httpClientFactory = httpClientFactory; + this.oAuthFactory = oAuthFactory; + this.httpService = httpService; + this.thingRegistry = thingRegistry; + this.componentContext = componentContext; } @Override protected void activate(ComponentContext componentContext) { super.activate(componentContext); - try { - httpClient.start(); - } catch (Exception e) { - logger.warn("Unable to start Jetty HttpClient {}", e.getMessage()); - } - } - - @Override - protected void deactivate(ComponentContext componentContext) { - super.deactivate(componentContext); - try { - httpClient.stop(); - } catch (Exception e) { - logger.warn("Unable to stop Jetty HttpClient {}", e.getMessage()); - } } @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { - return THING_TYPE_LINKY.equals(thingTypeUID); + return SUPPORTED_DEVICE_THING_TYPES_UIDS.contains(thingTypeUID); } @Override protected @Nullable ThingHandler createHandler(Thing thing) { - return supportsThingType(thing.getThingTypeUID()) ? new LinkyHandler(thing, localeProvider, gson, httpClient) - : null; + if (THING_TYPE_API_ENEDIS_BRIDGE.equals(thing.getThingTypeUID())) { + EnedisBridgeHandler handler = new EnedisBridgeHandler((Bridge) thing, this.httpClientFactory, + this.oAuthFactory, this.httpService, thingRegistry, componentContext, gson); + return handler; + } else if (THING_TYPE_API_WEB_ENEDIS_BRIDGE.equals(thing.getThingTypeUID())) { + EnedisWebBridgeHandler handler = new EnedisWebBridgeHandler((Bridge) thing, this.httpClientFactory, + this.oAuthFactory, this.httpService, thingRegistry, componentContext, gson); + return handler; + } else if (THING_TYPE_API_MYELECTRICALDATA_BRIDGE.equals(thing.getThingTypeUID())) { + MyElectricalDataBridgeHandler handler = new MyElectricalDataBridgeHandler((Bridge) thing, + this.httpClientFactory, this.oAuthFactory, this.httpService, thingRegistry, componentContext, gson); + return handler; + } else if (THING_TYPE_LINKY.equals(thing.getThingTypeUID())) { + LinkyHandler handler = new LinkyHandler(thing, localeProvider, timeZoneProvider); + return handler; + } + + return null; } } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/EnedisHttpApi.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/EnedisHttpApi.java index a2d2102ba91..c284ddde7d8 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/EnedisHttpApi.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/EnedisHttpApi.java @@ -13,42 +13,40 @@ package org.openhab.binding.linky.internal.api; import java.net.HttpCookie; -import java.net.URI; import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.HashMap; -import java.util.List; +import java.util.Arrays; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import javax.ws.rs.core.MediaType; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.api.ContentResponse; import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.client.util.FormContentProvider; -import org.eclipse.jetty.client.util.StringContentProvider; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpStatus; 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.Contact; +import org.openhab.binding.linky.internal.dto.Contract; +import org.openhab.binding.linky.internal.dto.Identity; +import org.openhab.binding.linky.internal.dto.MeterReading; import org.openhab.binding.linky.internal.dto.PrmDetail; import org.openhab.binding.linky.internal.dto.PrmInfo; +import org.openhab.binding.linky.internal.dto.ResponseContact; +import org.openhab.binding.linky.internal.dto.ResponseContract; +import org.openhab.binding.linky.internal.dto.ResponseIdentity; +import org.openhab.binding.linky.internal.dto.ResponseMeter; +import org.openhab.binding.linky.internal.dto.ResponseTempo; +import org.openhab.binding.linky.internal.dto.UsagePoint; import org.openhab.binding.linky.internal.dto.UserInfo; +import org.openhab.binding.linky.internal.handler.EnedisWebBridgeHandler; +import org.openhab.binding.linky.internal.handler.LinkyBridgeHandler; +import org.openhab.binding.linky.internal.handler.LinkyHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,191 +57,82 @@ import com.google.gson.JsonSyntaxException; * {@link EnedisHttpApi} wraps the Enedis Webservice. * * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API */ @NonNullByDefault public class EnedisHttpApi { - private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy"); - private static final String ENEDIS_DOMAIN = ".enedis.fr"; - private static final String URL_APPS_LINCS = "https://alex.microapplications" + ENEDIS_DOMAIN; - private static final String URL_MON_COMPTE = "https://mon-compte" + ENEDIS_DOMAIN; - private static final String URL_COMPTE_PART = URL_MON_COMPTE.replace("compte", "compte-particulier"); - private static final String URL_ENEDIS_AUTHENTICATE = URL_APPS_LINCS + "/authenticate?target=" + URL_COMPTE_PART; - private static final String USER_INFO_CONTRACT_URL = URL_APPS_LINCS + "/mon-compte-client/api/private/v1/userinfos"; - private static final String USER_INFO_URL = URL_APPS_LINCS + "/userinfos"; - private static final String PRM_INFO_BASE_URL = URL_APPS_LINCS + "/mes-mesures/api/private/v1/personnes/"; - private static final String PRM_INFO_URL = URL_APPS_LINCS + "/mes-prms-part/api/private/v2/personnes/%s/prms"; - private static final String MEASURE_URL = PRM_INFO_BASE_URL - + "%s/prms/%s/donnees-%s?dateDebut=%s&dateFin=%s&mesuretypecode=CONS"; - private static final URI COOKIE_URI = URI.create(URL_COMPTE_PART); - private static final Pattern REQ_PATTERN = Pattern.compile("ReqID%(.*?)%26"); private final Logger logger = LoggerFactory.getLogger(EnedisHttpApi.class); private final Gson gson; private final HttpClient httpClient; - private final LinkyConfiguration config; + private final LinkyBridgeHandler linkyBridgeHandler; - private boolean connected = false; - - public EnedisHttpApi(LinkyConfiguration config, Gson gson, HttpClient httpClient) { + public EnedisHttpApi(LinkyBridgeHandler linkyBridgeHandler, Gson gson, HttpClient httpClient) { this.gson = gson; this.httpClient = httpClient; - this.config = config; + this.linkyBridgeHandler = linkyBridgeHandler; } - public void initialize() throws LinkyException { - logger.debug("Starting login process for user: {}", config.username); - - try { - addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, config.internalAuthId); - logger.debug("Step 1: getting authentification"); - String data = getContent(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() != HttpStatus.FOUND_302) { - throw new LinkyException("Connection failed step 2"); - } - - logger.debug("Get the location and the ReqID"); - Matcher m = REQ_PATTERN.matcher(getLocation(result)); - if (!m.find()) { - throw new LinkyException("Unable to locate ReqId in header"); - } - - String reqId = m.group(1); - String authenticateUrl = 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%3D" + URL_APPS_LINCS - + "/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(authenticateUrl).header("X-NoSession", "true").header("X-Password", "anonymous") - .header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous").send(); - if (result.getStatus() != HttpStatus.OK_200) { - throw new LinkyException("Connection failed step 3 - auth1: %s", result.getContentAsString()); - } - - AuthData authData = gson.fromJson(result.getContentAsString(), AuthData.class); - if (authData == null || authData.callbacks.size() < 2 || authData.callbacks.get(0).input.isEmpty() - || authData.callbacks.get(1).input.isEmpty() || !config.username - .equals(Objects.requireNonNull(authData.callbacks.get(0).input.get(0)).valueAsString())) { - logger.debug("auth1 - invalid template for auth data: {}", result.getContentAsString()); - throw new LinkyException("Authentication error, the authentication_cookie is probably wrong"); - } - - authData.callbacks.get(1).input.get(0).value = config.password; - logger.debug("Step 4: auth2 - send the auth data"); - result = httpClient.POST(authenticateUrl).header(HttpHeader.CONTENT_TYPE, MediaType.APPLICATION_JSON) - .header("X-NoSession", "true").header("X-Password", "anonymous") - .header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous") - .content(new StringContentProvider(gson.toJson(authData))).send(); - if (result.getStatus() != HttpStatus.OK_200) { - throw new LinkyException("Connection failed step 3 - auth2: %s", result.getContentAsString()); - } - - AuthResult authResult = gson.fromJson(result.getContentAsString(), AuthResult.class); - if (authResult == null) { - throw new LinkyException("Invalid authentication result data"); - } - - logger.debug("Add the tokenId cookie"); - addCookie("enedisExt", authResult.tokenId); - - logger.debug("Step 5: retrieve the SAMLresponse"); - data = getContent(URL_MON_COMPTE + "/" + authResult.successUrl); - htmlDocument = Jsoup.parse(data); - el = htmlDocument.select("form").first(); - samlInput = el.select("input[name=SAMLResponse]").first(); - - logger.debug("Step 6: post the SAMLresponse to finish the authentication"); - result = httpClient.POST(el.attr("action")).content(getFormContent("SAMLResponse", samlInput.attr("value"))) - .send(); - if (result.getStatus() != HttpStatus.FOUND_302) { - throw new LinkyException("Connection failed step 6"); - } - - logger.debug("Step 7: retrieve cookieKey"); - result = httpClient.GET(USER_INFO_CONTRACT_URL); - - @SuppressWarnings("unchecked") - HashMap hashRes = gson.fromJson(result.getContentAsString(), HashMap.class); - - String cookieKey; - if (hashRes != null && hashRes.containsKey("cnAlex")) { - cookieKey = "personne_for_" + hashRes.get("cnAlex"); - } else { - throw new LinkyException("Connection failed step 7, missing cookieKey"); - } - - List lCookie = httpClient.getCookieStore().getCookies(); - Optional cookie = lCookie.stream().filter(it -> it.getName().contains(cookieKey)).findFirst(); - - String cookieVal = cookie.map(HttpCookie::getValue) - .orElseThrow(() -> new LinkyException("Connection failed step 7, missing cookieVal")); - - addCookie(cookieKey, cookieVal); - - connected = true; - } catch (InterruptedException | TimeoutException | ExecutionException | JsonSyntaxException e) { - throw new LinkyException(e, "Error opening connection with Enedis webservice"); - } - } - - private String getLocation(ContentResponse response) { - return response.getHeaders().get(HttpHeader.LOCATION); - } - - private void disconnect() throws LinkyException { - if (connected) { - logger.debug("Logout process"); - connected = false; - try { // Three times in a row to get disconnected - String location = getLocation(httpClient.GET(URL_APPS_LINCS + "/logout")); - location = getLocation(httpClient.GET(location)); - getLocation(httpClient.GET(location)); - httpClient.getCookieStore().removeAll(); - } catch (InterruptedException | ExecutionException | TimeoutException e) { - throw new LinkyException(e, "Error while disconnecting from Enedis webservice"); - } - } - } - - public boolean isConnected() { - return connected; - } - - public void dispose() throws LinkyException { - disconnect(); - } - - private void addCookie(String key, String value) { - HttpCookie cookie = new HttpCookie(key, value); - cookie.setDomain(ENEDIS_DOMAIN); - cookie.setPath("/"); - httpClient.getCookieStore().add(COOKIE_URI, cookie); - } - - private FormContentProvider getFormContent(String fieldName, String fieldValue) { + public FormContentProvider getFormContent(String fieldName, String fieldValue) { Fields fields = new Fields(); fields.put(fieldName, fieldValue); return new FormContentProvider(fields); } - private String getContent(String url) throws LinkyException { + public void addCookie(String key, String value) { + HttpCookie cookie = new HttpCookie(key, value); + cookie.setDomain(EnedisWebBridgeHandler.ENEDIS_DOMAIN); + cookie.setPath("/"); + httpClient.getCookieStore().add(EnedisWebBridgeHandler.COOKIE_URI, cookie); + } + + public String getLocation(ContentResponse response) { + return response.getHeaders().get(HttpHeader.LOCATION); + } + + public String getContent(LinkyHandler handler, String url) throws LinkyException { + return getContent(logger, linkyBridgeHandler, url, httpClient, linkyBridgeHandler.getToken(handler)); + } + + public String getContent(String url) throws LinkyException { + return getContent(logger, linkyBridgeHandler, url, httpClient, ""); + } + + private static String getContent(Logger logger, LinkyBridgeHandler linkyBridgeHandler, String url, + HttpClient httpClient, String token) throws LinkyException { try { - Request request = httpClient.newRequest(url) - .agent("Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"); + Request request = httpClient.newRequest(url); + + request = request.agent("Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"); request = request.method(HttpMethod.GET); + if (!token.isEmpty()) { + request = request.header("Authorization", "" + token); + request = request.header("Accept", "application/json"); + } + ContentResponse result = request.send(); - if (result.getStatus() != HttpStatus.OK_200) { + if (result.getStatus() == HttpStatus.TEMPORARY_REDIRECT_307 + || result.getStatus() == HttpStatus.MOVED_TEMPORARILY_302) { + String loc = result.getHeaders().get("Location"); + String newUrl = linkyBridgeHandler.getBaseUrl() + loc.substring(1); + request = httpClient.newRequest(newUrl); + request = request.method(HttpMethod.GET); + result = request.send(); + + if (result.getStatus() == HttpStatus.TEMPORARY_REDIRECT_307 + || result.getStatus() == HttpStatus.MOVED_TEMPORARILY_302) { + loc = result.getHeaders().get("Location"); + String[] urlParts = loc.split("/"); + if (urlParts.length < 4) { + throw new LinkyException("malformed url : %s", loc); + } + return urlParts[3]; + } + } + if (result.getStatus() != 200) { throw new LinkyException("Error requesting '%s': %s", url, result.getContentAsString()); } + String content = result.getContentAsString(); logger.trace("getContent returned {}", content); return content; @@ -252,54 +141,147 @@ public class EnedisHttpApi { } } - private T getData(String url, Class clazz) throws LinkyException { - if (!connected) { - initialize(); + private T getData(LinkyHandler handler, String url, Class clazz) throws LinkyException { + if (!linkyBridgeHandler.isConnected()) { + linkyBridgeHandler.initialize(); } - String data = getContent(url); - if (data.isEmpty()) { - throw new LinkyException("Requesting '%s' returned an empty response", url); - } - try { - return Objects.requireNonNull(gson.fromJson(data, clazz)); - } catch (JsonSyntaxException e) { - logger.debug("Invalid JSON response not matching {}: {}", clazz.getName(), data); - throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url); + + int numberRetry = 0; + LinkyException lastException = null; + logger.debug("getData begin {}: {}", clazz.getName(), url); + + while (numberRetry < 3) { + try { + String data = getContent(handler, url); + + if (!data.isEmpty()) { + try { + logger.debug("getData success {}: {}", clazz.getName(), url); + return Objects.requireNonNull(gson.fromJson(data, clazz)); + } catch (JsonSyntaxException e) { + logger.debug("Invalid JSON response not matching {}: {}", clazz.getName(), data); + throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url); + } + } + } catch (LinkyException ex) { + lastException = ex; + + logger.debug("getData error {}: {} , retry{}", clazz.getName(), url, numberRetry); + + // try to reinit connection, fail after 3 attemps + linkyBridgeHandler.connectionInit(); + } + numberRetry++; } + + logger.debug("getData error {}: {} , maxRetry", clazz.getName(), url); + + throw Objects.requireNonNull(lastException); } - public PrmInfo getPrmInfo(String internId) throws LinkyException { - String url = PRM_INFO_URL.formatted(internId); - PrmInfo[] prms = getData(url, PrmInfo[].class); + public PrmInfo getPrmInfo(LinkyHandler handler, String internId, String prmId) throws LinkyException { + String prmInfoUrl = linkyBridgeHandler.getContractUrl().formatted(internId); + PrmInfo[] prms = getData(handler, prmInfoUrl, PrmInfo[].class); if (prms.length < 1) { throw new LinkyException("Invalid prms data received"); } - return prms[0]; + + Optional result = Arrays.stream(prms).filter(x -> x.idPrm.equals(prmId)).findFirst(); + if (result.isPresent()) { + return result.get(); + } + + throw new LinkyException(("PRM with id : %s does not exist").formatted(prmId)); } - public PrmDetail getPrmDetails(String internId, String prmId) throws LinkyException { - String url = PRM_INFO_URL.formatted(internId) + "/" + prmId + public PrmDetail getPrmDetails(LinkyHandler handler, String internId, String prmId) throws LinkyException { + String prmInfoUrl = linkyBridgeHandler.getContractUrl(); + String url = prmInfoUrl.formatted(internId) + "/" + prmId + "?embed=SITALI&embed=SITCOM&embed=SITCON&embed=SYNCON"; - return getData(url, PrmDetail.class); + return getData(handler, url, PrmDetail.class); } - public UserInfo getUserInfo() throws LinkyException { - return getData(USER_INFO_URL, UserInfo.class); + public UserInfo getUserInfo(LinkyHandler handler) throws LinkyException { + String userInfoUrl = linkyBridgeHandler.getContactUrl(); + return getData(handler, userInfoUrl, UserInfo.class); } - private Consumption getMeasures(String userId, String prmId, LocalDate from, LocalDate to, String request) + public String formatUrl(String apiUrl, String prmId) { + return apiUrl.formatted(prmId); + } + + public Contract getContract(LinkyHandler handler, String prmId) throws LinkyException { + String contractUrl = linkyBridgeHandler.getContractUrl().formatted(prmId); + ResponseContract contractResponse = getData(handler, contractUrl, ResponseContract.class); + return contractResponse.customer.usagePoint[0].contracts; + } + + public UsagePoint getUsagePoint(LinkyHandler handler, String prmId) throws LinkyException { + String addressUrl = linkyBridgeHandler.getAddressUrl().formatted(prmId); + ResponseContract contractResponse = getData(handler, addressUrl, ResponseContract.class); + return contractResponse.customer.usagePoint[0].usagePoint; + } + + public Identity getIdentity(LinkyHandler handler, String prmId) throws LinkyException { + String identityUrl = linkyBridgeHandler.getIdentityUrl().formatted(prmId); + ResponseIdentity customerIdReponse = getData(handler, identityUrl, ResponseIdentity.class); + String name = customerIdReponse.identity.naturalPerson.lastname; + String[] nameParts = name.split(" "); + if (nameParts.length > 1) { + customerIdReponse.identity.naturalPerson.firstname = name.split(" ")[0]; + customerIdReponse.identity.naturalPerson.lastname = name.split(" ")[1]; + } + return customerIdReponse.identity.naturalPerson; + } + + public Contact getContact(LinkyHandler handler, String prmId) throws LinkyException { + String contactUrl = linkyBridgeHandler.getContactUrl().formatted(prmId); + ResponseContact contactResponse = getData(handler, contactUrl, ResponseContact.class); + return contactResponse.contact; + } + + private MeterReading getMeasures(LinkyHandler handler, String apiUrl, String mps, String prmId, LocalDate from, + LocalDate to) throws LinkyException { + String dtStart = from.format(linkyBridgeHandler.getApiDateFormat()); + String dtEnd = to.format(linkyBridgeHandler.getApiDateFormat()); + + if (handler.supportNewApiFormat()) { + String url = String.format(apiUrl, prmId, dtStart, dtEnd); + ResponseMeter meterResponse = getData(handler, url, ResponseMeter.class); + return meterResponse.meterReading; + } else { + String url = String.format(apiUrl, mps, prmId, dtStart, dtEnd); + ConsumptionReport consomptionReport = getData(handler, url, ConsumptionReport.class); + return MeterReading.convertFromComsumptionReport(consomptionReport); + } + } + + public MeterReading getEnergyData(LinkyHandler handler, String mps, String prmId, LocalDate from, LocalDate to) throws LinkyException { - String url = String.format(MEASURE_URL, userId, prmId, request, from.format(API_DATE_FORMAT), - to.format(API_DATE_FORMAT)); - ConsumptionReport report = getData(url, ConsumptionReport.class); - return report.firstLevel.consumptions; + return getMeasures(handler, linkyBridgeHandler.getDailyConsumptionUrl(), mps, prmId, from, to); } - public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException { - return getMeasures(userId, prmId, from, to, "energie"); + public MeterReading getLoadCurveData(LinkyHandler handler, String mps, String prmId, LocalDate from, LocalDate to) + throws LinkyException { + return getMeasures(handler, linkyBridgeHandler.getLoadCurveUrl(), mps, prmId, from, to); } - public Consumption getPowerData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException { - return getMeasures(userId, prmId, from, to, "pmax"); + public MeterReading getPowerData(LinkyHandler handler, String mps, String prmId, LocalDate from, LocalDate to) + throws LinkyException { + return getMeasures(handler, linkyBridgeHandler.getMaxPowerUrl(), mps, prmId, from, to); + } + + public ResponseTempo getTempoData(LinkyHandler handler, LocalDate from, LocalDate to) throws LinkyException { + String dtStart = from.format(linkyBridgeHandler.getApiDateFormatYearsFirst()); + String dtEnd = to.format(linkyBridgeHandler.getApiDateFormatYearsFirst()); + + String url = String.format(linkyBridgeHandler.getTempoUrl(), dtStart, dtEnd); + + if (url.isEmpty()) { + return new ResponseTempo(); + } + + ResponseTempo responseTempo = getData(handler, url, ResponseTempo.class); + return responseTempo; } } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/AuthData.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/AuthData.java index ea5f309fa76..06cc7cc4930 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/AuthData.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/AuthData.java @@ -29,6 +29,11 @@ public class AuthData { public @Nullable String name; public @Nullable Object value; + public NameValuePair(String name, Object value) { + this.name = name; + this.value = value; + } + public @Nullable String valueAsString() { return (value instanceof String stringValue) ? stringValue : null; } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ConsumptionReport.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ConsumptionReport.java index 71e324f9f4c..d9d0ffddd63 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ConsumptionReport.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ConsumptionReport.java @@ -12,7 +12,8 @@ */ package org.openhab.binding.linky.internal.dto; -import java.time.ZonedDateTime; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import com.google.gson.annotations.SerializedName; @@ -22,28 +23,32 @@ import com.google.gson.annotations.SerializedName; * returned by API calls * * @author Gaël L'hopital - Initial contribution + * @author Laurent ARNAL - fix to handle new Dto format after enedis site modifications */ public class ConsumptionReport { - public class Period { - public String grandeurPhysiqueEnum; - public ZonedDateTime dateDebut; - public ZonedDateTime dateFin; + + public class Data { + public LocalDateTime dateDebut; + public LocalDateTime dateFin; + public Double valeur; } public class Aggregate { - public List labels; - public List periodes; - public List datas; + @SerializedName("donnees") + public List datas; + public String unite; } public class ChronoData { - @SerializedName("JOUR") + @SerializedName("heure") + public Aggregate heure; + @SerializedName("jour") public Aggregate days; - @SerializedName("SEMAINE") + @SerializedName("semaine") public Aggregate weeks; - @SerializedName("MOIS") + @SerializedName("mois") public Aggregate months; - @SerializedName("ANNEE") + @SerializedName("annee") public Aggregate years; } @@ -51,14 +56,10 @@ public class ConsumptionReport { public ChronoData aggregats; public String grandeurMetier; public String grandeurPhysique; - public String unite; + public LocalDate dateDebut; + public LocalDate dateFin; } - public class FirstLevel { - @SerializedName("CONS") - public Consumption consumptions; - } - - @SerializedName("1") - public FirstLevel firstLevel; + @SerializedName("cons") + public Consumption consumptions; } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Contact.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Contact.java new file mode 100644 index 00000000000..ee7cef390d3 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Contact.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2025 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 Laurent Arnal - Initial contribution + */ + +public class Contact { + public String phone; + public String email; + + public static Contact convertFromUserInfo(UserInfo userInfo) { + Contact result = new Contact(); + + result.email = userInfo.userProperties.mail; + + return result; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Contract.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Contract.java new file mode 100644 index 00000000000..1e5899aa424 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Contract.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2010-2025 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 energy delivery point + * + * @author Laurent Arnal - Initial contribution + */ + +public class Contract { + public String segment; + + @SerializedName("subscribed_power") + public String subscribedPower; + + @SerializedName("last_activation_date") + public String lastActivationDate; + + @SerializedName("distribution_tariff") + public String distributionTariff; + + @SerializedName("offpeak_hours") + public String offpeakHours; + + @SerializedName("contract_status") + public String contractStatus; + + @SerializedName("contract_type") + public String contractType; + + @SerializedName("last_distribution_tariff_change_date") + public String lastDistributionTariffChangeDate; + + public static Contract convertFromPrmDetail(PrmDetail prmDetail) { + Contract result = new Contract(); + + result.segment = prmDetail.segment; + result.subscribedPower = prmDetail.situationContractuelleDtos[0].structureTarifaire().puissanceSouscrite() + .valeur(); + result.lastActivationDate = ""; + result.distributionTariff = prmDetail.situationContractuelleDtos[0].structureTarifaire().grilleFournisseur() + .calendrier().libelle(); + result.offpeakHours = ""; + result.contractStatus = prmDetail.situationContractuelleDtos[0].informationsContractuelles().etatContractuel() + .code(); + result.contractType = prmDetail.situationContractuelleDtos[0].informationsContractuelles().contrat() + .typeContrat().libelle(); + result.lastDistributionTariffChangeDate = ""; + + return result; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Identity.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Identity.java new file mode 100644 index 00000000000..6a95b16d689 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Identity.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2025 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 + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class Identity { + public String title; + public String firstname; + public String lastname; + + public static Identity convertFromUserInfo(UserInfo userInfo) { + Identity result = new Identity(); + + result.firstname = userInfo.userProperties.firstName; + result.lastname = userInfo.userProperties.name; + result.title = ""; + + return result; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/IntervalReading.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/IntervalReading.java new file mode 100644 index 00000000000..8ca5b835e64 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/IntervalReading.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2025 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.LocalDateTime; + +/** + * The {@link UserInfo} holds informations about energy delivery point + * + * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class IntervalReading { + public double value; + public LocalDateTime date; +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/MeterReading.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/MeterReading.java new file mode 100644 index 00000000000..f4de7ff4113 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/MeterReading.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2010-2025 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 org.openhab.binding.linky.internal.dto.ConsumptionReport.Data; + +import com.google.gson.annotations.SerializedName; + +/** + * The {@link UserInfo} holds informations about energy delivery point + * + * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class MeterReading { + @SerializedName("usage_point_id") + public String usagePointId; + + @SerializedName("start") + public String startDate; + + @SerializedName("end") + public String endDate; + + public String quality; + + @SerializedName("reading_type") + public ReadingType readingType; + + @SerializedName("interval_reading") + public IntervalReading[] baseValue; + public IntervalReading[] weekValue; + public IntervalReading[] monthValue; + public IntervalReading[] yearValue; + + public static MeterReading convertFromComsumptionReport(ConsumptionReport comsumptionReport) { + MeterReading result = new MeterReading(); + result.readingType = new ReadingType(); + + if (comsumptionReport.consumptions.aggregats != null) { + if (comsumptionReport.consumptions.aggregats.days != null) { + result.baseValue = fromAgregat(comsumptionReport.consumptions.aggregats.days); + } else if (comsumptionReport.consumptions.aggregats.heure != null) { + result.baseValue = fromAgregat(comsumptionReport.consumptions.aggregats.heure); + } + } + + return result; + } + + public static IntervalReading[] fromAgregat(ConsumptionReport.Aggregate agregat) { + int size = agregat.datas.size(); + IntervalReading[] result = new IntervalReading[size]; + + for (int i = 0; i < size; i++) { + Data dataObj = agregat.datas.get(i); + result[i] = new IntervalReading(); + result[i].value = Double.valueOf(dataObj.valeur); + result[i].date = dataObj.dateDebut; + } + + return result; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/PrmDetail.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/PrmDetail.java index e87b7fa147e..32eaac0448f 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/PrmDetail.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/PrmDetail.java @@ -21,6 +21,7 @@ import java.util.ArrayList; */ public class PrmDetail { public record Adresse(String ligne2, String ligne3, String ligne4, String ligne5, String ligne6) { + } public record DicEntry(String code, String libelle) { diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/PrmInfo.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/PrmInfo.java index bd95e4648ef..a9e5bf447c2 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/PrmInfo.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/PrmInfo.java @@ -16,6 +16,7 @@ package org.openhab.binding.linky.internal.dto; * The {@link UserInfo} holds ids of existing Prms * * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API */ public class PrmInfo { diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ReadingType.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ReadingType.java new file mode 100644 index 00000000000..0fd6cd37b46 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ReadingType.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2025 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 energy delivery point + * + * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class ReadingType { + @SerializedName("measurement_kind") + public String measurementKind; + + @SerializedName("measuring_period") + public String measuringPeriod; + + @SerializedName("unit") + public String unit; + + @SerializedName("aggregate") + public String aggregate; +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseContact.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseContact.java new file mode 100644 index 00000000000..e773f59a08c --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseContact.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2025 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 energy delivery point + * + * @author Laurent Arnal - Initial contribution - Rewrite addon to use official dataconect API + */ + +public class ResponseContact { + @SerializedName("customer_id") + public String customerId; + + @SerializedName("contact_data") + public Contact contact; +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseContract.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseContract.java new file mode 100644 index 00000000000..7385f7d0992 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseContract.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2010-2025 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 ResponseContract} holds informations about the contract + * + * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class ResponseContract { + public Customer customer; + + public class Customer { + @SerializedName("customer_id") + public String customerId; + + @SerializedName("usage_points") + public UsagePoints[] usagePoint; + } + + public class UsagePoints { + @SerializedName("usage_point") + public UsagePoint usagePoint; + public Contract contracts; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseIdentity.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseIdentity.java new file mode 100644 index 00000000000..00b07e46ad6 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseIdentity.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2025 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 energy delivery point + * + * @author Laurent Arnal - Initial contribution - Rewrite addon to use official dataconect API + */ + +public class ResponseIdentity { + @SerializedName("customer_id") + public String customerId; + + public IdentityEntry identity; + + public class IdentityEntry { + @SerializedName("natural_person") + public Identity naturalPerson; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseMeter.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseMeter.java new file mode 100644 index 00000000000..36c0e7ff092 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseMeter.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2025 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 energy delivery point + * + * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class ResponseMeter { + @SerializedName("meter_reading") + public MeterReading meterReading; +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseTempo.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseTempo.java new file mode 100644 index 00000000000..a6a1a5d0bc1 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/ResponseTempo.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2025 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.LinkedHashMap; + +/** + * The {@link UserInfo} holds informations about energy delivery point + * + * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class ResponseTempo extends LinkedHashMap { + private static final long serialVersionUID = 362498820763181264L; +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/TempoDay.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/TempoDay.java new file mode 100644 index 00000000000..132089acf24 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/TempoDay.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2010-2025 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 + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class TempoDay { + public String tempoDay; + public String tempoVal; +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/UsagePoint.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/UsagePoint.java new file mode 100644 index 00000000000..d53faade17a --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/UsagePoint.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2010-2025 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 energy delivery point + * + * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class UsagePoint { + @SerializedName("usage_point_id") + public String usagePointId; + + @SerializedName("usage_point_status") + public String usagePointStatus; + + @SerializedName("meter_type") + public String meterType; + + @SerializedName("usage_point_addresses") + public AddressInfo usagePointAddresses; + + public class AddressInfo { + public String street; + public String locality; + + @SerializedName("postal_code") + public String postalCode; + + @SerializedName("insee_code") + public String inseeCode; + public String city; + public String country; + } + + public static UsagePoint convertFromPrmDetail(PrmInfo prmInfo, PrmDetail prmDetail) { + UsagePoint result = new UsagePoint(); + + result.usagePointId = prmInfo.idPrm; + result.usagePointStatus = prmDetail.syntheseContractuelleDto.niveauOuvertureServices().libelle(); + result.meterType = prmDetail.situationComptageDto.dispositifComptage().typeComptage().code(); + result.usagePointAddresses = result.new AddressInfo(); + + result.usagePointAddresses.street = prmDetail.adresse.ligne4(); + result.usagePointAddresses.locality = prmDetail.adresse.ligne6(); + result.usagePointAddresses.city = prmDetail.adresse.ligne6().split(" ")[1]; + result.usagePointAddresses.postalCode = prmDetail.adresse.ligne6().split(" ")[0]; + result.usagePointAddresses.inseeCode = ""; + result.usagePointAddresses.country = ""; + + return result; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ApiBridgeHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ApiBridgeHandler.java new file mode 100644 index 00000000000..d66064b908a --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ApiBridgeHandler.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2010-2025 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.io.IOException; +import java.util.Hashtable; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.linky.internal.LinkyAuthServlet; +import org.openhab.binding.linky.internal.LinkyBindingConstants; +import org.openhab.binding.linky.internal.LinkyException; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthClientService; +import org.openhab.core.auth.client.oauth2.OAuthException; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.auth.client.oauth2.OAuthResponseException; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingStatus; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * {@link ApiBridgeHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + */ +@NonNullByDefault +public abstract class ApiBridgeHandler extends LinkyBridgeHandler { + private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class); + + private final OAuthFactory oAuthFactory; + + private @Nullable OAuthClientService oAuthService; + + private static @Nullable HttpServlet servlet; + + protected String tokenUrl = ""; + protected String authorizeUrl = ""; + + public ApiBridgeHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + final @Reference ThingRegistry thingRegistry, ComponentContext componentContext, Gson gson) { + super(bridge, httpClientFactory, oAuthFactory, httpService, thingRegistry, componentContext, gson); + + this.oAuthFactory = oAuthFactory; + + updateStatus(ThingStatus.UNKNOWN); + } + + @Override + public void initialize() { + super.initialize(); + + this.oAuthService = oAuthFactory.createOAuthClientService(LinkyBindingConstants.BINDING_ID, tokenUrl, + authorizeUrl, getClientId(), getClientSecret(), LinkyBindingConstants.LINKY_SCOPES, true); + + registerServlet(); + } + + public abstract String getClientId(); + + public abstract String getClientSecret(); + + public abstract boolean getIsSandbox(); + + private void registerServlet() { + try { + if (servlet == null) { + servlet = createServlet(); + + httpService.registerServlet(LinkyBindingConstants.LINKY_ALIAS, servlet, new Hashtable<>(), + httpService.createDefaultHttpContext()); + httpService.registerResources(LinkyBindingConstants.LINKY_ALIAS + LinkyBindingConstants.LINKY_IMG_ALIAS, + "web", null); + } + } catch (NamespaceException | ServletException | LinkyException e) { + logger.warn("Error during linky servlet startup", e); + } + } + + @Override + public void dispose() { + httpService.unregister(LinkyBindingConstants.LINKY_ALIAS); + httpService.unregister(LinkyBindingConstants.LINKY_ALIAS + LinkyBindingConstants.LINKY_IMG_ALIAS); + + super.dispose(); + } + + /** + * Creates a new {@link LinkyAuthServlet}. + * + * @return the newly created servlet + * @throws IOException thrown when an HTML template could not be read + */ + private HttpServlet createServlet() throws LinkyException { + return new LinkyAuthServlet(this); + } + + public String authorize(String redirectUri, String reqState, String reqCode) throws LinkyException { + // Will work only in case of direct oAuth2 authentification to enedis + // this is not the case in v1 as we go trough MyElectricalData + + try { + logger.debug("Make call to Enedis to get access token."); + OAuthClientService lcOAuthService = this.oAuthService; + if (lcOAuthService == null) { + return ""; + } + + final AccessTokenResponse credentials = lcOAuthService + .getAccessTokenByClientCredentials(LinkyBindingConstants.LINKY_SCOPES); + + String accessToken = credentials.getAccessToken(); + + logger.debug("Acces token: {}", accessToken); + return accessToken; + } catch (RuntimeException | OAuthException | IOException e) { + throw new LinkyException("Error during oAuth authorize :" + e.getMessage(), e); + } catch (final OAuthResponseException e) { + throw new LinkyException("Error during oAuth authorize :" + e.getMessage(), e); + } + } + + public boolean isAuthorized() { + final AccessTokenResponse accessTokenResponse = getAccessTokenResponse(); + + return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null + && accessTokenResponse.getRefreshToken() != null; + } + + protected @Nullable AccessTokenResponse getAccessTokenByClientCredentials() { + try { + OAuthClientService lcOAuthService = this.oAuthService; + if (lcOAuthService == null) { + return null; + } + + return lcOAuthService.getAccessTokenByClientCredentials(LinkyBindingConstants.LINKY_SCOPES); + } catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) { + logger.debug("Exception checking authorization: ", e); + return null; + } + } + + protected @Nullable AccessTokenResponse getAccessTokenResponse() { + try { + OAuthClientService lcOAuthService = this.oAuthService; + if (lcOAuthService == null) { + return null; + } + + return lcOAuthService.getAccessTokenResponse(); + } catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) { + logger.debug("Exception checking authorization: ", e); + return null; + } + } + + public String formatAuthorizationUrl(String redirectUri) { + try { + OAuthClientService lcOAuthService = this.oAuthService; + if (lcOAuthService == null) { + return ""; + } + + String uri = lcOAuthService.getAuthorizationUrl(redirectUri, LinkyBindingConstants.LINKY_SCOPES, + LinkyBindingConstants.BINDING_ID); + return uri; + } catch (final OAuthException e) { + logger.debug("Error constructing AuthorizationUrl: ", e); + return ""; + } + } + + @Override + public boolean supportNewApiFormat() { + return true; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/EnedisBridgeHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/EnedisBridgeHandler.java new file mode 100644 index 00000000000..04607ecb341 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/EnedisBridgeHandler.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2010-2025 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.time.format.DateTimeFormatter; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.linky.internal.LinkyConfiguration; +import org.openhab.binding.linky.internal.LinkyException; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ThingRegistry; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * {@link EnedisBridgeHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + */ +@NonNullByDefault +public class EnedisBridgeHandler extends ApiBridgeHandler { + private final Logger logger = LoggerFactory.getLogger(EnedisBridgeHandler.class); + + private static final String BASE_URL_PREPROD = "https://gw.ext.prod-sandbox.api.enedis.fr/"; + private static final String ENEDIS_ACCOUNT_URL_PREPROD = "gw.ext.prod-sandbox.api.enedis.fr"; + + private static final String BASE_URL_PROD = "https://gw.ext.prod.api.enedis.fr/"; + public static final String ENEDIS_ACCOUNT_URL_PROD = "https://mon-compte-particulier.enedis.fr/"; + + private static final String CONTRACT_URL = "customers_upc/v5/usage_points/contracts?usage_point_id=%s"; + private static final String IDENTITY_URL = "customers_i/v5/identity?usage_point_id=%s"; + private static final String CONTACT_URL = "customers_cd/v5/contact_data?usage_point_id=%s"; + private static final String ADDRESS_URL = "customers_upa/v5/usage_points/addresses?usage_point_id=%s"; + + private static final String MEASURE_DAILY_CONSUMPTION_URL = "metering_data_dc/v5/daily_consumption?usage_point_id=%s&start=%s&end=%s"; + private static final String MEASURE_MAX_POWER_URL = "metering_data_dcmp/v5/daily_consumption_max_power?usage_point_id=%s&start=%s&end=%s"; + private static final String LOAD_CURVE_CONSUMPTION_URL = "metering_data_clc/v5/consumption_load_curve?usage_point_id=%s&start=%s&end=%s"; + + public static final String ENEDIS_AUTHORIZE_URL = "dataconnect/v1/oauth2/authorize?duration=P36M"; + public static final String ENEDIS_API_TOKEN_URL = "oauth2/v3/token"; + + private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter API_DATE_FORMAT_YEAR_FIRST = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final String BASE_MYELECT_URL = "https://www.myelectricaldata.fr/"; + private static final String TEMPO_URL = BASE_MYELECT_URL + "rte/tempo/%s/%s"; + + public EnedisBridgeHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + final @Reference ThingRegistry thingRegistry, ComponentContext componentContext, Gson gson) { + super(bridge, httpClientFactory, oAuthFactory, httpService, thingRegistry, componentContext, gson); + } + + @Override + public void initialize() { + tokenUrl = getBaseUrl() + EnedisBridgeHandler.ENEDIS_API_TOKEN_URL; + authorizeUrl = getAccountUrl() + EnedisBridgeHandler.ENEDIS_AUTHORIZE_URL; + + super.initialize(); + } + + public String getAccountUrl() { + if (getIsSandbox()) { + return ENEDIS_ACCOUNT_URL_PREPROD; + } else { + return ENEDIS_ACCOUNT_URL_PROD; + } + } + + @Override + public String getClientId() { + LinkyConfiguration lcConfig = config; + if (lcConfig != null) { + return lcConfig.clientId; + } + return ""; + } + + @Override + public String getClientSecret() { + LinkyConfiguration lcConfig = config; + if (lcConfig != null) { + return lcConfig.clientSecret; + } + return ""; + } + + @Override + public boolean getIsSandbox() { + LinkyConfiguration lcConfig = config; + return (lcConfig != null) ? lcConfig.isSandbox : false; + } + + @Override + public void dispose() { + logger.debug("Shutting down Enedis bridge handler."); + + super.dispose(); + } + + @Override + public void connectionInit() { + } + + @Override + public String getToken(LinkyHandler handler) throws LinkyException { + AccessTokenResponse accesToken = getAccessTokenResponse(); + if (accesToken == null) { + accesToken = getAccessTokenByClientCredentials(); + } + + if (accesToken == null) { + throw new LinkyException("no token"); + } + + return "Bearer " + accesToken.getAccessToken(); + } + + @Override + public double getDivider() { + return 1000.00; + } + + @Override + public String getBaseUrl() { + if (getIsSandbox()) { + return BASE_URL_PREPROD; + } else { + return BASE_URL_PROD; + } + } + + @Override + public String getContactUrl() { + return getBaseUrl() + CONTACT_URL; + } + + @Override + public String getContractUrl() { + return getBaseUrl() + CONTRACT_URL; + } + + @Override + public String getIdentityUrl() { + return getBaseUrl() + IDENTITY_URL; + } + + @Override + public String getAddressUrl() { + return getBaseUrl() + ADDRESS_URL; + } + + @Override + public String getDailyConsumptionUrl() { + return getBaseUrl() + MEASURE_DAILY_CONSUMPTION_URL; + } + + @Override + public String getMaxPowerUrl() { + return getBaseUrl() + MEASURE_MAX_POWER_URL; + } + + @Override + public String getLoadCurveUrl() { + return getBaseUrl() + LOAD_CURVE_CONSUMPTION_URL; + } + + @Override + public String getTempoUrl() { + return TEMPO_URL; + } + + @Override + public DateTimeFormatter getApiDateFormat() { + return API_DATE_FORMAT; + } + + @Override + public DateTimeFormatter getApiDateFormatYearsFirst() { + return API_DATE_FORMAT_YEAR_FIRST; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/EnedisWebBridgeHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/EnedisWebBridgeHandler.java new file mode 100644 index 00000000000..8a10c56519b --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/EnedisWebBridgeHandler.java @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2010-2025 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.net.HttpCookie; +import java.net.URI; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.ws.rs.core.MediaType; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +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.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ThingRegistry; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * {@link EnedisBridgeHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + * + */ +@NonNullByDefault +public class EnedisWebBridgeHandler extends LinkyBridgeHandler { + private final Logger logger = LoggerFactory.getLogger(EnedisWebBridgeHandler.class); + + public static final String ENEDIS_DOMAIN = ".enedis.fr"; + + private static final String BASE_URL = "https://alex.microapplications" + ENEDIS_DOMAIN; + + public static final String URL_MON_COMPTE = "https://mon-compte" + ENEDIS_DOMAIN; + public static final String URL_COMPTE_PART = URL_MON_COMPTE.replace("compte", "compte-particulier"); + public static final URI COOKIE_URI = URI.create(URL_COMPTE_PART); + + private static final String USER_INFO_CONTRACT_URL = BASE_URL + "/mon-compte-client/api/private/v1/userinfos"; + private static final String USER_INFO_URL = BASE_URL + "/userinfos"; + private static final String PRM_INFO_BASE_URL = BASE_URL + "/mes-mesures-prm/api/private/v1/personnes/"; + private static final String PRM_INFO_URL = BASE_URL + "/mes-prms-part/api/private/v2/personnes/%s/prms"; + + private static final String MEASURE_DAILY_CONSUMPTION_URL = PRM_INFO_BASE_URL + + "%s/prms/%s/donnees-energetiques?mesuresTypeCode=ENERGIE&mesuresCorrigees=false&typeDonnees=CONS"; + + private static final String MEASURE_MAX_POWER_URL = PRM_INFO_BASE_URL + + "%s/prms/%s/donnees-energetiques?mesuresTypeCode=PMAX&mesuresCorrigees=false&typeDonnees=CONS"; + + private static final String LOAD_CURVE_CONSUMPTION_URL = PRM_INFO_BASE_URL + + "%s/prms/%s/donnees-energetiques?mesuresTypeCode=COURBE&mesuresCorrigees=false&typeDonnees=CONS&dateDebut=%s"; + + private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter API_DATE_FORMAT_YEAR_FIRST = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final String URL_ENEDIS_AUTHENTICATE = BASE_URL + "/authenticate?target=" + URL_COMPTE_PART; + + private static final Pattern REQ_PATTERN = Pattern.compile("ReqID%(.*?)%26"); + + private static final String BASE_MYELECT_URL = "https://www.myelectricaldata.fr/"; + private static final String TEMPO_URL = BASE_MYELECT_URL + "rte/tempo/%s/%s"; + + public EnedisWebBridgeHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + final @Reference ThingRegistry thingRegistry, ComponentContext componentContext, Gson gson) { + super(bridge, httpClientFactory, oAuthFactory, httpService, thingRegistry, componentContext, gson); + } + + @Override + public String getToken(LinkyHandler handler) throws LinkyException { + return ""; + } + + @Override + public double getDivider() { + return 1.00; + } + + @Override + public String getBaseUrl() { + return BASE_URL; + } + + @Override + public String getContactUrl() { + return USER_INFO_URL; + } + + @Override + public String getContractUrl() { + return PRM_INFO_URL; + } + + @Override + public String getIdentityUrl() { + return USER_INFO_URL; + } + + @Override + public String getAddressUrl() { + return ""; + } + + @Override + public String getDailyConsumptionUrl() { + return MEASURE_DAILY_CONSUMPTION_URL; + } + + @Override + public String getMaxPowerUrl() { + return MEASURE_MAX_POWER_URL; + } + + @Override + public String getLoadCurveUrl() { + return LOAD_CURVE_CONSUMPTION_URL; + } + + @Override + public String getTempoUrl() { + return TEMPO_URL; + } + + @Override + public DateTimeFormatter getApiDateFormat() { + return API_DATE_FORMAT; + } + + @Override + public DateTimeFormatter getApiDateFormatYearsFirst() { + return API_DATE_FORMAT_YEAR_FIRST; + } + + @Override + public synchronized void connectionInit() throws LinkyException { + LinkyConfiguration lcConfig = config; + if (lcConfig == null) { + return; + } + + logger.debug("Starting login process for user: {}", lcConfig.username); + + try { + enedisApi.addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, lcConfig.internalAuthId); + logger.debug("Step 1: getting authentification"); + String data = enedisApi.getContent(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(enedisApi.getFormContent("SAMLRequest", samlInput.attr("value"))).send(); + if (result.getStatus() != HttpStatus.FOUND_302) { + throw new LinkyException("Connection failed step 2"); + } + + logger.debug("Get the location and the ReqID"); + Matcher m = REQ_PATTERN.matcher(enedisApi.getLocation(result)); + if (!m.find()) { + throw new LinkyException("Unable to locate ReqId in header"); + } + + String reqId = m.group(1); + String authenticateUrl = 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%3D" + BASE_URL + + "/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(authenticateUrl).header("X-NoSession", "true").header("X-Password", "anonymous") + .header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous").send(); + if (result.getStatus() != HttpStatus.OK_200) { + throw new LinkyException("Connection failed step 3 - auth1: %s", result.getContentAsString()); + } + + AuthData authData = gson.fromJson(result.getContentAsString(), AuthData.class); + if (authData == null || authData.callbacks.size() < 2 || authData.callbacks.get(0).input.isEmpty() + || authData.callbacks.get(1).input.isEmpty() || !lcConfig.username + .equals(Objects.requireNonNull(authData.callbacks.get(0).input.get(0)).valueAsString())) { + logger.debug("auth1 - invalid template for auth data: {}", result.getContentAsString()); + throw new LinkyException("Authentication error, the authentication_cookie is probably wrong"); + } + + authData.callbacks.get(1).input.get(0).value = lcConfig.password; + logger.debug("Step 4: auth2 - send the auth data"); + result = httpClient.POST(authenticateUrl).header(HttpHeader.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .header("X-NoSession", "true").header("X-Password", "anonymous") + .header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous") + .content(new StringContentProvider(gson.toJson(authData))).send(); + if (result.getStatus() != HttpStatus.OK_200) { + throw new LinkyException("Connection failed step 3 - auth2 : %s", result.getContentAsString()); + } + + AuthResult authResult = gson.fromJson(result.getContentAsString(), AuthResult.class); + if (authResult == null) { + throw new LinkyException("Invalid authentication result data"); + } + + logger.debug("Add the tokenId cookie"); + enedisApi.addCookie("enedisExt", authResult.tokenId); + + logger.debug("Step 5: retrieve the SAMLresponse"); + data = enedisApi.getContent(URL_MON_COMPTE + "/" + authResult.successUrl); + htmlDocument = Jsoup.parse(data); + el = htmlDocument.select("form").first(); + samlInput = el.select("input[name=SAMLResponse]").first(); + + logger.debug("Step 6: post the SAMLresponse to finish the authentication"); + result = httpClient.POST(el.attr("action")) + .content(enedisApi.getFormContent("SAMLResponse", samlInput.attr("value"))).send(); + if (result.getStatus() != HttpStatus.FOUND_302) { + throw new LinkyException("Connection failed step 6"); + } + + logger.debug("Step 7: retrieve "); + result = httpClient.GET(USER_INFO_CONTRACT_URL); + + @SuppressWarnings("unchecked") + HashMap hashRes = gson.fromJson(result.getContentAsString(), HashMap.class); + + String cookieKey; + if (hashRes != null && hashRes.containsKey("cnAlex")) { + cookieKey = "personne_for_" + hashRes.get("cnAlex"); + } else { + throw new LinkyException("Connection failed step 7, missing cookieKey"); + } + + List lCookie = httpClient.getCookieStore().getCookies(); + Optional cookie = lCookie.stream().filter(it -> it.getName().contains(cookieKey)).findFirst(); + + String cookieVal = cookie.map(HttpCookie::getValue) + .orElseThrow(() -> new LinkyException("Connection failed step 7, missing cookieVal")); + + enedisApi.addCookie(cookieKey, cookieVal); + + connected = true; + } catch (InterruptedException | TimeoutException | ExecutionException | JsonSyntaxException e) { + throw new LinkyException(e, "Error opening connection with Enedis webservice"); + } + } + + @Override + public boolean supportNewApiFormat() { + return false; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyBridgeHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyBridgeHandler.java new file mode 100644 index 00000000000..a144a02a9a6 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyBridgeHandler.java @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2010-2025 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.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.openhab.binding.linky.internal.LinkyBindingConstants; +import org.openhab.binding.linky.internal.LinkyConfiguration; +import org.openhab.binding.linky.internal.LinkyException; +import org.openhab.binding.linky.internal.api.EnedisHttpApi; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.io.net.http.TrustAllTrustManager; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.types.Command; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * {@link LinkyBridgeHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + */ +@NonNullByDefault +public abstract class LinkyBridgeHandler extends BaseBridgeHandler { + private final Logger logger = LoggerFactory.getLogger(LinkyBridgeHandler.class); + + protected final HttpService httpService; + protected final BundleContext bundleContext; + protected final HttpClient httpClient; + protected final EnedisHttpApi enedisApi; + protected final ThingRegistry thingRegistry; + + protected final Gson gson; + + protected @Nullable LinkyConfiguration config; + protected boolean connected = false; + + private static final int REQUEST_BUFFER_SIZE = 8000; + private static final int RESPONSE_BUFFER_SIZE = 200000; + + private List registeredPrmId = new ArrayList<>(); + + public LinkyBridgeHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + final @Reference ThingRegistry thingRegistry, ComponentContext componentContext, Gson gson) { + super(bridge); + + SslContextFactory sslContextFactory = new SslContextFactory.Client(); + try { + SSLContext sslContext = SSLContext.getInstance("SSL"); + sslContext.init(null, new TrustManager[] { TrustAllTrustManager.getInstance() }, null); + sslContextFactory.setSslContext(sslContext); + } catch (NoSuchAlgorithmException e) { + logger.warn("An exception occurred while requesting the SSL encryption algorithm : '{}'", e.getMessage(), + e); + } catch (KeyManagementException e) { + logger.warn("An exception occurred while initialising the SSL context : '{}'", e.getMessage(), e); + } + + this.gson = gson; + this.httpService = httpService; + this.thingRegistry = thingRegistry; + this.bundleContext = componentContext.getBundleContext(); + + this.httpClient = httpClientFactory.createHttpClient(LinkyBindingConstants.BINDING_ID, sslContextFactory); + this.httpClient.setFollowRedirects(false); + this.httpClient.setRequestBufferSize(REQUEST_BUFFER_SIZE); + this.httpClient.setResponseBufferSize(RESPONSE_BUFFER_SIZE); + + try { + httpClient.start(); + } catch (Exception e) { + logger.warn("Unable to start Jetty HttpClient {}", e.getMessage()); + } + + this.enedisApi = new EnedisHttpApi(this, gson, this.httpClient); + + updateStatus(ThingStatus.UNKNOWN); + } + + public BundleContext getBundleContext() { + return bundleContext; + } + + @Override + public synchronized void initialize() { + logger.debug("Initializing Linky API bridge handler."); + + config = getConfigAs(LinkyConfiguration.class); + + updateStatus(ThingStatus.UNKNOWN); + + scheduler.submit(() -> { + try { + connectionInit(); + updateStatus(ThingStatus.ONLINE); + } catch (LinkyException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + }); + } + + public abstract void connectionInit() throws LinkyException; + + public void registerNewPrmId(String prmId) { + if (!registeredPrmId.contains(prmId)) { + registeredPrmId.add(prmId); + } + } + + public List getAllPrmId() { + return registeredPrmId; + } + + public boolean isConnected() { + return connected; + } + + public @Nullable EnedisHttpApi getEnedisApi() { + return enedisApi; + } + + @Override + public void dispose() { + logger.debug("Shutting down Linky API bridge handler."); + super.dispose(); + } + + @Override + protected void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) { + super.updateStatus(status, statusDetail, description); + } + + public abstract String getToken(LinkyHandler handler) throws LinkyException; + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + } + + public abstract double getDivider(); + + public abstract String getBaseUrl(); + + public abstract String getContactUrl(); + + public abstract String getContractUrl(); + + public abstract String getIdentityUrl(); + + public abstract String getAddressUrl(); + + public abstract String getDailyConsumptionUrl(); + + public abstract String getMaxPowerUrl(); + + public abstract String getLoadCurveUrl(); + + public abstract String getTempoUrl(); + + public abstract DateTimeFormatter getApiDateFormat(); + + public abstract DateTimeFormatter getApiDateFormatYearsFirst(); + + public abstract boolean supportNewApiFormat(); +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyHandler.java index 4ad4815373a..1342bb0f0d4 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyHandler.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyHandler.java @@ -14,35 +14,49 @@ package org.openhab.binding.linky.internal.handler; import static org.openhab.binding.linky.internal.LinkyBindingConstants.*; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.ZonedDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.time.temporal.WeekFields; import java.util.ArrayList; +import java.util.Date; import java.util.List; +import java.util.Locale; 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.eclipse.jetty.client.HttpClient; import org.openhab.binding.linky.internal.LinkyConfiguration; 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.Contact; +import org.openhab.binding.linky.internal.dto.Contract; +import org.openhab.binding.linky.internal.dto.Identity; +import org.openhab.binding.linky.internal.dto.IntervalReading; +import org.openhab.binding.linky.internal.dto.MeterReading; import org.openhab.binding.linky.internal.dto.PrmDetail; import org.openhab.binding.linky.internal.dto.PrmInfo; +import org.openhab.binding.linky.internal.dto.ResponseTempo; +import org.openhab.binding.linky.internal.dto.UsagePoint; import org.openhab.binding.linky.internal.dto.UserInfo; +import org.openhab.core.config.core.Configuration; import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; import org.openhab.core.library.unit.MetricPrefix; import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; @@ -50,39 +64,47 @@ import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.binding.BaseThingHandler; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.openhab.core.types.TimeSeries; +import org.openhab.core.types.TimeSeries.Policy; import org.openhab.core.types.UnDefType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import com.google.gson.Gson; +import org.threeten.extra.Months; +import org.threeten.extra.Weeks; +import org.threeten.extra.Years; /** * The {@link LinkyHandler} is responsible for handling commands, which are * sent to one of the channels. * * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API */ @NonNullByDefault public class LinkyHandler extends BaseThingHandler { + private final TimeZoneProvider timeZoneProvider; + private ZoneId zoneId = ZoneId.systemDefault(); + private static final int REFRESH_FIRST_HOUR_OF_DAY = 1; private static final int REFRESH_INTERVAL_IN_MIN = 120; private final Logger logger = LoggerFactory.getLogger(LinkyHandler.class); - private final HttpClient httpClient; - private final Gson gson; - private final WeekFields weekFields; - private final ExpiringDayCache cachedDailyData; - private final ExpiringDayCache cachedPowerData; - private final ExpiringDayCache cachedMonthlyData; - private final ExpiringDayCache cachedYearlyData; + private final ExpiringDayCache dailyConsumption; + private final ExpiringDayCache dailyConsumptionMaxPower; + private final ExpiringDayCache loadCurveConsumption; + private final ExpiringDayCache tempoInformation; private @Nullable ScheduledFuture refreshJob; + private LinkyConfiguration config; private @Nullable EnedisHttpApi enedisApi; + private double divider = 1.00; - private @NonNullByDefault({}) String prmId; - private @NonNullByDefault({}) String userId; + public String userId = ""; + + private @Nullable ScheduledFuture pollingJob = null; private enum Target { FIRST, @@ -90,216 +112,454 @@ public class LinkyHandler extends BaseThingHandler { ALL } - public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) { + public LinkyHandler(Thing thing, LocaleProvider localeProvider, TimeZoneProvider timeZoneProvider) { super(thing); - this.gson = gson; - this.httpClient = httpClient; - this.weekFields = WeekFields.of(localeProvider.getLocale()); - this.cachedDailyData = new ExpiringDayCache<>("daily cache", REFRESH_FIRST_HOUR_OF_DAY, () -> { + config = getConfigAs(LinkyConfiguration.class); + this.timeZoneProvider = timeZoneProvider; + + this.dailyConsumption = new ExpiringDayCache<>("dailyConsumption", REFRESH_FIRST_HOUR_OF_DAY, () -> { LocalDate today = LocalDate.now(); - Consumption consumption = getConsumptionData(today.minusDays(15), today); - if (consumption != null) { - logData(consumption.aggregats.days, "Day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.ALL); - logData(consumption.aggregats.weeks, "Week", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL); - consumption = getConsumptionAfterChecks(consumption, Target.LAST); + MeterReading meterReading = getConsumptionData(today.minusDays(1095), today); + meterReading = getMeterReadingAfterChecks(meterReading); + if (meterReading != null) { + logData(meterReading.baseValue, "Day", DateTimeFormatter.ISO_LOCAL_DATE, Target.ALL); + logData(meterReading.weekValue, "Week", DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL); } - return consumption; + return meterReading; }); - this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_FIRST_HOUR_OF_DAY, () -> { - // We request data for yesterday and the day before yesterday, even if the data for the day before yesterday - // is not needed by the binding. This is only a workaround to an API bug that will return - // INTERNAL_SERVER_ERROR rather than the expected data with a NaN value when the data for yesterday is not - // yet available. - // By requesting two days, the API is not failing and you get the expected NaN value for yesterday when the - // data is not yet available. + // We request data for yesterday and the day before yesterday, even if the data for the day before yesterday + // is not needed by the binding. This is only a workaround to an API bug that will return + // INTERNAL_SERVER_ERROR rather than the expected data with a NaN value when the data for yesterday is not yet + // available. + // By requesting two days, the API is not failing and you get the expected NaN value for yesterday when the data + // is not yet available. + this.dailyConsumptionMaxPower = new ExpiringDayCache<>("dailyConsumptionMaxPower", REFRESH_FIRST_HOUR_OF_DAY, + () -> { + LocalDate today = LocalDate.now(); + MeterReading meterReading = getPowerData(today.minusDays(1095), today); + meterReading = getMeterReadingAfterChecks(meterReading); + if (meterReading != null) { + logData(meterReading.baseValue, "Day (peak)", DateTimeFormatter.ISO_LOCAL_DATE, Target.ALL); + } + return meterReading; + }); + + // Read Tempo Information + this.tempoInformation = new ExpiringDayCache<>("tempoInformation", REFRESH_FIRST_HOUR_OF_DAY, () -> { LocalDate today = LocalDate.now(); - Consumption consumption = getPowerData(today.minusDays(2), today); - if (consumption != null) { - logData(consumption.aggregats.days, "Day (peak)", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, - Target.ALL); - consumption = getConsumptionAfterChecks(consumption, Target.LAST); - } - return consumption; + + ResponseTempo tempoData = getTempoData(today.minusDays(1095), today.plusDays(1)); + return tempoData; }); - this.cachedMonthlyData = new ExpiringDayCache<>("monthly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> { + // Comsuption Load Curve + this.loadCurveConsumption = new ExpiringDayCache<>("loadCurveConsumption", REFRESH_FIRST_HOUR_OF_DAY, () -> { LocalDate today = LocalDate.now(); - Consumption consumption = getConsumptionData(today.withDayOfMonth(1).minusMonths(1), today); - if (consumption != null) { - logData(consumption.aggregats.months, "Month", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL); - consumption = getConsumptionAfterChecks(consumption, Target.LAST); + MeterReading meterReading = getLoadCurveConsumption(today.minusDays(6), today); + meterReading = getMeterReadingAfterChecks(meterReading); + if (meterReading != null) { + logData(meterReading.baseValue, "Day (peak)", DateTimeFormatter.ISO_LOCAL_DATE, Target.ALL); } - return consumption; - }); - - this.cachedYearlyData = new ExpiringDayCache<>("yearly cache", REFRESH_FIRST_HOUR_OF_DAY, () -> { - LocalDate today = LocalDate.now(); - Consumption consumption = getConsumptionData(LocalDate.of(today.getYear() - 1, 1, 1), today); - if (consumption != null) { - logData(consumption.aggregats.years, "Year", true, DateTimeFormatter.ISO_LOCAL_DATE_TIME, Target.ALL); - consumption = getConsumptionAfterChecks(consumption, Target.LAST); - } - return consumption; + return meterReading; }); } @Override - public void initialize() { + public synchronized void initialize() { logger.debug("Initializing Linky handler."); + + // update the timezone if not set to default to openhab default timezone + Configuration thingConfig = getConfig(); + + Object val = thingConfig.get("timezone"); + if (val == null || "".equals(val)) { + zoneId = this.timeZoneProvider.getTimeZone(); + thingConfig.put("timezone", zoneId.getId()); + } else { + zoneId = ZoneId.of((String) val); + } + + saveConfiguration(thingConfig); + + // reread config to update timezone field + config = getConfigAs(LinkyConfiguration.class); + + Bridge bridge = getBridge(); + if (bridge == null) { + return; + } + + LinkyBridgeHandler bridgeHandler = (LinkyBridgeHandler) bridge.getHandler(); + if (bridgeHandler == null) { + return; + } + enedisApi = bridgeHandler.getEnedisApi(); + divider = bridgeHandler.getDivider(); + updateStatus(ThingStatus.UNKNOWN); - LinkyConfiguration config = getConfigAs(LinkyConfiguration.class); if (config.seemsValid()) { - enedisApi = new EnedisHttpApi(config, gson, httpClient); - scheduler.submit(() -> { - try { - EnedisHttpApi api = this.enedisApi; - api.initialize(); - updateStatus(ThingStatus.ONLINE); - - if (thing.getProperties().isEmpty()) { - UserInfo userInfo = api.getUserInfo(); - PrmInfo prmInfo = api.getPrmInfo(userInfo.userProperties.internId); - PrmDetail details = api.getPrmDetails(userInfo.userProperties.internId, prmInfo.idPrm); - updateProperties(Map.of(USER_ID, userInfo.userProperties.internId, PUISSANCE, - details.situationContractuelleDtos[0].structureTarifaire().puissanceSouscrite().valeur() - + " kVA", - PRM_ID, prmInfo.idPrm)); - } - - prmId = thing.getProperties().get(PRM_ID); - userId = thing.getProperties().get(USER_ID); - - updateData(); - - disconnect(); - - 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); - } catch (LinkyException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - } - }); + bridgeHandler.registerNewPrmId(config.prmId); + pollingJob = scheduler.scheduleWithFixedDelay(this::pollingCode, 0, 5, TimeUnit.SECONDS); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/offline.config-error-mandatory-settings"); } } + @Override + protected void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) { + super.updateStatus(status, statusDetail, description); + } + + public boolean supportNewApiFormat() throws LinkyException { + Bridge bridge = getBridge(); + if (bridge == null) { + throw new LinkyException("Unable to get bridge in supportNewApiFormat()"); + } + + LinkyBridgeHandler bridgeHandler = (LinkyBridgeHandler) bridge.getHandler(); + if (bridgeHandler == null) { + throw new LinkyException("Unable to get bridgeHandler in supportNewApiFormat()"); + } + + return bridgeHandler.supportNewApiFormat(); + } + + private void pollingCode() { + try { + EnedisHttpApi api = this.enedisApi; + + if (api != null) { + Bridge lcBridge = getBridge(); + ScheduledFuture lcPollingJob = pollingJob; + + if (lcBridge == null || lcBridge.getStatus() != ThingStatus.ONLINE) { + return; + } + + LinkyBridgeHandler bridgeHandler = (LinkyBridgeHandler) lcBridge.getHandler(); + if (bridgeHandler == null) { + return; + } + + if (!bridgeHandler.isConnected()) { + bridgeHandler.connectionInit(); + } + + if (supportNewApiFormat()) { + Identity identity = api.getIdentity(this, config.prmId); + Contact contact = api.getContact(this, config.prmId); + Contract contract = api.getContract(this, config.prmId); + UsagePoint usagePoint = api.getUsagePoint(this, config.prmId); + + updateMetaData(identity, contact, contract, usagePoint); + + updateProperties( + Map.of(USER_ID, "", PUISSANCE, contract.subscribedPower, PRM_ID, usagePoint.usagePointId)); + } else { + UserInfo userInfo = api.getUserInfo(this); + PrmInfo prmInfo = api.getPrmInfo(this, userInfo.userProperties.internId, config.prmId); + PrmDetail details = api.getPrmDetails(this, userInfo.userProperties.internId, prmInfo.idPrm); + + Identity identity = Identity.convertFromUserInfo(userInfo); + Contact contact = Contact.convertFromUserInfo(userInfo); + Contract contract = Contract.convertFromPrmDetail(details); + UsagePoint usagePoint = UsagePoint.convertFromPrmDetail(prmInfo, details); + + this.userId = userInfo.userProperties.internId; + + updateMetaData(identity, contact, contract, usagePoint); + + updateProperties(Map.of(USER_ID, userInfo.userProperties.internId, PUISSANCE, + details.situationContractuelleDtos[0].structureTarifaire().puissanceSouscrite().valeur() + + " kVA", + PRM_ID, prmInfo.idPrm)); + } + + updateData(); + + final LocalDateTime now = LocalDateTime.now(); + final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_FIRST_HOUR_OF_DAY) + .truncatedTo(ChronoUnit.HOURS); + + if (this.getThing().getStatusInfo().getStatusDetail() != ThingStatusDetail.COMMUNICATION_ERROR) { + updateStatus(ThingStatus.ONLINE); + } + + if (lcPollingJob != null) { + lcPollingJob.cancel(false); + } + + 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()); + } + } + + public @Nullable LinkyConfiguration getLinkyConfig() { + return config; + } + + private synchronized void updateMetaData(Identity identity, Contact contact, Contract contract, + UsagePoint usagePoint) { + String title = identity.title; + String firstName = identity.firstname; + String lastName = identity.lastname; + + updateState(MAIN_GROUP, MAIN_IDENTITY, new StringType(title + " " + firstName + " " + lastName)); + + updateState(MAIN_GROUP, MAIN_CONTRACT_SEGMENT, new StringType(contract.segment)); + updateState(MAIN_GROUP, MAIN_CONTRACT_CONTRACT_STATUS, new StringType(contract.contractStatus)); + updateState(MAIN_GROUP, MAIN_CONTRACT_CONTRACT_TYPE, new StringType(contract.contractType)); + updateState(MAIN_GROUP, MAIN_CONTRACT_DISTRIBUTION_TARIFF, new StringType(contract.distributionTariff)); + updateState(MAIN_GROUP, MAIN_CONTRACT_LAST_ACTIVATION_DATE, new StringType(contract.lastActivationDate)); + updateState(MAIN_GROUP, MAIN_CONTRACT_LAST_DISTRIBUTION_TARIFF_CHANGE_DATE, + new StringType(contract.lastDistributionTariffChangeDate)); + updateState(MAIN_GROUP, MAIN_CONTRACT_OFF_PEAK_HOURS, new StringType(contract.offpeakHours)); + updateState(MAIN_GROUP, MAIN_CONTRACT_SEGMENT, new StringType(contract.segment)); + updateState(MAIN_GROUP, MAIN_CONTRACT_SUBSCRIBED_POWER, new StringType(contract.subscribedPower)); + + updateState(MAIN_GROUP, MAIN_USAGEPOINT_ID, new StringType(usagePoint.usagePointId)); + updateState(MAIN_GROUP, MAIN_USAGEPOINT_STATUS, new StringType(usagePoint.usagePointStatus)); + updateState(MAIN_GROUP, MAIN_USAGEPOINT_METER_TYPE, new StringType(usagePoint.meterType)); + + updateState(MAIN_GROUP, MAIN_USAGEPOINT_METER_ADDRESS_CITY, + new StringType(usagePoint.usagePointAddresses.city)); + updateState(MAIN_GROUP, MAIN_USAGEPOINT_METER_ADDRESS_COUNTRY, + new StringType(usagePoint.usagePointAddresses.country)); + updateState(MAIN_GROUP, MAIN_USAGEPOINT_METER_ADDRESS_POSTAL_CODE, + new StringType(usagePoint.usagePointAddresses.postalCode)); + updateState(MAIN_GROUP, MAIN_USAGEPOINT_METER_ADDRESS_INSEE_CODE, + new StringType(usagePoint.usagePointAddresses.inseeCode)); + updateState(MAIN_GROUP, MAIN_USAGEPOINT_METER_ADDRESS_STREET, + new StringType(usagePoint.usagePointAddresses.street)); + + updateState(MAIN_GROUP, MAIN_CONTACT_MAIL, new StringType(contact.email)); + updateState(MAIN_GROUP, MAIN_CONTACT_PHONE, new StringType(contact.phone)); + } + /** * Request new data and updates channels */ private synchronized void updateData() { - boolean connectedBefore = isConnected(); + updateEnergyData(); updatePowerData(); - updateDailyWeeklyData(); - updateMonthlyData(); - updateYearlyData(); - if (!connectedBefore && isConnected()) { - disconnect(); + updateTempoTimeSeries(); + updateLoadCurveData(); + } + + private synchronized void updateTempoTimeSeries() { + tempoInformation.getValue().ifPresentOrElse(values -> { + TimeSeries timeSeries = new TimeSeries(Policy.REPLACE); + + values.forEach((k, v) -> { + try { + SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd"); + Date date = df.parse(k); + long epoch = date.getTime(); + Instant timestamp = Instant.ofEpochMilli(epoch); + + timeSeries.add(timestamp, new DecimalType(getTempoIdx(v))); + } catch (ParseException ex) { + } + }); + + int size = values.size(); + Object[] tempoValues = values.values().toArray(); + + updateTempoChannel(TEMPO_GROUP, TEMPO_TODAY_INFO, getTempoIdx((String) tempoValues[size - 2])); + updateTempoChannel(TEMPO_GROUP, TEMPO_TOMORROW_INFO, getTempoIdx((String) tempoValues[size - 1])); + + sendTimeSeries(TEMPO_GROUP, TEMPO_TEMPO_INFO_TIME_SERIES, timeSeries); + updateState(TEMPO_GROUP, TEMPO_TEMPO_INFO_TIME_SERIES, + new DecimalType(getTempoIdx((String) tempoValues[size - 2]))); + }, () -> { + updateTempoChannel(TEMPO_GROUP, TEMPO_TODAY_INFO, -1); + updateTempoChannel(TEMPO_GROUP, TEMPO_TOMORROW_INFO, -1); + }); + } + + private int getTempoIdx(String color) { + int val = 0; + if ("BLUE".equals(color)) { + val = 0; } + if ("WHITE".equals(color)) { + val = 1; + } + if ("RED".equals(color)) { + val = 2; + } + + return val; } private synchronized void updatePowerData() { - if (isLinked(PEAK_POWER) || isLinked(PEAK_TIMESTAMP)) { - cachedPowerData.getValue().ifPresentOrElse(values -> { - Aggregate days = values.aggregats.days; - updatekVAChannel(PEAK_POWER, days.datas.get(days.datas.size() - 1)); - updateState(PEAK_TIMESTAMP, new DateTimeType(days.periodes.get(days.datas.size() - 1).dateDebut)); - }, () -> { - updateKwhChannel(PEAK_POWER, Double.NaN); - updateState(PEAK_TIMESTAMP, UnDefType.UNDEF); - }); - } + dailyConsumptionMaxPower.getValue().ifPresentOrElse(values -> { + int dSize = values.baseValue.length; + + updatekVAChannel(DAILY_GROUP, PEAK_POWER_DAY_MINUS_1, values.baseValue[dSize - 1].value); + updateState(DAILY_GROUP, PEAK_POWER_TS_DAY_MINUS_1, + new DateTimeType(values.baseValue[dSize - 1].date.atZone(zoneId))); + + updatekVAChannel(DAILY_GROUP, PEAK_POWER_DAY_MINUS_2, values.baseValue[dSize - 2].value); + updateState(DAILY_GROUP, PEAK_POWER_TS_DAY_MINUS_2, + new DateTimeType(values.baseValue[dSize - 2].date.atZone(zoneId))); + + updatekVAChannel(DAILY_GROUP, PEAK_POWER_DAY_MINUS_3, values.baseValue[dSize - 3].value); + updateState(DAILY_GROUP, PEAK_POWER_TS_DAY_MINUS_3, + new DateTimeType(values.baseValue[dSize - 3].date.atZone(zoneId))); + + updatePowerTimeSeries(DAILY_GROUP, MAX_POWER_CHANNEL, values.baseValue); + updatePowerTimeSeries(WEEKLY_GROUP, MAX_POWER_CHANNEL, values.weekValue); + updatePowerTimeSeries(MONTHLY_GROUP, MAX_POWER_CHANNEL, values.monthValue); + updatePowerTimeSeries(YEARLY_GROUP, MAX_POWER_CHANNEL, values.yearValue); + + }, () -> { + updateKwhChannel(DAILY_GROUP, PEAK_POWER_DAY_MINUS_1, Double.NaN); + updateState(DAILY_GROUP, PEAK_POWER_TS_DAY_MINUS_1, UnDefType.UNDEF); + + updateKwhChannel(DAILY_GROUP, PEAK_POWER_DAY_MINUS_2, Double.NaN); + updateState(DAILY_GROUP, PEAK_POWER_TS_DAY_MINUS_2, UnDefType.UNDEF); + + updateKwhChannel(DAILY_GROUP, PEAK_POWER_DAY_MINUS_3, Double.NaN); + updateState(DAILY_GROUP, PEAK_POWER_TS_DAY_MINUS_3, UnDefType.UNDEF); + }); } - private void setCurrentAndPrevious(Aggregate periods, String currentChannel, String previousChannel) { - double currentValue = 0.0; - double previousValue = 0.0; - if (!periods.datas.isEmpty()) { - currentValue = periods.datas.get(periods.datas.size() - 1); - if (periods.datas.size() > 1) { - previousValue = periods.datas.get(periods.datas.size() - 2); + /** + * Request new daily/weekly data and updates channels + */ + private synchronized void updateEnergyData() { + dailyConsumption.getValue().ifPresentOrElse(values -> { + int dSize = values.baseValue.length; + + updateKwhChannel(DAILY_GROUP, DAY_MINUS_1, values.baseValue[dSize - 1].value); + updateKwhChannel(DAILY_GROUP, DAY_MINUS_2, values.baseValue[dSize - 2].value); + updateKwhChannel(DAILY_GROUP, DAY_MINUS_3, values.baseValue[dSize - 3].value); + + int idxCurrentYear = values.yearValue.length - 1; + int idxCurrentWeek = values.weekValue.length - 1; + int idxCurrentMonth = values.monthValue.length - 1; + + updateKwhChannel(WEEKLY_GROUP, WEEK_MINUS_0, values.weekValue[idxCurrentWeek].value); + updateKwhChannel(WEEKLY_GROUP, WEEK_MINUS_1, values.weekValue[idxCurrentWeek - 1].value); + updateKwhChannel(WEEKLY_GROUP, WEEK_MINUS_2, values.weekValue[idxCurrentWeek - 2].value); + + updateKwhChannel(MONTHLY_GROUP, MONTH_MINUS_0, values.monthValue[idxCurrentMonth].value); + updateKwhChannel(MONTHLY_GROUP, MONTH_MINUS_1, values.monthValue[idxCurrentMonth - 1].value); + updateKwhChannel(MONTHLY_GROUP, MONTH_MINUS_2, values.monthValue[idxCurrentMonth - 2].value); + + updateKwhChannel(YEARLY_GROUP, YEAR_MINUS_0, values.yearValue[idxCurrentYear].value); + updateKwhChannel(YEARLY_GROUP, YEAR_MINUS_1, values.yearValue[idxCurrentYear - 1].value); + updateKwhChannel(YEARLY_GROUP, YEAR_MINUS_2, values.yearValue[idxCurrentYear - 2].value); + + updateConsumptionTimeSeries(DAILY_GROUP, CONSUMPTION_CHANNEL, values.baseValue); + updateConsumptionTimeSeries(WEEKLY_GROUP, CONSUMPTION_CHANNEL, values.weekValue); + updateConsumptionTimeSeries(MONTHLY_GROUP, CONSUMPTION_CHANNEL, values.monthValue); + updateConsumptionTimeSeries(YEARLY_GROUP, CONSUMPTION_CHANNEL, values.yearValue); + }, () -> { + updateKwhChannel(DAILY_GROUP, DAY_MINUS_1, Double.NaN); + updateKwhChannel(DAILY_GROUP, DAY_MINUS_2, Double.NaN); + updateKwhChannel(DAILY_GROUP, DAY_MINUS_3, Double.NaN); + + updateKwhChannel(WEEKLY_GROUP, WEEK_MINUS_0, Double.NaN); + updateKwhChannel(WEEKLY_GROUP, WEEK_MINUS_1, Double.NaN); + updateKwhChannel(WEEKLY_GROUP, WEEK_MINUS_2, Double.NaN); + + updateKwhChannel(MONTHLY_GROUP, MONTH_MINUS_0, Double.NaN); + updateKwhChannel(MONTHLY_GROUP, MONTH_MINUS_1, Double.NaN); + updateKwhChannel(MONTHLY_GROUP, MONTH_MINUS_2, Double.NaN); + + updateKwhChannel(YEARLY_GROUP, YEAR_MINUS_0, Double.NaN); + updateKwhChannel(YEARLY_GROUP, YEAR_MINUS_1, Double.NaN); + updateKwhChannel(YEARLY_GROUP, YEAR_MINUS_2, Double.NaN); + }); + } + + /** + * Request new loadCurve data and updates channels + */ + private synchronized void updateLoadCurveData() { + loadCurveConsumption.getValue().ifPresentOrElse(values -> { + updatePowerTimeSeries(LOAD_CURVE_GROUP, POWER_CHANNEL, values.baseValue); + }, () -> { + }); + } + + private synchronized void updatePowerTimeSeries(String groupId, String channelId, IntervalReading[] iv) { + TimeSeries timeSeries = new TimeSeries(Policy.REPLACE); + + for (int i = 0; i < iv.length; i++) { + try { + if (iv[i].date == null) { + continue; + } + + Instant timestamp = iv[i].date.atZone(zoneId).toInstant(); + + if (Double.isNaN(iv[i].value)) { + continue; + } + timeSeries.add(timestamp, new DecimalType(iv[i].value)); + } catch (Exception ex) { + logger.debug("aa"); } } - updateKwhChannel(currentChannel, currentValue); - updateKwhChannel(previousChannel, previousValue); + + sendTimeSeries(groupId, channelId, timeSeries); } - /** - * Request new dayly/weekly data and updates channels - */ - private synchronized void updateDailyWeeklyData() { - if (isLinked(YESTERDAY) || isLinked(LAST_WEEK) || isLinked(THIS_WEEK)) { - cachedDailyData.getValue().ifPresentOrElse(values -> { - Aggregate days = values.aggregats.days; - updateKwhChannel(YESTERDAY, days.datas.get(days.datas.size() - 1)); - setCurrentAndPrevious(values.aggregats.weeks, THIS_WEEK, LAST_WEEK); - }, () -> { - updateKwhChannel(YESTERDAY, Double.NaN); - if (ZonedDateTime.now().get(weekFields.dayOfWeek()) == 1) { - updateKwhChannel(THIS_WEEK, 0.0); - updateKwhChannel(LAST_WEEK, Double.NaN); - } else { - updateKwhChannel(THIS_WEEK, Double.NaN); - } - }); + private synchronized void updateConsumptionTimeSeries(String groupId, String channelId, IntervalReading[] iv) { + TimeSeries timeSeries = new TimeSeries(Policy.REPLACE); + + for (int i = 0; i < iv.length; i++) { + if (iv[i].date == null) { + continue; + } + + Instant timestamp = iv[i].date.atZone(zoneId).toInstant(); + + if (Double.isNaN(iv[i].value)) { + continue; + } + timeSeries.add(timestamp, new DecimalType(iv[i].value)); } + + sendTimeSeries(groupId, channelId, timeSeries); } - /** - * Request new monthly data and updates channels - */ - private synchronized void updateMonthlyData() { - if (isLinked(LAST_MONTH) || isLinked(THIS_MONTH)) { - cachedMonthlyData.getValue().ifPresentOrElse( - values -> setCurrentAndPrevious(values.aggregats.months, THIS_MONTH, LAST_MONTH), () -> { - if (ZonedDateTime.now().getDayOfMonth() == 1) { - updateKwhChannel(THIS_MONTH, 0.0); - updateKwhChannel(LAST_MONTH, Double.NaN); - } else { - updateKwhChannel(THIS_MONTH, Double.NaN); - } - }); - } - } - - /** - * Request new yearly data and updates channels - */ - private synchronized void updateYearlyData() { - if (isLinked(LAST_YEAR) || isLinked(THIS_YEAR)) { - cachedYearlyData.getValue().ifPresentOrElse( - values -> setCurrentAndPrevious(values.aggregats.years, THIS_YEAR, LAST_YEAR), () -> { - if (ZonedDateTime.now().getDayOfYear() == 1) { - updateKwhChannel(THIS_YEAR, 0.0); - updateKwhChannel(LAST_YEAR, Double.NaN); - } else { - updateKwhChannel(THIS_YEAR, Double.NaN); - } - }); - } - } - - private void updateKwhChannel(String channelId, double consumption) { + private void updateKwhChannel(String groupId, String channelId, double consumption) { logger.debug("Update channel {} with {}", channelId, consumption); - updateState(channelId, + updateState(groupId, channelId, Double.isNaN(consumption) ? UnDefType.UNDEF : new QuantityType<>(consumption, Units.KILOWATT_HOUR)); } - private void updatekVAChannel(String channelId, double power) { + private void updatekVAChannel(String groupId, String channelId, double power) { logger.debug("Update channel {} with {}", channelId, power); - updateState(channelId, Double.isNaN(power) ? UnDefType.UNDEF + updateState(groupId, channelId, Double.isNaN(power) ? UnDefType.UNDEF : new QuantityType<>(power, MetricPrefix.KILO(Units.VOLT_AMPERE))); } + private void updateTempoChannel(String groupId, String channelId, int tempoValue) { + logger.debug("Update channel {} with {}", channelId, tempoValue); + updateState(groupId + "#" + channelId, new DecimalType(tempoValue)); + } + + protected void updateState(String groupId, String channelID, State state) { + super.updateState(groupId + "#" + channelID, state); + } + + protected void sendTimeSeries(String groupId, String channelID, TimeSeries timeSeries) { + super.sendTimeSeries(groupId + "#" + channelID, timeSeries); + } + /** * Produce a report of all daily values between two dates * @@ -309,9 +569,9 @@ public class LinkyHandler extends BaseThingHandler { * * @return the report as a list of string */ + public synchronized List reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) { List report = buildReport(startDay, endDay, separator); - disconnect(); return report; } @@ -319,19 +579,20 @@ public class LinkyHandler extends BaseThingHandler { List report = new ArrayList<>(); if (startDay.getYear() == endDay.getYear() && startDay.getMonthValue() == endDay.getMonthValue()) { // All values in the same month - Consumption result = getConsumptionData(startDay, endDay.plusDays(1)); - if (result != null) { - Aggregate days = result.aggregats.days; - int size = (days.datas == null || days.periodes == null) ? 0 - : (days.datas.size() <= days.periodes.size() ? days.datas.size() : days.periodes.size()); + MeterReading meterReading = getConsumptionData(startDay, endDay.plusDays(1)); + if (meterReading != null) { + IntervalReading[] days = meterReading.baseValue; + + int size = days.length; + for (int i = 0; i < size; i++) { - double consumption = days.datas.get(i); - LocalDate day = days.periodes.get(i).dateDebut.toLocalDate(); + double consumption = days[i].value; + LocalDate day = days[i].date.toLocalDate(); // Filter data in case it contains data from dates outside the requested period if (day.isBefore(startDay) || day.isAfter(endDay)) { continue; } - String line = days.periodes.get(i).dateDebut.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator; + String line = days[i].date.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator; if (consumption >= 0) { line += String.valueOf(consumption); } @@ -359,54 +620,85 @@ public class LinkyHandler extends BaseThingHandler { return report; } - private @Nullable Consumption getConsumptionData(LocalDate from, LocalDate to) { + private @Nullable MeterReading getConsumptionData(LocalDate from, LocalDate to) { logger.debug("getConsumptionData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE), to.format(DateTimeFormatter.ISO_LOCAL_DATE)); + EnedisHttpApi api = this.enedisApi; if (api != null) { try { - Consumption consumption = api.getEnergyData(userId, prmId, from, to); - updateStatus(ThingStatus.ONLINE); - return consumption; + MeterReading meterReading = api.getEnergyData(this, this.userId, config.prmId, from, to); + return meterReading; } catch (LinkyException e) { logger.debug("Exception when getting consumption data: {}", e.getMessage(), e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage()); } } + return null; } - private @Nullable Consumption getPowerData(LocalDate from, LocalDate to) { - logger.debug("getPowerData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE), + private @Nullable MeterReading getLoadCurveConsumption(LocalDate from, LocalDate to) { + logger.debug("getLoadCurveConsumption from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE), to.format(DateTimeFormatter.ISO_LOCAL_DATE)); + EnedisHttpApi api = this.enedisApi; if (api != null) { try { - Consumption consumption = api.getPowerData(userId, prmId, from, to); - updateStatus(ThingStatus.ONLINE); - return consumption; + MeterReading meterReading = api.getLoadCurveData(this, this.userId, config.prmId, from, to); + return meterReading; + } catch (LinkyException e) { + logger.debug("Exception when getting consumption data: {}", e.getMessage(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage()); + } + } + + return null; + } + + private @Nullable MeterReading getPowerData(LocalDate from, LocalDate to) { + logger.debug("getPowerData from {} to {}", from.format(DateTimeFormatter.ISO_LOCAL_DATE), + to.format(DateTimeFormatter.ISO_LOCAL_DATE)); + + EnedisHttpApi api = this.enedisApi; + if (api != null) { + try { + MeterReading meterReading = api.getPowerData(this, this.userId, config.prmId, from, to); + return meterReading; } catch (LinkyException e) { logger.debug("Exception when getting power data: {}", e.getMessage(), e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage()); } } + + return null; + } + + private @Nullable ResponseTempo getTempoData(LocalDate from, LocalDate to) { + logger.debug("getTempoData from"); + + EnedisHttpApi api = this.enedisApi; + if (api != null) { + try { + ResponseTempo result = api.getTempoData(this, from, to); + return result; + } catch (LinkyException e) { + logger.debug("Exception when getting tempo data: {}", e.getMessage(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage()); + } + } return null; } private boolean isConnected() { - EnedisHttpApi api = this.enedisApi; - return api == null ? false : api.isConnected(); - } - - private void disconnect() { - EnedisHttpApi api = this.enedisApi; - if (api != null) { - try { - api.dispose(); - } catch (LinkyException e) { - logger.debug("disconnect: {}", e.getMessage()); + Bridge bridge = getBridge(); + if (bridge != null) { + LinkyBridgeHandler bridgeHandler = (LinkyBridgeHandler) bridge.getHandler(); + if (bridgeHandler != null) { + return bridgeHandler.isConnected(); } } + return false; } @Override @@ -417,7 +709,12 @@ public class LinkyHandler extends BaseThingHandler { job.cancel(true); refreshJob = null; } - disconnect(); + + ScheduledFuture lcPollingJob = pollingJob; + if (lcPollingJob != null) { + lcPollingJob.cancel(true); + pollingJob = null; + } enedisApi = null; } @@ -425,123 +722,138 @@ public class LinkyHandler extends BaseThingHandler { public synchronized void handleCommand(ChannelUID channelUID, Command command) { if (command instanceof RefreshType) { logger.debug("Refreshing channel {}", channelUID.getId()); - boolean connectedBefore = isConnected(); - switch (channelUID.getId()) { - case YESTERDAY: - case LAST_WEEK: - case THIS_WEEK: - updateDailyWeeklyData(); - break; - case LAST_MONTH: - case THIS_MONTH: - updateMonthlyData(); - break; - case LAST_YEAR: - case THIS_YEAR: - updateYearlyData(); - break; - case PEAK_POWER: - case PEAK_TIMESTAMP: - updatePowerData(); - break; - default: - break; - } - if (!connectedBefore && isConnected()) { - disconnect(); - } + updateData(); } else { logger.debug("The Linky binding is read-only and can not handle command {}", command); } } - private @Nullable Consumption getConsumptionAfterChecks(Consumption consumption, Target target) { + private @Nullable MeterReading getMeterReadingAfterChecks(@Nullable MeterReading meterReading) { try { - checkData(consumption); + checkData(meterReading); } catch (LinkyException e) { logger.debug("Consumption data: {}", e.getMessage()); return null; } - if (target == Target.FIRST && !isDataFirstDayAvailable(consumption)) { - logger.debug("Data including yesterday are not yet available"); - return null; + + if (meterReading != null) { + if (meterReading.weekValue == null) { + LocalDate startDate = meterReading.baseValue[0].date.toLocalDate(); + LocalDate endDate = meterReading.baseValue[meterReading.baseValue.length - 1].date.toLocalDate(); + + int weeksNum = Weeks.between(startDate, endDate).getAmount() + 2; + int monthsNum = Months.between(startDate, endDate).getAmount() + 2; + int yearsNum = Years.between(startDate, endDate).getAmount() + 2; + + meterReading.weekValue = new IntervalReading[weeksNum]; + meterReading.monthValue = new IntervalReading[monthsNum]; + meterReading.yearValue = new IntervalReading[yearsNum]; + + for (int idx = 0; idx < weeksNum; idx++) { + meterReading.weekValue[idx] = new IntervalReading(); + } + for (int idx = 0; idx < monthsNum; idx++) { + meterReading.monthValue[idx] = new IntervalReading(); + } + for (int idx = 0; idx < yearsNum; idx++) { + meterReading.yearValue[idx] = new IntervalReading(); + } + + int size = meterReading.baseValue.length; + int baseYear = meterReading.baseValue[0].date.getYear(); + int baseMonth = meterReading.baseValue[0].date.getMonthValue(); + int baseWeek = meterReading.baseValue[0].date.get(WeekFields.of(Locale.FRANCE).weekOfYear()); + + for (int idx = 0; idx < size; idx++) { + IntervalReading ir = meterReading.baseValue[idx]; + LocalDateTime dt = ir.date; + double value = ir.value; + value = value / divider; + ir.value = value; + + int idxYear = dt.getYear() - baseYear; + int month = dt.getMonthValue(); + int weekOfYear = dt.get(WeekFields.of(Locale.FRANCE).weekOfYear()); + + int idxMonth = (idxYear * 12) + month - baseMonth; + int idxWeek = (idxYear * 52) + weekOfYear - baseWeek; + + if (idxWeek < weeksNum) { + meterReading.weekValue[idxWeek].value += value; + if (meterReading.weekValue[idxWeek].date == null) { + meterReading.weekValue[idxWeek].date = dt; + } + } + if (idxMonth < monthsNum) { + meterReading.monthValue[idxMonth].value += value; + if (meterReading.monthValue[idxMonth].date == null) { + meterReading.monthValue[idxMonth].date = LocalDateTime.of(dt.getYear(), month, 1, 0, 0); + } + } + + if (idxYear < yearsNum) { + meterReading.yearValue[idxYear].value += value; + + if (meterReading.yearValue[idxYear].date == null) { + meterReading.yearValue[idxYear].date = LocalDateTime.of(dt.getYear(), 1, 1, 0, 0); + } + } + } + } } - if (target == Target.LAST && !isDataLastDayAvailable(consumption)) { - logger.debug("Data including yesterday are not yet available"); - return null; - } - return consumption; + + return meterReading; } - private void checkData(Consumption consumption) throws LinkyException { - if (consumption.aggregats.days.periodes.isEmpty()) { - throw new LinkyException("Invalid consumptions data: no day period"); - } - if (consumption.aggregats.days.periodes.size() != consumption.aggregats.days.datas.size()) { - throw new LinkyException("Invalid consumptions data: not any data for each day period"); - } - if (consumption.aggregats.weeks.periodes.isEmpty()) { - throw new LinkyException("Invalid consumptions data: no week period"); - } - if (consumption.aggregats.weeks.periodes.size() != consumption.aggregats.weeks.datas.size()) { - throw new LinkyException("Invalid consumptions data: not any data for each week period"); - } - if (consumption.aggregats.months.periodes.isEmpty()) { - throw new LinkyException("Invalid consumptions data: no month period"); - } - if (consumption.aggregats.months.periodes.size() != consumption.aggregats.months.datas.size()) { - throw new LinkyException("Invalid consumptions data: not any data for each month period"); - } - if (consumption.aggregats.years.periodes.isEmpty()) { - throw new LinkyException("Invalid consumptions data: no year period"); - } - if (consumption.aggregats.years.periodes.size() != consumption.aggregats.years.datas.size()) { - throw new LinkyException("Invalid consumptions data: not any data for each year period"); + private void checkData(@Nullable MeterReading meterReading) throws LinkyException { + if (meterReading != null) { + if (meterReading.baseValue.length == 0) { + throw new LinkyException("Invalid meterReading data: no day period"); + } } } - private boolean isDataFirstDayAvailable(Consumption consumption) { - Aggregate days = consumption.aggregats.days; - logData(days, "First day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.FIRST); - return days.datas != null && !days.datas.isEmpty() && !days.datas.get(0).isNaN(); - } + /* + * + * private boolean isDataFirstDayAvailable(Consumption consumption) { + * Aggregate days = consumption.aggregats.days; + * logData(days, "First day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.FIRST); + * return days.datas != null && !days.datas.isEmpty() && !days.datas.get(0).isNaN(); + * } + * + * private boolean isDataLastDayAvailable(Consumption consumption) { + * Aggregate days = consumption.aggregats.days; + * logData(days, "Last day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.LAST); + * return days.datas != null && !days.datas.isEmpty() && !days.datas.get(days.datas.size() - 1).isNaN(); + * } + */ - private boolean isDataLastDayAvailable(Consumption consumption) { - Aggregate days = consumption.aggregats.days; - logData(days, "Last day", false, DateTimeFormatter.ISO_LOCAL_DATE, Target.LAST); - return days.datas != null && !days.datas.isEmpty() && !days.datas.get(days.datas.size() - 1).isNaN(); - } - - private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter, - Target target) { + private void logData(IntervalReading[] ivArray, String title, DateTimeFormatter dateTimeFormatter, Target target) { if (logger.isDebugEnabled()) { - int size = (aggregate.datas == null || aggregate.periodes == null) ? 0 - : (aggregate.datas.size() <= aggregate.periodes.size() ? aggregate.datas.size() - : aggregate.periodes.size()); + int size = ivArray.length; + if (target == Target.FIRST) { if (size > 0) { - logData(aggregate, 0, title, withDateFin, dateTimeFormatter); + logData(ivArray, 0, title, dateTimeFormatter); } } else if (target == Target.LAST) { if (size > 0) { - logData(aggregate, size - 1, title, withDateFin, dateTimeFormatter); + logData(ivArray, size - 1, title, dateTimeFormatter); } } else { for (int i = 0; i < size; i++) { - logData(aggregate, i, title, withDateFin, dateTimeFormatter); + logData(ivArray, i, title, dateTimeFormatter); } } } } - private void logData(Aggregate aggregate, int index, String title, boolean withDateFin, - DateTimeFormatter dateTimeFormatter) { - if (withDateFin) { - logger.debug("{} {} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter), - aggregate.periodes.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index)); - } else { - logger.debug("{} {} value {}", title, aggregate.periodes.get(index).dateDebut.format(dateTimeFormatter), - aggregate.datas.get(index)); - } + private void logData(IntervalReading[] ivArray, int index, String title, DateTimeFormatter dateTimeFormatter) { + IntervalReading iv = ivArray[index]; + logger.debug("{} {} value {}", title, iv.date.format(dateTimeFormatter), iv.value); + } + + public void saveConfiguration(Configuration config) { + updateConfiguration(config); } } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/MyElectricalDataBridgeHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/MyElectricalDataBridgeHandler.java new file mode 100644 index 00000000000..13a55386f4b --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/MyElectricalDataBridgeHandler.java @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2010-2025 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.time.format.DateTimeFormatter; +import java.util.Collection; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.linky.internal.LinkyBindingConstants; +import org.openhab.binding.linky.internal.LinkyConfiguration; +import org.openhab.binding.linky.internal.LinkyException; +import org.openhab.binding.linky.internal.api.EnedisHttpApi; +import org.openhab.core.auth.client.oauth2.OAuthFactory; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * {@link MyElectricalDataBridgeHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + */ +@NonNullByDefault +public class MyElectricalDataBridgeHandler extends ApiBridgeHandler { + private final Logger logger = LoggerFactory.getLogger(MyElectricalDataBridgeHandler.class); + + private static final String BASE_URL = "https://www.myelectricaldata.fr/"; + + private static final String CONTRACT_URL = BASE_URL + "contracts/%s/cache/"; + private static final String IDENTITY_URL = BASE_URL + "identity/%s/cache/"; + private static final String CONTACT_URL = BASE_URL + "contact/%s/cache/"; + private static final String ADDRESS_URL = BASE_URL + "addresses/%s/cache/"; + private static final String MEASURE_DAILY_CONSUMPTION_URL = BASE_URL + "daily_consumption/%s/start/%s/end/%s/cache"; + private static final String MEASURE_MAX_POWER_URL = BASE_URL + + "daily_consumption_max_power/%s/start/%s/end/%s/cache"; + private static final String LOAD_CURVE_CONSUMPTION_URL = BASE_URL + + "consumption_load_curve/%s/start/%s/end/%s/cache"; + + // List of Linky services related urls, information + public static final String LINKY_MYELECTRICALDATA_ACCOUNT_URL = "https://www.myelectricaldata.fr/"; + public static final String LINKY_MYELECTRICALDATA_AUTHORIZE_URL = EnedisBridgeHandler.ENEDIS_ACCOUNT_URL_PROD + + EnedisBridgeHandler.ENEDIS_AUTHORIZE_URL; + public static final String LINKY_MYELECTRICALDATA_API_TOKEN_URL = LINKY_MYELECTRICALDATA_ACCOUNT_URL + + "v1/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=na&user_type=na&state=na&person_id=-1&usage_points_id=%s"; + + public static final String LINKY_MYELECTRICALDATA_CLIENT_ID = "_h7zLaRr2INxqBI8jhDUQXsa_G4a"; + + private static final String TEMPO_URL = BASE_URL + "rte/tempo/%s/%s"; + + private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter API_DATE_FORMAT_YEAR_FIRST = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + // https://www.myelectricaldata.fr/v1/oauth2/authorize?response_type=code&client_id=&state=linky&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fconnectlinky&scope=am_application_scope+default&user_type=aa&person_id=-1&usage_points_id=aa + + public MyElectricalDataBridgeHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + final @Reference ThingRegistry thingRegistry, ComponentContext componentContext, Gson gson) { + super(bridge, httpClientFactory, oAuthFactory, httpService, thingRegistry, componentContext, gson); + } + + @Override + public void connectionInit() { + connected = true; + } + + @Override + public void initialize() { + tokenUrl = MyElectricalDataBridgeHandler.LINKY_MYELECTRICALDATA_API_TOKEN_URL; + authorizeUrl = MyElectricalDataBridgeHandler.LINKY_MYELECTRICALDATA_AUTHORIZE_URL; + + super.initialize(); + } + + @Override + public String getClientId() { + return MyElectricalDataBridgeHandler.LINKY_MYELECTRICALDATA_CLIENT_ID; + } + + @Override + public String getClientSecret() { + return ""; + } + + @Override + public boolean getIsSandbox() { + return false; + } + + @Override + public String formatAuthorizationUrl(String redirectUri) { + return super.formatAuthorizationUrl(""); + } + + @Override + public String authorize(String redirectUri, String reqState, String reqCode) throws LinkyException { + String url = String.format(MyElectricalDataBridgeHandler.LINKY_MYELECTRICALDATA_API_TOKEN_URL, getClientId(), + reqCode); + EnedisHttpApi enedisApi = getEnedisApi(); + if (enedisApi == null) { + return ""; + } + String token = enedisApi.getContent(url); + + logger.debug("token: {}", token); + + Collection col = this.thingRegistry.getAll(); + + for (Thing thing : col) { + if (LinkyBindingConstants.THING_TYPE_LINKY.equals(thing.getThingTypeUID())) { + Configuration config = thing.getConfiguration(); + String prmId = (String) config.get("prmId"); + + if (!prmId.equals(reqCode)) { + continue; + } + + config.put("token", token); + LinkyHandler handler = (LinkyHandler) thing.getHandler(); + if (handler != null) { + handler.saveConfiguration(config); + } + } + } + return token; + } + + @Override + public void dispose() { + logger.debug("Shutting down Netatmo API bridge handler."); + + super.dispose(); + } + + @Override + public String getToken(LinkyHandler handler) throws LinkyException { + LinkyConfiguration config = handler.getLinkyConfig(); + if (config == null) { + return ""; + } + return config.token; + } + + @Override + public double getDivider() { + return 1000.00; + } + + @Override + public String getBaseUrl() { + return BASE_URL; + } + + @Override + public String getContactUrl() { + return CONTACT_URL; + } + + @Override + public String getContractUrl() { + return CONTRACT_URL; + } + + @Override + public String getIdentityUrl() { + return IDENTITY_URL; + } + + @Override + public String getAddressUrl() { + return ADDRESS_URL; + } + + @Override + public String getDailyConsumptionUrl() { + return MEASURE_DAILY_CONSUMPTION_URL; + } + + @Override + public String getMaxPowerUrl() { + return MEASURE_MAX_POWER_URL; + } + + @Override + public String getLoadCurveUrl() { + return LOAD_CURVE_CONSUMPTION_URL; + } + + @Override + public String getTempoUrl() { + return TEMPO_URL; + } + + @Override + public DateTimeFormatter getApiDateFormat() { + return API_DATE_FORMAT; + } + + @Override + public DateTimeFormatter getApiDateFormatYearsFirst() { + return API_DATE_FORMAT_YEAR_FIRST; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky.properties b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky.properties index 9eeaf4460fd..18b0ce8af4a 100644 --- a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky.properties +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky.properties @@ -43,4 +43,4 @@ channel-type.linky.timestamp.label = Timestamp # Thing status descriptions -offline.config-error-mandatory-settings = Username, password and authId are mandatory. +offline.config-error-mandatory-settings = The prmId is mandatory. diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky_fr.properties b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky_fr.properties deleted file mode 100644 index a62d52db76e..00000000000 --- a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/i18n/linky_fr.properties +++ /dev/null @@ -1,46 +0,0 @@ -# add-on - -addon.linky.name = Extension Linky -addon.linky.description = Cette extension collecte vos données de consommation d'énergie depuis le site internet d'Enedis. - -# thing types - -thing-type.linky.linky.label = Linky -thing-type.linky.linky.description = Fournit vos données de consommation d'énergie. Pour recevoir ces données, vous devez activer votre compte sur https\://espace-client-particuliers.enedis.fr/web/espace-particuliers/compteur-linky. - -# thing types config - -thing-type.config.linky.linky.internalAuthId.label = ID d'authentification -thing-type.config.linky.linky.internalAuthId.description = ID d'authentification délivré après le captcha (voir documentation). -thing-type.config.linky.linky.password.label = Mot de Passe -thing-type.config.linky.linky.password.description = Votre mot de passe Enedis -thing-type.config.linky.linky.username.label = Nom d'utilisateur -thing-type.config.linky.linky.username.description = Votre nom d'utilisateur Enedis - -# channel group types - -channel-group-type.linky.daily.label = Consommation quotidienne -channel-group-type.linky.daily.channel.timestamp.label = Horodatage Pic -channel-group-type.linky.daily.channel.timestamp.description = Horodatage du pic maximum de consommation d'énergie -channel-group-type.linky.daily.channel.yesterday.label = Consommation Hier -channel-group-type.linky.monthly.label = Consommation mensuelle -channel-group-type.linky.monthly.channel.lastMonth.label = Consommation Mois Dernier -channel-group-type.linky.monthly.channel.thisMonth.label = Consommation Mois Actuel -channel-group-type.linky.weekly.label = Consommation hebdomadaire -channel-group-type.linky.weekly.channel.lastWeek.label = Consommation Semaine Dernière -channel-group-type.linky.weekly.channel.thisWeek.label = Consommation Semaine Actuelle -channel-group-type.linky.yearly.label = Consommation annuelle -channel-group-type.linky.yearly.channel.lastYear.label = Consommation Année Dernière -channel-group-type.linky.yearly.channel.thisYear.label = Consommation Année Actuelle - -# channel types - -channel-type.linky.consumption.label = Consommation Totale -channel-type.linky.consumption.description = Consommation pour un intervalle de temps donné -channel-type.linky.power.label = Pic Consommation Hier -channel-type.linky.power.description = Pic maximum de consommation d'énergie hier -channel-type.linky.timestamp.label = Horodatage - -# Thing status descriptions - -offline.config-error-mandatory-settings = Le nom d'utilisateur, le mot de passe et l'ID d'authentification sont obligatoires. diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-types.xml index fbcdbb509e3..fcdc03ac268 100644 --- a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-types.xml @@ -4,20 +4,39 @@ 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"> - - + + Provides your energy consumption data. In order to receive the data, you must activate your account at https://espace-client-particuliers.enedis.fr/web/espace-particuliers/compteur-linky. - - - - - - + + + + Your Enedis clientId + + + + Your Enedis clientSecret + + + + To test on the sandbox environment + true + + + + + + + + + Provides your energy consumption data. + In order to receive the data, you must activate your account at + https://espace-client-particuliers.enedis.fr/web/espace-particuliers/compteur-linky. + @@ -35,24 +54,127 @@ Authentication ID delivered after the captcha (see documentation). + + + + + + Provides your energy consumption data. + In order to receive the data, you must activate your account at + https://espace-client-particuliers.enedis.fr/web/espace-particuliers/compteur-linky. + + + + + + + + + + + + + + + + Provides your energy consumption data. + In order to receive the data, you must activate your account at + https://espace-client-particuliers.enedis.fr/web/espace-particuliers/compteur-linky. + + + + + + + + + + + + + + + + Your prmId + + + + The timezone associated with your Point of delivery. + Will default to openhab default timezone. + You will + need to change this if your linky is located in a different timezone that your openhab location. + You can use an + offset, or a label like Europe/Paris + + + + Your Enedis token (can be left empty, use the connection page to automatically fill it + http://youopenhab/connectlinky) + + + + + + + + + + + - + - + + + + + + + + + + + + + Maximum power usage value + + + + + Maximum power usage value for Yesterday + - - Maximum power usage timestamp + + Maximum power usage timestamp for Yesterday + + + + + Maximum power usage value for Day-2 + + + + Maximum power usage timestamp for Day-2 + + + + + Maximum power usage value for Day-3 + + + + Maximum power usage timestamp for Day-3 - + @@ -60,11 +182,21 @@ + + + + + + + + + Maximum power usage value + - + @@ -72,11 +204,21 @@ + + + + + + + + + Maximum power usage value + - + @@ -84,9 +226,129 @@ + + + + + + + + + Maximum power usage value + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Number + + This status describes the tempo color of a day. + + + + + + + + + + + String + + an information + energy + + + + + DateTime + + an information of type date + + + Number:Energy diff --git a/bundles/org.openhab.binding.linky/src/main/resources/templates/enedis-step1.html b/bundles/org.openhab.binding.linky/src/main/resources/templates/enedis-step1.html new file mode 100644 index 00000000000..73e9d5bba25 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/templates/enedis-step1.html @@ -0,0 +1,181 @@ + + + + + +${pageRefresh} +OpenHAB Linky binding for Enedis + + + + + + + +
+ + +


+
+
+ + + + + + + 1 + + + + 2 + + + + 3 + +
+
+

Plugin Linky / Enedis pour Openhab

+
+ +

Ce plugin permet d'exploiter vos données de consommation éléctrique fournis par Enedis au sein d'OpenHab.

+ +

Enedis gère le réseau d’électricité jusqu’au compteur d’électricité
+ Enedis est le gestionnaire du réseau public de distribution d’électricité sur 95% du territoire français continental.

+ +

Grace à ce plugin, vous serez en mesure de :

+ +
    +
  • Consulter les informations contractuelles liés à votre compte.
  • +
  • Créer des graphes de consommation par jour / semaine / mois / annéee.
  • +
  • Consulter la puissance maximum utilisé sur une période donnée.
  • +
  • Load curve
  • +
+ +

+
+ +
+ + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/templates/enedis-step2.html b/bundles/org.openhab.binding.linky/src/main/resources/templates/enedis-step2.html new file mode 100644 index 00000000000..92737e5b6d4 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/templates/enedis-step2.html @@ -0,0 +1,174 @@ + + + + + +${pageRefresh} +OpenHAB Linky binding for Enedis + + + + + + + +
+ + +


+
+
+ + + + + + + 1 + + + + 2 + + + + 3 + +
+
+

Plugin Linky / Enedis pour Openhab

+
+ +
+ +
+ +

Pour utiliser ce plugin, vous devez donner votre accord pour qu'Enedis transmette vos données.

+ +

+ Enedis est le gestionnaire du réseau public de distribution d’électricité sur 95% du territoire français continental.
+ Enedis gère le réseau d’électricité jusqu’au compteur d’électricité. +
+

+ +

Pour donner votre autorisation, vous devez avoir un compte personnel Enedis.
+ Ce compte vous permet également de suivre et gérer vos données de consommation [ou production en fonction de votre service] d’électricité.

+ +

Si vous n'avez pas de compte, vous pouvez le créer depuis cette page.
+ Munissez-vous pour celà de votre facture d’électricité pour créer votre espace.

+ +

En cliquant sur le bouton ci-dessous, vous allez accéder à votre compte personnel Enedis où vous pourrez donner votre accord pour qu’Enedis nous transmette vos données.

+

Une fois cette opération effectué, vous serez rediriger vers une page de confirmation.

+ +
+ + + +
+ +
+
+ + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/templates/enedis-step3-cb.html b/bundles/org.openhab.binding.linky/src/main/resources/templates/enedis-step3-cb.html new file mode 100644 index 00000000000..5efc6abb2a5 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/templates/enedis-step3-cb.html @@ -0,0 +1,174 @@ + + + + + +${pageRefresh} +OpenHAB Linky binding for Enedis + + + + + + + +
+ + +


+
+
+ + + + + + + 1 + + + + 2 + + + + 3 + +
+
+

Plugin Linky / Enedis pour Openhab

+
+ +
+ Vous avez autorisé l'accès pour le compteur Linky : ${prmId.Value}
+ Vous pouvez maintenant utiliser le plugin Linky avec Enedis.
+ ${authorizedUser} +
+ +
+ Une erreur c'est produite: + ${error} +
+ + +

+
+ + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/templates/index.html b/bundles/org.openhab.binding.linky/src/main/resources/templates/index.html new file mode 100644 index 00000000000..931dda58164 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/templates/index.html @@ -0,0 +1,152 @@ + + + + + +${pageRefresh} +Authorize openHAB Linky binding for Enedis + + + + + + + +

+ +


+ +
+
+

+ Merci de sélectionner votre provider +

+ +
+
+ +
+
+ +
+
+
+ +
+ + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/templates/myelectricaldata-step1.html b/bundles/org.openhab.binding.linky/src/main/resources/templates/myelectricaldata-step1.html new file mode 100644 index 00000000000..73ccb9f95b8 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/templates/myelectricaldata-step1.html @@ -0,0 +1,181 @@ + + + + + +${pageRefresh} +OpenHAB Linky binding for Enedis + + + + + + + +
+ + +


+
+
+ + + + + + + 1 + + + + 2 + + + + 3 + +
+
+

Plugin Linky / MyElectricalData pour Openhab

+
+ +

Ce plugin permet d'exploiter vos données de consommation éléctrique fournis par Enedis au sein d'OpenHab.

+ +

Enedis gère le réseau d’électricité jusqu’au compteur d’électricité
+ Enedis est le gestionnaire du réseau public de distribution d’électricité sur 95% du territoire français continental.

+ +

Grace à ce plugin, vous serez en mesure de :

+ +
    +
  • Consulter les informations contractuelles liés à votre compte.
  • +
  • Créer des graphes de consommation par jour / semaine / mois / annéee.
  • +
  • Consulter la puissance maximum utilisé sur une période donnée.
  • +
  • Load curve
  • +
+ +

+
+ +
+ + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/templates/myelectricaldata-step2.html b/bundles/org.openhab.binding.linky/src/main/resources/templates/myelectricaldata-step2.html new file mode 100644 index 00000000000..ab19fedc48f --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/templates/myelectricaldata-step2.html @@ -0,0 +1,169 @@ + + + + + +${pageRefresh} +OpenHAB Linky binding for Enedis + + + + + + + +
+ + +


+
+
+ + + + + + + 1 + + + + 2 + + + + 3 + +
+
+

Plugin Linky / MyElectricalData pour Openhab

+
+ +
+ +
+ +

Pour utiliser ce plugin, vous devez donner votre accord pour qu'Enedis transmette vos données.

+ +

Pour donner votre autorisation, vous devez avoir un compte personnel Enedis.
+ Ce compte vous permet également de suivre et gérer vos données de consommation [ou production en fonction de votre service] d’électricité.

+ +

Si vous n'avez pas de compte, vous pouvez le créer depuis cette page.
+ Munissez-vous pour celà de votre facture d’électricité pour créer votre espace.

+ +

En cliquant sur le bouton ci-dessous, vous allez accéder à votre compte personnel Enedis où vous pourrez donner votre accord pour qu’Enedis nous transmette vos données.

+

Une fois cette opération effectué, vous devez vous rendre manuellement sur cette page pour terminer la procédure.

+ + +
+ + + +
+ +
+
+ + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/templates/myelectricaldata-step3.html b/bundles/org.openhab.binding.linky/src/main/resources/templates/myelectricaldata-step3.html new file mode 100644 index 00000000000..73e8d8692c4 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/templates/myelectricaldata-step3.html @@ -0,0 +1,201 @@ + + + + + +${pageRefresh} +OpenHAB Linky binding for Enedis + + + + + + + +
+ + +


+
+
+ + + + + + + 1 + + + + 2 + + + + 3 + +
+
+

Plugin Linky / MyElectricalData pour Openhab

+
+ +
+ Vous pouvez maintenant utiliser le plugin Linky avec MyElectricalData. + ${authorizedUser} +
+ +
+ Une erreur c'est produite: + ${error} +
+ +
+

+ Vous devez maintenant récupérer le token depuis MyElectricalData. +

+ +

+ Pour ce faire : +

    +
  • Sélectionner le numéro de prmId dans la combobox ci-dessous.
  • +
  • Cliquer sur le bouton "Retrive token".
  • +
+

+
+ +


+
+
+
+
+ Please select your prmId : + +
+ +
+
+
+ + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/web/MyElectricalData.png b/bundles/org.openhab.binding.linky/src/main/resources/web/MyElectricalData.png new file mode 100644 index 00000000000..42a96f45a42 Binary files /dev/null and b/bundles/org.openhab.binding.linky/src/main/resources/web/MyElectricalData.png differ diff --git a/bundles/org.openhab.binding.linky/src/main/resources/web/boutonEnedis.png b/bundles/org.openhab.binding.linky/src/main/resources/web/boutonEnedis.png new file mode 100644 index 00000000000..fb54715051b Binary files /dev/null and b/bundles/org.openhab.binding.linky/src/main/resources/web/boutonEnedis.png differ diff --git a/bundles/org.openhab.binding.linky/src/main/resources/web/enedis.png b/bundles/org.openhab.binding.linky/src/main/resources/web/enedis.png new file mode 100644 index 00000000000..a28c3f31bbd Binary files /dev/null and b/bundles/org.openhab.binding.linky/src/main/resources/web/enedis.png differ diff --git a/bundles/org.openhab.binding.linky/src/main/resources/web/enedisSmall.png b/bundles/org.openhab.binding.linky/src/main/resources/web/enedisSmall.png new file mode 100644 index 00000000000..159281d2c39 Binary files /dev/null and b/bundles/org.openhab.binding.linky/src/main/resources/web/enedisSmall.png differ diff --git a/bundles/org.openhab.binding.linky/src/main/resources/web/linky.png b/bundles/org.openhab.binding.linky/src/main/resources/web/linky.png new file mode 100644 index 00000000000..fd718ea417e Binary files /dev/null and b/bundles/org.openhab.binding.linky/src/main/resources/web/linky.png differ diff --git a/bundles/org.openhab.binding.linky/src/main/resources/web/linky.svg b/bundles/org.openhab.binding.linky/src/main/resources/web/linky.svg new file mode 100644 index 00000000000..4fd5ed95509 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/web/linky.svg @@ -0,0 +1,76 @@ + + + + A0924594-7213-430D-A943-FE07E1B6E747 + Created with sketchtool. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.linky/src/main/resources/web/openhab_logo.svg b/bundles/org.openhab.binding.linky/src/main/resources/web/openhab_logo.svg new file mode 100644 index 00000000000..0401d16f698 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/web/openhab_logo.svg @@ -0,0 +1,733 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +