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