[Linky] Make use of DataConnect Enedis API (#16355)

* fix pull request

Signed-off-by: Laurent ARNAL <laurent@clae.net>
pull/18740/head
lo92fr 2025-06-01 13:20:13 +02:00 committed by GitHub
parent f88de93591
commit 2c5eb48743
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
89 changed files with 7095 additions and 1110 deletions

View File

@ -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

View File

@ -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
The binding has no configuration options, all configuration is done at Thing level.
Bridge linky:enedis:local "EnedisWebBridge" [
username="laurent@clae.net",
password="Mnbo32tyu123!",
internalAuthId="eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.u_mxXO7_d4I5bLvJzGtc2MARvpkYv0iM0EsO6a24k-tW9493_Myxwg.LVlfephhGTiCBxii8bRIkA.GOf9Ea8PTGshvkfjl62b6w.hSH97IkmBcEAz2udU-FqQg"
]
{
}
## Thing Configuration
3. Move username, password & internalAuthId configuration parameter from the old linky thing to the bridge thing.
The thing has the following configuration parameters:
4. Link your old thing to the new created bridge thing
Thing linky:linky:linkremotemelody "Linky Melody" (linky:enedis:local)
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.
## Getting Consumption Data Online
The new binding version can use multiple bridges to access consumption data.
You can use :
- 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.
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.
The binding will not provide this information unless this step is completed.
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 authID |
| timezone | The timezone at the location of your linky |
| internalAuthId | The internal authentication ID. |
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).
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 given for Firefox :
Instructions for Firefox :
1. Go to <https://mon-compte-client.enedis.fr/>.
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.
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.
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.
## Channels
```java
Bridge linky:enedis:local "EnedisWebBridge" [ username="example@domaine.fr", password="******", internalAuthId="******" ]
```
The information that is retrieved is available as these channels:
#### 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.<br/>
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 | 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 |
|---------------------------------------------------|-------------------|-------------------------------------------------------------------------------|
| 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%]" <energy> { channel="linky:linky:linkyremotexxxx:daily#yesterday" }
Number:Energy ConsoSemaineEnCours "Conso cette semaine [%.0f %unit%]" <energy> { channel="linky:linky:linkyremotexxxx:weekly#thisWeek" }
Number:Energy ConsoSemaineDerniere "Conso semaine dernière [%.0f %unit%]" <energy> { channel="linky:linky:linkyremotexxxx:weekly#lastWeek" }
Number:Energy ConsoMoisEnCours "Conso ce mois [%.0f %unit%]" <energy> { channel="linky:linky:linkyremotexxxx:monthly#thisMonth" }
Number:Energy ConsoMoisDernier "Conso mois dernier [%.0f %unit%]" <energy> { channel="linky:linky:linkyremotexxxx:monthly#lastMonth" }
Number:Energy ConsoAnneeEnCours "Conso cette année [%.0f %unit%]" <energy> { channel="linky:linky:linkyremotexxxx:yearly#thisYear" }
Number:Energy ConsoAnneeDerniere "Conso année dernière [%.0f %unit%]" <energy> { 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: []
```
## 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%]" <energy> { channel="linky:linky:local:daily#yesterday" }
Number:Energy ConsoSemaineEnCours "Conso cette semaine [%.0f %unit%]" <energy> { channel="linky:linky:local:weekly#thisWeek" }
Number:Energy ConsoSemaineDerniere "Conso semaine dernière [%.0f %unit%]" <energy> { channel="linky:linky:local:weekly#lastWeek" }
Number:Energy ConsoMoisEnCours "Conso ce mois [%.0f %unit%]" <energy> { channel="linky:linky:local:monthly#thisMonth" }
Number:Energy ConsoMoisDernier "Conso mois dernier [%.0f %unit%]" <energy> { channel="linky:linky:local:monthly#lastMonth" }
Number:Energy ConsoAnneeEnCours "Conso cette année [%.0f %unit%]" <energy> { channel="linky:linky:local:yearly#thisYear" }
Number:Energy ConsoAnneeDerniere "Conso année dernière [%.0f %unit%]" <energy> { channel="linky:linky:local:yearly#lastYear" }
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

View File

@ -0,0 +1,20 @@
2. Step 2
![connectlinky-enedis-step1](connectlinky-enedis-step1.png)<br/>
![connectlinky-enedis-step2](connectlinky-enedis-step2.png)<br/>
3. Step 3
![connectlinky-enedis-step2b](connectlinky-enedis-step2b.png)<br/>
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)<br/>
5. Step 5
![connectlinky-enedis-step3](connectlinky-enedis-step3.png)<br/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@ -0,0 +1,29 @@
2. Step 2
![connectlinky-myelectricaldata-step1](connectlinky-myelectricaldata-step1.png)<br/>
![connectlinky-myelectricaldata-step2](connectlinky-myelectricaldata-step2.png)<br/>
3. Step 3
![connectlinky-myelectricaldata-step2b](connectlinky-myelectricaldata-step2b.png)<br/>
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)<br/>
5. Step 5
![connectlinky-myelectricaldata-step2d](connectlinky-myelectricaldata-step2d.png)<br/>
6. Step 6
![connectlinky-myelectricaldata-step3](connectlinky-myelectricaldata-step3.png)<br/>
7. Step 7
![connectlinky-myelectricaldata-step3b](connectlinky-myelectricaldata-step3b.png)<br/>

View File

@ -17,14 +17,4 @@
<properties>
<bnd.importpackage>javax.annotation.meta;resolution:=optional</bnd.importpackage>
</properties>
<dependencies>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.14.3</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@ -4,7 +4,7 @@
<feature name="openhab-binding-linky" description="Linky Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle dependency="true">mvn:org.jsoup/jsoup/1.14.3</bundle>
<feature>openhab-core-auth-oauth2client</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.linky/${project.version}</bundle>
</feature>
</features>

View File

@ -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";
}

View File

@ -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<String, String> 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<HttpCookie> lCookie = httpClient.getCookieStore().getCookies();
Optional<HttpCookie> 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,14 +153,20 @@ public class EnedisHttpApi {
}
}
private <T> T getData(String url, Class<T> clazz) throws LinkyException {
if (!connected) {
initialize();
}
String data = getContent(url);
if (data.isEmpty()) {
throw new LinkyException("Requesting '%s' returned an empty response", url);
private <T> T getData(ThingBaseRemoteHandler handler, String url, Class<T> clazz) throws LinkyException {
if (!linkyBridgeHandler.isConnected()) {
linkyBridgeHandler.initialize();
}
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);
@ -302,38 +176,131 @@ public class EnedisHttpApi {
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url);
}
}
} catch (LinkyException ex) {
lastException = ex;
public PrmInfo getPrmInfo(String internId) throws LinkyException {
String url = PRM_INFO_URL.formatted(internId);
PrmInfo[] prms = getData(url, PrmInfo[].class);
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(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");
}
if (prmId.isBlank()) {
return prms[0];
}
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);
Optional<PrmInfo> result = Arrays.stream(prms).filter(x -> x.idPrm.equals(prmId)).findFirst();
if (result.isPresent()) {
return result.get();
}
public UserInfo getUserInfo() throws LinkyException {
return getData(USER_INFO_URL, UserInfo.class);
throw new LinkyException(("PRM with id : %s does not exist").formatted(prmId));
}
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;
}
}

View File

@ -44,11 +44,11 @@ public class ExpiringDayCache<V> {
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<V> {
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.
*

View File

@ -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();
}
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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;
}
}

View File

@ -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 = "";
}

View File

@ -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;
}
}

View File

@ -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<ThingTypeUID> 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";
}

View File

@ -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;
}

View File

@ -40,6 +40,8 @@ public class ConsumptionReport {
}
public class ChronoData {
@SerializedName("heure")
public Aggregate heure;
@SerializedName("jour")
public Aggregate days;
@SerializedName("semaine")

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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<String, String> {
private static final long serialVersionUID = 362498820763181264L;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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<ZonedDateTime>) (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;
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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<String> 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;
}
}

View File

@ -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;
}
}

View File

@ -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<String, String> 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<HttpCookie> lCookie = httpClient.getCookieStore().getCookies();
Optional<HttpCookie> 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;
}
}

View File

@ -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<Thing> 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;
}
}

View File

@ -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<Consumption> cachedDailyData;
private final ExpiringDayCache<Consumption> cachedPowerData;
private final ExpiringDayCache<Consumption> cachedMonthlyData;
private final ExpiringDayCache<Consumption> 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<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
List<String> report = buildReport(startDay, endDay, separator);
disconnect();
return report;
}
private List<String> buildReport(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
List<String> 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);
}
}
}

View File

@ -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;
}
}

View File

@ -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> metaData;
private final ExpiringDayCache<MeterReading> dailyConsumption;
private final ExpiringDayCache<MeterReading> dailyConsumptionMaxPower;
private final ExpiringDayCache<MeterReading> 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<String, String> 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<String, String> 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 <T extends Quantity<T>> void updateTimeSeries(String groupId, String channelId,
IntervalReading[] iv, Unit<T> 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<String> reportValues(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
return buildReport(startDay, endDay, separator);
}
private List<String> buildReport(LocalDate startDay, LocalDate endDay, @Nullable String separator) {
List<String> 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);
}
}

View File

@ -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<ResponseTempo> 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);
}
}
}

View File

@ -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 = "<p class='block authorized'>Addon authorized for %s.</p>";
private static final String HTML_ERROR = "<p class='block error'>Call to Enedis failed with error: %s</p>";
// 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<String, String> 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<String> prmIds = apiBridgeHandler.getAllPrmId();
for (String prmId : prmIds) {
optionBuffer.append("<option value=\"" + prmId + "\">" + prmId + "</option>");
}
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<String, String> 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<String, String> 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();
}
}

View File

@ -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;

View File

@ -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,10 +32,13 @@ import com.google.gson.stream.JsonWriter;
*
* @author Laurent Arnal - Initial contribution
*/
@NonNullByDefault
public class DoubleTypeAdapter extends TypeAdapter<Double> {
@Override
public Double read(JsonReader reader) throws IOException {
public @NonNull Double read(@Nullable JsonReader reader) throws IOException {
if (reader != null) {
if (reader.peek() == JsonToken.NULL) {
reader.nextNull();
return Double.NaN;
@ -44,9 +51,12 @@ public class DoubleTypeAdapter extends TypeAdapter<Double> {
return Double.NaN;
}
}
return Double.NaN;
}
@Override
public void write(JsonWriter writer, Double value) throws IOException {
public void write(@Nullable JsonWriter writer, @Nullable Double value) throws IOException {
if (writer != null) {
if (value == null) {
writer.nullValue();
return;
@ -54,3 +64,4 @@ public class DoubleTypeAdapter extends TypeAdapter<Double> {
writer.value(value.doubleValue());
}
}
}

View File

@ -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.

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<bridge-type id="enedis">
<label>Enedis Web Bridge</label>
<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.
</description>
<config-description>
<parameter name="username" type="text" required="true">
<label>Username</label>
<context>email</context>
<description>Your Enedis Username</description>
</parameter>
<parameter name="password" type="text" required="true">
<label>Password</label>
<context>password</context>
<description>Your Enedis Password</description>
</parameter>
<parameter name="internalAuthId" type="text" required="true">
<label>Auth ID</label>
<description>Authentication ID delivered after the captcha (see documentation).</description>
</parameter>
</config-description>
</bridge-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<bridge-type id="enedis-api">
<label>Enedis API Bridge</label>
<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.
</description>
<config-description>
<parameter name="clientId" type="text" required="false">
<label>clientId</label>
<description>Your Enedis clientId</description>
</parameter>
<parameter name="clientSecret" type="text" required="false">
<label>clientSecret</label>
<description>Your Enedis clientSecret</description>
</parameter>
<parameter name="isSandbox" type="boolean" required="false">
<label>isSandbox</label>
<description>To test on the sandbox environment</description>
<advanced>true</advanced>
</parameter>
</config-description>
</bridge-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<bridge-type id="my-electrical-data">
<label>MyElectricalData Bridge</label>
<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.
</description>
<config-description>
</config-description>
</bridge-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<channel-group-type id="daily">
<label>Daily Consumption</label>
<channels>
<channel id="yesterday" typeId="consumption">
<label>Yesterday Consumption</label>
<description>The energy consumption for previous day</description>
</channel>
<channel id="day-2" typeId="consumption">
<label>Day -2 Consumption</label>
<description>The energy consumption for day -2</description>
</channel>
<channel id="day-3" typeId="consumption">
<label>Day -3 Consumption</label>
<description>The energy consumption for day -3</description>
</channel>
<channel id="consumption" typeId="consumption">
<label>Consumption</label>
<description>The energy consumption</description>
</channel>
<channel id="max-power" typeId="power">
<label>Peak Value</label>
<description>Maximum power usage value</description>
</channel>
<channel id="power" typeId="power">
<label>Peak Value Yesterday</label>
<description>Maximum power usage value for Yesterday</description>
</channel>
<channel id="timestamp" typeId="timestamp">
<label>Peak Timestamp Yesterday</label>
<description>Maximum power usage timestamp for Yesterday</description>
</channel>
<channel id="power-2" typeId="power">
<label>Peak Value Day-2</label>
<description>Maximum power usage value for Day-2</description>
</channel>
<channel id="timestamp-2" typeId="timestamp">
<label>Peak Timestamp Day-2</label>
<description>Maximum power usage timestamp for Day-2</description>
</channel>
<channel id="power-3" typeId="power">
<label>Peak Value Day-3</label>
<description>Maximum power usage value for Day-3</description>
</channel>
<channel id="timestamp-3" typeId="timestamp">
<label>Peak Timestamp Day-3</label>
<description>Maximum power usage timestamp for Day-3</description>
</channel>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<channel-group-type id="load-curve">
<label>Load curve</label>
<channels>
<channel id="power" typeId="power">
<label>Load Curve Power</label>
</channel>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<channel-group-type id="monthly">
<label>Monthly Consumption</label>
<channels>
<channel id="thisMonth" typeId="consumption">
<label>This Month Consumption</label>
<description>The energy consumption for the current Month</description>
</channel>
<channel id="lastMonth" typeId="consumption">
<label>Last Month Consumption</label>
<description>The energy consumption for the previous Month</description>
</channel>
<channel id="month-2" typeId="consumption">
<label>Month -2 Consumption</label>
<description>The energy consumption for the Month -2</description>
</channel>
<channel id="consumption" typeId="consumption">
<label>Consumption</label>
<description>The energy consumption</description>
</channel>
<channel id="max-power" typeId="power">
<label>Peak Value</label>
<description>Maximum power usage value</description>
</channel>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<channel-group-type id="weekly">
<label>Weekly Consumption</label>
<channels>
<channel id="thisWeek" typeId="consumption">
<label>This Week Consumption</label>
<description>The energy consumption for the current Week</description>
</channel>
<channel id="lastWeek" typeId="consumption">
<label>Last Week Consumption</label>
<description>The energy consumption for the previous Week</description>
</channel>
<channel id="week-2" typeId="consumption">
<label>Week -2 Consumption</label>
<description>The energy consumption for the Week -2</description>
</channel>
<channel id="consumption" typeId="consumption">
<label>Consumption</label>
<description>The energy consumption</description>
</channel>
<channel id="max-power" typeId="power">
<label>Peak Value</label>
<description>Maximum power usage value</description>
</channel>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<channel-group-type id="yearly">
<label>Yearly Consumption</label>
<channels>
<channel id="thisYear" typeId="consumption">
<label>This Year Consumption</label>
<description>The energy consumption for the current Year</description>
</channel>
<channel id="lastYear" typeId="consumption">
<label>Last Year Consumption</label>
<description>The energy consumption for the previous Year</description>
</channel>
<channel id="year-2" typeId="consumption">
<label>Year -2 Consumption</label>
<description>The energy consumption for the Year -2</description>
</channel>
<channel id="consumption" typeId="consumption">
<label>Consumption</label>
<description>The energy consumption</description>
</channel>
<channel id="max-power" typeId="power">
<label>Peak Value</label>
<description>Maximum power usage value</description>
</channel>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<channel-group-type id="tempo-calendar">
<label>Tempo</label>
<channels>
<channel id="tempo-info-today" typeId="tempo-value">
<label>Tempo Today Color</label>
</channel>
<channel id="tempo-info-tomorrow" typeId="tempo-value">
<label>Tempo Today Color</label>
</channel>
<channel id="tempo-info-timeseries" typeId="tempo-value">
<label>Tempo Day Information</label>
</channel>
</channels>
</channel-group-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<thing-type id="linky">
<supported-bridge-type-refs>
<bridge-type-ref id="enedis"/>
<bridge-type-ref id="enedis-api"/>
<bridge-type-ref id="my-electrical-data"/>
</supported-bridge-type-refs>
<label>Linky</label>
<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.
</description>
<channel-groups>
<channel-group typeId="load-curve" id="load-curve"/>
<channel-group typeId="daily" id="daily"/>
<channel-group typeId="weekly" id="weekly"/>
<channel-group typeId="monthly" id="monthly"/>
<channel-group typeId="yearly" id="yearly"/>
</channel-groups>
<config-description>
<parameter name="prmId" type="text" required="false">
<label>prmId</label>
<description>The Meter Id (PRM). If not provided, the binding will use the first registered meter found on your
Enedis account.</description>
</parameter>
<parameter name="timezone" type="text" required="false">
<label>timezone</label>
<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</description>
</parameter>
<parameter name="token" type="text" required="false">
<label>Token</label>
<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)</description>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="linky"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
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">
<thing-type id="tempo-calendar">
<supported-bridge-type-refs>
<bridge-type-ref id="my-electrical-data"/>
</supported-bridge-type-refs>
<label>Tempo Calendar</label>
<description>Provides tempo calendar</description>
<channel-groups>
<channel-group typeId="tempo-calendar" id="tempo-calendar"/>
</channel-groups>
</thing-type>
</thing:thing-descriptions>

View File

@ -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">
<thing-type id="linky">
<label>Linky</label>
<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.
</description>
<semantic-equipment-tag>WebService</semantic-equipment-tag>
<channel-groups>
<channel-group typeId="daily" id="daily"/>
<channel-group typeId="weekly" id="weekly"/>
<channel-group typeId="monthly" id="monthly"/>
<channel-group typeId="yearly" id="yearly"/>
</channel-groups>
<config-description>
<parameter name="username" type="text" required="true">
<label>Username</label>
<context>email</context>
<description>Your Enedis Username</description>
</parameter>
<parameter name="password" type="text" required="true">
<label>Password</label>
<context>password</context>
<description>Your Enedis Password</description>
</parameter>
<parameter name="internalAuthId" type="text" required="true">
<label>Auth ID</label>
<description>Authentication ID delivered after the captcha (see documentation).</description>
</parameter>
<parameter name="timezone" type="text" required="false">
<label>Timezone</label>
<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</description>
</parameter>
</config-description>
</thing-type>
<channel-group-type id="daily">
<label>Daily consumption</label>
<channels>
<channel id="yesterday" typeId="consumption">
<label>Yesterday Consumption</label>
</channel>
<channel id="power" typeId="power"/>
<channel id="timestamp" typeId="timestamp">
<label>Peak Timestamp</label>
<description>Maximum power usage timestamp</description>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="weekly">
<label>Weekly consumption</label>
<channels>
<channel id="thisWeek" typeId="consumption">
<label>This Week Consumption</label>
</channel>
<channel id="lastWeek" typeId="consumption">
<label>Last Week Consumption</label>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="monthly">
<label>Monthly consumption</label>
<channels>
<channel id="thisMonth" typeId="consumption">
<label>This Month Consumption</label>
</channel>
<channel id="lastMonth" typeId="consumption">
<label>Last Month Consumption</label>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="yearly">
<label>Yearly consumption</label>
<channels>
<channel id="thisYear" typeId="consumption">
<label>This Year Consumption</label>
</channel>
<channel id="lastYear" typeId="consumption">
<label>Last Year Consumption</label>
</channel>
</channels>
</channel-group-type>
<channel-type id="tempo-value">
<item-type>Number</item-type>
<label>Tempo Color Information</label>
<description>This status describes the tempo color of a day.</description>
<state>
<options>
<option value="0">Blue</option>
<option value="1">White</option>
<option value="2">Red</option>
</options>
</state>
</channel-type>
<channel-type id="consumption">
<item-type>Number:Energy</item-type>
@ -108,8 +27,8 @@
<channel-type id="power">
<item-type unitHint="kVA">Number:Power</item-type>
<label>Yesterday Peak Power</label>
<description>Maximum power usage yesterday</description>
<label>Power Usage</label>
<description>Power usage for a period</description>
<state readOnly="true" pattern="%.2f %unit%"/>
</channel-type>

View File

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${pageRefresh}
<title>OpenHAB Linky binding for Enedis</title>
<link rel="icon" href="img/favicon.ico" type="image/vnd.microsoft.icon">
<link>
<style>
html {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
.block {
border: 1px solid #bbb;
background-color: white;
margin: 10px 0;
padding: 8px 10px;
}
.error {
background: #FFC0C0;
border: 1px solid darkred;
color: darkred
}
.authorized {
border: 1px solid #90EE90;
background-color: #E0FFE0;
}
.button {
margin-left:30px;
margin-bottom: 10px;
float:left;
}
.olList {
margin-top:20px;
}
.olList li {
margin:20px;
}
.box {
float:left;
transform: translate(0%, -30%);
}
.box select {
background-color: #0563af;
color: white;
padding: 12px;
width: 350px;
border: none;
font-size: 20px;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.2);
-webkit-appearance: button;
appearance: button;
outline: none;
}
/* Style the arrow inside the select element: */
.box::before {
content: "\f13a";
font-family: FontAwesome;
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
text-align: center;
font-size: 28px;
line-height: 45px;
color: rgba(255, 255, 255, 0.5);
background-color: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.logo {
display: inline;
}
.logoEnedis {
display: inline;
}
.logoTransfer {
display: inline;
margin-left:300px;
top: -30px;
}
.button a {
background: #1ED760;
border-radius: 500px;
color: white;
padding: 10px 20px 10px;
font-size: 16px;
font-weight: 700;
border-width: 0;
text-decoration: none;
}
</style>
<script language="javascript">
function retrieveToken()
{
var prmId = document.getElementById('prmId').value;
document.location= "${retrieveToken.uri}" + "&code=" + prmId;
}
</script>
</head>
<body>
<div style="margin:50px">
<div class="logo" style="vertical-align:top;">
<div style="display: inline-block;width:500;margin-right:100px;margin-top:0px;margin-bottom:0px;">
<img src="/connectlinky/img/openhab_logo.svg" height="100"/>
</div>
<div style="display: inline-block;width:100;margin-top:0px;margin-bottom:0px;">
</div>
<div style="display: inline-block;width:300;margin-top:0px;margin-bottom:0px;">
<img src="/connectlinky/img/enedis.png" height="100">
</div>
</div>
<br/><br/><br/>
<hr style="margin:0px;padding-left:20px;padding-right:20px;width:97%;"/>
<div style="display: block;width:400;margin-top:0px;margin-bottom:0px;float:right;top:60px;right:160px;position:absolute;">
<svg xmlns="http://www.w3.org/2000/svg" width="400" version="1.1">
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<circle cx="100" cy="110" r="20" stroke="black" stroke-width="1" fill="red" />
<text x="100" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">1</text>
<polyline points="130,110 170,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="200" cy="110" r="20" stroke="red" stroke-width="1" fill="none" />
<text x="200" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">2</text>
<polyline points="230,110 270,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="300" cy="110" r="20" stroke="black" stroke-width="1" fill="none" />
<text x="300" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">3</text>
</svg>
</div>
<div style="background:linear-gradient(#faf3ff, #e8cbfd);padding:20px;margin:0px;display:inline-block; width:97%;align:center;">
<h3>Plugin Linky / Enedis pour Openhab</h3>
<br/>
<p>Ce plugin permet d'exploiter vos données de consommation éléctrique fournis par Enedis au sein d'OpenHab.</p>
<p>Enedis gère le réseau délectricité jusquau compteur délectricité<br/>
Enedis est le gestionnaire du réseau public de distribution délectricité sur 95% du territoire français continental.</p>
<p>Grace à ce plugin, vous serez en mesure de : </p>
<ul>
<li>Consulter les informations contractuelles liés à votre compte.</li>
<li>Créer des graphes de consommation par jour / semaine / mois / annéee.</li>
<li>Consulter la puissance maximum utilisé sur une période donnée.</li>
<li>Load curve</li>
</ul>
<br/><br/>
<div>
<div class="button">
<a href="/connectlinky/enedis-step2">Suite
</a>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${pageRefresh}
<title>OpenHAB Linky binding for Enedis</title>
<link rel="icon" href="img/favicon.ico" type="image/vnd.microsoft.icon">
<link>
<style>
html {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
.block {
border: 1px solid #bbb;
background-color: white;
margin: 10px 0;
padding: 8px 10px;
}
.error {
background: #FFC0C0;
border: 1px solid darkred;
color: darkred
}
.authorized {
border: 1px solid #90EE90;
background-color: #E0FFE0;
}
.olList {
margin-top:20px;
}
.olList li {
margin:20px;
}
.box {
float:left;
transform: translate(0%, -30%);
}
.box select {
background-color: #0563af;
color: white;
padding: 12px;
width: 350px;
border: none;
font-size: 20px;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.2);
-webkit-appearance: button;
appearance: button;
outline: none;
}
/* Style the arrow inside the select element: */
.box::before {
content: "\f13a";
font-family: FontAwesome;
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
text-align: center;
font-size: 28px;
line-height: 45px;
color: rgba(255, 255, 255, 0.5);
background-color: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.logo {
display: inline;
}
.logoEnedis {
display: inline;
}
.logoTransfer {
display: inline;
margin-left:300px;
top: -30px;
}
</style>
<script language="javascript">
function retrieveToken()
{
var prmId = document.getElementById('prmId').value;
document.location= "${retrieveToken.uri}" + "&code=" + prmId;
}
</script>
</head>
<body>
<div style="margin:50px">
<div class="logo" style="vertical-align:top;">
<div style="display: inline-block;width:500;margin-right:100px;">
<img src="/connectlinky/img/openhab_logo.svg" height="100"/>
</div>
<div style="display: inline-block;width:100;">
</div>
<div style="display: inline-block;width:300;">
<img src="/connectlinky/img/enedis.png" height="100">
</div>
</div>
<br/><br/><br/>
<hr style="margin:0px;padding-left:20px;padding-right:20px;width:97%;"/>
<div style="display: block;width:400;margin-top:0px;margin-bottom:0px;float:right;top:60px;right:160px;position:absolute;">
<svg xmlns="http://www.w3.org/2000/svg" width="400" version="1.1">
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<circle cx="100" cy="110" r="20" stroke="black" stroke-width="1" fill="none" />
<text x="100" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">1</text>
<polyline points="130,110 170,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="200" cy="110" r="20" stroke="red" stroke-width="1" fill="red" />
<text x="200" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">2</text>
<polyline points="230,110 270,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="300" cy="110" r="20" stroke="black" stroke-width="1" fill="none" />
<text x="300" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">3</text>
</svg>
</div>
<div style="background:linear-gradient(#faf3ff, #e8cbfd);padding:20px;margin:0px;display:inline-block; width:97%;align:center;">
<h3>Plugin Linky / Enedis pour Openhab</h3>
<br/>
<div style="float:left; margin:30px;">
<img src="/connectlinky/img/linky.svg"/>
</div>
<p>Pour utiliser ce plugin, vous devez donner votre accord pour qu'Enedis transmette vos données.</p>
<p><b>
Enedis est le gestionnaire du réseau public de distribution délectricité sur 95% du territoire français continental.<br/>
Enedis gère le réseau délectricité jusquau compteur délectricité.
</b>
</p>
<p>Pour donner votre autorisation, vous devez avoir un compte personnel Enedis. <br/>
Ce compte vous permet également de suivre et gérer vos données de consommation [ou production en fonction de votre service] délectricité.</p>
<p>Si vous n'avez pas de compte, vous pouvez le créer depuis cette <a href="https://mon-compte-client.enedis.fr/">page</a>.<br/>
Munissez-vous pour celà de votre facture délectricité pour créer votre espace.</p>
<p>En cliquant sur le bouton ci-dessous, vous allez accéder à votre compte personnel Enedis où vous pourrez donner votre accord pour quEnedis nous transmette vos données.</p>
<p>Une fois cette opération effectué, vous serez rediriger vers une page de confirmation.</p>
<div class="button" style="float:right;margin-right:30px;margin-bottom:10px;height:100px;position:relative;display:block;">
<a href=${authorize.uri}><img src="/connectlinky/img/boutonEnedis.png"/>
</a>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${pageRefresh}
<title>OpenHAB Linky binding for Enedis</title>
<link rel="icon" href="img/favicon.ico" type="image/vnd.microsoft.icon">
<link>
<style>
html {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
.block {
border: 1px solid #bbb;
background-color: white;
margin: 10px 0;
padding: 8px 10px;
}
.error {
background: #FFC0C0;
border: 1px solid darkred;
color: darkred
}
.authorized {
border: 1px solid #90EE90;
background-color: #E0FFE0;
}
.button {
margin-left:30px;
margin-bottom: 10px;
float:left;
}
.olList {
margin-top:20px;
}
.olList li {
margin:20px;
}
.box {
float:left;
transform: translate(0%, -30%);
}
.box select {
background-color: #0563af;
color: white;
padding: 12px;
width: 350px;
border: none;
font-size: 20px;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.2);
-webkit-appearance: button;
appearance: button;
outline: none;
}
/* Style the arrow inside the select element: */
.box::before {
content: "\f13a";
font-family: FontAwesome;
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
text-align: center;
font-size: 28px;
line-height: 45px;
color: rgba(255, 255, 255, 0.5);
background-color: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.logo {
display: inline;
}
.logoEnedis {
display: inline;
}
.logoTransfer {
display: inline;
margin-left:300px;
top: -30px;
}
.button a {
background: #1ED760;
border-radius: 500px;
color: white;
padding: 10px 20px 10px;
font-size: 16px;
font-weight: 700;
border-width: 0;
text-decoration: none;
}
</style>
<script language="javascript">
function retrieveToken()
{
var prmId = document.getElementById('prmId').value;
document.location= "${retrieveToken.uri}" + "&code=" + prmId;
}
</script>
</head>
<body>
<div style="margin:50px">
<div class="logo" style="vertical-align:top;">
<div style="display: inline-block;width:500;margin-right:100px;">
<img src="/connectlinky/img/openhab_logo.svg" height="100"/>
</div>
<div style="display: inline-block;width:100;">
</div>
<div style="display: inline-block;width:300;">
<img src="/connectlinky/img/enedis.png" height="100">
</div>
</div>
<br/><br/><br/>
<hr style="margin:0px;padding-left:20px;padding-right:20px;width:97%;"/>
<div style="display: block;width:400;margin-top:0px;margin-bottom:0px;float:right;top:60px;right:160px;position:absolute;">
<svg xmlns="http://www.w3.org/2000/svg" width="400" version="1.1">
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<circle cx="100" cy="110" r="20" stroke="black" stroke-width="1" fill="none" />
<text x="100" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">1</text>
<polyline points="130,110 170,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="200" cy="110" r="20" stroke="red" stroke-width="1" fill="none" />
<text x="200" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">2</text>
<polyline points="230,110 270,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="300" cy="110" r="20" stroke="black" stroke-width="1" fill="red" />
<text x="300" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">3</text>
</svg>
</div>
<div style="background:linear-gradient(#faf3ff, #e8cbfd);padding:20px;margin:0px;display:inline-block; width:97%;align:center;">
<h3>Plugin Linky / Enedis pour Openhab</h3>
<br/>
<div style="display:${cb.displayConfirmation}">
Vous avez autorisé l'accès pour le compteur Linky : ${prmId.Value}<br/>
Vous pouvez maintenant utiliser le plugin Linky avec Enedis.<br/>
${authorizedUser}
</div>
<div style="display:${cb.displayError}">
Une erreur c'est produite:
${error}
</div>
<p>
<br/>
</body>
</html>

View File

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${pageRefresh}
<title>Authorize openHAB Linky binding for Enedis</title>
<link rel="icon" href="img/favicon.ico" type="image/vnd.microsoft.icon">
<link>
<style>
html {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
.block {
border: 1px solid #bbb;
background-color: white;
margin: 10px 0;
padding: 8px 10px;
}
.error {
background: #FFC0C0;
border: 1px solid darkred;
color: darkred
}
.authorized {
border: 1px solid #90EE90;
background-color: #E0FFE0;
}
.button {
margin-left:30px;
margin-bottom: 10px;
float:left;
}
.olList {
margin-top:20px;
}
.olList li {
margin:20px;
}
.box {
float:left;
transform: translate(0%, -30%);
}
.box select {
background-color: #0563af;
color: white;
padding: 12px;
width: 350px;
border: none;
font-size: 20px;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.2);
-webkit-appearance: button;
appearance: button;
outline: none;
}
/* Style the arrow inside the select element: */
.box::before {
content: "\f13a";
font-family: FontAwesome;
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
text-align: center;
font-size: 28px;
line-height: 45px;
color: rgba(255, 255, 255, 0.5);
background-color: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.logo {
display: inline;
}
.logoEnedis {
display: inline;
}
.logoTransfer {
display: inline;
margin-left:300px;
top: -30px;
}
.button a {
background: #1ED760;
border-radius: 500px;
color: white;
padding: 10px 20px 10px;
font-size: 16px;
font-weight: 700;
border-width: 0;
text-decoration: none;
}
</style>
<script language="javascript">
function retrieveToken()
{
var prmId = document.getElementById('prmId').value;
document.location= "${retrieveToken.uri}" + "&code=" + prmId;
}
</script>
</head>
<body>
<div style="margin:50px">
<div class="logo" style="vertical-align:top;">
<div style="display: inline-block;width:500;margin-right:100px;margin-top:0px;margin-bottom:0px;">
<img src="/connectlinky/img/openhab_logo.svg" height="100"/>
</div>
<div style="display: inline-block;width:100;margin-top:0px;margin-bottom:0px;">
</div>
</div>
<br/><br/><br/>
<hr style="margin:0px;padding-left:20px;padding-right:20px;width:97%;"/>
<div style="background:linear-gradient(#faf3ff, #e8cbfd);padding:20px;margin:0px;display:inline-block; width:97%;align:center;">
<h1>
Merci de sélectionner votre provider
</h1>
<div style="float:center;width:100%;">
<div style="display: inline-block;width:300;margin:100px;padding:20px;background-color:#ffffff;">
<a href="/connectlinky/myelectricaldata"><img src="/connectlinky/img/MyElectricalData.png" height="100"></a>
</div>
<div style="display: inline-block;width:300;margin:100px;padding:20px;background-color:#ffffff;">
<a href="/connectlinky/enedis"><img src="/connectlinky/img/enedis.png" height="100"></a>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${pageRefresh}
<title>OpenHAB Linky binding for Enedis</title>
<link rel="icon" href="img/favicon.ico" type="image/vnd.microsoft.icon">
<link>
<style>
html {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
.block {
border: 1px solid #bbb;
background-color: white;
margin: 10px 0;
padding: 8px 10px;
}
.error {
background: #FFC0C0;
border: 1px solid darkred;
color: darkred
}
.authorized {
border: 1px solid #90EE90;
background-color: #E0FFE0;
}
.button {
margin-left:30px;
margin-bottom: 10px;
float:left;
}
.olList {
margin-top:20px;
}
.olList li {
margin:20px;
}
.box {
float:left;
transform: translate(0%, -30%);
}
.box select {
background-color: #0563af;
color: white;
padding: 12px;
width: 350px;
border: none;
font-size: 20px;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.2);
-webkit-appearance: button;
appearance: button;
outline: none;
}
/* Style the arrow inside the select element: */
.box::before {
content: "\f13a";
font-family: FontAwesome;
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
text-align: center;
font-size: 28px;
line-height: 45px;
color: rgba(255, 255, 255, 0.5);
background-color: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.logo {
display: inline;
}
.logoEnedis {
display: inline;
}
.logoTransfer {
display: inline;
margin-left:300px;
top: -30px;
}
.button a {
background: #1ED760;
border-radius: 500px;
color: white;
padding: 10px 20px 10px;
font-size: 16px;
font-weight: 700;
border-width: 0;
text-decoration: none;
}
</style>
<script language="javascript">
function retrieveToken()
{
var prmId = document.getElementById('prmId').value;
document.location= "${retrieveToken.uri}" + "&code=" + prmId;
}
</script>
</head>
<body>
<div style="margin:50px">
<div class="logo" style="vertical-align:top;">
<div style="display: inline-block;width:500;margin-right:100px;margin-top:0px;margin-bottom:0px;">
<img src="/connectlinky/img/openhab_logo.svg" height="100"/>
</div>
<div style="display: inline-block;width:100;margin-top:0px;margin-bottom:0px;">
</div>
<div style="display: inline-block;width:300;margin-top:0px;margin-bottom:0px;">
<img src="/connectlinky/img/MyElectricalData.png" height="100">
</div>
</div>
<br/><br/><br/>
<hr style="margin:0px;padding-left:20px;padding-right:20px;width:97%;"/>
<div style="display: block;width:400;margin-top:0px;margin-bottom:0px;float:right;top:60px;right:160px;position:absolute;">
<svg xmlns="http://www.w3.org/2000/svg" width="400" version="1.1">
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<circle cx="100" cy="110" r="20" stroke="black" stroke-width="1" fill="red" />
<text x="100" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">1</text>
<polyline points="130,110 170,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="200" cy="110" r="20" stroke="red" stroke-width="1" fill="none" />
<text x="200" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">2</text>
<polyline points="230,110 270,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="300" cy="110" r="20" stroke="black" stroke-width="1" fill="none" />
<text x="300" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">3</text>
</svg>
</div>
<div style="background:linear-gradient(#faf3ff, #e8cbfd);padding:20px;margin:0px;display:inline-block; width:97%;align:center;">
<h3>Plugin Linky / MyElectricalData pour Openhab</h3>
<br/>
<p>Ce plugin permet d'exploiter vos données de consommation éléctrique fournis par Enedis au sein d'OpenHab.</p>
<p>Enedis gère le réseau délectricité jusquau compteur délectricité<br/>
Enedis est le gestionnaire du réseau public de distribution délectricité sur 95% du territoire français continental.</p>
<p>Grace à ce plugin, vous serez en mesure de : </p>
<ul>
<li>Consulter les informations contractuelles liés à votre compte.</li>
<li>Créer des graphes de consommation par jour / semaine / mois / annéee.</li>
<li>Consulter la puissance maximum utilisé sur une période donnée.</li>
<li>Load curve</li>
</ul>
<br/><br/>
<div>
<div class="button">
<a href="/connectlinky/myelectricaldata-step2">Suite
</a>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${pageRefresh}
<title>OpenHAB Linky binding for Enedis</title>
<link rel="icon" href="img/favicon.ico" type="image/vnd.microsoft.icon">
<link>
<style>
html {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
.block {
border: 1px solid #bbb;
background-color: white;
margin: 10px 0;
padding: 8px 10px;
}
.error {
background: #FFC0C0;
border: 1px solid darkred;
color: darkred
}
.authorized {
border: 1px solid #90EE90;
background-color: #E0FFE0;
}
.olList {
margin-top:20px;
}
.olList li {
margin:20px;
}
.box {
float:left;
transform: translate(0%, -30%);
}
.box select {
background-color: #0563af;
color: white;
padding: 12px;
width: 350px;
border: none;
font-size: 20px;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.2);
-webkit-appearance: button;
appearance: button;
outline: none;
}
/* Style the arrow inside the select element: */
.box::before {
content: "\f13a";
font-family: FontAwesome;
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
text-align: center;
font-size: 28px;
line-height: 45px;
color: rgba(255, 255, 255, 0.5);
background-color: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.logo {
display: inline;
}
.logoEnedis {
display: inline;
}
.logoTransfer {
display: inline;
margin-left:300px;
top: -30px;
}
</style>
<script language="javascript">
function retrieveToken()
{
var prmId = document.getElementById('prmId').value;
document.location= "${retrieveToken.uri}" + "&code=" + prmId;
}
</script>
</head>
<body>
<div style="margin:50px">
<div class="logo" style="vertical-align:top;">
<div style="display: inline-block;width:500;margin-right:100px;">
<img src="/connectlinky/img/openhab_logo.svg" height="100"/>
</div>
<div style="display: inline-block;width:100;">
</div>
<div style="display: inline-block;width:300;">
<img src="/connectlinky/img/MyElectricalData.png" height="100">
</div>
</div>
<br/><br/><br/>
<hr style="margin:0px;padding-left:20px;padding-right:20px;width:97%;"/>
<div style="display: block;width:400;margin-top:0px;margin-bottom:0px;float:right;top:60px;right:160px;position:absolute;">
<svg xmlns="http://www.w3.org/2000/svg" width="400" version="1.1">
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<circle cx="100" cy="110" r="20" stroke="black" stroke-width="1" fill="none" />
<text x="100" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">1</text>
<polyline points="130,110 170,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="200" cy="110" r="20" stroke="red" stroke-width="1" fill="red" />
<text x="200" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">2</text>
<polyline points="230,110 270,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="300" cy="110" r="20" stroke="black" stroke-width="1" fill="none" />
<text x="300" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">3</text>
</svg>
</div>
<div style="background:linear-gradient(#faf3ff, #e8cbfd);padding:20px;margin:0px;display:inline-block; width:97%;align:center;">
<h3>Plugin Linky / MyElectricalData pour Openhab</h3>
<br/>
<div style="float:left; margin:30px;">
<img src="/connectlinky/img/linky.svg"/>
</div>
<p>Pour utiliser ce plugin, vous devez donner votre accord pour qu'Enedis transmette vos données.</p>
<p>Pour donner votre autorisation, vous devez avoir un compte personnel Enedis. <br/>
Ce compte vous permet également de suivre et gérer vos données de consommation [ou production en fonction de votre service] délectricité.</p>
<p>Si vous n'avez pas de compte, vous pouvez le créer depuis cette <a href="https://mon-compte-client.enedis.fr/">page</a>.<br/>
Munissez-vous pour celà de votre facture délectricité pour créer votre espace.</p>
<p>En cliquant sur le bouton ci-dessous, vous allez accéder à votre compte personnel Enedis où vous pourrez donner votre accord pour quEnedis nous transmette vos données.</p>
<p>Une fois cette opération effectué, vous devez vous rendre manuellement sur cette <a href="/connectlinky/myelectricaldata-step3">page</a> pour terminer la procédure.</p>
<div class="button" style="float:right;margin-right:30px;margin-bottom:10px;height:100px;position:relative;display:block;">
<a href=${authorize.uri}><img src="/connectlinky/img/boutonEnedis.png"/>
</a>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,201 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${pageRefresh}
<title>OpenHAB Linky binding for Enedis</title>
<link rel="icon" href="img/favicon.ico" type="image/vnd.microsoft.icon">
<link>
<style>
html {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
.block {
border: 1px solid #bbb;
background-color: white;
margin: 10px 0;
padding: 8px 10px;
}
.error {
background: #FFC0C0;
border: 1px solid darkred;
color: darkred
}
.authorized {
border: 1px solid #90EE90;
background-color: #E0FFE0;
}
.button {
margin-left:30px;
margin-bottom: 10px;
float:left;
}
.olList {
margin-top:20px;
}
.olList li {
margin:20px;
}
.box {
float:left;
transform: translate(0%, -30%);
}
.box select {
background-color: #0563af;
color: white;
padding: 12px;
width: 350px;
border: none;
font-size: 20px;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.2);
-webkit-appearance: button;
appearance: button;
outline: none;
}
/* Style the arrow inside the select element: */
.box::before {
content: "\f13a";
font-family: FontAwesome;
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
text-align: center;
font-size: 28px;
line-height: 45px;
color: rgba(255, 255, 255, 0.5);
background-color: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.logo {
display: inline;
}
.logoEnedis {
display: inline;
}
.logoTransfer {
display: inline;
margin-left:300px;
top: -30px;
}
.button a {
background: #1ED760;
border-radius: 500px;
color: white;
padding: 10px 20px 10px;
font-size: 16px;
font-weight: 700;
border-width: 0;
text-decoration: none;
}
</style>
<script language="javascript">
function retrieveToken()
{
var prmId = document.getElementById('prmId').value;
document.location= "${retrieveToken.uri}" + "&code=" + prmId;
}
</script>
</head>
<body>
<div style="margin:50px">
<div class="logo" style="vertical-align:top;">
<div style="display: inline-block;width:500;margin-right:100px;">
<img src="/connectlinky/img/openhab_logo.svg" height="100"/>
</div>
<div style="display: inline-block;width:100;">
</div>
<div style="display: inline-block;width:300;">
<img src="/connectlinky/img/MyElectricalData.png" height="100">
</div>
</div>
<br/><br/><br/>
<hr style="margin:0px;padding-left:20px;padding-right:20px;width:97%;"/>
<div style="display: block;width:400;margin-top:0px;margin-bottom:0px;float:right;top:60px;right:160px;position:absolute;">
<svg xmlns="http://www.w3.org/2000/svg" width="400" version="1.1">
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<circle cx="100" cy="110" r="20" stroke="black" stroke-width="1" fill="none" />
<text x="100" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">1</text>
<polyline points="130,110 170,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="200" cy="110" r="20" stroke="red" stroke-width="1" fill="none" />
<text x="200" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">2</text>
<polyline points="230,110 270,110" fill="none" stroke="black" marker-end="url(#arrow)" />
<circle cx="300" cy="110" r="20" stroke="black" stroke-width="1" fill="red" />
<text x="300" y="110" stroke="#51c5cf" stroke-width="2px" dy=".3em" text-anchor="middle">3</text>
</svg>
</div>
<div style="background:linear-gradient(#faf3ff, #e8cbfd);padding:20px;margin:0px;display:inline-block; width:97%;align:center;">
<h3>Plugin Linky / MyElectricalData pour Openhab</h3>
<br/>
<div style="display:${cb.displayConfirmation}">
Vous pouvez maintenant utiliser le plugin Linky avec MyElectricalData.
${authorizedUser}
</div>
<div style="display:${cb.displayError}">
Une erreur c'est produite:
${error}
</div>
<div style="display:${cb.displayInstruction}">
<p>
Vous devez maintenant récupérer le token depuis MyElectricalData.
</p>
<p>
Pour ce faire :
<ul>
<li>Sélectionner le numéro de prmId dans la combobox ci-dessous.</li>
<li>Cliquer sur le bouton "Retrive token".</li>
</ul>
</p>
<br/>
<br/><br/><br/>
<div class="block${bridge.authorized}">
<br/>
<div>
<div class="box">
<b>Please select your prmId :</b>
<select id="prmId">
${prmId.Option}
</select>
</div>
<div class="button">
<a href="javascript:retrieveToken()">Retrieve token
</a>
</div>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="88px" height="130px" viewBox="0 0 88 130" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: sketchtool 54.1 (76490) - https://sketchapp.com -->
<title>A0924594-7213-430D-A943-FE07E1B6E747</title>
<desc>Created with sketchtool.</desc>
<defs>
<polygon id="path-1" points="0.110730673 0.170093458 86.2396006 0.170093458 86.2396006 129.793739 0.110730673 129.793739"></polygon>
<polygon id="path-3" points="0.325427204 0.129727087 48.7098019 0.129727087 48.7098019 56.2562126 0.325427204 56.2562126"></polygon>
<path d="M0.196850252,0.568317324 L0.196850252,4.99144282 C0.196850252,5.15434811 0.336285847,5.2833148 0.506637026,5.28578306 L0.506637026,5.28578306 L8.31313756,5.35242613 C8.48285781,5.35242613 8.6222934,5.22099118 8.6222934,5.06055415 L8.6222934,5.06055415 L8.6222934,0.645450511 C8.6222934,0.488098809 8.48285781,0.362834513 8.31313756,0.35913212 L8.31313756,0.35913212 L0.506637026,0.280764802 C0.336285847,0.28508426 0.196850252,0.412199753 0.196850252,0.568317324 L0.196850252,0.568317324 Z" id="path-5"></path>
<path d="M0.196850252,0.568317324 L0.196850252,4.99144282 C0.196850252,5.15434811 0.336285847,5.2833148 0.506637026,5.28578306 L0.506637026,5.28578306 L8.31313756,5.35242613 C8.48285781,5.35242613 8.6222934,5.22099118 8.6222934,5.06055415 L8.6222934,5.06055415 L8.6222934,0.645450511 C8.6222934,0.488098809 8.48285781,0.362834513 8.31313756,0.35913212 L8.31313756,0.35913212 L0.506637026,0.280764802 C0.336285847,0.28508426 0.196850252,0.412199753 0.196850252,0.568317324" id="path-7"></path>
<polygon id="path-9" points="0.363135436 4.99082575 1.58601855 4.98835749 1.58601855 0.284467195 0.363135436 0.271508819"></polygon>
</defs>
<g id="1---CONSOMMATION" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="1-SDM---J-5.1---Pmax-Tableau" transform="translate(-354.000000, -281.000000)">
<g id="Emilie-/-SDM-/-Compteur-Nav-gauche" transform="translate(265.000000, 265.000000)">
<g id="Group-3">
<g id="Atome-/-illustration-/-Compteur-/-Linky" transform="translate(87.000000, 16.000000)">
<g id="Group-61" transform="translate(2.816327, 0.000000)">
<g id="Group-10">
<g id="Group-3">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Clip-2"></g>
<path d="M86.2396006,125.153794 C86.2396006,127.724636 83.7948891,129.804028 80.7559186,129.793701 L5.57894389,129.683748 C2.56124301,129.681318 0.110730673,127.59585 0.110730673,125.035944 L0.110730673,4.80052336 C0.110730673,2.2412243 2.56124301,0.156364486 5.57894389,0.169728972 L80.7559186,0.277859813 C83.7948891,0.292439252 86.2396006,2.36757944 86.2396006,4.92748598 L86.2396006,125.153794 Z" id="Fill-1" fill="#CCD41F" mask="url(#mask-2)"></path>
</g>
<path d="M68.6672615,86.6276869 C68.6672615,89.3388551 66.0730185,91.5373131 62.8761376,91.5415654 L18.4530282,91.4692757 C15.2548582,91.4692757 12.6515917,89.2550234 12.6515917,86.5353505 L12.6515917,31.9049766 C12.6515917,29.1871262 15.2548582,26.9825935 18.4530282,26.9917056 L62.8761376,27.0573131 C66.0730185,27.0621729 68.6672615,29.268528 68.6672615,31.9948832 L68.6672615,86.6276869 Z" id="Fill-4" fill="#B7C243"></path>
<path d="M68.6672615,86.6276869 C68.6672615,89.3388551 66.0730185,91.5373131 62.8761376,91.5415654 L18.4530282,91.4692757 C15.2548582,91.4692757 12.6515917,89.2550234 12.6515917,86.5353505 L12.6515917,31.9049766 C12.6515917,29.1871262 15.2548582,26.9825935 18.4530282,26.9917056 L62.8761376,27.0573131 C66.0730185,27.0621729 68.6672615,29.268528 68.6672615,31.9948832 L68.6672615,86.6276869 Z" id="Stroke-6" stroke="#B7C243" stroke-width="0.365"></path>
<path d="M69.6296774,82.3895654 C69.6296774,84.9537243 67.1765869,87.0227897 64.1530852,87.0312944 L22.1966015,86.9650794 C19.1730998,86.9602196 16.7129195,84.8729299 16.7129195,82.3008738 L16.7129195,30.7012009 C16.7129195,28.1346121 19.1730998,26.0527897 22.1966015,26.0588645 L64.1530852,26.1208271 C67.1765869,26.1226495 69.6296774,28.2069019 69.6296774,30.7844252 L69.6296774,82.3895654 Z" id="Fill-8" fill="#CFCEC4"></path>
</g>
<g id="Group-13" transform="translate(18.647495, 27.318841)">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<g id="Clip-12"></g>
<path d="M48.7098019,51.988681 C48.7098019,54.3470215 46.4711985,56.2519292 43.7082957,56.2562126 L5.33952434,56.2029755 C2.57855861,56.1937967 0.32510436,54.2711434 0.32510436,51.9066837 L0.32510436,4.40576441 C0.32510436,2.03946891 2.57855861,0.129665895 5.33952434,0.129665895 L43.7082957,0.190245997 C46.4711985,0.19820096 48.7098019,2.11228742 48.7098019,4.4810306 L48.7098019,51.988681 Z" id="Fill-11" fill="#F9F9F7" mask="url(#mask-4)"></path>
</g>
<g id="Group-26" transform="translate(16.684601, 25.434783)">
<polygon id="Stroke-14" stroke="#7F8074" stroke-width="0.219" points="25.6782204 61.0846162 12.9698822 61.1106135 12.9698822 53.2775704 25.6782204 53.2449227"></polygon>
<polygon id="Stroke-16" stroke="#7F8074" stroke-width="0.219" points="42.2035583 61.1719792 29.5067872 61.2028132 29.5067872 53.3540508 42.2035583 53.3365178"></polygon>
<polygon id="Fill-18" fill="#9AA3AB" points="9.48357167 28.6337747 9.48357167 40.2285647 45.1190989 40.2811639 45.1190989 28.6797234"></polygon>
<path d="M9.48357167,52.0046103 C9.48357167,52.6950498 10.1551035,53.2579213 10.9673678,53.2645718 L43.6301618,53.3099159 C44.4507801,53.3147526 45.1190989,52.7409985 45.1190989,52.0439085 L45.1190989,40.281043 L9.48357167,40.2284438 L9.48357167,52.0046103 Z" id="Fill-20" fill="#4F4F4F"></path>
<path d="M45.1191631,17.4799108 C45.1191631,16.7767748 44.4508444,16.2157171 43.6302261,16.2247859 L10.9667895,16.1746051 C10.1551678,16.1613042 9.48299332,16.739895 9.48299332,17.4363804 L9.48299332,28.6339561 L45.1191631,28.6799048 L45.1191631,17.4799108 Z" id="Fill-22" fill="#4F4F4F"></path>
<path d="M53.3570915,56.6051023 C53.3570915,59.1570685 50.9113019,61.2162955 47.8967987,61.2247597 L6.0651855,61.1588596 C3.05068234,61.1540229 0.59782398,59.0766583 0.59782398,56.5168324 L0.59782398,5.16251708 C0.59782398,2.60813248 3.05068234,0.53620916 6.0651855,0.542255041 L47.8967987,0.603923024 C50.9113019,0.605736788 53.3570915,2.68007846 53.3570915,5.24534564 L53.3570915,56.6051023 Z" id="Stroke-24" stroke="#B7C243" stroke-width="0.365"></path>
</g>
<g id="Group-33" transform="translate(38.276438, 114.927536)">
<g id="Group-29">
<mask id="mask-6" fill="white">
<use xlink:href="#path-5"></use>
</mask>
<g id="Clip-28"></g>
<path d="M8.62172556,5.06061586 C8.62172556,5.22105289 8.4829209,5.35248784 8.31320065,5.35248784 L0.506069189,5.28584476 C0.33634894,5.2833765 0.196282415,5.15440981 0.196282415,4.99150452 L0.196282415,0.56837903 C0.196282415,0.412261459 0.33634894,0.285763032 0.506069189,0.280826508 L8.31320065,0.359193827 C8.4829209,0.363513285 8.62172556,0.488160516 8.62172556,0.646746349 L8.62172556,5.06061586 Z" id="Fill-27" fill="#4F4F4F" mask="url(#mask-6)"></path>
</g>
<g id="Group-32">
<mask id="mask-8" fill="white">
<use xlink:href="#path-7"></use>
</mask>
<g id="Clip-31"></g>
<path d="M6.60445213,-0.206716942 L5.19432293,-0.220292383 L5.19432293,0.573253848 C5.19432293,1.10578137 4.84100196,1.53711016 4.40250541,1.5290883 C3.97410374,1.5290883 3.6214137,1.09158887 3.6214137,0.563997865 L3.6214137,-0.240655544 L2.2093917,-0.255465116 C2.12043053,-0.249294461 2.03525494,-0.187587911 2.03525494,-0.0931768902 L2.03525494,2.39606533 C2.03525494,2.47813504 2.12043053,2.55650236 2.2093917,2.55650236 L6.60445213,2.59414335 C6.70035354,2.59414335 6.77606517,2.52256376 6.77606517,2.4287698 L6.77606517,-0.0505993709 C6.77606517,-0.138222672 6.70035354,-0.206716942 6.60445213,-0.206716942" id="Fill-30" fill="#FEFEFE" mask="url(#mask-8)"></path>
</g>
</g>
<polygon id="Fill-34" fill="#4F4F4F" points="42.2022263 113.967631 42.2022263 109.275362 44.1651206 109.275362 44.1651206 113.985507"></polygon>
<g id="Group-38" transform="translate(42.202226, 119.637681)">
<mask id="mask-10" fill="white">
<use xlink:href="#path-9"></use>
</mask>
<g id="Clip-37"></g>
<polygon id="Fill-36" mask="url(#mask-10)" points="0.363135436 4.99082575 1.58601855 4.98835749 1.58601855 0.284467195 0.363135436 0.271508819"></polygon>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 113 KiB

View File

@ -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));
}
}