update linky binding to use new dataconnect api

Signed-off-by: Laurent ARNAL <laurent@clae.net>
This commit is contained in:
Laurent ARNAL 2024-01-25 14:57:24 +01:00
parent 9544767641
commit 43585f32ed
26 changed files with 1142 additions and 211 deletions

View File

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

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Gaël L'hopital - Initial contribution *
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
@NonNullByDefault
public interface LinkyAccountHandler {
/**
* @return Returns true if the Spotify Bridge is authorized.
*/
boolean isAuthorized();
/**
* Calls Smartthings Api to obtain refresh and access tokens and persist data with Thing.
*
* @param redirectUrl The redirect url Smartthings calls back to
* @param reqCode The unique code passed by Smartthings to obtain the refresh and access tokens
* @return returns the name of the Smartthings user that is authorized
*/
String authorize(String redirectUrl, String reqCode);
/**
* Formats the Url to use to call Smartthings to authorize the application.
*
* @param redirectUri The uri Smartthings will redirect back to
* @return the formatted url that should be used to call Smartthings Web Api with
*/
String formatAuthorizationUrl(String redirectUri);
}

View File

@ -0,0 +1,202 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Hashtable;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.http.HttpService;
import org.osgi.service.http.NamespaceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* @author Gaël L'hopital - Initial contribution *
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
@Component(service = LinkyAuthService.class, configurationPid = "binding.internal.authService")
@NonNullByDefault
public class LinkyAuthService {
private static final String TEMPLATE_PATH = "templates/";
private static final String TEMPLATE_INDEX = TEMPLATE_PATH + "index.html";
private static final String ERROR_UKNOWN_BRIDGE = "Returned 'state' by doesn't match any Bridges. Has the bridge been removed?";
private final Logger logger = LoggerFactory.getLogger(LinkyAuthService.class);
// private final List<SpotifyAccountHandler> handlers = new ArrayList<>();
private @NonNullByDefault({}) HttpService httpService;
private @NonNullByDefault({}) BundleContext bundleContext;
private @Nullable LinkyAccountHandler accountHandler;
@Activate
protected void activate(ComponentContext componentContext, Map<String, Object> properties) {
try {
bundleContext = componentContext.getBundleContext();
httpService.registerServlet(LinkyBindingConstants.LINKY_ALIAS, createServlet(), new Hashtable<>(),
httpService.createDefaultHttpContext());
httpService.registerResources(LinkyBindingConstants.LINKY_ALIAS + LinkyBindingConstants.LINKY_IMG_ALIAS,
"web", null);
} catch (NamespaceException | ServletException | IOException e) {
logger.warn("Error during spotify servlet startup", e);
}
}
@Deactivate
protected void deactivate(ComponentContext componentContext) {
httpService.unregister(LinkyBindingConstants.LINKY_ALIAS);
httpService.unregister(LinkyBindingConstants.LINKY_ALIAS + LinkyBindingConstants.LINKY_IMG_ALIAS);
}
/**
* Creates a new {@link SpotifyAuthServlet}.
*
* @return the newly created servlet
* @throws IOException thrown when an HTML template could not be read
*/
private HttpServlet createServlet() throws IOException {
return new LinkyAuthServlet(this, readTemplate(TEMPLATE_INDEX));
}
/**
* Reads a template from file and returns the content as String.
*
* @param templateName name of the template file to read
* @return The content of the template file
* @throws IOException thrown when an HTML template could not be read
*/
private String readTemplate(String templateName) throws IOException {
final URL index = bundleContext.getBundle().getEntry(templateName);
if (index == null) {
throw new FileNotFoundException(
String.format("Cannot find '{}' - failed to initialize Linky servlet", templateName));
} else {
try (InputStream inputStream = index.openStream()) {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
/**
* Call with Spotify redirect uri returned State and Code values to get the refresh and access tokens and persist
* these values
*
* @param servletBaseURL the servlet base, which will be the Spotify redirect url
* @param state The Spotify returned state value
* @param code The Spotify returned code value
* @return returns the name of the Spotify user that is authorized
*/
public String authorize(String servletBaseURL, String state, String code) {
LinkyAccountHandler accountHandler = getLinkyAccountHandler();
if (accountHandler == null) {
logger.debug(
"Linky redirected with state '{}' but no matching bridge was found. Possible bridge has been removed.",
state);
throw new RuntimeException(ERROR_UKNOWN_BRIDGE);
} else {
return accountHandler.authorize(servletBaseURL, code);
}
}
/**
* @param listener Adds the given handler
*/
public void setLinkyAccountHandler(LinkyAccountHandler accountHandler) {
this.accountHandler = accountHandler;
}
/**
* @param handler Removes the given handler
*/
public void unsetLinkyAccountHandler(LinkyAccountHandler accountHandler) {
this.accountHandler = null;
}
/**
* @param listener Adds the given handler
*/
public @Nullable LinkyAccountHandler getLinkyAccountHandler() {
return this.accountHandler;
}
/**
* @param listener Adds the given handler
*/
/*
* public void addSpotifyAccountHandler(SpotifyAccountHandler listener) {
* if (!handlers.contains(listener)) {
* handlers.add(listener);
* }
* }
*/
/**
* @param handler Removes the given handler
*/
/*
* public void removeSpotifyAccountHandler(SpotifyAccountHandler handler) {
* handlers.remove(handler);
* }
*/
/**
* @return Returns all {@link SpotifyAccountHandler}s.
*/
/*
* public List<SpotifyAccountHandler> getSpotifyAccountHandlers() {
* return handlers;
* }
*/
/**
* Get the {@link SpotifyAccountHandler} that matches the given thing UID.
*
* @param thingUID UID of the thing to match the handler with
* @return the {@link SpotifyAccountHandler} matching the thing UID or null
*/
/*
* private @Nullable SpotifyAccountHandler getSpotifyAuthListener(String thingUID) {
* final Optional<SpotifyAccountHandler> maybeListener = handlers.stream().filter(l -> l.equalsThingUID(thingUID))
* .findFirst();
* return maybeListener.isPresent() ? maybeListener.get() : null;
* }
*/
@Reference
protected void setHttpService(HttpService httpService) {
this.httpService = httpService;
}
protected void unsetHttpService(HttpService httpService) {
this.httpService = null;
}
}

View File

@ -0,0 +1,104 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The xxx manages the authorization with the Linky Web API. The servlet implements the
* Authorization Code flow and saves the resulting refreshToken with the bridge.
*
* @author Gaël L'hopital - Initial contribution *
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
@NonNullByDefault
public class LinkyAuthServlet extends HttpServlet {
private static final long serialVersionUID = -4719613645562518231L;
private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}");
// Keys present in the index.html
private static final String KEY_BRIDGE_URI = "bridge.uri";
private final Logger logger = LoggerFactory.getLogger(LinkyAuthServlet.class);
private final LinkyAuthService linkyAuthService;
private final String indexTemplate;
public LinkyAuthServlet(LinkyAuthService linkyAuthService, String indexTemplate) {
this.linkyAuthService = linkyAuthService;
this.indexTemplate = indexTemplate;
}
@Override
protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
throws ServletException, IOException {
logger.debug("Linky auth callback servlet received GET request {}.", req.getRequestURI());
final Map<String, String> replaceMap = new HashMap<>();
/*
*
* final String servletBaseURL = req.getRequestURL().toString();
* final String queryString = req.getQueryString();
*
*
* String servletBaseURLSecure = servletBaseURL.replace("http://", "https://").replace("8080", "8443");
* handleSmartthingsRedirect(replaceMap, servletBaseURLSecure, queryString);
* resp.setContentType(CONTENT_TYPE);
* LinkyAccountHandler accountHandler = linkyAuthService.getLinkyAccountHandler();
*/
String uri = "https://mon-compte-particulier.enedis.fr/dataconnect/v1/oauth2/authorize?client_id=e551937c-5250-48bc-b4a6-2323af68db92&duration=P36M&response_type=code";
// replaceMap.put(KEY_REDIRECT_URI, servletBaseURLSecure);
replaceMap.put(KEY_BRIDGE_URI, uri);
resp.getWriter().append(replaceKeysFromMap(indexTemplate, replaceMap));
resp.getWriter().close();
}
/**
* Replaces all keys from the map found in the template with values from the map. If the key is not found the key
* will be kept in the template.
*
* @param template template to replace keys with values
* @param map map with key value pairs to replace in the template
* @return a template with keys replaced
*/
private String replaceKeysFromMap(String template, Map<String, String> map) {
final Matcher m = MESSAGE_KEY_PATTERN.matcher(template);
final StringBuffer sb = new StringBuffer();
while (m.find()) {
try {
final String key = m.group(1);
m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}')));
} catch (RuntimeException e) {
logger.debug("Error occurred during template filling, cause ", e);
}
}
m.appendTail(sb);
return sb.toString();
}
}

View File

@ -12,6 +12,9 @@
*/ */
package org.openhab.binding.linky.internal; package org.openhab.binding.linky.internal;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingTypeUID;
@ -20,6 +23,7 @@ import org.openhab.core.thing.ThingTypeUID;
* used across the whole binding. * used across the whole binding.
* *
* @author Gaël L'hopital - Initial contribution * @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API *
*/ */
@NonNullByDefault @NonNullByDefault
public class LinkyBindingConstants { public class LinkyBindingConstants {
@ -32,7 +36,7 @@ public class LinkyBindingConstants {
// Thing properties // Thing properties
public static final String PUISSANCE = "puissance"; public static final String PUISSANCE = "puissance";
public static final String PRM_ID = "prmId"; public static final String PRM_ID = "prmId";
public static final String USER_ID = "av2_interne_id"; public static final String USER_ID = "customerId";
// List of all Channel id's // List of all Channel id's
public static final String YESTERDAY = "daily#yesterday"; public static final String YESTERDAY = "daily#yesterday";
@ -44,4 +48,21 @@ public class LinkyBindingConstants {
public static final String LAST_MONTH = "monthly#lastMonth"; public static final String LAST_MONTH = "monthly#lastMonth";
public static final String THIS_YEAR = "yearly#thisYear"; public static final String THIS_YEAR = "yearly#thisYear";
public static final String LAST_YEAR = "yearly#lastYear"; public static final String LAST_YEAR = "yearly#lastYear";
// Authorization related Servlet and resources aliases.
public static final String LINKY_ALIAS = "/connectlinky";
public static final String LINKY_IMG_ALIAS = "/img";
/**
* Smartthings scopes needed by this binding to work.
*/
public static final String LINKY_SCOPES = Stream.of("r:devices:*", "w:devices:*", "x:devices:*", "r:hubs:*",
"r:locations:*", "w:locations:*", "x:locations:*", "r:scenes:*", "x:scenes:*", "r:rules:*", "w:rules:*",
"r:installedapps", "w:installedapps").collect(Collectors.joining(" "));
// List of Spotify services related urls, information
public static final String LINKY_ACCOUNT_URL = "https://api.smartthings.com/oauth";
public static final String LINKY_AUTHORIZE_URL = LINKY_ACCOUNT_URL + "/authorize";
public static final String LINKY_API_TOKEN_URL = LINKY_ACCOUNT_URL + "/token";
} }

View File

@ -19,15 +19,14 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* thing configuration. * thing configuration.
* *
* @author Gaël L'hopital - Initial contribution * @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/ */
@NonNullByDefault @NonNullByDefault
public class LinkyConfiguration { public class LinkyConfiguration {
public static final String INTERNAL_AUTH_ID = "internalAuthId"; public String token = "";
public String username = ""; public String prmId = "";
public String password = "";
public String internalAuthId = "";
public boolean seemsValid() { public boolean seemsValid() {
return !username.isBlank() && !password.isBlank() && !internalAuthId.isBlank(); return !token.isBlank() && !prmId.isBlank();
} }
} }

View File

@ -12,8 +12,6 @@
*/ */
package org.openhab.binding.linky.internal; package org.openhab.binding.linky.internal;
import static org.openhab.binding.linky.internal.LinkyBindingConstants.THING_TYPE_LINKY;
import java.security.KeyManagementException; import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
@ -27,6 +25,7 @@ import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.openhab.binding.linky.internal.handler.LinkyHandler; import org.openhab.binding.linky.internal.handler.LinkyHandler;
import org.openhab.core.auth.client.oauth2.OAuthFactory;
import org.openhab.core.i18n.LocaleProvider; import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.io.net.http.TrustAllTrustManager; import org.openhab.core.io.net.http.TrustAllTrustManager;
@ -50,10 +49,11 @@ import com.google.gson.JsonDeserializer;
* The {@link LinkyHandlerFactory} is responsible for creating things handlers. * The {@link LinkyHandlerFactory} is responsible for creating things handlers.
* *
* @author Gaël L'hopital - Initial contribution * @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/ */
@NonNullByDefault @NonNullByDefault
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.linky") @Component(service = ThingHandlerFactory.class, configurationPid = "binding.linky")
public class LinkyHandlerFactory extends BaseThingHandlerFactory { public class LinkyHandlerFactory extends BaseThingHandlerFactory implements LinkyAccountHandler {
private static final DateTimeFormatter LINKY_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX"); private static final DateTimeFormatter LINKY_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSX");
private static final int REQUEST_BUFFER_SIZE = 8000; private static final int REQUEST_BUFFER_SIZE = 8000;
private static final int RESPONSE_BUFFER_SIZE = 200000; private static final int RESPONSE_BUFFER_SIZE = 200000;
@ -65,10 +65,13 @@ public class LinkyHandlerFactory extends BaseThingHandlerFactory {
.create(); .create();
private final LocaleProvider localeProvider; private final LocaleProvider localeProvider;
private final HttpClient httpClient; private final HttpClient httpClient;
private final OAuthFactory oAuthFactory;
private final LinkyAuthService authService;
@Activate @Activate
public LinkyHandlerFactory(final @Reference LocaleProvider localeProvider, public LinkyHandlerFactory(final @Reference LocaleProvider localeProvider,
final @Reference HttpClientFactory httpClientFactory) { final @Reference HttpClientFactory httpClientFactory, final @Reference LinkyAuthService authService,
final @Reference OAuthFactory oAuthFactory) {
this.localeProvider = localeProvider; this.localeProvider = localeProvider;
SslContextFactory sslContextFactory = new SslContextFactory.Client(); SslContextFactory sslContextFactory = new SslContextFactory.Client();
try { try {
@ -85,6 +88,8 @@ public class LinkyHandlerFactory extends BaseThingHandlerFactory {
httpClient.setFollowRedirects(false); httpClient.setFollowRedirects(false);
httpClient.setRequestBufferSize(REQUEST_BUFFER_SIZE); httpClient.setRequestBufferSize(REQUEST_BUFFER_SIZE);
httpClient.setResponseBufferSize(RESPONSE_BUFFER_SIZE); httpClient.setResponseBufferSize(RESPONSE_BUFFER_SIZE);
this.oAuthFactory = oAuthFactory;
this.authService = authService;
} }
@Override @Override
@ -109,12 +114,32 @@ public class LinkyHandlerFactory extends BaseThingHandlerFactory {
@Override @Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) { public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return THING_TYPE_LINKY.equals(thingTypeUID); return LinkyBindingConstants.THING_TYPE_LINKY.equals(thingTypeUID);
} }
@Override @Override
protected @Nullable ThingHandler createHandler(Thing thing) { protected @Nullable ThingHandler createHandler(Thing thing) {
return supportsThingType(thing.getThingTypeUID()) ? new LinkyHandler(thing, localeProvider, gson, httpClient)
authService.setLinkyAccountHandler(this);
return supportsThingType(thing.getThingTypeUID())
? new LinkyHandler(thing, localeProvider, gson, httpClient, oAuthFactory)
: null; : null;
} }
@Override
public boolean isAuthorized() {
return true;
}
@Override
public String authorize(String redirectUrl, String reqCode) {
return "";
}
@Override
public String formatAuthorizationUrl(String redirectUri) {
return "";
}
} }

View File

@ -12,8 +12,6 @@
*/ */
package org.openhab.binding.linky.internal.api; package org.openhab.binding.linky.internal.api;
import java.net.HttpCookie;
import java.net.URI;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
@ -22,8 +20,6 @@ import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
@ -42,13 +38,17 @@ import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import org.openhab.binding.linky.internal.LinkyConfiguration; import org.openhab.binding.linky.internal.LinkyConfiguration;
import org.openhab.binding.linky.internal.LinkyException; import org.openhab.binding.linky.internal.LinkyException;
import org.openhab.binding.linky.internal.dto.AuthData; import org.openhab.binding.linky.internal.dto.AddressInfo;
import org.openhab.binding.linky.internal.dto.AuthResult;
import org.openhab.binding.linky.internal.dto.ConsumptionReport; import org.openhab.binding.linky.internal.dto.ConsumptionReport;
import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption; import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption;
import org.openhab.binding.linky.internal.dto.PrmDetail; import org.openhab.binding.linky.internal.dto.ContactInfo;
import org.openhab.binding.linky.internal.dto.Customer;
import org.openhab.binding.linky.internal.dto.CustomerIdResponse;
import org.openhab.binding.linky.internal.dto.CustomerReponse;
import org.openhab.binding.linky.internal.dto.IdentityInfo;
import org.openhab.binding.linky.internal.dto.MeterResponse;
import org.openhab.binding.linky.internal.dto.PrmInfo; import org.openhab.binding.linky.internal.dto.PrmInfo;
import org.openhab.binding.linky.internal.dto.UserInfo; import org.openhab.binding.linky.internal.dto.UsagePoint;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -59,23 +59,18 @@ import com.google.gson.JsonSyntaxException;
* {@link EnedisHttpApi} wraps the Enedis Webservice. * {@link EnedisHttpApi} wraps the Enedis Webservice.
* *
* @author Gaël L'hopital - Initial contribution * @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/ */
@NonNullByDefault @NonNullByDefault
public class EnedisHttpApi { public class EnedisHttpApi {
private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy"); private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final String ENEDIS_DOMAIN = ".enedis.fr";
private static final String URL_APPS_LINCS = "https://alex.microapplications" + ENEDIS_DOMAIN; private static final String BASE_URL = "https://www.myelectricaldata.fr/";
private static final String URL_MON_COMPTE = "https://mon-compte" + ENEDIS_DOMAIN; private static final String CONTRACT_URL = BASE_URL + "contracts";
private static final String URL_COMPTE_PART = URL_MON_COMPTE.replace("compte", "compte-particulier"); private static final String IDENTITY_URL = BASE_URL + "identity";
private static final String URL_ENEDIS_AUTHENTICATE = URL_APPS_LINCS + "/authenticate?target=" + URL_COMPTE_PART; private static final String CONTACT_URL = BASE_URL + "contact";
private static final String USER_INFO_CONTRACT_URL = URL_APPS_LINCS + "/mon-compte-client/api/private/v1/userinfos"; private static final String ADDRESS_URL = BASE_URL + "addresses";
private static final String USER_INFO_URL = URL_APPS_LINCS + "/userinfos"; private static final String MEASURE_URL = BASE_URL + "%s/%s/start/%s/end/%s/cache";
private static final String PRM_INFO_BASE_URL = URL_APPS_LINCS + "/mes-mesures/api/private/v1/personnes/";
private static final String PRM_INFO_URL = URL_APPS_LINCS + "/mes-prms-part/api/private/v2/personnes/%s/prms";
private static final String MEASURE_URL = PRM_INFO_BASE_URL
+ "%s/prms/%s/donnees-%s?dateDebut=%s&dateFin=%s&mesuretypecode=CONS";
private static final URI COOKIE_URI = URI.create(URL_COMPTE_PART);
private static final Pattern REQ_PATTERN = Pattern.compile("ReqID%(.*?)%26");
private final Logger logger = LoggerFactory.getLogger(EnedisHttpApi.class); private final Logger logger = LoggerFactory.getLogger(EnedisHttpApi.class);
private final Gson gson; private final Gson gson;
@ -91,108 +86,6 @@ public class EnedisHttpApi {
} }
public void initialize() throws LinkyException { public void initialize() throws LinkyException {
logger.debug("Starting login process for user: {}", config.username);
try {
addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, config.internalAuthId);
logger.debug("Step 1: getting authentification");
String data = getContent(URL_ENEDIS_AUTHENTICATE);
logger.debug("Reception request SAML");
Document htmlDocument = Jsoup.parse(data);
Element el = htmlDocument.select("form").first();
Element samlInput = el.select("input[name=SAMLRequest]").first();
logger.debug("Step 2: send SSO SAMLRequest");
ContentResponse result = httpClient.POST(el.attr("action"))
.content(getFormContent("SAMLRequest", samlInput.attr("value"))).send();
if (result.getStatus() != HttpStatus.FOUND_302) {
throw new LinkyException("Connection failed step 2");
}
logger.debug("Get the location and the ReqID");
Matcher m = REQ_PATTERN.matcher(getLocation(result));
if (!m.find()) {
throw new LinkyException("Unable to locate ReqId in header");
}
String reqId = m.group(1);
String authenticateUrl = URL_MON_COMPTE
+ "/auth/json/authenticate?realm=/enedis&forward=true&spEntityID=SP-ODW-PROD&goto=/auth/SSOPOST/metaAlias/enedis/providerIDP?ReqID%"
+ reqId + "%26index%3Dnull%26acsURL%3D" + URL_APPS_LINCS
+ "/saml/SSO%26spEntityID%3DSP-ODW-PROD%26binding%3Durn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST&AMAuthCookie=";
logger.debug("Step 3: auth1 - retrieve the template, thanks to cookie internalAuthId user is already set");
result = httpClient.POST(authenticateUrl).header("X-NoSession", "true").header("X-Password", "anonymous")
.header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous").send();
if (result.getStatus() != HttpStatus.OK_200) {
throw new LinkyException("Connection failed step 3 - auth1: %s", result.getContentAsString());
}
AuthData authData = gson.fromJson(result.getContentAsString(), AuthData.class);
if (authData == null || authData.callbacks.size() < 2 || authData.callbacks.get(0).input.isEmpty()
|| authData.callbacks.get(1).input.isEmpty() || !config.username
.equals(Objects.requireNonNull(authData.callbacks.get(0).input.get(0)).valueAsString())) {
logger.debug("auth1 - invalid template for auth data: {}", result.getContentAsString());
throw new LinkyException("Authentication error, the authentication_cookie is probably wrong");
}
authData.callbacks.get(1).input.get(0).value = config.password;
logger.debug("Step 4: auth2 - send the auth data");
result = httpClient.POST(authenticateUrl).header(HttpHeader.CONTENT_TYPE, MediaType.APPLICATION_JSON)
.header("X-NoSession", "true").header("X-Password", "anonymous")
.header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous")
.content(new StringContentProvider(gson.toJson(authData))).send();
if (result.getStatus() != HttpStatus.OK_200) {
throw new LinkyException("Connection failed step 3 - auth2: %s", result.getContentAsString());
}
AuthResult authResult = gson.fromJson(result.getContentAsString(), AuthResult.class);
if (authResult == null) {
throw new LinkyException("Invalid authentication result data");
}
logger.debug("Add the tokenId cookie");
addCookie("enedisExt", authResult.tokenId);
logger.debug("Step 5: retrieve the SAMLresponse");
data = getContent(URL_MON_COMPTE + "/" + authResult.successUrl);
htmlDocument = Jsoup.parse(data);
el = htmlDocument.select("form").first();
samlInput = el.select("input[name=SAMLResponse]").first();
logger.debug("Step 6: post the SAMLresponse to finish the authentication");
result = httpClient.POST(el.attr("action")).content(getFormContent("SAMLResponse", samlInput.attr("value")))
.send();
if (result.getStatus() != HttpStatus.FOUND_302) {
throw new LinkyException("Connection failed step 6");
}
logger.debug("Step 7: retrieve cookieKey");
result = httpClient.GET(USER_INFO_CONTRACT_URL);
@SuppressWarnings("unchecked")
HashMap<String, String> hashRes = gson.fromJson(result.getContentAsString(), HashMap.class);
String cookieKey;
if (hashRes != null && hashRes.containsKey("cnAlex")) {
cookieKey = "personne_for_" + hashRes.get("cnAlex");
} else {
throw new LinkyException("Connection failed step 7, missing cookieKey");
}
List<HttpCookie> lCookie = httpClient.getCookieStore().getCookies();
Optional<HttpCookie> cookie = lCookie.stream().filter(it -> it.getName().contains(cookieKey)).findFirst();
String cookieVal = cookie.map(HttpCookie::getValue)
.orElseThrow(() -> new LinkyException("Connection failed step 7, missing cookieVal"));
addCookie(cookieKey, cookieVal);
connected = true;
} catch (InterruptedException | TimeoutException | ExecutionException | JsonSyntaxException e) {
throw new LinkyException(e, "Error opening connection with Enedis webservice");
}
} }
private String getLocation(ContentResponse response) { private String getLocation(ContentResponse response) {
@ -203,14 +96,6 @@ public class EnedisHttpApi {
if (connected) { if (connected) {
logger.debug("Logout process"); logger.debug("Logout process");
connected = false; connected = false;
try { // Three times in a row to get disconnected
String location = getLocation(httpClient.GET(URL_APPS_LINCS + "/logout"));
location = getLocation(httpClient.GET(location));
getLocation(httpClient.GET(location));
httpClient.getCookieStore().removeAll();
} catch (InterruptedException | ExecutionException | TimeoutException e) {
throw new LinkyException(e, "Error while disconnecting from Enedis webservice");
}
} }
} }
@ -222,26 +107,14 @@ public class EnedisHttpApi {
disconnect(); disconnect();
} }
private void addCookie(String key, String value) { private String getData(String url) throws LinkyException {
HttpCookie cookie = new HttpCookie(key, value);
cookie.setDomain(ENEDIS_DOMAIN);
cookie.setPath("/");
httpClient.getCookieStore().add(COOKIE_URI, cookie);
}
private FormContentProvider getFormContent(String fieldName, String fieldValue) {
Fields fields = new Fields();
fields.put(fieldName, fieldValue);
return new FormContentProvider(fields);
}
private String getContent(String url) throws LinkyException {
try { try {
Request request = httpClient.newRequest(url) Request request = httpClient.newRequest(url);
.agent("Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0");
request = request.method(HttpMethod.GET); request = request.method(HttpMethod.GET);
request = request.header("Authorization", config.token);
ContentResponse result = request.send(); ContentResponse result = request.send();
if (result.getStatus() != HttpStatus.OK_200) { if (result.getStatus() != 200) {
throw new LinkyException("Error requesting '%s' : %s", url, result.getContentAsString()); throw new LinkyException("Error requesting '%s' : %s", url, result.getContentAsString());
} }
String content = result.getContentAsString(); String content = result.getContentAsString();
@ -252,54 +125,134 @@ public class EnedisHttpApi {
} }
} }
private <T> T getData(String url, Class<T> clazz) throws LinkyException { public PrmInfo getPrmInfo() throws LinkyException {
PrmInfo result = new PrmInfo();
Customer customer = getCustomer();
UsagePoint usagePoint = customer.usagePoints[0];
result.contractInfo = usagePoint.contracts;
result.usagePointInfo = usagePoint.usagePoint;
result.identityInfo = getIdentity();
result.addressInfo = getAddress();
result.contactInfo = getContact();
result.prmId = result.usagePointInfo.usagePointId;
result.customerId = customer.customerId;
return result;
}
public Customer getCustomer() throws LinkyException {
if (!connected) { if (!connected) {
initialize(); initialize();
} }
String data = getContent(url); String data = getData(String.format("%s/%s/cache", CONTRACT_URL, config.prmId));
if (data.isEmpty()) { if (data.isEmpty()) {
throw new LinkyException("Requesting '%s' returned an empty response", url); throw new LinkyException("Requesting '%s' returned an empty response", CONTRACT_URL);
} }
try { try {
return Objects.requireNonNull(gson.fromJson(data, clazz)); CustomerReponse cResponse = gson.fromJson(data, CustomerReponse.class);
if (cResponse == null) {
throw new LinkyException("Invalid customer data received");
}
return cResponse.customer;
} catch (JsonSyntaxException e) { } catch (JsonSyntaxException e) {
logger.debug("Invalid JSON response not matching {}: {}", clazz.getName(), data); logger.debug("invalid JSON response not matching PrmInfo[].class: {}", data);
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url); throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", CONTRACT_URL);
} }
} }
public PrmInfo getPrmInfo(String internId) throws LinkyException { public AddressInfo getAddress() throws LinkyException {
String url = PRM_INFO_URL.formatted(internId); if (!connected) {
PrmInfo[] prms = getData(url, PrmInfo[].class); initialize();
if (prms.length < 1) { }
throw new LinkyException("Invalid prms data received"); String data = getData(String.format("%s/%s/cache", ADDRESS_URL, "21454992660003"));
if (data.isEmpty()) {
throw new LinkyException("Requesting '%s' returned an empty response", ADDRESS_URL);
}
try {
CustomerReponse cResponse = gson.fromJson(data, CustomerReponse.class);
if (cResponse == null) {
throw new LinkyException("Invalid customer data received");
}
return cResponse.customer.usagePoints[0].usagePoint.usagePointAddresses;
} catch (JsonSyntaxException e) {
logger.debug("invalid JSON response not matching PrmInfo[].class: {}", data);
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", ADDRESS_URL);
} }
return prms[0];
} }
public PrmDetail getPrmDetails(String internId, String prmId) throws LinkyException { public IdentityInfo getIdentity() throws LinkyException {
String url = PRM_INFO_URL.formatted(internId) + "/" + prmId if (!connected) {
+ "?embed=SITALI&embed=SITCOM&embed=SITCON&embed=SYNCON"; initialize();
return getData(url, PrmDetail.class); }
String data = getData(String.format("%s/%s/cache", IDENTITY_URL, "21454992660003"));
if (data.isEmpty()) {
throw new LinkyException("Requesting '%s' returned an empty response", IDENTITY_URL);
}
try {
CustomerIdResponse iResponse = gson.fromJson(data, CustomerIdResponse.class);
if (iResponse == null) {
throw new LinkyException("Invalid customer data received");
}
return iResponse.identity.naturalPerson;
} catch (JsonSyntaxException e) {
logger.debug("invalid JSON response not matching PrmInfo[].class: {}", data);
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", IDENTITY_URL);
}
} }
public UserInfo getUserInfo() throws LinkyException { public ContactInfo getContact() throws LinkyException {
return getData(USER_INFO_URL, UserInfo.class); if (!connected) {
initialize();
}
String data = getData(String.format("%s/%s/cache", CONTACT_URL, "21454992660003"));
if (data.isEmpty()) {
throw new LinkyException("Requesting '%s' returned an empty response", CONTACT_URL);
}
try {
CustomerIdResponse cResponse = gson.fromJson(data, CustomerIdResponse.class);
if (cResponse == null) {
throw new LinkyException("Invalid customer data received");
}
return cResponse.contactData;
} catch (JsonSyntaxException e) {
logger.debug("invalid JSON response not matching PrmInfo[].class: {}", data);
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", CONTACT_URL);
}
} }
private Consumption getMeasures(String userId, String prmId, LocalDate from, LocalDate to, String request) private Consumption getMeasures(String userId, String prmId, LocalDate from, LocalDate to, String request)
throws LinkyException { throws LinkyException {
String url = String.format(MEASURE_URL, userId, prmId, request, from.format(API_DATE_FORMAT), String dtStart = from.format(API_DATE_FORMAT);
to.format(API_DATE_FORMAT)); String dtEnd = to.format(API_DATE_FORMAT);
ConsumptionReport report = getData(url, ConsumptionReport.class);
return report.firstLevel.consumptions; String url = String.format(MEASURE_URL, request, prmId, dtStart, dtEnd);
if (!connected) {
initialize();
}
String data = getData(url);
if (data.isEmpty()) {
throw new LinkyException("Requesting '%s' returned an empty response", url);
}
logger.trace("getData returned {}", data);
try {
MeterResponse meterResponse = gson.fromJson(data, MeterResponse.class);
if (meterResponse == null) {
throw new LinkyException("No report data received");
}
return new ConsumptionReport().new Consumption();
} catch (JsonSyntaxException e) {
logger.debug("invalid JSON response not matching ConsumptionReport.class: {}", data);
throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url);
}
} }
public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException { public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
return getMeasures(userId, prmId, from, to, "energie"); return getMeasures(userId, prmId, from, to, "daily_consumption");
} }
public Consumption getPowerData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException { public Consumption getPowerData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
return getMeasures(userId, prmId, from, to, "pmax"); return getMeasures(userId, prmId, from, to, "daily_consumption_max_power");
} }
} }

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class AddressInfo {
public String street;
public String locality;
@SerializedName("postalCode")
public String postal_code;
@SerializedName("insee_code")
public String inseeCode;
public String city;
public String country;
}

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class ContactInfo {
public String phone;
public String email;
}

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class Contracts {
public String segment;
@SerializedName("subscribed_power")
public String subscribedPower;
@SerializedName("last_activation_date")
public String lastActivationDate;
@SerializedName("distribution_tariff")
public String distributionTariff;
@SerializedName("offpeak_hours")
public String offpeakHours;
@SerializedName("contract_status")
public String contractStatus;
@SerializedName("last_distribution_tariff_change_date")
public String lastDistributionTariffChangeDate;
}

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class Customer {
@SerializedName("customer_id")
public String customerId;
@SerializedName("usage_points")
public UsagePoint[] usagePoints;
}

View File

@ -0,0 +1,34 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class CustomerIdResponse {
@SerializedName("customer_id")
public String customerId;
public IdentityDetails identity;
@SerializedName("contact_data")
public ContactInfo contactData;
}

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class CustomerReponse {
public Customer customer;
}

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class IdentityDetails {
@SerializedName("natural_person")
public IdentityInfo naturalPerson;
}

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class IdentityInfo {
public String title;
public String firstname;
public String lastname;
}

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class IntervalReading {
@SerializedName("value")
public String value;
@SerializedName("date")
public String date;
}

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class MeterReading {
@SerializedName("usage_point_id")
public String usagePointId;
@SerializedName("start")
public String startDate;
@SerializedName("end")
public String endDate;
@SerializedName("quality")
public String quality;
@SerializedName("reading_type")
public ReadingType readingType;
@SerializedName("interval_reading")
public IntervalReading[] intervalReading;
}

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class MeterResponse {
@SerializedName("meter_reading")
public MeterReading meterReading;
}

View File

@ -12,12 +12,22 @@
*/ */
package org.openhab.binding.linky.internal.dto; package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
/** /**
* The {@link UserInfo} holds ids of existing Prms * The {@link UserInfo} holds ids of existing Prms
* *
* @author Gaël L'hopital - Initial contribution * @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/ */
public class PrmInfo { public class PrmInfo {
public String idPrm; public String prmId;
public String customerId;
public Contracts contractInfo;
public UsagePointDetails usagePointInfo;
public ContactInfo contactInfo;
public AddressInfo addressInfo;
public IdentityInfo identityInfo;
} }

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class ReadingType {
@SerializedName("measurement_kind")
public String measurementKind;
@SerializedName("measuring_period")
public String measuringPeriod;
@SerializedName("unit")
public String unit;
@SerializedName("aggregate")
public String aggregate;
}

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class UsagePoint {
@SerializedName("usage_point")
public UsagePointDetails usagePoint;
public Contracts contracts;
}

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.linky.internal.dto;
import org.eclipse.jetty.jaas.spi.UserInfo;
import com.google.gson.annotations.SerializedName;
/**
* The {@link UserInfo} holds informations about energy delivery point
*
* @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/
public class UsagePointDetails {
@SerializedName("usage_point_id")
public String usagePointId;
@SerializedName("usage_point_status")
public String usagePointStatus;
@SerializedName("meter_type")
public String meterType;
@SerializedName("usage_point_addresses")
public AddressInfo usagePointAddresses;
}

View File

@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.linky.internal.LinkyBindingConstants;
import org.openhab.binding.linky.internal.LinkyConfiguration; import org.openhab.binding.linky.internal.LinkyConfiguration;
import org.openhab.binding.linky.internal.LinkyException; import org.openhab.binding.linky.internal.LinkyException;
import org.openhab.binding.linky.internal.api.EnedisHttpApi; import org.openhab.binding.linky.internal.api.EnedisHttpApi;
@ -37,7 +38,8 @@ 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.ConsumptionReport.Consumption;
import org.openhab.binding.linky.internal.dto.PrmDetail; import org.openhab.binding.linky.internal.dto.PrmDetail;
import org.openhab.binding.linky.internal.dto.PrmInfo; import org.openhab.binding.linky.internal.dto.PrmInfo;
import org.openhab.binding.linky.internal.dto.UserInfo; import org.openhab.core.auth.client.oauth2.OAuthClientService;
import org.openhab.core.auth.client.oauth2.OAuthFactory;
import org.openhab.core.i18n.LocaleProvider; import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.QuantityType;
@ -61,6 +63,7 @@ import com.google.gson.Gson;
* sent to one of the channels. * sent to one of the channels.
* *
* @author Gaël L'hopital - Initial contribution * @author Gaël L'hopital - Initial contribution
* @author Laurent Arnal - Rewrite addon to use official dataconect API
*/ */
@NonNullByDefault @NonNullByDefault
@ -81,6 +84,8 @@ public class LinkyHandler extends BaseThingHandler {
private @Nullable ScheduledFuture<?> refreshJob; private @Nullable ScheduledFuture<?> refreshJob;
private @Nullable EnedisHttpApi enedisApi; private @Nullable EnedisHttpApi enedisApi;
private final OAuthFactory oAuthFactory;
private @NonNullByDefault({}) String prmId; private @NonNullByDefault({}) String prmId;
private @NonNullByDefault({}) String userId; private @NonNullByDefault({}) String userId;
@ -90,7 +95,10 @@ public class LinkyHandler extends BaseThingHandler {
ALL ALL
} }
public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient) { private @Nullable OAuthClientService oAuthService;
public LinkyHandler(Thing thing, LocaleProvider localeProvider, Gson gson, HttpClient httpClient,
OAuthFactory oAuthFactory) {
super(thing); super(thing);
this.gson = gson; this.gson = gson;
this.httpClient = httpClient; this.httpClient = httpClient;
@ -107,6 +115,8 @@ public class LinkyHandler extends BaseThingHandler {
return consumption; return consumption;
}); });
this.oAuthFactory = oAuthFactory;
this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_FIRST_HOUR_OF_DAY, () -> { this.cachedPowerData = new ExpiringDayCache<>("power cache", REFRESH_FIRST_HOUR_OF_DAY, () -> {
// We request data for yesterday and the day before yesterday, even if the data for the day before yesterday // We request data for yesterday and the day before yesterday, even if the data for the day before yesterday
// is not needed by the binding. This is only a workaround to an API bug that will return // is not needed by the binding. This is only a workaround to an API bug that will return
@ -152,6 +162,11 @@ public class LinkyHandler extends BaseThingHandler {
LinkyConfiguration config = getConfigAs(LinkyConfiguration.class); LinkyConfiguration config = getConfigAs(LinkyConfiguration.class);
if (config.seemsValid()) { if (config.seemsValid()) {
OAuthClientService oAuthService = oAuthFactory.createOAuthClientService(thing.getUID().getAsString(),
LinkyBindingConstants.LINKY_API_TOKEN_URL, LinkyBindingConstants.LINKY_AUTHORIZE_URL, "clientId",
"clientSecret", LinkyBindingConstants.LINKY_SCOPES, true);
enedisApi = new EnedisHttpApi(config, gson, httpClient); enedisApi = new EnedisHttpApi(config, gson, httpClient);
scheduler.submit(() -> { scheduler.submit(() -> {
try { try {
@ -159,15 +174,9 @@ public class LinkyHandler extends BaseThingHandler {
api.initialize(); api.initialize();
updateStatus(ThingStatus.ONLINE); updateStatus(ThingStatus.ONLINE);
if (thing.getProperties().isEmpty()) { PrmInfo prmInfo = api.getPrmInfo();
UserInfo userInfo = api.getUserInfo(); updateProperties(Map.of(USER_ID, prmInfo.customerId, PUISSANCE,
PrmInfo prmInfo = api.getPrmInfo(userInfo.userProperties.internId); prmInfo.contractInfo.subscribedPower, PRM_ID, prmInfo.prmId));
PrmDetail details = api.getPrmDetails(userInfo.userProperties.internId, prmInfo.idPrm);
updateProperties(Map.of(USER_ID, userInfo.userProperties.internId, PUISSANCE,
details.situationContractuelleDtos[0].structureTarifaire().puissanceSouscrite().valeur()
+ " kVA",
PRM_ID, prmInfo.idPrm));
}
prmId = thing.getProperties().get(PRM_ID); prmId = thing.getProperties().get(PRM_ID);
userId = thing.getProperties().get(USER_ID); userId = thing.getProperties().get(USER_ID);
@ -544,4 +553,5 @@ public class LinkyHandler extends BaseThingHandler {
aggregate.datas.get(index)); aggregate.datas.get(index));
} }
} }
} }

View File

@ -20,19 +20,13 @@
</channel-groups> </channel-groups>
<config-description> <config-description>
<parameter name="username" type="text" required="true"> <parameter name="prmId" type="text" required="true">
<label>Username</label> <label>PrmId</label>
<context>email</context> <description>Your PrmId</description>
<description>Your Enedis Username</description>
</parameter> </parameter>
<parameter name="password" type="text" required="true"> <parameter name="token" type="text" required="true">
<label>Password</label> <label>Token</label>
<context>password</context> <description>Your Enedis token</description>
<description>Your Enedis Password</description>
</parameter>
<parameter name="internalAuthId" type="text" required="true">
<label>Auth ID</label>
<description>Authentication ID delivered after the captcha (see documentation).</description>
</parameter> </parameter>
</config-description> </config-description>
</thing-type> </thing-type>

View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
${pageRefresh}
<title>Authorize openHAB binding for Smartthings</title>
<link rel="icon" href="img/favicon.ico" type="image/vnd.microsoft.icon">
<link>
<style>
html {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
.logo {
display: block;
margin: auto;
width: 100%;
}
.block {
border: 1px solid #bbb;
background-color: white;
margin: 10px 0;
padding: 8px 10px;
}
.error {
background: #FFC0C0;
border: 1px solid darkred;
color: darkred
}
.authorized {
border: 1px solid #90EE90;
background-color: #E0FFE0;
}
.button {
margin-bottom: 10px;
}
.button a {
background: #1ED760;
border-radius: 500px;
color: white;
padding: 10px 20px 10px;
font-size: 16px;
font-weight: 700;
border-width: 0;
text-decoration: none;
}
</style>
</head>
<body>
<svg class="logo" xmlns="http://www.w3.org/2000/svg" height="60px" version="1.1" viewBox="0 0 530 180">
<path style="stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1;" d="m 195,90 l 15,10 c 0,0 -5,-5 0,-20 z m 10,3 l 120,0 l 0,-6 l -120,0 z m 130,-3 l -15,-10 c 0,0 5,5 0,20 z" />
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<g transform="matrix(1.0671711,0,0,1.0802403,+350,+3.638968)">
<svg fill="#15BFFF" width="150px" height="150px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>SmartThings icon</title>
<path d="M11.51 0C8.338 0 5.034.537 2.694 2.694.5 5.174 0 8.464 0 11.525v.942c0 3.165.537 6.499 2.694 8.84C5.188 23.513 8.494 24 11.569 24h.854c3.18 0 6.528-.53 8.883-2.694C23.514 18.811 24 15.5 24 12.423v-.853c0-3.18-.53-6.528-2.694-8.876C18.826.494 15.544 0 12.482 0zM12 3.505c1.244 0 2.256.99 2.256 2.206 0 1.065-.685 1.976-1.715 2.181v1.59c1.48.214 2.528 1.43 2.528 2.934 0 1.654-1.377 3-3.07 3-1.692 0-3.068-1.346-3.068-3 0-.17.017-.335.045-.497l-1.536-.488a2.258 2.258 0 01-1.962 1.12c-.237 0-.471-.037-.698-.11-1.183-.375-1.833-1.622-1.449-2.78a2.246 2.246 0 012.146-1.524c.237 0 .471.036.698.108a2.23 2.23 0 011.313 1.098c.204.391.282.823.232 1.249l1.535.488c.44-.86 1.378-1.453 2.384-1.599V7.892c-1.029-.205-1.896-1.116-1.896-2.181 0-1.217 1.012-2.206 2.257-2.206zm0 .882c-.747 0-1.354.594-1.354 1.324 0 .73.607 1.324 1.354 1.324.746 0 1.354-.594 1.354-1.324 0-.73-.608-1.324-1.354-1.324zm6.522 3.75c.98 0 1.843.613 2.146 1.525.186.56.138 1.158-.135 1.683-.274.526-.74.915-1.314 1.096-.227.073-.461.11-.698.11a2.258 2.258 0 01-1.962-1.12l-.634.201-.278-.838.632-.202a2.21 2.21 0 011.546-2.347c.226-.072.46-.108.697-.108zM5.476 9.02c-.588 0-1.105.368-1.287.915-.23.694.159 1.442.869 1.668.136.043.277.065.419.065.588 0 1.105-.368 1.287-.915a1.29 1.29 0 00-.081-1.01 1.338 1.338 0 00-.788-.658 1.377 1.377 0 00-.42-.065zm13.045 0c-.142 0-.282.021-.419.065a1.32 1.32 0 00-.869 1.668c.182.547.7.915 1.287.915.142 0 .283-.022.42-.065.344-.11.623-.343.787-.659.165-.315.193-.673.082-1.009a1.348 1.348 0 00-1.288-.915zM12 10.474c-1.095 0-1.986.871-1.986 1.942 0 1.07.89 1.941 1.986 1.941 1.094 0 1.985-.87 1.985-1.94 0-1.072-.89-1.943-1.985-1.943zm-2.706 4.831l.73.519-.39.526c.709.757.801 1.925.16 2.787-.423.57-1.106.91-1.827.91-.478 0-.937-.147-1.325-.422a2.177 2.177 0 01-.499-3.082 2.28 2.28 0 012.76-.71zm5.41 0l.392.528a2.285 2.285 0 012.76.71 2.178 2.178 0 01-.499 3.082 2.275 2.275 0 01-1.325.421 2.28 2.28 0 01-1.827-.91 2.172 2.172 0 01.16-2.785l-.39-.527zm-6.734 1.21c-.433 0-.843.205-1.097.547-.44.59-.304 1.42.3 1.849a1.37 1.37 0 001.891-.293c.44-.59.305-1.42-.3-1.85a1.364 1.364 0 00-.794-.252zm8.059 0c-.287 0-.561.088-.795.254a1.307 1.307 0 00-.299 1.849 1.371 1.371 0 001.891.293 1.307 1.307 0 00.3-1.85 1.37 1.37 0 00-1.097-.545Z"/>
</svg>
</g>
<g transform="matrix(1.0671711,0,0,1.0802403,-242.07004,-3.638968)">
<path d="m 235.55996,122.32139 65.99684,-65.245439 9.61127,-9.494429 9.61126,9.494429 48.71786,48.127399 -0.0713,0.24287 -0.96104,2.79606 -1.09061,2.7291 -1.22617,2.66469 -1.34976,2.59982 -1.22704,2.10395 -52.40328,-51.76861 c -22.86589,22.605906 -45.73092,45.21138 -68.59595,67.81602 -2.64028,-3.84168 -5.09265,-7.79865 -7.01216,-12.06586 z" style="clip-rule:evenodd;fill:#e64a19;fill-rule:evenodd;stroke-width:0.87332809" />
<path d="m 311.16893,3.3686968 c 46.45255,0 84.33469,37.4250352 84.33469,83.3154142 0,45.887409 -37.88214,83.314139 -84.33469,83.314139 -25.37318,0 -48.18501,-11.17665 -63.66633,-28.79057 l 2.98008,-2.95501 2.16319,-2.13785 2.16749,-2.14377 2.16835,-2.14209 0.0884,-0.0865 c 13.00665,15.21415 32.43854,24.9082 54.09884,24.9082 39.00964,0 70.82522,-31.42855 70.82522,-69.966579 0,-38.540154 -31.81558,-69.969554 -70.82522,-69.969554 -39.01137,0 -70.82694,31.4294 -70.82694,69.969554 0,5.189918 0.58434,10.24717 1.67968,15.121749 l -4.69923,4.65206 -6.27207,6.2012 c -2.73168,-8.18222 -4.217,-16.909931 -4.217,-25.97501 0,-45.89038 37.88214,-83.3154145 84.33556,-83.3154145 z" style="clip-rule:evenodd;fill:#474747;fill-rule:evenodd;stroke-width:0.87332809" />
</g>
</svg>
<h3>Authorize openHAB binding for Smartthings</h3>
<p>On this page you can authorize the openHAB Smartthings biding to access your Smartthings account.</p>
<p>
The redirect URI to use with Smartthings for this openHAB installation is
<a href="${redirectUri}">${redirectUri}</a>
</p>
${error} ${authorizedUser}
<div class="block${bridge.authorized}">
Connect to SmartThings:
<p>
<div class="button">
<a href=${bridge.uri}>Authorize Bridge
</a>
</div>
</p>
</div>
</body>
</html>