[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:
Gaël L'hopital
2020-10-15 19:58:38 +02:00
committed by GitHub
parent f90f91ff1f
commit 9b7fb69e8d
20 changed files with 772 additions and 379 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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