diff --git a/bundles/org.openhab.binding.linky/NOTICE b/bundles/org.openhab.binding.linky/NOTICE index 3e2c49e0050..81b31877eab 100644 --- a/bundles/org.openhab.binding.linky/NOTICE +++ b/bundles/org.openhab.binding.linky/NOTICE @@ -13,8 +13,3 @@ https://www.eclipse.org/legal/epl-2.0/. https://github.com/openhab/openhab-addons == Third-party Content - -jsoup -* License: MIT License -* Project: https://jsoup.org/ -* Source: https://github.com/jhy/jsoup \ No newline at end of file diff --git a/bundles/org.openhab.binding.linky/README.md b/bundles/org.openhab.binding.linky/README.md index 5c563a412c1..78fd4d0d722 100644 --- a/bundles/org.openhab.binding.linky/README.md +++ b/bundles/org.openhab.binding.linky/README.md @@ -1,66 +1,492 @@ # 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. +This binding enables the exploitation of electricity consumption data, mainly for the French market. +It supports different functionalities: -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. +- Connection to Enedis to retrieve consumption data online. +- Connection to the RTE API to get Tempo Red/White/Blue calendar information. -## 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). +## Migration -## Discovery +The new binding will need some tweak to you configuration to work. +Mainly the new binding uses Bridge to access Enedis data, so you will have to add this bridge to your configuration. +Step are: -This binding does not provide discovery service. +1. before updating to openHAB 5.0, in case you defined your thing with Main UI, backup username, password & internalAuthId configuration parameters as you will need to fill them again. -## Binding Configuration +2. add a bridge definition + + Bridge linky:enedis:local "EnedisWebBridge" [ + username="laurent@clae.net", + password="Mnbo32tyu123!", + internalAuthId="eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.u_mxXO7_d4I5bLvJzGtc2MARvpkYv0iM0EsO6a24k-tW9493_Myxwg.LVlfephhGTiCBxii8bRIkA.GOf9Ea8PTGshvkfjl62b6w.hSH97IkmBcEAz2udU-FqQg" + ] + { + } -The binding has no configuration options, all configuration is done at Thing level. +3. Move username, password & internalAuthId configuration parameter from the old linky thing to the bridge thing. -## Thing Configuration +4. Link your old thing to the new created bridge thing -The thing has the following configuration parameters: + Thing linky:linky:linkremotemelody "Linky Melody" (linky:enedis:local) -| Parameter | Description | -|----------------|--------------------------------------------| -| username | Your Enedis platform username. | -| password | Your Enedis platform password. | -| internalAuthId | The internal authID | -| timezone | The timezone at the location of your linky | +5. Start using the new channels added by the enhanced binding.. + Old items will work out of the box without the need to relink items to channels. -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). +## Getting Consumption Data Online -Instructions given for Firefox : +The new binding version can use multiple bridges to access consumption data. +You can use : -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. +- The enedis bridge: Uses the old Enedis API, based on the Enedis website, to gather data. +- The myelectricaldata bridge: Uses the new REST Enedis API via the MyElectricalData proxy site to access the data. +- TThe enedis-api bridge: Also uses the new REST Enedis API, but gathers data directly from the Enedis site. -A new timezone parameter has been introduced. If you don't put a value, it will default to the timezone of your openHAB installation. This parameter can be useful if you read data from a Linky in a different timezone. +You first need to create an Enedis account [here](https://mon-compte-client.enedis.fr/) if you don't already have one. +Ensure that you have accepted their conditions and check that you can see graphs on the website, especially the hourly view. + Enedis may require your permission the first time to start collecting hourly data. -## Channels +The binding will not provide this information unless this step is completed. -The information that is retrieved is available as these channels: +Advantage and Disadvantage of Each Method. + +- Enedis bridge is the older method. +- MyelectricalData and enedis bridges both use the new API format, making them less prone to changes in website architecture. +- MyelectricalData bridge is managed by a third-party provider but is stable. +- Enedis-api bridge directly connects to Enedis but currently requires a complex registration process with Enedis. + This limitation will likely be resolved in the near future, making Enedis-api Bridge the preferred method. + +Be warned that MyElectricalData bridge collect data using MyElectricalData service. +This service will store your enedis information for caching purpose. +This cache is crypted, so it may be a very big concerns, but of course, we don't know the details about this crypting, and if it can be reverse to access your data. + +### Bridge Configuration + +To retrieve data, the Linky device needs to be linked to a LinkyBridge. The available bridge options are enedis, myelectricaldata, and enedis-api. + +#### Enedis Web Bridge + +If you select enedis web bridge, you will need : + +- To create an Enedis account : https://mon-compte-client.enedis.fr/ +- To provide your credentials: username, password, and InternalAuthId. + + | Parameter | Description | + |----------------|--------------------------------------------| + | username | Your Enedis platform username. | + | password | Your Enedis platform password. | + | internalAuthId | The internal authentication ID. | + + This version is compatible with the latest Enedis Web API (deployed from June 2020). To bypass the captcha login, log in via a standard browser (e.g., Chrome, Firefox) and retrieve the user cookies (internalAuthId). + + Instructions for Firefox : + + 1. Go to . + 2. Select "Particulier" from the drop down and click "Connexion". + 3. Enter your Enedis account email and check "Je ne suis pas un robot". + 4. Click "Suivant". + 5. Enter your Enedis password and click "Connexion à Espace Client Enedis". + 6. Navigate to your Enedis account environment, then return to the previous page in your browser. + 7. Log out from your Enedis account. + 8. Repeat steps 1-2. This time, open the developer tools window (F12) and select the "Storage" tab. + 9. Under "Cookies", select "https://mon-compte-client.enedis.fr/". Locate the "internalAuthId" entry and copy its value into your OpenHAB configuration. + + A new timezone parameter has been introduced. If you don't put a value, it will default to the timezone of your openHAB installation. + This parameter can be useful if you read data from a Linky in a different timezone. + + ```java + Bridge linky:enedis:local "EnedisWebBridge" [ username="example@domaine.fr", password="******", internalAuthId="******" ] + ``` + +#### Myelectricaldata Bridge + +If you select MyElectricalData bridge, you will need : + +- To create an Enedis account : https://mon-compte-client.enedis.fr/ + +- To 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. + + You will find screenshoot of the procedure in the following directory + [doc/myelectricaldata/](doc/myelectricaldata/index.md) + + 1. Go to the connectlinky page on OpenHAB. + 2. Follow the first two steps of the wizard and click "Access Enedis". + 3. Log into your Enedis account. + 4. Authorize data collection for your PRM ID.
+ If you have multiple Linky meters, repeat the procedure for each one separately; selecting multiple meters at once will not work. + + + 5. You will then be redirect to a confirmation page on MyElectricalData web site + 6. Return to OpenHAB, go to "connectlinky/myelectricaldata-step3", select your PRM ID from the dropdown, and click "Retrieve Token". + 7. A confirmation page will appear if everything is correctly set up. + + ```java + Bridge linky:my-electrical-data:local "MyElectricalBridge" [ ] + ``` + +#### Enedis Bridge + +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. + + You will find screenshoot of the procedure in the following directory + [doc/enedis/](doc/enedis/index.md) + + + 1. Go to the connectlinky page on OpenHAB. + 2. Follow the first two steps of the wizard and click "Access Enedis". + 3. Log into your Enedis account. + 4. Authorize data collection for your PRM ID. + 5. A confirmation page will appear if everything is correctly set up. + + + ```java + Bridge linky:enedis-api:localSB "EnedisBridgeSandbox" [ clientId="myClientId...", clientSecret="myClientSecret..." ] + ``` + +### Thing Configuration + +The remote bridge works with Linky devices to retrieve consumption data from a remote API or website. + +You can have multiple Linky devices in your setup if you have different houses or multiple Linky meters linked to your account. +To do this, simply create multiple Linky devices and set the prmId to match your meter ID. +You can find the meter ID on the Enedis website or directly on your Linky meter. + +You can switch the Linky device from one bridge to another if you experience issues with a particular bridge. +The data retrieved will be almost identical regardless of the bridge you use. +Only a few contract-related items may differ between the web bridge and the API bridge. + +The device has the following configuration parameters: + + | Parameter | Description | + |----------------|------------------------------------------------------------------------------------------------------| + | prmId | The prmId linked to the Linky Handler (optional: if blank first registered meter will be used | + | timezone | The timezone associated with your Point of delivery | + | token | Optional: Required if a token is necessary to access this Linky device (used for MyElectricalData). | + + + + +```java +Thing linky:linky:linkyremote "Linky Remote" (linky:enedis:local) [ ] +Thing linky:linky:linkyremotexxxx "Linky Remote xxxx" (linky:enedis:local) [ prmId="xxxx" ] +Thing linky:linky:linkyremotexxxx "Linky Remote xxxx" (linky:enedis-api:local) [ prmId="xxxx" ] +Thing linky:linky:linkyremotexxxx "Linky Remote xxxx" (linky:myelectricaldata:local) [ prmId="xxxx", token="myElectricalDataToken" ] +``` + +### Thing Channels + +The retrieved information is available in multiple groups. + + + +- The daily group will give consumtion information with day granularity + + | Channel ID | Item Type | Description | + |---------------------------------------------------|-------------------|-------------------------------------------------------------------------------| + | daily#yesterday | consumption | Yesterday energy usage | + | daily#day-2 | consumption | Day-2 energy usage | + | daily#day-3 | consumption | Day-3 energy usage | + | daily#consumption | consumption | timeseries for energy usage (up to three years will be store if available) | + | daily#maw-power | power | timeseries for max-power usage | + | daily#power | power | Yesterday's peak power usage | + | daily#timestamp | timestamp | Timestamp of the power peak | + | daily#power-2 | power | Day-2's peak power usage | + | daily#timestamp-2 | timestamp | Timestamp Day-2's of the power peak | + | daily#power-3 | power | Day-3's peak power usage | + | daily#timestamp-3 | timestamp | Timestamp Day-3's of the power peak | + +- The weekly group will give consumtion information with week granularity + + | Channel ID | Item Type | Description | + |---------------------------------------------------|-------------------|-------------------------------------------------------------------------------| + | weekly#thisWeek | consumption | Current week energy usage | + | weekly#lastWeek | consumption | Last week energy usage | + | weekly#week-2 | consumption | Week -2 energy usage | + | weekly#consumption | consumption | timeseries for weeks energy usage | + | weekly#max-power | power | timeseries for max-power weekly usage | + +- The monthly group will give consumtion information with month granularity + + | Channel ID | Item Type | Description | + |---------------------------------------------------|-------------------|-------------------------------------------------------------------------------| + | monthly#thisMonth | consumption | Current month energy usage | + | monthly#lastMonth | consumption | Last month energy usage | + | monthly#month-2 | consumption | Month-2 energy usage | + | monthly#consumption | consumption | timeseries for months energy usage | + | monthly#max-power | power | timeseries for max-power monthly usage | + +- The yearly group will give consumtion information with year granularity + + | Channel ID | Item Type | Description | + |---------------------------------------------------|-------------------|-------------------------------------------------------------------------------| + | yearly#thisYear | consumption | Current year energy usage | + | yearly#lastYear | consumption | Last year energy usage | + | yearly#year-2 | consumption | year-2 energy usage | + | yearly#consumption | consumption | timeseries for years energy usage | + | yearly#max-power | power | timeseries for max-power yearly usage | + +- The load-curve group will give you access to load curve data with granularity as low as 30mn + + | Channel ID | Item Type | Description | + |---------------------------------------------------|-------------------|-------------------------------------------------------------------------------| + | load-curve#power | power | The load curve data | + + +- You will also find some Information as properties on the linky things + + | Channel ID | Description | + |---------------------------------------------------|-------------------------------------------------------------------------------| + | identitiy | The full name of the contract older | + | customerId | The internal Enedis customer ID | + | contractSubscribedPower | The subscribed max Power | + | contractLastActivationdate | The contract activation date | + | contractDistributionTariff | The current applied tarif | + | contractOffpeakHours | The OffPeakHour link to your contract | + | contractStatus | The current contract status | + | contractType | The contract type | + | contractLastdistributionTariffChangedate | The date of the last tariff change | + | contractSegment | The customer segment for this contract | + | usagePointId | The distribution / usage point uniq indentifier | + | usagePointStatus | The usage point current state | + | usagePointMeterType | The usage point meter type | + | usagePointCity | The usage point City | + | usagePointCountry | The usage point Country | + | usagePointPostalCode | The usage point Postal Code | + | usagePointStreet | The usage point Address Street | + | contactMail | The usage point Contact Mail | + | contactPhone | The usage point Contact Phone | + +### Full Example + +#### Remote Enedis Web Connection + +```java +Bridge linky:enedis:local "EnedisWebBridge" [ username="example@domaine.fr", password="******", internalAuthId="******" ] + +Thing linky:linky:linkyremotexxxx "Linky Remote xxxx" (linky:enedis:local) [ prmId="xxxx" ] +``` + +```java +Number:Energy ConsoHier "Conso hier [%.0f %unit%]" { channel="linky:linky:linkyremotexxxx:daily#yesterday" } +Number:Energy ConsoSemaineEnCours "Conso cette semaine [%.0f %unit%]" { channel="linky:linky:linkyremotexxxx:weekly#thisWeek" } +Number:Energy ConsoSemaineDerniere "Conso semaine dernière [%.0f %unit%]" { channel="linky:linky:linkyremotexxxx:weekly#lastWeek" } +Number:Energy ConsoMoisEnCours "Conso ce mois [%.0f %unit%]" { channel="linky:linky:linkyremotexxxx:monthly#thisMonth" } +Number:Energy ConsoMoisDernier "Conso mois dernier [%.0f %unit%]" { channel="linky:linky:linkyremotexxxx:monthly#lastMonth" } +Number:Energy ConsoAnneeEnCours "Conso cette année [%.0f %unit%]" { channel="linky:linky:linkyremotexxxx:yearly#thisYear" } +Number:Energy ConsoAnneeDerniere "Conso année dernière [%.0f %unit%]" { channel="linky:linky:linkyremotexxxx:yearly#lastYear" } +``` + +### Displaying Information Graph + +Using the timeseries channel, you will be able to easily create a calendar graph to display the Tempo calendar. +To do this, you need to enable a timeseries persistence framework. +Graph definitions will look like this: + +![TempoGraph](doc/GraphConso.png) + +Sample code : + +```java +config: + future: false + label: Linky Melody Conso Journalière + order: "110" + period: 2W + sidebar: true +slots: + dataZoom: + - component: oh-chart-datazoom + config: + type: inside + grid: + - component: oh-chart-grid + config: + containLabel: true + includeLabels: true + show: true + legend: + - component: oh-chart-legend + config: + bottom: 3 + type: scroll + series: + - component: oh-time-series + config: + areaStyle: + opacity: 0.2 + gridIndex: 0 + item: Linky_Melody_Daily_Conso_Day + label: + formatter: =v=>Number.parseFloat(v.data[1]).toFixed(2) + " Kwh" + position: inside + show: true + markLine: + data: + - type: average + markPoint: + data: + - name: min + type: min + - name: max + type: max + label: + backgroundColor: auto + name: Consumption + noBoundary: true + noItemState: true + service: influxdb + type: bar + xAxisIndex: 0 + yAxisIndex: 0 + tooltip: + - component: oh-chart-tooltip + config: + confine: true + smartFormatter: true + xAxis: + - component: oh-time-axis + config: + gridIndex: 0 + nameLocation: center + splitNumber: 10 + yAxis: + - component: oh-value-axis + config: + gridIndex: 0 + max: "150" + min: "0" + name: kWh + nameLocation: center +``` + +## Getting Tempo Calendar Information + +### Thing Channels + +- The tempo group will give information about the tempo day color link to a tempo contract + + | Channel ID | Item Type | Description | + |----------------------------------------------------------------|-------------------|-------------------------------------------------------------------------------| + | linky-tempo-calendar#tempo-info-today | tempo-value | The tempo color for the current day | + | linky-tempo-calendar#tempo-info-tomorrow | tempo-value | The tempo color for the tomorrow | + | linky-tempo-calendar#tempo-info-timeseries | tempo-value | A timeseries channel that will expose full tempo information for one year | + +### Displaying Tempo Graph + +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 + +The resulting graph will look like this: + +![TempoGraph](doc/TempoGraph.png) + + +Sample code: + +```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: [] + +``` -| Channel ID | Item Type | Description | -|-------------------|---------------|------------------------------| -| daily#yesterday | Number:Energy | Yesterday energy usage | -| daily#power | Number:Power | Yesterday's peak power usage | -| daily#timestamp | DateTime | Timestamp of the power peak | -| weekly#thisWeek | Number:Energy | Current week energy usage | -| weekly#lastWeek | Number:Energy | Last week energy usage | -| monthly#thisMonth | Number:Energy | Current month energy usage | -| monthly#lastMonth | Number:Energy | Last month energy usage | -| yearly#thisYear | Number:Energy | Current year energy usage | -| yearly#lastYear | Number:Energy | Last year energy usage | ## Console Commands @@ -77,26 +503,7 @@ Start and end day are formatted yyyy-mm-dd. Here is an example of command you can run: `openhab:linky linky:linky:local report 2020-11-15 2020-12-15`. -## Docker specificities +## Docker Specificities In case you are running openHAB inside Docker, the binding will work only if you set the environment variable `CRYPTO_POLICY` to the value "unlimited" as documented [here](https://github.com/openhab/openhab-docker#java-cryptographic-strength-policy). -## Full Example - -### Thing - -```java -Thing linky:linky:local "Compteur Linky" [ username="example@domaine.fr", password="******", internalAuthId="******" ] -``` - -### Items - -```java -Number:Energy ConsoHier "Conso hier [%.0f %unit%]" { channel="linky:linky:local:daily#yesterday" } -Number:Energy ConsoSemaineEnCours "Conso cette semaine [%.0f %unit%]" { channel="linky:linky:local:weekly#thisWeek" } -Number:Energy ConsoSemaineDerniere "Conso semaine dernière [%.0f %unit%]" { channel="linky:linky:local:weekly#lastWeek" } -Number:Energy ConsoMoisEnCours "Conso ce mois [%.0f %unit%]" { channel="linky:linky:local:monthly#thisMonth" } -Number:Energy ConsoMoisDernier "Conso mois dernier [%.0f %unit%]" { channel="linky:linky:local:monthly#lastMonth" } -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..eaa0d2075ce 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..fc13fac0b1c 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-index.png b/bundles/org.openhab.binding.linky/doc/connectlinky-index.png new file mode 100644 index 00000000000..555b73ca0dd 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/enedis/connectlinky-enedis-step1.png b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step1.png new file mode 100644 index 00000000000..867c3af9973 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step1.png differ diff --git a/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step2.png b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step2.png new file mode 100644 index 00000000000..7cf2826582c Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step2.png differ diff --git a/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step2b.png b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step2b.png new file mode 100644 index 00000000000..cbac33be064 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step2b.png differ diff --git a/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step2c.png b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step2c.png new file mode 100644 index 00000000000..0e9e8ddfc87 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step2c.png differ diff --git a/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step3.png b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step3.png new file mode 100644 index 00000000000..8a219bd46a4 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/enedis/connectlinky-enedis-step3.png differ diff --git a/bundles/org.openhab.binding.linky/doc/enedis/index.md b/bundles/org.openhab.binding.linky/doc/enedis/index.md new file mode 100644 index 00000000000..34db4308a07 --- /dev/null +++ b/bundles/org.openhab.binding.linky/doc/enedis/index.md @@ -0,0 +1,20 @@ + 2. Step 2 + + ![connectlinky-enedis-step1](connectlinky-enedis-step1.png)
+ ![connectlinky-enedis-step2](connectlinky-enedis-step2.png)
+ + 3. Step 3 + + ![connectlinky-enedis-step2b](connectlinky-enedis-step2b.png)
+ + 4. Step 4 + + 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](connectlinky-enedis-step2c.png)
+ + 5. Step 5 + + ![connectlinky-enedis-step3](connectlinky-enedis-step3.png)
+ diff --git a/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step1.png b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step1.png new file mode 100644 index 00000000000..163203d2a77 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step1.png differ diff --git a/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2.png b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2.png new file mode 100644 index 00000000000..e726253cc8b Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2.png differ diff --git a/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2b.png b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2b.png new file mode 100644 index 00000000000..41adb8c2bef Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2b.png differ diff --git a/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2c.png b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2c.png new file mode 100644 index 00000000000..1c100a54846 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2c.png differ diff --git a/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2d.png b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2d.png new file mode 100644 index 00000000000..77c014801c2 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step2d.png differ diff --git a/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step3.png b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step3.png new file mode 100644 index 00000000000..6684b6b6ca9 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step3.png differ diff --git a/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step3b.png b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step3b.png new file mode 100644 index 00000000000..4d2eb813df6 Binary files /dev/null and b/bundles/org.openhab.binding.linky/doc/myelectricaldata/connectlinky-myelectricaldata-step3b.png differ diff --git a/bundles/org.openhab.binding.linky/doc/myelectricaldata/index.md b/bundles/org.openhab.binding.linky/doc/myelectricaldata/index.md new file mode 100644 index 00000000000..d6b3e33f5a7 --- /dev/null +++ b/bundles/org.openhab.binding.linky/doc/myelectricaldata/index.md @@ -0,0 +1,29 @@ + 2. Step 2 + + ![connectlinky-myelectricaldata-step1](connectlinky-myelectricaldata-step1.png)
+ ![connectlinky-myelectricaldata-step2](connectlinky-myelectricaldata-step2.png)
+ + 3. Step 3 + + ![connectlinky-myelectricaldata-step2b](connectlinky-myelectricaldata-step2b.png)
+ + 4. Step 4 + + 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](connectlinky-myelectricaldata-step2c.png)
+ + 5. Step 5 + + ![connectlinky-myelectricaldata-step2d](connectlinky-myelectricaldata-step2d.png)
+ + 6. Step 6 + + ![connectlinky-myelectricaldata-step3](connectlinky-myelectricaldata-step3.png)
+ + 7. Step 7 + + ![connectlinky-myelectricaldata-step3b](connectlinky-myelectricaldata-step3b.png)
+ + diff --git a/bundles/org.openhab.binding.linky/pom.xml b/bundles/org.openhab.binding.linky/pom.xml index 17787276d8a..2ff88b44998 100644 --- a/bundles/org.openhab.binding.linky/pom.xml +++ b/bundles/org.openhab.binding.linky/pom.xml @@ -17,14 +17,4 @@ javax.annotation.meta;resolution:=optional - - - - org.jsoup - jsoup - 1.14.3 - provided - - - 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..a2e4f1f26b1 100644 --- a/bundles/org.openhab.binding.linky/src/main/feature/feature.xml +++ b/bundles/org.openhab.binding.linky/src/main/feature/feature.xml @@ -4,7 +4,7 @@ openhab-runtime-base - mvn:org.jsoup/jsoup/1.14.3 + openhab-core-auth-oauth2client 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/LinkyBindingConstants.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyBindingConstants.java deleted file mode 100644 index 33975c9c66f..00000000000 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyBindingConstants.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.core.thing.ThingTypeUID; - -/** - * The {@link LinkyBindingConstants} class defines common constants, which are - * used across the whole binding. - * - * @author Gaël L'hopital - Initial contribution - */ -@NonNullByDefault -public class LinkyBindingConstants { - - public static final String BINDING_ID = "linky"; - - // List of all Thing Type UIDs - public static final ThingTypeUID THING_TYPE_LINKY = new ThingTypeUID(BINDING_ID, "linky"); - - // Thing properties - public static final String PUISSANCE = "puissance"; - public static final String PRM_ID = "prmId"; - public static final String USER_ID = "av2_interne_id"; - - // List of all Channel id's - public static final String YESTERDAY = "daily#yesterday"; - public static final String PEAK_POWER = "daily#power"; - public static final String PEAK_TIMESTAMP = "daily#timestamp"; - public static final String THIS_WEEK = "weekly#thisWeek"; - public static final String LAST_WEEK = "weekly#lastWeek"; - public static final String THIS_MONTH = "monthly#thisMonth"; - public static final String LAST_MONTH = "monthly#lastMonth"; - public static final String THIS_YEAR = "yearly#thisYear"; - public static final String LAST_YEAR = "yearly#lastYear"; -} 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 bce596ffd69..ba070cfa040 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,41 @@ 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.BridgeRemoteBaseHandler; +import org.openhab.binding.linky.internal.handler.BridgeRemoteEnedisWebHandler; +import org.openhab.binding.linky.internal.handler.ThingBaseRemoteHandler; +import org.openhab.binding.linky.internal.handler.ThingLinkyRemoteHandler; +import org.openhab.binding.linky.internal.types.LinkyException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,194 +58,63 @@ 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("yyyy-MM-dd"); - 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-prm/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-energetiques?mesuresTypeCode=%s&mesuresCorrigees=false&typeDonnees=CONS&dateDebut=%s"; - 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 BridgeRemoteBaseHandler linkyBridgeHandler; - private boolean connected = false; - - public EnedisHttpApi(LinkyConfiguration config, Gson gson, HttpClient httpClient) { + public EnedisHttpApi(BridgeRemoteBaseHandler linkyBridgeHandler, Gson gson, HttpClient httpClient) { this.gson = gson; this.httpClient = httpClient; - this.config = config; + this.linkyBridgeHandler = linkyBridgeHandler; + } + + public FormContentProvider getFormContent(String fieldName, String fieldValue) { + Fields fields = new Fields(); + fields.put(fieldName, fieldValue); + return new FormContentProvider(fields); + } + + public void addCookie(String key, String value) { + HttpCookie cookie = new HttpCookie(key, value); + cookie.setDomain(BridgeRemoteEnedisWebHandler.ENEDIS_DOMAIN); + cookie.setPath("/"); + httpClient.getCookieStore().add(BridgeRemoteEnedisWebHandler.COOKIE_URI, cookie); } public void removeAllCookie() { httpClient.getCookieStore().removeAll(); } - public void initialize() throws LinkyException { - logger.debug("Starting login process for user: {}", config.username); - - try { - removeAllCookie(); - - 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) { + public 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 String getContent(ThingBaseRemoteHandler handler, String url) throws LinkyException { + return getContent(logger, linkyBridgeHandler, url, httpClient, linkyBridgeHandler.getToken(handler)); } - public boolean isConnected() { - return connected; + public String getContent(String url) throws LinkyException { + return getContent(logger, linkyBridgeHandler, url, httpClient, ""); } - 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) { - Fields fields = new Fields(); - fields.put(fieldName, fieldValue); - return new FormContentProvider(fields); - } - - private String getContent(String url) throws LinkyException { + private static String getContent(Logger logger, BridgeRemoteBaseHandler linkyBridgeHandler, String url, + HttpClient httpClient, String token) throws LinkyException { try { 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.TEMPORARY_REDIRECT_307 || result.getStatus() == HttpStatus.MOVED_TEMPORARILY_302) { @@ -256,7 +124,7 @@ public class EnedisHttpApi { if (loc.startsWith("http://") || loc.startsWith("https://")) { newUrl = loc; } else { - newUrl = URL_APPS_LINCS + loc; + newUrl = linkyBridgeHandler.getBaseUrl() + loc.substring(1); } request = httpClient.newRequest(newUrl); @@ -273,10 +141,10 @@ public class EnedisHttpApi { return urlParts[3]; } } - - if (result.getStatus() != HttpStatus.OK_200) { + 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; @@ -285,55 +153,154 @@ public class EnedisHttpApi { } } - private T getData(String url, Class clazz) throws LinkyException { - if (!connected) { - initialize(); + private T getData(ThingBaseRemoteHandler 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 { - T result = Objects.requireNonNull(gson.fromJson(data, clazz)); - logger.trace("getData success {}: {}", clazz.getName(), url); - return result; - } 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 { + T result = Objects.requireNonNull(gson.fromJson(data, clazz)); + logger.trace("getData success {}: {}", clazz.getName(), url); + return result; + } 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(ThingLinkyRemoteHandler 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]; + + if (prmId.isBlank()) { + 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 - + "?embed=SITALI&embed=SITCOM&embed=SITCON&embed=SYNCON"; - return getData(url, PrmDetail.class); - } - - public UserInfo getUserInfo() throws LinkyException { - return getData(USER_INFO_URL, UserInfo.class); - } - - private Consumption getMeasures(String userId, String prmId, LocalDate from, LocalDate to, String request) + public PrmDetail getPrmDetails(ThingLinkyRemoteHandler handler, String internId, String prmId) throws LinkyException { - String url = String.format(MEASURE_URL, userId, prmId, request, from.format(API_DATE_FORMAT)); - ConsumptionReport report = getData(url, ConsumptionReport.class); - return report.consumptions; + String prmInfoUrl = linkyBridgeHandler.getContractUrl(); + String url = prmInfoUrl.formatted(internId) + "/" + prmId + + "?embed=SITALI&embed=SITCOM&embed=SITCON&embed=SYNCON"; + return getData(handler, url, PrmDetail.class); } - public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException { - return getMeasures(userId, prmId, from, to, "ENERGIE"); + public UserInfo getUserInfo(ThingLinkyRemoteHandler handler) throws LinkyException { + String userInfoUrl = linkyBridgeHandler.getContactUrl(); + return getData(handler, userInfoUrl, UserInfo.class); } - public Consumption getPowerData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException { - return getMeasures(userId, prmId, from, to, "PMAX"); + public String formatUrl(String apiUrl, String prmId) { + return apiUrl.formatted(prmId); + } + + public Contract getContract(ThingLinkyRemoteHandler 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(ThingLinkyRemoteHandler 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(ThingLinkyRemoteHandler 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(ThingLinkyRemoteHandler handler, String prmId) throws LinkyException { + String contactUrl = linkyBridgeHandler.getContactUrl().formatted(prmId); + ResponseContact contactResponse = getData(handler, contactUrl, ResponseContact.class); + return contactResponse.contact; + } + + private MeterReading getMeasures(ThingLinkyRemoteHandler 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(ThingLinkyRemoteHandler handler, String mps, String prmId, LocalDate from, + LocalDate to) throws LinkyException { + return getMeasures(handler, linkyBridgeHandler.getDailyConsumptionUrl(), mps, prmId, from, to); + } + + public MeterReading getLoadCurveData(ThingLinkyRemoteHandler handler, String mps, String prmId, LocalDate from, + LocalDate to) throws LinkyException { + return getMeasures(handler, linkyBridgeHandler.getLoadCurveUrl(), mps, prmId, from, to); + } + + public MeterReading getPowerData(ThingLinkyRemoteHandler handler, String mps, String prmId, LocalDate from, + LocalDate to) throws LinkyException { + return getMeasures(handler, linkyBridgeHandler.getMaxPowerUrl(), mps, prmId, from, to); + } + + public ResponseTempo getTempoData(ThingBaseRemoteHandler 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/api/ExpiringDayCache.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/ExpiringDayCache.java index 5621534a75d..11977c63739 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/ExpiringDayCache.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/api/ExpiringDayCache.java @@ -44,11 +44,11 @@ public class ExpiringDayCache { private final String name; private final int beginningHour; private final int beginningMinute; - private Supplier<@Nullable V> action; private @Nullable V value; private LocalDateTime expiresAt; + public boolean missingData = false; /** * Create a new instance. @@ -80,6 +80,18 @@ public class ExpiringDayCache { return Optional.ofNullable(cachedValue); } + /** + * Returns if the value is Present or not. + */ + public boolean isPresent() { + V cachedValue = value; + return (cachedValue != null && !isExpired()); + } + + public void invalidate() { + value = null; + } + /** * Refreshes and returns the value in the cache. * diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyBridgeApiConfiguration.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyBridgeApiConfiguration.java new file mode 100644 index 00000000000..3085c75ad4a --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyBridgeApiConfiguration.java @@ -0,0 +1,34 @@ +/* + * 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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LinkyBridgeApiConfiguration} 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 LinkyBridgeApiConfiguration extends LinkyBridgeConfiguration { + public String clientId = ""; + public String clientSecret = ""; + public boolean isSandbox = false; + + @Override + public boolean seemsValid() { + return !clientId.isBlank() && !clientSecret.isBlank(); + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyBridgeConfiguration.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyBridgeConfiguration.java new file mode 100644 index 00000000000..7f001fbfe05 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyBridgeConfiguration.java @@ -0,0 +1,30 @@ +/* + * 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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.config.core.Configuration; + +/** + * The {@link LinkyBridgeConfiguration} 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 abstract class LinkyBridgeConfiguration extends Configuration { + public static final String INTERNAL_AUTH_ID = "internalAuthId"; + + public abstract boolean seemsValid(); +} 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/config/LinkyBridgeWebConfiguration.java similarity index 74% rename from bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyConfiguration.java rename to bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyBridgeWebConfiguration.java index 6021cae5cea..0620c2b8d8d 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/config/LinkyBridgeWebConfiguration.java @@ -10,24 +10,26 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.linky.internal; +package org.openhab.binding.linky.internal.config; import org.eclipse.jdt.annotation.NonNullByDefault; /** - * The {@link LinkyConfiguration} is the class used to match the + * The {@link LinkyBridgeWebConfiguration} 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 LinkyBridgeWebConfiguration extends LinkyBridgeConfiguration { public static final String INTERNAL_AUTH_ID = "internalAuthId"; + public String username = ""; public String password = ""; public String internalAuthId = ""; - public String timezone = ""; + @Override public boolean seemsValid() { return !username.isBlank() && !password.isBlank() && !internalAuthId.isBlank(); } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyThingConfiguration.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyThingConfiguration.java new file mode 100644 index 00000000000..c7f239418b7 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyThingConfiguration.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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.config.core.Configuration; + +/** + * The {@link LinkyThingConfiguration} 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 abstract class LinkyThingConfiguration extends Configuration { + public String prmId = ""; + + public boolean seemsValid() { + return true; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyThingRemoteConfiguration.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyThingRemoteConfiguration.java new file mode 100644 index 00000000000..1a8295dad28 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/config/LinkyThingRemoteConfiguration.java @@ -0,0 +1,28 @@ +/* + * 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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link LinkyThingRemoteConfiguration} 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 LinkyThingRemoteConfiguration extends LinkyThingConfiguration { + public String token = ""; + public String timezone = ""; +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/console/LinkyCommandExtension.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/console/LinkyCommandExtension.java index d178b3dd026..158f084cb84 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/console/LinkyCommandExtension.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/console/LinkyCommandExtension.java @@ -21,8 +21,8 @@ import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.linky.internal.LinkyBindingConstants; -import org.openhab.binding.linky.internal.handler.LinkyHandler; +import org.openhab.binding.linky.internal.constants.LinkyBindingConstants; +import org.openhab.binding.linky.internal.handler.ThingLinkyRemoteHandler; import org.openhab.core.io.console.Console; import org.openhab.core.io.console.ConsoleCommandCompleter; import org.openhab.core.io.console.StringsCompleter; @@ -62,10 +62,10 @@ public class LinkyCommandExtension extends AbstractConsoleCommandExtension imple if (args.length >= 2) { Thing thing = getThing(args[0]); ThingHandler thingHandler = null; - LinkyHandler handler = null; + ThingLinkyRemoteHandler handler = null; if (thing != null) { thingHandler = thing.getHandler(); - if (thingHandler instanceof LinkyHandler linkyHandler) { + if (thingHandler instanceof ThingLinkyRemoteHandler linkyHandler) { handler = linkyHandler; } } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/constants/LinkyBindingConstants.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/constants/LinkyBindingConstants.java new file mode 100644 index 00000000000..bccd2b5d1d3 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/constants/LinkyBindingConstants.java @@ -0,0 +1,137 @@ +/* + * 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.constants; + +import java.util.Currency; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link LinkyBindingConstants} class defines common constants, which are + * 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 { + + 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-api"); + public static final ThingTypeUID THING_TYPE_API_MYELECTRICALDATA_BRIDGE = new ThingTypeUID(BINDING_ID, + "my-electrical-data"); + public static final ThingTypeUID THING_TYPE_WEB_ENEDIS_BRIDGE = new ThingTypeUID(BINDING_ID, "enedis"); + public static final ThingTypeUID THING_TYPE_LINKY = new ThingTypeUID(BINDING_ID, "linky"); + + public static final ThingTypeUID THING_TYPE_TEMPO_CALENDAR = new ThingTypeUID(BINDING_ID, "tempo-calendar"); + + public static final Set SUPPORTED_DEVICE_THING_TYPES_UIDS = Set.of(THING_TYPE_API_ENEDIS_BRIDGE, + THING_TYPE_WEB_ENEDIS_BRIDGE, THING_TYPE_API_MYELECTRICALDATA_BRIDGE, THING_TYPE_LINKY, + THING_TYPE_TEMPO_CALENDAR); + + // 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 = "customerId"; + public static final String AV2_ID = "av2_interne_id"; + + public static final String LINKY_REMOTE_DAILY_GROUP = "daily"; + public static final String LINKY_REMOTE_WEEKLY_GROUP = "weekly"; + public static final String LINKY_REMOTE_MONTHLY_GROUP = "monthly"; + public static final String LINKY_REMOTE_YEARLY_GROUP = "yearly"; + public static final String LINKY_TEMPO_CALENDAR_GROUP = "tempo-calendar"; + public static final String LINKY_REMOTE_LOAD_CURVE_GROUP = "load-curve"; + + // List of all Channel id's + public static final String CHANNEL_CONSUMPTION = "consumption"; + public static final String CHANNEL_MAX_POWER = "max-power"; + public static final String CHANNEL_POWER = "power"; + public static final String CHANNEL_TIMESTAMP_CHANNEL = "power"; + + public static final String CHANNEL_DAY_MINUS_1 = "yesterday"; + public static final String CHANNEL_DAY_MINUS_2 = "day-2"; + public static final String CHANNEL_DAY_MINUS_3 = "day-3"; + + public static final String CHANNEL_PEAK_POWER_DAY_MINUS_1 = "power"; + public static final String CHANNEL_PEAK_POWER_TS_DAY_MINUS_1 = "timestamp"; + + public static final String CHANNEL_PEAK_POWER_DAY_MINUS_2 = "power-2"; + public static final String CHANNEL_PEAK_POWER_TS_DAY_MINUS_2 = "timestamp-2"; + + public static final String CHANNEL_PEAK_POWER_DAY_MINUS_3 = "power-3"; + public static final String CHANNEL_PEAK_POWER_TS_DAY_MINUS_3 = "timestamp-3"; + + public static final String CHANNEL_WEEK_MINUS_0 = "thisWeek"; + public static final String CHANNEL_WEEK_MINUS_1 = "lastWeek"; + public static final String CHANNEL_WEEK_MINUS_2 = "week-2"; + + public static final String CHANNEL_MONTH_MINUS_0 = "thisMonth"; + public static final String CHANNEL_MONTH_MINUS_1 = "lastMonth"; + public static final String CHANNEL_MONTH_MINUS_2 = "month-2"; + + public static final String CHANNEL_YEAR_MINUS_0 = "thisYear"; + public static final String CHANNEL_YEAR_MINUS_1 = "lastYear"; + public static final String CHANNEL_YEAR_MINUS_2 = "year-2"; + + public static final String CHANNEL_TEMPO_TODAY_INFO = "tempo-info-today"; + public static final String CHANNEL_TEMPO_TOMORROW_INFO = "tempo-info-tomorrow"; + public static final String CHANNEL_TEMPO_TEMPO_INFO_TIME_SERIES = "tempo-info-timeseries"; + + public static final String PROPERTY_IDENTITY = "identity"; + + public static final String PROPERTY_CONTRACT_LAST_ACTIVATION_DATE = "contractLastActivationdate"; + public static final String PROPERTY_CONTRACT_DISTRIBUTION_TARIFF = "contractDistributionTariff"; + public static final String PROPERTY_CONTRACT_OFF_PEAK_HOURS = "contractOffpeakHours"; + public static final String PROPERTY_CONTRACT_CONTRACT_STATUS = "contractStatus"; + public static final String PROPERTY_CONTRACT_CONTRACT_TYPE = "contractType"; + public static final String PROPERTY_CONTRACT_LAST_DISTRIBUTION_TARIFF_CHANGE_DATE = "contractLastdistributionTariffChangedate"; + public static final String PROPERTY_CONTRACT_SEGMENT = "contractSegment"; + public static final String PROPERTY_CONTRACT_SUBSCRIBED_POWER = "contractSubscribedPower"; + + public static final String PROPERTY_USAGEPOINT_ID = "usagePointId"; + public static final String PROPERTY_USAGEPOINT_STATUS = "usagePointStatus"; + public static final String PROPERTY_USAGEPOINT_METER_TYPE = "usagePointMeterType"; + + public static final String PROPERTY_USAGEPOINT_METER_ADDRESS_CITY = "usagePointCity"; + public static final String PROPERTY_USAGEPOINT_METER_ADDRESS_COUNTRY = "usagePointCountry"; + public static final String PROPERTY_USAGEPOINT_METER_ADDRESS_POSTAL_CODE = "usagePointPostalCode"; + public static final String PROPERTY_USAGEPOINT_METER_ADDRESS_STREET = "usagePointStreet"; + + public static final String PROPERTY_CONTACT_MAIL = "contactMail"; + public static final String PROPERTY_CONTACT_PHONE = "contactPhone"; + + // Authorization related Servlet and resources aliases. + public static final String LINKY_ALIAS = "/connectlinky"; + public static final String LINKY_IMG_ALIAS = "/img"; + + // List of all Channel ids + + public static final Currency CURRENCY_EUR = Currency.getInstance("EUR"); + + public static final String ERROR_OFFLINE_SERIAL_NOT_FOUND = "@text/linky.thingstate.serial_notfound"; + public static final String ERROR_OFFLINE_SERIAL_INUSE = "@text/linky.thingstate.serial_inuse"; + public static final String ERROR_OFFLINE_SERIAL_UNSUPPORTED = "@text/linky.thingstate.serial_unsupported"; + public static final String ERROR_OFFLINE_SERIAL_LISTENERS = "@text/linky.thingstate.serial_listeners"; + public static final String ERROR_OFFLINE_CONTROLLER_OFFLINE = "@text/linky.thingstate.controller_offline"; + public static final String ERROR_UNKNOWN_RETRY_IN_PROGRESS = "@text/linky.thingstate.controller_unknown_retry_inprogress"; + + /** + * 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/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 6f38eee8663..10348728252 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 @@ -40,6 +40,8 @@ public class ConsumptionReport { } public class ChronoData { + @SerializedName("heure") + public Aggregate heure; @SerializedName("jour") public Aggregate days; @SerializedName("semaine") 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..6b1b5c09374 --- /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 Contact} holds informations about the contact of a contract + * + * @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..de04bdd58fe --- /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 Contract} holds informations about the supplier contract + * + * @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..34d85016899 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/Identity.java @@ -0,0 +1,39 @@ +/* + * 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 Identity} holds the informations about the contractor identity + * + * @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 String internId; + + public static Identity convertFromUserInfo(UserInfo userInfo) { + Identity result = new Identity(); + + result.firstname = userInfo.userProperties.firstName; + result.lastname = userInfo.userProperties.name; + result.title = ""; + result.internId = userInfo.userProperties.internId; + + 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..31bc99aba91 --- /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 IntervalReading} holds informations for the energy consumption of a period + * + * @author Gaël L'hopital - Initial contribution + * @author Laurent Arnal - Rewrite addon to use official dataconect API + */ + +public class IntervalReading { + public Double value = 0.0; + public LocalDateTime date; +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/MetaData.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/MetaData.java new file mode 100644 index 00000000000..5f0cc1b1833 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/MetaData.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; + +/** + * The {@link MetaData} holds authentication information + * + * @author Gaël L'hopital - Initial contribution + */ + +public class MetaData { + public Identity identity; + public Contact contact; + public Contract contract; + public UsagePoint usagePoint; +} 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..92d849e777e --- /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 MeterReading} holds informations about energy consumption + * + * @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/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..0b71fb81547 --- /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 ReadingType} the type info associate with a meter reading + * + * @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..e3cdd5ca0c8 --- /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 ResponseContact} holds informations about the person contact associate with a contract + * + * @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..371e2a8be80 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/dto/UsagePoint.java @@ -0,0 +1,70 @@ +/* + * 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.Arrays; + +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(); + String[] cityParts = prmDetail.adresse.ligne6().split(" "); + result.usagePointAddresses.city = String.join(" ", Arrays.copyOfRange(cityParts, 1, cityParts.length)); + 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/LinkyHandlerFactory.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/factory/LinkyHandlerFactory.java similarity index 56% rename from bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyHandlerFactory.java rename to bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/factory/LinkyHandlerFactory.java index 36ccf55c3c6..e8a0555b21d 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/factory/LinkyHandlerFactory.java @@ -10,13 +10,11 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.linky.internal; +package org.openhab.binding.linky.internal.factory; import static java.time.temporal.ChronoField.*; -import static org.openhab.binding.linky.internal.LinkyBindingConstants.THING_TYPE_LINKY; +import static org.openhab.binding.linky.internal.constants.LinkyBindingConstants.*; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZonedDateTime; @@ -24,19 +22,19 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; -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.handler.LinkyHandler; +import org.openhab.binding.linky.internal.handler.BridgeRemoteEnedisHandler; +import org.openhab.binding.linky.internal.handler.BridgeRemoteEnedisWebHandler; +import org.openhab.binding.linky.internal.handler.BridgeRemoteMyElectricalDataHandler; +import org.openhab.binding.linky.internal.handler.ThingLinkyRemoteHandler; +import org.openhab.binding.linky.internal.handler.ThingTempoCalendarHandler; import org.openhab.binding.linky.internal.utils.DoubleTypeAdapter; +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.ThingTypeUID; import org.openhab.core.thing.binding.BaseThingHandlerFactory; @@ -46,8 +44,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; @@ -57,20 +54,23 @@ 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 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(); + .appendPattern("uuuu-MM-dd['T'][' ']HH:mm").optionalStart().appendLiteral(':') + .appendValue(SECOND_OF_MINUTE, 2).optionalStart().appendFraction(NANO_OF_SECOND, 0, 9, true).toFormatter(); - private static final int REQUEST_BUFFER_SIZE = 8000; - private static final int RESPONSE_BUFFER_SIZE = 200000; + private final HttpClientFactory httpClientFactory; + private final OAuthFactory oAuthFactory; + private final HttpService httpService; + private final ComponentContext componentContext; + private final TimeZoneProvider timeZoneProvider; - private final Logger logger = LoggerFactory.getLogger(LinkyHandlerFactory.class); private final Gson gson = new GsonBuilder() .registerTypeAdapter(ZonedDateTime.class, (JsonDeserializer) (json, type, jsonDeserializationContext) -> ZonedDateTime @@ -88,62 +88,55 @@ public class LinkyHandlerFactory extends BaseThingHandlerFactory { .atStartOfDay(); } }) - .registerTypeAdapter(Double.class, new DoubleTypeAdapter()).serializeNulls().create(); + .registerTypeAdapter(Double.class, new DoubleTypeAdapter()).create(); + private final LocaleProvider localeProvider; - private final HttpClient httpClient; - private final TimeZoneProvider timeZoneProvider; @Activate public LinkyHandlerFactory(final @Reference LocaleProvider localeProvider, - final @Reference HttpClientFactory httpClientFactory, final @Reference TimeZoneProvider timeZoneProvider) { + final @Reference HttpClientFactory httpClientFactory, final @Reference OAuthFactory oAuthFactory, + final @Reference HttpService httpService, ComponentContext componentContext, + final @Reference TimeZoneProvider timeZoneProvider) { this.localeProvider = localeProvider; this.timeZoneProvider = timeZoneProvider; - 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.httpClientFactory = httpClientFactory; + this.oAuthFactory = oAuthFactory; + this.httpService = httpService; + 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, timeZoneProvider) - : null; + if (THING_TYPE_API_ENEDIS_BRIDGE.equals(thing.getThingTypeUID())) { + BridgeRemoteEnedisHandler handler = new BridgeRemoteEnedisHandler((Bridge) thing, this.httpClientFactory, + this.oAuthFactory, this.httpService, componentContext, gson); + return handler; + } else if (THING_TYPE_WEB_ENEDIS_BRIDGE.equals(thing.getThingTypeUID())) { + BridgeRemoteEnedisWebHandler handler = new BridgeRemoteEnedisWebHandler((Bridge) thing, + this.httpClientFactory, this.oAuthFactory, this.httpService, componentContext, gson); + return handler; + } else if (THING_TYPE_API_MYELECTRICALDATA_BRIDGE.equals(thing.getThingTypeUID())) { + BridgeRemoteMyElectricalDataHandler handler = new BridgeRemoteMyElectricalDataHandler((Bridge) thing, + this.httpClientFactory, this.oAuthFactory, this.httpService, componentContext, gson); + return handler; + } else if (THING_TYPE_LINKY.equals(thing.getThingTypeUID())) { + ThingLinkyRemoteHandler handler = new ThingLinkyRemoteHandler(thing, localeProvider, timeZoneProvider); + return handler; + } else if (THING_TYPE_TEMPO_CALENDAR.equals(thing.getThingTypeUID())) { + ThingHandler handler = new ThingTempoCalendarHandler(thing); + return handler; + } + + return null; } } diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteApiHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteApiHandler.java new file mode 100644 index 00000000000..8204fdaebb4 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteApiHandler.java @@ -0,0 +1,210 @@ +/* + * 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 java.util.Objects; + +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.config.LinkyBridgeApiConfiguration; +import org.openhab.binding.linky.internal.constants.LinkyBindingConstants; +import org.openhab.binding.linky.internal.helpers.LinkyAuthServlet; +import org.openhab.binding.linky.internal.types.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.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +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 BridgeRemoteApiHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + */ +@NonNullByDefault +public abstract class BridgeRemoteApiHandler extends BridgeRemoteBaseHandler { + private final Logger logger = LoggerFactory.getLogger(BridgeRemoteApiHandler.class); + + private final OAuthFactory oAuthFactory; + + private @Nullable OAuthClientService oAuthService; + + private static @Nullable HttpServlet servlet; + + protected String tokenUrl = ""; + protected String authorizeUrl = ""; + + public BridgeRemoteApiHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + ComponentContext componentContext, Gson gson) { + super(bridge, httpClientFactory, oAuthFactory, httpService, componentContext, gson); + + this.oAuthFactory = oAuthFactory; + + updateStatus(ThingStatus.UNKNOWN); + } + + @Override + public void initialize() { + super.initialize(); + + config = getConfigAs(LinkyBridgeApiConfiguration.class); + + if (Objects.requireNonNull(config).seemsValid()) { + this.oAuthService = oAuthFactory.createOAuthClientService(LinkyBindingConstants.BINDING_ID, tokenUrl, + authorizeUrl, getClientId(), getClientSecret(), LinkyBindingConstants.LINKY_SCOPES, true); + + registerServlet(); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.config-error-mandatory-settings"); + } + } + + 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/BridgeRemoteBaseHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteBaseHandler.java new file mode 100644 index 00000000000..4ff0bf05956 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteBaseHandler.java @@ -0,0 +1,196 @@ +/* + * 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.api.EnedisHttpApi; +import org.openhab.binding.linky.internal.config.LinkyBridgeConfiguration; +import org.openhab.binding.linky.internal.constants.LinkyBindingConstants; +import org.openhab.binding.linky.internal.types.LinkyException; +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.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 BridgeRemoteBaseHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + */ +@NonNullByDefault +public abstract class BridgeRemoteBaseHandler extends BaseBridgeHandler { + private final Logger logger = LoggerFactory.getLogger(BridgeRemoteBaseHandler.class); + + protected final HttpService httpService; + protected final BundleContext bundleContext; + protected final HttpClient httpClient; + protected final EnedisHttpApi enedisApi; + + protected final Gson gson; + + protected @Nullable LinkyBridgeConfiguration 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 BridgeRemoteBaseHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + 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.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); + + this.enedisApi = new EnedisHttpApi(this, gson, this.httpClient); + } + + public BundleContext getBundleContext() { + return bundleContext; + } + + @Override + public synchronized void initialize() { + logger.debug("Initializing Linky Remote bridge handler."); + + updateStatus(ThingStatus.UNKNOWN); + + scheduler.submit(() -> { + try { + httpClient.start(); + + try { + connectionInit(); + updateStatus(ThingStatus.ONLINE); + } catch (LinkyException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + } catch (Exception e) { + logger.warn("Unable to start Jetty HttpClient {}", e.getMessage()); + 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(ThingBaseRemoteHandler 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(); + + public Gson getGson() { + return gson; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteEnedisHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteEnedisHandler.java new file mode 100644 index 00000000000..acb014d60d4 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteEnedisHandler.java @@ -0,0 +1,206 @@ +/* + * 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.Instant; +import java.time.format.DateTimeFormatter; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.linky.internal.config.LinkyBridgeApiConfiguration; +import org.openhab.binding.linky.internal.types.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.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 BridgeRemoteEnedisHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + */ +@NonNullByDefault +public class BridgeRemoteEnedisHandler extends BridgeRemoteApiHandler { + private final Logger logger = LoggerFactory.getLogger(BridgeRemoteEnedisHandler.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 BridgeRemoteEnedisHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + ComponentContext componentContext, Gson gson) { + super(bridge, httpClientFactory, oAuthFactory, httpService, componentContext, gson); + } + + @Override + public void initialize() { + tokenUrl = getBaseUrl() + BridgeRemoteEnedisHandler.ENEDIS_API_TOKEN_URL; + authorizeUrl = getAccountUrl() + BridgeRemoteEnedisHandler.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() { + LinkyBridgeApiConfiguration lcConfig = (LinkyBridgeApiConfiguration) config; + if (lcConfig != null) { + return lcConfig.clientId; + } + return ""; + } + + @Override + public String getClientSecret() { + LinkyBridgeApiConfiguration lcConfig = (LinkyBridgeApiConfiguration) config; + if (lcConfig != null) { + return lcConfig.clientSecret; + } + return ""; + } + + @Override + public boolean getIsSandbox() { + LinkyBridgeApiConfiguration lcConfig = (LinkyBridgeApiConfiguration) 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(ThingBaseRemoteHandler handler) throws LinkyException { + AccessTokenResponse accesToken = getAccessTokenResponse(); + + // Store token is about to expire, ask for a new one. + if (accesToken != null && accesToken.isExpired(Instant.now(), 1200)) { + accesToken = null; + } + + 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/BridgeRemoteEnedisWebHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteEnedisWebHandler.java new file mode 100644 index 00000000000..0d46ec4c5ef --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteEnedisWebHandler.java @@ -0,0 +1,358 @@ +/* + * 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 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.openhab.binding.linky.internal.config.LinkyBridgeWebConfiguration; +import org.openhab.binding.linky.internal.dto.AuthData; +import org.openhab.binding.linky.internal.dto.AuthResult; +import org.openhab.binding.linky.internal.types.LinkyException; +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.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +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 BridgeRemoteEnedisHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + * + */ +@NonNullByDefault +public class BridgeRemoteEnedisWebHandler extends BridgeRemoteBaseHandler { + private final Logger logger = LoggerFactory.getLogger(BridgeRemoteEnedisWebHandler.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 String BASE_MYELECT_URL = "https://www.myelectricaldata.fr/"; + private static final String TEMPO_URL = BASE_MYELECT_URL + "rte/tempo/%s/%s"; + + public BridgeRemoteEnedisWebHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + ComponentContext componentContext, Gson gson) { + super(bridge, httpClientFactory, oAuthFactory, httpService, componentContext, gson); + } + + @Override + public void initialize() { + super.initialize(); + + config = getConfigAs(LinkyBridgeWebConfiguration.class); + if (!Objects.requireNonNull(config).seemsValid()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.config-error-mandatory-settings"); + } + } + + @Override + public String getToken(ThingBaseRemoteHandler 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 { + LinkyBridgeWebConfiguration lcConfig = (LinkyBridgeWebConfiguration) config; + if (lcConfig == null) { + return; + } + + logger.debug("Starting login process for user: {}", lcConfig.username); + + try { + ContentResponse result = null; + String uri = ""; + String gotoUri = ""; + + // has we reconnect, remove all previous cookie to start from fresh session + enedisApi.removeAllCookie(); + + enedisApi.addCookie(LinkyBridgeWebConfiguration.INTERNAL_AUTH_ID, lcConfig.internalAuthId); + + // ====================================================== + logger.debug("Step 1a: getting authentification"); + // ====================================================== + uri = URL_ENEDIS_AUTHENTICATE; + result = httpClient.GET(uri); + + if (result.getStatus() != HttpStatus.MOVED_TEMPORARILY_302) { + throw new LinkyException("Connection failed step 1a - auth1: %d %s", result.getStatus(), + result.getContentAsString()); + } + + // ====================================================== + logger.debug("Step 1b: ..."); + // ====================================================== + uri = BASE_URL + result.getHeaders().get("Location"); + result = httpClient.GET(uri); + + if (result.getStatus() != HttpStatus.MOVED_TEMPORARILY_302) { + throw new LinkyException("Connection failed step 1b - auth1: %d %s", result.getStatus(), + result.getContentAsString()); + } + + // ====================================================== + logger.debug("Step 1c: ..."); + // ====================================================== + uri = result.getHeaders().get("Location"); + + result = httpClient.GET(uri); + + if (result.getStatus() != HttpStatus.MOVED_TEMPORARILY_302) { + throw new LinkyException("Connection failed step 1c - auth1: %d %s", result.getStatus(), + result.getContentAsString()); + } + + // ====================================================== + logger.debug("Step 1d: ..."); + // ====================================================== + uri = result.getHeaders().get("Location"); + int idx = uri.indexOf("goto="); + gotoUri = uri.substring(idx + 5); + + result = httpClient.GET(uri); + + if (result.getStatus() != HttpStatus.MOVED_TEMPORARILY_302) { + throw new LinkyException("Connection failed step 1d - auth1: %d %s", result.getStatus(), + result.getContentAsString()); + } + + // ====================================================== + logger.debug("Step 1e: ..."); + // ====================================================== + uri = URL_MON_COMPTE + result.getHeaders().get("Location"); + result = httpClient.GET(uri); + + if (result.getStatus() != HttpStatus.OK_200) { + throw new LinkyException("Connection failed step 1e - auth1: %d %s", result.getStatus(), + result.getContentAsString()); + } + + // ====================================================== + logger.debug("Step 2: auth1 - retrieve the template, thanks to cookie internalAuthId user is already set"); + // ====================================================== + uri = URL_MON_COMPTE + "/auth/json/authenticate?realm=/enedis&goto=" + gotoUri; + + result = httpClient.POST(uri).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) { + if (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 3: auth2 - send the auth data"); + // ====================================================== + result = httpClient.POST(uri).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); + + logger.debug("Add the tokenId cookie"); + if (authResult == null) { + throw new LinkyException("Errors on step3 : authResult=null"); + } + + enedisApi.addCookie("enedisExt", authResult.tokenId); + // ====================================================== + logger.debug("Step 4a: Confirm login"); + // ====================================================== + uri = authResult.successUrl; + result = httpClient.GET(uri); + if (result.getStatus() != HttpStatus.MOVED_TEMPORARILY_302) { + throw new LinkyException("Connection failed step 4a - auth2: %d %s", result.getStatus(), + result.getContentAsString()); + } + + // ====================================================== + logger.debug("Step 4b:Confirm login"); + // ====================================================== + uri = result.getHeaders().get("Location"); + result = httpClient.GET(uri); + if (result.getStatus() != HttpStatus.MOVED_TEMPORARILY_302) { + throw new LinkyException("Connection failed step 4b - auth2: %d %s", result.getStatus(), + result.getContentAsString()); + } + + // ====================================================== + logger.debug("Step 4c: Confirm login"); + // ====================================================== + uri = BASE_URL + "/authenticate?target=https://mon-compte-client.enedis.fr%2Fhub%3FallEspace%3Dfalse"; + // "result.getHeaders().get("Location"); + + result = httpClient.GET(uri); + if (result.getStatus() != HttpStatus.TEMPORARY_REDIRECT_307) { + throw new LinkyException("Connection failed step 4c - auth2: %d %s", result.getStatus(), + result.getContentAsString()); + } + + // =========================================================== + logger.debug("Step 5: retrieve user information andd cookie"); + // =========================================================== + 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/BridgeRemoteMyElectricalDataHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteMyElectricalDataHandler.java new file mode 100644 index 00000000000..f27dd895e4b --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/BridgeRemoteMyElectricalDataHandler.java @@ -0,0 +1,219 @@ +/* + * 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.api.EnedisHttpApi; +import org.openhab.binding.linky.internal.config.LinkyThingRemoteConfiguration; +import org.openhab.binding.linky.internal.constants.LinkyBindingConstants; +import org.openhab.binding.linky.internal.types.LinkyException; +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.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 BridgeRemoteMyElectricalDataHandler} is the base handler to access enedis data. + * + * @author Laurent Arnal - Initial contribution + */ +@NonNullByDefault +public class BridgeRemoteMyElectricalDataHandler extends BridgeRemoteApiHandler { + private final Logger logger = LoggerFactory.getLogger(BridgeRemoteMyElectricalDataHandler.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 = BridgeRemoteEnedisHandler.ENEDIS_ACCOUNT_URL_PROD + + BridgeRemoteEnedisHandler.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 BridgeRemoteMyElectricalDataHandler(Bridge bridge, final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory, final @Reference HttpService httpService, + ComponentContext componentContext, Gson gson) { + super(bridge, httpClientFactory, oAuthFactory, httpService, componentContext, gson); + } + + @Override + public void connectionInit() { + connected = true; + } + + @Override + public void initialize() { + tokenUrl = BridgeRemoteMyElectricalDataHandler.LINKY_MYELECTRICALDATA_API_TOKEN_URL; + authorizeUrl = BridgeRemoteMyElectricalDataHandler.LINKY_MYELECTRICALDATA_AUTHORIZE_URL; + + super.initialize(); + } + + @Override + public String getClientId() { + return BridgeRemoteMyElectricalDataHandler.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(BridgeRemoteMyElectricalDataHandler.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.getThing().getThings(); + + 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); + ThingLinkyRemoteHandler handler = (ThingLinkyRemoteHandler) 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(ThingBaseRemoteHandler handler) throws LinkyException { + if (handler.getLinkyConfig() instanceof LinkyThingRemoteConfiguration config) { + return config.token; + } + return ""; + } + + @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/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 deleted file mode 100644 index 20f502a9572..00000000000 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/LinkyHandler.java +++ /dev/null @@ -1,569 +0,0 @@ -/* - * 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 static org.openhab.binding.linky.internal.LinkyBindingConstants.*; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoUnit; -import java.time.temporal.WeekFields; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Random; -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.PrmDetail; -import org.openhab.binding.linky.internal.dto.PrmInfo; -import org.openhab.binding.linky.internal.dto.UserInfo; -import org.openhab.core.i18n.LocaleProvider; -import org.openhab.core.i18n.TimeZoneProvider; -import org.openhab.core.library.types.DateTimeType; -import org.openhab.core.library.types.QuantityType; -import org.openhab.core.library.unit.MetricPrefix; -import org.openhab.core.library.unit.Units; -import org.openhab.core.thing.ChannelUID; -import org.openhab.core.thing.Thing; -import org.openhab.core.thing.ThingStatus; -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.UnDefType; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.gson.Gson; - -/** - * The {@link LinkyHandler} is responsible for handling commands, which are - * sent to one of the channels. - * - * @author Gaël L'hopital - Initial contribution - */ - -@NonNullByDefault -public class LinkyHandler extends BaseThingHandler { - private static final Random randomNumbers = new Random(); - private static final int REFRESH_HOUR_OF_DAY = 1; - private static final int REFRESH_MINUTE_OF_DAY = randomNumbers.nextInt(60); - private static final int REFRESH_INTERVAL_IN_MIN = 120; - - private final TimeZoneProvider timeZoneProvider; - 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 ZoneId zoneId = ZoneId.systemDefault(); - - private @Nullable ScheduledFuture refreshJob; - private @Nullable EnedisHttpApi enedisApi; - - private @NonNullByDefault({}) String prmId; - private @NonNullByDefault({}) String userId; - - private enum Target { - FIRST, - LAST, - ALL - } - - public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient, - TimeZoneProvider timeZoneProvider) { - super(thing); - this.gson = gson; - this.httpClient = httpClient; - this.weekFields = WeekFields.of(localeProvider.getLocale()); - this.timeZoneProvider = timeZoneProvider; - - this.cachedDailyData = new ExpiringDayCache<>("daily cache", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_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); - } - return consumption; - }); - - this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_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. - 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; - }); - - this.cachedMonthlyData = new ExpiringDayCache<>("monthly cache", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_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); - } - return consumption; - }); - - this.cachedYearlyData = new ExpiringDayCache<>("yearly cache", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_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; - }); - } - - @Override - public void initialize() { - logger.debug("Initializing Linky handler."); - updateStatus(ThingStatus.UNKNOWN); - - LinkyConfiguration config = getConfigAs(LinkyConfiguration.class); - if (config.seemsValid()) { - - if (config.timezone.isBlank()) { - zoneId = this.timeZoneProvider.getTimeZone(); - } else { - zoneId = ZoneId.of(config.timezone); - } - - enedisApi = new EnedisHttpApi(config, gson, httpClient); - scheduler.submit(() -> { - try { - EnedisHttpApi api = this.enedisApi; - api.initialize(); - updateStatus(ThingStatus.ONLINE); - - updateData(); - - disconnect(); - - final LocalDateTime now = LocalDateTime.now(); - final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_HOUR_OF_DAY) - .withMinute(REFRESH_MINUTE_OF_DAY).truncatedTo(ChronoUnit.MINUTES); - - 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()); - } - }); - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "@text/offline.config-error-mandatory-settings"); - } - } - - private synchronized void updateMetaData() throws LinkyException { - EnedisHttpApi api = this.enedisApi; - if (api != null) { - 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); - } - } - - /** - * Request new data and updates channels - */ - private synchronized void updateData() { - boolean connectedBefore = isConnected(); - try { - updateMetaData(); - updatePowerData(); - updateDailyWeeklyData(); - updateMonthlyData(); - updateYearlyData(); - if (!connectedBefore && isConnected()) { - disconnect(); - } - } catch (LinkyException e) { - logger.debug("Exception occurs during data update {}", e.getMessage(), e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - } - } - - 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).valeur); - updateState(PEAK_TIMESTAMP, - new DateTimeType(days.datas.get(days.datas.size() - 1).dateDebut.atZone(zoneId))); - }, () -> { - updateKwhChannel(PEAK_POWER, Double.NaN); - updateState(PEAK_TIMESTAMP, 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).valeur; - if (periods.datas.size() > 1) { - previousValue = periods.datas.get(periods.datas.size() - 2).valeur; - } - } - updateKwhChannel(currentChannel, currentValue); - updateKwhChannel(previousChannel, previousValue); - } - - /** - * 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).valeur); - 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); - } - }); - } - } - - /** - * 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) { - logger.debug("Update channel {} with {}", channelId, consumption); - updateState(channelId, - Double.isNaN(consumption) ? UnDefType.UNDEF : new QuantityType<>(consumption, Units.KILOWATT_HOUR)); - } - - private void updatekVAChannel(String channelId, double power) { - logger.debug("Update channel {} with {}", channelId, power); - updateState(channelId, Double.isNaN(power) ? UnDefType.UNDEF - : new QuantityType<>(power, MetricPrefix.KILO(Units.VOLT_AMPERE))); - } - - /** - * Produce a report of all daily values between two dates - * - * @param startDay the start day of the report - * @param endDay the end day of the report - * @param separator the separator to be used betwwen the date and the value - * - * @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; - } - - private List buildReport(LocalDate startDay, LocalDate endDay, @Nullable String separator) { - 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) ? 0 : days.datas.size(); - for (int i = 0; i < size; i++) { - double consumption = days.datas.get(i).valeur; - LocalDate day = days.datas.get(i).dateDebut.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.datas.get(i).dateDebut.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator; - if (consumption >= 0) { - line += String.valueOf(consumption); - } - report.add(line); - } - } else { - LocalDate currentDay = startDay; - while (!currentDay.isAfter(endDay)) { - report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator); - currentDay = currentDay.plusDays(1); - } - } - } else { - // Concatenate the report produced for each month between the two dates - LocalDate first = startDay; - do { - LocalDate last = first.withDayOfMonth(first.lengthOfMonth()); - if (last.isAfter(endDay)) { - last = endDay; - } - report.addAll(buildReport(first, last, separator)); - first = last.plusDays(1); - } while (!first.isAfter(endDay)); - } - return report; - } - - private @Nullable Consumption 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; - } 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), - 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; - } 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 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()); - } - } - } - - @Override - public void dispose() { - logger.debug("Disposing the Linky handler."); - ScheduledFuture job = this.refreshJob; - if (job != null && !job.isCancelled()) { - job.cancel(true); - refreshJob = null; - } - disconnect(); - enedisApi = null; - } - - @Override - public synchronized void handleCommand(ChannelUID channelUID, Command command) { - if (command instanceof RefreshType) { - logger.debug("Refreshing channel {}", channelUID.getId()); - boolean connectedBefore = isConnected(); - - try { - updateMetaData(); - 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; - } - } catch (LinkyException ex) { - logger.debug("Unable to handleCommand refresh", ex); - } - if (!connectedBefore && isConnected()) { - disconnect(); - } - } else { - logger.debug("The Linky binding is read-only and can not handle command {}", command); - } - } - - private @Nullable Consumption getConsumptionAfterChecks(Consumption consumption, Target target) { - try { - checkData(consumption); - } 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 (target == Target.LAST && !isDataLastDayAvailable(consumption)) { - logger.debug("Data including yesterday are not yet available"); - return null; - } - return consumption; - } - - private void checkData(Consumption consumption) throws LinkyException { - if (consumption.aggregats.days != null && consumption.aggregats.days.datas.isEmpty()) { - throw new LinkyException("Invalid consumptions data: no day period"); - } - if (consumption.aggregats.weeks != null && consumption.aggregats.weeks.datas.isEmpty()) { - throw new LinkyException("Invalid consumptions data: no week period"); - } - if (consumption.aggregats.months != null && consumption.aggregats.months.datas.isEmpty()) { - throw new LinkyException("Invalid consumptions data: no month period"); - } - if (consumption.aggregats.years != null && consumption.aggregats.years.datas.isEmpty()) { - throw new LinkyException("Invalid consumptions data: no year 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).valeur.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).valeur.isNaN(); - } - - private void logData(Aggregate aggregate, String title, boolean withDateFin, DateTimeFormatter dateTimeFormatter, - Target target) { - if (logger.isDebugEnabled()) { - int size = (aggregate.datas == null) ? 0 : aggregate.datas.size(); - if (target == Target.FIRST) { - if (size > 0) { - logData(aggregate, 0, title, withDateFin, dateTimeFormatter); - } - } else if (target == Target.LAST) { - if (size > 0) { - logData(aggregate, size - 1, title, withDateFin, dateTimeFormatter); - } - } else { - for (int i = 0; i < size; i++) { - logData(aggregate, i, title, withDateFin, dateTimeFormatter); - } - } - } - } - - private void logData(Aggregate aggregate, int index, String title, boolean withDateFin, - DateTimeFormatter dateTimeFormatter) { - if (withDateFin) { - logger.debug("{} {} {} value {}", title, aggregate.datas.get(index).dateDebut.format(dateTimeFormatter), - aggregate.datas.get(index).dateFin.format(dateTimeFormatter), aggregate.datas.get(index).valeur); - } else { - logger.debug("{} {} value {}", title, aggregate.datas.get(index).dateDebut.format(dateTimeFormatter), - aggregate.datas.get(index).valeur); - } - } -} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ThingBaseRemoteHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ThingBaseRemoteHandler.java new file mode 100644 index 00000000000..c1273c85d51 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ThingBaseRemoteHandler.java @@ -0,0 +1,53 @@ +/* + * 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 org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.linky.internal.config.LinkyThingRemoteConfiguration; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; + +/** + * The {@link ThingBaseRemoteHandler} 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 ThingBaseRemoteHandler extends BaseThingHandler { + + protected LinkyThingRemoteConfiguration config; + + public ThingBaseRemoteHandler(Thing thing) { + super(thing); + + config = getConfigAs(LinkyThingRemoteConfiguration.class); + } + + @Override + public synchronized void initialize() { + } + + @Override + public synchronized void handleCommand(ChannelUID channelUID, Command command) { + } + + public @Nullable LinkyThingRemoteConfiguration getLinkyConfig() { + return config; + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ThingLinkyRemoteHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ThingLinkyRemoteHandler.java new file mode 100644 index 00000000000..7e1a439127d --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ThingLinkyRemoteHandler.java @@ -0,0 +1,834 @@ +/* + * 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 static org.openhab.binding.linky.internal.constants.LinkyBindingConstants.*; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +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.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import javax.measure.Quantity; +import javax.measure.Unit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.linky.internal.api.EnedisHttpApi; +import org.openhab.binding.linky.internal.api.ExpiringDayCache; +import org.openhab.binding.linky.internal.config.LinkyThingRemoteConfiguration; +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.MetaData; +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.UsagePoint; +import org.openhab.binding.linky.internal.dto.UserInfo; +import org.openhab.binding.linky.internal.types.LinkyException; +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.QuantityType; +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; +import org.openhab.core.thing.ThingStatusDetail; +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; + +/** + * The {@link ThingLinkyRemoteHandler} 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 ThingLinkyRemoteHandler extends ThingBaseRemoteHandler { + private static final Random RANDOM_NUMBERS = new Random(); + private static final int REFRESH_HOUR_OF_DAY = 1; + private static final int REFRESH_MINUTE_OF_DAY = RANDOM_NUMBERS.nextInt(60); + private static final int REFRESH_INTERVAL_IN_MIN = 120; + + private final TimeZoneProvider timeZoneProvider; + private final Logger logger = LoggerFactory.getLogger(ThingLinkyRemoteHandler.class); + + private final ExpiringDayCache metaData; + private final ExpiringDayCache dailyConsumption; + private final ExpiringDayCache dailyConsumptionMaxPower; + private final ExpiringDayCache loadCurveConsumption; + + private ZoneId zoneId = ZoneId.systemDefault(); + private @Nullable ScheduledFuture refreshJob; + private @Nullable EnedisHttpApi enedisApi; + private double divider = 1.00; + + public String userId = ""; + + private @Nullable ScheduledFuture pollingJob = null; + + private enum Target { + FIRST, + LAST, + ALL + } + + public ThingLinkyRemoteHandler(Thing thing, LocaleProvider localeProvider, TimeZoneProvider timeZoneProvider) { + super(thing); + + this.timeZoneProvider = timeZoneProvider; + + this.metaData = new ExpiringDayCache<>("metaData", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_OF_DAY, () -> { + MetaData metaData = getMetaData(); + return metaData; + }); + + this.dailyConsumption = new ExpiringDayCache<>("dailyConsumption", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_OF_DAY, + () -> { + LocalDate today = LocalDate.now(); + 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 meterReading; + }); + + // We request data for yesterday and the day before yesterday + // even if the data for the day before yesterday + // 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_HOUR_OF_DAY, + REFRESH_MINUTE_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; + }); + + // Comsuption Load Curve + this.loadCurveConsumption = new ExpiringDayCache<>("loadCurveConsumption", REFRESH_HOUR_OF_DAY, + REFRESH_MINUTE_OF_DAY, () -> { + LocalDate today = LocalDate.now(); + 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 meterReading; + }); + } + + @Override + public synchronized void initialize() { + logger.debug("Initializing Linky handler for {}", config.prmId); + + // reread config to update timezone field + config = getConfigAs(LinkyThingRemoteConfiguration.class); + + Bridge bridge = getBridge(); + if (bridge == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, "@text/offline.missing-bridge"); + return; + } + + if (bridge.getHandler() instanceof BridgeRemoteBaseHandler bridgeHandler) { + enedisApi = bridgeHandler.getEnedisApi(); + divider = bridgeHandler.getDivider(); + + updateStatus(ThingStatus.UNKNOWN); + + if (config.seemsValid()) { + if (config.timezone.isBlank()) { + zoneId = this.timeZoneProvider.getTimeZone(); + } else { + zoneId = ZoneId.of(config.timezone); + } + + if (bridgeHandler instanceof BridgeRemoteApiHandler && config.prmId.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.config-error-mandatory-settings"); + return; + } + + if (!config.prmId.isBlank()) { + bridgeHandler.registerNewPrmId(config.prmId); + } + pollingJob = scheduler.schedule(this::pollingCode, 5, TimeUnit.SECONDS); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.config-error-mandatory-settings"); + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED); + } + } + + public boolean supportNewApiFormat() throws LinkyException { + Bridge bridge = getBridge(); + if (bridge == null) { + throw new LinkyException("Unable to get bridge in supportNewApiFormat()"); + } + + if (bridge.getHandler() instanceof BridgeRemoteBaseHandler bridgeHandler) { + return bridgeHandler.supportNewApiFormat(); + } else { + throw new LinkyException("Unable to get bridgeHandler in 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; + } + + BridgeRemoteBaseHandler bridgeHandler = (BridgeRemoteBaseHandler) lcBridge.getHandler(); + if (bridgeHandler == null) { + return; + } + + if (!bridgeHandler.isConnected()) { + bridgeHandler.connectionInit(); + } + + updateData(); + + final LocalDateTime now = LocalDateTime.now(); + final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_HOUR_OF_DAY) + .withMinute(REFRESH_MINUTE_OF_DAY).truncatedTo(ChronoUnit.MINUTES); + + updateStatus(ThingStatus.ONLINE); + + if (lcPollingJob != null) { + lcPollingJob.cancel(false); + pollingJob = null; + } + + refreshJob = scheduler.scheduleWithFixedDelay(this::updateData, + ChronoUnit.MINUTES.between(now, nextDayFirstTimeUpdate) % REFRESH_INTERVAL_IN_MIN + 1, + REFRESH_INTERVAL_IN_MIN, TimeUnit.MINUTES); + } + } catch (LinkyException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + } + + private synchronized void updateMetaData() { + metaData.getValue().ifPresentOrElse(values -> { + String title = values.identity.title; + String firstName = values.identity.firstname; + String lastName = values.identity.lastname; + + Map props = this.editProperties(); + + if (values.identity.internId == null) { + values.identity.internId = values.identity.firstname + " " + values.identity.lastname; + } + userId = values.identity.internId; + + addProps(props, USER_ID, userId); + + addProps(props, PROPERTY_USAGEPOINT_ID, values.usagePoint.usagePointId); + + addProps(props, PROPERTY_IDENTITY, title + " " + firstName + " " + lastName); + + addProps(props, PROPERTY_CONTRACT_SEGMENT, values.contract.segment); + addProps(props, PROPERTY_CONTRACT_CONTRACT_STATUS, values.contract.contractStatus); + addProps(props, PROPERTY_CONTRACT_CONTRACT_TYPE, values.contract.contractType); + addProps(props, PROPERTY_CONTRACT_DISTRIBUTION_TARIFF, values.contract.distributionTariff); + addProps(props, PROPERTY_CONTRACT_LAST_ACTIVATION_DATE, values.contract.lastActivationDate); + addProps(props, PROPERTY_CONTRACT_LAST_DISTRIBUTION_TARIFF_CHANGE_DATE, + values.contract.lastDistributionTariffChangeDate); + addProps(props, PROPERTY_CONTRACT_OFF_PEAK_HOURS, values.contract.offpeakHours); + addProps(props, PROPERTY_CONTRACT_SUBSCRIBED_POWER, values.contract.subscribedPower + " kVA"); + + addProps(props, PROPERTY_USAGEPOINT_STATUS, values.usagePoint.usagePointStatus); + addProps(props, PROPERTY_USAGEPOINT_METER_TYPE, values.usagePoint.meterType); + addProps(props, PROPERTY_USAGEPOINT_METER_ADDRESS_CITY, values.usagePoint.usagePointAddresses.city); + addProps(props, PROPERTY_USAGEPOINT_METER_ADDRESS_COUNTRY, values.usagePoint.usagePointAddresses.country); + addProps(props, PROPERTY_USAGEPOINT_METER_ADDRESS_POSTAL_CODE, + values.usagePoint.usagePointAddresses.postalCode); + addProps(props, PROPERTY_USAGEPOINT_METER_ADDRESS_STREET, values.usagePoint.usagePointAddresses.street); + + addProps(props, PROPERTY_CONTACT_MAIL, values.contact.email); + addProps(props, PROPERTY_CONTACT_PHONE, values.contact.phone); + + this.updateProperties(props); + }, () -> { + }); + } + + private void addProps(Map props, String key, @Nullable String value) { + if (value == null || value.isBlank()) { + return; + } + + props.put(key, value); + } + + private @Nullable MetaData getMetaData() { + try { + EnedisHttpApi api = this.enedisApi; + MetaData result = new MetaData(); + if (api != null) { + if (supportNewApiFormat()) { + if (config.prmId.isBlank()) { + throw new LinkyException("@text/offline.config-error-mandatory-settings"); + } + result.identity = api.getIdentity(this, config.prmId); + result.contact = api.getContact(this, config.prmId); + result.contract = api.getContract(this, config.prmId); + result.usagePoint = api.getUsagePoint(this, config.prmId); + } 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); + + config.prmId = prmInfo.idPrm; + result.identity = Identity.convertFromUserInfo(userInfo); + result.contact = Contact.convertFromUserInfo(userInfo); + result.contract = Contract.convertFromPrmDetail(details); + result.usagePoint = UsagePoint.convertFromPrmDetail(prmInfo, details); + } + } + return result; + } catch (LinkyException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + + return null; + } + + /** + * Request new data and updates channels + */ + private synchronized void updateData() { + // If one of the cache is expired, force also a metaData refresh to prevent 500 error from Enedis servers ! + logger.debug("updateData() called"); + logger.debug("Cache state {} {} {}", dailyConsumption.isPresent(), dailyConsumptionMaxPower.isPresent(), + loadCurveConsumption.isPresent()); + + if (!dailyConsumption.isPresent() || !dailyConsumptionMaxPower.isPresent() + || !loadCurveConsumption.isPresent()) { + logger.debug("invalidate metaData cache to force refresh"); + metaData.invalidate(); + } + + updateMetaData(); + // Stop there if we are not able to get Metadata + if (thing.getStatus() == ThingStatus.OFFLINE) { + return; + } + + updateEnergyData(); + updatePowerData(); + updateLoadCurveData(); + } + + private synchronized void updatePowerData() { + if (isLinkedPowerData()) { + dailyConsumptionMaxPower.getValue().ifPresentOrElse(values -> { + int dSize = values.baseValue.length; + + updatekVAChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_DAY_MINUS_1, + values.baseValue[dSize - 1].value); + updateState(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_TS_DAY_MINUS_1, + new DateTimeType(values.baseValue[dSize - 1].date.atZone(zoneId))); + + updatekVAChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_DAY_MINUS_2, + values.baseValue[dSize - 2].value); + updateState(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_TS_DAY_MINUS_2, + new DateTimeType(values.baseValue[dSize - 2].date.atZone(zoneId))); + + updatekVAChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_DAY_MINUS_3, + values.baseValue[dSize - 3].value); + updateState(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_TS_DAY_MINUS_3, + new DateTimeType(values.baseValue[dSize - 3].date.atZone(zoneId))); + + updateTimeSeries(LINKY_REMOTE_DAILY_GROUP, CHANNEL_MAX_POWER, values.baseValue, + MetricPrefix.KILO(Units.VOLT_AMPERE)); + + updateTimeSeries(LINKY_REMOTE_WEEKLY_GROUP, CHANNEL_MAX_POWER, values.weekValue, + MetricPrefix.KILO(Units.VOLT_AMPERE)); + + updateTimeSeries(LINKY_REMOTE_MONTHLY_GROUP, CHANNEL_MAX_POWER, values.monthValue, + MetricPrefix.KILO(Units.VOLT_AMPERE)); + + updateTimeSeries(LINKY_REMOTE_YEARLY_GROUP, CHANNEL_MAX_POWER, values.yearValue, + MetricPrefix.KILO(Units.VOLT_AMPERE)); + }, () -> { + updateKwhChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_DAY_MINUS_1, Double.NaN); + updateState(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_TS_DAY_MINUS_1, UnDefType.UNDEF); + + updateKwhChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_DAY_MINUS_2, Double.NaN); + updateState(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_TS_DAY_MINUS_2, UnDefType.UNDEF); + + updateKwhChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_DAY_MINUS_3, Double.NaN); + updateState(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_TS_DAY_MINUS_3, UnDefType.UNDEF); + }); + } + } + + /** + * Request new daily/weekly data and updates channels + */ + private synchronized void updateEnergyData() { + dailyConsumption.getValue().ifPresentOrElse(values -> { + int dSize = values.baseValue.length; + + updateKwhChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_DAY_MINUS_1, values.baseValue[dSize - 1].value); + updateKwhChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_DAY_MINUS_2, values.baseValue[dSize - 2].value); + updateKwhChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_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(LINKY_REMOTE_WEEKLY_GROUP, CHANNEL_WEEK_MINUS_0, values.weekValue[idxCurrentWeek].value); + updateKwhChannel(LINKY_REMOTE_WEEKLY_GROUP, CHANNEL_WEEK_MINUS_1, + values.weekValue[idxCurrentWeek - 1].value); + if (idxCurrentWeek - 2 >= 0) { + updateKwhChannel(LINKY_REMOTE_WEEKLY_GROUP, CHANNEL_WEEK_MINUS_2, + values.weekValue[idxCurrentWeek - 2].value); + } + + updateKwhChannel(LINKY_REMOTE_MONTHLY_GROUP, CHANNEL_MONTH_MINUS_0, + values.monthValue[idxCurrentMonth].value); + updateKwhChannel(LINKY_REMOTE_MONTHLY_GROUP, CHANNEL_MONTH_MINUS_1, + values.monthValue[idxCurrentMonth - 1].value); + if (idxCurrentMonth - 2 >= 0) { + updateKwhChannel(LINKY_REMOTE_MONTHLY_GROUP, CHANNEL_MONTH_MINUS_2, + values.monthValue[idxCurrentMonth - 2].value); + } + + updateKwhChannel(LINKY_REMOTE_YEARLY_GROUP, CHANNEL_YEAR_MINUS_0, values.yearValue[idxCurrentYear].value); + updateKwhChannel(LINKY_REMOTE_YEARLY_GROUP, CHANNEL_YEAR_MINUS_1, + values.yearValue[idxCurrentYear - 1].value); + if (idxCurrentYear - 2 >= 0) { + updateKwhChannel(LINKY_REMOTE_YEARLY_GROUP, CHANNEL_YEAR_MINUS_2, + values.yearValue[idxCurrentYear - 2].value); + } + + updateTimeSeries(LINKY_REMOTE_DAILY_GROUP, CHANNEL_CONSUMPTION, values.baseValue, Units.KILOWATT_HOUR); + updateTimeSeries(LINKY_REMOTE_WEEKLY_GROUP, CHANNEL_CONSUMPTION, values.weekValue, Units.KILOWATT_HOUR); + updateTimeSeries(LINKY_REMOTE_MONTHLY_GROUP, CHANNEL_CONSUMPTION, values.monthValue, Units.KILOWATT_HOUR); + updateTimeSeries(LINKY_REMOTE_YEARLY_GROUP, CHANNEL_CONSUMPTION, values.yearValue, Units.KILOWATT_HOUR); + }, () -> { + updateKwhChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_DAY_MINUS_1, Double.NaN); + updateKwhChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_DAY_MINUS_2, Double.NaN); + updateKwhChannel(LINKY_REMOTE_DAILY_GROUP, CHANNEL_DAY_MINUS_3, Double.NaN); + + updateKwhChannel(LINKY_REMOTE_WEEKLY_GROUP, CHANNEL_WEEK_MINUS_0, Double.NaN); + updateKwhChannel(LINKY_REMOTE_WEEKLY_GROUP, CHANNEL_WEEK_MINUS_1, Double.NaN); + updateKwhChannel(LINKY_REMOTE_WEEKLY_GROUP, CHANNEL_WEEK_MINUS_2, Double.NaN); + + updateKwhChannel(LINKY_REMOTE_MONTHLY_GROUP, CHANNEL_MONTH_MINUS_0, Double.NaN); + updateKwhChannel(LINKY_REMOTE_MONTHLY_GROUP, CHANNEL_MONTH_MINUS_1, Double.NaN); + updateKwhChannel(LINKY_REMOTE_MONTHLY_GROUP, CHANNEL_MONTH_MINUS_2, Double.NaN); + + updateKwhChannel(LINKY_REMOTE_YEARLY_GROUP, CHANNEL_YEAR_MINUS_0, Double.NaN); + updateKwhChannel(LINKY_REMOTE_YEARLY_GROUP, CHANNEL_YEAR_MINUS_1, Double.NaN); + updateKwhChannel(LINKY_REMOTE_YEARLY_GROUP, CHANNEL_YEAR_MINUS_2, Double.NaN); + }); + } + + /** + * Request new loadCurve data and updates channels + */ + private synchronized void updateLoadCurveData() { + if (isLinked(LINKY_REMOTE_LOAD_CURVE_GROUP, CHANNEL_POWER)) { + loadCurveConsumption.getValue().ifPresentOrElse(values -> { + updateTimeSeries(LINKY_REMOTE_LOAD_CURVE_GROUP, CHANNEL_POWER, values.baseValue, + MetricPrefix.KILO(Units.VOLT_AMPERE)); + }, () -> { + }); + } + } + + private synchronized > void updateTimeSeries(String groupId, String channelId, + IntervalReading[] iv, Unit unit) { + 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 QuantityType<>(iv[i].value, unit)); + } catch (Exception ex) { + logger.error("error occurs durring updatePowerTimeSeries for {} : {}", config.prmId, ex.getMessage(), + ex); + } + } + + sendTimeSeries(groupId, channelId, timeSeries); + } + + private void updateKwhChannel(String groupId, String channelId, double consumption) { + updateState(groupId, channelId, + Double.isNaN(consumption) ? UnDefType.UNDEF : new QuantityType<>(consumption, Units.KILOWATT_HOUR)); + } + + private void updatekVAChannel(String groupId, String channelId, double power) { + updateState(groupId, channelId, Double.isNaN(power) ? UnDefType.UNDEF + : new QuantityType<>(power, MetricPrefix.KILO(Units.VOLT_AMPERE))); + } + + 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 + * + * @param startDay the start day of the report + * @param endDay the end day of the report + * @param separator the separator to be used betwwen the date and the value + * + * @return the report as a list of string + */ + + public synchronized List reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) { + return buildReport(startDay, endDay, separator); + } + + private List buildReport(LocalDate startDay, LocalDate endDay, @Nullable String separator) { + List report = new ArrayList<>(); + if (startDay.getYear() == endDay.getYear() && startDay.getMonthValue() == endDay.getMonthValue()) { + // All values in the same month + 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[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[i].date.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator; + if (consumption >= 0) { + line += String.valueOf(consumption); + } + report.add(line); + } + } else { + LocalDate currentDay = startDay; + while (!currentDay.isAfter(endDay)) { + report.add(currentDay.format(DateTimeFormatter.ISO_LOCAL_DATE) + separator); + currentDay = currentDay.plusDays(1); + } + } + } else { + // Concatenate the report produced for each month between the two dates + LocalDate first = startDay; + do { + LocalDate last = first.withDayOfMonth(first.lengthOfMonth()); + if (last.isAfter(endDay)) { + last = endDay; + } + report.addAll(buildReport(first, last, separator)); + first = last.plusDays(1); + } while (!first.isAfter(endDay)); + } + return report; + } + + private @Nullable MeterReading getConsumptionData(LocalDate from, LocalDate to) { + logger.debug("getConsumptionData for {} from {} to {}", config.prmId, + from.format(DateTimeFormatter.ISO_LOCAL_DATE), to.format(DateTimeFormatter.ISO_LOCAL_DATE)); + + EnedisHttpApi api = this.enedisApi; + if (api != null) { + try { + return api.getEnergyData(this, this.userId, config.prmId, from, to); + } catch (LinkyException e) { + logger.debug("Exception when getting consumption data for {} : {}", config.prmId, e.getMessage(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage()); + } + } + + return null; + } + + private @Nullable MeterReading getLoadCurveConsumption(LocalDate from, LocalDate to) { + logger.debug("getLoadCurveConsumption for {} from {} to {}", config.prmId, + from.format(DateTimeFormatter.ISO_LOCAL_DATE), to.format(DateTimeFormatter.ISO_LOCAL_DATE)); + + EnedisHttpApi api = this.enedisApi; + if (api != null) { + try { + return api.getLoadCurveData(this, this.userId, config.prmId, from, to); + } 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 for {} from {} to {}", config.prmId, from.format(DateTimeFormatter.ISO_LOCAL_DATE), + to.format(DateTimeFormatter.ISO_LOCAL_DATE)); + + EnedisHttpApi api = this.enedisApi; + if (api != null) { + try { + return api.getPowerData(this, this.userId, config.prmId, from, to); + } catch (LinkyException e) { + logger.debug("Exception when getting power data: {}", e.getMessage(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage()); + } + } + + return null; + } + + @Override + public void dispose() { + logger.debug("Disposing the Linky handler {}", config.prmId); + ScheduledFuture job = this.refreshJob; + if (job != null && !job.isCancelled()) { + job.cancel(true); + refreshJob = null; + } + + ScheduledFuture lcPollingJob = pollingJob; + if (lcPollingJob != null) { + lcPollingJob.cancel(true); + pollingJob = null; + } + enedisApi = null; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + logger.debug("Refreshing channel {} {}", config.prmId, channelUID.getId()); + updateData(); + } else { + logger.debug("The Linky binding is read-only and can not handle command {}", command); + } + } + + public @Nullable MeterReading getMeterReadingAfterChecks(@Nullable MeterReading meterReading) { + try { + checkData(meterReading); + } catch (LinkyException e) { + logger.debug("Consumption data: {} {}", config.prmId, e.getMessage()); + return null; + } + + if (!isDataLastDayAvailable(meterReading)) { + 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 startWeek = startDate.get(WeekFields.of(Locale.FRANCE).weekOfYear()); + int endWeek = endDate.get(WeekFields.of(Locale.FRANCE).weekOfYear()); + + int yearsNum = endDate.getYear() - startDate.getYear() + 1; + int monthsNum = (endDate.getYear() - startDate.getYear()) * 12 + endDate.getMonthValue() + - startDate.getMonthValue() + 1; + + int weeksNum = (endDate.getYear() - startDate.getYear()) * 52 + endWeek - startWeek + 1; + + 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 = startDate.getYear(); + int baseMonth = startDate.getMonthValue(); + int baseWeek = startWeek; + + 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 idxMonth = idxYear * 12 + dt.getMonthValue() - baseMonth; + int dtWeek = dt.get(WeekFields.of(Locale.FRANCE).weekOfYear()); + int idxWeek = (idxYear * 52) + dtWeek - baseWeek; + int month = dt.getMonthValue(); + + 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); + } + } + } + } + } + + return meterReading; + } + + 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"); + } + } else { + throw new LinkyException("Invalid meterReading == null"); + } + } + + private boolean isDataLastDayAvailable(@Nullable MeterReading meterReading) { + if (meterReading != null) { + IntervalReading[] iv = meterReading.baseValue; + + logData(iv, "Last day", DateTimeFormatter.ISO_LOCAL_DATE, Target.LAST); + return iv.length != 0 && !iv[iv.length - 1].value.isNaN(); + } + + return false; + } + + private void logData(IntervalReading[] ivArray, String title, DateTimeFormatter dateTimeFormatter, Target target) { + if (logger.isDebugEnabled()) { + int size = ivArray.length; + + if (target == Target.FIRST) { + if (size > 0) { + logData(ivArray, 0, title, dateTimeFormatter); + } + } else if (target == Target.LAST) { + if (size > 0) { + logData(ivArray, size - 1, title, dateTimeFormatter); + } + } else { + for (int i = size - 3; i < size; i++) { + logData(ivArray, i, title, dateTimeFormatter); + } + } + } + } + + private void logData(IntervalReading[] ivArray, int index, String title, DateTimeFormatter dateTimeFormatter) { + try { + IntervalReading iv = ivArray[index]; + String date = ""; + if (iv.date != null) { + date = iv.date.format(dateTimeFormatter); + } + logger.debug("({}) {} {} value {}", config.prmId, title, date, iv.value); + } catch (Exception e) { + logger.error("error during logData", e); + } + } + + public void saveConfiguration(Configuration config) { + updateConfiguration(config); + } + + private boolean isLinkedPowerData() { + return (isLinked(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_DAY_MINUS_1) + || isLinked(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_TS_DAY_MINUS_1) + || isLinked(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_DAY_MINUS_2) + || isLinked(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_TS_DAY_MINUS_2) + || isLinked(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_DAY_MINUS_3) + || isLinked(LINKY_REMOTE_DAILY_GROUP, CHANNEL_PEAK_POWER_TS_DAY_MINUS_3) + || isLinked(LINKY_REMOTE_DAILY_GROUP, CHANNEL_MAX_POWER) + || isLinked(LINKY_REMOTE_WEEKLY_GROUP, CHANNEL_MAX_POWER) + || isLinked(LINKY_REMOTE_MONTHLY_GROUP, CHANNEL_MAX_POWER) + || isLinked(LINKY_REMOTE_YEARLY_GROUP, CHANNEL_MAX_POWER)); + } + + private boolean isLinked(String groupName, String channelName) { + return isLinked(groupName + "#" + channelName); + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ThingTempoCalendarHandler.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ThingTempoCalendarHandler.java new file mode 100644 index 00000000000..d0b3fbed278 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/handler/ThingTempoCalendarHandler.java @@ -0,0 +1,285 @@ +/* + * 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 static org.openhab.binding.linky.internal.constants.LinkyBindingConstants.*; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.Random; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.linky.internal.api.EnedisHttpApi; +import org.openhab.binding.linky.internal.api.ExpiringDayCache; +import org.openhab.binding.linky.internal.dto.ResponseTempo; +import org.openhab.binding.linky.internal.types.LinkyException; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ThingTempoCalendarHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Laurent Arnal - Initial contribution + */ + +@NonNullByDefault +@SuppressWarnings("null") +public class ThingTempoCalendarHandler extends ThingBaseRemoteHandler { + + private static final Random RANDOM_NUMBERS = new Random(); + private static final int REFRESH_HOUR_OF_DAY = 1; + private static final int REFRESH_MINUTE_OF_DAY = RANDOM_NUMBERS.nextInt(60); + private static final int REFRESH_INTERVAL_IN_MIN = 120; + + private final Logger logger = LoggerFactory.getLogger(ThingTempoCalendarHandler.class); + + private final ExpiringDayCache tempoInformation; + + private @Nullable ScheduledFuture refreshJob; + private @Nullable EnedisHttpApi enedisApi; + + public String userId = ""; + + private @Nullable ScheduledFuture pollingJob = null; + + public ThingTempoCalendarHandler(Thing thing) { + super(thing); + + // Read Tempo Information + this.tempoInformation = new ExpiringDayCache<>("tempoInformation", REFRESH_HOUR_OF_DAY, REFRESH_MINUTE_OF_DAY, + () -> { + LocalDate today = LocalDate.now(); + + ResponseTempo tempoData = getTempoData(today.minusDays(1095), today.plusDays(1)); + return tempoData; + }); + } + + @Override + public synchronized void initialize() { + logger.debug("Initializing Linky tempo handler"); + + Bridge bridge = getBridge(); + if (bridge == null) { + return; + } + + BridgeRemoteBaseHandler bridgeHandler = (BridgeRemoteBaseHandler) bridge.getHandler(); + if (bridgeHandler == null) { + return; + } + enedisApi = bridgeHandler.getEnedisApi(); + + updateStatus(ThingStatus.UNKNOWN); + + pollingJob = scheduler.schedule(this::pollingCode, 5, TimeUnit.SECONDS); + } + + @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()"); + } + + BridgeRemoteBaseHandler bridgeHandler = (BridgeRemoteBaseHandler) 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; + } + + BridgeRemoteBaseHandler bridgeHandler = (BridgeRemoteBaseHandler) lcBridge.getHandler(); + if (bridgeHandler == null) { + return; + } + + if (!bridgeHandler.isConnected()) { + bridgeHandler.connectionInit(); + } + + updateData(); + + final LocalDateTime now = LocalDateTime.now(); + final LocalDateTime nextDayFirstTimeUpdate = now.plusDays(1).withHour(REFRESH_HOUR_OF_DAY) + .withMinute(REFRESH_MINUTE_OF_DAY).truncatedTo(ChronoUnit.MINUTES); + + if (this.getThing().getStatusInfo().getStatusDetail() != ThingStatusDetail.COMMUNICATION_ERROR) { + updateStatus(ThingStatus.ONLINE); + } + + if (lcPollingJob != null) { + lcPollingJob.cancel(false); + pollingJob = null; + } + + 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()); + } + } + + /** + * Request new data and updates channels + */ + private synchronized void updateData() { + // If one of the cache is expired, force also a metaData refresh to prevent 500 error from Enedis servers ! + logger.info("updateData() called"); + logger.info("updateTempoData() called"); + updateTempoTimeSeries(); + } + + 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(LINKY_TEMPO_CALENDAR_GROUP, CHANNEL_TEMPO_TODAY_INFO, + getTempoIdx((String) tempoValues[size - 2])); + updateTempoChannel(LINKY_TEMPO_CALENDAR_GROUP, CHANNEL_TEMPO_TOMORROW_INFO, + getTempoIdx((String) tempoValues[size - 1])); + + sendTimeSeries(LINKY_TEMPO_CALENDAR_GROUP, CHANNEL_TEMPO_TEMPO_INFO_TIME_SERIES, timeSeries); + updateState(LINKY_TEMPO_CALENDAR_GROUP, CHANNEL_TEMPO_TEMPO_INFO_TIME_SERIES, + new DecimalType(getTempoIdx((String) tempoValues[size - 2]))); + }, () -> { + updateTempoChannel(LINKY_TEMPO_CALENDAR_GROUP, CHANNEL_TEMPO_TODAY_INFO, -1); + updateTempoChannel(LINKY_TEMPO_CALENDAR_GROUP, CHANNEL_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 void updateTempoChannel(String groupId, String channelId, int tempoValue) { + logger.debug("Update channel ({}) {} with {}", config.prmId, 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); + } + + 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; + } + + @Override + public void dispose() { + logger.debug("Disposing the Linky handler {}", config.prmId); + ScheduledFuture job = this.refreshJob; + if (job != null && !job.isCancelled()) { + job.cancel(true); + refreshJob = null; + } + + ScheduledFuture lcPollingJob = pollingJob; + if (lcPollingJob != null) { + lcPollingJob.cancel(true); + pollingJob = null; + } + enedisApi = null; + } + + @Override + public synchronized void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + logger.debug("Refreshing channel {} {}", config.prmId, channelUID.getId()); + updateData(); + } else { + logger.debug("The Linky binding is read-only and can not handle command {}", command); + } + } +} diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/helpers/LinkyAuthServlet.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/helpers/LinkyAuthServlet.java new file mode 100644 index 00000000000..8911d66f1cc --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/helpers/LinkyAuthServlet.java @@ -0,0 +1,272 @@ +/* + * 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.helpers; + +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.BridgeRemoteApiHandler; +import org.openhab.binding.linky.internal.types.LinkyException; +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 BridgeRemoteApiHandler apiBridgeHandler; + + public LinkyAuthServlet(BridgeRemoteApiHandler 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<@Nullable String> 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<@Nullable String> 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/LinkyException.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/types/LinkyException.java similarity index 95% rename from bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyException.java rename to bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/types/LinkyException.java index 591707377fb..de3d10bb206 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/LinkyException.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/types/LinkyException.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.linky.internal; +package org.openhab.binding.linky.internal.types; import org.eclipse.jdt.annotation.NonNullByDefault; diff --git a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/utils/DoubleTypeAdapter.java b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/utils/DoubleTypeAdapter.java index c4ebf749971..c3c927be81c 100644 --- a/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/utils/DoubleTypeAdapter.java +++ b/bundles/org.openhab.binding.linky/src/main/java/org/openhab/binding/linky/internal/utils/DoubleTypeAdapter.java @@ -14,6 +14,10 @@ package org.openhab.binding.linky.internal.utils; import java.io.IOException; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; @@ -28,29 +32,36 @@ import com.google.gson.stream.JsonWriter; * * @author Laurent Arnal - Initial contribution */ + +@NonNullByDefault public class DoubleTypeAdapter extends TypeAdapter { @Override - public Double read(JsonReader reader) throws IOException { - if (reader.peek() == JsonToken.NULL) { - reader.nextNull(); - return Double.NaN; - } - String stringValue = reader.nextString(); - try { - Double value = Double.valueOf(stringValue); - return value; - } catch (NumberFormatException e) { - return Double.NaN; + public @NonNull Double read(@Nullable JsonReader reader) throws IOException { + if (reader != null) { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + return Double.NaN; + } + String stringValue = reader.nextString(); + try { + Double value = Double.valueOf(stringValue); + return value; + } catch (NumberFormatException e) { + return Double.NaN; + } } + return Double.NaN; } @Override - public void write(JsonWriter writer, Double value) throws IOException { - if (value == null) { - writer.nullValue(); - return; + public void write(@Nullable JsonWriter writer, @Nullable Double value) throws IOException { + if (writer != null) { + if (value == null) { + writer.nullValue(); + return; + } + writer.value(value.doubleValue()); } - writer.value(value.doubleValue()); } } 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 fa77fc096ce..0da0c67a429 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 @@ -5,44 +5,112 @@ addon.linky.description = Retrieves your energy consumption data from Enedis web # thing types +thing-type.linky.enedis-api.label = Enedis API Bridge +thing-type.linky.enedis-api.description = 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. +thing-type.linky.enedis.label = Enedis Web Bridge +thing-type.linky.enedis.description = 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. thing-type.linky.linky.label = Linky thing-type.linky.linky.description = 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. +thing-type.linky.my-electrical-data.label = MyElectricalData Bridge +thing-type.linky.my-electrical-data.description = 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. +thing-type.linky.tempo-calendar.label = Tempo Calendar +thing-type.linky.tempo-calendar.description = Provides tempo calendar # thing types config -thing-type.config.linky.linky.internalAuthId.label = Auth ID -thing-type.config.linky.linky.internalAuthId.description = Authentication ID delivered after the captcha (see documentation). -thing-type.config.linky.linky.password.label = Password -thing-type.config.linky.linky.password.description = Your Enedis Password -thing-type.config.linky.linky.timezone.label = Timezone -thing-type.config.linky.linky.timezone.description = 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 -thing-type.config.linky.linky.username.label = Username -thing-type.config.linky.linky.username.description = Your Enedis Username +thing-type.config.linky.enedis-api.clientId.label = clientId +thing-type.config.linky.enedis-api.clientId.description = Your Enedis clientId +thing-type.config.linky.enedis-api.clientSecret.label = clientSecret +thing-type.config.linky.enedis-api.clientSecret.description = Your Enedis clientSecret +thing-type.config.linky.enedis-api.isSandbox.label = isSandbox +thing-type.config.linky.enedis-api.isSandbox.description = To test on the sandbox environment +thing-type.config.linky.enedis.internalAuthId.label = Auth ID +thing-type.config.linky.enedis.internalAuthId.description = Authentication ID delivered after the captcha (see documentation). +thing-type.config.linky.enedis.password.label = Password +thing-type.config.linky.enedis.password.description = Your Enedis Password +thing-type.config.linky.enedis.username.label = Username +thing-type.config.linky.enedis.username.description = Your Enedis Username +thing-type.config.linky.linky.prmId.label = prmId +thing-type.config.linky.linky.prmId.description = The Meter Id (PRM). If not provided, the binding will use the first registered meter found on your Enedis account. +thing-type.config.linky.linky.timezone.label = timezone +thing-type.config.linky.linky.timezone.description = 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 +thing-type.config.linky.linky.token.label = Token +thing-type.config.linky.linky.token.description = Your Enedis token (you will need it only if you use MyElectricalData bridge. This can be left empty, the connection page will automatically fill it http://youopenhab/connectlinky) # channel group types -channel-group-type.linky.daily.label = Daily consumption -channel-group-type.linky.daily.channel.timestamp.label = Peak Timestamp -channel-group-type.linky.daily.channel.timestamp.description = Maximum power usage timestamp +channel-group-type.linky.daily.label = Daily Consumption +channel-group-type.linky.daily.channel.consumption.label = Consumption +channel-group-type.linky.daily.channel.consumption.description = The energy consumption +channel-group-type.linky.daily.channel.day-2.label = Day -2 Consumption +channel-group-type.linky.daily.channel.day-2.description = The energy consumption for day -2 +channel-group-type.linky.daily.channel.day-3.label = Day -3 Consumption +channel-group-type.linky.daily.channel.day-3.description = The energy consumption for day -3 +channel-group-type.linky.daily.channel.max-power.label = Peak Value +channel-group-type.linky.daily.channel.max-power.description = Maximum power usage value +channel-group-type.linky.daily.channel.power.label = Peak Value Yesterday +channel-group-type.linky.daily.channel.power.description = Maximum power usage value for Yesterday +channel-group-type.linky.daily.channel.power-2.label = Peak Value Day-2 +channel-group-type.linky.daily.channel.power-2.description = Maximum power usage value for Day-2 +channel-group-type.linky.daily.channel.power-3.label = Peak Value Day-3 +channel-group-type.linky.daily.channel.power-3.description = Maximum power usage value for Day-3 +channel-group-type.linky.daily.channel.timestamp.label = Peak Timestamp Yesterday +channel-group-type.linky.daily.channel.timestamp.description = Maximum power usage timestamp for Yesterday +channel-group-type.linky.daily.channel.timestamp-2.label = Peak Timestamp Day-2 +channel-group-type.linky.daily.channel.timestamp-2.description = Maximum power usage timestamp for Day-2 +channel-group-type.linky.daily.channel.timestamp-3.label = Peak Timestamp Day-3 +channel-group-type.linky.daily.channel.timestamp-3.description = Maximum power usage timestamp for Day-3 channel-group-type.linky.daily.channel.yesterday.label = Yesterday Consumption -channel-group-type.linky.monthly.label = Monthly consumption +channel-group-type.linky.daily.channel.yesterday.description = The energy consumption for previous day +channel-group-type.linky.load-curve.label = Load curve +channel-group-type.linky.load-curve.channel.power.label = Load Curve Power +channel-group-type.linky.monthly.label = Monthly Consumption +channel-group-type.linky.monthly.channel.consumption.label = Consumption +channel-group-type.linky.monthly.channel.consumption.description = The energy consumption channel-group-type.linky.monthly.channel.lastMonth.label = Last Month Consumption +channel-group-type.linky.monthly.channel.lastMonth.description = The energy consumption for the previous Month +channel-group-type.linky.monthly.channel.max-power.label = Peak Value +channel-group-type.linky.monthly.channel.max-power.description = Maximum power usage value +channel-group-type.linky.monthly.channel.month-2.label = Month -2 Consumption +channel-group-type.linky.monthly.channel.month-2.description = The energy consumption for the Month -2 channel-group-type.linky.monthly.channel.thisMonth.label = This Month Consumption -channel-group-type.linky.weekly.label = Weekly consumption +channel-group-type.linky.monthly.channel.thisMonth.description = The energy consumption for the current Month +channel-group-type.linky.tempo-calendar.label = Tempo +channel-group-type.linky.tempo-calendar.channel.tempo-info-timeseries.label = Tempo Day Information +channel-group-type.linky.tempo-calendar.channel.tempo-info-today.label = Tempo Today Color +channel-group-type.linky.tempo-calendar.channel.tempo-info-tomorrow.label = Tempo Today Color +channel-group-type.linky.weekly.label = Weekly Consumption +channel-group-type.linky.weekly.channel.consumption.label = Consumption +channel-group-type.linky.weekly.channel.consumption.description = The energy consumption channel-group-type.linky.weekly.channel.lastWeek.label = Last Week Consumption +channel-group-type.linky.weekly.channel.lastWeek.description = The energy consumption for the previous Week +channel-group-type.linky.weekly.channel.max-power.label = Peak Value +channel-group-type.linky.weekly.channel.max-power.description = Maximum power usage value channel-group-type.linky.weekly.channel.thisWeek.label = This Week Consumption -channel-group-type.linky.yearly.label = Yearly consumption +channel-group-type.linky.weekly.channel.thisWeek.description = The energy consumption for the current Week +channel-group-type.linky.weekly.channel.week-2.label = Week -2 Consumption +channel-group-type.linky.weekly.channel.week-2.description = The energy consumption for the Week -2 +channel-group-type.linky.yearly.label = Yearly Consumption +channel-group-type.linky.yearly.channel.consumption.label = Consumption +channel-group-type.linky.yearly.channel.consumption.description = The energy consumption channel-group-type.linky.yearly.channel.lastYear.label = Last Year Consumption +channel-group-type.linky.yearly.channel.lastYear.description = The energy consumption for the previous Year +channel-group-type.linky.yearly.channel.max-power.label = Peak Value +channel-group-type.linky.yearly.channel.max-power.description = Maximum power usage value channel-group-type.linky.yearly.channel.thisYear.label = This Year Consumption +channel-group-type.linky.yearly.channel.thisYear.description = The energy consumption for the current Year +channel-group-type.linky.yearly.channel.year-2.label = Year -2 Consumption +channel-group-type.linky.yearly.channel.year-2.description = The energy consumption for the Year -2 # channel types channel-type.linky.consumption.label = Total Consumption channel-type.linky.consumption.description = Consumption at given time interval -channel-type.linky.power.label = Yesterday Peak Power -channel-type.linky.power.description = Maximum power usage yesterday +channel-type.linky.power.label = Power Usage +channel-type.linky.power.description = Power usage for a period +channel-type.linky.tempo-value.label = Tempo Color Information +channel-type.linky.tempo-value.description = This status describes the tempo color of a day. +channel-type.linky.tempo-value.state.option.0 = Blue +channel-type.linky.tempo-value.state.option.1 = White +channel-type.linky.tempo-value.state.option.2 = Red channel-type.linky.timestamp.label = Timestamp - -# thing status descriptions - -offline.config-error-mandatory-settings = Username, password and authId are mandatory. diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/bridge-remote-enedis-web.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/bridge-remote-enedis-web.xml new file mode 100644 index 00000000000..7876c219e18 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/bridge-remote-enedis-web.xml @@ -0,0 +1,33 @@ + + + + + + + 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. + + + + + + email + Your Enedis Username + + + + password + Your Enedis Password + + + + Authentication ID delivered after the captcha (see documentation). + + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/bridge-remote-enedis.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/bridge-remote-enedis.xml new file mode 100644 index 00000000000..fac1f49342f --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/bridge-remote-enedis.xml @@ -0,0 +1,32 @@ + + + + + + + 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 + + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/bridge-remote-my-electrical-data.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/bridge-remote-my-electrical-data.xml new file mode 100644 index 00000000000..9832e02b813 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/bridge-remote-my-electrical-data.xml @@ -0,0 +1,19 @@ + + + + + + + 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. + + + + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-daily.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-daily.xml new file mode 100644 index 00000000000..a79f1384e7f --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-daily.xml @@ -0,0 +1,67 @@ + + + + + + + + + The energy consumption for previous day + + + + + The energy consumption for day -2 + + + + + The energy consumption for day -3 + + + + + The energy consumption + + + + + Maximum power usage value + + + + + Maximum power usage value for Yesterday + + + + + 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 + + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-load-curve.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-load-curve.xml new file mode 100644 index 00000000000..4f2dcc1a80b --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-load-curve.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-monthly.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-monthly.xml new file mode 100644 index 00000000000..3b6695f8d70 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-monthly.xml @@ -0,0 +1,36 @@ + + + + + + + + + The energy consumption for the current Month + + + + + The energy consumption for the previous Month + + + + + The energy consumption for the Month -2 + + + + + The energy consumption + + + + + Maximum power usage value + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-weekly.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-weekly.xml new file mode 100644 index 00000000000..3b2e59ca141 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-weekly.xml @@ -0,0 +1,32 @@ + + + + + + + + + The energy consumption for the current Week + + + + The energy consumption for the previous Week + + + + The energy consumption for the Week -2 + + + + The energy consumption + + + + Maximum power usage value + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-yearly.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-yearly.xml new file mode 100644 index 00000000000..9ad761e6ead --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-remote-yearly.xml @@ -0,0 +1,32 @@ + + + + + + + + + The energy consumption for the current Year + + + + The energy consumption for the previous Year + + + + The energy consumption for the Year -2 + + + + The energy consumption + + + + Maximum power usage value + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-tempo-calendar.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-tempo-calendar.xml new file mode 100644 index 00000000000..98bf6b57f8e --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/group-linky-tempo-calendar.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-linky-remote.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-linky-remote.xml new file mode 100644 index 00000000000..9b9ac3e7af4 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-linky-remote.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + 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. + + + + + + + + + + + + + + The Meter Id (PRM). If not provided, the binding will use the first registered meter found on your + Enedis account. + + + + 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 (you will need it only if you use MyElectricalData bridge. This can be left empty, + the connection page will automatically fill it + http://youopenhab/connectlinky) + + + + + + diff --git a/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-linky-tempo-calendar.xml b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-linky-tempo-calendar.xml new file mode 100644 index 00000000000..97a22ad31d0 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/main/resources/OH-INF/thing/thing-linky-tempo-calendar.xml @@ -0,0 +1,21 @@ + + + + + + + + + + Provides tempo calendar + + + + + + + + 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 d743ed723b3..e37b85a6055 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,99 +4,18 @@ 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. - - WebService - - - - - - - - - - - - email - Your Enedis Username - - - - password - Your Enedis Password - - - - Authentication ID delivered after the captcha (see documentation). - - - - 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 - - - - - - - - - - - - - - - Maximum power usage timestamp - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + Number + + This status describes the tempo color of a day. + + + + + + + + Number:Energy @@ -108,8 +27,8 @@ Number:Power - - Maximum power usage yesterday + + Power usage for a period 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.linky/src/test/java/org/openhab/binding/linky/internal/handler/ThingLinkyRemoteHandlerTest.java b/bundles/org.openhab.binding.linky/src/test/java/org/openhab/binding/linky/internal/handler/ThingLinkyRemoteHandlerTest.java new file mode 100644 index 00000000000..3933b20b405 --- /dev/null +++ b/bundles/org.openhab.binding.linky/src/test/java/org/openhab/binding/linky/internal/handler/ThingLinkyRemoteHandlerTest.java @@ -0,0 +1,332 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; + +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.binding.linky.internal.config.LinkyThingRemoteConfiguration; +import org.openhab.binding.linky.internal.dto.IntervalReading; +import org.openhab.binding.linky.internal.dto.MeterReading; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TimeZoneProvider; +import org.openhab.core.thing.Thing; + +/** + * The {@link ThingLinkyRemoteHandler} is responsible for extra validation for Raw things. + * + * @author Laurent Arnal - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +public class ThingLinkyRemoteHandlerTest { + + private ThingLinkyRemoteHandler handler; + + @Mock + LocaleProvider localProvider; + + @Mock + Thing thing; + + @Mock + TimeZoneProvider tzProvider; + + @Mock + LinkyThingRemoteConfiguration config; + + @BeforeEach + public void setUp() { + when(thing.getConfiguration()).thenReturn(new LinkyThingRemoteConfiguration()); + } + + /* + * @AfterAll + * public void tearDown() { + * // handler.dispose(); + * } + */ + + public @Nullable MeterReading getMeterReadingAfterChecks(ThingLinkyRemoteHandler handler, + @Nullable MeterReading meterReading) { + return handler.getMeterReadingAfterChecks(meterReading); + } + + @Test + public void testBase() { + handler = new ThingLinkyRemoteHandler(thing, localProvider, tzProvider); + + MeterReading mr = getMeterReadingAfterChecks(handler, null); + assertEquals(mr, null); + } + + @Test + public void testValidRange1() { + handler = new ThingLinkyRemoteHandler(thing, localProvider, tzProvider); + + MeterReading mr = new MeterReading(); + mr.baseValue = new IntervalReading[75]; + LocalDateTime startDate = LocalDateTime.of(2025, 1, 1, 0, 0, 0); + for (int idx = 0; idx < 75; idx++) { + mr.baseValue[idx] = new IntervalReading(); + mr.baseValue[idx].value = Double.valueOf(idx); + mr.baseValue[idx].date = startDate.plusDays(idx); + } + + mr = getMeterReadingAfterChecks(handler, mr); + assertNotEquals(mr, null); + if (mr == null) { + return; + } + assertNotEquals(mr.weekValue, null); + assertNotEquals(mr.monthValue, null); + assertNotEquals(mr.yearValue, null); + + assertEquals(mr.weekValue.length, 11); + assertEquals(mr.monthValue.length, 3); + assertEquals(mr.yearValue.length, 1); + + assertEquals(mr.weekValue[0].value, 10); + + assertEquals(mr.weekValue[1].value, 56); + assertEquals(mr.weekValue[1].date, LocalDateTime.of(2025, 1, 6, 0, 0, 0)); + + assertEquals(mr.weekValue[4].value, 203); + assertEquals(mr.weekValue[4].date, LocalDateTime.of(2025, 1, 27, 0, 0, 0)); + + assertEquals(mr.weekValue[10].value, 497); + assertEquals(mr.weekValue[10].date, LocalDateTime.of(2025, 3, 10, 0, 0, 0)); + + assertEquals(mr.monthValue[0].value, 465); + assertEquals(mr.monthValue[0].date, LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + + assertEquals(mr.monthValue[1].value, 1246); + assertEquals(mr.monthValue[1].date, LocalDateTime.of(2025, 2, 1, 0, 0, 0)); + + assertEquals(mr.monthValue[2].value, 1064); + assertEquals(mr.monthValue[2].date, LocalDateTime.of(2025, 3, 1, 0, 0, 0)); + + assertEquals(mr.yearValue[0].value, 2775); + assertEquals(mr.yearValue[0].date, LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + } + + @Test + public void testValidRange2() { + handler = new ThingLinkyRemoteHandler(thing, localProvider, tzProvider); + + MeterReading mr = new MeterReading(); + mr.baseValue = new IntervalReading[128]; + LocalDateTime startDate = LocalDateTime.of(2024, 11, 6, 0, 0, 0); + for (int idx = 0; idx < 128; idx++) { + mr.baseValue[idx] = new IntervalReading(); + mr.baseValue[idx].value = Double.valueOf(idx); + mr.baseValue[idx].date = startDate.plusDays(idx); + } + + mr = getMeterReadingAfterChecks(handler, mr); + assertNotEquals(mr, null); + if (mr == null) { + return; + } + assertNotEquals(mr.weekValue, null); + assertNotEquals(mr.monthValue, null); + assertNotEquals(mr.yearValue, null); + + assertEquals(mr.weekValue.length, 19); + assertEquals(mr.monthValue.length, 5); + assertEquals(mr.yearValue.length, 2); + + assertEquals(mr.weekValue[0].value, 10); + assertEquals(mr.weekValue[0].date, LocalDateTime.of(2024, 11, 6, 0, 0, 0)); + + assertEquals(mr.weekValue[2].value, 105); + assertEquals(mr.weekValue[2].date, LocalDateTime.of(2024, 11, 18, 0, 0, 0)); + + assertEquals(mr.weekValue[6].value, 301); + assertEquals(mr.weekValue[6].date, LocalDateTime.of(2024, 12, 16, 0, 0, 0)); + + assertEquals(mr.weekValue[8].value, 399); + assertEquals(mr.weekValue[8].date, LocalDateTime.of(2024, 12, 30, 0, 0, 0)); + + assertEquals(mr.weekValue[12].value, 595); + assertEquals(mr.weekValue[12].date, LocalDateTime.of(2025, 01, 27, 0, 0, 0)); + + assertEquals(mr.weekValue[18].value, 502); + assertEquals(mr.weekValue[18].date, LocalDateTime.of(2025, 03, 10, 0, 0, 0)); + + assertEquals(mr.monthValue[0].value, 300); + assertEquals(mr.monthValue[0].date, LocalDateTime.of(2024, 11, 1, 0, 0, 0)); + + assertEquals(mr.monthValue[1].value, 1240); + assertEquals(mr.monthValue[1].date, LocalDateTime.of(2024, 12, 1, 0, 0, 0)); + + assertEquals(mr.monthValue[2].value, 2201); + assertEquals(mr.monthValue[2].date, LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + + assertEquals(mr.monthValue[3].value, 2814); + assertEquals(mr.monthValue[3].date, LocalDateTime.of(2025, 2, 1, 0, 0, 0)); + + assertEquals(mr.monthValue[4].value, 1573); + assertEquals(mr.monthValue[4].date, LocalDateTime.of(2025, 3, 1, 0, 0, 0)); + + assertEquals(mr.yearValue[0].value, 1540); + assertEquals(mr.yearValue[0].date, LocalDateTime.of(2024, 1, 1, 0, 0, 0)); + + assertEquals(mr.yearValue[1].value, 6588); + assertEquals(mr.yearValue[1].date, LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + } + + @Test + public void testValidRange3() { + handler = new ThingLinkyRemoteHandler(thing, localProvider, tzProvider); + + MeterReading mr = new MeterReading(); + mr.baseValue = new IntervalReading[716]; + LocalDateTime startDate = LocalDateTime.of(2023, 03, 29, 0, 0, 0); + for (int idx = 0; idx < 716; idx++) { + mr.baseValue[idx] = new IntervalReading(); + mr.baseValue[idx].value = Double.valueOf(idx); + mr.baseValue[idx].date = startDate.plusDays(idx); + } + + mr = getMeterReadingAfterChecks(handler, mr); + assertNotEquals(mr, null); + if (mr == null) { + return; + } + + assertNotEquals(mr.weekValue, null); + assertNotEquals(mr.monthValue, null); + assertNotEquals(mr.yearValue, null); + + assertEquals(mr.weekValue.length, 103); + assertEquals(mr.monthValue.length, 25); + assertEquals(mr.yearValue.length, 3); + + assertEquals(mr.weekValue[0].value, 10); + assertEquals(mr.weekValue[0].date, LocalDateTime.of(2023, 03, 29, 0, 0, 0)); + + assertEquals(mr.weekValue[2].value, 105); + assertEquals(mr.weekValue[2].date, LocalDateTime.of(2023, 04, 10, 0, 0, 0)); + + assertEquals(mr.weekValue[15].value, 742); + assertEquals(mr.weekValue[15].date, LocalDateTime.of(2023, 07, 10, 0, 0, 0)); + + assertEquals(mr.weekValue[39].value, 1918); + assertEquals(mr.weekValue[39].date, LocalDateTime.of(2023, 12, 25, 0, 0, 0)); + + assertEquals(mr.weekValue[56].value, 2751); + assertEquals(mr.weekValue[56].date, LocalDateTime.of(2024, 04, 22, 0, 0, 0)); + + assertEquals(mr.weekValue[90].value, 4417); + assertEquals(mr.weekValue[90].date, LocalDateTime.of(2024, 12, 16, 0, 0, 0)); + + assertEquals(mr.weekValue[97].value, 4760); + assertEquals(mr.weekValue[97].date, LocalDateTime.of(2025, 02, 03, 0, 0, 0)); + + assertEquals(mr.weekValue[102].value, 2854); + assertEquals(mr.weekValue[102].date, LocalDateTime.of(2025, 03, 10, 0, 0, 0)); + + assertEquals(mr.monthValue[0].value, 3); + assertEquals(mr.monthValue[0].date, LocalDateTime.of(2023, 03, 01, 0, 0, 0)); + + assertEquals(mr.monthValue[5].value, 4340); + assertEquals(mr.monthValue[5].date, LocalDateTime.of(2023, 8, 01, 0, 0, 0)); + + assertEquals(mr.monthValue[9].value, 8122); + assertEquals(mr.monthValue[9].date, LocalDateTime.of(2023, 12, 1, 0, 0, 0)); + + assertEquals(mr.monthValue[10].value, 9083); + assertEquals(mr.monthValue[10].date, LocalDateTime.of(2024, 01, 01, 0, 0, 0)); + + assertEquals(mr.monthValue[17].value, 15686); + assertEquals(mr.monthValue[17].date, LocalDateTime.of(2024, 8, 01, 0, 0, 0)); + + assertEquals(mr.monthValue[22].value, 20429); + assertEquals(mr.monthValue[22].date, LocalDateTime.of(2025, 01, 01, 0, 0, 0)); + + assertEquals(mr.monthValue[23].value, 19278); + assertEquals(mr.monthValue[23].date, LocalDateTime.of(2025, 02, 01, 0, 0, 0)); + + assertEquals(mr.monthValue[24].value, 9217); + assertEquals(mr.monthValue[24].date, LocalDateTime.of(2025, 03, 01, 0, 0, 0)); + + assertEquals(mr.yearValue[0].value, 38503); + assertEquals(mr.yearValue[0].date, LocalDateTime.of(2023, 1, 1, 0, 0, 0)); + + assertEquals(mr.yearValue[1].value, 168543); + assertEquals(mr.yearValue[1].date, LocalDateTime.of(2024, 1, 1, 0, 0, 0)); + + assertEquals(mr.yearValue[2].value, 48924); + assertEquals(mr.yearValue[2].date, LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + } + + @Test + public void testValidRange4() { + handler = new ThingLinkyRemoteHandler(thing, localProvider, tzProvider); + + MeterReading mr = new MeterReading(); + mr.baseValue = new IntervalReading[35]; + LocalDateTime startDate = LocalDateTime.of(2025, 02, 10, 0, 0, 0); + for (int idx = 0; idx < 35; idx++) { + mr.baseValue[idx] = new IntervalReading(); + mr.baseValue[idx].value = Double.valueOf(idx); + mr.baseValue[idx].date = startDate.plusDays(idx); + } + + mr = getMeterReadingAfterChecks(handler, mr); + assertNotEquals(mr, null); + if (mr == null) { + return; + } + + assertNotEquals(mr.weekValue, null); + assertNotEquals(mr.monthValue, null); + assertNotEquals(mr.yearValue, null); + + assertEquals(mr.weekValue.length, 5); + assertEquals(mr.monthValue.length, 2); + assertEquals(mr.yearValue.length, 1); + + assertEquals(mr.weekValue[0].value, 21); + assertEquals(mr.weekValue[0].date, LocalDateTime.of(2025, 2, 10, 0, 0, 0)); + + assertEquals(mr.weekValue[1].value, 70); + assertEquals(mr.weekValue[1].date, LocalDateTime.of(2025, 2, 17, 0, 0, 0)); + + assertEquals(mr.weekValue[2].value, 119); + assertEquals(mr.weekValue[2].date, LocalDateTime.of(2025, 2, 24, 0, 0, 0)); + + assertEquals(mr.weekValue[3].value, 168); + assertEquals(mr.weekValue[3].date, LocalDateTime.of(2025, 3, 3, 0, 0, 0)); + + assertEquals(mr.weekValue[4].value, 217); + assertEquals(mr.weekValue[4].date, LocalDateTime.of(2025, 3, 10, 0, 0, 0)); + + assertEquals(mr.monthValue[0].value, 171); + assertEquals(mr.monthValue[0].date, LocalDateTime.of(2025, 2, 1, 0, 0, 0)); + + assertEquals(mr.monthValue[1].value, 424); + assertEquals(mr.monthValue[1].date, LocalDateTime.of(2025, 3, 1, 0, 0, 0)); + + assertEquals(mr.yearValue[0].value, 595); + assertEquals(mr.yearValue[0].date, LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + } +}